Band Lineup Display, the backstage app itself
The Electron app that runs on the backstage PC: stage lineup photos with mic/pack labels, packaged for Windows. It also carries the ProPresenter Now/Next feature in two forms that share one engine — propresenter-core, where every ProPresenter 7/20 quirk lives, and propresenter-app, the standalone display it serves. Until now this existed only on the production machine and as a read-only snapshot inside the ProdDash repo. Source only: the 231 MB dist/ is build output, and credentials, photos and processed images are written to the Electron userData directory, never here. tmp/pp-mock.js stays — it is the ProPresenter mock ProdDash's own CLAUDE.md tells people to run. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,279 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Config storage for the standalone Now/Next display.
|
||||
*
|
||||
* The file format matches the Band Lineup app's credential files (an `enc:` prefix
|
||||
* for OS-encrypted secrets, `plain:` for base64) so the two programs stay readable
|
||||
* to each other. Under Electron we use safeStorage; under plain `node server.js`
|
||||
* there is no OS keychain hook, so the password is base64-obfuscated at rest —
|
||||
* treat the config file as a secret either way.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const dgram = require('dgram');
|
||||
|
||||
const DEFAULT_CONFIG = {
|
||||
enabled: false,
|
||||
host: '',
|
||||
port: 0,
|
||||
password: '',
|
||||
};
|
||||
|
||||
/** Electron's safeStorage when available (kiosk mode); null under plain Node. */
|
||||
function safeStorage() {
|
||||
try {
|
||||
// eslint-disable-next-line global-require
|
||||
const electron = require('electron');
|
||||
if (electron?.safeStorage?.isEncryptionAvailable?.()) return electron.safeStorage;
|
||||
} catch {
|
||||
/* not running under Electron */
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function encodeSecret(value) {
|
||||
const text = String(value ?? '');
|
||||
if (!text) return '';
|
||||
const store = safeStorage();
|
||||
if (store) return `enc:${store.encryptString(text).toString('base64')}`;
|
||||
return `plain:${Buffer.from(text, 'utf8').toString('base64')}`;
|
||||
}
|
||||
|
||||
function decodeSecret(value) {
|
||||
const raw = String(value ?? '');
|
||||
if (!raw) return '';
|
||||
if (raw.startsWith('enc:')) {
|
||||
const store = safeStorage();
|
||||
if (!store) return ''; // written under Electron, now read by plain Node — can't decrypt
|
||||
try {
|
||||
return store.decryptString(Buffer.from(raw.slice(4), 'base64'));
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
if (raw.startsWith('plain:')) return Buffer.from(raw.slice(6), 'base64').toString('utf8');
|
||||
return raw;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where config.json lives. Override with PP_DISPLAY_DATA_DIR (handy on a NAS or
|
||||
* when several displays share one host). Electron callers pass app.getPath('userData').
|
||||
*/
|
||||
function resolveDataDir(preferred) {
|
||||
const fromEnv = process.env.PP_DISPLAY_DATA_DIR;
|
||||
if (fromEnv) return path.resolve(fromEnv);
|
||||
if (preferred) return path.resolve(preferred);
|
||||
return path.join(__dirname, 'data');
|
||||
}
|
||||
|
||||
function configPath(dataDir) {
|
||||
return path.join(dataDir, 'config.json');
|
||||
}
|
||||
|
||||
function normalize(entry) {
|
||||
const host = String(entry?.host || '').trim();
|
||||
const portNum = Number.parseInt(String(entry?.port ?? ''), 10);
|
||||
const port = Number.isFinite(portNum) && portNum > 0 && portNum <= 65535 ? portNum : 0;
|
||||
return {
|
||||
enabled: Boolean(entry?.enabled) && Boolean(host) && Boolean(port),
|
||||
host,
|
||||
port,
|
||||
password: String(entry?.password || ''),
|
||||
};
|
||||
}
|
||||
|
||||
function loadConfig(dataDir) {
|
||||
try {
|
||||
const raw = JSON.parse(fs.readFileSync(configPath(dataDir), 'utf8'));
|
||||
return normalize({
|
||||
enabled: raw.enabled,
|
||||
host: raw.host,
|
||||
port: raw.port,
|
||||
password: decodeSecret(raw.password),
|
||||
});
|
||||
} catch {
|
||||
return { ...DEFAULT_CONFIG };
|
||||
}
|
||||
}
|
||||
|
||||
function saveConfig(dataDir, config) {
|
||||
const clean = normalize(config);
|
||||
fs.mkdirSync(dataDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
configPath(dataDir),
|
||||
JSON.stringify(
|
||||
{
|
||||
enabled: clean.enabled,
|
||||
host: clean.host,
|
||||
port: clean.port,
|
||||
password: encodeSecret(clean.password),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
'utf8',
|
||||
);
|
||||
return clean;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* Which address should someone actually bookmark? *
|
||||
* *
|
||||
* An A/V machine backstage is the worst case: alongside the house LAN *
|
||||
* LAN it often has a camera/Dante network, a VPN tunnel, and on Windows *
|
||||
* Hyper-V/WSL switches that hand out plausible-looking 192.168.x *
|
||||
* addresses. Listing every non-internal IPv4 unranked (what we used to *
|
||||
* do) gives a volunteer five equal-looking URLs and no way to choose. *
|
||||
* So: hard-filter the impossible ones, score the rest, and let two *
|
||||
* stronger signals override the score — the OS's own default route, *
|
||||
* and (best of all) the local address a real viewer actually reached *
|
||||
* us on. *
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
/** Adapter names that are never the church LAN. Windows keys are friendly names. */
|
||||
const VIRTUAL_IFACE = [
|
||||
/vethernet/i, /hyper-?v/i, /\bwsl\b/i, /virtualbox/i, /vmware/i, /vmnet/i, /docker/i,
|
||||
/tailscale/i, /zerotier/i, /wintun/i, /wireguard/i, /nordlynx/i, /openvpn/i, /^tap-/i,
|
||||
/radmin/i, /hamachi/i, /npcap/i, /bluetooth/i, /teredo/i, /isatap/i, /wan miniport/i,
|
||||
/loopback pseudo/i, /^utun/i, /^awdl/i, /^llw/i, /^bridge/i, /^ipsec/i, /^gif/i, /^stf/i,
|
||||
];
|
||||
|
||||
/** Addresses that can never carry a bookmark, whatever adapter they came from. */
|
||||
function isImpossibleAddress(address) {
|
||||
if (/^169\.254\./.test(address)) return true; // DHCP failed on that NIC
|
||||
if (/^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./.test(address)) return true; // CGNAT / Tailscale
|
||||
if (/^25\./.test(address)) return true; // Hamachi
|
||||
if (/^192\.168\.56\./.test(address)) return true; // VirtualBox host-only default
|
||||
return false;
|
||||
}
|
||||
|
||||
function isPrivate(address) {
|
||||
return /^10\./.test(address)
|
||||
|| /^192\.168\./.test(address)
|
||||
|| /^172\.(1[6-9]|2\d|3[01])\./.test(address);
|
||||
}
|
||||
|
||||
/** Locally-administered MAC bit — set on virtual/bridged adapters. */
|
||||
function isLocallyAdministeredMac(mac) {
|
||||
const firstOctet = Number.parseInt(String(mac || '').split(':')[0], 16);
|
||||
return Number.isFinite(firstOctet) && (firstOctet & 0x02) !== 0;
|
||||
}
|
||||
|
||||
function scoreAddress(entry, ifaceName) {
|
||||
let score = 0;
|
||||
if (/^192\.168\./.test(entry.address)) score += 40; // overwhelmingly the church-LAN shape
|
||||
else if (/^10\./.test(entry.address)) score += 30;
|
||||
else if (/^172\./.test(entry.address)) score += 20;
|
||||
else score -= 60; // public or unrecognised
|
||||
if (entry.netmask === '255.255.255.0') score += 10;
|
||||
if (/^ethernet/i.test(ifaceName)) score += 8; // prefer wired on an A/V machine
|
||||
else if (/wi-?fi|wlan|^en0$/i.test(ifaceName)) score += 4;
|
||||
if (isLocallyAdministeredMac(entry.mac)) score -= 25;
|
||||
return score;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ranked candidate addresses for viewers, best first.
|
||||
* @param {object} [hints]
|
||||
* @param {string} [hints.defaultRoute] Address of the interface holding the default route.
|
||||
* @param {string[]} [hints.observed] Local addresses real viewers have already connected to.
|
||||
*/
|
||||
function viewerAddresses(hints = {}) {
|
||||
const defaultRoute = String(hints.defaultRoute || '');
|
||||
const observed = new Set((hints.observed || []).filter(Boolean));
|
||||
const out = [];
|
||||
const interfaces = os.networkInterfaces();
|
||||
|
||||
for (const [ifaceName, entries] of Object.entries(interfaces)) {
|
||||
if (VIRTUAL_IFACE.some((pattern) => pattern.test(ifaceName))) continue;
|
||||
for (const entry of entries || []) {
|
||||
// Node <18 reported family as a string, newer as the number 4 — accept both.
|
||||
const isIPv4 = entry.family === 'IPv4' || entry.family === 4;
|
||||
if (!isIPv4 || entry.internal) continue;
|
||||
if (isImpossibleAddress(entry.address)) continue;
|
||||
if (entry.mac === '00:00:00:00:00:00') continue; // tunnel / pseudo adapter
|
||||
|
||||
let score = scoreAddress(entry, ifaceName);
|
||||
if (entry.address === defaultRoute) score += 100; // the OS's own answer beats any heuristic
|
||||
const confirmed = observed.has(entry.address);
|
||||
if (confirmed) score += 1000; // a real viewer reached us here
|
||||
|
||||
out.push({
|
||||
address: entry.address,
|
||||
iface: ifaceName,
|
||||
netmask: entry.netmask,
|
||||
private: isPrivate(entry.address),
|
||||
confirmed,
|
||||
score,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
out.sort((a, b) => b.score - a.score || a.address.localeCompare(b.address));
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* The address on the interface that holds the default route, per the OS routing table.
|
||||
* A UDP "connect" sends no packets — it only asks the kernel which source address it
|
||||
* would use — so this is free and side-effect-less.
|
||||
*/
|
||||
function defaultRouteAddress() {
|
||||
return new Promise((resolve) => {
|
||||
let settled = false;
|
||||
const done = (value) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
resolve(value);
|
||||
};
|
||||
let socket;
|
||||
try {
|
||||
socket = dgram.createSocket('udp4');
|
||||
} catch {
|
||||
done('');
|
||||
return;
|
||||
}
|
||||
socket.once('error', () => {
|
||||
try { socket.close(); } catch { /* already closed */ }
|
||||
done('');
|
||||
});
|
||||
const timer = setTimeout(() => {
|
||||
try { socket.close(); } catch { /* already closed */ }
|
||||
done('');
|
||||
}, 400);
|
||||
timer.unref?.();
|
||||
try {
|
||||
socket.connect(53, '203.0.113.1', () => { // RFC 5737 documentation address; nothing is sent
|
||||
let address = '';
|
||||
try { address = socket.address().address || ''; } catch { /* ignore */ }
|
||||
clearTimeout(timer);
|
||||
try { socket.close(); } catch { /* already closed */ }
|
||||
done(address);
|
||||
});
|
||||
} catch {
|
||||
clearTimeout(timer);
|
||||
try { socket.close(); } catch { /* already closed */ }
|
||||
done('');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Legacy helper: flat list of plausible addresses, best first. */
|
||||
function lanAddresses() {
|
||||
return viewerAddresses().map((entry) => entry.address);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
DEFAULT_CONFIG,
|
||||
resolveDataDir,
|
||||
configPath,
|
||||
loadConfig,
|
||||
saveConfig,
|
||||
normalize,
|
||||
lanAddresses,
|
||||
viewerAddresses,
|
||||
defaultRouteAddress,
|
||||
};
|
||||
Reference in New Issue
Block a user