2026-09-24 13:05:20 -04:00
import http from 'http' ;
import https from 'https' ;
import fs from 'fs' ;
import path from 'path' ;
import os from 'os' ;
import { fileURLToPath , pathToFileURL } from 'url' ;
import { createRequire } from 'module' ;
import { createProPresenterLink , ProPresenterLink } from './propresenter-link.js' ;
import { attachFollowerLink } from './follower-link.js' ;
const require = createRequire ( import . meta . url );
const pdfParse = require ( 'pdf-parse/lib/pdf-parse.js' );
const llamaLauncher = require ( './llama-launcher.cjs' );
const upscaler = require ( './upscaler.cjs' );
// Log capture system for log viewer
const serverLogs = [];
const MAX_SERVER_LOGS = 2000 ; // Keep last 2000 logs
// Log forwarder function (set by main process)
let logForwarder = null ;
export function registerLogForwarder ( fn ) {
logForwarder = fn ;
}
// Intercept console methods to capture logs
const originalConsoleLog = console . log ;
const originalConsoleWarn = console . warn ;
const originalConsoleError = console . error ;
function addLog ( level , ... args ) {
const message = args . map ( arg => {
if ( typeof arg === 'object' ) {
try {
return JSON . stringify ( arg , null , 2 );
} catch {
return String ( arg );
}
}
return String ( arg );
}). join ( ' ' );
const logEntry = {
timestamp : new Date (). toISOString (),
level : level ,
message : message ,
category : message . match ( /\[([^\]]+)\]/ ) ? .[ 1 ] || ''
};
serverLogs . push ( logEntry );
if ( serverLogs . length > MAX_SERVER_LOGS ) {
serverLogs . shift ();
}
// Forward to main process if available
if ( logForwarder ) {
try {
logForwarder ( logEntry );
} catch ( e ) {
// Ignore forwarding errors
}
}
}
console . log = function (... args ) {
originalConsoleLog . apply ( console , args );
addLog ( 'log' , ... args );
};
console . warn = function (... args ) {
originalConsoleWarn . apply ( console , args );
addLog ( 'warn' , ... args );
};
console . error = function (... args ) {
originalConsoleError . apply ( console , args );
addLog ( 'error' , ... args );
};
// Get __dirname equivalent for ES modules
const __filename = fileURLToPath ( import . meta . url );
const __dirname = path . dirname ( __filename );
const APP_NAME = 'Bible App' ;
function ensureDirWritable ( dir , label ) {
try {
if ( ! dir ) return false ;
fs . mkdirSync ( dir , { recursive : true });
const testFile = path . join ( dir , '.write_test' );
fs . writeFileSync ( testFile , 'ok' );
fs . unlinkSync ( testFile );
return true ;
} catch ( e ) {
console . warn ( `[SERVER] Cannot use ${ label } directory ${ dir } :` , e . message );
return false ;
}
}
function resolveUserDataRoot () {
try {
const candidates = [];
const envDir = ( process . env . BIBLE_APP_DATA_DIR || process . env . BIBLE_APP_USER_DATA_DIR || '' ). trim ();
if ( envDir ) candidates . push ( envDir );
if ( process . platform === 'win32' ) {
const appData = process . env . APPDATA || path . join ( os . homedir (), 'AppData' , 'Roaming' );
candidates . push ( path . join ( appData , APP_NAME ));
} else if ( process . platform === 'darwin' ) {
candidates . push ( path . join ( os . homedir (), 'Library' , 'Application Support' , APP_NAME ));
} else {
candidates . push ( path . join ( os . homedir (), '.config' , APP_NAME ));
}
for ( const dir of candidates ) {
if ( ensureDirWritable ( dir , 'user-data' )) return dir ;
}
} catch ( e ) {
console . warn ( '[SERVER] Failed to resolve user-data root:' , e . message || e );
}
return '' ;
}
// Track deleted default images to prevent re-seeding (must be defined before migrateLegacyContent)
function getDeletedDefaultsPath () {
const userDataRoot = resolveUserDataRoot ();
if ( userDataRoot ) {
return path . join ( userDataRoot , 'deleted-defaults.json' );
}
// Fallback to __dirname if userDataRoot is not available
return path . join ( __dirname , 'deleted-defaults.json' );
}
function loadDeletedDefaults () {
try {
const deletedPath = getDeletedDefaultsPath ();
if ( fs . existsSync ( deletedPath )) {
const content = fs . readFileSync ( deletedPath , 'utf-8' );
const data = JSON . parse ( content );
return data . deleted || [];
}
} catch ( e ) {
console . warn ( `[SERVER] Failed to load deleted defaults:` , e . message || e );
}
return [];
}
function saveDeletedDefaults ( deletedList ) {
try {
const deletedPath = getDeletedDefaultsPath ();
const data = { deleted : deletedList };
fs . writeFileSync ( deletedPath , JSON . stringify ( data , null , 2 ), 'utf-8' );
} catch ( e ) {
console . warn ( `[SERVER] Failed to save deleted defaults:` , e . message || e );
}
}
function markDefaultAsDeleted ( type , filename ) {
const deleted = loadDeletedDefaults ();
const key = ` ${ type } : ${ filename } ` ;
if ( ! deleted . includes ( key )) {
deleted . push ( key );
saveDeletedDefaults ( deleted );
console . log ( `[SERVER] Marked default as deleted: ${ key } ` );
console . log ( `[SERVER] Deleted defaults list:` , deleted );
} else {
console . log ( `[SERVER] Default ${ key } was already marked as deleted` );
}
}
function isDefaultDeleted ( type , filename ) {
const deleted = loadDeletedDefaults ();
const key = ` ${ type } : ${ filename } ` ;
const isDeleted = deleted . includes ( key );
if ( isDeleted ) {
console . log ( `[SERVER] Default ${ type } : ${ filename } is marked as deleted, skipping seed` );
}
return isDeleted ;
}
// Default Graphics Setting - controls whether default graphics are available
function getDefaultGraphicsConfigPath () {
const userDataRoot = resolveUserDataRoot ();
if ( userDataRoot ) {
return path . join ( userDataRoot , 'default-graphics-config.json' );
}
return path . join ( __dirname , 'default-graphics-config.json' );
}
function isDefaultGraphicsEnabled () {
try {
const configPath = getDefaultGraphicsConfigPath ();
if ( fs . existsSync ( configPath )) {
const content = fs . readFileSync ( configPath , 'utf-8' );
const data = JSON . parse ( content );
return data . enabled !== false ; // Default to true if not set
}
} catch ( e ) {
console . warn ( `[SERVER] Failed to read default graphics config:` , e . message || e );
}
return true ; // Default to enabled
}
function setDefaultGraphicsEnabled ( enabled ) {
try {
const configPath = getDefaultGraphicsConfigPath ();
const data = { enabled : enabled === true };
fs . writeFileSync ( configPath , JSON . stringify ( data , null , 2 ), 'utf-8' );
console . log ( `[SERVER] Default graphics ${ enabled ? 'enabled' : 'disabled' } ` );
return true ;
} catch ( e ) {
console . error ( `[SERVER] Failed to save default graphics config:` , e . message || e );
return false ;
}
}
function isDefaultGraphicsFile ( label , filename ) {
const nameLower = filename . toLowerCase ();
if ( label === 'Notes' && ( nameLower === 'notes.png' || nameLower === 'splash.png' || nameLower === 'splash.jpg' )) {
return true ;
}
if ( label === 'Announcements' && nameLower === 'announcements.png' ) {
return true ;
}
return false ;
}
function migrateLegacyContent ( label , targetDir , sources ) {
if ( ! targetDir ) return ;
const defaultGraphicsEnabled = isDefaultGraphicsEnabled ();
try {
fs . mkdirSync ( targetDir , { recursive : true });
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 ;
// Check if default graphics are disabled
if ( ! defaultGraphicsEnabled && isDefaultGraphicsFile ( label , name )) {
console . log ( `[SERVER] Skipping migration of default graphics (disabled): ${ name } in ${ label } ` );
return ;
}
// Check if this is a default image that the user has deleted
const nameLower = name . toLowerCase ();
let isDeletedDefault = false ;
if ( label === 'Notes' && ( nameLower === 'notes.png' || nameLower === 'splash.png' || nameLower === 'splash.jpg' )) {
if ( nameLower === 'notes.png' ) {
isDeletedDefault = isDefaultDeleted ( 'notes' , 'Notes.png' );
} else {
isDeletedDefault = isDefaultDeleted ( 'splash' , 'splash.png' );
}
} else if ( label === 'Announcements' && nameLower === 'announcements.png' ) {
isDeletedDefault = isDefaultDeleted ( 'announcements' , 'Announcements.png' );
}
if ( isDeletedDefault ) {
console . log ( `[SERVER] Skipping migration of deleted default: ${ name } in ${ label } ` );
return ;
}
const stat = fs . statSync ( from );
if ( stat . isDirectory ()) {
fs . cpSync ( from , to , { recursive : true , force : false , errorOnExist : false });
} else if ( stat . isFile ()) {
fs . copyFileSync ( from , to , fs . constants . COPYFILE_EXCL );
}
} catch ( _ ) {
/* ignore copy errors per-file */
}
});
});
} catch ( e ) {
console . warn ( `[SERVER] Failed to migrate ${ label } :` , e . message || e );
}
}
function resolveContentDirectory ( label , envVar ) {
const userDataRoot = resolveUserDataRoot ();
const envDir = ( process . env [ envVar ] || '' ). trim ();
const preferred = userDataRoot ? path . join ( userDataRoot , label ) : '' ;
const execDir = path . dirname ( process . execPath || '' );
const candidates = [
envDir ,
preferred ,
execDir ? path . join ( execDir , label ) : '' ,
path . join ( process . cwd (), label ),
path . join ( __dirname , label )
]. filter ( Boolean );
let chosen = '' ;
for ( const dir of candidates ) {
if ( ensureDirWritable ( dir , label )) {
chosen = dir ;
break ;
}
}
const hasCustomEnv = envDir && preferred && path . resolve ( envDir ) !== path . resolve ( preferred );
if ( ! hasCustomEnv && preferred && ensureDirWritable ( preferred , label )) {
migrateLegacyContent ( label , preferred , [
envDir && path . resolve ( envDir ) !== path . resolve ( preferred ) ? envDir : '' ,
execDir ? path . join ( execDir , label ) : '' ,
path . join ( process . cwd (), label ),
path . join ( __dirname , label )
]);
chosen = preferred ;
}
return chosen || preferred || path . join ( __dirname , label );
}
// Fixed at 8080 in production (the window, the phone remote, Companion and the booth follower all
// expect it). BIBLE_APP_PORT exists ONLY so a developer can run a test copy beside the live app without
// stealing 8080 from it — a test server that takes 8080 first leaves the real app on 8081, where none of
// the devices pointed at :8080 can find it.
const PORT = parseInt ( process . env . BIBLE_APP_PORT , 10 ) || 8080 ;
// Renderer invoker hook (set by Electron main)
let __rendererInvoker = null ;
export function registerRendererInvoker ( fn ) {
__rendererInvoker = typeof fn === 'function' ? fn : null ;
}
function invokeRenderer ( action , payload = {}) {
try {
if ( typeof __rendererInvoker === 'function' ) {
__rendererInvoker ( action , payload );
console . log ( `[OSC] Successfully invoked renderer: ${ action } ` , payload && Object . keys ( payload ). length > 0 ? payload : '' );
return true ;
} else {
// Log warning if renderer invoker not ready (helps debug timing issues)
console . warn ( `[OSC] Renderer invoker not ready for action: ${ action } . Command will be lost. Make sure the app window is loaded.` );
}
} catch ( e ) {
console . error ( `[OSC] Error invoking renderer for action ${ action } :` , e && e . message || e );
}
return false ;
}
// --- Local update system (NO GitHub, no cloud) -------------------------------------------------
// Builds are published to a folder or URL YOU control (a network share, a USB drive, or a small
// web server). The app reads <source>/latest.json, compares versions, then copies/downloads the
// installer and launches it. Source is adjustable in Settings > Updates.
let __appControl = null ;
export function registerAppControl ( fns ) { __appControl = ( fns && typeof fns === 'object' ) ? fns : null ; }
function appVersion () {
try { if ( __appControl && typeof __appControl . getVersion === 'function' ) return __appControl . getVersion (); } catch ( _ ) {}
try { return require ( './package.json' ). version || '0.0.0' ; } catch ( _ ) { return '0.0.0' ; }
}
function readUpdateConfig () {
const cfg = { source : '' , autoCheck : true , lastCheck : 0 , lastResult : '' };
try {
const p = path . join ( notesDir , 'update-config.json' );
if ( fs . existsSync ( p )) {
const d = JSON . parse ( fs . readFileSync ( p , 'utf-8' ));
if ( d && typeof d === 'object' ) {
if ( typeof d . source === 'string' ) cfg . source = d . source . trim ();
if ( typeof d . autoCheck === 'boolean' ) cfg . autoCheck = d . autoCheck ;
if ( typeof d . lastCheck === 'number' ) cfg . lastCheck = d . lastCheck ;
if ( typeof d . lastResult === 'string' ) cfg . lastResult = d . lastResult ;
}
}
} catch ( _ ) {}
return cfg ;
}
function writeUpdateConfig ( updates ) {
const cur = readUpdateConfig ();
if ( updates && typeof updates === 'object' ) {
if ( typeof updates . source === 'string' ) cur . source = updates . source . trim ();
if ( typeof updates . autoCheck === 'boolean' ) cur . autoCheck = updates . autoCheck ;
if ( typeof updates . lastCheck === 'number' ) cur . lastCheck = updates . lastCheck ;
if ( typeof updates . lastResult === 'string' ) cur . lastResult = updates . lastResult ;
}
try { fs . mkdirSync ( notesDir , { recursive : true }); } catch ( _ ) {}
fs . writeFileSync ( path . join ( notesDir , 'update-config.json' ), JSON . stringify ( cur , null , 2 ) + '\n' );
return cur ;
}
// "1.4.0" vs "1.3.169" -> 1 when a is newer. Numeric, segment-by-segment (string compare would
// wrongly rank 1.3.9 above 1.3.169).
function compareVersions ( a , b ) {
const pa = String ( a || '0' ). split ( /[.\-+]/ ). map ( n => parseInt ( n , 10 ) || 0 );
const pb = String ( b || '0' ). split ( /[.\-+]/ ). map ( n => parseInt ( n , 10 ) || 0 );
for ( let i = 0 ; i < Math . max ( pa . length , pb . length ); i ++ ) {
const x = pa [ i ] || 0 , y = pb [ i ] || 0 ;
if ( x > y ) return 1 ;
if ( x < y ) return - 1 ;
}
return 0 ;
}
const isHttpSource = ( s ) => /^ https ?: \ / \ //i.test(String(s || ''));
function fetchTextFromUrl ( url , timeoutMs = 8000 ) {
return new Promise (( resolve , reject ) => {
const mod = /^https:/i . test ( url ) ? https : http ;
const req = mod . get ( url , ( res ) => {
if ( res . statusCode && res . statusCode >= 400 ) { res . resume (); return reject ( new Error ( 'HTTP ' + res . statusCode )); }
let body = '' ;
res . on ( 'data' , c => { body += c . toString ( 'utf-8' ); });
res . on ( 'end' , () => resolve ( body ));
});
req . on ( 'error' , reject );
req . setTimeout ( timeoutMs , () => { try { req . destroy (); } catch ( _ ) {} reject ( new Error ( 'Timed out' )); });
});
}
function downloadToFile ( url , destPath , onProgress ) {
return new Promise (( resolve , reject ) => {
const mod = /^https:/i . test ( url ) ? https : http ;
const req = mod . get ( url , ( res ) => {
if ( res . statusCode && res . statusCode >= 300 && res . statusCode < 400 && res . headers . location ) {
res . resume ();
return downloadToFile ( res . headers . location , destPath , onProgress ). then ( resolve , reject );
}
if ( res . statusCode && res . statusCode >= 400 ) { res . resume (); return reject ( new Error ( 'HTTP ' + res . statusCode )); }
const total = parseInt ( res . headers [ 'content-length' ] || '0' , 10 );
let done = 0 ;
const out = fs . createWriteStream ( destPath );
res . on ( 'data' , ( c ) => { done += c . length ; if ( onProgress ) onProgress ( done , total ); });
res . pipe ( out );
out . on ( 'finish' , () => out . close (() => resolve ( destPath )));
out . on ( 'error' , reject );
});
req . on ( 'error' , reject );
req . setTimeout ( 30000 , () => { try { req . destroy (); } catch ( _ ) {} reject ( new Error ( 'Download timed out' )); });
});
}
// Read <source>/latest.json from a folder/share or an http(s) URL.
// Tolerant parse: a UTF-8 BOM (what Notepad and PowerShell write) would otherwise break JSON.parse.
function parseManifestJson ( text ) {
const cleaned = String ( text || '' ). replace ( /^ / , '' ). trim ();
if ( ! cleaned ) throw new Error ( 'latest.json is empty' );
return JSON . parse ( cleaned );
}
async function readUpdateManifest ( source ) {
if ( ! source ) throw new Error ( 'No update source configured' );
if ( isHttpSource ( source )) {
const base = source . replace ( /\/+$/ , '' );
return parseManifestJson ( await fetchTextFromUrl ( base + '/latest.json' ));
}
const p = path . join ( source , 'latest.json' );
if ( ! fs . existsSync ( p )) throw new Error ( 'latest.json not found in ' + source );
return parseManifestJson ( fs . readFileSync ( p , 'utf-8' ));
}
// Verify a transferred file against the size/hash published in latest.json. A half-copied or
// cloud-placeholder file (the classic "This app can't run on your PC" 0-byte installer) is caught
// here instead of being launched.
function sha256File ( file ) {
return new Promise (( resolve , reject ) => {
try {
const crypto = require ( 'crypto' );
const h = crypto . createHash ( 'sha256' );
const s = fs . createReadStream ( file );
s . on ( 'data' , c => h . update ( c ));
s . on ( 'error' , reject );
s . on ( 'end' , () => resolve ( h . digest ( 'hex' )));
} catch ( e ) { reject ( e ); }
});
}
async function verifyTransfer ( file , expectedSize , expectedSha , label ) {
if ( ! fs . existsSync ( file )) throw new Error ( ` ${ label } did not transfer (file missing)` );
const actualSize = fs . statSync ( file ). size ;
if ( actualSize === 0 ) throw new Error ( ` ${ label } came across empty (0 bytes) — the copy did not finish. Check the folder or network and try again.` );
if ( expectedSize && actualSize !== expectedSize ) {
throw new Error ( ` ${ label } is incomplete ( ${ Math . round ( actualSize / 1048576 ) } MB of ${ Math . round ( expectedSize / 1048576 ) } MB). The copy was interrupted — try again.` );
}
if ( expectedSha ) {
const actual = await sha256File ( file );
if ( String ( actual ). toLowerCase () !== String ( expectedSha ). toLowerCase ()) {
throw new Error ( ` ${ label } was damaged in transfer (checksum mismatch). Try again.` );
}
}
return true ;
}
// Progress state for the (potentially large) installer transfer.
let __updateJob = { state : 'idle' , pct : 0 , message : '' , file : '' , version : '' };
// Progress state for the one-time AI model install (~1.9 GB, ships beside the installer).
let __modelJob = { state : 'idle' , pct : 0 , message : '' };
// Optional OSC support (secure local control via UDP)
let osc = null ;
try { osc = require ( 'osc' ); } catch ( _ ) { osc = null ; }
let oscPort = null ; // Track OSC port instance
let oscListening = false ; // Track if OSC is actually listening
let oscActivityLog = []; // Store recent OSC activity for debugging
const MAX_OSC_LOG_ENTRIES = 50 ; // Keep last 50 entries
function readOscConfig () {
// Defaults
const cfg = {
address : '127.0.0.1' ,
port : 3333 ,
allow : [],
allowPrefixes : [],
token : '' ,
feedbackEnabled : true , // Enable OSC feedback by default
feedbackAddress : null , // null = use live sender or last known OSC sender
feedbackPort : null , // null = use live sender port or last known OSC sender port
/*
* What the one-line status on /bibleapp/feedback/information carries, in this order.
* See buildOscInformationLine(). Pick the fields in Settings > OSC; an empty list turns the
* line off. 'tab' and 'splash' are the default pair: which tab he is on, and whether the
* congregation is looking at the splash rather than at it.
*/
feedbackInformation : [ 'tab' , 'passage' , 'splash' ],
// Per-tab 1/0 paths for Companion button colours (see OSC_TAB_KINDS).
feedbackTabStates : true
};
try {
// Allow env overrides
const envAddr = ( process . env . OSC_LISTEN_ADDRESS || process . env . OSC_ADDRESS || '' ). trim ();
const envPort = parseInt ( process . env . OSC_LISTEN_PORT || process . env . OSC_PORT || '' , 10 );
const envAllow = ( process . env . OSC_ALLOW || '' ). trim (); // comma-separated IPs
const envAllowPrefixes = ( process . env . OSC_ALLOW_PREFIXES || '' ). trim (); // comma-separated prefixes
const envToken = ( process . env . OSC_TOKEN || '' ). trim ();
if ( envAddr ) cfg . address = envAddr ;
if ( ! Number . isNaN ( envPort ) && envPort > 0 ) cfg . port = envPort ;
if ( envAllow ) cfg . allow = envAllow . split ( ',' ). map ( s => s . trim ()). filter ( Boolean );
if ( envAllowPrefixes ) cfg . allowPrefixes = envAllowPrefixes . split ( ',' ). map ( s => s . trim ()). filter ( Boolean );
if ( envToken ) cfg . token = envToken ;
// Allow Notes/osc-config.json file with { "address": "...", "port": 3333 }
const cfgPath = path . join ( notesDir , 'osc-config.json' );
if ( fs . existsSync ( cfgPath )) {
try {
const data = JSON . parse ( fs . readFileSync ( cfgPath , 'utf-8' ));
if ( data && typeof data === 'object' ) {
if ( typeof data . address === 'string' && data . address . trim ()) cfg . address = data . address . trim ();
if ( typeof data . port === 'number' && data . port > 0 ) cfg . port = data . port ;
if ( Array . isArray ( data . allow )) cfg . allow = data . allow . map ( s => String ( s ). trim ()). filter ( Boolean );
if ( Array . isArray ( data . allowPrefixes )) cfg . allowPrefixes = data . allowPrefixes . map ( s => String ( s ). trim ()). filter ( Boolean );
if ( typeof data . token === 'string' ) cfg . token = data . token . trim ();
if ( typeof data . feedbackEnabled === 'boolean' ) cfg . feedbackEnabled = data . feedbackEnabled ;
if ( typeof data . feedbackAddress === 'string' && data . feedbackAddress . trim ()) cfg . feedbackAddress = data . feedbackAddress . trim ();
if ( typeof data . feedbackPort === 'number' && data . feedbackPort > 0 ) cfg . feedbackPort = data . feedbackPort ;
if ( Array . isArray ( data . feedbackInformation )) cfg . feedbackInformation = sanitizeOscInformationFields ( data . feedbackInformation );
if ( typeof data . feedbackTabStates === 'boolean' ) cfg . feedbackTabStates = data . feedbackTabStates ;
}
} catch ( _ ) {}
} else {
// Config file doesn't exist - create default one if Notes directory is writable
try {
// Ensure Notes directory exists
fs . mkdirSync ( notesDir , { recursive : true });
// Create default config file
const defaultConfig = {
address : '127.0.0.1' , // Listen on loopback only by default (avoid unauthenticated LAN control); set to 0.0.0.0 + allow rules to expose
port : 3333 ,
allow : [],
allowPrefixes : [],
token : '' ,
feedbackEnabled : true ,
feedbackAddress : null ,
feedbackPort : null ,
feedbackInformation : [ 'tab' , 'passage' , 'splash' ],
feedbackTabStates : true
};
fs . writeFileSync ( cfgPath , JSON . stringify ( defaultConfig , null , 2 ) + '\n' );
console . log ( `[OSC] Created default config file at ${ cfgPath } ` );
// Use the default config
cfg . address = defaultConfig . address ;
cfg . port = defaultConfig . port ;
} catch ( e ) {
console . warn ( `[OSC] Could not create config file at ${ cfgPath } :` , e . message );
}
}
} catch ( _ ) {}
return cfg ;
}
// --- Phone remote config (isolated small file; the PIN must NOT travel inside osc-config.json or service profiles) ---
function readRemoteConfig () {
const cfg = { enabled : false , pin : '' , adminEnabled : false , adminPin : '' };
try {
const envEnabled = String ( process . env . REMOTE_ENABLED || '' ). toLowerCase ();
if ( envEnabled === '1' || envEnabled === 'true' ) cfg . enabled = true ;
const envPin = ( process . env . REMOTE_PIN || '' ). trim ();
if ( envPin ) cfg . pin = envPin ;
const envAdminEnabled = String ( process . env . ADMIN_ENABLED || '' ). toLowerCase ();
if ( envAdminEnabled === '1' || envAdminEnabled === 'true' ) cfg . adminEnabled = true ;
const envAdminPin = ( process . env . ADMIN_PIN || '' ). trim ();
if ( envAdminPin ) cfg . adminPin = envAdminPin ;
const cfgPath = path . join ( notesDir , 'remote-config.json' );
if ( fs . existsSync ( cfgPath )) {
const data = JSON . parse ( fs . readFileSync ( cfgPath , 'utf-8' ));
if ( data && typeof data === 'object' ) {
if ( typeof data . enabled === 'boolean' ) cfg . enabled = data . enabled ;
if ( typeof data . pin === 'string' ) cfg . pin = data . pin . trim ();
if ( typeof data . adminEnabled === 'boolean' ) cfg . adminEnabled = data . adminEnabled ;
if ( typeof data . adminPin === 'string' ) cfg . adminPin = data . adminPin . trim ();
}
}
} catch ( _ ) {}
return cfg ;
}
function writeRemoteConfig ( updates ) {
const cfgPath = path . join ( notesDir , 'remote-config.json' );
const cur = readRemoteConfig ();
if ( updates && typeof updates === 'object' ) {
if ( typeof updates . enabled === 'boolean' ) cur . enabled = updates . enabled ;
if ( typeof updates . pin === 'string' ) cur . pin = updates . pin . trim ();
if ( typeof updates . adminEnabled === 'boolean' ) cur . adminEnabled = updates . adminEnabled ;
if ( typeof updates . adminPin === 'string' ) cur . adminPin = updates . adminPin . trim ();
}
try { fs . mkdirSync ( notesDir , { recursive : true }); } catch ( _ ) {}
fs . writeFileSync ( cfgPath , JSON . stringify ({
enabled : cur . enabled , pin : cur . pin ,
adminEnabled : cur . adminEnabled , adminPin : cur . adminPin
}, null , 2 ) + '\n' );
return cur ;
}
/*
* ---- Brute-force guard for the PIN gates: lock an IP for 30s after 5 bad PINs ----
*
* Shared by the phone remote and the admin console, each with its OWN store, so failed guesses at
* one surface can never lock the other out mid-service.
*
* This used to be inline per gate as:
* const count = ((rec && rec.until <= now) ? 0 : (rec ? rec.count : 0)) + 1;
* which never locked anything. A not-currently-locked record carries `until: 0`, and `0 <= now` is
* always true, so the streak reset to 1 on every single attempt and could not reach the threshold —
* the guard had no effect at all and a PIN could be guessed at full speed. The streak now resets
* only when it should: after a lock has actually expired, or once the attempts go quiet.
*/
const PIN_FAIL_LIMIT = 5 ;
const PIN_LOCK_MS = 30000 ;
const PIN_STREAK_TTL_MS = 300000 ; // 5 minutes of quiet clears a partial streak
function pinLockRemaining ( store , ip , now ) {
const rec = store . get ( ip );
if ( rec && rec . until > now ) return rec . until - now ;
return 0 ;
}
function pinFailureRecord ( store , ip , now ) {
const rec = store . get ( ip );
const lockExpired = !! ( rec && rec . until && rec . until <= now );
const wentQuiet = !! ( rec && now - ( rec . lastAt || 0 ) > PIN_STREAK_TTL_MS );
const count = (( ! rec || lockExpired || wentQuiet ) ? 0 : rec . count ) + 1 ;
if ( count >= PIN_FAIL_LIMIT ) {
// Lock, and restart the streak so the next lock needs another full run of bad guesses.
store . set ( ip , { count : 0 , until : now + PIN_LOCK_MS , lastAt : now });
} else {
store . set ( ip , { count , until : 0 , lastAt : now });
}
}
const __remoteAuthFails = new Map (); // ip -> { count, until, lastAt }
function remotePinOk ( ip , pin ) {
try {
const cfg = readRemoteConfig ();
if ( ! cfg . enabled ) return { ok : false , reason : 'disabled' };
if ( ! cfg . pin ) return { ok : false , reason : 'no-pin' };
const now = Date . now ();
const retryMs = pinLockRemaining ( __remoteAuthFails , ip , now );
if ( retryMs ) return { ok : false , reason : 'locked' , retryMs };
if ( String ( pin || '' ). trim () && String ( pin ). trim () === cfg . pin ) {
__remoteAuthFails . delete ( ip );
return { ok : true };
}
pinFailureRecord ( __remoteAuthFails , ip , now );
return { ok : false , reason : 'bad-pin' };
} catch ( _ ) { return { ok : false , reason : 'error' }; }
}
/*
* Admin console auth — a SEPARATE credential from the phone remote's PIN.
*
* The remote PIN gets handed to whoever is running the service that week; the admin console can
* reach settings, content and the live stage state, so it must not be reachable with that PIN.
* Its own failure counter too, so guessing at admin cannot lock the operator's remote out
* mid-service (and vice versa).
*/
const __adminAuthFails = new Map (); // ip -> { count, until, lastAt }
function adminPinOk ( ip , pin ) {
try {
const cfg = readRemoteConfig ();
if ( ! cfg . adminEnabled ) return { ok : false , reason : 'disabled' };
if ( ! cfg . adminPin ) return { ok : false , reason : 'no-pin' };
const now = Date . now ();
const retryMs = pinLockRemaining ( __adminAuthFails , ip , now );
if ( retryMs ) return { ok : false , reason : 'locked' , retryMs };
if ( String ( pin || '' ). trim () && String ( pin ). trim () === cfg . adminPin ) {
__adminAuthFails . delete ( ip );
return { ok : true };
}
pinFailureRecord ( __adminAuthFails , ip , now );
return { ok : false , reason : 'bad-pin' };
} catch ( _ ) { return { ok : false , reason : 'error' }; }
}
function isLoopbackIp ( ip ) {
const s = String ( ip || '' );
return s === '127.0.0.1' || s === '::1' || s === '::ffff:127.0.0.1' || s === 'localhost' ;
}
// `page` defaults to the phone remote; the admin console passes 'admin.html' for the same list.
function lanRemoteUrls ( page ) {
const target = page || 'remote.html' ;
const urls = [];
try {
// Friendliest first: the computer's name via mDNS (phones on the same Wi-Fi resolve <name>.local)
const host = String ( os . hostname () || '' ). trim (). toLowerCase ();
if ( host ) urls . push ( `http:// ${ host } .local: ${ PORT } / ${ target } ` );
} catch ( _ ) {}
try {
const ifaces = os . networkInterfaces ();
Object . keys ( ifaces ). forEach ( name => {
( ifaces [ name ] || []). forEach ( ni => {
if ( ni && ni . family === 'IPv4' && ! ni . internal ) urls . push ( `http:// ${ ni . address } : ${ PORT } / ${ target } ` );
});
});
} catch ( _ ) {}
return urls ;
}
// Live UI snapshot pushed by the renderer (remote-state.js) so the phone remote can mirror the app.
let __remoteAppState = null ;
let __remoteAppStateTs = 0 ;
// OSC feedback activity log (for debugging in UI)
const oscFeedbackLog = [];
const MAX_OSC_FEEDBACK_LOG_ENTRIES = 50 ;
// Function to encode OSC message to buffer (raw OSC packet format)
function encodeOSCMessage ( address , args ) {
const dgram = require ( 'dgram' );
// OSC message format:
// - Address string (null-terminated, padded to 4-byte boundary)
// - Type tag string (starts with ',', null-terminated, padded to 4-byte boundary)
// - Arguments (each padded to 4-byte boundary)
let buffer = Buffer . alloc ( 0 );
// Encode address
const addrBuf = Buffer . from ( address + '\0' , 'utf8' );
const addrPadded = Buffer . alloc ( Math . ceil ( addrBuf . length / 4 ) * 4 );
addrBuf . copy ( addrPadded );
buffer = Buffer . concat ([ buffer , addrPadded ]);
// Build type tag string
let typeTag = ',' ;
args . forEach ( arg => {
if ( arg . type === 'i' ) typeTag += 'i' ; // integer
else if ( arg . type === 'f' ) typeTag += 'f' ; // float
else if ( arg . type === 's' ) typeTag += 's' ; // string
else if ( arg . type === 'b' ) typeTag += 'b' ; // blob
else typeTag += 'i' ; // default to integer
});
// Encode type tag
const tagBuf = Buffer . from ( typeTag + '\0' , 'utf8' );
const tagPadded = Buffer . alloc ( Math . ceil ( tagBuf . length / 4 ) * 4 );
tagBuf . copy ( tagPadded );
buffer = Buffer . concat ([ buffer , tagPadded ]);
// Encode arguments
args . forEach ( arg => {
if ( arg . type === 'i' ) {
// Integer: 32-bit big-endian
const intBuf = Buffer . alloc ( 4 );
intBuf . writeInt32BE ( arg . value , 0 );
buffer = Buffer . concat ([ buffer , intBuf ]);
} else if ( arg . type === 'f' ) {
// Float: 32-bit big-endian IEEE 754
const floatBuf = Buffer . alloc ( 4 );
floatBuf . writeFloatBE ( arg . value , 0 );
buffer = Buffer . concat ([ buffer , floatBuf ]);
} else if ( arg . type === 's' ) {
// String: null-terminated, padded to 4-byte boundary
const strBuf = Buffer . from ( String ( arg . value ) + '\0' , 'utf8' );
const strPadded = Buffer . alloc ( Math . ceil ( strBuf . length / 4 ) * 4 );
strBuf . copy ( strPadded );
buffer = Buffer . concat ([ buffer , strPadded ]);
} else if ( arg . type === 'b' ) {
// Blob: 32-bit size (big-endian) followed by data, padded to 4-byte boundary
const blobData = Buffer . isBuffer ( arg . value ) ? arg . value : Buffer . from ( arg . value );
const sizeBuf = Buffer . alloc ( 4 );
sizeBuf . writeInt32BE ( blobData . length , 0 );
const blobPadded = Buffer . alloc ( Math . ceil ( blobData . length / 4 ) * 4 );
blobData . copy ( blobPadded );
buffer = Buffer . concat ([ buffer , sizeBuf , blobPadded ]);
}
});
return buffer ;
}
// Function to send OSC feedback message using raw UDP
function sendOscFeedbackRaw ( targetAddress , targetPort , address , args ) {
const dgram = require ( 'dgram' );
return new Promise (( resolve , reject ) => {
let socket = null ;
let sendTimeout = null ;
try {
socket = dgram . createSocket ( 'udp4' );
const oscPacket = encodeOSCMessage ( address , args );
// Set socket to close after sending (with timeout)
const cleanup = () => {
if ( sendTimeout ) {
clearTimeout ( sendTimeout );
sendTimeout = null ;
}
if ( socket ) {
try {
socket . close ();
} catch ( e ) {
// Ignore close errors
}
socket = null ;
}
};
socket . on ( 'error' , ( err ) => {
console . error ( `[OSC Feedback Raw] Socket error:` , err . message );
console . error ( `[OSC Feedback Raw] Error details:` , err );
cleanup ();
reject ( err );
});
// Bind to a random port and wait for it to be ready
// Bind to a specific interface that can reach the target
// For remote addresses, bind to 0.0.0.0 (all interfaces)
// For localhost, bind to 127.0.0.1
const bindAddress = ( targetAddress === '127.0.0.1' || targetAddress === 'localhost' ) ? '127.0.0.1' : '0.0.0.0' ;
socket . bind ( 0 , bindAddress , () => {
try {
const localAddr = socket . address ();
const localPort = localAddr . port ;
console . log ( `[OSC Feedback Raw] Socket bound to ${ localAddr . address } : ${ localPort } ` );
console . log ( `[OSC Feedback Raw] Sending OSC packet to ${ targetAddress } : ${ targetPort } ` );
console . log ( `[OSC Feedback Raw] Packet size: ${ oscPacket . length } bytes` );
console . log ( `[OSC Feedback Raw] Packet hex (full):` , oscPacket . toString ( 'hex' ));
// Decode and verify packet format
try {
const addrEnd = oscPacket . indexOf ( 0 );
const addr = oscPacket . slice ( 0 , addrEnd ). toString ( 'utf8' );
const tagStart = Math . ceil (( addrEnd + 1 ) / 4 ) * 4 ;
const tagEnd = oscPacket . indexOf ( 0 , tagStart );
const tag = oscPacket . slice ( tagStart , tagEnd ). toString ( 'utf8' );
const valueStart = Math . ceil (( tagEnd + 1 ) / 4 ) * 4 ;
const value = oscPacket . readInt32BE ( valueStart );
console . log ( `[OSC Feedback Raw] Decoded packet:` );
console . log ( `[OSC Feedback Raw] Address: " ${ addr } "` );
console . log ( `[OSC Feedback Raw] Type tag: " ${ tag } "` );
console . log ( `[OSC Feedback Raw] Value: ${ value } ` );
} catch ( decodeErr ) {
console . warn ( `[OSC Feedback Raw] Could not decode packet for verification:` , decodeErr . message );
}
// Set timeout to ensure we don't hang forever
sendTimeout = setTimeout (() => {
console . error ( `[OSC Feedback Raw] Send timeout after 5 seconds` );
cleanup ();
reject ( new Error ( 'Send timeout' ));
}, 5000 );
// Send the packet
// Use send() with offset and length for better reliability
// CRITICAL: Send to the exact address and port Companion is listening on
socket . send ( oscPacket , 0 , oscPacket . length , targetPort , targetAddress , ( err , bytesSent ) => {
if ( sendTimeout ) {
clearTimeout ( sendTimeout );
sendTimeout = null ;
}
if ( err ) {
console . error ( `[OSC Feedback Raw] Send error:` , err . message );
console . error ( `[OSC Feedback Raw] Error code:` , err . code );
console . error ( `[OSC Feedback Raw] Error syscall:` , err . syscall );
console . error ( `[OSC Feedback Raw] Failed to send to ${ targetAddress } : ${ targetPort } ` );
cleanup ();
reject ( err );
} else {
console . log ( `[OSC Feedback Raw] ✓ OSC packet sent successfully to ${ targetAddress } : ${ targetPort } ` );
console . log ( `[OSC Feedback Raw] Bytes sent: ${ bytesSent } / ${ oscPacket . length } ` );
console . log ( `[OSC Feedback Raw] Packet details:` );
console . log ( `[OSC Feedback Raw] - Address: ${ address } ` );
console . log ( `[OSC Feedback Raw] - Args: ${ JSON . stringify ( args . map ( a => ` ${ a . type } : ${ a . value } ` )) } ` );
console . log ( `[OSC Feedback Raw] - Source: ${ socket . address (). address } : ${ socket . address (). port } ` );
console . log ( `[OSC Feedback Raw] - Destination: ${ targetAddress } : ${ targetPort } ` );
console . log ( `[OSC Feedback Raw] ⚠ If Companion doesn't receive this, check:` );
console . log ( `[OSC Feedback Raw] 1. Companion OSC Listener is ON and port matches ( ${ targetPort } )` );
console . log ( `[OSC Feedback Raw] 2. Firewall isn't blocking UDP port ${ targetPort } ` );
console . log ( `[OSC Feedback Raw] 3. Companion has OSC Listener action configured for " ${ address } "` );
console . log ( `[OSC Feedback Raw] 4. Try setting feedbackPort to 16759 in osc-config.json if port ${ targetPort } doesn't work` );
// Don't close immediately - give it a moment to ensure packet is sent
setTimeout (() => {
cleanup ();
resolve ();
}, 100 );
}
});
} catch ( bindErr ) {
console . error ( `[OSC Feedback Raw] Error after bind:` , bindErr . message );
cleanup ();
reject ( bindErr );
}
});
} catch ( e ) {
console . error ( `[OSC Feedback Raw] Error creating socket:` , e . message );
console . error ( `[OSC Feedback Raw] Error stack:` , e . stack );
if ( socket ) {
try {
socket . close ();
} catch ( _ ) {}
}
reject ( e );
}
});
}
const OSC_FEEDBACK_PULSE_MS = 1000 ;
const OSC_STATE_FEEDBACK_PULSE_MS = 850 ;
const oscStateUiCache = {
activeTool : null ,
penColorHex : null ,
splashVisible : null ,
ppsyncEnabled : null , // ProPresenter Sync: "Follow ProPresenter" switched on
ppsyncLive : null , // ProPresenter Sync: slides actually being received
// Which tab is up, as the app last reported it (/api/osc/state-feedback, kind 'active_tab').
tabIndex : null , // 1-based position among the tabs that are actually on the row
tabCount : null ,
tabKind : null , // 'Bible', 'Sync', 'Whiteboard', 'Notes', 'Graphics', ... (display label)
tabSlug : null , // the same thing as a path-safe slug: 'bible', 'sync', 'whiteboard', ...
tabPassage : null , // 'John 3' - scripture tabs only
tabTranslation : null // 'ESV'
};
/*
* ---------------------------------------------------------------------------------------------------
* Which tab is up, as BOOLEAN paths - the ones that colour a Companion button.
*
* The information line says which tab is active in words, which is the right thing for button TEXT, but
* a Companion colour comes from a boolean feedback: the button goes green on 1 and back to grey on 0.
* So the same fact goes out a second way, as 1/0 per tab, in two flavours:
*
* /bibleapp/feedback/state/tab_active/<n> 1 while the n-th tab on the row is the one showing
* /bibleapp/feedback/state/tab_kind/<slug> 1 while a tab of that kind is showing
*
* Use tab_active for a row of buttons that mirrors the tab bar, and tab_kind for a fixed button asking
* is-he-on-the-whiteboard / on-graphics / on-the-sync-tab - which stays right however the row is
* reordered. A tab_kind lights for ANY tab of that kind, so with three Bible tabs open tab_kind/bible
* is 1 on all three and tab_active/<n> says which one.
*
* EVERY slot and EVERY kind is sent on every refresh, not just the one that changed. That costs ~27 tiny
* UDP packets per tab change and buys the thing that matters on stage: a button can never be left lit
* for a tab he is no longer on. Tabs get reordered, hidden and closed, so clearing only the one lit last
* time is not enough - a row that changes shape underneath an incremental update strands a lit button,
* and a stale green light offstage is worse than no light at all.
*
* OSC_TAB_SLOTS is the ceiling for how many buttons can mirror the row. Slots past the end of the row
* are sent as 0, so spare buttons simply stay dark.
* ---------------------------------------------------------------------------------------------------
*/
const OSC_TAB_KINDS = [ 'bible' , 'sync' , 'whiteboard' , 'notes' , 'media' , 'alpha' ,
'announcements' , 'graphics' , 'maps' , 'prayer' , 'ask' ];
const OSC_TAB_SLOTS = 16 ;
/*
* ---------------------------------------------------------------------------------------------------
* /bibleapp/feedback/information - ONE path, one short line, everything worth knowing offstage.
*
* The per-thing state paths below are still sent and still work; this is the line for the button you
* actually look at during a service. It answers the two questions you cannot answer from the booth:
* which tab is he on, and is the congregation looking at the splash instead of at it.
*
* Terse on purpose - it is read at a glance on a Companion button, not parsed:
*
* 2/5 Bible | John 3 | LIVE
* 4/5 Sync | Romans 8 | SPLASH
*
* Pick the pieces in Settings > OSC > Information line (cfg.feedbackInformation); they appear in the
* order listed here, not the order they were ticked. Untick everything and the line stops being sent.
* Sent as a single OSC string argument, and only when it actually changes.
* ---------------------------------------------------------------------------------------------------
*/
const OSC_INFORMATION_FIELDS = [ 'tab' , 'passage' , 'translation' , 'tool' , 'color' , 'splash' , 'ppsync' ];
const OSC_INFORMATION_TOOL_LABEL = { pen : 'Pen' , highlight : 'Highlighter' , erase : 'Eraser' , select : 'Select' };
function sanitizeOscInformationFields ( list ) {
if ( ! Array . isArray ( list )) return [];
const picked = new Set (
list . map ( v => String ( v || '' ). trim (). toLowerCase ()). filter ( v => OSC_INFORMATION_FIELDS . includes ( v ))
);
return OSC_INFORMATION_FIELDS . filter ( f => picked . has ( f ));
}
function buildOscInformationLine ( fields ) {
const c = oscStateUiCache ;
const parts = [];
( fields || []). forEach (( field ) => {
switch ( field ) {
case 'tab' : {
const pos = ( c . tabIndex && c . tabCount ) ? ` ${ c . tabIndex } / ${ c . tabCount } ` : '' ;
const bit = [ pos , c . tabKind || '' ]. filter ( Boolean ). join ( ' ' );
if ( bit ) parts . push ( bit );
break ;
}
case 'passage' :
if ( c . tabPassage ) parts . push ( c . tabPassage );
break ;
case 'translation' :
if ( c . tabTranslation ) parts . push ( c . tabTranslation );
break ;
case 'tool' :
if ( c . activeTool ) parts . push ( OSC_INFORMATION_TOOL_LABEL [ c . activeTool ] || c . activeTool );
break ;
case 'color' :
if ( c . penColorHex ) parts . push ( '#' + c . penColorHex );
break ;
case 'splash' :
// The safety net: LIVE means the TV is showing the app, SPLASH means it is not.
if ( typeof c . splashVisible === 'boolean' ) parts . push ( c . splashVisible ? 'SPLASH' : 'LIVE' );
break ;
case 'ppsync' :
if ( c . ppsyncEnabled === false ) parts . push ( 'PP OFF' );
else if ( typeof c . ppsyncLive === 'boolean' ) parts . push ( c . ppsyncLive ? 'PP LIVE' : 'PP DOWN' );
break ;
}
});
return parts . join ( ' | ' );
}
let lastOscInformationLine = null ;
function sendOscInformation ( info = null , reason = 'update' , force = false ) {
try {
const target = resolveOscFeedbackTarget ( info );
if ( target . cfg . feedbackEnabled === false ) return Promise . resolve ();
const fields = sanitizeOscInformationFields ( target . cfg . feedbackInformation );
if ( ! fields . length ) return Promise . resolve (); // every field unticked = line switched off
const line = buildOscInformationLine ( fields );
if ( ! line ) return Promise . resolve ();
if ( ! force && line === lastOscInformationLine ) return Promise . resolve ();
const targetAddress = target . targetAddress ;
const targetPort = target . targetPort ;
if ( ! targetAddress || ! targetPort ) return Promise . resolve ();
lastOscInformationLine = line ;
rememberOscFeedbackTarget ({ address : targetAddress , port : targetPort }, 'information' );
const feedbackAddress = '/bibleapp/feedback/information' ;
const args = [{ type : 's' , value : line }];
return sendOscFeedbackRaw ( targetAddress , targetPort , feedbackAddress , args )
. then (() => {
oscFeedbackLog . push ({
time : new Date (). toISOString (),
command : 'information' ,
success : true ,
target : ` ${ targetAddress } : ${ targetPort } ` ,
path : feedbackAddress ,
args : [{ type : 's' , value : line }],
source : 'information' ,
reason
});
if ( oscFeedbackLog . length > MAX_OSC_FEEDBACK_LOG_ENTRIES ) oscFeedbackLog . shift ();
console . log ( `[OSC Feedback] information ( ${ reason } ): ${ line } ` );
})
. catch (( err ) => {
// A failed send must not stick: the next change has to be allowed to try again.
lastOscInformationLine = null ;
oscFeedbackLog . push ({
time : new Date (). toISOString (),
command : 'information' ,
success : false ,
error : `Raw UDP send failed: ${ err . message } ` ,
target : ` ${ targetAddress } : ${ targetPort } ` ,
path : feedbackAddress ,
source : 'information' ,
reason
});
if ( oscFeedbackLog . length > MAX_OSC_FEEDBACK_LOG_ENTRIES ) oscFeedbackLog . shift ();
});
} catch ( e ) {
console . error ( '[OSC Feedback] information failed:' , e . message );
return Promise . resolve ();
}
}
const oscFeedbackRuntime = {
lastSenderAddress : null ,
lastSenderPort : null ,
lastSenderSeenAt : 0 ,
lastSenderSource : null ,
snapshotTimer : null
};
function normalizeOscRemoteAddress ( address ) {
if ( ! address || typeof address !== 'string' ) return null ;
const trimmed = address . trim ();
if ( ! trimmed ) return null ;
if ( trimmed . startsWith ( '::ffff:' )) {
return trimmed . substring ( 7 );
}
return trimmed ;
}
function normalizeOscRemotePort ( portValue ) {
const parsed = Number ( portValue );
return Number . isInteger ( parsed ) && parsed > 0 && parsed < 65536 ? parsed : null ;
}
function extractOscRemoteTarget ( info ) {
const remoteAddress = normalizeOscRemoteAddress (
info ? . address || info ? . remoteAddress || info ? . rinfo ? . address || info ? . socket ? . remoteAddress || null
);
const remotePort = normalizeOscRemotePort (
info ? . port || info ? . remotePort || info ? . rinfo ? . port || info ? . socket ? . remotePort || null
);
return { remoteAddress , remotePort };
}
function rememberOscFeedbackTarget ( info , source = 'osc' ) {
const { remoteAddress , remotePort } = extractOscRemoteTarget ( info );
if ( ! remoteAddress || ! remotePort ) return false ;
oscFeedbackRuntime . lastSenderAddress = remoteAddress ;
oscFeedbackRuntime . lastSenderPort = remotePort ;
oscFeedbackRuntime . lastSenderSeenAt = Date . now ();
oscFeedbackRuntime . lastSenderSource = source ;
return true ;
}
function sendOscStateSnapshot ( info = null , reason = 'snapshot' ) {
// The one-line status rides along with every snapshot, so it is refreshed by the same events.
sendOscInformation ( info , reason );
const commands = [];
if ( oscStateUiCache . activeTool ) {
[ 'pen' , 'highlight' , 'erase' , 'select' ]. forEach (( name ) => {
commands . push ({ command : `state/tool_ ${ name } ` , value : name === oscStateUiCache . activeTool ? 1 : 0 });
});
}
if ( oscStateUiCache . penColorHex ) {
commands . push ({ command : `state/pen_color/ ${ oscStateUiCache . penColorHex } ` , value : 1 });
}
if ( typeof oscStateUiCache . splashVisible === 'boolean' ) {
commands . push ({ command : 'state/splash_visible' , value : oscStateUiCache . splashVisible ? 1 : 0 });
}
if ( typeof oscStateUiCache . ppsyncEnabled === 'boolean' ) {
commands . push ({ command : 'state/ppsync_enabled' , value : oscStateUiCache . ppsyncEnabled ? 1 : 0 });
}
if ( typeof oscStateUiCache . ppsyncLive === 'boolean' ) {
commands . push ({ command : 'state/ppsync_live' , value : oscStateUiCache . ppsyncLive ? 1 : 0 });
}
// Per-tab booleans for Companion button colours (Settings > OSC). Whole set every time - see the
// note by OSC_TAB_KINDS for why an incremental update cannot be trusted here.
if (( oscStateUiCache . tabSlug || oscStateUiCache . tabIndex ) && readOscConfig (). feedbackTabStates !== false ) {
OSC_TAB_KINDS . forEach (( kind ) => {
commands . push ({ command : `state/tab_kind/ ${ kind } ` , value : kind === oscStateUiCache . tabSlug ? 1 : 0 });
});
for ( let n = 1 ; n <= OSC_TAB_SLOTS ; n ++ ) {
commands . push ({ command : `state/tab_active/ ${ n } ` , value : n === oscStateUiCache . tabIndex ? 1 : 0 });
}
}
if ( ! commands . length ) {
return Promise . resolve ({ skipped : true , reason : 'no_cached_state' });
}
console . log ( `[OSC Feedback] Sending state snapshot ( ${ reason } ) with ${ commands . length } message(s)` );
return Promise . allSettled ( commands . map ( entry => sendOscStateFeedback ( entry . command , entry . value , info )));
}
function scheduleOscStateSnapshot ( info = null , reason = 'snapshot' ) {
if ( oscFeedbackRuntime . snapshotTimer ) {
clearTimeout ( oscFeedbackRuntime . snapshotTimer );
oscFeedbackRuntime . snapshotTimer = null ;
}
const snapshotInfo = info ? { ... info } : null ;
oscFeedbackRuntime . snapshotTimer = setTimeout (() => {
oscFeedbackRuntime . snapshotTimer = null ;
sendOscStateSnapshot ( snapshotInfo , reason ). catch (( err ) => {
console . warn ( '[OSC Feedback] State snapshot failed:' , err && err . message || err );
});
}, 180 );
}
function resolveOscFeedbackTarget ( info ) {
const cfg = readOscConfig ();
const explicitAddress = ( cfg . feedbackAddress && String ( cfg . feedbackAddress ). trim ())
? String ( cfg . feedbackAddress ). trim ()
: null ;
const explicitPort = ( typeof cfg . feedbackPort === 'number' && cfg . feedbackPort > 0 )
? cfg . feedbackPort
: null ;
const { remoteAddress , remotePort } = extractOscRemoteTarget ( info );
const cachedAddress = oscFeedbackRuntime . lastSenderAddress ;
const cachedPort = oscFeedbackRuntime . lastSenderPort ;
const allowLoopbackFallback =
( cfg . address === '127.0.0.1' || cfg . address === '::1' || cfg . address === 'localhost' ) &&
( ! cfg . allow || cfg . allow . length === 0 ) &&
( ! cfg . allowPrefixes || cfg . allowPrefixes . length === 0 );
const targetAddress = explicitAddress || remoteAddress || cachedAddress || ( allowLoopbackFallback ? '127.0.0.1' : null );
const targetPort = explicitPort || remotePort || cachedPort || ( allowLoopbackFallback ? 16759 : null );
const resolutionSource = explicitAddress || explicitPort
? 'configured'
: ( remoteAddress && remotePort )
? 'message_sender'
: ( cachedAddress && cachedPort )
? 'last_sender'
: ( allowLoopbackFallback ? 'loopback_fallback' : 'unresolved' );
return {
cfg ,
targetAddress ,
targetPort ,
resolutionSource ,
remoteAddress ,
remotePort ,
cachedAddress ,
cachedPort
};
}
// Function to send OSC feedback message.
// Keep this path simple and deterministic: one target address, one target port.
function sendOscFeedback ( _originalMessage , info , command , success , errorMessage = null ) {
const appendFeedbackLog = ( entry ) => {
oscFeedbackLog . push ( entry );
if ( oscFeedbackLog . length > MAX_OSC_FEEDBACK_LOG_ENTRIES ) {
oscFeedbackLog . shift ();
}
};
try {
const target = resolveOscFeedbackTarget ( info );
const cfg = target . cfg ;
if ( cfg . feedbackEnabled === false ) {
appendFeedbackLog ({
time : new Date (). toISOString (),
command ,
success : false ,
error : 'Feedback is disabled in configuration' ,
target : 'N/A'
});
return ;
}
const targetAddress = target . targetAddress ;
const targetPort = target . targetPort ;
if ( ! targetAddress || ! targetPort ) {
appendFeedbackLog ({
time : new Date (). toISOString (),
command ,
success : false ,
error : 'Cannot determine feedback target address/port' ,
target : ` ${ targetAddress || 'unknown' } : ${ targetPort || 'unknown' } `
});
console . warn ( `[OSC Feedback] No target available for command " ${ command } " (resolution= ${ target . resolutionSource } )` );
return ;
}
rememberOscFeedbackTarget ({ address : targetAddress , port : targetPort }, `feedback: ${ command } ` );
const feedbackAddress = `/bibleapp/feedback/ ${ command } ` ;
const sendFeedbackValue = ( value , includeErrorMessage = false ) => {
const args = [{ type : 'i' , value }];
if ( includeErrorMessage && errorMessage ) {
args . push ({ type : 's' , value : String ( errorMessage ) });
}
return sendOscFeedbackRaw ( targetAddress , targetPort , feedbackAddress , args )
. then (() => {
appendFeedbackLog ({
time : new Date (). toISOString (),
command ,
success : value === 1 ,
target : ` ${ targetAddress } : ${ targetPort } ` ,
path : feedbackAddress ,
status : value ,
args : args . map ( a => ({ type : a . type , value : a . value }))
});
})
. catch (( rawErr ) => {
appendFeedbackLog ({
time : new Date (). toISOString (),
command ,
success : false ,
error : `Raw UDP send failed: ${ rawErr . message } ` ,
target : ` ${ targetAddress } : ${ targetPort } ` ,
path : feedbackAddress ,
status : value
});
console . error ( `[OSC Feedback] Raw UDP send failed for ${ targetAddress } : ${ targetPort } :` , rawErr . message );
});
};
// Pulse behavior for Companion button reset:
// - success: send 1 immediately, then send 0 after 1 second
// - failure: send 0 immediately (with optional error message)
if ( success ) {
sendFeedbackValue ( 1 ). then (() => {
scheduleOscStateSnapshot ( info , `command: ${ command } ` );
setTimeout (() => {
sendFeedbackValue ( 0 );
}, OSC_FEEDBACK_PULSE_MS );
});
} else {
sendFeedbackValue ( 0 , true );
}
} catch ( e ) {
console . error ( '[OSC Feedback] Error sending feedback:' , e . message );
}
}
function sendOscStateFeedback ( command , value , info = null ) {
const appendFeedbackLog = ( entry ) => {
oscFeedbackLog . push ( entry );
if ( oscFeedbackLog . length > MAX_OSC_FEEDBACK_LOG_ENTRIES ) {
oscFeedbackLog . shift ();
}
};
try {
const target = resolveOscFeedbackTarget ( info );
const cfg = target . cfg ;
if ( cfg . feedbackEnabled === false ) return ;
const targetAddress = target . targetAddress ;
const targetPort = target . targetPort ;
if ( ! targetAddress || ! targetPort ) {
appendFeedbackLog ({
time : new Date (). toISOString (),
command ,
success : false ,
error : `Cannot determine state feedback target (resolution= ${ target . resolutionSource } )` ,
target : ` ${ targetAddress || 'unknown' } : ${ targetPort || 'unknown' } ` ,
path : `/bibleapp/feedback/ ${ command } ` ,
status : Number ( value ) === 0 ? 0 : 1 ,
source : 'state'
});
console . warn ( `[OSC Feedback] No target available for state " ${ command } " (resolution= ${ target . resolutionSource } )` );
return Promise . resolve ();
}
rememberOscFeedbackTarget ({ address : targetAddress , port : targetPort }, `state: ${ command } ` );
const feedbackAddress = `/bibleapp/feedback/ ${ command } ` ;
const numericValue = Number ( value ) === 0 ? 0 : 1 ;
const args = [{ type : 'i' , value : numericValue }];
return sendOscFeedbackRaw ( targetAddress , targetPort , feedbackAddress , args )
. then (() => {
appendFeedbackLog ({
time : new Date (). toISOString (),
command ,
success : true ,
target : ` ${ targetAddress } : ${ targetPort } ` ,
path : feedbackAddress ,
status : numericValue ,
source : 'state'
});
})
. catch (( rawErr ) => {
appendFeedbackLog ({
time : new Date (). toISOString (),
command ,
success : false ,
error : `Raw UDP send failed: ${ rawErr . message } ` ,
target : ` ${ targetAddress } : ${ targetPort } ` ,
path : feedbackAddress ,
status : numericValue ,
source : 'state'
});
});
} catch ( _ ) {
return Promise . resolve ();
}
}
// OSC client for sending messages to Accordance
let accordanceOscClient = null ;
// Generate test data for words (fallback when Accordance not available)
function generateTestData ( word , reference ) {
const wordLower = word . toLowerCase ();
// Common Bible words with test data
const wordData = {
'love' : {
strongs : 'G25' ,
lexicalForm : 'ἀγαπάω' ,
morphology : 'V-PAI-1S' ,
definition : 'to love, to be fond of, to like' ,
usage : 'Used 143 times in NT' ,
crossReferences : [ 'John 3:16' , '1 John 4:8' , 'Romans 5:8' ],
commentaries : [ 'Matthew Henry' , 'John Gill' ],
originalLanguage : 'Greek' ,
greek : { text : 'ἀγαπάω' , transliteration : 'agapaō' , meaning : 'to love, to be fond of' }
},
'faith' : {
strongs : 'G4102' ,
lexicalForm : 'πίστις' ,
morphology : 'N-NSF' ,
definition : 'faith, trust, belief' ,
usage : 'Used 244 times in NT' ,
crossReferences : [ 'Hebrews 11:1' , 'Romans 1:17' , 'Ephesians 2:8' ],
commentaries : [],
originalLanguage : 'Greek' ,
greek : { text : 'πίστις' , transliteration : 'pistis' , meaning : 'faith, trust, belief' }
},
'god' : {
strongs : 'G2316' ,
lexicalForm : 'θεός' ,
morphology : 'N-NSM' ,
definition : 'God, a god' ,
usage : 'Used 1317 times in NT' ,
crossReferences : [ 'John 1:1' , 'Romans 1:20' , '1 John 4:8' ],
commentaries : [],
originalLanguage : 'Greek' ,
greek : { text : 'θεός' , transliteration : 'theos' , meaning : 'God, a god' }
},
'christ' : {
strongs : 'G5547' ,
lexicalForm : 'Χριστός' ,
morphology : 'N-NSM' ,
definition : 'Christ, Anointed One, Messiah' ,
usage : 'Used 529 times in NT' ,
crossReferences : [ 'Matthew 16:16' , 'John 1:41' , 'Acts 2:36' ],
commentaries : [],
originalLanguage : 'Greek' ,
greek : { text : 'Χριστός' , transliteration : 'Christos' , meaning : 'Christ, Anointed One, Messiah' }
},
'lord' : {
strongs : 'G2962' ,
lexicalForm : 'κύριος' ,
morphology : 'N-NSM' ,
definition : 'lord, master, sir' ,
usage : 'Used 717 times in NT' ,
crossReferences : [ 'Philippians 2:11' , 'Romans 10:9' , '1 Corinthians 8:6' ],
commentaries : [],
originalLanguage : 'Greek' ,
greek : { text : 'κύριος' , transliteration : 'kyrios' , meaning : 'lord, master, sir' }
}
};
const data = wordData [ wordLower ];
if ( data ) {
return {
success : true ,
word : word ,
reference : reference ,
... data ,
note : 'Accordance API not yet available - using test data. The Accordance OSC API is still in development. See ACCORDANCE_STATUS.md for details.'
};
} else {
// Generic response for words without specific test data
return {
success : true ,
word : word ,
reference : reference ,
strongs : null ,
lexicalForm : null ,
morphology : null ,
definition : `[Test mode] Word: " ${ word } ". Accordance API not yet available - still in development. Using test data.` ,
usage : null ,
crossReferences : [],
commentaries : [],
originalLanguage : null ,
note : `Accordance API not yet available. The Accordance OSC API is still in development (see forum topic 37607). Using test data for now. Once Accordance releases the API, this integration will work automatically.`
};
}
}
function createAccordanceOSCClient () {
if ( ! osc ) return null ;
try {
const cfg = readOscConfig ();
// Accordance typically listens on a different port (commonly 3334 or configurable)
// For now, we'll use the same config but you may need to adjust based on Accordance's OSC settings
const client = new osc . UDPPort ({
localAddress : '127.0.0.1' ,
localPort : 0 , // Let OS assign a port
remoteAddress : cfg . address || '127.0.0.1' ,
remotePort : cfg . accordancePort || 53000 // Default Accordance OSC port (typically 53000)
});
client . open ();
return client ;
} catch ( e ) {
console . error ( '[Accordance OSC] Failed to create client:' , e . message );
return null ;
}
}
// Query Greek WordNet API for word information (replacing Accordance OSC)
async function queryAccordanceViaOSC ( word , reference = '' ) {
return new Promise (( resolve ) => {
try {
// Use Greek WordNet API instead of Accordance OSC (since Accordance API isn't available yet)
// Greek WordNet API: https://greekwordnet.chs.harvard.edu/api
console . log ( '[Greek WordNet] Querying for word:' , word );
// Helper function to make HTTPS requests
const makeRequest = ( url ) => {
return new Promise (( resolveReq , rejectReq ) => {
const urlObj = new URL ( url );
const options = {
hostname : urlObj . hostname ,
port : 443 ,
path : urlObj . pathname + urlObj . search ,
method : 'GET' ,
headers : {
'Accept' : 'application/json' ,
'User-Agent' : 'Bible-App/1.0'
}
};
const req = https . request ( options , ( res ) => {
let data = '' ;
res . on ( 'data' , ( chunk ) => { data += chunk ; });
res . on ( 'end' , () => {
try {
const json = JSON . parse ( data );
resolveReq ({ ok : res . statusCode === 200 , status : res . statusCode , json });
} catch ( e ) {
rejectReq ( new Error ( 'Invalid JSON response' ));
}
});
});
req . on ( 'error' , rejectReq );
req . setTimeout ( 5000 , () => { req . destroy (); rejectReq ( new Error ( 'Request timeout' )); });
req . end ();
});
};
// Try to translate English word to Greek first
const translateUrl = `https://greekwordnet.chs.harvard.edu/api/translate/en/ ${ encodeURIComponent ( word ) } ` ;
makeRequest ( translateUrl )
. then ( async ( translateResponse ) => {
if ( ! translateResponse . ok ) {
throw new Error ( `Translation API returned ${ translateResponse . status } ` );
}
const translateData = translateResponse . json ;
console . log ( '[Greek WordNet] Translation response:' , translateData );
if ( translateData && Array . isArray ( translateData ) && translateData . length > 0 ) {
// Get the first result
const firstResult = translateData [ 0 ];
const greekLemma = firstResult . lemma || firstResult . headword || '' ;
if ( greekLemma ) {
// Get synsets for this lemma
const synsetsUrl = `https://greekwordnet.chs.harvard.edu/api/lemmas/ ${ encodeURIComponent ( greekLemma ) } /synsets` ;
try {
const synsetsResponse = await makeRequest ( synsetsUrl );
let synsets = [];
if ( synsetsResponse . ok ) {
synsets = synsetsResponse . json ;
}
// Parse the data into our format
const result = {
success : true ,
word : word ,
reference : reference ,
strongs : null , // WordNet doesn't provide Strong's numbers
lexicalForm : greekLemma ,
morphology : firstResult . morpho || null ,
definition : synsets . length > 0 ? ( synsets [ 0 ]. gloss || synsets [ 0 ]. label || '' ) : '' ,
usage : synsets . length > 1 ? `Found ${ synsets . length } meanings` : null ,
crossReferences : [],
commentaries : [],
originalLanguage : 'Greek' ,
greek : {
text : greekLemma ,
transliteration : '' , // WordNet doesn't provide transliteration
meaning : synsets . length > 0 ? ( synsets [ 0 ]. gloss || synsets [ 0 ]. label || '' ) : ''
},
hebrew : null ,
note : 'Data from Greek WordNet API (Harvard) - https://greekwordnet.chs.harvard.edu/api'
};
// Add multiple definitions if available
if ( synsets . length > 1 ) {
result . definition = synsets . map ( s => s . gloss || s . label ). filter ( Boolean ). join ( '; ' );
}
resolve ( result );
return ;
} catch ( e ) {
console . warn ( '[Greek WordNet] Error fetching synsets:' , e );
}
}
}
// Fallback: return basic translation data
const basicResult = {
success : true ,
word : word ,
reference : reference ,
lexicalForm : translateData [ 0 ] ? . lemma || translateData [ 0 ] ? . headword || '' ,
definition : translateData [ 0 ] ? . gloss || translateData [ 0 ] ? . label || '' ,
originalLanguage : 'Greek' ,
greek : {
text : translateData [ 0 ] ? . lemma || translateData [ 0 ] ? . headword || '' ,
meaning : translateData [ 0 ] ? . gloss || translateData [ 0 ] ? . label || ''
},
note : 'Data from Greek WordNet API (Harvard) - https://greekwordnet.chs.harvard.edu/api'
};
resolve ( basicResult );
})
. catch (( error ) => {
console . warn ( '[Greek WordNet] Error querying API:' , error . message );
// Fall back to test data
const testData = generateTestData ( word , reference );
resolve ( testData );
});
// NOTE: The promise is resolved asynchronously inside the .then/.catch above.
// Do NOT resolve synchronously here — doing so settled the promise with test data
// before the WordNet request completed, discarding all real API results.
} catch ( e ) {
resolve ({
success : false ,
error : 'Exception in queryAccordanceViaOSC: ' + e . message ,
word : word ,
reference : reference
});
}
});
}
function startOscListener () {
console . log ( '[OSC] Attempting to start OSC listener...' );
if ( ! osc ) {
console . error ( '[OSC] ❌ Module not installed. Run: npm i osc' );
return ;
}
console . log ( '[OSC] ✓ OSC module loaded' );
const cfg = readOscConfig ();
console . log ( '[OSC] Config loaded:' , {
address : cfg . address ,
port : cfg . port ,
hasToken : !! cfg . token ,
allowCount : cfg . allow ? . length || 0 ,
prefixCount : cfg . allowPrefixes ? . length || 0
});
const ipAllowed = ( remoteAddr ) => {
try {
if ( ! remoteAddr ) {
console . log ( `[OSC] IP check: remoteAddr is falsy` );
return false ;
}
console . log ( `[OSC] IP check: remoteAddr=" ${ remoteAddr } "` );
console . log ( `[OSC] IP check: cfg.allow=` , cfg . allow );
console . log ( `[OSC] IP check: cfg.allowPrefixes=` , cfg . allowPrefixes );
// Normalize IPv6-mapped IPv4 addresses (::ffff:10.6.3.222 -> 10.6.3.222)
let normalizedAddr = remoteAddr ;
if ( remoteAddr . startsWith ( '::ffff:' )) {
normalizedAddr = remoteAddr . substring ( 7 ); // Remove '::ffff:' prefix
console . log ( `[OSC] IP check: Normalized IPv6-mapped IPv4: " ${ normalizedAddr } "` );
}
// Always allow loopback
if ( remoteAddr === '127.0.0.1' || remoteAddr === '::1' || normalizedAddr === '127.0.0.1' ) {
console . log ( `[OSC] IP check: ✓ Allowed (loopback)` );
return true ;
}
// Check direct match (try both original and normalized)
if ( cfg . allow && cfg . allow . length > 0 ) {
const directMatch = cfg . allow . includes ( remoteAddr ) || cfg . allow . includes ( normalizedAddr );
console . log ( `[OSC] IP check: Direct match in allow list: ${ directMatch } (checked " ${ remoteAddr } " and " ${ normalizedAddr } ")` );
if ( directMatch ) {
console . log ( `[OSC] IP check: ✓ Allowed (direct match)` );
return true ;
}
}
// Check prefix match (try both original and normalized)
if ( cfg . allowPrefixes && cfg . allowPrefixes . length > 0 ) {
const prefixMatch = cfg . allowPrefixes . some ( prefix =>
remoteAddr . startsWith ( prefix ) || normalizedAddr . startsWith ( prefix )
);
console . log ( `[OSC] IP check: Prefix match: ${ prefixMatch } (checked " ${ remoteAddr } " and " ${ normalizedAddr } ")` );
if ( prefixMatch ) {
console . log ( `[OSC] IP check: ✓ Allowed (prefix match)` );
return true ;
}
}
// If no allow rules configured and bound to loopback by default, allow
if (( cfg . address === '127.0.0.1' || cfg . address === '::1' || cfg . address === 'localhost' ) &&
( ! cfg . allow || cfg . allow . length === 0 ) &&
( ! cfg . allowPrefixes || cfg . allowPrefixes . length === 0 )) {
console . log ( `[OSC] IP check: ✓ Allowed (loopback default)` );
return true ;
}
// Otherwise block by default
console . log ( `[OSC] IP check: ❌ BLOCKED (no match found)` );
return false ;
} catch ( e ) {
console . error ( `[OSC] IP check error:` , e );
return false ;
}
};
let port = null ;
try {
console . log ( `[OSC] Creating UDP port on ${ cfg . address } : ${ cfg . port } ...` );
port = new osc . UDPPort ({
localAddress : cfg . address ,
localPort : cfg . port
});
console . log ( '[OSC] ✓ UDP port object created' );
} catch ( e ) {
console . error ( '[OSC] ❌ Failed to create UDP port:' , e && e . message || e );
console . error ( '[OSC] Error stack:' , e . stack );
return ;
}
port . on ( 'ready' , () => {
oscListening = true ;
oscPort = port ;
console . log ( `[OSC] ✓ Listening on ${ cfg . address } : ${ cfg . port } ` );
console . log ( '[OSC] Available addresses: /bibleapp/undo, /redo, /reset, /clear, /clear_all, /close, /tab/left, /tab/right, /tool (pen|highlight|erase|select), /pen/color (with color arg), /new, /new/tab, /new/bible, /new/whiteboard, /new/notes, /splash/show' );
if ( cfg . allow ? . length || cfg . allowPrefixes ? . length ) {
console . log ( '[OSC] Allowed remotes:' , JSON . stringify ({ allow : cfg . allow , allowPrefixes : cfg . allowPrefixes }));
} else {
console . log ( '[OSC] IP filtering: All IPs allowed (no restrictions)' );
}
if ( cfg . token ) {
console . log ( '[OSC] ⚠ Token required: YES - All commands must include token:' , cfg . token );
} else {
console . log ( '[OSC] Token required: NO' );
}
console . log ( '[OSC] Renderer invoker ready:' , typeof __rendererInvoker === 'function' ? 'YES' : 'NO' );
});
port . on ( 'error' , ( e ) => {
oscListening = false ;
console . error ( '[OSC] ❌ Port error:' , e && e . message || e );
console . error ( '[OSC] Error details:' , e );
});
port . on ( 'message' , ( msg , _timeTag , info ) => {
try {
// Re-read config on each message to ensure we have the latest settings
const currentCfg = readOscConfig ();
// Extract remote address and port - try multiple possible properties
let remoteAddr = null ;
let remotePort = null ;
if ( info ) {
remoteAddr = info . address || info . remoteAddress || info . rinfo ? . address || info . socket ? . remoteAddress ;
// Also check if it's nested in rinfo
if ( ! remoteAddr && info . rinfo ) {
remoteAddr = info . rinfo . address ;
}
// Extract port - OSC library typically provides this in rinfo.port
remotePort = info . port || info . remotePort || info . rinfo ? . port || info . socket ? . remotePort ;
// Log the full info structure for debugging
console . log ( `[OSC] Full info object structure:` , JSON . stringify ( info , null , 2 ));
console . log ( `[OSC] Detected remote address: ${ remoteAddr } , port: ${ remotePort } ` );
}
const timestamp = new Date (). toISOString ();
const logEntry = {
time : timestamp ,
from : remoteAddr || 'unknown' ,
address : msg . address ,
args : msg . args ? msg . args . map ( a => ({ type : a . type , value : a . value })) : [],
status : 'received'
};
oscActivityLog . push ( logEntry );
if ( oscActivityLog . length > MAX_OSC_LOG_ENTRIES ) {
oscActivityLog . shift (); // Remove oldest entry
}
console . log ( `[OSC] ========== MESSAGE RECEIVED ==========` );
console . log ( `[OSC] From: ${ remoteAddr || 'unknown' } ` );
console . log ( `[OSC] Info object keys:` , info ? Object . keys ( info ) : 'null' );
console . log ( `[OSC] Info object:` , JSON . stringify ( info , null , 2 ));
console . log ( `[OSC] Message object:` , JSON . stringify ({
address : msg . address ,
args : msg . args ? msg . args . map ( a => ({ type : a . type , value : a . value })) : []
}, null , 2 ));
// Create a new ipAllowed function with current config
const ipAllowedWithConfig = ( addr ) => {
try {
if ( ! addr ) {
console . log ( `[OSC] IP check: remoteAddr is falsy` );
return false ;
}
console . log ( `[OSC] IP check: remoteAddr=" ${ addr } "` );
console . log ( `[OSC] IP check: currentCfg.allow=` , currentCfg . allow );
console . log ( `[OSC] IP check: currentCfg.allowPrefixes=` , currentCfg . allowPrefixes );
// Normalize IPv6-mapped IPv4 addresses (::ffff:10.6.3.222 -> 10.6.3.222)
let normalizedAddr = addr ;
if ( addr . startsWith ( '::ffff:' )) {
normalizedAddr = addr . substring ( 7 ); // Remove '::ffff:' prefix
console . log ( `[OSC] IP check: Normalized IPv6-mapped IPv4: " ${ normalizedAddr } "` );
}
// Always allow loopback
if ( addr === '127.0.0.1' || addr === '::1' || normalizedAddr === '127.0.0.1' ) {
console . log ( `[OSC] IP check: ✓ Allowed (loopback)` );
return true ;
}
// Check direct match (try both original and normalized)
if ( currentCfg . allow && currentCfg . allow . length > 0 ) {
const directMatch = currentCfg . allow . includes ( addr ) || currentCfg . allow . includes ( normalizedAddr );
console . log ( `[OSC] IP check: Direct match in allow list: ${ directMatch } (checked " ${ addr } " and " ${ normalizedAddr } ")` );
if ( directMatch ) {
console . log ( `[OSC] IP check: ✓ Allowed (direct match)` );
return true ;
}
}
// Check prefix match (try both original and normalized)
if ( currentCfg . allowPrefixes && currentCfg . allowPrefixes . length > 0 ) {
const prefixMatch = currentCfg . allowPrefixes . some ( prefix =>
addr . startsWith ( prefix ) || normalizedAddr . startsWith ( prefix )
);
console . log ( `[OSC] IP check: Prefix match: ${ prefixMatch } (checked " ${ addr } " and " ${ normalizedAddr } ")` );
if ( prefixMatch ) {
console . log ( `[OSC] IP check: ✓ Allowed (prefix match)` );
return true ;
}
}
// If listening on 0.0.0.0 (all interfaces) and no allow rules configured, allow all
if ( currentCfg . address === '0.0.0.0' || currentCfg . address === '::' ) {
const hasAllowRules = ( currentCfg . allow && currentCfg . allow . length > 0 ) ||
( currentCfg . allowPrefixes && currentCfg . allowPrefixes . length > 0 );
if ( ! hasAllowRules ) {
console . log ( `[OSC] IP check: ✓ Allowed (0.0.0.0 with no restrictions)` );
return true ;
}
// If we have allow rules but didn't match, fall through to block
console . log ( `[OSC] IP check: ❌ BLOCKED (0.0.0.0 with restrictions, no match)` );
return false ;
}
// If no allow rules configured and bound to loopback by default, allow
if (( currentCfg . address === '127.0.0.1' || currentCfg . address === '::1' || currentCfg . address === 'localhost' ) &&
( ! currentCfg . allow || currentCfg . allow . length === 0 ) &&
( ! currentCfg . allowPrefixes || currentCfg . allowPrefixes . length === 0 )) {
console . log ( `[OSC] IP check: ✓ Allowed (loopback default)` );
return true ;
}
// Otherwise block by default
console . log ( `[OSC] IP check: ❌ BLOCKED (no match found)` );
return false ;
} catch ( e ) {
console . error ( `[OSC] IP check error:` , e );
return false ;
}
};
if ( ! ipAllowedWithConfig ( remoteAddr )) {
console . warn ( `[OSC] ❌ BLOCKED message from ${ remoteAddr } - IP not in allowed list` );
console . warn ( `[OSC] Config address: ${ currentCfg . address } ` );
console . warn ( `[OSC] Allowed IPs:` , currentCfg . allow );
console . warn ( `[OSC] Allowed prefixes:` , currentCfg . allowPrefixes );
if ( oscActivityLog . length > 0 ) {
oscActivityLog [ oscActivityLog . length - 1 ]. status = 'blocked' ;
oscActivityLog [ oscActivityLog . length - 1 ]. debug = {
remoteAddr ,
configAddress : currentCfg . address ,
allowedIPs : currentCfg . allow || [],
allowedPrefixes : currentCfg . allowPrefixes || [],
configPath : path . join ( notesDir , 'osc-config.json' )
};
}
// Send feedback for blocked IP (if we can determine the address)
// Note: We still try to send feedback even if IP is blocked, as feedback might go to a different address
const blockedMsg = msg || { address : 'unknown' };
sendOscFeedback ( blockedMsg , info , 'blocked' , false , `IP ${ remoteAddr } not in allowed list` );
console . log ( `[OSC] ========================================` );
return ;
}
rememberOscFeedbackTarget ( info , 'osc_message' );
const address = ( msg && msg . address ) || '' ;
const args = ( msg && Array . isArray ( msg . args )) ? msg . args : [];
try {
const argVals = args . map ( a => ( a && typeof a . value !== 'undefined' ) ? a . value : a );
console . log ( `[OSC] ✓ IP ALLOWED - Processing message` );
console . log ( `[OSC] Route: " ${ address } "` );
console . log ( `[OSC] Args: ${ JSON . stringify ( argVals ) } ` );
} catch ( _ ) {}
const getVal = ( i ) => {
const v = args [ i ];
return ( v && typeof v . value !== 'undefined' ) ? v . value : v ;
};
const asString = ( v ) => ( typeof v === 'string' ) ? v : ( v != null ? String ( v ) : '' );
const firstArg = getVal ( 0 );
const allVals = args . map (( v )=> {
const raw = ( v && typeof v . value !== 'undefined' ) ? v . value : v ;
return asString ( raw );
});
const route = String ( address || '' ). toLowerCase ();
console . log ( `[OSC] Original route: " ${ route } "` );
// Normalize optional /bibleapp prefix - but also check original route for compatibility
const norm = route . startsWith ( '/bibleapp/' ) ? route : ( route . startsWith ( '/' ) ? '/bibleapp' + route : '/bibleapp/' + route );
console . log ( `[OSC] Normalized route: " ${ norm } "` );
console . log ( `[OSC] Will check both normalized (" ${ norm } ") and original (" ${ route } ") routes` );
// Tool subroutes convenience
const toolFromSubroute = (() => {
if ( norm === '/bibleapp/tool/pen' ) return 'pen' ;
if ( norm === '/bibleapp/tool/highlight' ) return 'highlight' ;
if ( norm === '/bibleapp/tool/erase' ) return 'erase' ;
if ( norm === '/bibleapp/tool/select' ) return 'select' ;
return '' ;
})();
// Pen color subroutes
const penColorFromSubroute = (() => {
if ( norm . startsWith ( '/bibleapp/pen/color/' )) {
const colorPart = norm . replace ( '/bibleapp/pen/color/' , '' ). toLowerCase ();
return colorPart || null ;
}
return null ;
})();
// Token can appear anywhere among args; accept if any arg exactly matches the token
const tokenOk = ( ! currentCfg . token ) ? true : allVals . some ( v => v === currentCfg . token );
if ( currentCfg . token && ! tokenOk ) {
console . warn ( `[OSC] Token validation failed. Received args: ${ JSON . stringify ( allVals ) } , expected token: ${ currentCfg . token ? '***' : 'none' } ` );
}
const findToolName = () => {
const allowed = new Set ([ 'pen' , 'highlight' , 'erase' , 'select' ]);
for ( const v of allVals ) {
const s = String ( v || '' ). toLowerCase ();
if ( allowed . has ( s )) return s ;
}
return '' ;
};
console . log ( `[OSC] Checking route: norm=" ${ norm } ", route=" ${ route } "` );
if ( norm === '/bibleapp/undo' || route === '/undo' ) {
console . log ( `[OSC] ✓ MATCHED route: undo (norm: " ${ norm } ", original: " ${ route } ")` );
// Update log entry
if ( oscActivityLog . length > 0 ) {
oscActivityLog [ oscActivityLog . length - 1 ]. status = 'matched' ;
oscActivityLog [ oscActivityLog . length - 1 ]. action = 'undo' ;
}
if ( ! tokenOk ) {
console . warn ( '[OSC] ❌ Token validation failed for undo. Token required:' , !! currentCfg . token );
if ( oscActivityLog . length > 0 ) {
oscActivityLog [ oscActivityLog . length - 1 ]. status = 'token_failed' ;
}
sendOscFeedback ( msg , info , 'undo' , false , 'Token validation failed' );
console . log ( `[OSC] ========================================` );
return ;
}
console . log ( '[OSC] ✓ Token OK - Executing: undo' );
const result = invokeRenderer ( 'undo' );
if ( ! result ) {
console . warn ( '[OSC] ❌ Failed to invoke renderer for undo command' );
if ( oscActivityLog . length > 0 ) {
oscActivityLog [ oscActivityLog . length - 1 ]. status = 'failed' ;
oscActivityLog [ oscActivityLog . length - 1 ]. error = 'Renderer invoker returned false' ;
}
console . log ( '[OSC] Sending feedback: undo (failed)' );
sendOscFeedback ( msg , info , 'undo' , false , 'Renderer invoker returned false' );
} else {
console . log ( '[OSC] ✓ Command executed successfully' );
if ( oscActivityLog . length > 0 ) {
oscActivityLog [ oscActivityLog . length - 1 ]. status = 'executed' ;
}
console . log ( '[OSC] Sending feedback: undo (success)' );
sendOscFeedback ( msg , info , 'undo' , true );
}
console . log ( `[OSC] ========================================` );
return ;
}
if ( norm === '/bibleapp/redo' ) {
if ( ! tokenOk ) {
console . warn ( '[OSC] Token validation failed for /bibleapp/redo' );
sendOscFeedback ( msg , info , 'redo' , false , 'Token validation failed' );
return ;
}
console . log ( '[OSC] Executing: redo' );
const result = invokeRenderer ( 'redo' );
sendOscFeedback ( msg , info , 'redo' , !! result , result ? null : 'Renderer invoker returned false' );
return ;
}
if ( norm === '/bibleapp/reset' || route === '/reset' ) {
console . log ( `[OSC] ✓ Matched route: reset (norm: " ${ norm } ", original: " ${ route } ")` );
// Update log entry
if ( oscActivityLog . length > 0 ) {
oscActivityLog [ oscActivityLog . length - 1 ]. status = 'matched' ;
oscActivityLog [ oscActivityLog . length - 1 ]. action = 'reset_app' ;
}
if ( ! tokenOk ) {
console . warn ( '[OSC] ❌ Token validation failed for reset' );
if ( oscActivityLog . length > 0 ) {
oscActivityLog [ oscActivityLog . length - 1 ]. status = 'token_failed' ;
}
sendOscFeedback ( msg , info , 'reset' , false , 'Token validation failed' );
console . log ( `[OSC] ========================================` );
return ;
}
console . log ( '[OSC] ✓ Token OK - Executing: reset' );
const result = invokeRenderer ( 'reset_app' );
if ( ! result ) {
console . warn ( '[OSC] ❌ Failed to invoke renderer for reset command' );
if ( oscActivityLog . length > 0 ) {
oscActivityLog [ oscActivityLog . length - 1 ]. status = 'failed' ;
oscActivityLog [ oscActivityLog . length - 1 ]. error = 'Renderer invoker returned false' ;
}
sendOscFeedback ( msg , info , 'reset' , false , 'Renderer invoker returned false' );
} else {
console . log ( '[OSC] ✓ Command executed successfully' );
if ( oscActivityLog . length > 0 ) {
oscActivityLog [ oscActivityLog . length - 1 ]. status = 'executed' ;
}
sendOscFeedback ( msg , info , 'reset' , true );
}
console . log ( `[OSC] ========================================` );
return ;
}
if ( norm === '/bibleapp/clear' ) {
if ( ! tokenOk ) {
console . warn ( '[OSC] Token validation failed for /bibleapp/clear' );
sendOscFeedback ( msg , info , 'clear' , false , 'Token validation failed' );
return ;
}
console . log ( '[OSC] Executing: clear' );
const result = invokeRenderer ( 'clear_current' );
sendOscFeedback ( msg , info , 'clear' , !! result , result ? null : 'Renderer invoker returned false' );
return ;
}
if ( norm === '/bibleapp/clear_all' ) {
if ( ! tokenOk ) {
console . warn ( '[OSC] Token validation failed for /bibleapp/clear_all' );
sendOscFeedback ( msg , info , 'clear_all' , false , 'Token validation failed' );
return ;
}
console . log ( '[OSC] Executing: clear_all' );
const result = invokeRenderer ( 'clear_all' );
sendOscFeedback ( msg , info , 'clear_all' , !! result , result ? null : 'Renderer invoker returned false' );
return ;
}
if ( norm === '/bibleapp/close' || norm === '/bibleapp/close_active_tab' ) {
if ( ! tokenOk ) {
console . warn ( '[OSC] Token validation failed for /bibleapp/close' );
sendOscFeedback ( msg , info , 'close' , false , 'Token validation failed' );
return ;
}
console . log ( '[OSC] Executing: close_active_tab' );
const result = invokeRenderer ( 'close_active_tab' );
sendOscFeedback ( msg , info , 'close' , !! result , result ? null : 'Renderer invoker returned false' );
return ;
}
if ( norm === '/bibleapp/tab/left' || norm === '/bibleapp/prev_tab' || norm === '/bibleapp/tab/prev' ) {
if ( ! tokenOk ) {
console . warn ( '[OSC] Token validation failed for /bibleapp/tab/left' );
sendOscFeedback ( msg , info , 'tab_left' , false , 'Token validation failed' );
return ;
}
console . log ( '[OSC] Executing: switch_tab_left' );
const result = invokeRenderer ( 'switch_tab_left' );
sendOscFeedback ( msg , info , 'tab_left' , !! result , result ? null : 'Renderer invoker returned false' );
return ;
}
if ( norm === '/bibleapp/tab/right' || norm === '/bibleapp/next_tab' || norm === '/bibleapp/tab/next' ) {
if ( ! tokenOk ) {
console . warn ( '[OSC] Token validation failed for /bibleapp/tab/right' );
sendOscFeedback ( msg , info , 'tab_right' , false , 'Token validation failed' );
return ;
}
console . log ( '[OSC] Executing: switch_tab_right' );
const result = invokeRenderer ( 'switch_tab_right' );
sendOscFeedback ( msg , info , 'tab_right' , !! result , result ? null : 'Renderer invoker returned false' );
return ;
}
if ( norm === '/bibleapp/new' || norm === '/bibleapp/new/tab' || norm === '/bibleapp/new/bible' || norm === '/bibleapp/new_tab' || norm === '/bibleapp/new_bible_tab' ) {
if ( ! tokenOk ) {
console . warn ( '[OSC] Token validation failed for /bibleapp/new' );
sendOscFeedback ( msg , info , 'new_bible_tab' , false , 'Token validation failed' );
return ;
}
console . log ( '[OSC] Executing: new_bible_tab' );
const result = invokeRenderer ( 'new_bible_tab' );
sendOscFeedback ( msg , info , 'new_bible_tab' , !! result , result ? null : 'Renderer invoker returned false' );
return ;
}
if ( norm === '/bibleapp/new/whiteboard' || norm === '/bibleapp/new_whiteboard_tab' ) {
if ( ! tokenOk ) {
console . warn ( '[OSC] Token validation failed for /bibleapp/new/whiteboard' );
sendOscFeedback ( msg , info , 'new_whiteboard_tab' , false , 'Token validation failed' );
return ;
}
console . log ( '[OSC] Executing: new_whiteboard_tab' );
const result = invokeRenderer ( 'new_whiteboard_tab' );
sendOscFeedback ( msg , info , 'new_whiteboard_tab' , !! result , result ? null : 'Renderer invoker returned false' );
return ;
}
if ( norm === '/bibleapp/new/notes' || norm === '/bibleapp/new_notes_tab' ) {
if ( ! tokenOk ) {
console . warn ( '[OSC] Token validation failed for /bibleapp/new/notes' );
sendOscFeedback ( msg , info , 'new_notes_tab' , false , 'Token validation failed' );
return ;
}
console . log ( '[OSC] Executing: new_notes_tab' );
const result = invokeRenderer ( 'new_notes_tab' );
sendOscFeedback ( msg , info , 'new_notes_tab' , !! result , result ? null : 'Renderer invoker returned false' );
return ;
}
if ( norm === '/bibleapp/splash/show' || norm === '/bibleapp/splash' || norm === '/bibleapp/show_splash' || norm === '/bibleapp/splash_on' ||
route === '/splash/show' || route === '/splash' || route === '/show_splash' || route === '/splash_on' ) {
if ( ! tokenOk ) {
console . warn ( '[OSC] Token validation failed for splash/show' );
sendOscFeedback ( msg , info , 'splash_show' , false , 'Token validation failed' );
console . log ( `[OSC] ========================================` );
return ;
}
console . log ( '[OSC] ✓ Matched route: splash/show' );
console . log ( '[OSC] ✓ Token OK - Executing: splash_show' );
// Update log entry
if ( oscActivityLog . length > 0 ) {
oscActivityLog [ oscActivityLog . length - 1 ]. status = 'matched' ;
oscActivityLog [ oscActivityLog . length - 1 ]. action = 'splash_show' ;
}
const result = invokeRenderer ( 'splash_show' );
if ( ! result ) {
console . warn ( '[OSC] ❌ Failed to invoke renderer for splash_show command' );
if ( oscActivityLog . length > 0 ) {
oscActivityLog [ oscActivityLog . length - 1 ]. status = 'failed' ;
oscActivityLog [ oscActivityLog . length - 1 ]. error = 'Renderer invoker returned false' ;
}
sendOscFeedback ( msg , info , 'splash_show' , false , 'Renderer invoker returned false' );
} else {
console . log ( '[OSC] ✓ Command executed successfully' );
if ( oscActivityLog . length > 0 ) {
oscActivityLog [ oscActivityLog . length - 1 ]. status = 'executed' ;
}
sendOscFeedback ( msg , info , 'splash_show' , true );
}
console . log ( `[OSC] ========================================` );
return ;
}
if ( norm === '/bibleapp/tool' || toolFromSubroute ) {
if ( ! tokenOk ) {
console . warn ( '[OSC] Token validation failed for /bibleapp/tool' );
sendOscFeedback ( msg , info , 'tool' , false , 'Token validation failed' );
return ;
}
const name = toolFromSubroute || findToolName ();
if ([ 'pen' , 'highlight' , 'erase' , 'select' ]. includes ( name )) {
console . log ( '[OSC] Executing: tool' , name );
const result = invokeRenderer ( 'tool' , { name });
sendOscFeedback ( msg , info , `tool_ ${ name } ` , !! result , result ? null : 'Renderer invoker returned false' );
} else {
sendOscFeedback ( msg , info , 'tool' , false , `Invalid tool name: ${ name } ` );
}
return ;
}
// Pen color commands: /bibleapp/pen/color or /bibleapp/pen/color/<color>
if ( norm === '/bibleapp/pen/color' || penColorFromSubroute !== null ) {
if ( ! tokenOk ) {
sendOscFeedback ( msg , info , 'pen_color' , false , 'Token validation failed' );
return ;
}
// Get color from subroute or first argument
let color = penColorFromSubroute ;
if ( ! color && args . length > 0 ) {
color = asString ( firstArg );
}
if ( color ) {
// Normalize color (support hex, color names, etc.)
color = color . trim ();
// Map common color names to hex values
const colorMap = {
'white' : '#fefefe' ,
'red' : '#ff3333' ,
'blue' : '#0090ff' ,
'green' : '#00ff00' ,
'yellow' : '#ffff00' ,
'black' : '#000000' ,
'orange' : '#ff8800' ,
'purple' : '#8800ff' ,
'pink' : '#ff00ff' ,
'cyan' : '#00ffff'
};
const lowerColor = color . toLowerCase ();
if ( colorMap [ lowerColor ]) {
color = colorMap [ lowerColor ];
}
let success = false ;
let normalizedHex = null ;
// Validate and normalize hex color format
if ( color . startsWith ( '#' )) {
// Already has #, validate it's a valid hex color (3 or 6 digits)
const hexPart = color . substring ( 1 );
if ( /^[0-9a-f]{3}$/i . test ( hexPart ) || /^[0-9a-f]{6}$/i . test ( hexPart )) {
// Expand 3-digit hex to 6-digit if needed
if ( hexPart . length === 3 ) {
color = '#' + hexPart . split ( '' ). map ( c => c + c ). join ( '' );
}
normalizedHex = color . substring ( 1 ). toLowerCase ();
const result = invokeRenderer ( 'pen_color' , { color });
success = !! result ;
} else {
sendOscFeedback ( msg , info , 'pen_color' , false , `Invalid hex color format: ${ color } ` );
return ;
}
} else if ( /^[0-9a-f]{3}$/i . test ( color )) {
// 3-digit hex without #
color = '#' + color . split ( '' ). map ( c => c + c ). join ( '' );
normalizedHex = color . substring ( 1 ). toLowerCase ();
const result = invokeRenderer ( 'pen_color' , { color });
success = !! result ;
} else if ( /^[0-9a-f]{6}$/i . test ( color )) {
// 6-digit hex without #
normalizedHex = color . toLowerCase ();
const result = invokeRenderer ( 'pen_color' , { color : '#' + color });
success = !! result ;
} else {
sendOscFeedback ( msg , info , 'pen_color' , false , `Invalid color format: ${ color } ` );
return ;
}
// Send per-color feedback path so each Companion color button can match uniquely.
// Example: /bibleapp/feedback/pen_color/ff3333
const feedbackCommand = normalizedHex ? `pen_color/ ${ normalizedHex } ` : 'pen_color' ;
sendOscFeedback ( msg , info , feedbackCommand , success , success ? null : 'Renderer invoker returned false' );
} else {
sendOscFeedback ( msg , info , 'pen_color' , false , 'No color specified' );
}
return ;
}
// ---- Phase 3 additions: presentation + navigation + profiles ----
const nonTokenArgs = () => allVals . map ( v => String ( v || '' ). trim ()). filter ( v => v && ( ! currentCfg . token || v !== currentCfg . token ));
if ( norm === '/bibleapp/redletter' || norm === '/bibleapp/red-letter' || norm === '/bibleapp/red_letter' ) {
if ( ! tokenOk ) { sendOscFeedback ( msg , info , 'redletter' , false , 'Token validation failed' ); return ; }
const result = invokeRenderer ( 'redLetterToggle' );
sendOscFeedback ( msg , info , 'redletter' , !! result , result ? null : 'Renderer invoker returned false' );
return ;
}
if ( norm === '/bibleapp/laser' ) {
if ( ! tokenOk ) { sendOscFeedback ( msg , info , 'laser' , false , 'Token validation failed' ); return ; }
const result = invokeRenderer ( 'laser' );
sendOscFeedback ( msg , info , 'laser' , !! result , result ? null : 'Renderer invoker returned false' );
return ;
}
if ( norm === '/bibleapp/spotlight' ) {
if ( ! tokenOk ) { sendOscFeedback ( msg , info , 'spotlight' , false , 'Token validation failed' ); return ; }
const result = invokeRenderer ( 'spotlight' );
sendOscFeedback ( msg , info , 'spotlight' , !! result , result ? null : 'Renderer invoker returned false' );
return ;
}
if ( norm === '/bibleapp/present/off' || norm === '/bibleapp/laser/off' || norm === '/bibleapp/spotlight/off' ) {
if ( ! tokenOk ) { sendOscFeedback ( msg , info , 'present_off' , false , 'Token validation failed' ); return ; }
const result = invokeRenderer ( 'presentOff' );
sendOscFeedback ( msg , info , 'present_off' , !! result , result ? null : 'Renderer invoker returned false' );
return ;
}
if ( norm === '/bibleapp/chapter/next' || norm === '/bibleapp/next_chapter' ) {
if ( ! tokenOk ) { sendOscFeedback ( msg , info , 'chapter_next' , false , 'Token validation failed' ); return ; }
const result = invokeRenderer ( 'chapterNext' );
sendOscFeedback ( msg , info , 'chapter_next' , !! result , result ? null : 'Renderer invoker returned false' );
return ;
}
if ( norm === '/bibleapp/maps' || norm === '/bibleapp/map' || norm === '/bibleapp/showmaps' ) {
if ( ! tokenOk ) { sendOscFeedback ( msg , info , 'show_maps' , false , 'Token validation failed' ); return ; }
const result = invokeRenderer ( 'showMaps' );
sendOscFeedback ( msg , info , 'show_maps' , !! result , result ? null : 'Renderer invoker returned false' );
return ;
}
if ( norm === '/bibleapp/chapter/prev' || norm === '/bibleapp/prev_chapter' ) {
if ( ! tokenOk ) { sendOscFeedback ( msg , info , 'chapter_prev' , false , 'Token validation failed' ); return ; }
const result = invokeRenderer ( 'chapterPrev' );
sendOscFeedback ( msg , info , 'chapter_prev' , !! result , result ? null : 'Renderer invoker returned false' );
return ;
}
if ( norm === '/bibleapp/dock/float' ) {
if ( ! tokenOk ) { sendOscFeedback ( msg , info , 'dock_float' , false , 'Token validation failed' ); return ; }
const result = invokeRenderer ( 'dockFloat' );
sendOscFeedback ( msg , info , 'dock_float' , !! result , result ? null : 'Renderer invoker returned false' );
return ;
}
if ( norm === '/bibleapp/dock/rail' || norm === '/bibleapp/dock' ) {
if ( ! tokenOk ) { sendOscFeedback ( msg , info , 'dock_rail' , false , 'Token validation failed' ); return ; }
const result = invokeRenderer ( 'dockRail' );
sendOscFeedback ( msg , info , 'dock_rail' , !! result , result ? null : 'Renderer invoker returned false' );
return ;
}
if ( norm === '/bibleapp/profile' || norm === '/bibleapp/profile/apply' ) {
if ( ! tokenOk ) { sendOscFeedback ( msg , info , 'profile' , false , 'Token validation failed' ); return ; }
const name = nonTokenArgs ()[ 0 ] || '' ;
if ( ! name ) { sendOscFeedback ( msg , info , 'profile' , false , 'No profile name' ); return ; }
const result = invokeRenderer ( 'applyProfile' , { name });
sendOscFeedback ( msg , info , 'profile' , !! result , result ? null : 'Renderer invoker returned false' );
return ;
}
if ( norm === '/bibleapp/goto' || norm === '/bibleapp/passage' || norm === '/bibleapp/scripture' ) {
if ( ! tokenOk ) { sendOscFeedback ( msg , info , 'goto' , false , 'Token validation failed' ); return ; }
const ref = nonTokenArgs (). join ( ' ' );
if ( ! ref ) { sendOscFeedback ( msg , info , 'goto' , false , 'No reference' ); return ; }
const result = invokeRenderer ( 'gotoPassage' , { ref });
sendOscFeedback ( msg , info , 'goto' , !! result , result ? null : 'Renderer invoker returned false' );
return ;
}
// If we get here, no route matched
console . warn ( `[OSC] ⚠ No matching route found!` );
console . warn ( `[OSC] Normalized route: " ${ norm } "` );
console . warn ( `[OSC] Original route: " ${ route } "` );
console . warn ( `[OSC] Token OK: ${ tokenOk } ` );
console . warn ( `[OSC] Checking if routes match...` );
console . warn ( `[OSC] norm === '/bibleapp/undo': ${ norm === '/bibleapp/undo' } ` );
console . warn ( `[OSC] route === '/undo': ${ route === '/undo' } ` );
console . warn ( `[OSC] norm === '/bibleapp/reset': ${ norm === '/bibleapp/reset' } ` );
console . warn ( `[OSC] route === '/reset': ${ route === '/reset' } ` );
console . warn ( `[OSC] norm === '/bibleapp/splash/show': ${ norm === '/bibleapp/splash/show' } ` );
console . warn ( `[OSC] route === '/splash/show': ${ route === '/splash/show' } ` );
// Update log entry
if ( oscActivityLog . length > 0 ) {
oscActivityLog [ oscActivityLog . length - 1 ]. status = 'no_match' ;
oscActivityLog [ oscActivityLog . length - 1 ]. normalizedRoute = norm ;
oscActivityLog [ oscActivityLog . length - 1 ]. debug = {
norm ,
route ,
normMatchesUndo : norm === '/bibleapp/undo' ,
routeMatchesUndo : route === '/undo' ,
normMatchesReset : norm === '/bibleapp/reset' ,
routeMatchesReset : route === '/reset' ,
normMatchesSplash : norm === '/bibleapp/splash/show' ,
routeMatchesSplash : route === '/splash/show'
};
}
// Send feedback for unmatched route
sendOscFeedback ( msg , info , 'unknown' , false , `No matching route found: ${ route } ` );
console . log ( `[OSC] Available routes: /bibleapp/undo (or /undo), /bibleapp/redo (or /redo), /bibleapp/clear, /bibleapp/tab/left, /bibleapp/tab/right, /bibleapp/tool, /bibleapp/splash/show (or /splash/show), etc.` );
console . log ( `[OSC] ========================================` );
} catch ( e ) {
console . error ( '[OSC] ❌ Message handling error:' , e && e . message || e );
console . error ( '[OSC] Error stack:' , e . stack );
// Update log entry with error
if ( oscActivityLog . length > 0 ) {
oscActivityLog [ oscActivityLog . length - 1 ]. status = 'error' ;
oscActivityLog [ oscActivityLog . length - 1 ]. error = String ( e && e . message || e );
}
console . log ( `[OSC] ========================================` );
}
});
try {
console . log ( '[OSC] Opening UDP port...' );
port . open ();
console . log ( '[OSC] ✓ Port.open() called successfully - waiting for ready event...' );
// Store port reference
oscPort = port ;
} catch ( e ) {
oscListening = false ;
oscPort = null ;
console . error ( '[OSC] ❌ Failed to open UDP port:' , e && e . message || e );
console . error ( '[OSC] Error stack:' , e . stack );
}
}
// Global Notes directory (prefer user data for persistence across installs)
function resolveNotesDirectory () {
return resolveContentDirectory ( 'Notes' , 'BIBLE_APP_NOTES_DIR' );
}
const notesDir = resolveNotesDirectory ();
/*
* ProPresenter Sync — the single live link to ProPresenter (see propresenter-link.js). The renderer
* follows it over SSE (/api/propresenter/events). Password encryption is Electron's safeStorage, handed
* in through registerAppControl a moment after this module loads, so it is looked up lazily.
*/
const ppLink = createProPresenterLink ({
configPath : path . join ( notesDir , 'propresenter-config.json' ),
secrets : {
available : () => { try { return !! ( __appControl && typeof __appControl . secretsAvailable === 'function' && __appControl . secretsAvailable ()); } catch ( _ ) { return false ; } },
encrypt : ( s ) => ( __appControl && typeof __appControl . encryptSecret === 'function' ) ? __appControl . encryptSecret ( s ) : null ,
decrypt : ( s ) => ( __appControl && typeof __appControl . decryptSecret === 'function' ) ? __appControl . decryptSecret ( s ) : null
}
});
const __ppSseClients = new Set ();
function ppBroadcast ( snap ) {
const line = 'data: ' + JSON . stringify ( snap ) + '\n\n' ;
for ( const res of __ppSseClients ) { try { res . write ( line ); } catch ( _ ) {} }
}
ppLink . on ( 'state' , ppBroadcast );
/*
* Companion feedback: the link state as live OSC state paths, on the same channel the tool and pen
* buttons use (OSC_FEEDBACK_PATHS.md). Besides Settings › ProPresenter Sync this is the ONLY place the
* state is shown — nothing on the stage screen marks the sync tab.
* /bibleapp/feedback/state/ppsync_enabled 1 while "Follow ProPresenter" is on, else 0
* /bibleapp/feedback/state/ppsync_live 1 while slides are actually being received, else 0
* Sent on change only (a reconnect loop flips neither), and repeated in every state snapshot.
*/
function ppOscFeedback ( snap ) {
try {
const enabled = !! ( snap && snap . config && snap . config . enabled );
const live = !! ( snap && snap . status === 'live' );
let changed = false ;
if ( oscStateUiCache . ppsyncEnabled !== enabled ) {
oscStateUiCache . ppsyncEnabled = enabled ;
changed = true ;
sendOscStateFeedback ( 'state/ppsync_enabled' , enabled ? 1 : 0 );
}
if ( oscStateUiCache . ppsyncLive !== live ) {
oscStateUiCache . ppsyncLive = live ;
changed = true ;
sendOscStateFeedback ( 'state/ppsync_live' , live ? 1 : 0 );
}
if ( changed ) scheduleOscStateSnapshot ( null , 'ppsync:' + (( snap && snap . status ) || 'idle' ));
} catch ( e ) { console . warn ( '[PPSYNC] companion feedback failed:' , e && e . message || e ); }
}
ppLink . on ( 'state' , ppOscFeedback );
/*
* Follower Screen settings (Settings › Follower Screen). One switch, persisted beside the other
* per-machine config in the Notes folder so it travels with this installation and nowhere else. The
* address shown in Settings is computed HERE, on the machine that is actually running, from its own
* network interfaces and the port this server really bound — the app is installed at several
* locations and each one must show its own address, never a remembered one.
*/
const followerConfigPath = path . join ( notesDir , 'follower-config.json' );
function readFollowerConfig () {
const cfg = { enabled : true };
try {
if ( fs . existsSync ( followerConfigPath )) {
const d = JSON . parse ( fs . readFileSync ( followerConfigPath , 'utf-8' ));
if ( d && typeof d === 'object' && typeof d . enabled === 'boolean' ) cfg . enabled = d . enabled ;
}
} catch ( _ ) {}
return cfg ;
}
function writeFollowerConfig ( cfg ) {
try { fs . writeFileSync ( followerConfigPath , JSON . stringify ({ enabled : !! cfg . enabled }, null , 2 )); return true ; } catch ( e ) { console . error ( '[FOLLOWER] could not save config:' , e && e . message || e ); return false ; }
}
function followerUrls () {
const port = activeServerPort || PORT ;
const urls = [];
try {
const ifaces = os . networkInterfaces ();
Object . keys ( ifaces ). forEach ( name => {
( ifaces [ name ] || []). forEach ( ni => {
if ( ni && ni . family === 'IPv4' && ! ni . internal ) urls . push ( `http:// ${ ni . address } : ${ port } /follower.html` );
});
});
} catch ( _ ) {}
try {
const host = String ( os . hostname () || '' ). trim (). toLowerCase ();
if ( host ) urls . push ( `http:// ${ host } .local: ${ port } /follower.html` );
} catch ( _ ) {}
return urls ;
}
// Comment heartbeat keeps idle SSE connections from being dropped by anything in between.
const __ppHeartbeat = setInterval (() => { for ( const res of __ppSseClients ) { try { res . write ( ': ping\n\n' ); } catch ( _ ) {} } }, 15000 );
try { __ppHeartbeat . unref (); } catch ( _ ) {}
// Start after Electron main has registered the credential store (needed only to decrypt a saved password).
setTimeout (() => { try { ppLink . start (); } catch ( e ) { console . error ( '[PPSYNC] start failed:' , e && e . message || e ); } }, 1200 );
// Utility: splash exclusions to keep splash assets out of Notes flows
function loadSplashExclusions () {
const exclusions = new Set ();
const common = [ 'splash.png' , 'splash.jpg' , 'splash.jpeg' , 'splash.webp' , 'splash.gif' , 'splash.bmp' ];
common . forEach ( n => exclusions . add ( n . toLowerCase ()));
try {
const configPath = path . join ( notesDir , 'splash-config.json' );
if ( fs . existsSync ( configPath )) {
const cfg = JSON . parse ( fs . readFileSync ( configPath , 'utf-8' ) || '{}' );
if ( cfg && cfg . default ) exclusions . add ( String ( cfg . default ). trim (). toLowerCase ());
if ( cfg && Array . isArray ( cfg . exclusions )) {
cfg . exclusions . forEach ( n => { if ( n ) exclusions . add ( String ( n ). trim (). toLowerCase ()); });
}
}
} catch ( _ ) {}
try {
const manifestPath = path . join ( notesDir , 'splash-upload-manifest.json' );
if ( fs . existsSync ( manifestPath )) {
const arr = JSON . parse ( fs . readFileSync ( manifestPath , 'utf-8' ) || '[]' );
if ( Array . isArray ( arr )) {
arr . forEach ( n => { if ( n ) exclusions . add ( String ( n ). trim (). toLowerCase ()); });
}
}
} catch ( _ ) {}
return exclusions ;
}
// Helper functions for deleted defaults are defined earlier in the file (before migrateLegacyContent)
// Default graphics are no longer auto-seeded on startup
// Users can install Starter Pack graphics via Settings page
// This prevents automatic installation of default graphics
function resolveAnnouncementsDirectory () {
return resolveContentDirectory ( 'Announcements' , 'BIBLE_APP_ANNOUNCEMENTS_DIR' );
}
const announcementsDir = resolveAnnouncementsDirectory ();
function resolveGraphicsDirectory () {
// Use a distinct storage folder to avoid colliding with the bundled Graphics assets used for splash defaults.
return resolveContentDirectory ( 'GraphicsTab' , 'BIBLE_APP_GRAPHICS_DIR' );
}
const graphicsDir = resolveGraphicsDirectory ();
function resolveMapsDirectory () { return resolveContentDirectory ( 'MapsTab' , 'BIBLE_APP_MAPS_DIR' ); }
const mapsDir = resolveMapsDirectory ();
// Default Announcements graphic is no longer auto-seeded on startup
// Users can install Starter Pack graphics via Settings page
const mimeTypes = {
'.html' : 'text/html' ,
'.js' : 'text/javascript' ,
'.mjs' : 'text/javascript' ,
'.css' : 'text/css' ,
'.json' : 'application/json' ,
'.png' : 'image/png' ,
'.jpg' : 'image/jpeg' ,
'.jpeg' : 'image/jpeg' ,
'.gif' : 'image/gif' ,
'.svg' : 'image/svg+xml' ,
'.wav' : 'audio/wav' ,
'.mp4' : 'video/mp4' ,
'.m4v' : 'video/mp4' ,
'.webm' : 'video/webm' ,
'.mov' : 'video/quicktime' ,
'.pdf' : 'application/pdf' ,
'.bmp' : 'image/bmp' ,
'.webp' : 'image/webp' ,
'.avif' : 'image/avif' ,
'.woff' : 'font/woff' ,
'.woff2' : 'font/woff2' ,
'.ttf' : 'application/font-ttf' ,
'.eot' : 'application/vnd.ms-fontobject' ,
'.otf' : 'application/font-otf' ,
'.wasm' : 'application/wasm' ,
'.jfif' : 'image/jpeg' ,
'.apng' : 'image/apng' ,
'.ico' : 'image/x-icon'
};
/*
* Still + vector image formats Chromium can actually decode inside an <img>. SVG is the point of
* this list for the Maps tab — a vector map stays razor sharp however far the pastor pinch-zooms.
* Deliberately absent: tif/tiff/heic/heif/eps/ai (Chromium cannot decode them, so they would list
* and then render as a blank black card) and svgz (needs Content-Encoding: gzip, which the static
* handler does not send).
*/
const STILL_IMAGE_RE = /\.(png|jpe?g|jfif|gif|bmp|webp|svg|avif|apng|ico)$/i ;
// Video belongs on the Graphics tab only. Maps is images-only, by request.
const MEDIA_VIDEO_RE = /\.(mp4|m4v|webm|mov)$/i ;
// In-memory cache for the Strong's Greek/Hebrew dictionaries. These files are large and
// static for the life of the app, but were previously re-read + re-parsed from disk on every
// word-tap. Cache the finished JSON response string once so subsequent lookups are instant.
// (Session-scoped: cleared on app restart, which is when the underlying files could change.)
const __dictCache = { greek : null , hebrew : null };
// Per-book cache for word-by-word interlinear data (STEP Bible TAHOT/TAGNT, CC BY 4.0)
const __interlinearCache = new Map ();
const INTERLINEAR_ATTRIBUTION = 'Tagged Bible data © STEPBible.org, Tyndale House Cambridge (CC BY 4.0)' ;
// Bible book names for simple scripture detection
const BIBLE_BOOKS = [
'Genesis' , 'Exodus' , 'Leviticus' , 'Numbers' , 'Deuteronomy' , 'Joshua' , 'Judges' , 'Ruth' , '1 Samuel' , '2 Samuel' , '1 Kings' , '2 Kings' , '1 Chronicles' , '2 Chronicles' , 'Ezra' , 'Nehemiah' , 'Esther' , 'Job' , 'Psalms' , 'Proverbs' , 'Ecclesiastes' , 'Song of Solomon' , 'Song of Songs' , 'Isaiah' , 'Jeremiah' , 'Lamentations' , 'Ezekiel' , 'Daniel' , 'Hosea' , 'Joel' , 'Amos' , 'Obadiah' , 'Jonah' , 'Micah' , 'Nahum' , 'Habakkuk' , 'Zephaniah' , 'Haggai' , 'Zechariah' , 'Malachi' , 'Matthew' , 'Mark' , 'Luke' , 'John' , 'Acts' , 'Romans' , '1 Corinthians' , '2 Corinthians' , 'Galatians' , 'Ephesians' , 'Philippians' , 'Colossians' , '1 Thessalonians' , '2 Thessalonians' , '1 Timothy' , '2 Timothy' , 'Titus' , 'Philemon' , 'Hebrews' , 'James' , '1 Peter' , '2 Peter' , '1 John' , '2 John' , '3 John' , 'Jude' , 'Revelation'
];
const BOOKS_REGEX = new RegExp ( `\\b(?: ${ BIBLE_BOOKS . map ( b => b . replace ( /\\s+/g , '\\\\s+' )). join ( '|' ) } )\\b` , 'i' );
// Lines like RTF control artifacts that sometimes leak through
const JUNK_LINE_RE = /^(?:e?ftab\d+|tightenfactor[0-9a-z]*|fs\d+|cf\d+|li\d+|ri\d+|sa\d+|sb\d+|fi\d+|tx\d+|pard|plain|sectd)$/i ;
const JUNK_INLINE_RE = /(e?ftab\d+|tightenfactor[0-9a-z]*|fs\d+|cf\d+|li\d+|ri\d+|sa\d+|sb\d+|fi\d+|tx\d+|pard|plain|sectd)/gi ;
function collapseParagraphs ( text ) {
if ( ! text ) return '' ;
// Protect double newlines (paragraphs)
const TOKEN = '\u0000\u0000PARA\u0000\u0000' ;
return text
. replace ( /\n{3,}/g , '\n\n' )
. replace ( /\n\n/g , TOKEN )
. replace ( /\n/g , ' ' )
. replace ( new RegExp ( TOKEN , 'g' ), '\n\n' )
. replace ( /\s{2,}/g , ' ' )
. trim ();
}
function cleanInlineArtifacts ( text ) {
if ( ! text ) return '' ;
return text
. replace ( JUNK_INLINE_RE , '' )
. replace ( /\s{2,}/g , ' ' )
. trim ();
}
function isScriptureLine ( line ) {
if ( BOOKS_REGEX . test ( line ) && /\b\d+\s*:\s*\d+/ . test ( line )) return true ;
// Verse-only lines like "3:16" or ranges
if ( /^\s*\d+\s*:\s*\d+(?:\s*[-–—]\s*\d+)?\s*$/ . test ( line )) return true ;
return false ;
}
function isScriptureSegment ( text ) {
if ( ! text ) return false ;
// Only treat as scripture when a book name appears with chapter:verse or translation tag appears
if ( BOOKS_REGEX . test ( text ) && /\b\d+\s*:\s*\d+/ . test ( text )) return true ;
if ( /\b(?:nkjv|esv|niv|nlt|nasb|csb|net|kjv)\b/i . test ( text )) return true ;
const commaCount = ( text . match ( /[;,]/g ) || []). length ;
const wordCount = ( text . match ( /\b\w+\b/g ) || []). length ;
if ( /\bthe\s+lord\b/i . test ( text ) && ( commaCount >= 2 || wordCount >= 20 )) return true ;
return false ;
}
function splitIntoPoints ( nonBlueText , opts = {}) {
const { requireMarker = false , headingAllowed = true , minWords = 1 , bracketOnly = false } = opts ;
const normalized = ( nonBlueText || '' ). replace ( /\r\n?/g , '\n' );
const lines = normalized . split ( /\n/ );
const onlyPunct = ( s ) => ! s . replace ( /[\s.,;:!?'"\-–—•\u2022\u25CF\u25A0\u25CB\u00B7]/g , '' ). length ;
const isPointStart = ( sTrim ) => {
if ( /^[\u2022\u25CF\u25A0\u25CB\u25AA\u00B7\-–—]\s+/ . test ( sTrim )) return true ; // bullets/dashes
if ( /^(?:\(?\d+\)|\d+[.)]|\(?[ivxIVX]+\)|[A-Z][.)])\s+/ . test ( sTrim )) return true ; // numbers/roman/letters
// Headings: mostly uppercase or lines ending with ':' or '?'
if ( headingAllowed && ! requireMarker ) {
const letters = sTrim . replace ( /[^A-Za-z]/g , '' );
const upp = letters . replace ( /[^A-Z]/g , '' ). length ;
const upperRatio = letters . length ? upp / letters . length : 0 ;
if (( /[?:]$/ . test ( sTrim ) || upperRatio >= 0.6 ) && sTrim . length >= 3 && sTrim . split ( /\s+/ ). length <= 20 ) return true ;
}
return false ;
};
// First pass: line-based aggregation
const pointsBasic = (() => {
const pts = []; let current = []; let currentWordCount = 0 ;
for ( const raw of lines ) {
const lineTrim = ( raw || '' ). trim ();
if ( lineTrim . length === 0 ) {
if ( current . length ) {
const joined = current . join ( '\n' ). trim ();
const words = ( joined . match ( /\b\w+\b/g ) || []). length ;
if ( joined && ! onlyPunct ( joined ) && words >= minWords ) {
pts . push ( joined );
} else if ( pts . length ) {
// append short fragment to previous
pts [ pts . length - 1 ] = ( pts [ pts . length - 1 ] + '\n' + joined ). trim ();
}
current = []; currentWordCount = 0 ;
}
continue ;
}
if ( isScriptureLine ( lineTrim )) continue ;
if ( isPointStart ( lineTrim ) && current . length ) {
const joined = current . join ( '\n' ). trim ();
const words = ( joined . match ( /\b\w+\b/g ) || []). length ;
if ( joined && ! onlyPunct ( joined ) && words >= minWords ) pts . push ( joined );
else if ( pts . length ) pts [ pts . length - 1 ] = ( pts [ pts . length - 1 ] + '\n' + joined ). trim ();
current = [ lineTrim ]; currentWordCount = ( lineTrim . match ( /\b\w+\b/g ) || []). length ;
} else {
current . push ( lineTrim );
currentWordCount += ( lineTrim . match ( /\b\w+\b/g ) || []). length ;
}
}
const last = current . join ( '\n' ). trim ();
if ( last && ! onlyPunct ( last )) {
const words = ( last . match ( /\b\w+\b/g ) || []). length ;
if ( words >= minWords ) pts . push ( last ); else if ( pts . length ) pts [ pts . length - 1 ] = ( pts [ pts . length - 1 ] + '\n' + last ). trim ();
}
return pts ;
})();
// Second pass: marker-based split anywhere in text (for bullets/numbering mid-line)
// Enumerated points: lines starting with "N." (1..12) capture blocks until next N.
const pointsEnumerated = (() => {
const starts = [];
const re = /(^|\n)\s*(\d{1,2})\.\s*/g ;
let m ;
// Always include start-of-text as a start if it begins with 1.
while (( m = re . exec ( normalized )) !== null ) {
starts . push ( m . index + ( m [ 1 ] ? m [ 1 ]. length : 0 ));
}
if ( starts . length === 0 ) return [];
starts . push ( normalized . length );
const out = [];
for ( let i = 0 ; i < starts . length - 1 ; i ++ ) {
const seg = normalized . slice ( starts [ i ], starts [ i + 1 ]). trim ();
if ( ! seg ) continue ;
const segLines = seg . split ( /\n/ ). map ( s => s . trim ()). filter ( s => s . length > 0 && ! isScriptureLine ( s ));
const text = segLines . join ( '\n' ). trim ();
if ( text && ! onlyPunct ( text )) out . push ( text );
}
return out ;
})();
const pointsBlank = (() => {
// Split on runs of >= 2 blank lines (user inserted multiple blanks between points)
const rawParts = normalized . split ( /\n\s*\n\s*\n+/ ); // 3+ blank lines
if ( rawParts . length <= 1 ) return [];
const out = [];
for ( const seg of rawParts ) {
const segLines = seg . split ( /\n/ ). map ( s => s . trim ()). filter ( s => s . length > 0 && ! isScriptureLine ( s ));
const text = segLines . join ( '\n' ). trim ();
if ( text && ! onlyPunct ( text )) out . push ( text );
}
return out ;
})();
// Bracketed blocks mode ONLY: each point is wrapped in matching brackets set by user
const pointsBrackets = (() => {
// STRICT: Only single square brackets [ ... ] delimit each point
const re = /\[([\s\S]*?)\]/g ;
const out = []; let m ;
while (( m = re . exec ( normalized )) !== null ) {
const seg = ( m [ 1 ] || '' ). trim ();
if ( ! seg ) continue ;
const segLines = seg . split ( /\n/ )
. map ( s => s . trim ())
. filter ( s => s . length > 0 && ! isScriptureLine ( s ));
const text = segLines . join ( '\n' ). trim ();
if ( text && ! onlyPunct ( text )) out . push ( text );
}
return out ;
})();
const pointsAdvanced = (() => {
const marker = requireMarker
? /(^|\n)\s*(?:[\u2022\u25CF\u25A0\u25CB\u25AA\u00B7\-–—]|\(?\d+\)|\d+[.)]|\(?[ivxIVX]+\)|[A-Z][.)])\s+/g
: /(^|\n)\s*(?:[\u2022\u25CF\u25A0\u25CB\u25AA\u00B7\-–—]|\(?\d+\)|\d+[.)]|\(?[ivxIVX]+\)|[A-Z][.)]|[^\n]{1,80}[:?])\s+/g ;
const indices = []; let m ;
// Always include start-of-text as a segment start
indices . push ( 0 );
while (( m = marker . exec ( normalized )) !== null ) {
indices . push ( m . index + ( m [ 1 ] ? m [ 1 ]. length : 0 ));
}
if ( indices . length <= 1 ) return [];
indices . push ( normalized . length );
const out = [];
for ( let i = 0 ; i < indices . length - 1 ; i ++ ) {
const seg = normalized . slice ( indices [ i ], indices [ i + 1 ]). trim ();
if ( ! seg ) continue ;
const segLines = seg . split ( /\n/ ). map ( s => s . trim ()). filter ( s => s . length > 0 && ! isScriptureLine ( s ));
const text = segLines . join ( '\n' ). trim ();
if ( text && ! onlyPunct ( text )) out . push ( text );
}
return out ;
})();
// Selection strategy
let chosen ;
if ( bracketOnly ) {
chosen = pointsBrackets ;
} else if ( pointsEnumerated . length >= 2 ) {
chosen = pointsEnumerated ;
} else if ( pointsBrackets . length >= 2 ) {
chosen = pointsBrackets ;
} else if ( pointsBlank . length >= 2 ) {
chosen = pointsBlank ;
} else {
chosen = pointsAdvanced . length > pointsBasic . length ? pointsAdvanced : pointsBasic ;
}
const junkPattern = /^(?:e?ftab\d+|tightenfactor[0-9a-z]*|fs\d+|cf\d+|li\d+|ri\d+|sa\d+|sb\d+|fi\d+|tx\d+)$/i ;
const isHeading = ( s ) => {
const t = s . trim ();
if ( ! t ) return true ;
if ( /[?:]$/ . test ( t )) return true ;
const letters = t . replace ( /[^A-Za-z]/g , '' );
const upp = letters . replace ( /[^A-Z]/g , '' ). length ;
const upperRatio = letters . length ? upp / letters . length : 0 ;
return upperRatio >= 0.6 && t . split ( /\s+/ ). length <= 12 ;
};
const cleanPoint = ( p ) => {
const lines = p . split ( /\n/ ). map ( s => ( s || '' ). trim ()). filter ( Boolean );
// Remove junk anywhere (not just prefix)
const noJunk = lines . filter ( l => ! junkPattern . test ( l ));
// Try to capture a title block: first 3 meaningful lines (allow scripture here)
const meaningful = noJunk . filter ( l => l . length > 1 );
const first3 = meaningful . slice ( 0 , 3 );
if ( first3 . length >= 3 ) {
const joined = first3 . join ( ' ' );
const looksTitle = /\bFighting\b/i . test ( joined ) && /\bSentences\b/i . test ( first3 [ 1 ]) && /\bIsaiah\b/i . test ( first3 [ 2 ]);
if ( looksTitle ) return first3 . join ( '\n' ). trim ();
}
// Otherwise, drop scripture lines and return remaining content
const withoutScripture = noJunk . filter ( l => ! isScriptureLine ( l ));
return withoutScripture . join ( '\n' ). trim ();
};
let cleaned = chosen
. map ( cleanPoint )
. filter ( s => s );
// Remove segments that clearly start with scripture (book name first line + Chapter:Verse)
cleaned = cleaned . filter ( seg => {
const firstLine = ( seg . split ( /\n/ ). find ( l => l . trim (). length ) || '' ). trim ();
if ( ! firstLine ) return false ;
if ( BOOKS_REGEX . test ( firstLine ) && /\b\d+\s*:\s*\d+/ . test ( firstLine )) return false ;
return ! isScriptureSegment ( seg );
});
// If first segment got filtered by scripture rules but contains a title pattern, restore its first lines
if ( cleaned . length === 0 && chosen . length > 0 ) {
const fallback = cleanPoint ( chosen [ 0 ]);
if ( fallback ) cleaned = [ fallback ];
}
return cleaned ;
}
function parseRtfSkippingBlue ( rtf ) {
// Extract color table entries in order (before stripping)
const colorTableMatch = rtf . match ( /\{\\colortbl([\s\S]*?)\}/ );
const colors = [{ r : 0 , g : 0 , b : 0 }]; // index 0 unused; entries start at 1 (after first ';')
if ( colorTableMatch ) {
const table = colorTableMatch [ 1 ] || '' ;
const entries = table . split ( ';' ). filter ( Boolean );
for ( const entry of entries ) {
const r = parseInt (( entry . match ( /\\red(\d+)/ ) || [])[ 1 ] || '0' , 10 );
const g = parseInt (( entry . match ( /\\green(\d+)/ ) || [])[ 1 ] || '0' , 10 );
const b = parseInt (( entry . match ( /\\blue(\d+)/ ) || [])[ 1 ] || '0' , 10 );
colors . push ({ r , g , b });
}
}
// Remove ignorable RTF groups: font table, color table (already parsed), stylesheet, info, and destinations
let text = rtf
. replace ( /\{\\fonttbl[\s\S]*?\}/g , '' )
. replace ( /\{\\colortbl[\s\S]*?\}/g , '' )
. replace ( /\{\\stylesheet[\s\S]*?\}/g , '' )
. replace ( /\{\\info[\s\S]*?\}/g , '' )
. replace ( /\{\\\*[^}]*\}/g , '' );
// Decode hex escapes next
text = text . replace ( /\\'[0-9a-fA-F]{2}/g , ( m )=>{
const hex = m . slice ( 2 );
try { return Buffer . from ( hex , 'hex' ). toString ( 'latin1' ); } catch { return ' ' ; }
});
// Replace paragraph markers with newlines
text = text . replace ( /\\par[d]?/g , '\n' );
// Now iterate and filter by current color index via \cfN
let out = '' ;
let idx = 0 ;
let i = 0 ;
let currentColor = 0 ;
const isBlue = ( c )=> c && c . b >= 180 && c . r <= 60 && c . g <= 80 ; // blue-ish heuristic
while ( i < text . length ) {
const ch = text [ i ];
if ( ch === '{' || ch === '}' ) { i ++ ; continue ; }
if ( ch === '\\' ) {
// control word
const m = text . slice ( i ). match ( /^\\([a-zA-Z]+)(-?\d+)?\s?/ );
if ( m ) {
const word = m [ 1 ];
const num = m [ 2 ] ? parseInt ( m [ 2 ], 10 ) : null ;
if ( word === 'cf' && num !== null ) {
currentColor = num ;
}
i += m [ 0 ]. length ;
continue ;
} else {
// standalone backslash or unknown; skip one
i ++ ;
continue ;
}
}
if ( ! isBlue ( colors [ currentColor ])) out += ch ;
i ++ ;
}
// Remove remaining control words/braces just in case and common header controls
out = out
. replace ( /\\[a-z]+-?\d*\s?/gi , '' )
. replace ( /[{}]/g , '' )
. replace ( /;+/g , '' )
. replace ( /[\r\t]/g , '' );
out = out . replace ( /\n{2,}/g , '\n' );
return out ;
}
// Extract points from RTF using highlighted runs (\highlightN > 0)
function extractHighlightedPointsFromRtf ( rtf ) {
// Keep original for control parsing; decode hex escapes early for readability
let src = rtf . replace ( /\\'[0-9a-fA-F]{2}/g , ( m )=>{
const hex = m . slice ( 2 );
try { return Buffer . from ( hex , 'hex' ). toString ( 'latin1' ); } catch { return ' ' ; }
});
let i = 0 ;
let highlight = 0 ; // 0 = none (\highlightN)
let cb = 0 ; // background color (\cbN)
let chcbpat = 0 ; // character background pattern (\chcbpatN)
let bold = false , italic = false ;
let current = '' ;
const out = [];
const pushCurrent = () => {
const text = ( current || '' ). split ( /\n/ )
. map ( s => s . trim ())
. filter ( s => s . length > 0 && ! isScriptureLine ( s ))
. join ( '\n' )
. trim ();
if ( text ) out . push ( text );
current = '' ;
};
while ( i < src . length ) {
const ch = src [ i ];
if ( ch === '{' || ch === '}' ) { i ++ ; continue ; }
if ( ch === '\\' ) {
// control word or symbol
const m = src . slice ( i ). match ( /^\\([a-zA-Z]+)(-?\d+)?\s?/ );
if ( m ) {
const word = m [ 1 ];
const num = m [ 2 ] ? parseInt ( m [ 2 ], 10 ) : null ;
if ( word === 'par' ) {
if ( highlight > 0 ) current += '\n' ;
} else if ( word === 'highlight' ) {
const prevActive = ( highlight > 0 ) || ( cb > 0 ) || ( chcbpat > 0 );
highlight = num != null ? num : 0 ;
const nowActive = ( highlight > 0 ) || ( cb > 0 ) || ( chcbpat > 0 );
if ( prevActive && ! nowActive ) pushCurrent ();
} else if ( word === 'cb' ) {
const prevActive = ( highlight > 0 ) || ( cb > 0 ) || ( chcbpat > 0 );
cb = num != null ? num : 0 ;
const nowActive = ( highlight > 0 ) || ( cb > 0 ) || ( chcbpat > 0 );
if ( prevActive && ! nowActive ) pushCurrent ();
} else if ( word === 'chcbpat' ) {
const prevActive = ( highlight > 0 ) || ( cb > 0 ) || ( chcbpat > 0 );
chcbpat = num != null ? num : 0 ;
const nowActive = ( highlight > 0 ) || ( cb > 0 ) || ( chcbpat > 0 );
if ( prevActive && ! nowActive ) pushCurrent ();
} else if ( word === 'b' ) {
bold = num === 0 ? false : true ;
} else if ( word === 'i' ) {
italic = num === 0 ? false : true ;
}
i += m [ 0 ]. length ;
continue ;
}
// unknown control; skip backslash
i ++ ;
continue ;
}
// text char
if (( highlight > 0 ) || ( cb > 0 ) || ( chcbpat > 0 )) current += ch ;
i ++ ;
}
if (( highlight > 0 ) || ( cb > 0 ) || ( chcbpat > 0 )) pushCurrent ();
return out ;
}
function extractBracketedPointsFromPlain ( text ) {
if ( ! text ) return [];
// Normalize to remove BOM/zero-width chars and convert fullwidth ASCII
let s = text . replace ( /\uFEFF/g , '' )
. replace ( /[\u200B-\u200D\u2060]/g , '' )
. replace ( /\u00A0/g , ' ' )
. normalize ( 'NFKC' );
// Map common CJK lenticular brackets to square brackets
s = s . replace ( /[\u3010]/g , '[' ). replace ( /[\u3011]/g , ']' );
// Allow optional whitespace around brackets; non-greedy capture
const re = /\[\s*([\s\S]*?)\s*\]/g ;
const out = []; let m ;
while (( m = re . exec ( s )) !== null ) {
const seg = ( m [ 1 ] || '' ). trim ();
if ( ! seg ) continue ;
const segLines = seg . split ( /\r?\n/ )
. map ( t => t . trim ())
. filter ( t => t . length > 0 && ! JUNK_LINE_RE . test ( t ));
const cleaned = cleanInlineArtifacts ( collapseParagraphs ( segLines . join ( '\n' )));
if ( cleaned ) out . push ( cleaned );
}
return out ;
}
// Robust: extract [ ... ] blocks directly from raw RTF, ignoring control words/groups
function extractBracketedPointsFromRtfRaw ( rtf ) {
if ( ! rtf ) return [];
let i = 0 ;
let capturing = false ;
let buf = '' ;
const out = [];
const push = () => {
const text = cleanInlineArtifacts ( collapseParagraphs (
buf . split ( /\n/ )
. map ( s => s . trim ())
. filter ( s => s . length > 0 && ! JUNK_LINE_RE . test ( s ))
. join ( '\n' )
. trim ()
));
if ( text ) out . push ( text );
buf = '' ;
};
while ( i < rtf . length ) {
const ch = rtf [ i ];
if ( ch === '\\' ) {
// RTF control sequence
// Hex escape \'hh
const mHex = rtf . slice ( i ). match ( /^\\'([0-9a-fA-F]{2})/ );
if ( mHex ) {
const code = parseInt ( mHex [ 1 ], 16 );
if ( capturing ) buf += String . fromCharCode ( code );
i += mHex [ 0 ]. length ;
continue ;
}
// Unicode \uN?
const mUni = rtf . slice ( i ). match ( /^\\u(-?\d+)\??/ );
if ( mUni ) {
const num = parseInt ( mUni [ 1 ], 10 );
const char = num >= 0 ? String . fromCharCode ( num ) : '' ;
if ( capturing && char ) buf += char ;
i += mUni [ 0 ]. length ;
continue ;
}
// Paragraph \par
const mPar = rtf . slice ( i ). match ( /^\\par\b/ );
if ( mPar ) {
if ( capturing ) buf += '\n' ;
i += mPar [ 0 ]. length ;
// optional trailing space
if ( rtf [ i ] === ' ' ) i ++ ;
continue ;
}
// Background/Highlight state changes – skip; handled implicitly by brackets
const mCtl = rtf . slice ( i ). match ( /^\\[a-zA-Z]+-?\d* ?/ );
if ( mCtl ) {
i += mCtl [ 0 ]. length ;
continue ;
}
// Single escaped char e.g. \{ or \}
i += 2 ;
continue ;
}
if ( ch === '{' || ch === '}' ) { i ++ ; continue ; }
if ( ch === '[' ) {
if ( ! capturing ) { capturing = true ; buf = '' ; i ++ ; continue ; }
// if already capturing, treat as literal
if ( capturing ) { buf += '[' ; i ++ ; continue ; }
}
if ( ch === ']' && capturing ) {
// end of block
i ++ ;
capturing = false ;
push ();
continue ;
}
if ( capturing ) buf += ch ;
i ++ ;
}
if ( capturing ) push ();
return out ;
}
function selectDefaultNotesFile () {
// Fallback chain: file listed in Notes/default.txt, otherwise Notes/slides.*, otherwise newest supported note asset
try {
const configured = path . join ( notesDir , 'default.txt' );
if ( fs . existsSync ( configured )) {
const name = fs . readFileSync ( configured , 'utf-8' ). trim ();
const p = path . join ( notesDir , name );
const splashEx = loadSplashExclusions ();
if ( name && fs . existsSync ( p ) && ! splashEx . has ( name . toLowerCase ())) return name ;
}
} catch {}
const candidates = fs . readdirSync ( notesDir )
. filter ( f => / \ .( pdf | rtf | key | png | jpe ? g | gif | bmp | webp ) $ / i . test ( f ))
. filter ( f => ! loadSplashExclusions (). has ( f . toLowerCase ()))
. map ( f => ({ f , t : fs . statSync ( path . join ( notesDir , f )). mtimeMs }))
. sort (( a , b )=> b . t - a . t );
const slidesPref = candidates . find ( x => /^ slides \ . / i . test ( x . f ));
if ( slidesPref ) return slidesPref . f ;
return candidates . length ? candidates [ 0 ]. f : '' ;
}
/*
* Follower link: a second screen (the booth machine feeding ProPresenter) mirrors this app by
* re-rendering published STATE, not video. See follower-link.js. Attached right after the server
* object exists so the upgrade handler is in place before anything can connect.
*/
let followerLink = null ;
const server = http . createServer ( async ( req , res ) => {
console . log ( ` ${ req . method } ${ req . url } ` );
// Log directory paths on startup (first request)
if ( ! server . _dirsLogged ) {
console . log ( `[SERVER] ========== DIRECTORY RESOLUTION ==========` );
console . log ( `[SERVER] Notes directory: ${ notesDir } ` );
console . log ( `[SERVER] Announcements directory: ${ announcementsDir } ` );
console . log ( `[SERVER] Graphics tab directory: ${ graphicsDir } ` );
console . log ( `[SERVER] __dirname: ${ __dirname } ` );
console . log ( `[SERVER] process.cwd(): ${ process . cwd () } ` );
console . log ( `[SERVER] process.execPath: ${ process . execPath || 'N/A' } ` );
console . log ( `[SERVER] BIBLE_APP_NOTES_DIR: ${ process . env . BIBLE_APP_NOTES_DIR || 'NOT SET' } ` );
console . log ( `[SERVER] BIBLE_APP_ANNOUNCEMENTS_DIR: ${ process . env . BIBLE_APP_ANNOUNCEMENTS_DIR || 'NOT SET' } ` );
console . log ( `[SERVER] BIBLE_APP_GRAPHICS_DIR: ${ process . env . BIBLE_APP_GRAPHICS_DIR || 'NOT SET' } ` );
// Verify directories exist and are writable
try {
const notesExists = fs . existsSync ( notesDir );
const notesWritable = notesExists ? (() => {
try {
const testFile = path . join ( notesDir , '.write_test' );
fs . writeFileSync ( testFile , 'test' );
fs . unlinkSync ( testFile );
return true ;
} catch { return false ; }
})() : false ;
console . log ( `[SERVER] Notes dir exists: ${ notesExists } , writable: ${ notesWritable } ` );
const annExists = fs . existsSync ( announcementsDir );
const annWritable = annExists ? (() => {
try {
const testFile = path . join ( announcementsDir , '.write_test' );
fs . writeFileSync ( testFile , 'test' );
fs . unlinkSync ( testFile );
return true ;
} catch { return false ; }
})() : false ;
console . log ( `[SERVER] Announcements dir exists: ${ annExists } , writable: ${ annWritable } ` );
const graphicsExists = fs . existsSync ( graphicsDir );
const graphicsWritable = graphicsExists ? (() => {
try {
const testFile = path . join ( graphicsDir , '.write_test' );
fs . writeFileSync ( testFile , 'test' );
fs . unlinkSync ( testFile );
return true ;
} catch { return false ; }
})() : false ;
console . log ( `[SERVER] Graphics dir exists: ${ graphicsExists } , writable: ${ graphicsWritable } ` );
} catch ( e ) {
console . error ( `[SERVER] Error checking directories:` , e );
}
console . log ( `[SERVER] ===========================================` );
server . _dirsLogged = true ;
}
// Debug endpoint to show directory info
if ( req . url === '/api/debug/directories' ) {
try {
const notesFiles = fs . existsSync ( notesDir ) ? fs . readdirSync ( notesDir ) : [];
const annFiles = fs . existsSync ( announcementsDir ) ? fs . readdirSync ( announcementsDir ) : [];
const graphicsFiles = fs . existsSync ( graphicsDir ) ? fs . readdirSync ( graphicsDir ) : [];
res . writeHead ( 200 , { 'Content-Type' : 'application/json' });
res . end ( JSON . stringify ({
notesDir ,
announcementsDir ,
graphicsDir ,
notesDirExists : fs . existsSync ( notesDir ),
announcementsDirExists : fs . existsSync ( announcementsDir ),
graphicsDirExists : fs . existsSync ( graphicsDir ),
notesFiles : notesFiles . slice ( 0 , 20 ),
announcementsFiles : annFiles . slice ( 0 , 20 ),
graphicsFiles : graphicsFiles . slice ( 0 , 20 ),
__dirname ,
cwd : process . cwd (),
execPath : process . execPath ,
envNotesDir : process . env . BIBLE_APP_NOTES_DIR || null ,
envAnnouncementsDir : process . env . BIBLE_APP_ANNOUNCEMENTS_DIR || null ,
envGraphicsDir : process . env . BIBLE_APP_GRAPHICS_DIR || null
}, null , 2 ));
} catch ( e ) {
res . writeHead ( 500 , { 'Content-Type' : 'application/json' });
res . end ( JSON . stringify ({ error : e . message }));
}
return ;
}
// Notes dir ensured at startup
// ===== ASK TAB \u2014 Whisper offline speech-to-text =====
// Accepts a raw WAV (16-bit PCM) POST body and returns the transcription.
// The renderer captures audio via the Web Audio API, encodes to WAV in
// JavaScript, and posts it here. We invoke the bundled whisper-cli.exe.
if ( req . url === '/api/ask/transcribe' && req . method === 'POST' ) {
const chunks = [];
let totalLen = 0 ;
const MAX = 20 * 1024 * 1024 ; // 20 MB cap (~10 min at 16-bit/16kHz)
req . on ( 'data' , ( chunk ) => {
totalLen += chunk . length ;
if ( totalLen > MAX ) {
try { req . destroy (); } catch ( _ ) {}
return ;
}
chunks . push ( chunk );
});
req . on ( 'end' , async () => {
const audioBuf = Buffer . concat ( chunks );
if ( ! audioBuf . length ) {
res . writeHead ( 400 , { 'Content-Type' : 'application/json' });
res . end ( JSON . stringify ({ ok : false , error : 'Empty audio body' }));
return ;
}
// Locate the bundled whisper binary + model. In the packaged
// app these live under app.asar.unpacked; in dev they live in the
// project tree.
const whisperCandidates = [
path . join ( process . resourcesPath || '' , 'app.asar.unpacked' , 'vendor' , 'whisper' ),
path . join ( __dirname , 'vendor' , 'whisper' )
];
let whisperDir = null ;
for ( const c of whisperCandidates ) {
try { if ( c && fs . existsSync ( path . join ( c , 'whisper-cli.exe' ))) { whisperDir = c ; break ; } } catch ( _ ) {}
}
if ( ! whisperDir ) {
res . writeHead ( 500 , { 'Content-Type' : 'application/json' });
res . end ( JSON . stringify ({ ok : false , error : 'Whisper binary not found on disk' }));
return ;
}
const exePath = path . join ( whisperDir , 'whisper-cli.exe' );
/*
* Best model on disk wins, so dropping a bigger file into vendor/whisper upgrades accuracy
* with no code change. tiny.en is the least accurate of the family and is only the floor.
*/
const MODEL_PREFERENCE = [
'ggml-medium.en.bin' , 'ggml-small.en.bin' , 'ggml-base.en.bin' , 'ggml-tiny.en.bin'
];
let modelPath = '' ;
for ( const name of MODEL_PREFERENCE ) {
const p = path . join ( whisperDir , name );
try { if ( fs . existsSync ( p )) { modelPath = p ; break ; } } catch ( _ ) {}
}
if ( ! modelPath || ! fs . existsSync ( modelPath )) {
res . writeHead ( 500 , { 'Content-Type' : 'application/json' });
res . end ( JSON . stringify ({ ok : false , error : 'Whisper model file missing' }));
return ;
}
// Persist audio to a temp WAV file for whisper-cli to read.
const tmpName = `bibleapp-ask- ${ Date . now () } - ${ Math . random (). toString ( 36 ). slice ( 2 ) } .wav` ;
const tmpWav = path . join ( os . tmpdir (), tmpName );
try {
fs . writeFileSync ( tmpWav , audioBuf );
} catch ( e ) {
res . writeHead ( 500 , { 'Content-Type' : 'application/json' });
res . end ( JSON . stringify ({ ok : false , error : 'Failed to write temp audio: ' + e . message }));
return ;
}
const cleanup = () => {
try { fs . unlinkSync ( tmpWav ); } catch ( _ ) {}
try { fs . unlinkSync ( tmpWav + '.txt' ); } catch ( _ ) {}
};
const child_process = require ( 'child_process' );
/*
* -nt no timestamps, -np no progress, -l en English, -sns suppress non-speech tokens so
* "[MUSIC PLAYING]"-style noise labels never reach the transcript at all.
*
* There used to be a long "--prompt" of Bible vocabulary here, meant to stop the tiny model
* mishearing domain words. It was doing the opposite and is the main reason the Ask tab kept
* answering "Could you say that again?": whisper treats the prompt as preceding context, so
* on quiet or empty audio it CONTINUES that devotional text instead of reporting silence.
* A/B on the identical near-silent recording:
* no prompt -> "[BLANK_AUDIO]" (an honest failure we can catch)
* with prompt -> "Amen." (invented, indistinguishable from a real answer)
* That fabricated text then went to the language model as the operator's question, which
* correctly judged it nonsense and asked for a repeat. Scripture vocabulary is still fed to
* the model itself through retrieveGroundingContext, where it cannot invent audio.
*/
const args = [ '-m' , modelPath , '-f' , tmpWav , '-nt' , '-np' , '-l' , 'en' , '-t' , '4' , '-sns' ];
const child = child_process . spawn ( exePath , args , { cwd : whisperDir , windowsHide : true });
let stdout = '' ;
let stderr = '' ;
child . stdout . on ( 'data' , ( d ) => { stdout += d . toString ( 'utf-8' ); });
child . stderr . on ( 'data' , ( d ) => { stderr += d . toString ( 'utf-8' ); });
const watchdog = setTimeout (() => { try { child . kill (); } catch ( _ ) {} }, 60000 );
child . on ( 'close' , ( code ) => {
clearTimeout ( watchdog );
cleanup ();
/*
* whisper-cli with -nt prints just the recognised text. Everything below exists to make
* a bad recording FAIL VISIBLY instead of quietly becoming a wrong question.
*
* The old filter only removed the exact tokens [BLANK_AUDIO]/[MUSIC]/[SOUND]/[NOISE], so
* "[MUSIC PLAYING]", "[ Silence ]", "(buzzing)", "Amen." and "You" all counted as a
* successful transcript and were sent to the model as the operator's question.
*/
const cleaned = stdout
. replace ( /[\[(][^\])]{0,40}[\])]/g , ' ' ) // any bracketed/parenthesised noise label
. replace ( /\s+/g , ' ' )
. trim ();
const words = cleaned . split ( ' ' ). filter ( Boolean );
// Single words and bare politeness are what whisper emits when it heard nothing useful.
const FILLER = /^(you|yeah|yes|no|ok|okay|amen|thank you|thanks|bye|hmm+|uh|um|the|a|and|so|oh)[.!?]*$/i ;
// One word is allowed when it is a substantial one ("Methuselah?"), because a terse name
// lookup is a real question; it is the SHORT single words that are always misfires.
const tooThin = ! cleaned
|| FILLER . test ( cleaned )
|| cleaned . length < 6
|| ( words . length < 2 && cleaned . length < 8 );
if ( ! tooThin ) {
res . writeHead ( 200 , { 'Content-Type' : 'application/json' });
res . end ( JSON . stringify ({ ok : true , text : cleaned }));
} else {
res . writeHead ( 200 , { 'Content-Type' : 'application/json' });
res . end ( JSON . stringify ({
ok : false ,
error : 'Didn’ t catch that — tap the mic and speak again' ,
heard : cleaned || '(silence)' ,
stderr : stderr . slice ( - 400 )
}));
}
});
child . on ( 'error' , ( err ) => {
clearTimeout ( watchdog );
cleanup ();
res . writeHead ( 500 , { 'Content-Type' : 'application/json' });
res . end ( JSON . stringify ({ ok : false , error : err . message }));
});
});
req . on ( 'error' , () => { try { res . end (); } catch ( _ ) {} });
return ;
}
// ===== ASK TAB (AI assistant) endpoints =====
// Health check: probes the bundled llama.cpp server first (fully offline),
// falls back to Ollama if a user installed it manually. The Ask tab uses
// this to render the right UI state.
if ( req . url === '/api/ask/health' ) {
const { probeLlamaHealth , getLlamaStatus , ensureLlamaStarting } = llamaLauncher ;
const probeOllama = () => new Promise (( resolve ) => {
const req2 = http . request ({ hostname : 'localhost' , port : 11434 , path : '/api/tags' , method : 'GET' , timeout : 1200 }, ( r2 ) => {
let body = '' ;
r2 . on ( 'data' , ( c ) => { body += c . toString ( 'utf-8' ); });
r2 . on ( 'end' , () => {
let models = [];
try { const j = JSON . parse ( body ); if ( j && Array . isArray ( j . models )) models = j . models . map ( m => m && m . name ). filter ( Boolean ); } catch ( _ ) {}
resolve ({ available : r2 . statusCode === 200 , models });
});
});
req2 . on ( 'timeout' , () => { try { req2 . destroy (); } catch ( _ ) {} resolve ({ available : false }); });
req2 . on ( 'error' , () => resolve ({ available : false }));
req2 . end ();
});
ensureLlamaStarting (). catch (() => {});
Promise . all ([ probeLlamaHealth (), probeOllama ()]). then (([ llama , ollama ]) => {
const status = getLlamaStatus ();
const llamaPort = status . port ;
res . writeHead ( 200 , { 'Content-Type' : 'application/json' , 'Cache-Control' : 'no-store' });
res . end ( JSON . stringify ({
ok : true ,
llama : llama . ready ,
llamaLoading : !! llama . loading ,
llamaPort ,
llamaError : status . lastError || null ,
llamaDirFound : !! status . dir ,
llamaLoadingSeconds : status . loadingSeconds || 0 ,
llamaHealthStatus : llama . status || status . lastHealthStatus || '' ,
llamaPhase : status . phase || '' ,
llamaCacheReady : !! status . cacheReady ,
llamaRestartAttempts : status . restartAttempts || 0 ,
llamaRecentLog : status . recentLog || '' ,
ollama : !! ollama . available ,
models : ollama . models || []
}));
}). catch (( e ) => {
res . writeHead ( 500 , { 'Content-Type' : 'application/json' });
res . end ( JSON . stringify ({ ok : false , error : e . message }));
});
return ;
}
// Streaming chat endpoint. Body: { provider, model, messages, apiKey?, baseUrl? }
// - provider 'ollama' (default) streams from local Ollama at localhost:11434
// - provider 'openai' streams from OpenAI-compatible API (uses apiKey + baseUrl)
// - provider 'anthropic' streams from Anthropic Messages API
// Output is a Server-Sent-Events-like stream of plain text chunks separated by
// newlines; the client just appends them as they arrive.
if ( req . url === '/api/ask/chat' && req . method === 'POST' ) {
let raw = '' ;
req . on ( 'data' , ( chunk ) => { raw += chunk . toString ( 'utf-8' ); if ( raw . length > 1024 * 256 ) { try { req . destroy (); } catch ( _ ) {} } });
req . on ( 'end' , async () => {
let payload = {};
try { payload = JSON . parse ( raw || '{}' ); } catch ( _ ) { payload = {}; }
const provider = String ( payload . provider || 'llama' ). toLowerCase ();
const model = String ( payload . model || ( provider === 'llama' ? 'local' : ( provider === 'ollama' ? 'llama3.2' : 'gpt-4o-mini' )));
const messages = Array . isArray ( payload . messages ) ? payload . messages : [];
const apiKey = typeof payload . apiKey === 'string' ? payload . apiKey : '' ;
const baseUrl = typeof payload . baseUrl === 'string' && payload . baseUrl ? payload . baseUrl : '' ;
if ( ! messages . length ) {
res . writeHead ( 400 , { 'Content-Type' : 'application/json' });
res . end ( JSON . stringify ({ ok : false , error : 'messages array required' }));
return ;
}
res . writeHead ( 200 , {
'Content-Type' : 'text/plain; charset=utf-8' ,
'Cache-Control' : 'no-store, no-cache, must-revalidate' ,
'Connection' : 'keep-alive' ,
'X-Accel-Buffering' : 'no'
});
const writeChunk = ( text ) => {
if ( ! text ) return ;
try { res . write ( text ); } catch ( _ ) {}
};
const finish = ( errMessage ) => {
if ( errMessage ) {
try { res . write ( `\n[ERROR] ${ errMessage } ` ); } catch ( _ ) {}
}
try { res . end (); } catch ( _ ) {}
};
try {
if ( provider === 'llama' ) {
// Bundled llama.cpp server speaks OpenAI-compatible streaming SSE.
const llamaPort = parseInt ( process . env . BIBLE_APP_LLAMA_PORT || '8090' , 10 );
// cache_prompt: llama-server reuses the KV cache for the unchanged prompt prefix
// (our big static system prompt), so repeat questions skip most prompt processing.
// max_tokens 200: answers are terse by design — don't let a rare ramble run long.
// prewarm: boot-time cache priming — generate just 1 token.
const isPrewarm = !! payload . prewarm ;
const body = JSON . stringify ({ model : 'local' , messages , stream : true , max_tokens : isPrewarm ? 1 : 200 , cache_prompt : true });
const upstream = http . request ({
hostname : '127.0.0.1' ,
port : llamaPort ,
path : '/v1/chat/completions' ,
method : 'POST' ,
headers : { 'Content-Type' : 'application/json' , 'Content-Length' : Buffer . byteLength ( body ) }
}, ( upRes ) => {
if ( upRes . statusCode && upRes . statusCode >= 400 ) {
let errBody = '' ;
upRes . on ( 'data' , ( c ) => { errBody += c . toString ( 'utf-8' ); });
upRes . on ( 'end' , () => finish ( `Local AI returned ${ upRes . statusCode } : ${ errBody . slice ( 0 , 400 ) } ` ));
return ;
}
let buf = '' ;
upRes . on ( 'data' , ( chunk ) => {
buf += chunk . toString ( 'utf-8' );
const events = buf . split ( '\n\n' );
buf = events . pop () || '' ;
for ( const ev of events ) {
const lines = ev . split ( '\n' ). map ( l => l . trim ()). filter ( Boolean );
for ( const ln of lines ) {
if ( ! ln . startsWith ( 'data:' )) continue ;
const data = ln . slice ( 5 ). trim ();
if ( ! data ) continue ;
if ( data === '[DONE]' ) { finish (); return ; }
try {
const obj = JSON . parse ( data );
const piece = obj && obj . choices && obj . choices [ 0 ] && ( obj . choices [ 0 ]. delta && obj . choices [ 0 ]. delta . content );
if ( typeof piece === 'string' ) writeChunk ( piece );
} catch ( _ ) {}
}
}
});
upRes . on ( 'end' , () => finish ());
upRes . on ( 'error' , ( err ) => finish ( err . message || 'upstream error' ));
});
upstream . on ( 'error' , ( err ) => finish ( `Local AI not reachable on port ${ llamaPort } . ( ${ err . message } )` ));
upstream . write ( body );
upstream . end ();
} else if ( provider === 'ollama' ) {
const body = JSON . stringify ({ model , messages , stream : true });
const ollamaReq = http . request ({
hostname : 'localhost' ,
port : 11434 ,
path : '/api/chat' ,
method : 'POST' ,
headers : { 'Content-Type' : 'application/json' , 'Content-Length' : Buffer . byteLength ( body ) }
}, ( ollamaRes ) => {
if ( ollamaRes . statusCode && ollamaRes . statusCode >= 400 ) {
let errBody = '' ;
ollamaRes . on ( 'data' , ( c ) => { errBody += c . toString ( 'utf-8' ); });
ollamaRes . on ( 'end' , () => {
finish ( `Ollama returned ${ ollamaRes . statusCode } : ${ errBody . slice ( 0 , 400 ) } ` );
});
return ;
}
let buf = '' ;
ollamaRes . on ( 'data' , ( chunk ) => {
buf += chunk . toString ( 'utf-8' );
let idx ;
while (( idx = buf . indexOf ( '\n' )) !== - 1 ) {
const line = buf . slice ( 0 , idx ). trim ();
buf = buf . slice ( idx + 1 );
if ( ! line ) continue ;
try {
const obj = JSON . parse ( line );
if ( obj && obj . message && typeof obj . message . content === 'string' ) {
writeChunk ( obj . message . content );
}
if ( obj && obj . done ) {
finish ();
return ;
}
} catch ( _ ) {}
}
});
ollamaRes . on ( 'end' , () => finish ());
ollamaRes . on ( 'error' , ( err ) => finish ( err . message || 'ollama stream error' ));
});
ollamaReq . on ( 'error' , ( err ) => {
finish ( `Cannot reach Ollama. Install it from https://ollama.com and run "ollama pull llama3.2". ( ${ err . message } )` );
});
ollamaReq . write ( body );
ollamaReq . end ();
} else if ( provider === 'openai' || provider === 'openai-compatible' ) {
if ( ! apiKey ) { finish ( 'Missing API key for OpenAI provider' ); return ; }
const target = baseUrl || 'https://api.openai.com' ;
const u = new URL ( '/v1/chat/completions' , target );
const body = JSON . stringify ({ model , messages , stream : true });
const isHttps = u . protocol === 'https:' ;
const lib = isHttps ? https : http ;
const upstream = lib . request ({
hostname : u . hostname ,
port : u . port || ( isHttps ? 443 : 80 ),
path : u . pathname + u . search ,
method : 'POST' ,
headers : {
'Content-Type' : 'application/json' ,
'Authorization' : `Bearer ${ apiKey } ` ,
'Content-Length' : Buffer . byteLength ( body )
}
}, ( upRes ) => {
if ( upRes . statusCode && upRes . statusCode >= 400 ) {
let errBody = '' ;
upRes . on ( 'data' , ( c ) => { errBody += c . toString ( 'utf-8' ); });
upRes . on ( 'end' , () => finish ( `Provider returned ${ upRes . statusCode } : ${ errBody . slice ( 0 , 400 ) } ` ));
return ;
}
let buf = '' ;
upRes . on ( 'data' , ( chunk ) => {
buf += chunk . toString ( 'utf-8' );
const events = buf . split ( '\n\n' );
buf = events . pop () || '' ;
for ( const ev of events ) {
const lines = ev . split ( '\n' ). map ( l => l . trim ()). filter ( Boolean );
for ( const ln of lines ) {
if ( ! ln . startsWith ( 'data:' )) continue ;
const data = ln . slice ( 5 ). trim ();
if ( ! data || data === '[DONE]' ) { if ( data === '[DONE]' ) finish (); continue ; }
try {
const obj = JSON . parse ( data );
const piece = obj && obj . choices && obj . choices [ 0 ] && ( obj . choices [ 0 ]. delta && obj . choices [ 0 ]. delta . content );
if ( typeof piece === 'string' ) writeChunk ( piece );
} catch ( _ ) {}
}
}
});
upRes . on ( 'end' , () => finish ());
upRes . on ( 'error' , ( err ) => finish ( err . message || 'upstream error' ));
});
upstream . on ( 'error' , ( err ) => finish ( err . message || 'request error' ));
upstream . write ( body );
upstream . end ();
} else if ( provider === 'anthropic' ) {
if ( ! apiKey ) { finish ( 'Missing API key for Anthropic provider' ); return ; }
const u = new URL ( '/v1/messages' , baseUrl || 'https://api.anthropic.com' );
const sysMsg = messages . find ( m => m && m . role === 'system' );
const convo = messages . filter ( m => m && m . role !== 'system' );
const body = JSON . stringify ({
model ,
max_tokens : 4096 ,
stream : true ,
system : sysMsg ? sysMsg . content : undefined ,
messages : convo
});
const isHttps = u . protocol === 'https:' ;
const lib = isHttps ? https : http ;
const upstream = lib . request ({
hostname : u . hostname ,
port : u . port || ( isHttps ? 443 : 80 ),
path : u . pathname + u . search ,
method : 'POST' ,
headers : {
'Content-Type' : 'application/json' ,
'x-api-key' : apiKey ,
'anthropic-version' : '2023-06-01' ,
'Content-Length' : Buffer . byteLength ( body )
}
}, ( upRes ) => {
if ( upRes . statusCode && upRes . statusCode >= 400 ) {
let errBody = '' ;
upRes . on ( 'data' , ( c ) => { errBody += c . toString ( 'utf-8' ); });
upRes . on ( 'end' , () => finish ( `Anthropic returned ${ upRes . statusCode } : ${ errBody . slice ( 0 , 400 ) } ` ));
return ;
}
let buf = '' ;
upRes . on ( 'data' , ( chunk ) => {
buf += chunk . toString ( 'utf-8' );
const events = buf . split ( '\n\n' );
buf = events . pop () || '' ;
for ( const ev of events ) {
const lines = ev . split ( '\n' ). map ( l => l . trim ()). filter ( Boolean );
for ( const ln of lines ) {
if ( ! ln . startsWith ( 'data:' )) continue ;
const data = ln . slice ( 5 ). trim ();
if ( ! data ) continue ;
try {
const obj = JSON . parse ( data );
if ( obj && obj . type === 'content_block_delta' && obj . delta && typeof obj . delta . text === 'string' ) {
writeChunk ( obj . delta . text );
} else if ( obj && obj . type === 'message_stop' ) {
finish ();
}
} catch ( _ ) {}
}
}
});
upRes . on ( 'end' , () => finish ());
upRes . on ( 'error' , ( err ) => finish ( err . message || 'upstream error' ));
});
upstream . on ( 'error' , ( err ) => finish ( err . message || 'request error' ));
upstream . write ( body );
upstream . end ();
} else {
finish ( `Unknown provider: ${ provider } ` );
}
} catch ( e ) {
finish ( e && e . message ? e . message : 'Server error' );
}
});
req . on ( 'error' , () => { try { res . end (); } catch ( _ ) {} });
return ;
}
// Control endpoints for remote actions (Bitfocus Companion / HTTP)
if ( req . url . startsWith ( '/api/control/' )) {
try {
// Gate HTTP control behind an env flag for security; disabled by default
const ENABLE_HTTP_CONTROL = String ( process . env . ENABLE_HTTP_CONTROL || '' ). toLowerCase ();
const httpControlOn = ENABLE_HTTP_CONTROL === '1' || ENABLE_HTTP_CONTROL === 'true' ;
if ( ! httpControlOn ) {
res . writeHead ( 403 , { 'Content-Type' : 'application/json' });
res . end ( JSON . stringify ({ ok : false , error : 'HTTP control disabled. Use OSC or set ENABLE_HTTP_CONTROL=1.' }));
return ;
}
const urlObj = new URL ( req . url , 'http://localhost' );
const pathOnly = urlObj . pathname || '' ;
const method = ( req . method || 'GET' ). toUpperCase ();
// Accept both GET and POST for convenience
const ok = ( data ) => {
res . writeHead ( 200 , { 'Content-Type' : 'application/json' });
res . end ( JSON . stringify ({ ok : true , ...( data || {}) }));
};
const fail = ( code , message ) => {
res . writeHead ( code || 400 , { 'Content-Type' : 'application/json' });
res . end ( JSON . stringify ({ ok : false , error : message || 'Bad Request' }));
};
const requireInvoker = () => {
if ( ! __rendererInvoker ) {
fail ( 503 , 'Renderer not ready' );
return false ;
}
return true ;
};
if ( ! requireInvoker ()) return ;
// Route map
if ( pathOnly === '/api/control/undo' && ( method === 'POST' || method === 'GET' )) {
invokeRenderer ( 'undo' );
return ok ();
}
if ( pathOnly === '/api/control/redo' && ( method === 'POST' || method === 'GET' )) {
invokeRenderer ( 'redo' );
return ok ();
}
if ( pathOnly === '/api/control/clear-current' && ( method === 'POST' || method === 'GET' )) {
invokeRenderer ( 'clear_current' );
return ok ();
}
if ( pathOnly === '/api/control/clear-all' && ( method === 'POST' || method === 'GET' )) {
invokeRenderer ( 'clear_all' );
return ok ();
}
if ( pathOnly === '/api/control/reset-app' && ( method === 'POST' || method === 'GET' )) {
invokeRenderer ( 'reset_app' );
return ok ();
}
if ( pathOnly === '/api/control/tab/close-active' && ( method === 'POST' || method === 'GET' )) {
invokeRenderer ( 'close_active_tab' );
return ok ();
}
if ( pathOnly === '/api/control/tool' && ( method === 'POST' || method === 'GET' )) {
const name = ( urlObj . searchParams . get ( 'name' ) || '' ). toLowerCase ();
if ( ! name ) return fail ( 400 , 'Missing tool name' );
if ( ! [ 'pen' , 'highlight' , 'erase' , 'select' ]. includes ( name )) return fail ( 400 , 'Unsupported tool' );
invokeRenderer ( 'tool' , { name });
return ok ({ name });
}
return fail ( 404 , 'Unknown control endpoint' );
} catch ( e ) {
res . writeHead ( 500 , { 'Content-Type' : 'application/json' });
res . end ( JSON . stringify ({ ok : false , error : 'Server error' , detail : String ( e && e . message || e ) }));
}
return ;
}
// Local update system: check / download / install builds from a folder or URL you control.
// Loopback-only (this is the operator's own machine driving its own install).
if ( req . url . startsWith ( '/api/update/' )) {
try {
const urlObj = new URL ( req . url , 'http://localhost' );
const pathOnly = urlObj . pathname || '' ;
const method = ( req . method || 'GET' ). toUpperCase ();
const ip = ( req . socket && req . socket . remoteAddress ) || '' ;
const ok = ( data ) => { res . writeHead ( 200 , { 'Content-Type' : 'application/json' }); res . end ( JSON . stringify ({ ok : true , ...( data || {}) })); };
const fail = ( code , message ) => { res . writeHead ( code || 400 , { 'Content-Type' : 'application/json' }); res . end ( JSON . stringify ({ ok : false , error : message || 'Bad Request' })); };
if ( ! isLoopbackIp ( ip )) return fail ( 403 , 'Local only' );
if ( pathOnly === '/api/update/status' && method === 'GET' ) {
const cfg = readUpdateConfig ();
return ok ({ version : appVersion (), source : cfg . source , autoCheck : cfg . autoCheck , lastCheck : cfg . lastCheck , lastResult : cfg . lastResult , job : __updateJob });
}
if ( pathOnly === '/api/update/check' && ( method === 'POST' || method === 'GET' )) {
const cfg = readUpdateConfig ();
if ( ! cfg . source ) return fail ( 400 , 'Set an update folder or address first.' );
try {
const man = await readUpdateManifest ( cfg . source );
const latest = String ( man . version || '' ). trim ();
const cur = appVersion ();
const available = latest && compareVersions ( latest , cur ) > 0 ;
writeUpdateConfig ({ lastCheck : Date . now (), lastResult : available ? ( 'Update ' + latest + ' available' ) : 'Up to date' });
return ok ({ current : cur , latest , updateAvailable : !! available , notes : man . notes || '' , file : man . file || '' , date : man . date || '' });
} catch ( e ) {
const msg = String ( e && e . message || e );
writeUpdateConfig ({ lastCheck : Date . now (), lastResult : 'Check failed: ' + msg });
return fail ( 502 , 'Could not read update info: ' + msg );
}
}
if ( pathOnly === '/api/update/install' && method === 'POST' ) {
const cfg = readUpdateConfig ();
if ( ! cfg . source ) return fail ( 400 , 'No update source configured' );
if ( __updateJob . state === 'working' ) return ok ({ job : __updateJob });
let man ;
try { man = await readUpdateManifest ( cfg . source ); } catch ( e ) { return fail ( 502 , 'Could not read update info: ' + String ( e && e . message || e )); }
const file = String ( man . file || '' ). trim ();
const version = String ( man . version || '' ). trim ();
if ( ! file ) return fail ( 400 , 'Update info is missing the installer file name' );
if ( compareVersions ( version , appVersion ()) <= 0 ) return ok ({ updateAvailable : false , message : 'Already up to date' });
const dest = path . join ( os . tmpdir (), 'bibleapp-update-' + version + '-' + path . basename ( file ));
__updateJob = { state : 'working' , pct : 0 , message : 'Preparing…' , file , version };
// Respond immediately; the transfer continues in the background (installers are ~1.4 GB).
ok ({ started : true , version , file });
( async () => {
try {
if ( isHttpSource ( cfg . source )) {
const base = cfg . source . replace ( /\/+$/ , '' );
__updateJob . message = 'Downloading…' ;
await downloadToFile ( base + '/' + encodeURIComponent ( file ), dest , ( done , total ) => {
__updateJob . pct = total ? Math . round (( done / total ) * 100 ) : 0 ;
});
} else {
const src = path . join ( cfg . source , file );
if ( ! fs . existsSync ( src )) throw new Error ( 'Installer not found: ' + src );
__updateJob . message = 'Copying…' ;
const total = fs . statSync ( src ). size ;
await new Promise (( resolve , reject ) => {
const rs = fs . createReadStream ( src );
const ws = fs . createWriteStream ( dest );
let done = 0 ;
rs . on ( 'data' , ( c ) => { done += c . length ; __updateJob . pct = total ? Math . round (( done / total ) * 100 ) : 0 ; });
rs . on ( 'error' , reject ); ws . on ( 'error' , reject );
ws . on ( 'finish' , resolve );
rs . pipe ( ws );
});
}
__updateJob . message = 'Checking the download…' ;
await verifyTransfer ( dest , man . size , man . sha256 , 'The installer' );
__updateJob = { state : 'ready' , pct : 100 , message : 'Starting installer…' , file , version };
setTimeout (() => {
try {
if ( __appControl && typeof __appControl . runInstaller === 'function' ) __appControl . runInstaller ( dest );
else __updateJob = { state : 'error' , pct : 100 , message : 'Installer downloaded to ' + dest + ' — run it manually.' , file , version };
} catch ( e ) {
__updateJob = { state : 'error' , pct : 100 , message : 'Could not start installer: ' + String ( e && e . message || e ), file , version };
}
}, 800 );
} catch ( e ) {
__updateJob = { state : 'error' , pct : 0 , message : String ( e && e . message || e ), file , version };
}
})();
return ;
}
// Config read/write
if ( pathOnly === '/api/update/config' && method === 'POST' ) {
let body = '' ;
req . on ( 'data' , c => { body += c . toString (); });
req . on ( 'end' , () => {
try {
const d = JSON . parse ( body || '{}' );
const saved = writeUpdateConfig ({ source : d . source , autoCheck : d . autoCheck });
return ok ({ source : saved . source , autoCheck : saved . autoCheck });
} catch ( e ) { return fail ( 400 , 'Invalid JSON' ); }
});
return ;
}
// ---- Local AI model provisioning (the ~1.9 GB model ships beside the installer) ----
if ( pathOnly === '/api/update/model-status' && method === 'GET' ) {
// Report the model the app will actually load — bundled with the installer OR
// installed by hand. Reporting only the hand-installed copy made machines with a
// perfectly good bundled model claim the AI was missing.
const info = typeof llamaLauncher . getModelInfo === 'function'
? llamaLauncher . getModelInfo ()
: { ready : llamaLauncher . isCachedModelReady (), source : 'installed' , path : llamaLauncher . getCachedModelPath (), sizeMB : 0 };
return ok ({
installed : ! llamaLauncher . isModelMissing (),
ready : info . ready ,
source : info . source ,
path : info . path ,
sizeMB : info . sizeMB ,
expectedMB : Math . round ( llamaLauncher . EXPECTED_MODEL_BYTES / 1048576 ),
job : __modelJob
});
}
if ( pathOnly === '/api/update/model-install' && method === 'POST' ) {
let body = '' ;
req . on ( 'data' , c => { body += c . toString (); });
req . on ( 'end' , () => {
let d = {};
try { d = JSON . parse ( body || '{}' ); } catch ( _ ) {}
if ( __modelJob . state === 'working' ) return ok ({ job : __modelJob });
const cfg = readUpdateConfig ();
// Explicit path wins; otherwise take model.gguf from the update folder/URL.
let src = String ( d . source || '' ). trim ();
if ( ! src ) {
if ( ! cfg . source ) return fail ( 400 , 'Set an update folder in Settings > Updates first, or give the model file path.' );
src = isHttpSource ( cfg . source ) ? ( cfg . source . replace ( /\/+$/ , '' ) + '/model.gguf' ) : path . join ( cfg . source , 'model.gguf' );
}
const dest = llamaLauncher . getCachedModelPath ();
__modelJob = { state : 'working' , pct : 0 , message : 'Preparing…' };
ok ({ started : true , source : src });
( async () => {
try {
try { fs . mkdirSync ( path . dirname ( dest ), { recursive : true }); } catch ( _ ) {}
const tmp = dest + '.part' ;
if ( isHttpSource ( src )) {
__modelJob . message = 'Downloading AI model…' ;
await downloadToFile ( src , tmp , ( done , total ) => { __modelJob . pct = total ? Math . round (( done / total ) * 100 ) : 0 ; });
} else {
if ( ! fs . existsSync ( src )) throw new Error ( 'Model file not found: ' + src );
__modelJob . message = 'Copying AI model…' ;
const total = fs . statSync ( src ). size ;
await new Promise (( resolve , reject ) => {
const rs = fs . createReadStream ( src );
const ws = fs . createWriteStream ( tmp );
let done = 0 ;
rs . on ( 'data' , ( c ) => { done += c . length ; __modelJob . pct = total ? Math . round (( done / total ) * 100 ) : 0 ; });
rs . on ( 'error' , reject ); ws . on ( 'error' , reject );
ws . on ( 'finish' , resolve );
rs . pipe ( ws );
});
}
// Verify BEFORE the swap so a bad transfer never replaces a working model.
__modelJob . message = 'Checking the model file…' ;
let expSize , expSha ;
try { const m = await readUpdateManifest ( cfg . source ); expSize = m . modelSize ; expSha = m . modelSha256 ; } catch ( _ ) {}
await verifyTransfer ( tmp , expSize , expSha , 'The AI model' );
try { if ( fs . existsSync ( dest )) fs . unlinkSync ( dest ); } catch ( _ ) {}
fs . renameSync ( tmp , dest ); // atomic swap: a half-copied model is never used
__modelJob = { state : 'done' , pct : 100 , message : 'AI model installed. Starting…' };
try { llamaLauncher . ensureLlamaStarting (); } catch ( _ ) {}
} catch ( e ) {
try { const t = llamaLauncher . getCachedModelPath () + '.part' ; if ( fs . existsSync ( t )) fs . unlinkSync ( t ); } catch ( _ ) {}
__modelJob = { state : 'error' , pct : 0 , message : String ( e && e . message || e ) };
}
})();
});
return ;
}
return fail ( 404 , 'Unknown update endpoint' );
} catch ( e ) {
try { res . writeHead ( 500 , { 'Content-Type' : 'application/json' }); res . end ( JSON . stringify ({ ok : false , error : String ( e && e . message || e ) })); } catch ( _ ) {}
}
return ;
}
// Phone remote: mobile web control served over the church LAN, gated by a PIN set in Settings.
// Config read/write is loopback-only (the operator's own machine); actions require the PIN.
/*
* ---- Admin console API (/api/admin/*) ----
*
* The admin console is an OFFSTAGE copy of the app: it runs in the admin's own browser, so
* nothing they do there is rendered on the TV. These endpoints are the only bridge back to the
* live stage, and every one of them is deliberately narrow:
*
* status public is admin turned on / does it have a PIN (for the login screen only)
* auth PIN verify the admin PIN
* live-state PIN READ the TV's current state (never writes) — powers the side-by-side
* diff and the "clone live into my copy" button
* push PIN perform ONE named action on the TV
* config loopback Settings saves the admin PIN / enable flag
*
* `push` is intentionally per-action rather than a "publish my whole state" call. A bulk
* overwrite would replace whatever the operator has on screen mid-service; one action at a time
* means the admin pushes the single fix they meant to and nothing else moves.
*/
if ( req . url . startsWith ( '/api/admin/' )) {
try {
const urlObj = new URL ( req . url , 'http://localhost' );
const pathOnly = urlObj . pathname || '' ;
const method = ( req . method || 'GET' ). toUpperCase ();
const ip = ( req . socket && req . socket . remoteAddress ) || '' ;
const ok = ( data ) => { res . writeHead ( 200 , { 'Content-Type' : 'application/json' }); res . end ( JSON . stringify ({ ok : true , ...( data || {}) })); };
const fail = ( code , message , extra ) => { res . writeHead ( code || 400 , { 'Content-Type' : 'application/json' }); res . end ( JSON . stringify ({ ok : false , error : message || 'Bad Request' , ...( extra || {}) })); };
const denyMsg = ( reason ) => reason === 'disabled' ? 'Admin access is turned off' : ( reason === 'locked' ? 'Too many attempts — wait a moment' : ( reason === 'no-pin' ? 'No admin PIN set yet' : 'Wrong PIN' ));
/*
* What the admin may push to the live TV.
*
* Same vocabulary as the phone remote MINUS the mirror gestures (mirror_scroll /
* mirror_draw / mirror_tap): those replay raw finger input against whatever happens to be
* under the coordinates on the TV right now. The admin is looking at their own copy, not
* the TV, so those coordinates mean nothing here and would land on the wrong element.
* Admin pushes are all named, intentional state changes.
*/
const ADMIN_PUSH_ACTIONS = new Set ([
// Passage + navigation
'gotoPassage' , 'chapterPrev' , 'chapterNext' ,
'scroll_top' , 'scroll_bottom' , 'scroll_up_5' , 'scroll_down_5' ,
// Tabs
'switchToMode' , 'switchTabId' , 'switch_tab_left' , 'switch_tab_right' ,
'new_bible_tab' , 'new_whiteboard_tab' , 'new_notes_tab' ,
'close_tab' , 'close_active_tab' , 'showMaps' ,
// Presentation
'splash_show' , 'splash_hide' , 'splashToggle' , 'presentOff' ,
'laser' , 'spotlight' , 'redLetterToggle' ,
// Drawing
'tool' , 'pen_color' , 'undo' , 'redo' , 'clear_current' , 'clear_all' ,
// Toolbar dock
'dockFloat' , 'dockRail' ,
// Settings + profiles
'setSetting' , 'applyProfile' ,
// Last resort
'reset_app'
]);
// Public: the login screen needs to know whether admin is even available.
if ( pathOnly === '/api/admin/status' && method === 'GET' ) {
const cfg = readRemoteConfig ();
return ok ({ enabled : !! cfg . adminEnabled , hasPin : !! cfg . adminPin , v : appVersion () });
}
/*
* ---- Content staging ----
*
* The point of the admin console is to prepare content WITHOUT the stage reacting.
* A normal upload writes straight into the live content directory, where the Graphics
* or Maps tab picks it up immediately — so admin uploads land in a staging directory
* instead, and only a deliberate commit moves them into the live set.
*
* ADD — copy the staged files in alongside what is already live.
* REPLACE — the staged set becomes the live set. Only offered for the three types
* with their OWN directory. Splash and Notes SHARE notesDir and overlap on
* image extensions, so there is no way to tell one type's images from the
* other's; a replace there would silently delete the other type's files.
* Add-only for those, deliberately.
*
* Replaced files are never deleted outright — they move to a timestamped folder under
* the staging root so a mistake mid-service is recoverable.
*/
const STAGE_TYPES = {
graphics : { dir : graphicsDir , exts : /\.(png|jpe?g|gif|bmp|webp|svg|avif|mp4|m4v|webm|mov)$/i , canReplace : true , label : 'Graphics' },
maps : { dir : mapsDir , exts : /\.(png|jpe?g|gif|bmp|webp|svg|avif)$/i , canReplace : true , label : 'Maps' },
announcements : { dir : announcementsDir , exts : /\.(png|jpe?g|gif|bmp|webp|svg|avif|mp4|m4v|webm|mov)$/i , canReplace : true , label : 'Announcements' },
splash : { dir : notesDir , exts : /\.(png|jpe?g|gif|bmp|webp)$/i , canReplace : false , label : 'Splash screens' },
notes : { dir : notesDir , exts : /\.(pdf|rtf|key|png|jpe?g|gif|bmp|webp)$/i , canReplace : false , label : 'Notes' }
};
const stageRoot = () => {
const root = resolveUserDataRoot () || __dirname ;
return path . join ( root , 'admin-staging' );
};
const stageDirFor = ( type ) => path . join ( stageRoot (), path . basename ( String ( type || '' )));
const listDir = ( dir , exts ) => {
try {
if ( ! fs . existsSync ( dir )) return [];
return fs . readdirSync ( dir )
. filter (( f ) => exts . test ( f ))
. map (( name ) => {
let st = null ;
try { st = fs . statSync ( path . join ( dir , name )); } catch ( _ ) {}
return { name , size : st ? st . size : 0 , mtimeMs : st ? st . mtimeMs : 0 };
})
. sort (( a , b ) => b . mtimeMs - a . mtimeMs );
} catch ( _ ) { return []; }
};
/*
* Is `name` a content file this type is allowed to touch?
*
* path.basename() stops a path escaping the directory, but that is NOT enough on its
* own: "../../default.txt" collapses to "default.txt", which is a REAL file inside the
* content directory, and for the splash/notes types that directory is notesDir — home
* to remote-config.json (the admin PIN itself), profiles.json, osc-config.json and
* splash-config.json. Without this check a delete could take out the app's own
* configuration. The per-type extension list admits only media/document files, so it
* excludes every .txt and .json in there.
*/
const isContentFile = ( spec , name ) => !! name && name === path . basename ( name ) && spec . exts . test ( name );
// Never overwrite inside a directory: mirror the live uploader's " (n)" de-duplication.
const uniquePath = ( dir , name ) => {
let target = path . join ( dir , name );
if ( ! fs . existsSync ( target )) return target ;
const ext = path . extname ( name );
const base = path . basename ( name , ext );
let i = 1 ;
while ( fs . existsSync ( path . join ( dir , ` ${ base } ( ${ i } ) ${ ext } ` ))) i ++ ;
return path . join ( dir , ` ${ base } ( ${ i } ) ${ ext } ` );
};
/*
* Staged upload. The PIN rides in a header, not the body: the body IS the file.
* Handled before the JSON body reader below, which would otherwise consume the bytes.
*/
if ( pathOnly === '/api/admin/stage/upload' && method === 'POST' ) {
const r = adminPinOk ( ip , req . headers [ 'x-admin-pin' ]);
if ( ! r . ok ) return fail ( r . reason === 'locked' ? 429 : 401 , denyMsg ( r . reason ), { reason : r . reason });
const type = String ( req . headers [ 'x-stage-type' ] || '' ). toLowerCase ();
const spec = STAGE_TYPES [ type ];
if ( ! spec ) return fail ( 400 , 'Unknown content type' );
const rawName = String ( req . headers [ 'x-filename' ] || 'upload' );
const safeName = decodeURIComponent ( rawName ). replace ( /[^a-zA-Z0-9._ ()-]/g , '_' );
if ( ! spec . exts . test ( safeName )) return fail ( 400 , `That file type is not allowed for ${ spec . label } ` );
const dir = stageDirFor ( type );
try { fs . mkdirSync ( dir , { recursive : true }); } catch ( _ ) {}
const target = uniquePath ( dir , safeName );
const chunks = [];
let bytes = 0 , aborted = false ;
const MAX = 200 * 1024 * 1024 ; // videos are allowed here; the live cap is 50MB for images
req . on ( 'data' , ( c ) => {
if ( aborted ) return ;
bytes += c . length ;
if ( bytes > MAX ) { aborted = true ; try { fail ( 413 , 'File too large (200 MB max)' ); } catch ( _ ) {} req . destroy (); return ; }
chunks . push ( c );
});
req . on ( 'end' , async () => {
if ( aborted ) return ;
try {
fs . writeFileSync ( target , Buffer . concat ( chunks ));
// Same courtesy as the live uploader: a phone photo is far too small for a
// 2160x3840 panel, so upscale now rather than at commit time.
let upscaled = null ;
try {
const up = await upscaler . enhanceIfSmall ( target );
if ( up && up . status === 'enhanced' ) upscaled = ` ${ up . from } -> ${ up . to } ` ;
} catch ( _ ) {}
const st = fs . statSync ( target );
return ok ({ file : path . basename ( target ), size : st . size , upscaled });
} catch ( e ) {
return fail ( 500 , 'Could not save the staged file' , { detail : String ( e && e . message || e ) });
}
});
req . on ( 'error' , () => { try { res . end (); } catch ( _ ) {} });
return ;
}
let body = '' ;
req . on ( 'data' , c => { body += c . toString (); if ( body . length > 1e6 ) { try { req . destroy (); } catch ( _ ) {} } });
req . on ( 'end' , () => {
let data = {};
try { data = JSON . parse ( body || '{}' ); } catch ( _ ) { return fail ( 400 , 'Invalid JSON' ); }
// Loopback-only: Settings saves the admin enable flag + PIN.
if ( pathOnly === '/api/admin/config' && method === 'POST' ) {
if ( ! isLoopbackIp ( ip )) return fail ( 403 , 'Local only' );
const saved = writeRemoteConfig ({ adminEnabled : data . enabled , adminPin : data . pin });
return ok ({ enabled : !! saved . adminEnabled , hasPin : !! saved . adminPin });
}
if ( pathOnly === '/api/admin/auth' && method === 'POST' ) {
const r = adminPinOk ( ip , data . pin );
if ( r . ok ) return ok ({ auth : true , v : appVersion () });
return fail ( r . reason === 'locked' ? 429 : 401 , denyMsg ( r . reason ), { reason : r . reason });
}
/*
* READ-ONLY view of what the TV is showing right now, from the snapshot the stage
* renderer pushes to /api/remote/app-state. `stale` matters: if the renderer has gone
* quiet the admin must not be shown an old picture and told it is live.
*/
if ( pathOnly === '/api/admin/live-state' && method === 'POST' ) {
const r = adminPinOk ( ip , data . pin );
if ( ! r . ok ) return fail ( r . reason === 'locked' ? 429 : 401 , denyMsg ( r . reason ), { reason : r . reason });
const ageMs = __remoteAppState ? ( Date . now () - __remoteAppStateTs ) : null ;
return ok ({
state : __remoteAppState ,
ageMs ,
stale : ageMs == null || ageMs > 8000 ,
rendererReady : typeof __rendererInvoker === 'function' ,
v : appVersion ()
});
}
// Staged + live listing for one content type, plus what the live default is.
if ( pathOnly === '/api/admin/stage/list' && method === 'POST' ) {
const r = adminPinOk ( ip , data . pin );
if ( ! r . ok ) return fail ( r . reason === 'locked' ? 429 : 401 , denyMsg ( r . reason ), { reason : r . reason });
const spec = STAGE_TYPES [ String ( data . type || '' ). toLowerCase ()];
if ( ! spec ) return fail ( 400 , 'Unknown content type' );
let liveDefault = '' ;
try {
const dp = path . join ( spec . dir , 'default.txt' );
if ( fs . existsSync ( dp )) liveDefault = ( fs . readFileSync ( dp , 'utf-8' ). split ( /\r?\n/ ). find (( l ) => l . trim ()) || '' ). trim ();
} catch ( _ ) {}
return ok ({
type : String ( data . type ). toLowerCase (),
label : spec . label ,
canReplace : spec . canReplace ,
staged : listDir ( stageDirFor ( data . type ), spec . exts ),
live : listDir ( spec . dir , spec . exts ),
liveDefault
});
}
/*
* Choose which ALREADY-LIVE file is the one on screen. The per-type
* /api/<type>/set-default endpoint does the same job but is ungated; this keeps
* every stage-changing operation behind the admin PIN and in one audit log.
*/
if ( pathOnly === '/api/admin/stage/live-default' && method === 'POST' ) {
const r = adminPinOk ( ip , data . pin );
if ( ! r . ok ) return fail ( r . reason === 'locked' ? 429 : 401 , denyMsg ( r . reason ), { reason : r . reason });
const type = String ( data . type || '' ). toLowerCase ();
const spec = STAGE_TYPES [ type ];
if ( ! spec ) return fail ( 400 , 'Unknown content type' );
const name = path . basename ( String ( data . name || '' ));
if ( ! isContentFile ( spec , name )) return fail ( 400 , `Not a ${ spec . label } content file` );
if ( ! fs . existsSync ( path . join ( spec . dir , name ))) return fail ( 404 , 'That file is not live' );
try { fs . writeFileSync ( path . join ( spec . dir , 'default.txt' ), name + '\n' ); }
catch ( e ) { return fail ( 500 , 'Could not set it' , { detail : String ( e && e . message || e ) }); }
try { console . log ( `[ADMIN] live-default ${ type } = ${ name } from ${ ip } ` ); } catch ( _ ) {}
return ok ({ type , name });
}
/*
* Remove one live file. Retired to the same recovery folder a replace uses rather
* than unlinked, so pulling the wrong slide mid-service is undoable.
*/
if ( pathOnly === '/api/admin/stage/live-delete' && method === 'POST' ) {
const r = adminPinOk ( ip , data . pin );
if ( ! r . ok ) return fail ( r . reason === 'locked' ? 429 : 401 , denyMsg ( r . reason ), { reason : r . reason });
const type = String ( data . type || '' ). toLowerCase ();
const spec = STAGE_TYPES [ type ];
if ( ! spec ) return fail ( 400 , 'Unknown content type' );
const name = path . basename ( String ( data . name || '' ));
if ( ! isContentFile ( spec , name )) return fail ( 400 , `Not a ${ spec . label } content file` );
const src = path . join ( spec . dir , name );
if ( ! fs . existsSync ( src )) return fail ( 404 , 'That file is not live' );
try {
const bin = path . join ( stageRoot (), '_replaced' , type , String ( Date . now ()));
fs . mkdirSync ( bin , { recursive : true });
try { fs . renameSync ( src , path . join ( bin , name )); }
catch ( _ ) { fs . copyFileSync ( src , path . join ( bin , name )); fs . unlinkSync ( src ); }
// If it was the one on screen, drop the pointer so the app re-derives one.
const dp = path . join ( spec . dir , 'default.txt' );
try {
if ( fs . existsSync ( dp )) {
const cur = ( fs . readFileSync ( dp , 'utf-8' ). split ( /\r?\n/ ). find (( l ) => l . trim ()) || '' ). trim ();
if ( cur . toLowerCase () === name . toLowerCase ()) fs . unlinkSync ( dp );
}
} catch ( _ ) {}
try { console . log ( `[ADMIN] live-delete ${ type } ${ name } from ${ ip } ` ); } catch ( _ ) {}
return ok ({ type , removed : name , recoverable : true });
} catch ( e ) {
return fail ( 500 , 'Could not remove it' , { detail : String ( e && e . message || e ) });
}
}
if ( pathOnly === '/api/admin/stage/delete' && method === 'POST' ) {
const r = adminPinOk ( ip , data . pin );
if ( ! r . ok ) return fail ( r . reason === 'locked' ? 429 : 401 , denyMsg ( r . reason ), { reason : r . reason });
const spec = STAGE_TYPES [ String ( data . type || '' ). toLowerCase ()];
if ( ! spec ) return fail ( 400 , 'Unknown content type' );
const name = path . basename ( String ( data . name || '' ));
// Same guard as the live operations: only ever a content file of this type.
if ( ! isContentFile ( spec , name )) return fail ( 400 , `Not a ${ spec . label } content file` );
const p = path . join ( stageDirFor ( data . type ), name );
try { if ( fs . existsSync ( p )) fs . unlinkSync ( p ); } catch ( e ) { return fail ( 500 , 'Could not remove' , { detail : String ( e && e . message || e ) }); }
return ok ({ removed : name });
}
/*
* Commit staged files into the live set. This is the only operation here that the
* congregation can see the result of, so it is explicit and it is logged.
*/
if ( pathOnly === '/api/admin/stage/commit' && method === 'POST' ) {
const r = adminPinOk ( ip , data . pin );
if ( ! r . ok ) return fail ( r . reason === 'locked' ? 429 : 401 , denyMsg ( r . reason ), { reason : r . reason });
const type = String ( data . type || '' ). toLowerCase ();
const spec = STAGE_TYPES [ type ];
if ( ! spec ) return fail ( 400 , 'Unknown content type' );
const mode = data . mode === 'replace' ? 'replace' : 'add' ;
if ( mode === 'replace' && ! spec . canReplace ) {
return fail ( 400 , ` ${ spec . label } shares a folder with another content type, so it is add-only` );
}
const stageDir = stageDirFor ( type );
const staged = listDir ( stageDir , spec . exts );
if ( ! staged . length ) return fail ( 400 , 'Nothing staged to push' );
const moved = [];
const retired = [];
try {
fs . mkdirSync ( spec . dir , { recursive : true });
if ( mode === 'replace' ) {
// Retire, never delete: a wrong replace mid-service must be recoverable.
const bin = path . join ( stageRoot (), '_replaced' , type , String ( Date . now ()));
fs . mkdirSync ( bin , { recursive : true });
for ( const f of listDir ( spec . dir , spec . exts )) {
try {
fs . renameSync ( path . join ( spec . dir , f . name ), path . join ( bin , f . name ));
retired . push ( f . name );
} catch ( _ ) {
try { fs . copyFileSync ( path . join ( spec . dir , f . name ), path . join ( bin , f . name )); fs . unlinkSync ( path . join ( spec . dir , f . name )); retired . push ( f . name ); } catch ( _ ) {}
}
}
// The old default/order refer to files that are gone.
for ( const meta of [ 'default.txt' , ` ${ type } -order.json` , 'graphics-order.json' , 'announcements-order.json' , 'maps-order.json' ]) {
try { const mp = path . join ( spec . dir , meta ); if ( fs . existsSync ( mp )) fs . unlinkSync ( mp ); } catch ( _ ) {}
}
}
for ( const f of staged ) {
const src = path . join ( stageDir , f . name );
const dest = uniquePath ( spec . dir , f . name );
try { fs . renameSync ( src , dest ); }
catch ( _ ) { fs . copyFileSync ( src , dest ); fs . unlinkSync ( src ); } // different volume
moved . push ( path . basename ( dest ));
}
// Optionally make one of the just-pushed files the one on screen.
let madeDefault = '' ;
const want = data . setDefault ? path . basename ( String ( data . setDefault )) : '' ;
if ( want ) {
const match = moved . find (( m ) => m === want ) || moved . find (( m ) => m . toLowerCase () === want . toLowerCase ()) || '' ;
if ( match ) {
try { fs . writeFileSync ( path . join ( spec . dir , 'default.txt' ), match + '\n' ); madeDefault = match ; } catch ( _ ) {}
}
}
try { console . log ( `[ADMIN] commit ${ type } mode= ${ mode } added= ${ moved . length } retired= ${ retired . length } default= ${ madeDefault || '-' } from ${ ip } ` ); } catch ( _ ) {}
return ok ({ type , mode , added : moved , retired , madeDefault });
} catch ( e ) {
return fail ( 500 , 'Commit failed' , { detail : String ( e && e . message || e ), added : moved , retired });
}
}
if ( pathOnly === '/api/admin/push' && method === 'POST' ) {
const r = adminPinOk ( ip , data . pin );
if ( ! r . ok ) return fail ( r . reason === 'locked' ? 429 : 401 , denyMsg ( r . reason ), { reason : r . reason });
const action = String ( data . action || '' ). trim ();
if ( ! ADMIN_PUSH_ACTIONS . has ( action )) return fail ( 400 , 'Unsupported action' );
if ( ! __rendererInvoker ) return fail ( 503 , 'App not ready' );
const payload = ( data . payload && typeof data . payload === 'object' ) ? data . payload : {};
invokeRenderer ( action , payload );
try { console . log ( `[ADMIN] push ${ action } ${ JSON . stringify ( payload ) } from ${ ip } ` ); } catch ( _ ) {}
return ok ({ action , payload });
}
return fail ( 404 , 'Unknown admin endpoint' );
});
req . on ( 'error' , () => { try { res . end (); } catch ( _ ) {} });
} catch ( e ) {
try { res . writeHead ( 500 , { 'Content-Type' : 'application/json' }); res . end ( JSON . stringify ({ ok : false , error : 'Server error' , detail : String ( e && e . message || e ) })); } catch ( _ ) {}
}
return ;
}
// ---- ProPresenter Sync: live state for the app, settings for the stage machine ----
// Follower relay health — is the booth screen actually connected and receiving? Plus the address
// to open on the booth machine, computed on THIS machine right now.
if ( req . url . startsWith ( '/api/follower/status' )) {
res . writeHead ( 200 , { 'Content-Type' : 'application/json' , 'Cache-Control' : 'no-store' });
const st = followerLink ? followerLink . status () : { error : 'relay not started' };
res . end ( JSON . stringify ({ ... st , enabled : readFollowerConfig (). enabled !== false , urls : followerUrls (), port : activeServerPort || PORT , version : appVersion () }));
return ;
}
// The Settings switch. Loopback only: the stage machine's own Settings window may change it, nothing on the LAN.
if ( req . url . startsWith ( '/api/follower/config' )) {
const ipf = ( req . socket && req . socket . remoteAddress ) || '' ;
const method = ( req . method || 'GET' ). toUpperCase ();
if ( ! isLoopbackIp ( ipf )) { res . writeHead ( 403 , { 'Content-Type' : 'application/json' }); res . end ( JSON . stringify ({ ok : false , error : 'Local only' })); return ; }
if ( method === 'GET' ) {
res . writeHead ( 200 , { 'Content-Type' : 'application/json' , 'Cache-Control' : 'no-store' });
res . end ( JSON . stringify ({ ok : true , config : readFollowerConfig (), urls : followerUrls () }));
return ;
}
if ( method !== 'POST' ) { res . writeHead ( 405 , { 'Content-Type' : 'application/json' }); res . end ( JSON . stringify ({ ok : false , error : 'Method not allowed' })); return ; }
let fbody = '' ;
req . on ( 'data' , c => { fbody += c . toString (); if ( fbody . length > 1e4 ) { try { req . destroy (); } catch ( _ ) {} } });
req . on ( 'end' , () => {
let data = {};
try { data = JSON . parse ( fbody || '{}' ); } catch ( _ ) { res . writeHead ( 400 , { 'Content-Type' : 'application/json' }); res . end ( JSON . stringify ({ ok : false , error : 'Invalid JSON' })); return ; }
const cfg = readFollowerConfig ();
if ( typeof data . enabled === 'boolean' ) cfg . enabled = data . enabled ;
const saved = writeFollowerConfig ( cfg );
try { if ( followerLink ) followerLink . applyEnabled (); } catch ( _ ) {}
console . log ( '[FOLLOWER] settings saved: enabled=' + cfg . enabled );
res . writeHead ( saved ? 200 : 500 , { 'Content-Type' : 'application/json' });
res . end ( JSON . stringify ({ ok : saved , config : cfg , urls : followerUrls () }));
});
return ;
}
if ( req . url . startsWith ( '/api/propresenter/' )) {
try {
const urlObj = new URL ( req . url , 'http://localhost' );
const pathOnly = urlObj . pathname || '' ;
const method = ( req . method || 'GET' ). toUpperCase ();
const ip = ( req . socket && req . socket . remoteAddress ) || '' ;
const ok = ( data ) => { res . writeHead ( 200 , { 'Content-Type' : 'application/json' , 'Cache-Control' : 'no-store' }); res . end ( JSON . stringify ({ ok : true , ...( data || {}) })); };
const fail = ( code , message , extra ) => { res . writeHead ( code || 400 , { 'Content-Type' : 'application/json' }); res . end ( JSON . stringify ({ ok : false , error : message || 'Bad Request' , ...( extra || {}) })); };
// Live state: one JSON snapshot now, another on every change, comment heartbeats between.
if ( pathOnly === '/api/propresenter/events' && method === 'GET' ) {
res . writeHead ( 200 , { 'Content-Type' : 'text/event-stream' , 'Cache-Control' : 'no-store' , 'Connection' : 'keep-alive' , 'X-Accel-Buffering' : 'no' });
res . write ( 'retry: 1500\n\n' );
res . write ( 'data: ' + JSON . stringify ( ppLink . snapshot ()) + '\n\n' );
__ppSseClients . add ( res );
const drop = () => { __ppSseClients . delete ( res ); };
req . on ( 'close' , drop ); res . on ( 'close' , drop ); res . on ( 'error' , drop );
return ;
}
if ( pathOnly === '/api/propresenter/state' && method === 'GET' ) return ok ({ state : ppLink . snapshot () });
// Settings are loopback-only: the stage machine's own window may change them, nothing on the LAN.
if ( pathOnly === '/api/propresenter/config' && method === 'GET' ) {
if ( ! isLoopbackIp ( ip )) return fail ( 403 , 'Local only' );
return ok ({ config : ppLink . publicConfig (), secureStore : ppLink . _secretsAvailable () });
}
if ( method !== 'POST' ) return fail ( 404 , 'Not found' );
let body = '' ;
req . on ( 'data' , c => { body += c . toString (); if ( body . length > 1e5 ) { try { req . destroy (); } catch ( _ ) {} } });
req . on ( 'end' , async () => {
let data = {};
try { data = JSON . parse ( body || '{}' ); } catch ( _ ) { return fail ( 400 , 'Invalid JSON' ); }
if ( ! isLoopbackIp ( ip )) return fail ( 403 , 'Local only' );
if ( pathOnly === '/api/propresenter/config' ) {
const r = ppLink . saveConfig ( data );
console . log ( '[PPSYNC] config saved:' , JSON . stringify ( r . config ));
return ok ({ config : r . config , note : r . note , state : ppLink . snapshot () });
}
// Test uses the FORM values, not the saved ones, so the operator can try before committing.
if ( pathOnly === '/api/propresenter/test' ) {
try {
const pw = ( typeof data . password === 'string' && data . password ) ? data . password : null ;
const result = await ProPresenterLink . test ( data , pw , null , ppLink );
console . log ( '[PPSYNC] test' , data . host + ':' + data . port , '->' , result . ok ? ( 'ok ' + result . kind ) : ( 'fail: ' + result . error ));
return ok ({ result });
} catch ( e ) { return fail ( 500 , 'Test failed: ' + ( e && e . message || e )); }
}
return fail ( 404 , 'Not found' );
});
} catch ( e ) {
try { res . writeHead ( 500 , { 'Content-Type' : 'application/json' }); res . end ( JSON . stringify ({ ok : false , error : 'Server error' , detail : String ( e && e . message || e ) })); } catch ( _ ) {}
}
return ;
}
if ( req . url . startsWith ( '/api/remote/' )) {
try {
const urlObj = new URL ( req . url , 'http://localhost' );
const pathOnly = urlObj . pathname || '' ;
const method = ( req . method || 'GET' ). toUpperCase ();
const ip = ( req . socket && req . socket . remoteAddress ) || '' ;
const ok = ( data ) => { res . writeHead ( 200 , { 'Content-Type' : 'application/json' }); res . end ( JSON . stringify ({ ok : true , ...( data || {}) })); };
const fail = ( code , message , extra ) => { res . writeHead ( code || 400 , { 'Content-Type' : 'application/json' }); res . end ( JSON . stringify ({ ok : false , error : message || 'Bad Request' , ...( extra || {}) })); };
const denyMsg = ( reason ) => reason === 'disabled' ? 'Remote is turned off' : ( reason === 'locked' ? 'Too many attempts — wait a moment' : ( reason === 'no-pin' ? 'No PIN set yet' : 'Wrong PIN' ));
// Safe subset of control:perform the remote is allowed to trigger.
// clear_all + the four scroll actions are handled in app.js; they were unreachable from the
// phone until they were listed here.
const REMOTE_ACTIONS = new Set ([ 'chapterPrev' , 'chapterNext' , 'gotoPassage' , 'splash_show' , 'splashToggle' , 'presentOff' , 'laser' , 'spotlight' , 'redLetterToggle' , 'showMaps' , 'switchToMode' , 'switchTabId' , 'tool' , 'undo' , 'redo' , 'clear_current' , 'clear_all' , 'switch_tab_left' , 'switch_tab_right' , 'applyProfile' , 'new_bible_tab' , 'scroll_top' , 'scroll_bottom' , 'scroll_up_5' , 'scroll_down_5' , 'close_tab' , 'mirror_scroll' , 'mirror_draw' , 'splash_hide' , 'mirror_tap' ]);
// Public (no PIN): lets the phone page show a friendly on/off state.
// `v` is the app version: the phone remembers the version it booted against and reloads
// itself when this changes, so an updated TV can never keep talking to a STALE phone page
// (a Home-Screen web app resumes its old JS from memory across app updates otherwise).
if ( pathOnly === '/api/remote/status' && method === 'GET' ) {
const cfg = readRemoteConfig ();
return ok ({ enabled : !! cfg . enabled , hasPin : !! cfg . pin , v : appVersion () });
}
// Loopback-only: Settings reads current config + the LAN URLs to show.
if ( pathOnly === '/api/remote/info' && method === 'GET' ) {
if ( ! isLoopbackIp ( ip )) return fail ( 403 , 'Local only' );
const cfg = readRemoteConfig ();
return ok ({
enabled : !! cfg . enabled , hasPin : !! cfg . pin , port : PORT , urls : lanRemoteUrls (),
adminEnabled : !! cfg . adminEnabled , adminHasPin : !! cfg . adminPin ,
adminUrls : lanRemoteUrls ( 'admin.html' )
});
}
// Everything else is POST with a JSON body.
let body = '' ;
req . on ( 'data' , c => { body += c . toString (); if ( body . length > 1e6 ) { try { req . destroy (); } catch ( _ ) {} } });
req . on ( 'end' , () => {
let data = {};
try { data = JSON . parse ( body || '{}' ); } catch ( _ ) { return fail ( 400 , 'Invalid JSON' ); }
// Loopback-only: the renderer pushes its live UI snapshot here (remote-state.js).
if ( pathOnly === '/api/remote/app-state' && method === 'POST' ) {
if ( ! isLoopbackIp ( ip )) return fail ( 403 , 'Local only' );
if ( data && typeof data === 'object' ) { __remoteAppState = data ; __remoteAppStateTs = Date . now (); }
return ok ({});
}
// Phone: current app state (PIN-gated) — tabs in order, active tab/tool, splash, red-letter.
// `v` rides along on every poll so a phone left open through a TV update notices within
// a second and reloads itself onto the matching page.
if ( pathOnly === '/api/remote/state' && method === 'POST' ) {
const rs = remotePinOk ( ip , data . pin );
if ( ! rs . ok ) return fail ( rs . reason === 'locked' ? 429 : 401 , denyMsg ( rs . reason ), { reason : rs . reason });
return ok ({ state : __remoteAppState , ageMs : __remoteAppState ? ( Date . now () - __remoteAppStateTs ) : null , v : appVersion () });
}
// Loopback-only: Settings saves enable flag + PIN.
if ( pathOnly === '/api/remote/config' && method === 'POST' ) {
if ( ! isLoopbackIp ( ip )) return fail ( 403 , 'Local only' );
const saved = writeRemoteConfig ({ enabled : data . enabled , pin : data . pin });
return ok ({ enabled : saved . enabled , hasPin : !! saved . pin });
}
// Phone: verify PIN before showing controls.
if ( pathOnly === '/api/remote/auth' && method === 'POST' ) {
const r = remotePinOk ( ip , data . pin );
if ( r . ok ) return ok ({ auth : true });
return fail ( r . reason === 'locked' ? 429 : 401 , denyMsg ( r . reason ), { reason : r . reason });
}
// Phone: perform a whitelisted action (PIN required every time).
if ( pathOnly === '/api/remote/action' && method === 'POST' ) {
const r = remotePinOk ( ip , data . pin );
if ( ! r . ok ) return fail ( r . reason === 'locked' ? 429 : 401 , denyMsg ( r . reason ), { reason : r . reason });
const action = String ( data . action || '' ). trim ();
if ( ! REMOTE_ACTIONS . has ( action )) return fail ( 400 , 'Unsupported action' );
if ( ! __rendererInvoker ) return fail ( 503 , 'App not ready' );
const payload = ( data . payload && typeof data . payload === 'object' ) ? data . payload : {};
invokeRenderer ( action , payload );
return ok ({ action });
}
/*
* Phone: one JPEG of the live stage screen for the TV view (PIN required every time).
*
* POST, not GET, on purpose: a GET would put the PIN in a query string, where it lands in
* logs and history. That does mean the phone cannot point an <img src> at this — it fetches
* the blob and sets an object URL, which is also what lets it pace frames and never keep
* two requests in flight.
*/
if ( pathOnly === '/api/remote/frame' && method === 'POST' ) {
const r = remotePinOk ( ip , data . pin );
if ( ! r . ok ) return fail ( r . reason === 'locked' ? 429 : 401 , denyMsg ( r . reason ), { reason : r . reason });
if ( ! __appControl || typeof __appControl . captureScreen !== 'function' ) return fail ( 503 , 'Screen capture not available' );
Promise . resolve ( __appControl . captureScreen ( data . width , data . quality ))
. then (( frame ) => {
// Nothing painted since the phone's last frame: a 204 tells it to keep the
// picture it has — a few bytes instead of ~100 KB of identical JPEG.
if ( frame && frame . unchanged ) { res . writeHead ( 204 , { 'Cache-Control' : 'no-store' }); res . end (); return ; }
if ( ! frame || ! frame . jpeg ) return fail ( 503 , 'No frame' ); // capture busy or window gone
const frameHeaders = {
'Content-Type' : 'image/jpeg' ,
'Content-Length' : frame . jpeg . length ,
'Cache-Control' : 'no-store' ,
'X-Frame-Width' : String ( frame . width ),
'X-Frame-Height' : String ( frame . height )
};
// Exact scroll position of THIS frame (fraction of the window height), and
// the page's scroll limit — lets the phone place content perfectly and stop
// scrolls at the true end of the chapter.
if ( typeof frame . sc === 'number' && isFinite ( frame . sc )) frameHeaders [ 'X-Scroll-S' ] = String ( frame . sc );
if ( typeof frame . sm === 'number' && isFinite ( frame . sm )) frameHeaders [ 'X-Scroll-M' ] = String ( frame . sm );
if ( typeof frame . iv === 'number' && isFinite ( frame . iv )) frameHeaders [ 'X-Ink-V' ] = String ( frame . iv );
res . writeHead ( 200 , frameHeaders );
res . end ( frame . jpeg );
})
. catch (( e ) => fail ( 500 , 'Capture failed' , { detail : String ( e && e . message || e ) }));
return ;
}
return fail ( 404 , 'Unknown remote endpoint' );
});
req . on ( 'error' , () => { try { res . end (); } catch ( _ ) {} });
} catch ( e ) {
try { res . writeHead ( 500 , { 'Content-Type' : 'application/json' }); res . end ( JSON . stringify ({ ok : false , error : 'Server error' , detail : String ( e && e . message || e ) })); } catch ( _ ) {}
}
return ;
}
// UI-driven OSC state feedback endpoint (active tool, momentary actions).
if ( req . url . startsWith ( '/api/osc/state-feedback' )) {
const method = ( req . method || 'GET' ). toUpperCase ();
if ( method !== 'POST' ) {
res . writeHead ( 405 , { 'Content-Type' : 'application/json' });
res . end ( JSON . stringify ({ ok : false , error : 'Method not allowed' }));
return ;
}
try {
let body = '' ;
req . on ( 'data' , chunk => { body += chunk . toString (); });
req . on ( 'end' , () => {
try {
const data = JSON . parse ( body || '{}' );
const kind = String ( data . kind || '' ). trim (). toLowerCase ();
const ok = ( payload ) => {
res . writeHead ( 200 , { 'Content-Type' : 'application/json' });
res . end ( JSON . stringify ({ ok : true , ...( payload || {}) }));
};
const fail = ( code , message ) => {
res . writeHead ( code || 400 , { 'Content-Type' : 'application/json' });
res . end ( JSON . stringify ({ ok : false , error : message || 'Bad Request' }));
};
if ( ! kind ) return fail ( 400 , 'Missing kind' );
if ( kind === 'tool_active' ) {
const tool = String ( data . tool || '' ). trim (). toLowerCase ();
const tools = [ 'pen' , 'highlight' , 'erase' , 'select' ];
if ( ! tools . includes ( tool )) return fail ( 400 , 'Invalid tool' );
oscStateUiCache . activeTool = tool ;
tools . forEach (( name ) => {
sendOscStateFeedback ( `state/tool_ ${ name } ` , name === tool ? 1 : 0 );
});
scheduleOscStateSnapshot ( null , 'ui:tool_active' );
return ok ({ kind , tool });
}
if ( kind === 'action_pulse' ) {
const action = String ( data . action || '' ). trim (). toLowerCase ();
if ( ! action ) return fail ( 400 , 'Missing action' );
const rawMs = Number ( data . durationMs );
const pulseMs = Number . isFinite ( rawMs )
? Math . max ( 100 , Math . min ( 5000 , Math . round ( rawMs )))
: OSC_STATE_FEEDBACK_PULSE_MS ;
const command = `state/action_ ${ action } ` ;
sendOscStateFeedback ( command , 1 );
setTimeout (() => {
sendOscStateFeedback ( command , 0 );
}, pulseMs );
scheduleOscStateSnapshot ( null , `ui:action_ ${ action } ` );
return ok ({ kind , action , pulseMs });
}
if ( kind === 'pen_color_active' ) {
const hex = String ( data . colorHex || '' ). trim (). toLowerCase ();
if ( ! /^[0-9a-f]{6}$/ . test ( hex )) return fail ( 400 , 'Invalid colorHex (expected 6-digit hex)' );
const prev = oscStateUiCache . penColorHex ;
if ( prev && prev !== hex ) {
sendOscStateFeedback ( `state/pen_color/ ${ prev } ` , 0 );
}
oscStateUiCache . penColorHex = hex ;
sendOscStateFeedback ( `state/pen_color/ ${ hex } ` , 1 );
scheduleOscStateSnapshot ( null , 'ui:pen_color_active' );
return ok ({ kind , colorHex : hex , previous : prev || null });
}
if ( kind === 'splash_visible' ) {
const visible = !! data . visible ;
if ( oscStateUiCache . splashVisible === visible ) {
return ok ({ kind , visible , deduped : true });
}
oscStateUiCache . splashVisible = visible ;
sendOscStateFeedback ( 'state/splash_visible' , visible ? 1 : 0 );
scheduleOscStateSnapshot ( null , 'ui:splash_visible' );
return ok ({ kind , visible });
}
/*
* Which tab is up. The app pushes this on every tab switch, tab-row rebuild and
* passage load; it is what makes "he has moved to a different tab" visible from the
* booth. Feeds the information line only - no per-tab OSC paths are invented here,
* because a tab row that changes shape would leave stale ones lit on Companion.
*/
if ( kind === 'active_tab' ) {
const clip = ( v , n ) => {
const s = String ( v == null ? '' : v ). trim ();
return s ? s . slice ( 0 , n ) : null ;
};
const idx = Number ( data . index );
const count = Number ( data . count );
// The slug builds an OSC path, so it is accepted only when it names a kind that
// actually exists - never echoed straight from the client into a path.
const rawSlug = String ( data . slug || '' ). trim (). toLowerCase ();
const slug = OSC_TAB_KINDS . includes ( rawSlug ) ? rawSlug : null ;
const next = {
tabIndex : Number . isInteger ( idx ) && idx > 0 ? idx : null ,
tabCount : Number . isInteger ( count ) && count > 0 ? count : null ,
tabKind : clip ( data . kind_ , 24 ),
tabSlug : slug ,
tabPassage : clip ( data . passage , 48 ),
tabTranslation : clip ( data . translation , 12 )
};
const changed = Object . keys ( next ). some ( k => oscStateUiCache [ k ] !== next [ k ]);
if ( ! changed ) return ok ({ kind , deduped : true });
Object . assign ( oscStateUiCache , next );
scheduleOscStateSnapshot ( null , 'ui:active_tab' );
return ok ({ kind , ... next });
}
return fail ( 400 , `Unsupported kind: ${ kind } ` );
} catch ( e ) {
res . writeHead ( 400 , { 'Content-Type' : 'application/json' });
res . end ( JSON . stringify ({ ok : false , error : 'Invalid JSON' , detail : String ( e && e . message || e ) }));
}
});
return ;
} catch ( e ) {
res . writeHead ( 500 , { 'Content-Type' : 'application/json' });
res . end ( JSON . stringify ({ ok : false , error : 'Server error' , detail : String ( e && e . message || e ) }));
return ;
}
}
// API endpoints for OSC configuration
if ( req . url . startsWith ( '/api/osc/' )) {
try {
const urlObj = new URL ( req . url , 'http://localhost' );
const pathOnly = urlObj . pathname || '' ;
const method = ( req . method || 'GET' ). toUpperCase ();
const ok = ( data ) => {
res . writeHead ( 200 , { 'Content-Type' : 'application/json' });
res . end ( JSON . stringify ({ ok : true , ...( data || {}) }));
};
const fail = ( code , message ) => {
res . writeHead ( code || 400 , { 'Content-Type' : 'application/json' });
res . end ( JSON . stringify ({ ok : false , error : message || 'Bad Request' }));
};
// Get OSC config
if ( pathOnly === '/api/osc/config' && method === 'GET' ) {
try {
const cfg = readOscConfig ();
// Don't expose token in response for security
const safeCfg = { ... cfg };
if ( safeCfg . token ) {
safeCfg . token = safeCfg . token . length > 0 ? '***' : '' ;
}
return ok ({ config : safeCfg , hasToken : cfg . token && cfg . token . length > 0 });
} catch ( e ) {
return fail ( 500 , 'Failed to read OSC config: ' + String ( e && e . message || e ));
}
}
// Update OSC config
if ( pathOnly === '/api/osc/config' && method === 'POST' ) {
try {
let body = '' ;
req . on ( 'data' , chunk => { body += chunk . toString (); });
req . on ( 'end' , () => {
try {
const updates = JSON . parse ( body );
const cfgPath = path . join ( notesDir , 'osc-config.json' );
let currentCfg = readOscConfig ();
// Merge updates (preserve existing values if not provided)
if ( typeof updates . address === 'string' && updates . address . trim ()) {
currentCfg . address = updates . address . trim ();
}
if ( typeof updates . port === 'number' && updates . port > 0 && updates . port < 65536 ) {
currentCfg . port = updates . port ;
}
if ( Array . isArray ( updates . allow )) {
currentCfg . allow = updates . allow . map ( s => String ( s ). trim ()). filter ( Boolean );
}
if ( Array . isArray ( updates . allowPrefixes )) {
currentCfg . allowPrefixes = updates . allowPrefixes . map ( s => String ( s ). trim ()). filter ( Boolean );
}
if ( typeof updates . token === 'string' ) {
// Only update token if provided (empty string clears it)
currentCfg . token = updates . token . trim ();
}
if ( typeof updates . accordancePort === 'number' && updates . accordancePort > 0 ) {
currentCfg . accordancePort = updates . accordancePort ;
}
if ( typeof updates . feedbackEnabled === 'boolean' ) {
currentCfg . feedbackEnabled = updates . feedbackEnabled ;
}
if ( typeof updates . feedbackAddress === 'string' ) {
currentCfg . feedbackAddress = updates . feedbackAddress . trim () || null ;
}
if ( typeof updates . feedbackPort === 'number' && updates . feedbackPort > 0 && updates . feedbackPort < 65536 ) {
currentCfg . feedbackPort = updates . feedbackPort ;
} else if ( updates . feedbackPort === null || updates . feedbackPort === '' ) {
currentCfg . feedbackPort = null ;
}
if ( Array . isArray ( updates . feedbackInformation )) {
// An empty array is a real choice: it switches the information line off.
currentCfg . feedbackInformation = sanitizeOscInformationFields ( updates . feedbackInformation );
lastOscInformationLine = null ; // resend on the next change, in the new shape
}
if ( typeof updates . feedbackTabStates === 'boolean' ) {
currentCfg . feedbackTabStates = updates . feedbackTabStates ;
}
// Write config file
fs . writeFileSync ( cfgPath , JSON . stringify ( currentCfg , null , 2 ) + '\n' );
// Restart OSC listener with new config
try {
// Note: We can't easily restart OSC without refactoring, but we can log
console . log ( '[OSC] Config updated. Restart app to apply changes to OSC listener.' );
} catch ( e ) {
console . warn ( '[OSC] Failed to restart listener:' , e );
}
// Return safe config (without token)
const safeCfg = { ... currentCfg };
if ( safeCfg . token ) {
safeCfg . token = safeCfg . token . length > 0 ? '***' : '' ;
}
return ok ({ config : safeCfg , message : 'Config updated. Restart app to apply OSC changes.' });
} catch ( e ) {
return fail ( 400 , 'Invalid JSON: ' + String ( e && e . message || e ));
}
});
return ;
} catch ( e ) {
return fail ( 500 , 'Failed to update OSC config: ' + String ( e && e . message || e ));
}
}
// Get OSC status
if ( pathOnly === '/api/osc/status' && method === 'GET' ) {
try {
const cfg = readOscConfig ();
const status = {
moduleInstalled : osc !== null ,
listening : oscListening ,
portOpen : oscPort !== null ,
configured : true ,
address : cfg . address ,
port : cfg . port ,
hasToken : cfg . token && cfg . token . length > 0 ,
allowedIPs : cfg . allow || [],
allowedPrefixes : cfg . allowPrefixes || [],
rendererReady : typeof __rendererInvoker === 'function' ,
notesDir : notesDir ,
configPath : path . join ( notesDir , 'osc-config.json' ),
configExists : fs . existsSync ( path . join ( notesDir , 'osc-config.json' )),
feedbackEnabled : cfg . feedbackEnabled !== false ,
feedbackAddress : cfg . feedbackAddress || 'auto-detect' ,
feedbackPort : cfg . feedbackPort || 'auto-detect' ,
cachedFeedbackAddress : oscFeedbackRuntime . lastSenderAddress || null ,
cachedFeedbackPort : oscFeedbackRuntime . lastSenderPort || null ,
cachedFeedbackLastSeenAt : oscFeedbackRuntime . lastSenderSeenAt || null ,
cachedFeedbackSource : oscFeedbackRuntime . lastSenderSource || null
};
return ok ({ status });
} catch ( e ) {
return fail ( 500 , 'Failed to get OSC status: ' + String ( e && e . message || e ));
}
}
// Get OSC activity log
if ( pathOnly === '/api/osc/activity' && method === 'GET' ) {
try {
// Include feedback status in activity log entries
const enrichedLog = oscActivityLog . slice ( - 20 ). map ( entry => {
const enriched = { ... entry };
// Add feedback info if available
if ( entry . feedbackSent !== undefined ) {
enriched . feedbackSent = entry . feedbackSent ;
}
if ( entry . feedbackTarget ) {
enriched . feedbackTarget = entry . feedbackTarget ;
}
return enriched ;
});
return ok ({
activity : enrichedLog ,
total : oscActivityLog . length
});
} catch ( e ) {
return fail ( 500 , 'Failed to get OSC activity: ' + String ( e && e . message || e ));
}
}
// Get OSC feedback log
if ( pathOnly === '/api/osc/feedback-log' && method === 'GET' ) {
try {
return ok ({
feedback : oscFeedbackLog . slice ( - 30 ), // Last 30 entries
total : oscFeedbackLog . length
});
} catch ( e ) {
return fail ( 500 , 'Failed to get OSC feedback log: ' + String ( e && e . message || e ));
}
}
// Test OSC command (for debugging)
if ( pathOnly === '/api/osc/test' && method === 'POST' ) {
try {
let body = '' ;
req . on ( 'data' , chunk => { body += chunk . toString (); });
req . on ( 'end' , () => {
try {
const testData = JSON . parse ( body );
const action = testData . action || 'undo' ;
const payload = testData . payload || {};
console . log ( `[OSC TEST] Testing action: ${ action } ` , payload );
const result = invokeRenderer ( action , payload );
if ( result ) {
return ok ({ message : `Command ${ action } sent successfully` , rendererReady : true });
} else {
return fail ( 503 , `Renderer invoker not ready. Action: ${ action } ` );
}
} catch ( e ) {
return fail ( 400 , 'Invalid JSON: ' + String ( e && e . message || e ));
}
});
return ;
} catch ( e ) {
return fail ( 500 , 'Failed to test OSC: ' + String ( e && e . message || e ));
}
}
// Test OSC packet encoding and sending
if ( pathOnly === '/api/osc/test-osc-packet' && method === 'POST' ) {
try {
let body = '' ;
req . on ( 'data' , chunk => { body += chunk . toString (); });
req . on ( 'end' , () => {
try {
const testData = JSON . parse ( body );
const address = testData . address || '10.6.3.222' ;
const port = testData . port || 16759 ; // Default to Companion UDP listener port
const oscPath = testData . path || '/bibleapp/feedback/undo' ;
const oscValue = testData . value !== undefined ? testData . value : 1 ;
console . log ( `[OSC Packet Test] Testing OSC packet to ${ address } : ${ port } ` );
console . log ( `[OSC Packet Test] Path: ${ oscPath } , Value: ${ oscValue } ` );
// Create properly formatted OSC message
const args = [{ type : 'i' , value : oscValue }];
const oscPacket = encodeOSCMessage ( oscPath , args );
console . log ( `[OSC Packet Test] Encoded OSC packet:` , oscPacket );
console . log ( `[OSC Packet Test] Packet length: ${ oscPacket . length } bytes` );
console . log ( `[OSC Packet Test] Packet hex:` , oscPacket . toString ( 'hex' ));
// Send via raw UDP
sendOscFeedbackRaw ( address , port , oscPath , args )
. then (() => {
console . log ( `[OSC Packet Test] ✓ OSC packet sent successfully` );
return ok ({
message : `OSC packet sent successfully` ,
target : ` ${ address } : ${ port } ` ,
path : oscPath ,
value : oscValue ,
packetSize : oscPacket . length
});
})
. catch (( err ) => {
console . error ( `[OSC Packet Test] Failed:` , err . message );
return fail ( 500 , 'Failed to send OSC packet: ' + err . message );
});
return ;
} catch ( e ) {
return fail ( 400 , 'Invalid JSON: ' + String ( e && e . message || e ));
}
});
return ;
} catch ( e ) {
return fail ( 500 , 'Failed to test OSC packet: ' + String ( e && e . message || e ));
}
}
// Test UDP connectivity (for debugging feedback)
if ( pathOnly === '/api/osc/test-udp' && method === 'POST' ) {
try {
let body = '' ;
req . on ( 'data' , chunk => { body += chunk . toString (); });
req . on ( 'end' , () => {
try {
const testData = JSON . parse ( body );
const address = testData . address || '127.0.0.1' ;
const port = testData . port || 16759 ;
console . log ( `[OSC UDP Test] Testing UDP connectivity to ${ address } : ${ port } ` );
// Use Node's dgram module to test UDP directly
const dgram = require ( 'dgram' );
const testSocket = dgram . createSocket ( 'udp4' );
const testMessage = Buffer . from ( JSON . stringify ({ test : true , timestamp : Date . now () }));
testSocket . on ( 'error' , ( err ) => {
console . error ( `[OSC UDP Test] Socket error:` , err . message );
testSocket . close ();
return fail ( 500 , 'UDP test failed: ' + err . message );
});
let socketClosed = false ;
const closeSocket = () => {
if ( ! socketClosed && testSocket && typeof testSocket . close === 'function' ) {
try {
socketClosed = true ;
testSocket . close ();
} catch ( closeErr ) {
// Ignore close errors
console . warn ( `[OSC UDP Test] Error closing socket:` , closeErr . message );
}
}
};
testSocket . bind ( 0 , () => {
const localPort = testSocket . address (). port ;
console . log ( `[OSC UDP Test] Test socket bound to port ${ localPort } ` );
testSocket . send ( testMessage , port , address , ( err ) => {
if ( err ) {
console . error ( `[OSC UDP Test] Send error:` , err . message );
closeSocket ();
return fail ( 500 , 'UDP send failed: ' + err . message );
}
console . log ( `[OSC UDP Test] ✓ Test packet sent to ${ address } : ${ port } ` );
// Close socket after a short delay to ensure packet is sent
setTimeout (() => {
closeSocket ();
}, 100 );
return ok ({
message : `UDP test packet sent successfully` ,
target : ` ${ address } : ${ port } ` ,
localPort : localPort
});
});
});
// Timeout after 5 seconds
setTimeout (() => {
closeSocket ();
}, 5000 );
return ;
} catch ( e ) {
return fail ( 400 , 'Invalid JSON: ' + String ( e && e . message || e ));
}
});
return ;
} catch ( e ) {
return fail ( 500 , 'Failed to test UDP: ' + String ( e && e . message || e ));
}
}
// Test OSC feedback (for debugging)
if ( pathOnly === '/api/osc/test-feedback' && method === 'POST' ) {
try {
let body = '' ;
req . on ( 'data' , chunk => { body += chunk . toString (); });
req . on ( 'end' , () => {
try {
const testData = JSON . parse ( body );
const command = testData . command || 'undo' ;
const success = testData . success !== false ; // Default to true
// Get configured feedback settings
const cfg = readOscConfig ();
const address = testData . address || cfg . feedbackAddress || '10.6.3.222' ;
let port = testData . port || cfg . feedbackPort || 16759 ; // Default to Companion UDP listener port
const tryBothPorts = testData . tryBothPorts === true ;
console . log ( `[OSC TEST FEEDBACK] Testing feedback for command: ${ command } ` );
console . log ( `[OSC TEST FEEDBACK] Success: ${ success } , Address: ${ address } , Port: ${ port } ` );
console . log ( `[OSC TEST FEEDBACK] Using config - Address: ${ cfg . feedbackAddress || 'default' } , Port: ${ cfg . feedbackPort || 'default' } ` );
console . log ( `[OSC TEST FEEDBACK] Try both ports: ${ tryBothPorts } ` );
// Create a mock message and info object
const mockMessage = { address : `/bibleapp/ ${ command } ` };
const mockInfo = {
address : address ,
port : port ,
remoteAddress : address ,
remotePort : port
};
// Send feedback to primary port
sendOscFeedback ( mockMessage , mockInfo , command , success , success ? null : 'Test error message' );
const results = [{ port : port , status : 'sent' }];
// If trying both ports, also send to the secondary common Companion port
if ( tryBothPorts ) {
const secondaryPort = port === 16759 ? 12321 : 16759 ;
console . log ( `[OSC TEST FEEDBACK] Also sending to secondary port ( ${ secondaryPort } )` );
// Send to UDP feedback port using raw UDP (non-blocking)
const feedbackAddress = `/bibleapp/feedback/ ${ command } ` ;
const status = success ? 1 : 0 ;
const args = [{ type : 'i' , value : status }];
sendOscFeedbackRaw ( address , secondaryPort , feedbackAddress , args ). catch ( err => {
console . warn ( `[OSC TEST FEEDBACK] Failed to send to port ${ secondaryPort } :` , err . message );
});
results . push ({ port : secondaryPort , status : 'sent' });
}
return ok ({
message : `Feedback test sent for ${ command } ` ,
target : ` ${ address } : ${ port } ` ,
command : command ,
success : success ,
ports : results ,
usedConfig : {
address : cfg . feedbackAddress || 'default' ,
port : cfg . feedbackPort || 'default'
}
});
} catch ( e ) {
return fail ( 400 , 'Invalid JSON: ' + String ( e && e . message || e ));
}
});
return ;
} catch ( e ) {
return fail ( 500 , 'Failed to test OSC feedback: ' + String ( e && e . message || e ));
}
}
return fail ( 404 , 'Unknown OSC endpoint' );
} catch ( e ) {
res . writeHead ( 500 , { 'Content-Type' : 'application/json' });
res . end ( JSON . stringify ({ ok : false , error : 'Server error' , detail : String ( e && e . message || e ) }));
}
return ;
}
// API endpoint for server logs (log viewer)
if ( req . url . startsWith ( '/api/logs' )) {
try {
const urlObj = new URL ( req . url , 'http://localhost' );
const limit = parseInt ( urlObj . searchParams . get ( 'limit' ) || '1000' , 10 );
const filtered = serverLogs . slice ( - limit );
res . writeHead ( 200 , { 'Content-Type' : 'application/json' });
res . end ( JSON . stringify ({
ok : true ,
logs : filtered ,
total : serverLogs . length ,
limit : limit
}));
} catch ( e ) {
res . writeHead ( 500 , { 'Content-Type' : 'application/json' });
res . end ( JSON . stringify ({ ok : false , error : 'Failed to get logs: ' + String ( e && e . message || e ) }));
}
return ;
}
// API endpoint for Default Graphics setting
if ( req . url . startsWith ( '/api/default-graphics' )) {
try {
if ( req . method === 'GET' ) {
const enabled = isDefaultGraphicsEnabled ();
res . writeHead ( 200 , { 'Content-Type' : 'application/json' });
res . end ( JSON . stringify ({ ok : true , enabled }));
return ;
}
if ( req . method === 'POST' ) {
let body = '' ;
req . on ( 'data' , ( c ) => body += c . toString ());
req . on ( 'end' , () => {
try {
const data = JSON . parse ( body );
const enabled = data . enabled === true ;
const success = setDefaultGraphicsEnabled ( enabled );
if ( success ) {
// If disabling, delete existing default graphics from user directories
if ( ! enabled ) {
const deletedFiles = [];
try {
// Delete Notes.png
const notesPng = path . join ( notesDir , 'Notes.png' );
if ( fs . existsSync ( notesPng )) {
fs . unlinkSync ( notesPng );
deletedFiles . push ( 'Notes.png' );
}
// Delete splash.png
const splashPng = path . join ( notesDir , 'splash.png' );
if ( fs . existsSync ( splashPng )) {
fs . unlinkSync ( splashPng );
deletedFiles . push ( 'splash.png' );
}
// Delete Announcements.png
const annPng = path . join ( announcementsDir , 'Announcements.png' );
if ( fs . existsSync ( annPng )) {
fs . unlinkSync ( annPng );
deletedFiles . push ( 'Announcements.png' );
}
console . log ( `[SERVER] Deleted default graphics files: ${ deletedFiles . join ( ', ' ) } ` );
} catch ( e ) {
console . error ( `[SERVER] Error deleting default graphics:` , e );
}
res . writeHead ( 200 , { 'Content-Type' : 'application/json' });
res . end ( JSON . stringify ({
ok : true ,
enabled : false ,
deletedFiles : deletedFiles . length > 0 ? deletedFiles : []
}));
} else {
// If enabling, restore default graphics if they don't exist
const restoredFiles = [];
try {
fs . mkdirSync ( notesDir , { recursive : true });
fs . mkdirSync ( announcementsDir , { recursive : true });
// Restore splash.png
const bundledSplashPng = path . join ( __dirname , 'Graphics' , 'splash.png' );
const targetSplash = path . join ( notesDir , 'splash.png' );
if ( fs . existsSync ( bundledSplashPng ) && ! fs . existsSync ( targetSplash )) {
fs . copyFileSync ( bundledSplashPng , targetSplash );
restoredFiles . push ( 'splash.png' );
console . log ( `[SERVER] Restored splash.png` );
}
// Restore Notes.png
const bundledNotesPng = path . join ( __dirname , 'Notes' , 'Notes.png' );
const targetNotesPng = path . join ( notesDir , 'Notes.png' );
if ( fs . existsSync ( bundledNotesPng ) && ! fs . existsSync ( targetNotesPng )) {
fs . copyFileSync ( bundledNotesPng , targetNotesPng );
restoredFiles . push ( 'Notes.png' );
console . log ( `[SERVER] Restored Notes.png` );
}
// Restore Announcements.png
const bundledAnnPng = path . join ( __dirname , 'Announcements' , 'Announcements.png' );
const targetAnnPng = path . join ( announcementsDir , 'Announcements.png' );
if ( fs . existsSync ( bundledAnnPng ) && ! fs . existsSync ( targetAnnPng )) {
fs . copyFileSync ( bundledAnnPng , targetAnnPng );
restoredFiles . push ( 'Announcements.png' );
console . log ( `[SERVER] Restored Announcements.png` );
}
// Ensure default.txt files point to default graphics if they don't exist
const notesDefaultPath = path . join ( notesDir , 'default.txt' );
if ( ! fs . existsSync ( notesDefaultPath ) && restoredFiles . includes ( 'Notes.png' )) {
fs . writeFileSync ( notesDefaultPath , 'Notes.png\n' , 'utf-8' );
}
const annDefaultPath = path . join ( announcementsDir , 'default.txt' );
if ( ! fs . existsSync ( annDefaultPath ) && restoredFiles . includes ( 'Announcements.png' )) {
fs . writeFileSync ( annDefaultPath , 'Announcements.png\n' , 'utf-8' );
}
// Update splash-config.json to point to splash.png if it was restored
if ( restoredFiles . includes ( 'splash.png' )) {
const splashConfigPath = path . join ( notesDir , 'splash-config.json' );
let cfg = { default : 'splash.png' };
try {
if ( fs . existsSync ( splashConfigPath )) {
cfg = JSON . parse ( fs . readFileSync ( splashConfigPath , 'utf-8' ) || '{}' );
}
} catch ( _ ) { cfg = {}; }
cfg . default = 'splash.png' ;
fs . writeFileSync ( splashConfigPath , JSON . stringify ( cfg , null , 2 ), 'utf-8' );
}
console . log ( `[SERVER] Restored default graphics files: ${ restoredFiles . join ( ', ' ) } ` );
} catch ( e ) {
console . error ( `[SERVER] Error restoring default graphics:` , e );
}
res . writeHead ( 200 , { 'Content-Type' : 'application/json' });
res . end ( JSON . stringify ({
ok : true ,
enabled : true ,
restoredFiles : restoredFiles . length > 0 ? restoredFiles : []
}));
}
} else {
res . writeHead ( 500 , { 'Content-Type' : 'application/json' });
res . end ( JSON . stringify ({ ok : false , error : 'Failed to save setting' }));
}
} catch ( e ) {
res . writeHead ( 400 , { 'Content-Type' : 'application/json' });
res . end ( JSON . stringify ({ ok : false , error : 'Invalid request body' , detail : e . message }));
}
});
return ;
}
res . writeHead ( 405 , { 'Content-Type' : 'application/json' });
res . end ( JSON . stringify ({ ok : false , error : 'Method not allowed' }));
} catch ( e ) {
res . writeHead ( 500 , { 'Content-Type' : 'application/json' });
res . end ( JSON . stringify ({ ok : false , error : 'Server error' , detail : String ( e && e . message || e ) }));
}
return ;
}
// API endpoint for Starter Pack installation
if ( req . url . startsWith ( '/api/starter-pack/install' )) {
try {
if ( req . method !== 'POST' ) {
res . writeHead ( 405 , { 'Content-Type' : 'application/json' });
res . end ( JSON . stringify ({ ok : false , error : 'Method not allowed' }));
return ;
}
// Check if default graphics are enabled
if ( ! isDefaultGraphicsEnabled ()) {
res . writeHead ( 403 , { 'Content-Type' : 'application/json' });
res . end ( JSON . stringify ({
ok : false ,
error : 'Default graphics are disabled. Enable them in Settings to install Starter Pack graphics.'
}));
return ;
}
const installedFiles = [];
try {
// Copy splash.png from Graphics folder
const bundledSplashPng = path . join ( __dirname , 'Graphics' , 'splash.png' );
const targetSplash = path . join ( notesDir , 'splash.png' );
if ( fs . existsSync ( bundledSplashPng ) && ! fs . existsSync ( targetSplash )) {
fs . copyFileSync ( bundledSplashPng , targetSplash );
installedFiles . push ( 'splash.png' );
console . log ( `[STARTER PACK] Installed splash.png` );
}
// Copy Notes.png
const bundledNotesPng = path . join ( __dirname , 'Notes' , 'Notes.png' );
const targetNotesPng = path . join ( notesDir , 'Notes.png' );
if ( fs . existsSync ( bundledNotesPng ) && ! fs . existsSync ( targetNotesPng )) {
fs . copyFileSync ( bundledNotesPng , targetNotesPng );
installedFiles . push ( 'Notes.png' );
console . log ( `[STARTER PACK] Installed Notes.png` );
}
// Copy Announcements.png
const bundledAnnPng = path . join ( __dirname , 'Announcements' , 'Announcements.png' );
const targetAnnPng = path . join ( announcementsDir , 'Announcements.png' );
if ( fs . existsSync ( bundledAnnPng ) && ! fs . existsSync ( targetAnnPng )) {
fs . copyFileSync ( bundledAnnPng , targetAnnPng );
installedFiles . push ( 'Announcements.png' );
console . log ( `[STARTER PACK] Installed Announcements.png` );
}
// Create default.txt files if they don't exist
const notesDefaultPath = path . join ( notesDir , 'default.txt' );
if ( ! fs . existsSync ( notesDefaultPath )) {
fs . writeFileSync ( notesDefaultPath , 'Notes.png\n' , 'utf-8' );
}
const annDefaultPath = path . join ( announcementsDir , 'default.txt' );
if ( ! fs . existsSync ( annDefaultPath )) {
fs . writeFileSync ( annDefaultPath , 'Announcements.png\n' , 'utf-8' );
}
// Update splash-config.json to point to splash.png
const splashConfigPath = path . join ( notesDir , 'splash-config.json' );
let cfg = { default : 'splash.png' };
try {
if ( fs . existsSync ( splashConfigPath )) {
cfg = JSON . parse ( fs . readFileSync ( splashConfigPath , 'utf-8' ) || '{}' );
}
} catch ( _ ) { cfg = {}; }
cfg . default = 'splash.png' ;
fs . writeFileSync ( splashConfigPath , JSON . stringify ( cfg , null , 2 ), 'utf-8' );
res . writeHead ( 200 , { 'Content-Type' : 'application/json' });
res . end ( JSON . stringify ({
ok : true ,
installed : installedFiles . length > 0 ? ` ${ installedFiles . length } file(s) installed` : 'All files already exist' ,
files : installedFiles
}));
} catch ( e ) {
console . error ( `[STARTER PACK] Error installing:` , e );
res . writeHead ( 500 , { 'Content-Type' : 'application/json' });
res . end ( JSON . stringify ({ ok : false , error : 'Failed to install Starter Pack' , detail : e . message }));
}
} catch ( e ) {
res . writeHead ( 500 , { 'Content-Type' : 'application/json' });
res . end ( JSON . stringify ({ ok : false , error : 'Server error' , detail : String ( e && e . message || e ) }));
}
return ;
}
// API endpoints for local dictionaries
// Word-by-word original-language data for a verse or whole chapter.
// GET /api/interlinear?book=John&chapter=3[&verse=16]
// Source: STEP Bible TAHOT/TAGNT (Tyndale House Cambridge, CC BY 4.0) in ./interlinear/<Book>.json
if ( req . url . startsWith ( '/api/interlinear' )) {
try {
const u = new URL ( req . url , 'http://localhost' );
const book = String ( u . searchParams . get ( 'book' ) || '' ). trim ();
const chapter = parseInt ( u . searchParams . get ( 'chapter' ), 10 );
const verse = parseInt ( u . searchParams . get ( 'verse' ), 10 );
if ( ! book || ! /^[1-3]? ?[A-Za-z ]{2,24}$/ . test ( book ) || ! chapter || chapter < 1 ) {
res . writeHead ( 400 , { 'Content-Type' : 'application/json' });
res . end ( JSON . stringify ({ ok : false , error : 'book and chapter required' }));
return ;
}
let data = __interlinearCache . get ( book );
if ( data === undefined ) {
const p = path . join ( __dirname , 'interlinear' , path . basename ( book ) + '.json' );
data = fs . existsSync ( p ) ? JSON . parse ( fs . readFileSync ( p , 'utf-8' )) : null ;
__interlinearCache . set ( book , data );
}
if ( ! data ) {
res . writeHead ( 404 , { 'Content-Type' : 'application/json' });
res . end ( JSON . stringify ({ ok : false , error : 'No interlinear data for ' + book }));
return ;
}
res . writeHead ( 200 , { 'Content-Type' : 'application/json' , 'Cache-Control' : 'max-age=3600' });
if ( verse ) {
res . end ( JSON . stringify ({ ok : true , book , chapter , verse , words : data [ chapter + '|' + verse ] || [], attribution : INTERLINEAR_ATTRIBUTION }));
} else {
const verses = {};
const prefix = chapter + '|' ;
for ( const k of Object . keys ( data )) {
if ( k . startsWith ( prefix )) verses [ k . slice ( prefix . length )] = data [ k ];
}
res . end ( JSON . stringify ({ ok : true , book , chapter , verses , attribution : INTERLINEAR_ATTRIBUTION }));
}
} catch ( e ) {
res . writeHead ( 500 , { 'Content-Type' : 'application/json' });
res . end ( JSON . stringify ({ ok : false , error : 'Interlinear lookup failed' , detail : String ( e && e . message || e ) }));
}
return ;
}
if ( req . url . startsWith ( '/api/dict/greek' )) {
// Serve from in-memory cache when available (skips disk read + parse + merge on repeat lookups)
if ( __dictCache . greek ) {
res . writeHead ( 200 , { 'Content-Type' : 'application/json' });
res . end ( __dictCache . greek );
return ;
}
// Prefer dedicated JSON if present; merge optional WordNet dictionary when available
const jsonPath = path . join ( __dirname , 'dict' , 'greek.json' );
const wordnetPath = path . join ( __dirname , 'dict' , 'greek-wordnet.json' );
const jsPath = path . join ( __dirname , 'Dictionary' , 'greek' , 'strongs-greek-dictionary.js' );
try {
const mergeDicts = ( baseObj , extraObj ) => {
const out = { ...( baseObj || {}) };
if ( extraObj && typeof extraObj === 'object' ) {
for ( const k of Object . keys ( extraObj )) {
if ( k === '__meta' ) continue ;
const a = out [ k ] || {};
const b = extraObj [ k ] || {};
out [ k ] = {
... a ,
text : a . text || b . text ,
transliteration : a . transliteration || b . transliteration ,
meaning : a . meaning || b . meaning ,
...( Object . keys ( b ). reduce (( acc , key ) => {
if ( ! ( key in a )) acc [ key ] = b [ key ];
return acc ;
}, {}))
};
}
}
return out ;
};
let dictObj = {};
let haveAnyLocal = false ;
// Load primary greek.json if present
if ( fs . existsSync ( jsonPath )) {
try {
const data = fs . readFileSync ( jsonPath , 'utf-8' );
const parsed = data ? JSON . parse ( data ) : {};
// Only use if it has actual entries
if ( parsed && typeof parsed === 'object' && Object . keys ( parsed ). length > 0 ) {
dictObj = parsed ;
haveAnyLocal = true ;
}
} catch ( _ ) {}
}
// Merge optional WordNet dictionary if present
if ( fs . existsSync ( wordnetPath )) {
try {
const data = fs . readFileSync ( wordnetPath , 'utf-8' );
const wnObj = data ? JSON . parse ( data ) : {};
if ( wnObj && typeof wnObj === 'object' && Object . keys ( wnObj ). length > 0 ) {
dictObj = mergeDicts ( dictObj , wnObj );
haveAnyLocal = true ;
}
} catch ( _ ) {}
}
// Only return early if we have actual data
if ( haveAnyLocal && dictObj && typeof dictObj === 'object' && Object . keys ( dictObj ). length > 0 ) {
__dictCache . greek = JSON . stringify ( dictObj );
res . writeHead ( 200 , { 'Content-Type' : 'application/json' });
res . end ( __dictCache . greek );
return ;
}
// Fall back: extract JSON object from Strong's JS and merge WordNet if present
if ( ! fs . existsSync ( jsPath )) {
throw new Error ( `Strong's Greek dictionary JS file not found at ${ jsPath } ` );
}
const js = fs . readFileSync ( jsPath , 'utf-8' );
const match = js . match ( /var\s+strongsGreekDictionary\s*=\s*(\{[\s\S]*?\});\s*module\.exports\s*=\s*strongsGreekDictionary;/ );
if ( ! match ) {
throw new Error ( 'Could not parse Strong\'s Greek JS - pattern not found' );
}
let strongsObj = {};
try {
strongsObj = JSON . parse ( match [ 1 ]);
if ( ! strongsObj || typeof strongsObj !== 'object' || Object . keys ( strongsObj ). length === 0 ) {
throw new Error ( 'Parsed dictionary is empty' );
}
} catch ( parseErr ) {
// If parsing fails, try to serve raw match (might be valid JSON)
console . warn ( '[SERVER] Failed to parse Strong\'s dictionary:' , parseErr . message );
try {
strongsObj = JSON . parse ( match [ 1 ]);
} catch ( _ ) {
// Last resort: serve unparsed content
__dictCache . greek = match [ 1 ];
res . writeHead ( 200 , { 'Content-Type' : 'application/json' });
res . end ( __dictCache . greek );
return ;
}
}
if ( fs . existsSync ( wordnetPath )) {
try {
const wnRaw = fs . readFileSync ( wordnetPath , 'utf-8' );
const wnObj = wnRaw ? JSON . parse ( wnRaw ) : {};
if ( wnObj && typeof wnObj === 'object' && Object . keys ( wnObj ). length > 0 ) {
strongsObj = mergeDicts ( strongsObj , wnObj );
}
} catch ( _ ) {}
}
__dictCache . greek = JSON . stringify ( strongsObj );
res . writeHead ( 200 , { 'Content-Type' : 'application/json' });
res . end ( __dictCache . greek );
} catch ( e ) {
res . writeHead ( 500 , { 'Content-Type' : 'application/json' });
res . end ( JSON . stringify ({ error : 'Failed to load greek dictionary' , detail : e . message }));
}
return ;
}
if ( req . url . startsWith ( '/api/notes/list' )) {
try {
console . log ( `[SERVER] Listing notes from: ${ notesDir } ` );
let currentDefault = '' ;
try {
const defaultPath = path . join ( notesDir , 'default.txt' );
console . log ( `[SERVER] Reading notes default.txt from: ${ defaultPath } ` );
if ( fs . existsSync ( defaultPath )) {
const cfg = fs . readFileSync ( defaultPath , 'utf-8' );
const firstLine = ( cfg || '' ). split ( /\r?\n/ ). find ( l => l . trim ());
if ( firstLine ) {
const candidate = firstLine . trim ();
const splashEx = loadSplashExclusions ();
if ( ! splashEx . has ( candidate . toLowerCase ())) {
currentDefault = candidate ;
}
console . log ( `[SERVER] Notes default: ${ currentDefault } ` );
}
} else {
console . log ( `[SERVER] Notes default.txt does not exist at: ${ defaultPath } ` );
}
} catch ( e ) {
console . error ( `[SERVER] Error reading notes default.txt:` , e );
}
const allFiles = fs . readdirSync ( notesDir );
console . log ( `[SERVER] All files in notesDir ( ${ allFiles . length } ):` , allFiles . slice ( 0 , 10 ));
const defaultGraphicsEnabled = isDefaultGraphicsEnabled ();
const files = allFiles
. filter ( f => / \ .( pdf | rtf | key | png | jpe ? g | gif | bmp | webp ) $ / i . test ( f ))
. filter ( f => ! loadSplashExclusions (). has ( f . toLowerCase ()))
. filter ( f => {
// Filter out default graphics if disabled
if ( ! defaultGraphicsEnabled && isDefaultGraphicsFile ( 'Notes' , f )) {
return false ;
}
return true ;
})
. map ( f => {
try {
const filePath = path . join ( notesDir , f );
const stats = fs . statSync ( filePath );
return {
name : f ,
url : `/Notes/ ${ encodeURIComponent ( f ) } ` ,
mtimeMs : stats . mtimeMs || 0
};
} catch ( e ) {
console . error ( `[SERVER] Error getting stats for ${ f } :` , e );
return { name : f , url : `/Notes/ ${ encodeURIComponent ( f ) } ` , mtimeMs : 0 };
}
})
. sort (( a , b ) => b . mtimeMs - a . mtimeMs );
console . log ( `[SERVER] Returning ${ files . length } note files` );
res . writeHead ( 200 , { 'Content-Type' : 'application/json' });
res . end ( JSON . stringify ({ files , currentDefault }));
} catch ( e ) {
console . error ( `[SERVER] Error listing notes:` , e );
res . writeHead ( 500 , { 'Content-Type' : 'application/json' });
res . end ( JSON . stringify ({ error : 'Failed to list notes' , detail : e . message }));
}
return ;
}
if ( req . url . startsWith ( '/api/notes/upload' ) && req . method === 'POST' ) {
// Simple raw upload; expect header x-filename and raw PDF/image/RTF body
const rawName = ( req . headers [ 'x-filename' ] || 'upload.pdf' ). toString ();
const safeName = rawName . replace ( /[^a-zA-Z0-9._ -]/g , '_' );
const allowed = /\.(pdf|rtf|key|png|jpe?g|gif|bmp|webp)$/i ;
if ( ! allowed . test ( safeName )) {
res . writeHead ( 400 , { 'Content-Type' : 'application/json' });
res . end ( JSON . stringify ({ error : 'Unsupported file type' , detail : 'Only PDF, RTF, Keynote, or common image types are allowed.' }));
return ;
}
// Ensure notesDir exists
try {
fs . mkdirSync ( notesDir , { recursive : true });
} catch ( _ ) {}
let targetPath = path . join ( notesDir , safeName );
// Avoid overwrite
if ( fs . existsSync ( targetPath )) {
const ext = path . extname ( safeName );
const base = path . basename ( safeName , ext );
let i = 1 ;
while ( fs . existsSync ( path . join ( notesDir , ` ${ base } ( ${ i } ) ${ ext } ` ))) i ++ ;
targetPath = path . join ( notesDir , ` ${ base } ( ${ i } ) ${ ext } ` );
}
const chunks = [];
let uploadBytes = 0 ;
let uploadAborted = false ;
const MAX_UPLOAD_BYTES = 50 * 1024 * 1024 ; // 50MB cap to prevent memory-exhaustion DoS
req . on ( 'data' , ( c ) => {
if ( uploadAborted ) return ;
uploadBytes += c . length ;
if ( uploadBytes > MAX_UPLOAD_BYTES ) {
uploadAborted = true ;
try { res . writeHead ( 413 , { 'Content-Type' : 'application/json' }); res . end ( JSON . stringify ({ error : 'Payload too large' })); } catch ( _ ) {}
req . destroy ();
return ;
}
chunks . push ( c );
});
req . on ( 'end' , async () => {
try {
const buf = Buffer . concat ( chunks );
console . log ( `[SERVER] Saving notes asset to: ${ targetPath } ` );
fs . writeFileSync ( targetPath , buf );
// Last-minute pictures are usually far too small for the stage TV. Upscale on the GPU
// now (a second or two) so the operator never has to think about resolution. Never
// throws: on any failure the uploaded file is left exactly as it was.
try {
const up = await upscaler . enhanceIfSmall ( targetPath );
if ( up && up . status === 'enhanced' ) console . log ( `[UPSCALE] ${ path . basename ( targetPath ) } ${ up . from } -> ${ up . to } (x ${ up . scale } , ${ up . ms } ms)` );
} catch ( _ ) {}
console . log ( `[SERVER] Notes asset saved successfully: ${ path . basename ( targetPath ) } ` );
res . writeHead ( 200 , { 'Content-Type' : 'application/json' });
res . end ( JSON . stringify ({ ok : true , file : path . basename ( targetPath ), url : `/Notes/ ${ encodeURIComponent ( path . basename ( targetPath )) } ` }));
} catch ( e ) {
console . error ( `[SERVER] Failed to save notes PDF:` , e );
res . writeHead ( 500 , { 'Content-Type' : 'application/json' });
res . end ( JSON . stringify ({ error : 'Failed to save upload' , detail : e . message }));
}
});
req . on ( 'error' , ( e ) => {
console . error ( `[SERVER] Notes upload error:` , e );
res . writeHead ( 500 , { 'Content-Type' : 'application/json' });
res . end ( JSON . stringify ({ error : 'Upload error' , detail : e . message }));
});
return ;
}
if ( req . url . startsWith ( '/api/notes/points' )) {
try {
const u = new URL ( req . url , 'http://localhost' );
let fileParam = u . searchParams . get ( 'file' ) || '' ;
if ( ! fileParam ) fileParam = selectDefaultNotesFile ();
// If default points to slides.key, alias to slides.pdf when present
if ( /^slides\.key$/i . test ( fileParam )) {
const pdfCandidate = 'slides.pdf' ;
if ( fs . existsSync ( path . join ( notesDir , pdfCandidate ))) fileParam = pdfCandidate ;
}
const fileName = path . basename ( fileParam ); // prevent path traversal
const filePath = path . join ( notesDir , fileName );
if ( ! fs . existsSync ( filePath )) {
res . writeHead ( 404 , { 'Content-Type' : 'application/json' });
res . end ( JSON . stringify ({ error : 'File not found' }));
return ;
}
const ext = path . extname ( fileName ). toLowerCase ();
if ( loadSplashExclusions (). has ( fileName . toLowerCase ())) {
res . writeHead ( 400 , { 'Content-Type' : 'application/json' });
res . end ( JSON . stringify ({ error : 'Unsupported file type' , detail : 'This file is reserved for splash only' }));
return ;
}
if ( /\.(png|jpe?g|gif|bmp|webp)$/i . test ( fileName )) {
const st = fs . statSync ( filePath );
const version = st . mtimeMs || Date . now ();
const url = `/Notes/ ${ encodeURIComponent ( fileName ) } ?v= ${ version } ` ;
const respData = { file : fileName , mode : 'image' , url , mtimeMs : st . mtimeMs || 0 , size : st . size || 0 };
res . writeHead ( 200 , { 'Content-Type' : 'application/json' });
res . end ( JSON . stringify ( respData ));
return ;
}
let text = '' ;
if ( ext === '.pdf' ) {
// Special handling: when Notes/default.txt points to slides.pdf, serve page-per-point mode
if ( fileName . toLowerCase () === 'slides.pdf' ) {
const url = `/Notes/ ${ encodeURIComponent ( fileName ) } ` ;
const respData = { file : fileName , mode : 'pdf-pages' , url };
res . writeHead ( 200 , { 'Content-Type' : 'application/json' });
res . end ( JSON . stringify ( respData ));
return ;
}
const data = fs . readFileSync ( filePath );
const result = await pdfParse ( data );
text = ( result && result . text ) ? result . text : '' ;
} else if ( ext === '.key' ) {
// Treat .key as alias for a sibling .pdf with the same basename, if present
const pdfSibling = path . join ( notesDir , path . basename ( fileName , ext ) + '.pdf' );
if ( fs . existsSync ( pdfSibling )) {
const url = `/Notes/ ${ encodeURIComponent ( path . basename ( pdfSibling )) } ` ;
const respData = { file : fileName , mode : 'pdf-pages' , url };
res . writeHead ( 200 , { 'Content-Type' : 'application/json' });
res . end ( JSON . stringify ( respData ));
return ;
} else {
res . writeHead ( 400 , { 'Content-Type' : 'application/json' });
res . end ( JSON . stringify ({ error : 'Unsupported file type' , detail : 'Keynote (.key) not directly supported. Please export to PDF.' }));
return ;
}
} else if ( ext === '.rtf' || ext === '.txt' ) {
const raw = fs . readFileSync ( filePath , 'utf-8' );
const plain = ext === '.rtf' ? parseRtfSkippingBlue ( raw ) : raw ;
text = plain ;
// First try bracketed points in plain text for both .rtf and .txt
let bracketPoints = extractBracketedPointsFromPlain ( plain );
// If still nothing and it's RTF, try highlighted spans
if (( ! bracketPoints || bracketPoints . length === 0 ) && ext === '.rtf' ) {
bracketPoints = extractHighlightedPointsFromRtf ( raw );
}
if ( bracketPoints && bracketPoints . length > 0 ) {
// Use the bracket/highlight result directly
const respData = { file : fileName , points : bracketPoints , count : bracketPoints . length };
res . writeHead ( 200 , { 'Content-Type' : 'application/json' });
res . end ( JSON . stringify ( respData ));
return ;
}
} else {
res . writeHead ( 400 , { 'Content-Type' : 'application/json' });
res . end ( JSON . stringify ({ error : 'Unsupported file type' }));
return ;
}
let points = [];
if ( ext === '.rtf' ) {
// Prefer robust raw-RTF bracket extraction
const raw = fs . readFileSync ( filePath , 'utf-8' );
let bracketed = extractBracketedPointsFromRtfRaw ( raw );
if ( ! bracketed || bracketed . length === 0 ) {
// Fallback: bracket extraction from cleaned plain text
const plain = parseRtfSkippingBlue ( raw );
bracketed = extractBracketedPointsFromPlain ( plain );
}
if ( bracketed && bracketed . length > 0 ) {
points = bracketed ;
} else {
// Fallback to highlighted spans
points = extractHighlightedPointsFromRtf ( raw );
}
}
if ( ! points || points . length === 0 ) {
// Fallback to parsing by content
const isPdf = ext === '.pdf' ;
points = splitIntoPoints ( text , { requireMarker : isPdf , headingAllowed : ! isPdf , bracketOnly : false });
}
const debug = u . searchParams . get ( 'debug' ) === '1' ;
res . writeHead ( 200 , { 'Content-Type' : 'application/json' });
res . end ( JSON . stringify ({ file : fileName , points , count : points . length , debug : debug ? { rawPreview : text . slice ( 0 , 1500 ) } : undefined }));
} catch ( e ) {
res . writeHead ( 500 , { 'Content-Type' : 'application/json' });
res . end ( JSON . stringify ({ error : 'Failed to parse points' , detail : e . message }));
}
return ;
}
if ( req . url . startsWith ( '/api/notes/fileinfo' )) {
try {
const u = new URL ( req . url , 'http://localhost' );
let fileParam = u . searchParams . get ( 'file' ) || '' ;
if ( ! fileParam ) fileParam = selectDefaultNotesFile ();
if ( /^slides\.key$/i . test ( fileParam )) {
const pdfCandidate = 'slides.pdf' ;
if ( fs . existsSync ( path . join ( notesDir , pdfCandidate ))) fileParam = pdfCandidate ;
}
const fileName = path . basename ( fileParam );
const filePath = path . join ( notesDir , fileName );
if ( ! fs . existsSync ( filePath )) {
res . writeHead ( 404 , { 'Content-Type' : 'application/json' , 'Cache-Control' : 'no-store, no-cache, must-revalidate, max-age=0' , 'Pragma' : 'no-cache' , 'Expires' : '0' });
res . end ( JSON . stringify ({ error : 'File not found' }));
return ;
}
const stat = fs . statSync ( filePath );
const info = { file : fileName , size : stat . size , mtimeMs : stat . mtimeMs };
res . writeHead ( 200 , { 'Content-Type' : 'application/json' , 'Cache-Control' : 'no-store, no-cache, must-revalidate, max-age=0' , 'Pragma' : 'no-cache' , 'Expires' : '0' });
res . end ( JSON . stringify ( info ));
} catch ( e ) {
res . writeHead ( 500 , { 'Content-Type' : 'application/json' , 'Cache-Control' : 'no-store, no-cache, must-revalidate, max-age=0' , 'Pragma' : 'no-cache' , 'Expires' : '0' });
res . end ( JSON . stringify ({ error : 'Failed to read file info' , detail : e . message }));
}
return ;
}
if ( req . url . startsWith ( '/api/splash/fileinfo' )) {
try {
const u = new URL ( req . url , 'http://localhost' );
let fileParam = u . searchParams . get ( 'file' ) || '' ;
// Decode URL-encoded filename (handles %20 for spaces, etc.)
try {
fileParam = decodeURIComponent ( fileParam );
} catch ( e ) {
console . warn ( '[SERVER] Failed to decode file param:' , fileParam , e );
}
if ( ! fileParam ) {
// Try to get from splash-config.json
try {
const configPath = path . join ( notesDir , 'splash-config.json' );
if ( fs . existsSync ( configPath )) {
const cfg = JSON . parse ( fs . readFileSync ( configPath , 'utf-8' ));
if ( cfg && cfg . default && cfg . default . toLowerCase () !== 'notes.png' ) fileParam = cfg . default ;
}
} catch ( _ ) {}
}
// Hard skip Notes.png as a splash candidate
if ( fileParam . toLowerCase () === 'notes.png' ) {
fileParam = '' ;
}
// Fallback to splash.png if nothing provided
if ( ! fileParam ) fileParam = 'splash.png' ;
const fileName = path . basename ( fileParam );
let filePath = path . join ( notesDir , fileName );
// Try exact match first
if ( ! fs . existsSync ( filePath )) {
// Try case-insensitive match
try {
const files = fs . readdirSync ( notesDir ). filter ( f => f . toLowerCase () !== 'notes.png' );
const foundFile = files . find ( f => f . toLowerCase () === fileName . toLowerCase ());
if ( foundFile ) {
filePath = path . join ( notesDir , foundFile );
console . log ( `[SERVER] Splash fileinfo: case-insensitive match " ${ fileName } " -> " ${ foundFile } "` );
}
} catch ( e ) {
console . error ( `[SERVER] Error finding file:` , e );
}
}
if ( ! fs . existsSync ( filePath )) {
res . writeHead ( 404 , { 'Content-Type' : 'application/json' , 'Cache-Control' : 'no-store, no-cache, must-revalidate, max-age=0' , 'Pragma' : 'no-cache' , 'Expires' : '0' });
res . end ( JSON . stringify ({ error : 'File not found' , file : fileName , path : filePath }));
return ;
}
const stat = fs . statSync ( filePath );
const actualFileName = path . basename ( filePath );
const info = { file : actualFileName , size : stat . size , mtimeMs : stat . mtimeMs };
res . writeHead ( 200 , { 'Content-Type' : 'application/json' , 'Cache-Control' : 'no-store, no-cache, must-revalidate, max-age=0' , 'Pragma' : 'no-cache' , 'Expires' : '0' });
res . end ( JSON . stringify ( info ));
} catch ( e ) {
res . writeHead ( 500 , { 'Content-Type' : 'application/json' , 'Cache-Control' : 'no-store, no-cache, must-revalidate, max-age=0' , 'Pragma' : 'no-cache' , 'Expires' : '0' });
res . end ( JSON . stringify ({ error : 'Failed to read file info' , detail : e . message }));
}
return ;
}
if ( req . url . startsWith ( '/api/announcements/fileinfo' )) {
try {
const u = new URL ( req . url , 'http://localhost' );
let fileParam = u . searchParams . get ( 'file' ) || '' ;
// Decode URL-encoded filename (handles %20 for spaces, etc.)
try {
fileParam = decodeURIComponent ( fileParam );
} catch ( e ) {
console . warn ( '[SERVER] Failed to decode file param:' , fileParam , e );
}
if ( ! fileParam ) {
// Try to get from default.txt
try {
const defaultPath = path . join ( announcementsDir , 'default.txt' );
if ( fs . existsSync ( defaultPath )) {
const cfg = fs . readFileSync ( defaultPath , 'utf-8' );
const firstLine = ( cfg || '' ). split ( /\r?\n/ ). find ( l => l . trim ());
if ( firstLine ) fileParam = firstLine . trim ();
}
} catch ( _ ) {}
}
const fileName = path . basename ( fileParam );
let filePath = path . join ( announcementsDir , fileName );
// Try exact match first
if ( ! fs . existsSync ( filePath )) {
// Try case-insensitive match
try {
const files = fs . readdirSync ( announcementsDir );
const foundFile = files . find ( f => f . toLowerCase () === fileName . toLowerCase ());
if ( foundFile ) {
filePath = path . join ( announcementsDir , foundFile );
console . log ( `[SERVER] Announcements fileinfo: case-insensitive match " ${ fileName } " -> " ${ foundFile } "` );
}
} catch ( e ) {
console . error ( `[SERVER] Error finding file:` , e );
}
}
if ( ! fs . existsSync ( filePath )) {
res . writeHead ( 404 , { 'Content-Type' : 'application/json' , 'Cache-Control' : 'no-store, no-cache, must-revalidate, max-age=0' , 'Pragma' : 'no-cache' , 'Expires' : '0' });
res . end ( JSON . stringify ({ error : 'File not found' , file : fileName , path : filePath }));
return ;
}
const stat = fs . statSync ( filePath );
const actualFileName = path . basename ( filePath );
const info = { file : actualFileName , size : stat . size , mtimeMs : stat . mtimeMs };
res . writeHead ( 200 , { 'Content-Type' : 'application/json' , 'Cache-Control' : 'no-store, no-cache, must-revalidate, max-age=0' , 'Pragma' : 'no-cache' , 'Expires' : '0' });
res . end ( JSON . stringify ( info ));
} catch ( e ) {
res . writeHead ( 500 , { 'Content-Type' : 'application/json' , 'Cache-Control' : 'no-store, no-cache, must-revalidate, max-age=0' , 'Pragma' : 'no-cache' , 'Expires' : '0' });
res . end ( JSON . stringify ({ error : 'Failed to read file info' , detail : e . message }));
}
return ;
}
if ( req . url . startsWith ( '/api/announcements/list' )) {
// List all available announcement images
try {
console . log ( `[SERVER] ========== ANNOUNCEMENTS LIST ==========` );
console . log ( `[SERVER] Announcements directory: ${ announcementsDir } ` );
console . log ( `[SERVER] Directory exists: ${ fs . existsSync ( announcementsDir ) } ` );
const imageFiles = [];
let order = [];
let currentDefault = '' ;
try {
// Load saved order if available
try {
const orderPath = path . join ( announcementsDir , 'announcements-order.json' );
if ( fs . existsSync ( orderPath )) {
const raw = fs . readFileSync ( orderPath , 'utf-8' );
const parsed = JSON . parse ( raw );
if ( Array . isArray ( parsed )) order = parsed ;
}
} catch ( e ) {
console . warn ( '[SERVER] Unable to read announcements order file:' , e . message );
}
// Read current default from default.txt
try {
const defaultPath = path . join ( announcementsDir , 'default.txt' );
console . log ( `[SERVER] Reading announcements default.txt from: ${ defaultPath } ` );
console . log ( `[SERVER] default.txt exists: ${ fs . existsSync ( defaultPath ) } ` );
if ( fs . existsSync ( defaultPath )) {
const cfg = fs . readFileSync ( defaultPath , 'utf-8' );
const firstLine = ( cfg || '' ). split ( /\r?\n/ ). find ( l => l . trim ());
if ( firstLine ) {
currentDefault = firstLine . trim ();
console . log ( `[SERVER] Announcements default: " ${ currentDefault } "` );
} else {
console . log ( `[SERVER] default.txt is empty` );
}
} else {
console . log ( `[SERVER] Announcements default.txt does not exist at: ${ defaultPath } ` );
}
} catch ( e ) {
console . error ( `[SERVER] Error reading announcements default.txt:` , e );
}
if ( ! fs . existsSync ( announcementsDir )) {
console . error ( `[SERVER] ERROR: Announcements directory does not exist: ${ announcementsDir } ` );
} else {
const allFiles = fs . readdirSync ( announcementsDir );
console . log ( `[SERVER] All files in announcementsDir ( ${ allFiles . length } total):` , allFiles );
const defaultGraphicsEnabled = isDefaultGraphicsEnabled ();
allFiles . forEach ( name => {
if ( /\.(png|jpe?g|gif|bmp|webp)$/i . test ( name )) {
// Filter out default graphics if disabled
if ( ! defaultGraphicsEnabled && isDefaultGraphicsFile ( 'Announcements' , name )) {
console . log ( `[SERVER] Skipping default graphics file (disabled): ${ name } ` );
return ;
}
try {
const filePath = path . join ( announcementsDir , name );
const stats = fs . statSync ( filePath );
const version = stats . mtimeMs || Date . now ();
imageFiles . push ({
name ,
url : `/Announcements/ ${ encodeURIComponent ( name ) } ?v= ${ version } ` ,
mtimeMs : stats . mtimeMs || 0
});
} catch ( e ) {
console . error ( `[SERVER] Error getting stats for ${ name } :` , e );
}
}
});
// Sort by saved order first, fallback to modification time
if ( order . length ) {
const orderMap = new Map ( order . map (( name , idx ) => [ name . toLowerCase (), idx ]));
imageFiles . sort (( a , b ) => {
const aKey = a . name . toLowerCase ();
const bKey = b . name . toLowerCase ();
const aOrd = orderMap . has ( aKey ) ? orderMap . get ( aKey ) : Number . MAX_SAFE_INTEGER ;
const bOrd = orderMap . has ( bKey ) ? orderMap . get ( bKey ) : Number . MAX_SAFE_INTEGER ;
if ( aOrd !== bOrd ) return aOrd - bOrd ;
return ( b . mtimeMs || 0 ) - ( a . mtimeMs || 0 );
});
} else {
imageFiles . sort (( a , b ) => b . mtimeMs - a . mtimeMs );
}
console . log ( `[SERVER] Found ${ imageFiles . length } image files:` , imageFiles . map ( f => f . name ));
console . log ( `[SERVER] Current default: " ${ currentDefault } "` );
if ( currentDefault ) {
const defaultExists = imageFiles . some ( f => f . name === currentDefault );
console . log ( `[SERVER] Default file exists in list: ${ defaultExists } ` );
}
}
console . log ( `[SERVER] ==========================================` );
} catch ( e ) {
console . error ( `[SERVER] Error reading announcements directory:` , e );
}
res . writeHead ( 200 , { 'Content-Type' : 'application/json' , 'Cache-Control' : 'no-store, no-cache, must-revalidate, max-age=0' , 'Pragma' : 'no-cache' , 'Expires' : '0' });
res . end ( JSON . stringify ({ images : imageFiles , files : imageFiles , currentDefault , order }));
} catch ( e ) {
console . error ( `[SERVER] Error listing announcements:` , e );
res . writeHead ( 500 , { 'Content-Type' : 'application/json' , 'Cache-Control' : 'no-store, no-cache, must-revalidate, max-age=0' , 'Pragma' : 'no-cache' , 'Expires' : '0' });
res . end ( JSON . stringify ({ error : 'Failed to list announcement images' , detail : e && e . message || String ( e ) }));
}
return ;
}
if ( req . url . startsWith ( '/api/announcements/set-order' ) && req . method === 'POST' ) {
try {
let body = '' ;
req . on ( 'data' , chunk => body += chunk );
req . on ( 'end' , () => {
try {
const parsed = JSON . parse ( body || '{}' );
const order = Array . isArray ( parsed . order ) ? parsed . order . map ( x => String ( x || '' ). trim ()). filter ( Boolean ) : [];
if ( ! order . length ) {
res . writeHead ( 400 , { 'Content-Type' : 'application/json' });
res . end ( JSON . stringify ({ error : 'Invalid order payload' }));
return ;
}
const orderPath = path . join ( announcementsDir , 'announcements-order.json' );
fs . writeFileSync ( orderPath , JSON . stringify ( order , null , 2 ));
res . writeHead ( 200 , { 'Content-Type' : 'application/json' });
res . end ( JSON . stringify ({ ok : true , order }));
} catch ( e ) {
res . writeHead ( 400 , { 'Content-Type' : 'application/json' });
res . end ( JSON . stringify ({ error : 'Bad JSON' , detail : e . message }));
}
});
} catch ( e ) {
res . writeHead ( 500 , { 'Content-Type' : 'application/json' });
res . end ( JSON . stringify ({ error : 'Failed to save order' , detail : e . message }));
}
return ;
}
if ( req . url . startsWith ( '/api/announcements/resolve' )) {
try {
console . log ( `[SERVER] Resolving announcement, dir: ${ announcementsDir } ` );
const attempts = [];
const seen = new Set ();
const addAttempt = ( rawName ) => {
if ( ! rawName ) return ;
const cleaned = rawName . replace ( /\\/g , '/' ). replace ( /^\/+/ , '' ). replace ( /^Announcements\//i , '' );
if ( ! cleaned || cleaned . includes ( '..' )) return ;
const key = cleaned . toLowerCase ();
if ( seen . has ( key )) return ;
seen . add ( key );
attempts . push ( cleaned );
};
// default.txt hint (first non-empty line)
try {
const defaultPath = path . join ( announcementsDir , 'default.txt' );
console . log ( `[SERVER] Reading default.txt from: ${ defaultPath } ` );
if ( fs . existsSync ( defaultPath )) {
const cfg = fs . readFileSync ( defaultPath , 'utf-8' );
const firstLine = ( cfg || '' ). split ( /\r?\n/ ). find ( l => l . trim ());
if ( firstLine ) {
console . log ( `[SERVER] Found default: ${ firstLine . trim () } ` );
addAttempt ( firstLine . trim ());
}
} else {
console . log ( `[SERVER] default.txt does not exist at: ${ defaultPath } ` );
}
} catch ( e ) {
console . error ( `[SERVER] Error reading default.txt:` , e );
}
// Preferred filenames
[ 'announcement.jpg' , 'announcement.png' , 'Announcements.png' , 'splash.jpg' ]. forEach ( addAttempt );
// Discover available images (newest first)
try {
const discovered = fs . readdirSync ( announcementsDir )
. filter ( f => / \ .( png | jpe ? g | gif | bmp | webp ) $ / i . test ( f ))
. map ( name => {
try {
const st = fs . statSync ( path . join ( announcementsDir , name ));
return { name , mtimeMs : st . mtimeMs || 0 };
} catch ( _ ) {
return { name , mtimeMs : 0 };
}
})
. sort (( a , b ) => b . mtimeMs - a . mtimeMs );
discovered . forEach ( entry => addAttempt ( entry . name ));
} catch ( _ ) {}
let selected = '' ;
let selectedStat = null ;
console . log ( `[SERVER] Trying ${ attempts . length } announcement candidates` );
for ( const name of attempts ) {
const candidatePath = path . join ( announcementsDir , name );
console . log ( `[SERVER] Checking: ${ candidatePath } (exists: ${ fs . existsSync ( candidatePath ) } )` );
if ( fs . existsSync ( candidatePath )) {
selected = name ;
try { selectedStat = fs . statSync ( candidatePath ); } catch ( _ ) {}
console . log ( `[SERVER] Selected: ${ selected } ` );
break ;
}
}
if ( ! selected ) {
res . writeHead ( 404 , { 'Content-Type' : 'application/json' , 'Cache-Control' : 'no-store, no-cache, must-revalidate, max-age=0' , 'Pragma' : 'no-cache' , 'Expires' : '0' });
res . end ( JSON . stringify ({ error : 'No announcement image found' , attempts }));
return ;
}
const version = ( selectedStat && selectedStat . mtimeMs ) ? selectedStat . mtimeMs : Date . now ();
const fileAbsolute = path . join ( announcementsDir , selected );
let fileUrl = '' ;
try { fileUrl = pathToFileURL ( fileAbsolute ). href ; } catch ( _ ) {}
const primaryUrl = `/Announcements/ ${ encodeURIComponent ( selected ) } ?v= ${ version } ` ;
const attemptSet = new Set ();
const attemptUrls = [];
// Always try the selected file first
attemptSet . add ( selected . toLowerCase ());
attemptUrls . push ( primaryUrl );
if ( fileUrl ) {
attemptSet . add ( fileUrl . toLowerCase ());
attemptUrls . unshift ( fileUrl );
}
attempts . forEach ( name => {
const key = name . toLowerCase ();
if ( attemptSet . has ( key )) return ;
attemptSet . add ( key );
attemptUrls . push ( `/Announcements/ ${ encodeURIComponent ( name ) } ?v= ${ version } ` );
});
res . writeHead ( 200 , { 'Content-Type' : 'application/json' , 'Cache-Control' : 'no-store, no-cache, must-revalidate, max-age=0' , 'Pragma' : 'no-cache' , 'Expires' : '0' });
res . end ( JSON . stringify ({
url : primaryUrl ,
file : selected ,
mtimeMs : selectedStat ? selectedStat . mtimeMs : null ,
attempts : attemptUrls ,
rawAttempts : attempts ,
fileUrl
}));
} catch ( e ) {
res . writeHead ( 500 , { 'Content-Type' : 'application/json' , 'Cache-Control' : 'no-store, no-cache, must-revalidate, max-age=0' , 'Pragma' : 'no-cache' , 'Expires' : '0' });
res . end ( JSON . stringify ({ error : 'Failed to resolve announcement image' , detail : e && e . message || String ( e ) }));
}
return ;
}
if ( req . url . startsWith ( '/api/dict/hebrew' )) {
// Serve from in-memory cache when available (skips disk read + parse on repeat lookups)
if ( __dictCache . hebrew ) {
res . writeHead ( 200 , { 'Content-Type' : 'application/json' });
res . end ( __dictCache . hebrew );
return ;
}
const jsonPath = path . join ( __dirname , 'dict' , 'hebrew.json' );
const jsPath = path . join ( __dirname , 'Dictionary' , 'hebrew' , 'strongs-hebrew-dictionary.js' );
try {
if ( fs . existsSync ( jsonPath )) {
const data = fs . readFileSync ( jsonPath , 'utf-8' );
__dictCache . hebrew = data ;
res . writeHead ( 200 , { 'Content-Type' : 'application/json' });
res . end ( __dictCache . hebrew );
return ;
}
const js = fs . readFileSync ( jsPath , 'utf-8' );
// Expect pattern: var strongsHebrewDictionary = { ... }; module.exports = strongsHebrewDictionary;
const match = js . match ( /var\s+strongsHebrewDictionary\s*=\s*(\{[\s\S]*?\});\s*module\.exports\s*=\s*strongsHebrewDictionary;/ );
if ( ! match ) throw new Error ( 'Could not parse Strong\'s Hebrew JS' );
__dictCache . hebrew = match [ 1 ];
res . writeHead ( 200 , { 'Content-Type' : 'application/json' });
res . end ( __dictCache . hebrew );
} catch ( e ) {
res . writeHead ( 500 , { 'Content-Type' : 'application/json' });
res . end ( JSON . stringify ({ error : 'Failed to load hebrew dictionary' , detail : e . message }));
}
return ;
}
// Accordance API endpoints for enhanced word information
if ( req . url . startsWith ( '/api/accordance/' )) {
try {
const urlObj = new URL ( req . url , 'http://localhost' );
const pathOnly = urlObj . pathname || '' ;
// Status endpoint to check if Accordance is available
if ( pathOnly === '/api/accordance/status' && req . method === 'GET' ) {
// Check if Accordance OSC config exists and OSC module is available
const cfg = readOscConfig ();
const available = osc !== null && cfg . address && cfg . port ;
res . writeHead ( 200 , { 'Content-Type' : 'application/json' });
res . end ( JSON . stringify ({
available ,
configured : !! ( cfg . address && cfg . port ),
oscModuleInstalled : osc !== null
}));
return ;
}
// Word lookup endpoint
if ( pathOnly === '/api/accordance/word' && req . method === 'POST' ) {
let body = '' ;
req . on ( 'data' , chunk => { body += chunk . toString (); });
req . on ( 'end' , async () => {
try {
const payload = JSON . parse ( body );
const word = payload . word || '' ;
const reference = payload . reference || '' ;
if ( ! word ) {
res . writeHead ( 400 , { 'Content-Type' : 'application/json' });
res . end ( JSON . stringify ({ success : false , error : 'Word parameter required' }));
return ;
}
// Query Accordance via OSC
const accordanceData = await queryAccordanceViaOSC ( word , reference );
res . writeHead ( 200 , { 'Content-Type' : 'application/json' });
res . end ( JSON . stringify ( accordanceData ));
} catch ( e ) {
res . writeHead ( 500 , { 'Content-Type' : 'application/json' });
res . end ( JSON . stringify ({ success : false , error : 'Failed to query Accordance' , detail : e . message }));
}
});
return ;
}
res . writeHead ( 404 , { 'Content-Type' : 'application/json' });
res . end ( JSON . stringify ({ success : false , error : 'Endpoint not found' }));
} catch ( e ) {
res . writeHead ( 500 , { 'Content-Type' : 'application/json' });
res . end ( JSON . stringify ({ success : false , error : 'Server error' , detail : e . message }));
}
return ;
}
// Content Management API endpoints
// Splash screens endpoints
if ( req . url . startsWith ( '/api/splash/list' )) {
try {
const imageFiles = [];
let currentDefault = '' ;
try {
// Read current default from splash-config.json
try {
const configPath = path . join ( notesDir , 'splash-config.json' );
if ( fs . existsSync ( configPath )) {
const cfg = JSON . parse ( fs . readFileSync ( configPath , 'utf-8' ));
if ( cfg && cfg . default && cfg . default . toLowerCase () !== 'notes.png' ) currentDefault = cfg . default ;
}
} catch ( _ ) {}
const files = fs . readdirSync ( notesDir );
files . forEach ( name => {
if ( /\.(png|jpe?g|gif|bmp|webp)$/i . test ( name )) {
// Avoid pulling Notes.png into splash picker
if ( name . toLowerCase () === 'notes.png' ) return ;
try {
const filePath = path . join ( notesDir , name );
const stats = fs . statSync ( filePath );
const version = stats . mtimeMs || Date . now ();
imageFiles . push ({
name ,
url : `/Notes/ ${ encodeURIComponent ( name ) } ?v= ${ version } ` ,
mtimeMs : stats . mtimeMs || 0
});
} catch ( _ ) {}
}
});
imageFiles . sort (( a , b ) => b . mtimeMs - a . mtimeMs );
// Auto-correct default if missing or invalid
if ( ! currentDefault || ! imageFiles . find ( f => f . name === currentDefault )) {
const preferred = imageFiles . find ( f => f . name . toLowerCase () === 'splash.png' ) || imageFiles [ 0 ];
if ( preferred ) {
currentDefault = preferred . name ;
try {
const configPath = path . join ( notesDir , 'splash-config.json' );
fs . writeFileSync ( configPath , JSON . stringify ({ default : currentDefault }, null , 2 ), 'utf-8' );
console . log ( `[SERVER] Reset splash default to ${ currentDefault } ` );
} catch ( _ ) {}
}
}
} catch ( _ ) {}
// Log what we're returning
console . log ( `[SERVER] Splash list - currentDefault: " ${ currentDefault } ", files:` , imageFiles . map ( f => f . name ));
res . writeHead ( 200 , { 'Content-Type' : 'application/json' , 'Cache-Control' : 'no-store, no-cache, must-revalidate, max-age=0' , 'Pragma' : 'no-cache' , 'Expires' : '0' });
res . end ( JSON . stringify ({ files : imageFiles , currentDefault }));
} catch ( e ) {
res . writeHead ( 500 , { 'Content-Type' : 'application/json' , 'Cache-Control' : 'no-store, no-cache, must-revalidate, max-age=0' , 'Pragma' : 'no-cache' , 'Expires' : '0' });
res . end ( JSON . stringify ({ error : 'Failed to list splash images' , detail : e && e . message || String ( e ) }));
}
return ;
}
if ( req . url . startsWith ( '/api/splash/upload' ) && req . method === 'POST' ) {
// Simple raw upload; expect header x-filename and raw image body
const rawName = ( req . headers [ 'x-filename' ] || 'splash.png' ). toString ();
const safeName = rawName . replace ( /[^a-zA-Z0-9._ -]/g , '_' );
// Ensure notesDir exists
try {
fs . mkdirSync ( notesDir , { recursive : true });
} catch ( _ ) {}
let targetPath = path . join ( notesDir , safeName );
// Avoid overwrite
if ( fs . existsSync ( targetPath )) {
const ext = path . extname ( safeName );
const base = path . basename ( safeName , ext );
let i = 1 ;
while ( fs . existsSync ( path . join ( notesDir , ` ${ base } ( ${ i } ) ${ ext } ` ))) i ++ ;
targetPath = path . join ( notesDir , ` ${ base } ( ${ i } ) ${ ext } ` );
}
const chunks = [];
let uploadBytes = 0 ;
let uploadAborted = false ;
const MAX_UPLOAD_BYTES = 50 * 1024 * 1024 ; // 50MB cap to prevent memory-exhaustion DoS
req . on ( 'data' , ( c ) => {
if ( uploadAborted ) return ;
uploadBytes += c . length ;
if ( uploadBytes > MAX_UPLOAD_BYTES ) {
uploadAborted = true ;
try { res . writeHead ( 413 , { 'Content-Type' : 'application/json' }); res . end ( JSON . stringify ({ error : 'Payload too large' })); } catch ( _ ) {}
req . destroy ();
return ;
}
chunks . push ( c );
});
req . on ( 'end' , async () => {
try {
const buf = Buffer . concat ( chunks );
console . log ( `[SERVER] Saving splash to: ${ targetPath } ( ${ buf . length } bytes)` );
fs . writeFileSync ( targetPath , buf );
// Last-minute pictures are usually far too small for the stage TV. Upscale on the GPU
// now (a second or two) so the operator never has to think about resolution. Never
// throws: on any failure the uploaded file is left exactly as it was.
try {
const up = await upscaler . enhanceIfSmall ( targetPath );
if ( up && up . status === 'enhanced' ) console . log ( `[UPSCALE] ${ path . basename ( targetPath ) } ${ up . from } -> ${ up . to } (x ${ up . scale } , ${ up . ms } ms)` );
} catch ( _ ) {}
// Verify file was written
if ( ! fs . existsSync ( targetPath )) {
throw new Error ( 'File was not written to disk' );
}
const savedStats = fs . statSync ( targetPath );
const savedFilename = path . basename ( targetPath );
const savedUrl = `/Notes/ ${ encodeURIComponent ( savedFilename ) } ?v= ${ savedStats . mtimeMs } ` ;
console . log ( `[SERVER] Splash saved successfully: ${ savedFilename } ( ${ savedStats . size } bytes, url: ${ savedUrl } )` );
// Track splash uploads to exclude from Notes list
try {
const manifestPath = path . join ( notesDir , 'splash-upload-manifest.json' );
let arr = [];
if ( fs . existsSync ( manifestPath )) {
arr = JSON . parse ( fs . readFileSync ( manifestPath , 'utf-8' ) || '[]' );
if ( ! Array . isArray ( arr )) arr = [];
}
if ( ! arr . includes ( savedFilename )) {
arr . push ( savedFilename );
fs . writeFileSync ( manifestPath , JSON . stringify ( arr , null , 2 ), 'utf-8' );
}
} catch ( e ) {
console . warn ( '[SERVER] Failed to update splash upload manifest:' , e . message || e );
}
res . writeHead ( 200 , { 'Content-Type' : 'application/json' });
res . end ( JSON . stringify ({ ok : true , file : savedFilename , url : savedUrl , mtimeMs : savedStats . mtimeMs }));
} catch ( e ) {
console . error ( `[SERVER] Failed to save splash:` , e );
res . writeHead ( 500 , { 'Content-Type' : 'application/json' });
res . end ( JSON . stringify ({ error : 'Failed to save upload' , detail : e . message }));
}
});
req . on ( 'error' , ( e ) => {
console . error ( `[SERVER] Splash upload error:` , e );
res . writeHead ( 500 , { 'Content-Type' : 'application/json' });
res . end ( JSON . stringify ({ error : 'Upload error' , detail : e . message }));
});
return ;
}
if ( req . url . startsWith ( '/api/splash/set-default' ) && req . method === 'POST' ) {
let body = '' ;
req . on ( 'data' , ( c ) => body += c . toString ());
req . on ( 'end' , () => {
try {
const data = JSON . parse ( body );
const filename = data . filename ;
if ( ! filename ) {
res . writeHead ( 400 , { 'Content-Type' : 'application/json' });
res . end ( JSON . stringify ({ error : 'Filename required' }));
return ;
}
// Decode filename in case it's URL-encoded
let decodedFilename = filename ;
try {
decodedFilename = decodeURIComponent ( filename );
} catch ( e ) {
console . warn ( '[SERVER] Failed to decode filename:' , filename , e );
}
// Verify the file actually exists before setting it as default
decodedFilename = path . basename ( decodedFilename ); // strip any path components to prevent traversal
let filePath = path . join ( notesDir , decodedFilename );
let actualFilename = decodedFilename ;
if ( ! fs . existsSync ( filePath )) {
// Try to find the file with case-insensitive matching
const files = fs . readdirSync ( notesDir );
const foundFile = files . find ( f => f . toLowerCase () === decodedFilename . toLowerCase ());
if ( foundFile ) {
console . log ( `[SERVER] File case mismatch: requested " ${ decodedFilename } ", found " ${ foundFile } "` );
actualFilename = foundFile ;
filePath = path . join ( notesDir , foundFile );
} else {
res . writeHead ( 404 , { 'Content-Type' : 'application/json' });
res . end ( JSON . stringify ({ error : 'File not found' , filename : decodedFilename , availableFiles : files . slice ( 0 , 10 ) }));
return ;
}
}
// Splash screens use localStorage in the browser, but we can also create a config file
const configPath = path . join ( notesDir , 'splash-config.json' );
fs . writeFileSync ( configPath , JSON . stringify ({ default : actualFilename }, null , 2 ));
console . log ( `[SERVER] Set splash default to: ${ actualFilename } ` );
res . writeHead ( 200 , { 'Content-Type' : 'application/json' });
res . end ( JSON . stringify ({ ok : true , filename : actualFilename }));
} catch ( e ) {
res . writeHead ( 500 , { 'Content-Type' : 'application/json' });
res . end ( JSON . stringify ({ error : 'Failed to set default' , detail : e . message }));
}
});
return ;
}
if ( req . url . startsWith ( '/api/splash/delete' ) && req . method === 'POST' ) {
let body = '' ;
req . on ( 'data' , ( c ) => body += c . toString ());
req . on ( 'end' , () => {
try {
const data = JSON . parse ( body );
const filename = data . filename ;
if ( ! filename ) {
res . writeHead ( 400 , { 'Content-Type' : 'application/json' });
res . end ( JSON . stringify ({ error : 'Filename required' }));
return ;
}
const filePath = path . join ( notesDir , path . basename ( filename ));
if ( ! fs . existsSync ( filePath )) {
res . writeHead ( 404 , { 'Content-Type' : 'application/json' });
res . end ( JSON . stringify ({ error : 'File not found' }));
return ;
}
// Check if this is a default image and mark it as deleted
const basename = path . basename ( filename ). toLowerCase ();
if ( basename === 'splash.png' || basename === 'splash.jpg' ) {
markDefaultAsDeleted ( 'splash' , 'splash.png' );
}
fs . unlinkSync ( filePath );
res . writeHead ( 200 , { 'Content-Type' : 'application/json' });
res . end ( JSON . stringify ({ ok : true }));
} catch ( e ) {
res . writeHead ( 500 , { 'Content-Type' : 'application/json' });
res . end ( JSON . stringify ({ error : 'Failed to delete file' , detail : e . message }));
}
});
return ;
}
// Announcements endpoints (upload, set-default, delete)
if ( req . url . startsWith ( '/api/announcements/upload' ) && req . method === 'POST' ) {
// Simple raw upload; expect header x-filename and raw image body
const rawName = ( req . headers [ 'x-filename' ] || 'announcement.jpg' ). toString ();
const safeName = rawName . replace ( /[^a-zA-Z0-9._ -]/g , '_' );
// Ensure announcementsDir exists
try {
fs . mkdirSync ( announcementsDir , { recursive : true });
} catch ( _ ) {}
let targetPath = path . join ( announcementsDir , safeName );
// Avoid overwrite
if ( fs . existsSync ( targetPath )) {
const ext = path . extname ( safeName );
const base = path . basename ( safeName , ext );
let i = 1 ;
while ( fs . existsSync ( path . join ( announcementsDir , ` ${ base } ( ${ i } ) ${ ext } ` ))) i ++ ;
targetPath = path . join ( announcementsDir , ` ${ base } ( ${ i } ) ${ ext } ` );
}
const chunks = [];
let uploadBytes = 0 ;
let uploadAborted = false ;
const MAX_UPLOAD_BYTES = 50 * 1024 * 1024 ; // 50MB cap to prevent memory-exhaustion DoS
req . on ( 'data' , ( c ) => {
if ( uploadAborted ) return ;
uploadBytes += c . length ;
if ( uploadBytes > MAX_UPLOAD_BYTES ) {
uploadAborted = true ;
try { res . writeHead ( 413 , { 'Content-Type' : 'application/json' }); res . end ( JSON . stringify ({ error : 'Payload too large' })); } catch ( _ ) {}
req . destroy ();
return ;
}
chunks . push ( c );
});
req . on ( 'end' , async () => {
try {
const buf = Buffer . concat ( chunks );
console . log ( `[SERVER] Saving announcement to: ${ targetPath } ( ${ buf . length } bytes)` );
fs . writeFileSync ( targetPath , buf );
// Last-minute pictures are usually far too small for the stage TV. Upscale on the GPU
// now (a second or two) so the operator never has to think about resolution. Never
// throws: on any failure the uploaded file is left exactly as it was.
try {
const up = await upscaler . enhanceIfSmall ( targetPath );
if ( up && up . status === 'enhanced' ) console . log ( `[UPSCALE] ${ path . basename ( targetPath ) } ${ up . from } -> ${ up . to } (x ${ up . scale } , ${ up . ms } ms)` );
} catch ( _ ) {}
// Verify file was written
if ( ! fs . existsSync ( targetPath )) {
throw new Error ( 'File was not written to disk' );
}
const savedStats = fs . statSync ( targetPath );
const savedFilename = path . basename ( targetPath );
const savedUrl = `/Announcements/ ${ encodeURIComponent ( savedFilename ) } ?v= ${ savedStats . mtimeMs } ` ;
console . log ( `[SERVER] Announcement saved successfully: ${ savedFilename } ( ${ savedStats . size } bytes, url: ${ savedUrl } )` );
res . writeHead ( 200 , { 'Content-Type' : 'application/json' });
res . end ( JSON . stringify ({ ok : true , file : savedFilename , url : savedUrl , mtimeMs : savedStats . mtimeMs }));
} catch ( e ) {
console . error ( `[SERVER] Failed to save announcement:` , e );
res . writeHead ( 500 , { 'Content-Type' : 'application/json' });
res . end ( JSON . stringify ({ error : 'Failed to save upload' , detail : e . message }));
}
});
req . on ( 'error' , ( e ) => {
console . error ( `[SERVER] Announcement upload error:` , e );
res . writeHead ( 500 , { 'Content-Type' : 'application/json' });
res . end ( JSON . stringify ({ error : 'Upload error' , detail : e . message }));
});
return ;
}
if ( req . url . startsWith ( '/api/announcements/set-default' ) && req . method === 'POST' ) {
let body = '' ;
req . on ( 'data' , ( c ) => body += c . toString ());
req . on ( 'end' , () => {
try {
const data = JSON . parse ( body );
const filename = data . filename ;
if ( ! filename ) {
res . writeHead ( 400 , { 'Content-Type' : 'application/json' });
res . end ( JSON . stringify ({ error : 'Filename required' }));
return ;
}
// Verify the file actually exists before setting it as default
// Decode filename in case it's URL-encoded
let decodedFilename = filename ;
try {
decodedFilename = decodeURIComponent ( filename );
} catch ( e ) {
console . warn ( '[SERVER] Failed to decode filename:' , filename , e );
}
decodedFilename = path . basename ( decodedFilename ); // strip any path components to prevent traversal
let filePath = path . join ( announcementsDir , decodedFilename );
let actualFilename = decodedFilename ;
if ( ! fs . existsSync ( filePath )) {
// Try to find the file with case-insensitive matching
const files = fs . readdirSync ( announcementsDir );
const foundFile = files . find ( f => f . toLowerCase () === decodedFilename . toLowerCase ());
if ( foundFile ) {
console . log ( `[SERVER] File case mismatch: requested " ${ decodedFilename } ", found " ${ foundFile } "` );
actualFilename = foundFile ;
filePath = path . join ( announcementsDir , foundFile );
} else {
res . writeHead ( 404 , { 'Content-Type' : 'application/json' });
res . end ( JSON . stringify ({ error : 'File not found' , filename : decodedFilename , availableFiles : files . slice ( 0 , 10 ) }));
return ;
}
}
const defaultPath = path . join ( announcementsDir , 'default.txt' );
fs . writeFileSync ( defaultPath , actualFilename + '\n' , 'utf-8' );
console . log ( `[SERVER] Set announcements default to: ${ actualFilename } ` );
res . writeHead ( 200 , { 'Content-Type' : 'application/json' });
res . end ( JSON . stringify ({ ok : true , filename : actualFilename }));
} catch ( e ) {
res . writeHead ( 500 , { 'Content-Type' : 'application/json' });
res . end ( JSON . stringify ({ error : 'Failed to set default' , detail : e . message }));
}
});
return ;
}
if ( req . url . startsWith ( '/api/announcements/delete' ) && req . method === 'POST' ) {
let body = '' ;
req . on ( 'data' , ( c ) => body += c . toString ());
req . on ( 'end' , () => {
try {
const data = JSON . parse ( body );
const filename = data . filename ;
if ( ! filename ) {
res . writeHead ( 400 , { 'Content-Type' : 'application/json' });
res . end ( JSON . stringify ({ error : 'Filename required' }));
return ;
}
const filePath = path . join ( announcementsDir , path . basename ( filename ));
if ( ! fs . existsSync ( filePath )) {
res . writeHead ( 404 , { 'Content-Type' : 'application/json' });
res . end ( JSON . stringify ({ error : 'File not found' }));
return ;
}
// Check if this is a default image and mark it as deleted
const basename = path . basename ( filename ). toLowerCase ();
if ( basename === 'announcements.png' ) {
markDefaultAsDeleted ( 'announcements' , 'Announcements.png' );
}
fs . unlinkSync ( filePath );
res . writeHead ( 200 , { 'Content-Type' : 'application/json' });
res . end ( JSON . stringify ({ ok : true }));
} catch ( e ) {
res . writeHead ( 500 , { 'Content-Type' : 'application/json' });
res . end ( JSON . stringify ({ error : 'Failed to delete file' , detail : e . message }));
}
});
return ;
}
// Graphics endpoints (fileinfo, list, order, resolve, upload, set-default, delete)
if ( req . url . startsWith ( '/api/graphics/fileinfo' )) {
try {
const u = new URL ( req . url , 'http://localhost' );
let fileParam = u . searchParams . get ( 'file' ) || '' ;
if ( ! fileParam ) {
try {
const defaultPath = path . join ( graphicsDir , 'default.txt' );
if ( fs . existsSync ( defaultPath )) {
const cfg = fs . readFileSync ( defaultPath , 'utf-8' );
const firstLine = ( cfg || '' ). split ( /\r?\n/ ). find ( l => l . trim ());
if ( firstLine ) fileParam = firstLine . trim ();
}
} catch ( _ ) {}
}
const fileName = path . basename ( fileParam );
let filePath = path . join ( graphicsDir , fileName );
if ( ! fs . existsSync ( filePath )) {
try {
const files = fs . readdirSync ( graphicsDir );
const foundFile = files . find ( f => f . toLowerCase () === fileName . toLowerCase ());
if ( foundFile ) {
filePath = path . join ( graphicsDir , foundFile );
console . log ( `[SERVER] Graphics fileinfo: case-insensitive match " ${ fileName } " -> " ${ foundFile } "` );
}
} catch ( e ) {
console . warn ( `[SERVER] Graphics fileinfo directory read failed:` , e . message );
}
}
if ( ! fs . existsSync ( filePath )) {
res . writeHead ( 404 , { 'Content-Type' : 'application/json' });
res . end ( JSON . stringify ({ error : 'Graphics file not found' , file : fileName }));
return ;
}
const st = fs . statSync ( filePath );
res . writeHead ( 200 , { 'Content-Type' : 'application/json' , 'Cache-Control' : 'no-store, no-cache, must-revalidate, max-age=0' , 'Pragma' : 'no-cache' , 'Expires' : '0' });
res . end ( JSON . stringify ({ file : path . basename ( filePath ), mtimeMs : st . mtimeMs || 0 , size : st . size || 0 }));
} catch ( e ) {
res . writeHead ( 500 , { 'Content-Type' : 'application/json' });
res . end ( JSON . stringify ({ error : 'Failed to read file info' , detail : e . message }));
}
return ;
}
if ( req . url . startsWith ( '/api/graphics/list' )) {
try {
console . log ( `[SERVER] ========== GRAPHICS LIST ==========` );
console . log ( `[SERVER] Graphics directory: ${ graphicsDir } ` );
console . log ( `[SERVER] Directory exists: ${ fs . existsSync ( graphicsDir ) } ` );
const imageFiles = [];
let order = [];
let currentDefault = '' ;
try {
try {
const orderPath = path . join ( graphicsDir , 'graphics-order.json' );
if ( fs . existsSync ( orderPath )) {
const raw = fs . readFileSync ( orderPath , 'utf-8' );
const parsed = JSON . parse ( raw );
if ( Array . isArray ( parsed )) order = parsed ;
}
} catch ( e ) {
console . warn ( '[SERVER] Unable to read graphics order file:' , e . message );
}
try {
const defaultPath = path . join ( graphicsDir , 'default.txt' );
if ( fs . existsSync ( defaultPath )) {
const cfg = fs . readFileSync ( defaultPath , 'utf-8' );
const firstLine = ( cfg || '' ). split ( /\r?\n/ ). find ( l => l . trim ());
if ( firstLine ) currentDefault = firstLine . trim ();
}
} catch ( e ) {
console . error ( `[SERVER] Error reading graphics default.txt:` , e );
}
// Per-video loop flags (set from the Content Manager)
let loopCfg = {};
try {
const lp = path . join ( graphicsDir , 'video-loop.json' );
if ( fs . existsSync ( lp )) loopCfg = JSON . parse ( fs . readFileSync ( lp , 'utf-8' )) || {};
} catch ( _ ) {}
if ( ! fs . existsSync ( graphicsDir )) {
console . error ( `[SERVER] ERROR: Graphics directory does not exist: ${ graphicsDir } ` );
} else {
const allFiles = fs . readdirSync ( graphicsDir );
console . log ( `[SERVER] All files in graphicsDir ( ${ allFiles . length } total):` , allFiles );
allFiles . forEach ( name => {
// Graphics accepts videos too (tap-to-play in the app) + svg/avif images
if ( /\.(png|jpe?g|gif|bmp|webp|svg|avif|mp4|m4v|webm|mov)$/i . test ( name )) {
try {
const filePath = path . join ( graphicsDir , name );
const stats = fs . statSync ( filePath );
const version = stats . mtimeMs || Date . now ();
imageFiles . push ({
name ,
url : `/Graphics/ ${ encodeURIComponent ( name ) } ?v= ${ version } ` ,
mtimeMs : stats . mtimeMs || 0 ,
isVideo : /\.(mp4|m4v|webm|mov)$/i . test ( name ) || undefined ,
loop : /\.(mp4|m4v|webm|mov)$/i . test ( name ) ? !! loopCfg [ name ] : undefined
});
} catch ( e ) {
console . error ( `[SERVER] Error getting stats for ${ name } :` , e );
}
}
});
if ( order . length ) {
const orderMap = new Map ( order . map (( name , idx ) => [ name . toLowerCase (), idx ]));
imageFiles . sort (( a , b ) => {
const aKey = a . name . toLowerCase ();
const bKey = b . name . toLowerCase ();
const aOrd = orderMap . has ( aKey ) ? orderMap . get ( aKey ) : Number . MAX_SAFE_INTEGER ;
const bOrd = orderMap . has ( bKey ) ? orderMap . get ( bKey ) : Number . MAX_SAFE_INTEGER ;
if ( aOrd !== bOrd ) return aOrd - bOrd ;
return ( b . mtimeMs || 0 ) - ( a . mtimeMs || 0 );
});
} else {
imageFiles . sort (( a , b ) => b . mtimeMs - a . mtimeMs );
}
}
console . log ( `[SERVER] ==========================================` );
} catch ( e ) {
console . error ( `[SERVER] Error reading graphics directory:` , e );
}
res . writeHead ( 200 , { 'Content-Type' : 'application/json' , 'Cache-Control' : 'no-store, no-cache, must-revalidate, max-age=0' , 'Pragma' : 'no-cache' , 'Expires' : '0' });
res . end ( JSON . stringify ({ images : imageFiles , files : imageFiles , currentDefault , order }));
} catch ( e ) {
console . error ( `[SERVER] Error listing graphics:` , e );
res . writeHead ( 500 , { 'Content-Type' : 'application/json' , 'Cache-Control' : 'no-store, no-cache, must-revalidate, max-age=0' , 'Pragma' : 'no-cache' , 'Expires' : '0' });
res . end ( JSON . stringify ({ error : 'Failed to list graphics images' , detail : e && e . message || String ( e ) }));
}
return ;
}
// Per-video loop flag (Content Manager toggle); stored in {graphicsDir}/video-loop.json
if ( req . url . startsWith ( '/api/graphics/video-loop' ) && req . method === 'POST' ) {
try {
let body = '' ;
req . on ( 'data' , chunk => body += chunk );
req . on ( 'end' , () => {
try {
const parsed = JSON . parse ( body || '{}' );
const fileName = path . basename ( String ( parsed . filename || '' ). trim ());
if ( ! fileName || ! /\.(mp4|m4v|webm|mov)$/i . test ( fileName )) {
res . writeHead ( 400 , { 'Content-Type' : 'application/json' });
res . end ( JSON . stringify ({ error : 'Invalid video filename' }));
return ;
}
const lp = path . join ( graphicsDir , 'video-loop.json' );
let cfg = {};
try { if ( fs . existsSync ( lp )) cfg = JSON . parse ( fs . readFileSync ( lp , 'utf-8' )) || {}; } catch ( _ ) {}
cfg [ fileName ] = !! parsed . loop ;
fs . mkdirSync ( graphicsDir , { recursive : true });
fs . writeFileSync ( lp , JSON . stringify ( cfg , null , 2 ));
res . writeHead ( 200 , { 'Content-Type' : 'application/json' });
res . end ( JSON . stringify ({ ok : true , filename : fileName , loop : !! parsed . loop }));
} catch ( e ) {
res . writeHead ( 400 , { 'Content-Type' : 'application/json' });
res . end ( JSON . stringify ({ error : 'Bad JSON' , detail : e . message }));
}
});
} catch ( e ) {
res . writeHead ( 500 , { 'Content-Type' : 'application/json' });
res . end ( JSON . stringify ({ error : 'Failed to save loop setting' , detail : e . message }));
}
return ;
}
if ( req . url . startsWith ( '/api/graphics/set-order' ) && req . method === 'POST' ) {
try {
let body = '' ;
req . on ( 'data' , chunk => body += chunk );
req . on ( 'end' , () => {
try {
const parsed = JSON . parse ( body || '{}' );
const order = Array . isArray ( parsed . order ) ? parsed . order . map ( x => String ( x || '' ). trim ()). filter ( Boolean ) : [];
if ( ! order . length ) {
res . writeHead ( 400 , { 'Content-Type' : 'application/json' });
res . end ( JSON . stringify ({ error : 'Invalid order payload' }));
return ;
}
const orderPath = path . join ( graphicsDir , 'graphics-order.json' );
fs . mkdirSync ( graphicsDir , { recursive : true });
fs . writeFileSync ( orderPath , JSON . stringify ( order , null , 2 ));
res . writeHead ( 200 , { 'Content-Type' : 'application/json' });
res . end ( JSON . stringify ({ ok : true , order }));
} catch ( e ) {
res . writeHead ( 400 , { 'Content-Type' : 'application/json' });
res . end ( JSON . stringify ({ error : 'Bad JSON' , detail : e . message }));
}
});
} catch ( e ) {
res . writeHead ( 500 , { 'Content-Type' : 'application/json' });
res . end ( JSON . stringify ({ error : 'Failed to save order' , detail : e . message }));
}
return ;
}
if ( req . url . startsWith ( '/api/graphics/resolve' )) {
try {
console . log ( `[SERVER] Resolving graphic, dir: ${ graphicsDir } ` );
const attempts = [];
const seen = new Set ();
const addAttempt = ( rawName ) => {
if ( ! rawName ) return ;
const cleaned = rawName . replace ( /\\/g , '/' ). replace ( /^\/+/ , '' ). replace ( /^Graphics\//i , '' );
if ( ! cleaned || cleaned . includes ( '..' )) return ;
const key = cleaned . toLowerCase ();
if ( seen . has ( key )) return ;
seen . add ( key );
attempts . push ( cleaned );
};
try {
const defaultPath = path . join ( graphicsDir , 'default.txt' );
if ( fs . existsSync ( defaultPath )) {
const cfg = fs . readFileSync ( defaultPath , 'utf-8' );
const firstLine = ( cfg || '' ). split ( /\r?\n/ ). find ( l => l . trim ());
if ( firstLine ) addAttempt ( firstLine . trim ());
}
} catch ( e ) {
console . error ( `[SERVER] Error reading graphics default.txt:` , e );
}
[ 'graphic.jpg' , 'graphic.png' , 'Graphics.png' , 'Graphics.jpg' ]. forEach ( addAttempt );
try {
const discovered = fs . readdirSync ( graphicsDir )
. filter ( f => / \ .( png | jpe ? g | gif | bmp | webp ) $ / i . test ( f ))
. map ( name => {
try {
const st = fs . statSync ( path . join ( graphicsDir , name ));
return { name , mtimeMs : st . mtimeMs || 0 };
} catch ( _ ) {
return { name , mtimeMs : 0 };
}
})
. sort (( a , b ) => b . mtimeMs - a . mtimeMs );
discovered . forEach ( entry => addAttempt ( entry . name ));
} catch ( _ ) {}
let selected = '' ;
let selectedStat = null ;
for ( const name of attempts ) {
const candidatePath = path . join ( graphicsDir , name );
if ( fs . existsSync ( candidatePath )) {
selected = name ;
try { selectedStat = fs . statSync ( candidatePath ); } catch ( _ ) {}
break ;
}
}
if ( ! selected ) {
res . writeHead ( 404 , { 'Content-Type' : 'application/json' , 'Cache-Control' : 'no-store, no-cache, must-revalidate, max-age=0' , 'Pragma' : 'no-cache' , 'Expires' : '0' });
res . end ( JSON . stringify ({ error : 'No graphics image found' , attempts }));
return ;
}
const version = ( selectedStat && selectedStat . mtimeMs ) ? selectedStat . mtimeMs : Date . now ();
const fileAbsolute = path . join ( graphicsDir , selected );
let fileUrl = '' ;
try { fileUrl = pathToFileURL ( fileAbsolute ). href ; } catch ( _ ) {}
const primaryUrl = `/Graphics/ ${ encodeURIComponent ( selected ) } ?v= ${ version } ` ;
const attemptSet = new Set ();
const attemptUrls = [];
attemptSet . add ( selected . toLowerCase ());
attemptUrls . push ( primaryUrl );
if ( fileUrl ) {
attemptSet . add ( fileUrl . toLowerCase ());
attemptUrls . unshift ( fileUrl );
}
attempts . forEach ( name => {
const key = name . toLowerCase ();
if ( attemptSet . has ( key )) return ;
attemptSet . add ( key );
attemptUrls . push ( `/Graphics/ ${ encodeURIComponent ( name ) } ?v= ${ version } ` );
});
res . writeHead ( 200 , { 'Content-Type' : 'application/json' , 'Cache-Control' : 'no-store, no-cache, must-revalidate, max-age=0' , 'Pragma' : 'no-cache' , 'Expires' : '0' });
res . end ( JSON . stringify ({
url : primaryUrl ,
title : path . basename ( selected ),
attempts ,
all : attemptUrls
}));
} catch ( e ) {
res . writeHead ( 500 , { 'Content-Type' : 'application/json' , 'Cache-Control' : 'no-store, no-cache, must-revalidate, max-age=0' , 'Pragma' : 'no-cache' , 'Expires' : '0' });
res . end ( JSON . stringify ({ error : 'Failed to resolve graphics image' , detail : e . message }));
}
return ;
}
if ( req . url . startsWith ( '/api/graphics/upload' ) && req . method === 'POST' ) {
const rawName = ( req . headers [ 'x-filename' ] || 'graphic.jpg' ). toString ();
const safeName = rawName . replace ( /[^a-zA-Z0-9._ -]/g , '_' );
try {
fs . mkdirSync ( graphicsDir , { recursive : true });
} catch ( _ ) {}
let targetPath = path . join ( graphicsDir , safeName );
if ( fs . existsSync ( targetPath )) {
const ext = path . extname ( safeName );
const base = path . basename ( safeName , ext );
let i = 1 ;
while ( fs . existsSync ( path . join ( graphicsDir , ` ${ base } ( ${ i } ) ${ ext } ` ))) i ++ ;
targetPath = path . join ( graphicsDir , ` ${ base } ( ${ i } ) ${ ext } ` );
}
const chunks = [];
let uploadBytes = 0 ;
let uploadAborted = false ;
const MAX_UPLOAD_BYTES = 50 * 1024 * 1024 ; // 50MB cap to prevent memory-exhaustion DoS
req . on ( 'data' , ( c ) => {
if ( uploadAborted ) return ;
uploadBytes += c . length ;
if ( uploadBytes > MAX_UPLOAD_BYTES ) {
uploadAborted = true ;
try { res . writeHead ( 413 , { 'Content-Type' : 'application/json' }); res . end ( JSON . stringify ({ error : 'Payload too large' })); } catch ( _ ) {}
req . destroy ();
return ;
}
chunks . push ( c );
});
req . on ( 'end' , async () => {
try {
const buf = Buffer . concat ( chunks );
fs . writeFileSync ( targetPath , buf );
// Last-minute pictures are usually far too small for the stage TV. Upscale on the GPU
// now (a second or two) so the operator never has to think about resolution. Never
// throws: on any failure the uploaded file is left exactly as it was.
try {
const up = await upscaler . enhanceIfSmall ( targetPath );
if ( up && up . status === 'enhanced' ) console . log ( `[UPSCALE] ${ path . basename ( targetPath ) } ${ up . from } -> ${ up . to } (x ${ up . scale } , ${ up . ms } ms)` );
} catch ( _ ) {}
const savedStats = fs . statSync ( targetPath );
const savedFilename = path . basename ( targetPath );
const savedUrl = `/Graphics/ ${ encodeURIComponent ( savedFilename ) } ?v= ${ savedStats . mtimeMs } ` ;
res . writeHead ( 200 , { 'Content-Type' : 'application/json' });
res . end ( JSON . stringify ({ ok : true , file : savedFilename , url : savedUrl , mtimeMs : savedStats . mtimeMs }));
} catch ( e ) {
res . writeHead ( 500 , { 'Content-Type' : 'application/json' });
res . end ( JSON . stringify ({ error : 'Failed to save upload' , detail : e . message }));
}
});
req . on ( 'error' , ( e ) => {
res . writeHead ( 500 , { 'Content-Type' : 'application/json' });
res . end ( JSON . stringify ({ error : 'Upload error' , detail : e . message }));
});
return ;
}
// ---- Service Profiles (named presets: tabs + default media + font size + OSC config) ----
if ( req . url . startsWith ( '/api/profiles' )) {
const purl = new URL ( req . url , 'http://localhost' );
const ppath = purl . pathname ;
const profilesPath = path . join ( notesDir , 'profiles.json' );
const readProfiles = () => { try { const o = JSON . parse ( fs . readFileSync ( profilesPath , 'utf-8' ) || '{"profiles":[]}' ); return ( o && Array . isArray ( o . profiles )) ? o : { profiles : [] }; } catch ( _ ) { return { profiles : [] }; } };
const writeProfiles = ( obj ) => { fs . mkdirSync ( notesDir , { recursive : true }); fs . writeFileSync ( profilesPath , JSON . stringify ( obj , null , 2 ) + '\n' ); };
const sendJson = ( code , o ) => { res . writeHead ( code , { 'Content-Type' : 'application/json' }); res . end ( JSON . stringify ( o )); };
const readBody = ( cb ) => { let b = '' ; req . on ( 'data' , c => b += c . toString ()); req . on ( 'end' , () => { try { cb ( JSON . parse ( b || '{}' )); } catch ( e ) { sendJson ( 400 , { ok : false , error : 'Invalid JSON: ' + ( e . message || e ) }); } }); };
if ( ppath === '/api/profiles/list' && req . method === 'GET' ) {
const db = readProfiles ();
return sendJson ( 200 , { ok : true , profiles : db . profiles . map ( x => ({ name : x . name })) });
}
if ( ppath === '/api/profiles/get' && req . method === 'GET' ) {
const name = purl . searchParams . get ( 'name' ) || '' ;
const prof = readProfiles (). profiles . find ( x => x . name === name );
return prof ? sendJson ( 200 , { ok : true , profile : prof }) : sendJson ( 404 , { ok : false , error : 'Profile not found' });
}
if ( ppath === '/api/profiles/save' && req . method === 'POST' ) {
return readBody (( prof ) => {
if ( ! prof || ! prof . name || ! String ( prof . name ). trim ()) return sendJson ( 400 , { ok : false , error : 'Profile name required' });
prof . name = String ( prof . name ). trim ();
const db = readProfiles ();
const i = db . profiles . findIndex ( x => x . name === prof . name );
if ( i >= 0 ) db . profiles [ i ] = prof ; else db . profiles . push ( prof );
writeProfiles ( db );
sendJson ( 200 , { ok : true , profile : prof });
});
}
if ( ppath === '/api/profiles/delete' && req . method === 'POST' ) {
return readBody (( d ) => {
const db = readProfiles ();
db . profiles = db . profiles . filter ( x => x . name !== d . name );
writeProfiles ( db );
sendJson ( 200 , { ok : true });
});
}
if ( ppath === '/api/profiles/apply' && req . method === 'POST' ) {
return readBody (( d ) => {
const prof = readProfiles (). profiles . find ( x => x . name === d . name );
if ( ! prof ) return sendJson ( 404 , { ok : false , error : 'Profile not found' });
// Server-side apply: default media files + OSC config. (Client applies tabs + font size.)
const setDefaultFile = ( dir , file ) => {
if ( ! file ) return ;
try { const base = path . basename ( String ( file )); if ( fs . existsSync ( path . join ( dir , base ))) fs . writeFileSync ( path . join ( dir , 'default.txt' ), base + '\n' ); } catch ( _ ) {}
};
setDefaultFile ( graphicsDir , prof . defaultGraphics );
setDefaultFile ( announcementsDir , prof . defaultAnnouncements );
setDefaultFile ( notesDir , prof . defaultNotes );
setDefaultFile ( mapsDir , prof . defaultMaps );
// Splash uses splash-config.json (not default.txt); images live in the Notes dir.
if ( prof . defaultSplash ) {
try {
const base = path . basename ( String ( prof . defaultSplash ));
if ( fs . existsSync ( path . join ( notesDir , base ))) {
fs . writeFileSync ( path . join ( notesDir , 'splash-config.json' ), JSON . stringify ({ default : base }, null , 2 ), 'utf-8' );
}
} catch ( _ ) {}
}
if ( prof . osc && typeof prof . osc === 'object' ) {
try {
const cur = readOscConfig ();
[ 'address' , 'port' , 'allow' , 'allowPrefixes' , 'token' , 'feedbackEnabled' , 'feedbackAddress' , 'feedbackPort' , 'feedbackInformation' , 'feedbackTabStates' , 'accordancePort' ]. forEach ( k => { if ( k in prof . osc ) cur [ k ] = prof . osc [ k ]; });
fs . writeFileSync ( path . join ( notesDir , 'osc-config.json' ), JSON . stringify ( cur , null , 2 ) + '\n' );
} catch ( _ ) {}
}
sendJson ( 200 , { ok : true , profile : prof });
});
}
return sendJson ( 404 , { ok : false , error : 'Unknown profiles route' });
}
if ( req . url . startsWith ( '/api/graphics/set-default' ) && req . method === 'POST' ) {
let body = '' ;
req . on ( 'data' , ( c ) => body += c . toString ());
req . on ( 'end' , () => {
try {
const data = JSON . parse ( body );
const filename = data . filename ;
if ( ! filename ) {
res . writeHead ( 400 , { 'Content-Type' : 'application/json' });
res . end ( JSON . stringify ({ error : 'Filename required' }));
return ;
}
let decodedFilename = filename ;
try {
decodedFilename = decodeURIComponent ( filename );
} catch ( _ ) {}
decodedFilename = path . basename ( decodedFilename ); // strip any path components to prevent traversal
let filePath = path . join ( graphicsDir , decodedFilename );
let actualFilename = decodedFilename ;
if ( ! fs . existsSync ( filePath )) {
const files = fs . readdirSync ( graphicsDir );
const foundFile = files . find ( f => f . toLowerCase () === decodedFilename . toLowerCase ());
if ( foundFile ) {
actualFilename = foundFile ;
filePath = path . join ( graphicsDir , foundFile );
} else {
res . writeHead ( 404 , { 'Content-Type' : 'application/json' });
res . end ( JSON . stringify ({ error : 'File not found' , filename : decodedFilename , availableFiles : files . slice ( 0 , 10 ) }));
return ;
}
}
const defaultPath = path . join ( graphicsDir , 'default.txt' );
fs . mkdirSync ( graphicsDir , { recursive : true });
fs . writeFileSync ( defaultPath , actualFilename + '\n' , 'utf-8' );
res . writeHead ( 200 , { 'Content-Type' : 'application/json' });
res . end ( JSON . stringify ({ ok : true , filename : actualFilename }));
} catch ( e ) {
res . writeHead ( 500 , { 'Content-Type' : 'application/json' });
res . end ( JSON . stringify ({ error : 'Failed to set default' , detail : e . message }));
}
});
return ;
}
if ( req . url . startsWith ( '/api/graphics/delete' ) && req . method === 'POST' ) {
let body = '' ;
req . on ( 'data' , ( c ) => body += c . toString ());
req . on ( 'end' , () => {
try {
const data = JSON . parse ( body );
const filename = data . filename ;
if ( ! filename ) {
res . writeHead ( 400 , { 'Content-Type' : 'application/json' });
res . end ( JSON . stringify ({ error : 'Filename required' }));
return ;
}
const filePath = path . join ( graphicsDir , path . basename ( filename ));
if ( ! fs . existsSync ( filePath )) {
res . writeHead ( 404 , { 'Content-Type' : 'application/json' });
res . end ( JSON . stringify ({ error : 'File not found' }));
return ;
}
fs . unlinkSync ( filePath );
res . writeHead ( 200 , { 'Content-Type' : 'application/json' });
res . end ( JSON . stringify ({ ok : true }));
} catch ( e ) {
res . writeHead ( 500 , { 'Content-Type' : 'application/json' });
res . end ( JSON . stringify ({ error : 'Failed to delete file' , detail : e . message }));
}
});
return ;
}
// Maps endpoints (fileinfo, list, order, resolve, upload, set-default, delete)
if ( req . url . startsWith ( '/api/maps/fileinfo' )) {
try {
const u = new URL ( req . url , 'http://localhost' );
let fileParam = u . searchParams . get ( 'file' ) || '' ;
if ( ! fileParam ) {
try {
const defaultPath = path . join ( mapsDir , 'default.txt' );
if ( fs . existsSync ( defaultPath )) {
const cfg = fs . readFileSync ( defaultPath , 'utf-8' );
const firstLine = ( cfg || '' ). split ( /\r?\n/ ). find ( l => l . trim ());
if ( firstLine ) fileParam = firstLine . trim ();
}
} catch ( _ ) {}
}
const fileName = path . basename ( fileParam );
let filePath = path . join ( mapsDir , fileName );
if ( ! fs . existsSync ( filePath )) {
try {
const files = fs . readdirSync ( mapsDir );
const foundFile = files . find ( f => f . toLowerCase () === fileName . toLowerCase ());
if ( foundFile ) {
filePath = path . join ( mapsDir , foundFile );
console . log ( `[SERVER] Maps fileinfo: case-insensitive match " ${ fileName } " -> " ${ foundFile } "` );
}
} catch ( e ) {
console . warn ( `[SERVER] Maps fileinfo directory read failed:` , e . message );
}
}
if ( ! fs . existsSync ( filePath )) {
res . writeHead ( 404 , { 'Content-Type' : 'application/json' });
res . end ( JSON . stringify ({ error : 'Maps file not found' , file : fileName }));
return ;
}
const st = fs . statSync ( filePath );
res . writeHead ( 200 , { 'Content-Type' : 'application/json' , 'Cache-Control' : 'no-store, no-cache, must-revalidate, max-age=0' , 'Pragma' : 'no-cache' , 'Expires' : '0' });
res . end ( JSON . stringify ({ file : path . basename ( filePath ), mtimeMs : st . mtimeMs || 0 , size : st . size || 0 }));
} catch ( e ) {
res . writeHead ( 500 , { 'Content-Type' : 'application/json' });
res . end ( JSON . stringify ({ error : 'Failed to read file info' , detail : e . message }));
}
return ;
}
if ( req . url . startsWith ( '/api/maps/list' )) {
try {
console . log ( `[SERVER] ========== MAPS LIST ==========` );
console . log ( `[SERVER] Maps directory: ${ mapsDir } ` );
console . log ( `[SERVER] Directory exists: ${ fs . existsSync ( mapsDir ) } ` );
const imageFiles = [];
let order = [];
let currentDefault = '' ;
try {
try {
const orderPath = path . join ( mapsDir , 'maps-order.json' );
if ( fs . existsSync ( orderPath )) {
const raw = fs . readFileSync ( orderPath , 'utf-8' );
const parsed = JSON . parse ( raw );
if ( Array . isArray ( parsed )) order = parsed ;
}
} catch ( e ) {
console . warn ( '[SERVER] Unable to read maps order file:' , e . message );
}
try {
const defaultPath = path . join ( mapsDir , 'default.txt' );
if ( fs . existsSync ( defaultPath )) {
const cfg = fs . readFileSync ( defaultPath , 'utf-8' );
const firstLine = ( cfg || '' ). split ( /\r?\n/ ). find ( l => l . trim ());
if ( firstLine ) currentDefault = firstLine . trim ();
}
} catch ( e ) {
console . error ( `[SERVER] Error reading maps default.txt:` , e );
}
if ( ! fs . existsSync ( mapsDir )) {
console . error ( `[SERVER] ERROR: Maps directory does not exist: ${ mapsDir } ` );
} else {
const allFiles = fs . readdirSync ( mapsDir );
console . log ( `[SERVER] All files in mapsDir ( ${ allFiles . length } total):` , allFiles );
allFiles . forEach ( name => {
// Maps accepts still + vector images (SVG scales sharp under pinch-zoom).
// Video is intentionally excluded — that is the Graphics tab's job.
if ( STILL_IMAGE_RE . test ( name ) && ! MEDIA_VIDEO_RE . test ( name )) {
try {
const filePath = path . join ( mapsDir , name );
const stats = fs . statSync ( filePath );
const version = stats . mtimeMs || Date . now ();
imageFiles . push ({
name ,
url : `/Maps/ ${ encodeURIComponent ( name ) } ?v= ${ version } ` ,
mtimeMs : stats . mtimeMs || 0
});
} catch ( e ) {
console . error ( `[SERVER] Error getting stats for ${ name } :` , e );
}
}
});
if ( order . length ) {
const orderMap = new Map ( order . map (( name , idx ) => [ name . toLowerCase (), idx ]));
imageFiles . sort (( a , b ) => {
const aKey = a . name . toLowerCase ();
const bKey = b . name . toLowerCase ();
const aOrd = orderMap . has ( aKey ) ? orderMap . get ( aKey ) : Number . MAX_SAFE_INTEGER ;
const bOrd = orderMap . has ( bKey ) ? orderMap . get ( bKey ) : Number . MAX_SAFE_INTEGER ;
if ( aOrd !== bOrd ) return aOrd - bOrd ;
return ( b . mtimeMs || 0 ) - ( a . mtimeMs || 0 );
});
} else {
imageFiles . sort (( a , b ) => b . mtimeMs - a . mtimeMs );
}
}
console . log ( `[SERVER] ==========================================` );
} catch ( e ) {
console . error ( `[SERVER] Error reading maps directory:` , e );
}
res . writeHead ( 200 , { 'Content-Type' : 'application/json' , 'Cache-Control' : 'no-store, no-cache, must-revalidate, max-age=0' , 'Pragma' : 'no-cache' , 'Expires' : '0' });
res . end ( JSON . stringify ({ images : imageFiles , files : imageFiles , currentDefault , order }));
} catch ( e ) {
console . error ( `[SERVER] Error listing maps:` , e );
res . writeHead ( 500 , { 'Content-Type' : 'application/json' , 'Cache-Control' : 'no-store, no-cache, must-revalidate, max-age=0' , 'Pragma' : 'no-cache' , 'Expires' : '0' });
res . end ( JSON . stringify ({ error : 'Failed to list maps images' , detail : e && e . message || String ( e ) }));
}
return ;
}
if ( req . url . startsWith ( '/api/maps/set-order' ) && req . method === 'POST' ) {
try {
let body = '' ;
req . on ( 'data' , chunk => body += chunk );
req . on ( 'end' , () => {
try {
const parsed = JSON . parse ( body || '{}' );
const order = Array . isArray ( parsed . order ) ? parsed . order . map ( x => String ( x || '' ). trim ()). filter ( Boolean ) : [];
if ( ! order . length ) {
res . writeHead ( 400 , { 'Content-Type' : 'application/json' });
res . end ( JSON . stringify ({ error : 'Invalid order payload' }));
return ;
}
const orderPath = path . join ( mapsDir , 'maps-order.json' );
fs . mkdirSync ( mapsDir , { recursive : true });
fs . writeFileSync ( orderPath , JSON . stringify ( order , null , 2 ));
res . writeHead ( 200 , { 'Content-Type' : 'application/json' });
res . end ( JSON . stringify ({ ok : true , order }));
} catch ( e ) {
res . writeHead ( 400 , { 'Content-Type' : 'application/json' });
res . end ( JSON . stringify ({ error : 'Bad JSON' , detail : e . message }));
}
});
} catch ( e ) {
res . writeHead ( 500 , { 'Content-Type' : 'application/json' });
res . end ( JSON . stringify ({ error : 'Failed to save order' , detail : e . message }));
}
return ;
}
if ( req . url . startsWith ( '/api/maps/resolve' )) {
try {
console . log ( `[SERVER] Resolving map, dir: ${ mapsDir } ` );
const attempts = [];
const seen = new Set ();
const addAttempt = ( rawName ) => {
if ( ! rawName ) return ;
const cleaned = rawName . replace ( /\\/g , '/' ). replace ( /^\/+/ , '' ). replace ( /^Maps\//i , '' );
if ( ! cleaned || cleaned . includes ( '..' )) return ;
const key = cleaned . toLowerCase ();
if ( seen . has ( key )) return ;
seen . add ( key );
attempts . push ( cleaned );
};
try {
const defaultPath = path . join ( mapsDir , 'default.txt' );
if ( fs . existsSync ( defaultPath )) {
const cfg = fs . readFileSync ( defaultPath , 'utf-8' );
const firstLine = ( cfg || '' ). split ( /\r?\n/ ). find ( l => l . trim ());
if ( firstLine ) addAttempt ( firstLine . trim ());
}
} catch ( e ) {
console . error ( `[SERVER] Error reading maps default.txt:` , e );
}
[ 'graphic.jpg' , 'graphic.png' , 'Maps.png' , 'Maps.jpg' ]. forEach ( addAttempt );
try {
const discovered = fs . readdirSync ( mapsDir )
. filter ( f => STILL_IMAGE_RE . test ( f ) && ! MEDIA_VIDEO_RE . test ( f ))
. map ( name => {
try {
const st = fs . statSync ( path . join ( mapsDir , name ));
return { name , mtimeMs : st . mtimeMs || 0 };
} catch ( _ ) {
return { name , mtimeMs : 0 };
}
})
. sort (( a , b ) => b . mtimeMs - a . mtimeMs );
discovered . forEach ( entry => addAttempt ( entry . name ));
} catch ( _ ) {}
let selected = '' ;
let selectedStat = null ;
for ( const name of attempts ) {
const candidatePath = path . join ( mapsDir , name );
if ( fs . existsSync ( candidatePath )) {
selected = name ;
try { selectedStat = fs . statSync ( candidatePath ); } catch ( _ ) {}
break ;
}
}
if ( ! selected ) {
res . writeHead ( 404 , { 'Content-Type' : 'application/json' , 'Cache-Control' : 'no-store, no-cache, must-revalidate, max-age=0' , 'Pragma' : 'no-cache' , 'Expires' : '0' });
res . end ( JSON . stringify ({ error : 'No maps image found' , attempts }));
return ;
}
const version = ( selectedStat && selectedStat . mtimeMs ) ? selectedStat . mtimeMs : Date . now ();
const fileAbsolute = path . join ( mapsDir , selected );
let fileUrl = '' ;
try { fileUrl = pathToFileURL ( fileAbsolute ). href ; } catch ( _ ) {}
const primaryUrl = `/Maps/ ${ encodeURIComponent ( selected ) } ?v= ${ version } ` ;
const attemptSet = new Set ();
const attemptUrls = [];
attemptSet . add ( selected . toLowerCase ());
attemptUrls . push ( primaryUrl );
if ( fileUrl ) {
attemptSet . add ( fileUrl . toLowerCase ());
attemptUrls . unshift ( fileUrl );
}
attempts . forEach ( name => {
const key = name . toLowerCase ();
if ( attemptSet . has ( key )) return ;
attemptSet . add ( key );
attemptUrls . push ( `/Maps/ ${ encodeURIComponent ( name ) } ?v= ${ version } ` );
});
res . writeHead ( 200 , { 'Content-Type' : 'application/json' , 'Cache-Control' : 'no-store, no-cache, must-revalidate, max-age=0' , 'Pragma' : 'no-cache' , 'Expires' : '0' });
res . end ( JSON . stringify ({
url : primaryUrl ,
title : path . basename ( selected ),
attempts ,
all : attemptUrls
}));
} catch ( e ) {
res . writeHead ( 500 , { 'Content-Type' : 'application/json' , 'Cache-Control' : 'no-store, no-cache, must-revalidate, max-age=0' , 'Pragma' : 'no-cache' , 'Expires' : '0' });
res . end ( JSON . stringify ({ error : 'Failed to resolve maps image' , detail : e . message }));
}
return ;
}
if ( req . url . startsWith ( '/api/maps/upload' ) && req . method === 'POST' ) {
const rawName = ( req . headers [ 'x-filename' ] || 'graphic.jpg' ). toString ();
const safeName = rawName . replace ( /[^a-zA-Z0-9._ -]/g , '_' );
// The Content Manager checks this too, but that check is client-side and a direct POST would
// skip it — leaving a video sitting in /Maps/ that the list endpoint then refuses to show.
if ( ! STILL_IMAGE_RE . test ( safeName ) || MEDIA_VIDEO_RE . test ( safeName )) {
res . writeHead ( 400 , { 'Content-Type' : 'application/json' });
res . end ( JSON . stringify ({ error : 'Maps accepts still and vector images only (PNG, JPG, GIF, BMP, WEBP, SVG, AVIF). Video belongs on the Graphics tab.' }));
req . resume (); // drain the body so the socket is not left half-read
return ;
}
try {
fs . mkdirSync ( mapsDir , { recursive : true });
} catch ( _ ) {}
let targetPath = path . join ( mapsDir , safeName );
if ( fs . existsSync ( targetPath )) {
const ext = path . extname ( safeName );
const base = path . basename ( safeName , ext );
let i = 1 ;
while ( fs . existsSync ( path . join ( mapsDir , ` ${ base } ( ${ i } ) ${ ext } ` ))) i ++ ;
targetPath = path . join ( mapsDir , ` ${ base } ( ${ i } ) ${ ext } ` );
}
const chunks = [];
let uploadBytes = 0 ;
let uploadAborted = false ;
const MAX_UPLOAD_BYTES = 50 * 1024 * 1024 ; // 50MB cap to prevent memory-exhaustion DoS
req . on ( 'data' , ( c ) => {
if ( uploadAborted ) return ;
uploadBytes += c . length ;
if ( uploadBytes > MAX_UPLOAD_BYTES ) {
uploadAborted = true ;
try { res . writeHead ( 413 , { 'Content-Type' : 'application/json' }); res . end ( JSON . stringify ({ error : 'Payload too large' })); } catch ( _ ) {}
req . destroy ();
return ;
}
chunks . push ( c );
});
req . on ( 'end' , async () => {
try {
const buf = Buffer . concat ( chunks );
fs . writeFileSync ( targetPath , buf );
// Last-minute pictures are usually far too small for the stage TV. Upscale on the GPU
// now (a second or two) so the operator never has to think about resolution. Never
// throws: on any failure the uploaded file is left exactly as it was.
try {
const up = await upscaler . enhanceIfSmall ( targetPath );
if ( up && up . status === 'enhanced' ) console . log ( `[UPSCALE] ${ path . basename ( targetPath ) } ${ up . from } -> ${ up . to } (x ${ up . scale } , ${ up . ms } ms)` );
} catch ( _ ) {}
const savedStats = fs . statSync ( targetPath );
const savedFilename = path . basename ( targetPath );
const savedUrl = `/Maps/ ${ encodeURIComponent ( savedFilename ) } ?v= ${ savedStats . mtimeMs } ` ;
res . writeHead ( 200 , { 'Content-Type' : 'application/json' });
res . end ( JSON . stringify ({ ok : true , file : savedFilename , url : savedUrl , mtimeMs : savedStats . mtimeMs }));
} catch ( e ) {
res . writeHead ( 500 , { 'Content-Type' : 'application/json' });
res . end ( JSON . stringify ({ error : 'Failed to save upload' , detail : e . message }));
}
});
req . on ( 'error' , ( e ) => {
res . writeHead ( 500 , { 'Content-Type' : 'application/json' });
res . end ( JSON . stringify ({ error : 'Upload error' , detail : e . message }));
});
return ;
}
if ( req . url . startsWith ( '/api/maps/set-default' ) && req . method === 'POST' ) {
let body = '' ;
req . on ( 'data' , ( c ) => body += c . toString ());
req . on ( 'end' , () => {
try {
const data = JSON . parse ( body );
const filename = data . filename ;
if ( ! filename ) {
res . writeHead ( 400 , { 'Content-Type' : 'application/json' });
res . end ( JSON . stringify ({ error : 'Filename required' }));
return ;
}
let decodedFilename = filename ;
try {
decodedFilename = decodeURIComponent ( filename );
} catch ( _ ) {}
decodedFilename = path . basename ( decodedFilename ); // strip any path components to prevent traversal
let filePath = path . join ( mapsDir , decodedFilename );
let actualFilename = decodedFilename ;
if ( ! fs . existsSync ( filePath )) {
const files = fs . readdirSync ( mapsDir );
const foundFile = files . find ( f => f . toLowerCase () === decodedFilename . toLowerCase ());
if ( foundFile ) {
actualFilename = foundFile ;
filePath = path . join ( mapsDir , foundFile );
} else {
res . writeHead ( 404 , { 'Content-Type' : 'application/json' });
res . end ( JSON . stringify ({ error : 'File not found' , filename : decodedFilename , availableFiles : files . slice ( 0 , 10 ) }));
return ;
}
}
const defaultPath = path . join ( mapsDir , 'default.txt' );
fs . mkdirSync ( mapsDir , { recursive : true });
fs . writeFileSync ( defaultPath , actualFilename + '\n' , 'utf-8' );
res . writeHead ( 200 , { 'Content-Type' : 'application/json' });
res . end ( JSON . stringify ({ ok : true , filename : actualFilename }));
} catch ( e ) {
res . writeHead ( 500 , { 'Content-Type' : 'application/json' });
res . end ( JSON . stringify ({ error : 'Failed to set default' , detail : e . message }));
}
});
return ;
}
if ( req . url . startsWith ( '/api/maps/delete' ) && req . method === 'POST' ) {
let body = '' ;
req . on ( 'data' , ( c ) => body += c . toString ());
req . on ( 'end' , () => {
try {
const data = JSON . parse ( body );
const filename = data . filename ;
if ( ! filename ) {
res . writeHead ( 400 , { 'Content-Type' : 'application/json' });
res . end ( JSON . stringify ({ error : 'Filename required' }));
return ;
}
const filePath = path . join ( mapsDir , path . basename ( filename ));
if ( ! fs . existsSync ( filePath )) {
res . writeHead ( 404 , { 'Content-Type' : 'application/json' });
res . end ( JSON . stringify ({ error : 'File not found' }));
return ;
}
fs . unlinkSync ( filePath );
res . writeHead ( 200 , { 'Content-Type' : 'application/json' });
res . end ( JSON . stringify ({ ok : true }));
} catch ( e ) {
res . writeHead ( 500 , { 'Content-Type' : 'application/json' });
res . end ( JSON . stringify ({ error : 'Failed to delete file' , detail : e . message }));
}
});
return ;
}
// Notes endpoints (set-default, delete)
if ( req . url . startsWith ( '/api/notes/set-default' ) && req . method === 'POST' ) {
let body = '' ;
req . on ( 'data' , ( c ) => body += c . toString ());
req . on ( 'end' , () => {
try {
const data = JSON . parse ( body );
const filename = data . filename ;
if ( ! filename ) {
res . writeHead ( 400 , { 'Content-Type' : 'application/json' });
res . end ( JSON . stringify ({ error : 'Filename required' }));
return ;
}
const defaultPath = path . join ( notesDir , 'default.txt' );
fs . writeFileSync ( defaultPath , filename + '\n' , 'utf-8' );
res . writeHead ( 200 , { 'Content-Type' : 'application/json' });
res . end ( JSON . stringify ({ ok : true , filename }));
} catch ( e ) {
res . writeHead ( 500 , { 'Content-Type' : 'application/json' });
res . end ( JSON . stringify ({ error : 'Failed to set default' , detail : e . message }));
}
});
return ;
}
if ( req . url . startsWith ( '/api/notes/delete' ) && req . method === 'POST' ) {
let body = '' ;
req . on ( 'data' , ( c ) => body += c . toString ());
req . on ( 'end' , () => {
try {
const data = JSON . parse ( body );
const filename = data . filename ;
if ( ! filename ) {
res . writeHead ( 400 , { 'Content-Type' : 'application/json' });
res . end ( JSON . stringify ({ error : 'Filename required' }));
return ;
}
const filePath = path . join ( notesDir , path . basename ( filename ));
if ( ! fs . existsSync ( filePath )) {
res . writeHead ( 404 , { 'Content-Type' : 'application/json' });
res . end ( JSON . stringify ({ error : 'File not found' }));
return ;
}
// Check if this is a default image and mark it as deleted
const basename = path . basename ( filename ). toLowerCase ();
if ( basename === 'notes.png' ) {
markDefaultAsDeleted ( 'notes' , 'Notes.png' );
}
fs . unlinkSync ( filePath );
res . writeHead ( 200 , { 'Content-Type' : 'application/json' });
res . end ( JSON . stringify ({ ok : true }));
} catch ( e ) {
res . writeHead ( 500 , { 'Content-Type' : 'application/json' });
res . end ( JSON . stringify ({ error : 'Failed to delete file' , detail : e . message }));
}
});
return ;
}
// Proxy to Greek WordNet to avoid CORS issues in the browser
if ( req . url . startsWith ( '/api/wordnet/' )) {
try {
const incoming = new URL ( req . url , 'http://localhost' );
let forwardPath = incoming . pathname . replace ( /^\/api\/wordnet/ , '' ) || '/' ;
const forwardSearch = incoming . search || '' ;
// Normalize slashes
forwardPath = forwardPath . replace ( /\/{2,}/g , '/' );
// For certain endpoints, upstream prefers no trailing slash
if (( /^\/(?:lemmatize|translate)(?:\/|$)/ . test ( forwardPath ) || /^\/sentiment(?:\/|$)/ . test ( forwardPath )) && forwardPath . length > 1 ) {
// Remove trailing slash if present (but keep root '/')
forwardPath = forwardPath . replace ( /\/+$/ , '' );
}
const hostname = 'greekwordnet.chs.harvard.edu' ;
const port = 443 ;
const method = ( req . method || 'GET' ). toUpperCase ();
// Filter headers for proxy
const headers = { ... req . headers };
delete headers . host ;
delete headers . origin ;
delete headers . referer ;
delete headers . connection ;
headers [ 'host' ] = hostname ;
const makeRequest = ( pathToUse , redirectsLeft , bodyBuffer ) => {
const options = {
hostname ,
port ,
method ,
path : pathToUse ,
headers
};
// Ensure sane defaults for upstream
options . headers = options . headers || {};
options . headers [ 'Accept' ] = options . headers [ 'Accept' ] || 'application/json, text/plain, */*' ;
options . headers [ 'User-Agent' ] = options . headers [ 'User-Agent' ] || 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119 Safari/537.36' ;
options . headers [ 'Accept-Language' ] = options . headers [ 'Accept-Language' ] || 'en-US,en;q=0.9' ;
// Some servers behave better with a referer
options . headers [ 'Referer' ] = options . headers [ 'Referer' ] || `https:// ${ hostname } /` ;
options . headers [ 'Connection' ] = 'close' ;
options . headers [ 'Accept-Encoding' ] = 'identity' ;
const pr = https . request ( options , ( prRes ) => {
const status = prRes . statusCode || 500 ;
try { console . log ( `[WordNet Proxy] ${ method } ${ pathToUse }${ forwardSearch } -> ${ status } ` ); } catch ( _ ) {}
// Follow simple redirects from the upstream (e.g., trailing slash)
if ([ 301 , 302 , 307 , 308 ]. includes ( status ) && redirectsLeft > 0 ) {
const loc = prRes . headers . location || '' ;
try {
const next = new URL ( loc , `https:// ${ hostname } ` );
if ( next . hostname === hostname ) {
prRes . resume (); // drain
const nextPath = next . pathname + ( next . search || '' );
return makeRequest ( nextPath , redirectsLeft - 1 , bodyBuffer );
}
} catch ( _ ) { /* fall through */ }
}
// Pass-through response
const outHeaders = { ... prRes . headers };
// Ensure no caching during dev to see live changes
outHeaders [ 'Cache-Control' ] = 'no-store, no-cache, must-revalidate, max-age=0' ;
outHeaders [ 'Pragma' ] = 'no-cache' ;
outHeaders [ 'Expires' ] = '0' ;
res . writeHead ( status , outHeaders );
prRes . pipe ( res );
});
pr . on ( 'error' , ( e ) => {
try { console . error ( '[WordNet Proxy] Upstream error:' , e && e . message || e ); } catch ( _ ) {}
res . writeHead ( 502 , { 'Content-Type' : 'application/json' });
res . end ( JSON . stringify ({ error : 'Upstream error' , detail : String ( e && e . message || e ) }));
});
if ( bodyBuffer && bodyBuffer . length && method !== 'GET' && method !== 'HEAD' ) {
pr . write ( bodyBuffer );
}
pr . end ();
};
// Buffer request body (for POST/PUT)
if ( method === 'POST' || method === 'PUT' || method === 'PATCH' ) {
const chunks = [];
req . on ( 'data' , ( c ) => chunks . push ( c ));
req . on ( 'end' , () => {
const body = Buffer . concat ( chunks );
makeRequest ( forwardPath + forwardSearch , 2 , body );
});
req . on ( 'error' , ( e ) => {
res . writeHead ( 400 , { 'Content-Type' : 'application/json' });
res . end ( JSON . stringify ({ error : 'Proxy read error' , detail : String ( e && e . message || e ) }));
});
} else {
makeRequest ( forwardPath + forwardSearch , 2 , null );
}
} catch ( e ) {
res . writeHead ( 500 , { 'Content-Type' : 'application/json' });
res . end ( JSON . stringify ({ error : 'Proxy failure' , detail : String ( e && e . message || e ) }));
}
return ;
}
// Handle static files relative to app directory (works in packaged EXE)
// Strip query parameters and hash before processing
const urlWithoutQuery = ( req . url || '/' ). split ( '?' )[ 0 ]. split ( '#' )[ 0 ];
// Decode URL once to handle URL encoding (spaces, special chars)
let decodedUrlPath = urlWithoutQuery ;
try {
decodedUrlPath = decodeURIComponent ( urlWithoutQuery );
} catch ( e ) {
console . warn ( '[SERVER] Failed to decode URL:' , urlWithoutQuery , e );
decodedUrlPath = urlWithoutQuery ;
}
const relPath = decodedUrlPath === '/' ? 'index.html' : decodedUrlPath . replace ( /^\/+/ , '' );
// Don't normalize yet - we need to check the prefix first (normalize converts / to \ on Windows)
const safeRelPath = relPath . replace ( /^\.+(\\|\/|$)/g , '' );
// Serve Notes from external notesDir; Announcements from resolved dir; other assets from app dir
// Check for both forward and backslashes (Windows path.normalize converts / to \)
const isNotesRequest = /^Notes[\\\/]/i . test ( safeRelPath );
const isAnnouncementsRequest = /^Announcements[\\\/]/i . test ( safeRelPath );
const isGraphicsRequest = /^Graphics[\\\/]/i . test ( safeRelPath );
const isMapsRequest = /^Maps[\\\/]/i . test ( safeRelPath );
const isContentManager = safeRelPath === 'content-manager.html' || safeRelPath . toLowerCase () === 'content-manager.html' ;
const isLogViewer = safeRelPath === 'log-viewer.html' || safeRelPath . toLowerCase () === 'log-viewer.html' ;
const basePath = isNotesRequest
? notesDir
: isAnnouncementsRequest
? announcementsDir
: isGraphicsRequest
? graphicsDir
: isMapsRequest
? mapsDir
: __dirname ;
// Extract filename from path (already decoded above)
// Handle both forward and backslashes, and case-insensitive matching
let filePath ;
if ( isNotesRequest ) {
// Remove the Notes/ prefix (handles both / and \)
const filename = safeRelPath . replace ( /^Notes[\\\/]+/i , '' ). replace ( /^Notes$/i , '' );
// Normalize the filename part (handles spaces, special chars)
const normalizedFilename = path . normalize ( filename );
filePath = path . join ( notesDir , normalizedFilename );
} else if ( isAnnouncementsRequest ) {
// Remove the Announcements/ prefix (handles both / and \)
const filename = safeRelPath . replace ( /^Announcements[\\\/]+/i , '' ). replace ( /^Announcements$/i , '' );
// Normalize the filename part (handles spaces, special chars)
const normalizedFilename = path . normalize ( filename );
filePath = path . join ( announcementsDir , normalizedFilename );
console . log ( `[SERVER] Announcements file request - safeRelPath: " ${ safeRelPath } ", extracted filename: " ${ filename } ", normalized: " ${ normalizedFilename } ", full path: " ${ filePath } "` );
} else if ( isGraphicsRequest ) {
// Remove the Graphics/ prefix (handles both / and \)
const filename = safeRelPath . replace ( /^Graphics[\\\/]+/i , '' ). replace ( /^Graphics$/i , '' );
const normalizedFilename = path . normalize ( filename );
filePath = path . join ( graphicsDir , normalizedFilename );
console . log ( `[SERVER] Graphics file request - safeRelPath: " ${ safeRelPath } ", extracted filename: " ${ filename } ", normalized: " ${ normalizedFilename } ", full path: " ${ filePath } "` );
} else if ( isMapsRequest ) {
// Remove the Maps/ prefix (handles both / and \)
const filename = safeRelPath . replace ( /^Maps[\\\/]+/i , '' ). replace ( /^Maps$/i , '' );
const normalizedFilename = path . normalize ( filename );
filePath = path . join ( mapsDir , normalizedFilename );
console . log ( `[SERVER] Maps file request - safeRelPath: " ${ safeRelPath } ", extracted filename: " ${ filename } ", normalized: " ${ normalizedFilename } ", full path: " ${ filePath } "` );
} else {
// For other files, normalize the whole path
const normalized = path . normalize ( safeRelPath );
filePath = path . join ( basePath , normalized );
}
// Get file extension
const extname = path . extname ( filePath );
let contentType = mimeTypes [ extname ] || 'application/octet-stream' ;
// Debug logging for file requests
if ( isNotesRequest || isAnnouncementsRequest || isGraphicsRequest || isMapsRequest ) {
const dir = isNotesRequest ? notesDir : isAnnouncementsRequest ? announcementsDir : isGraphicsRequest ? graphicsDir : mapsDir ;
console . log ( `[SERVER] ========== FILE REQUEST DEBUG ==========` );
console . log ( `[SERVER] Request URL: ${ req . url } ` );
console . log ( `[SERVER] URL without query: ${ urlWithoutQuery } ` );
console . log ( `[SERVER] Decoded URL path: ${ decodedUrlPath } ` );
console . log ( `[SERVER] Safe relPath: ${ safeRelPath } ` );
console . log ( `[SERVER] Directory: ${ dir } ` );
console . log ( `[SERVER] Directory exists: ${ fs . existsSync ( dir ) } ` );
console . log ( `[SERVER] Resolved file path: ${ filePath } ` );
console . log ( `[SERVER] File exists: ${ fs . existsSync ( filePath ) } ` );
// Always list files in directory for debugging
try {
const files = fs . existsSync ( dir ) ? fs . readdirSync ( dir ) : [];
console . log ( `[SERVER] All files in directory ( ${ files . length } total):` , files );
if ( ! fs . existsSync ( filePath )) {
// Try to find files with similar names (case-insensitive, space variations)
const requestedName = path . basename ( filePath ). toLowerCase ();
console . log ( `[SERVER] Looking for file matching: " ${ requestedName } "` );
const similarFiles = files . filter ( f => {
const fLower = f . toLowerCase ();
return fLower === requestedName ||
fLower . replace ( /\s+/g , '' ) === requestedName . replace ( /\s+/g , '' ) ||
fLower . replace ( /\s+/g , '_' ) === requestedName . replace ( /\s+/g , '_' );
});
if ( similarFiles . length > 0 ) {
console . log ( `[SERVER] Similar files found:` , similarFiles );
} else {
console . log ( `[SERVER] No similar files found` );
}
}
} catch ( e ) {
console . error ( `[SERVER] Error listing directory:` , e );
}
console . log ( `[SERVER] ==========================================` );
}
// Try to find the file with case-insensitive matching if it doesn't exist
let actualFilePath = filePath ;
if ( ! fs . existsSync ( filePath ) && ( isNotesRequest || isAnnouncementsRequest || isGraphicsRequest || isMapsRequest )) {
try {
const dir = isNotesRequest ? notesDir : isAnnouncementsRequest ? announcementsDir : isGraphicsRequest ? graphicsDir : mapsDir ;
const requestedName = path . basename ( filePath );
// Check if this is a default graphics file and default graphics are disabled
if ( ! isDefaultGraphicsEnabled () && isDefaultGraphicsFile ( isNotesRequest ? 'Notes' : isAnnouncementsRequest ? 'Announcements' : isGraphicsRequest ? 'GraphicsTab' : 'MapsTab' , requestedName )) {
console . log ( `[SERVER] Default graphics disabled, not serving default file: ${ requestedName } ` );
res . writeHead ( 404 , { 'Content-Type' : 'text/plain' });
res . end ( `File not found: ${ requestedName } (default graphics are disabled)` );
return ;
}
if ( ! fs . existsSync ( dir )) {
console . error ( `[SERVER] Directory does not exist: ${ dir } ` );
} else {
const files = fs . readdirSync ( dir );
console . log ( `[SERVER] Searching for file " ${ requestedName } " in directory " ${ dir } "` );
console . log ( `[SERVER] Available files:` , files );
// Try exact match first (case-insensitive)
const exactMatch = files . find ( f => f . toLowerCase () === requestedName . toLowerCase ());
if ( exactMatch ) {
actualFilePath = path . join ( dir , exactMatch );
console . log ( `[SERVER] ✓ Found case-insensitive match: " ${ exactMatch } " -> " ${ actualFilePath } "` );
} else {
// Try space variations
const spaceVariations = files . filter ( f => {
const fLower = f . toLowerCase (). replace ( /\s+/g , '' );
const reqLower = requestedName . toLowerCase (). replace ( /\s+/g , '' );
return fLower === reqLower ;
});
if ( spaceVariations . length > 0 ) {
actualFilePath = path . join ( dir , spaceVariations [ 0 ]);
console . log ( `[SERVER] ✓ Found space variation match: " ${ spaceVariations [ 0 ] } " -> " ${ actualFilePath } "` );
} else {
console . error ( `[SERVER] ✗ No matching file found for " ${ requestedName } "` );
}
}
}
} catch ( e ) {
console . error ( `[SERVER] Error finding file variation:` , e );
}
}
// Security: ensure the resolved path stays within its intended base directory (block path traversal).
// Embedded "../" sequences survive path.normalize, so verify containment before reading.
{
const allowedRoot = isNotesRequest
? notesDir
: isAnnouncementsRequest
? announcementsDir
: isGraphicsRequest
? graphicsDir
: isMapsRequest
? mapsDir
: __dirname ;
const resolvedRoot = path . resolve ( allowedRoot );
const resolvedTarget = path . resolve ( actualFilePath );
if ( resolvedTarget !== resolvedRoot && ! resolvedTarget . startsWith ( resolvedRoot + path . sep )) {
console . warn ( `[SERVER] Blocked path traversal attempt: ${ req . url } -> ${ resolvedTarget } ` );
res . writeHead ( 403 , { 'Content-Type' : 'text/plain' });
res . end ( 'Forbidden' );
return ;
}
}
// Read file
fs . readFile ( actualFilePath , ( error , content ) => {
if ( error ) {
if ( error . code === 'ENOENT' ) {
// File not found - provide helpful error message with full debugging info
console . error ( `[SERVER] ========== FILE NOT FOUND ERROR ==========` );
console . error ( `[SERVER] Requested URL: ${ req . url } ` );
console . error ( `[SERVER] Decoded URL path: ${ decodedUrlPath } ` );
console . error ( `[SERVER] Safe relPath: ${ safeRelPath } ` );
console . error ( `[SERVER] Original filePath: ${ filePath } ` );
console . error ( `[SERVER] Actual filePath (after matching): ${ actualFilePath } ` );
console . error ( `[SERVER] Directory: ${ isNotesRequest ? notesDir : isAnnouncementsRequest ? announcementsDir : isGraphicsRequest ? graphicsDir : isMapsRequest ? mapsDir : basePath } ` );
if ( isAnnouncementsRequest || isNotesRequest || isGraphicsRequest || isMapsRequest ) {
const dir = isNotesRequest ? notesDir : isAnnouncementsRequest ? announcementsDir : isGraphicsRequest ? graphicsDir : mapsDir ;
try {
if ( fs . existsSync ( dir )) {
const files = fs . readdirSync ( dir );
console . error ( `[SERVER] Files in directory ( ${ files . length } ):` , files );
} else {
console . error ( `[SERVER] Directory does not exist: ${ dir } ` );
}
} catch ( e ) {
console . error ( `[SERVER] Error listing directory:` , e );
}
}
console . error ( `[SERVER] ==========================================` );
res . writeHead ( 404 , { 'Content-Type' : 'text/plain' });
res . end ( `File not found: ${ path . basename ( actualFilePath ) } ` );
} else {
// Server error
console . error ( `[SERVER] Error reading file: ${ error . message } ` );
res . writeHead ( 500 );
res . end ( `Server Error: ${ error . code } ` );
}
} else {
// Success - Set CSP headers to allow Google OAuth and pdf.js CDN/worker
const headers = {
'Content-Type' : contentType ,
'Content-Security-Policy' : "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://apis.google.com https://www.gstatic.com https://accounts.google.com https://cdn.jsdelivr.net; worker-src 'self' blob: https://cdn.jsdelivr.net; frame-src 'self' blob: https://accounts.google.com; connect-src 'self' https://www.googleapis.com https://accounts.google.com https://bible-api.com https://api.esv.org https://api.scripture.api.bible https://cdn.jsdelivr.net https://greekwordnet.chs.harvard.edu https://complete-study-bible.p.rapidapi.com https://*.rapidapi.com; img-src 'self' data: https: blob:; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://cdnjs.cloudflare.com; font-src 'self' https://fonts.gstatic.com https://cdnjs.cloudflare.com;"
};
// Disable caching for core app assets to ensure rebuilt changes are picked up
if ( /^text\/(?:html|css|javascript)$/ . test ( contentType )) {
headers [ 'Cache-Control' ] = 'no-store, no-cache, must-revalidate, max-age=0' ;
headers [ 'Pragma' ] = 'no-cache' ;
headers [ 'Expires' ] = '0' ;
}
// Also disable caching on Notes and Announcements assets so hot-swapped files are picked up immediately
const isNotesAsset = /^Notes\//i . test ( safeRelPath );
const isAnnouncementsAsset = /^Announcements\//i . test ( safeRelPath );
if ( isNotesAsset || isAnnouncementsAsset ) {
headers [ 'Cache-Control' ] = 'no-store, no-cache, must-revalidate, max-age=0' ;
headers [ 'Pragma' ] = 'no-cache' ;
headers [ 'Expires' ] = '0' ;
}
/*
* Stamp the app's own HTML: index.html/follower.html/remote.html reference their scripts as
* "app.js?v=__BUILD__". Substituting the real version here makes every build's script URLs
* distinct, so no browser, proxy or embedded view can ever serve a previous build's script
* for the current page. (These assets are already sent no-store; this closes the remaining
* gap for anything that ignores that.) Only files from the app directory are touched.
*/
if ( contentType === 'text/html' && basePath === __dirname ) {
try {
const stamped = content . toString ( 'utf-8' ). split ( '__BUILD__' ). join ( buildStamp ());
res . writeHead ( 200 , headers );
res . end ( stamped , 'utf-8' );
return ;
} catch ( _ ) { /* fall through and serve the file as-is */ }
}
res . writeHead ( 200 , headers );
res . end ( content , 'utf-8' );
}
});
});
// One stamp per server start: the version plus the start time, so even a re-installed identical
// version gets fresh script URLs.
const __serverStartStamp = Date . now (). toString ( 36 );
function buildStamp () { return appVersion () + '-' + __serverStartStamp ; }
/*
* The port the app window must load.
*
* This is exported as a FUNCTION and not just written to server-port.txt, because in a packaged build
* __dirname is inside app.asar — read-only — so the file write below silently fails and whatever port
* happened to be packaged wins forever. A stale 8081 in that file shipped once and the window loaded
* nothing, fell back to file://index.html, and the whole app came up with no server: default graphics,
* no user content, and Content Manager resolving to file:///C:/content-manager.html. Ask the running
* server, never a file.
*
* The file write stays for anything outside the app that still watches it, and is allowed to fail.
*/
let activeServerPort = null ; // null until the socket is actually bound
let announceListening ;
const listeningPromise = new Promise (( resolve ) => { announceListening = resolve ; });
export function getServerPort () { return activeServerPort ; }
/*
* Await the REAL port. Returning the intended PORT before binding is not good enough: when 8080 is
* taken (a second copy of the app, or anything else on the box) this server quietly moves to 8081 via
* the EADDRINUSE handler below, and a window that loaded 8080 anyway would attach to a stranger's
* server — observed loading a DIFFERENT running copy of the app.
*/
export function whenServerListening () { return listeningPromise ; }
const portFilePath = path . join ( __dirname , 'server-port.txt' );
server . on ( 'listening' , () => {
// The follower relay rides the same HTTP server (one port, one firewall rule).
if ( ! followerLink ) { try { followerLink = attachFollowerLink ( server , { isEnabled : () => readFollowerConfig (). enabled !== false }); } catch ( e ) { console . error ( '[FOLLOWER] relay failed to start:' , e && e . message || e ); } }
const addr = server . address ();
activeServerPort = ( typeof addr === 'object' && addr && addr . port ) ? addr . port : PORT ;
try { fs . writeFileSync ( portFilePath , String ( activeServerPort )); } catch ( _ ) {}
try { announceListening ( activeServerPort ); } catch ( _ ) {}
});
server . listen ( PORT , () => {
console . log ( `Bible App server running at http://localhost: ${ PORT } ` );
console . log ( 'Press Ctrl+C to stop the server' );
try {
llamaLauncher . ensureLlamaStarting ()
. then (( result ) => {
if ( result && result . error ) {
console . warn ( '[LLAMA] Auto-start issue:' , result . error );
} else {
console . log ( '[LLAMA] Local AI backend starting on port' , llamaLauncher . getLlamaPort ());
}
})
. catch (( e ) => {
console . warn ( '[LLAMA] Failed to auto-start local AI:' , e . message || e );
});
} catch ( e ) {
console . warn ( '[LLAMA] Failed to auto-start local AI:' , e . message || e );
}
// Start OSC immediately - renderer invoker will be registered by main.cjs
// OSC will queue commands until renderer is ready
console . log ( '[OSC] Starting OSC listener...' );
try {
startOscListener ();
// Check renderer status after a delay
setTimeout (() => {
if ( typeof __rendererInvoker === 'function' ) {
console . log ( '[OSC] ✓ Renderer invoker is ready - OSC commands will work!' );
} else {
console . warn ( '[OSC] ⚠ Renderer invoker not yet ready - OSC commands will queue until ready' );
// Keep checking
const checkInterval = setInterval (() => {
if ( typeof __rendererInvoker === 'function' ) {
console . log ( '[OSC] ✓ Renderer invoker is now ready! OSC commands will work.' );
clearInterval ( checkInterval );
}
}, 500 );
setTimeout (() => clearInterval ( checkInterval ), 10000 );
}
}, 2000 );
} catch ( e ) {
console . error ( '[OSC] ❌ Failed to start OSC listener:' , e && e . message || e );
console . error ( '[OSC] Error stack:' , e . stack );
}
2026-09-24 13:17:55 -04:00
/*
* "Check for updates automatically when the app starts" (Settings > Updates). Until now that
* setting was stored but never acted on, so the TV only learned about a new build when someone
* opened Settings and tapped Check for Updates.
*
* This CHECKS only. The answer lands in lastResult for Settings to show; installing stays a
* deliberate tap, because installing closes and reopens the app — which must never happen by
* itself in the middle of a service. The delay keeps it clear of the llama/OSC startup rush.
*/
setTimeout ( async () => {
try {
const cfg = readUpdateConfig ();
if ( ! cfg . autoCheck || ! cfg . source ) return ;
const man = await readUpdateManifest ( cfg . source );
const latest = String ( man . version || '' ). trim ();
const current = appVersion ();
const available = latest && compareVersions ( latest , current ) > 0 ;
writeUpdateConfig ({ lastCheck : Date . now (), lastResult : available ? ( 'Update ' + latest + ' available' ) : 'Up to date' });
console . log ( available
? `[UPDATE] Version ${ latest } is available (this machine has ${ current } ) — install it from Settings > Updates.`
: `[UPDATE] Up to date ( ${ current } ).` );
} catch ( e ) {
const msg = String ( e && e . message || e );
try { writeUpdateConfig ({ lastCheck : Date . now (), lastResult : 'Check failed: ' + msg }); } catch ( _ ) {}
console . warn ( '[UPDATE] Startup check failed:' , msg );
}
}, 5000 );
2026-09-24 13:05:20 -04:00
});
server . on ( 'error' , ( error ) => {
if ( error . code === 'EADDRINUSE' ) {
console . log ( `Port ${ PORT } is already in use. Trying port ${ PORT + 1 } ` );
server . listen ( PORT + 1 );
} else {
console . error ( 'Server error:' , error );
}
});