First commit on the local Gitea server, made after moving the tree off the Windows machine. Build outputs, node_modules and the AI models stay out (see .gitignore); everything the installer build needs is tracked. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
628 lines
25 KiB
JavaScript
628 lines
25 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);
|
|
}
|
|
}
|
|
|
|
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 '';
|
|
}
|
|
}
|
|
|