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 );
}
}
2026-10-21 20:25:15 -04:00
/*
* 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 );
});
}
2026-09-24 13:05:20 -04:00
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
2026-10-21 20:25:15 -04:00
// The Browser tab's pages (browser-tab.js). Every <webview> is locked down in will-attach-webview below.
webviewTag : true ,
2026-09-24 13:05:20 -04:00
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 ( _ ) {}
2026-10-21 20:25:15 -04:00
try { secureBrowserTab ( win ); } catch ( e ) { console . warn ( '[BROWSER TAB] setup failed:' , e && e . message ); }
2026-09-24 13:05:20 -04:00
// 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.
2026-10-21 20:37:14 -04:00
// 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 ( _ ) {}
2026-09-24 13:05:20 -04:00
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 '' ;
}
}