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,570 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* A minimal multicast-DNS responder — just enough to make one name resolve.
|
||||
*
|
||||
* WHY THIS EXISTS: a bookmark has to survive DHCP handing the host a new address.
|
||||
* The obvious answer, "bookmark the computer's name", does not work here: Windows
|
||||
* ships an mDNS *resolver* but no *responder*, so it never publishes its own
|
||||
* <name>.local, and an iPad or Mac asking for it gets silence. (Windows-to-Windows
|
||||
* appears to work only via LLMNR/NetBIOS, which Microsoft is ramping down.)
|
||||
* So the app publishes its OWN name — nownext.local by default — which macOS, iOS
|
||||
* and Windows 10 1703+/11 all resolve natively.
|
||||
*
|
||||
* Scope on purpose: one A record for one name. No service discovery (_http._tcp),
|
||||
* no PTR/SRV/TXT, no full RFC 6762 probing state machine. Zero dependencies.
|
||||
*
|
||||
* Limits worth knowing: mDNS is link-local (TTL 1) and does not cross VLANs or
|
||||
* subnets, and it needs inbound UDP 5353 through the firewall. The ranked IP
|
||||
* addresses remain the fallback for anywhere this can't reach.
|
||||
*/
|
||||
|
||||
const dgram = require('dgram');
|
||||
const os = require('os');
|
||||
|
||||
const MDNS_ADDRESS = '224.0.0.251';
|
||||
const MDNS_PORT = 5353;
|
||||
const TYPE_A = 1;
|
||||
const TYPE_ANY = 255;
|
||||
const CLASS_IN = 1;
|
||||
/** Set on unique records so resolvers replace rather than accumulate. */
|
||||
const CACHE_FLUSH = 0x8000;
|
||||
const DEFAULT_TTL = 120;
|
||||
|
||||
function ipToInt(address) {
|
||||
const parts = String(address).split('.');
|
||||
if (parts.length !== 4) return null;
|
||||
let value = 0;
|
||||
for (const part of parts) {
|
||||
const octet = Number.parseInt(part, 10);
|
||||
if (!Number.isInteger(octet) || octet < 0 || octet > 255) return null;
|
||||
value = (value * 256) + octet;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function sameSubnet(addressA, addressB, netmask) {
|
||||
const a = ipToInt(addressA);
|
||||
const b = ipToInt(addressB);
|
||||
const mask = ipToInt(netmask);
|
||||
if (a === null || b === null || mask === null) return false;
|
||||
// >>> 0 keeps the result unsigned; a /0 mask would match everything, so guard it.
|
||||
return mask !== 0 && ((a & mask) >>> 0) === ((b & mask) >>> 0);
|
||||
}
|
||||
|
||||
/** Encode a dotted name as length-prefixed DNS labels. */
|
||||
function encodeName(name) {
|
||||
const labels = String(name).replace(/\.$/, '').split('.');
|
||||
const parts = [];
|
||||
for (const label of labels) {
|
||||
const bytes = Buffer.from(label, 'utf8');
|
||||
if (bytes.length > 63) throw new Error(`mDNS label too long: ${label}`);
|
||||
parts.push(Buffer.from([bytes.length]), bytes);
|
||||
}
|
||||
parts.push(Buffer.from([0]));
|
||||
return Buffer.concat(parts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a DNS name at `offset`, following compression pointers.
|
||||
* Returns the name plus the offset just past the name in the ORIGINAL position.
|
||||
*/
|
||||
function decodeName(buffer, offset) {
|
||||
const labels = [];
|
||||
let cursor = offset;
|
||||
let end = -1;
|
||||
let hops = 0;
|
||||
while (cursor < buffer.length) {
|
||||
const length = buffer[cursor];
|
||||
if (length === 0) {
|
||||
cursor += 1;
|
||||
break;
|
||||
}
|
||||
if ((length & 0xc0) === 0xc0) {
|
||||
// Compression pointer: follow it, but the caller continues after the pointer.
|
||||
if (cursor + 1 >= buffer.length) return null;
|
||||
if (end === -1) end = cursor + 2;
|
||||
cursor = ((length & 0x3f) << 8) | buffer[cursor + 1];
|
||||
hops += 1;
|
||||
if (hops > 16) return null; // malformed / pointer loop
|
||||
continue;
|
||||
}
|
||||
if (cursor + 1 + length > buffer.length) return null;
|
||||
labels.push(buffer.toString('utf8', cursor + 1, cursor + 1 + length));
|
||||
cursor += 1 + length;
|
||||
}
|
||||
return { name: labels.join('.'), offset: end === -1 ? cursor : end };
|
||||
}
|
||||
|
||||
/** Parse the question section of a query. Returns null for anything malformed. */
|
||||
function decodeQuestions(buffer) {
|
||||
if (buffer.length < 12) return null;
|
||||
const flags = buffer.readUInt16BE(2);
|
||||
if ((flags & 0x8000) !== 0) return { isResponse: true, questions: [] }; // a response, not a query
|
||||
const count = buffer.readUInt16BE(4);
|
||||
if (count === 0 || count > 32) return { isResponse: false, questions: [] };
|
||||
const questions = [];
|
||||
let offset = 12;
|
||||
for (let i = 0; i < count; i += 1) {
|
||||
const decoded = decodeName(buffer, offset);
|
||||
if (!decoded) return null;
|
||||
offset = decoded.offset;
|
||||
if (offset + 4 > buffer.length) return null;
|
||||
const type = buffer.readUInt16BE(offset);
|
||||
const rawClass = buffer.readUInt16BE(offset + 2);
|
||||
offset += 4;
|
||||
questions.push({
|
||||
name: decoded.name,
|
||||
type,
|
||||
class: rawClass & 0x7fff,
|
||||
// The top bit of QCLASS is the "unicast response wanted" flag (RFC 6762 §5.4).
|
||||
wantsUnicast: (rawClass & 0x8000) !== 0,
|
||||
});
|
||||
}
|
||||
return { isResponse: false, questions };
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the answer section of a response, returning the A records only.
|
||||
* Needed for conflict detection: enterprise APs and mesh routers commonly REFLECT mDNS,
|
||||
* so our own announcement comes back from the reflector's address. Matching on raw bytes
|
||||
* flags that as a name clash; comparing the actual advertised addresses does not.
|
||||
*/
|
||||
function decodeAnswers(buffer) {
|
||||
if (buffer.length < 12) return [];
|
||||
const answerCount = buffer.readUInt16BE(6);
|
||||
if (answerCount === 0 || answerCount > 64) return [];
|
||||
const questionCount = buffer.readUInt16BE(4);
|
||||
let offset = 12;
|
||||
// Skip the question section, if the responder echoed it.
|
||||
for (let i = 0; i < questionCount; i += 1) {
|
||||
const decoded = decodeName(buffer, offset);
|
||||
if (!decoded) return [];
|
||||
offset = decoded.offset + 4;
|
||||
}
|
||||
const answers = [];
|
||||
for (let i = 0; i < answerCount; i += 1) {
|
||||
const decoded = decodeName(buffer, offset);
|
||||
if (!decoded) break;
|
||||
offset = decoded.offset;
|
||||
if (offset + 10 > buffer.length) break;
|
||||
const type = buffer.readUInt16BE(offset);
|
||||
const rdLength = buffer.readUInt16BE(offset + 8);
|
||||
offset += 10;
|
||||
if (offset + rdLength > buffer.length) break;
|
||||
if (type === TYPE_A && rdLength === 4) {
|
||||
answers.push({
|
||||
name: decoded.name,
|
||||
address: `${buffer[offset]}.${buffer[offset + 1]}.${buffer[offset + 2]}.${buffer[offset + 3]}`,
|
||||
});
|
||||
}
|
||||
offset += rdLength;
|
||||
}
|
||||
return answers;
|
||||
}
|
||||
|
||||
/** Build a standard mDNS query for one name (type A, class IN). */
|
||||
function buildQuery(name) {
|
||||
const header = Buffer.alloc(12);
|
||||
header.writeUInt16BE(0, 0); // mDNS queries use id 0
|
||||
header.writeUInt16BE(0, 2); // standard query
|
||||
header.writeUInt16BE(1, 4); // one question
|
||||
const question = Buffer.alloc(4);
|
||||
question.writeUInt16BE(TYPE_A, 0);
|
||||
question.writeUInt16BE(CLASS_IN, 2);
|
||||
return Buffer.concat([header, encodeName(name), question]);
|
||||
}
|
||||
|
||||
/** Build a response packet carrying one A record per address. */
|
||||
function buildResponse(name, addresses, ttl, queryId) {
|
||||
const encodedName = encodeName(name);
|
||||
const answers = addresses.map((address) => {
|
||||
const rdata = Buffer.from(String(address).split('.').map((octet) => Number.parseInt(octet, 10)));
|
||||
const record = Buffer.alloc(10);
|
||||
record.writeUInt16BE(TYPE_A, 0);
|
||||
record.writeUInt16BE(CLASS_IN | CACHE_FLUSH, 2);
|
||||
record.writeUInt32BE(ttl, 4);
|
||||
record.writeUInt16BE(4, 8);
|
||||
return Buffer.concat([encodedName, record, rdata]);
|
||||
});
|
||||
|
||||
const header = Buffer.alloc(12);
|
||||
header.writeUInt16BE(queryId || 0, 0);
|
||||
header.writeUInt16BE(0x8400, 2); // QR=1 (response), AA=1 (authoritative)
|
||||
header.writeUInt16BE(0, 4); // no question echoed back
|
||||
header.writeUInt16BE(answers.length, 6);
|
||||
header.writeUInt16BE(0, 8);
|
||||
header.writeUInt16BE(0, 10);
|
||||
return Buffer.concat([header, ...answers]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} [options]
|
||||
* @param {string} [options.name] Bare label to publish; ".local" is appended. Default "nownext".
|
||||
* @param {() => string[]} [options.getAddresses] Current IPv4 addresses to advertise, best first.
|
||||
* @param {(message: string) => void} [options.log]
|
||||
*/
|
||||
function createMdnsResponder(options = {}) {
|
||||
const label = String(options.name || 'nownext').replace(/[^a-zA-Z0-9-]/g, '').toLowerCase() || 'nownext';
|
||||
const fqdn = `${label}.local`;
|
||||
const ttl = Number.isFinite(options.ttl) && options.ttl > 0 ? options.ttl : DEFAULT_TTL;
|
||||
const getAddresses = typeof options.getAddresses === 'function' ? options.getAddresses : () => [];
|
||||
const log = typeof options.log === 'function' ? options.log : () => {};
|
||||
|
||||
let socket = null;
|
||||
let started = false;
|
||||
let announceTimer = null;
|
||||
let lastError = '';
|
||||
let conflictHost = '';
|
||||
/** True while we have stood down from the name because another host really holds it. */
|
||||
let relinquished = false;
|
||||
/** In-flight conflict verification: { foreign: string } while probing, else null. */
|
||||
let probe = null;
|
||||
let recoveryTimer = null;
|
||||
|
||||
/** Interfaces we can advertise on, as {address, netmask}. */
|
||||
function localInterfaces() {
|
||||
const out = [];
|
||||
for (const entries of Object.values(os.networkInterfaces())) {
|
||||
for (const entry of entries || []) {
|
||||
const isIPv4 = entry.family === 'IPv4' || entry.family === 4;
|
||||
if (isIPv4 && !entry.internal) out.push({ address: entry.address, netmask: entry.netmask });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Which of our addresses to hand a particular asker. On a multi-homed A/V machine
|
||||
* (house LAN + camera network) answering with everything would send a studio PC an
|
||||
* address it cannot route to, so prefer addresses on the asker's own subnet.
|
||||
*/
|
||||
function addressesForPeer(peerAddress) {
|
||||
const advertised = getAddresses().filter(Boolean);
|
||||
if (!advertised.length) return [];
|
||||
const interfaces = localInterfaces();
|
||||
const onPeerSubnet = advertised.filter((address) => {
|
||||
const match = interfaces.find((entry) => entry.address === address);
|
||||
return match && sameSubnet(peerAddress, address, match.netmask);
|
||||
});
|
||||
return onPeerSubnet.length ? onPeerSubnet : advertised;
|
||||
}
|
||||
|
||||
function send(message, port, address) {
|
||||
if (!socket) return;
|
||||
try {
|
||||
socket.send(message, port, address, (err) => {
|
||||
if (err) lastError = err.message;
|
||||
});
|
||||
} catch (err) {
|
||||
lastError = err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
}
|
||||
|
||||
/** Aim the next multicast send out of a specific interface. Best effort. */
|
||||
function setOutgoingInterface(address) {
|
||||
try {
|
||||
socket.setMulticastInterface(address);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Announce (or, with ttlValue 0, withdraw) the name.
|
||||
*
|
||||
* One packet PER INTERFACE, each advertising only that interface's own address. Sending
|
||||
* every address to every interface is the tempting shortcut and it is wrong: a studio PC
|
||||
* would cache all of them, and browsers try cached addresses in turn — so a machine that
|
||||
* can only route to the house LAN might sit waiting on the camera-network address first.
|
||||
* A listener should only ever learn the address it can actually reach us on.
|
||||
*/
|
||||
function announce(ttlValue = ttl) {
|
||||
const advertised = getAddresses().filter(Boolean);
|
||||
if (!advertised.length) return;
|
||||
const interfaces = localInterfaces();
|
||||
let sent = 0;
|
||||
for (const address of advertised) {
|
||||
if (!interfaces.some((entry) => entry.address === address)) continue;
|
||||
if (!setOutgoingInterface(address)) continue;
|
||||
send(buildResponse(fqdn, [address], ttlValue, 0), MDNS_PORT, MDNS_ADDRESS);
|
||||
sent += 1;
|
||||
}
|
||||
if (!sent) {
|
||||
// No interface would take the hint — fall back to one packet on the default route.
|
||||
setOutgoingInterface('0.0.0.0');
|
||||
send(buildResponse(fqdn, advertised, ttlValue, 0), MDNS_PORT, MDNS_ADDRESS);
|
||||
}
|
||||
setOutgoingInterface('0.0.0.0');
|
||||
}
|
||||
|
||||
function handleMessage(buffer, rinfo) {
|
||||
let parsed;
|
||||
try {
|
||||
parsed = decodeQuestions(buffer);
|
||||
} catch {
|
||||
return; // never let a malformed packet from the network throw
|
||||
}
|
||||
if (!parsed) return;
|
||||
|
||||
if (parsed.isResponse) {
|
||||
// A genuine clash is another host advertising OUR name with an address that is not
|
||||
// ours. A reflected copy of our own announcement carries only our own addresses, so
|
||||
// it no longer trips this.
|
||||
const mine = new Set(localInterfaces().map((entry) => entry.address));
|
||||
for (const answer of decodeAnswers(buffer)) {
|
||||
if (answer.name.toLowerCase() !== fqdn) continue;
|
||||
if (mine.has(answer.address)) continue;
|
||||
// Only believe a claim from something on one of our own subnets. An off-link
|
||||
// packet naming our record is a spoof, not a neighbour.
|
||||
if (!onOurLink(rinfo.address)) continue;
|
||||
if (probe) {
|
||||
probe.foreign = answer.address; // a probe is running: this answers it
|
||||
} else if (!relinquished) {
|
||||
void verifyThenConcede(answer.address);
|
||||
}
|
||||
break;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Once the name is contested we must not answer for it (RFC 6762 §9). Two responders
|
||||
// both answering is worse than neither: the name resolves to a different machine each
|
||||
// time, so half the screens land on the wrong host and show nothing.
|
||||
if (relinquished) return;
|
||||
|
||||
for (const question of parsed.questions) {
|
||||
if (question.name.toLowerCase() !== fqdn) continue;
|
||||
if (question.type !== TYPE_A && question.type !== TYPE_ANY) continue;
|
||||
if (question.class !== CLASS_IN && question.class !== TYPE_ANY) continue;
|
||||
const addresses = addressesForPeer(rinfo.address);
|
||||
if (!addresses.length) continue;
|
||||
const queryId = buffer.readUInt16BE(0);
|
||||
// Legacy resolvers (source port != 5353) and QU queries want a direct reply.
|
||||
if (question.wantsUnicast || rinfo.port !== MDNS_PORT) {
|
||||
send(buildResponse(fqdn, addresses, ttl, queryId), rinfo.port, rinfo.address);
|
||||
} else {
|
||||
// Reply out of the interface the answer is valid for, so nobody else on another
|
||||
// network caches an address they cannot reach.
|
||||
setOutgoingInterface(addresses[0]);
|
||||
send(buildResponse(fqdn, addresses, ttl, 0), MDNS_PORT, MDNS_ADDRESS);
|
||||
setOutgoingInterface('0.0.0.0');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Is this address on one of our own subnets? */
|
||||
function onOurLink(address) {
|
||||
return localInterfaces().some((entry) => sameSubnet(address, entry.address, entry.netmask));
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask whether anyone else is holding our name (RFC 6762 §8.1-style probing: three
|
||||
* queries, 250ms apart). Resolves with the foreign address, or '' if the link is quiet.
|
||||
*
|
||||
* This is the difference between a robust responder and a one-packet kill switch. A
|
||||
* genuine second host answers a direct question; a spoofer who fires one unsolicited
|
||||
* packet and leaves does not.
|
||||
*/
|
||||
function probeForConflict() {
|
||||
return new Promise((resolve) => {
|
||||
if (!socket) {
|
||||
resolve('');
|
||||
return;
|
||||
}
|
||||
probe = { foreign: '' };
|
||||
const query = buildQuery(fqdn);
|
||||
let sent = 0;
|
||||
const tick = () => {
|
||||
if (!socket || !probe) return;
|
||||
if (probe.foreign || sent >= 3) {
|
||||
const found = probe ? probe.foreign : '';
|
||||
probe = null;
|
||||
resolve(found);
|
||||
return;
|
||||
}
|
||||
sent += 1;
|
||||
send(query, MDNS_PORT, MDNS_ADDRESS);
|
||||
const next = setTimeout(tick, 250);
|
||||
next.unref?.();
|
||||
};
|
||||
tick();
|
||||
// Give the third query time to be answered before declaring the link quiet.
|
||||
const done = setTimeout(() => {
|
||||
const found = probe ? probe.foreign : '';
|
||||
probe = null;
|
||||
resolve(found);
|
||||
}, 1200);
|
||||
done.unref?.();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A neighbour appears to hold our name. Verify it before standing down, then keep
|
||||
* checking so the name comes back when they go away.
|
||||
*
|
||||
* Deliberately NOT the RFC's rename-on-conflict: the URL is the whole point of this
|
||||
* feature, and renaming would break every bookmark in the building — and would hand a
|
||||
* spoofer the good name. Standing aside temporarily keeps resolution unambiguous while
|
||||
* the numeric addresses (which the host app lists) keep working throughout.
|
||||
*/
|
||||
async function verifyThenConcede(claimedBy) {
|
||||
if (relinquished || probe) return;
|
||||
const confirmed = await probeForConflict();
|
||||
if (!confirmed) {
|
||||
log(`ignored an unverified claim on ${fqdn} (nobody answered a probe) — keeping the name`);
|
||||
return;
|
||||
}
|
||||
conflictHost = confirmed;
|
||||
relinquished = true;
|
||||
log(`another host at ${confirmed} really holds ${fqdn} — standing down until it leaves`);
|
||||
if (announceTimer) {
|
||||
clearInterval(announceTimer);
|
||||
announceTimer = null;
|
||||
}
|
||||
try {
|
||||
announce(0); // TTL 0: resolvers drop OUR records so the name stops being ambiguous
|
||||
} catch {
|
||||
/* nothing more we can do */
|
||||
}
|
||||
startRecoveryWatch();
|
||||
}
|
||||
|
||||
/** While stood down, re-probe periodically and reclaim the name once the link is quiet. */
|
||||
function startRecoveryWatch() {
|
||||
if (recoveryTimer) return;
|
||||
recoveryTimer = setInterval(async () => {
|
||||
if (!socket || !relinquished) return;
|
||||
const stillThere = await probeForConflict();
|
||||
if (stillThere) return;
|
||||
log(`${fqdn} is free again — reclaiming it`);
|
||||
relinquished = false;
|
||||
conflictHost = '';
|
||||
clearInterval(recoveryTimer);
|
||||
recoveryTimer = null;
|
||||
announce();
|
||||
announceTimer = setInterval(announce, Math.max(30, ttl / 2) * 1000);
|
||||
announceTimer.unref?.();
|
||||
}, 60000);
|
||||
recoveryTimer.unref?.();
|
||||
}
|
||||
|
||||
function start() {
|
||||
if (started) return Promise.resolve({ ok: true, name: fqdn });
|
||||
started = true;
|
||||
return new Promise((resolve) => {
|
||||
let settled = false;
|
||||
const finish = (result) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
resolve(result);
|
||||
};
|
||||
try {
|
||||
// reuseAddr is essential: macOS already runs mDNSResponder on 5353 and we must
|
||||
// coexist with it (it will not answer for our name, so there is no conflict).
|
||||
socket = dgram.createSocket({ type: 'udp4', reuseAddr: true });
|
||||
} catch (err) {
|
||||
lastError = err instanceof Error ? err.message : String(err);
|
||||
started = false;
|
||||
finish({ ok: false, name: fqdn, error: lastError });
|
||||
return;
|
||||
}
|
||||
|
||||
socket.on('error', (err) => {
|
||||
lastError = err instanceof Error ? err.message : String(err);
|
||||
log(`socket error: ${lastError}`);
|
||||
try { socket.close(); } catch { /* already closed */ }
|
||||
socket = null;
|
||||
started = false;
|
||||
finish({ ok: false, name: fqdn, error: lastError });
|
||||
});
|
||||
|
||||
socket.on('message', (buffer, rinfo) => {
|
||||
try {
|
||||
handleMessage(buffer, rinfo);
|
||||
} catch (err) {
|
||||
lastError = err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
});
|
||||
|
||||
socket.bind({ port: MDNS_PORT, exclusive: false }, () => {
|
||||
try {
|
||||
socket.setMulticastTTL(255); // RFC 6762 §11
|
||||
} catch { /* not fatal */ }
|
||||
let joined = 0;
|
||||
for (const entry of localInterfaces()) {
|
||||
try {
|
||||
socket.addMembership(MDNS_ADDRESS, entry.address);
|
||||
joined += 1;
|
||||
} catch {
|
||||
/* some adapters refuse multicast; others still work */
|
||||
}
|
||||
}
|
||||
if (!joined) {
|
||||
try {
|
||||
socket.addMembership(MDNS_ADDRESS);
|
||||
joined = 1;
|
||||
} catch (err) {
|
||||
lastError = err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
}
|
||||
// Announce twice a second apart (RFC 6762 §8.3 asks for repeats), then hourly-ish
|
||||
// so a resolver that missed both still learns the name.
|
||||
announce();
|
||||
const second = setTimeout(announce, 1000);
|
||||
second.unref?.();
|
||||
announceTimer = setInterval(announce, Math.max(30, ttl / 2) * 1000);
|
||||
announceTimer.unref?.();
|
||||
finish({ ok: joined > 0, name: fqdn, interfaces: joined, error: joined ? '' : lastError });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function stop() {
|
||||
if (announceTimer) {
|
||||
clearInterval(announceTimer);
|
||||
announceTimer = null;
|
||||
}
|
||||
if (recoveryTimer) {
|
||||
clearInterval(recoveryTimer);
|
||||
recoveryTimer = null;
|
||||
}
|
||||
probe = null;
|
||||
if (socket) {
|
||||
// Goodbye: TTL 0 tells resolvers to forget the name immediately, so a screen opened
|
||||
// right after we stop fails fast instead of hanging on a stale cache entry.
|
||||
try {
|
||||
announce(0);
|
||||
} catch { /* going away anyway */ }
|
||||
try { socket.close(); } catch { /* already closed */ }
|
||||
socket = null;
|
||||
}
|
||||
started = false;
|
||||
}
|
||||
|
||||
return {
|
||||
start,
|
||||
stop,
|
||||
get name() { return fqdn; },
|
||||
get url() { return fqdn; },
|
||||
isRunning: () => Boolean(socket),
|
||||
/** True only while we still own the name and are answering for it. */
|
||||
isPublishing: () => Boolean(socket) && !relinquished,
|
||||
getLastError: () => lastError,
|
||||
getConflictHost: () => conflictHost,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createMdnsResponder,
|
||||
MDNS_PORT,
|
||||
// exported for tests
|
||||
buildQuery,
|
||||
encodeName,
|
||||
decodeName,
|
||||
decodeQuestions,
|
||||
decodeAnswers,
|
||||
buildResponse,
|
||||
sameSubnet,
|
||||
};
|
||||
Reference in New Issue
Block a user