The dev machine is now a laptop, so the release path had to stop assuming Windows and a reachable network share. - tools/build-win.cjs: build the Windows x64 installer from macOS or Windows. electron-builder fetches its own mac wine build, so the only real trap is architecture: a plain `electron-builder --win` on Apple Silicon silently produces an arm64 installer the stage PC cannot run. This pins --x64 and strips the CSC_* signing vars (an EMPTY WIN_CSC_LINK aborts the build). - tools/publish-update.cjs: an http(s) destination now uploads to a Gitea generic package registry. Not a Gitea release: this server caps release attachments at 100 MB and disallows .exe, while the package registry takes the 750 MB installer and serves it anonymously — which the updater requires, since it sends no credentials. Installer first, manifest last, so a TV checking mid-publish never sees a manifest naming a file that is not there yet. - tools/release.cjs: bump, build and publish in one command. - server.js: honour "check for updates automatically when the app starts". The setting was stored but never acted on. It checks only — installing restarts the app, which must stay a deliberate tap. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
7863 lines
409 KiB
JavaScript
7863 lines
409 KiB
JavaScript
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);
|
||
}
|
||
|
||
/*
|
||
* "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);
|
||
});
|
||
|
||
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);
|
||
}
|
||
});
|