PLWL/src/lansync-discovery.js

133 lines
3.8 KiB
JavaScript

'use strict'
// UDP discovery for the LAN sync: beacons fill the devices list and double
// as liveness (a device drops off a few seconds after close).
const dgram = require('dgram')
const os = require('os')
// Broadcast to the global address AND to every interface's own subnet
// broadcast - some networks drop one or the other.
function broadcastAddresses() {
const out = ['255.255.255.255']
const interfaces = os.networkInterfaces()
for (const list of Object.values(interfaces)) {
for (const iface of list || []) {
if (iface.family !== 'IPv4' || iface.internal || !iface.netmask) continue
const addr = ipToInt(iface.address)
const mask = ipToInt(iface.netmask)
if (addr == null || mask == null) continue
out.push(intToIp((addr | (~mask >>> 0)) >>> 0))
}
}
return out
}
function ipToInt(ip) {
const parts = String(ip).split('.')
if (parts.length !== 4) return null
let out = 0
for (const part of parts) {
const n = Number(part)
if (!Number.isInteger(n) || n < 0 || n > 255) return null
out = (out << 8) | n
}
return out >>> 0
}
function intToIp(int) {
return [24, 16, 8, 0].map(shift => (int >>> shift) & 255).join('.')
}
function createDiscovery({ identity, tag, protocol, port, beaconMs, deviceTtlMs, getPort }) {
let socket = null
let timer = null
let devicesCb = null
// id -> { id, name, address, port, at }
const seen = new Map()
function listDevices() {
const known = identity().known
const now = Date.now()
return [...seen.values()]
.filter(d => now - d.at <= deviceTtlMs)
.map(d => {
const pair = known[d.id]
return { id: d.id, name: d.name, address: d.address, port: d.port, paired: !!pair, code: pair ? pair.code : '' }
})
.sort((a, b) => String(a.name).localeCompare(String(b.name)))
}
function emitDevices() {
if (devicesCb) devicesCb(listDevices())
}
function pruneDevices() {
const now = Date.now()
let changed = false
for (const [id, device] of seen) {
if (now - device.at > deviceTtlMs) {
seen.delete(id)
changed = true
}
}
if (changed) emitDevices()
}
function beacon() {
if (!socket || !getPort()) return
const me = identity()
const payload = Buffer.from(JSON.stringify({ t: tag, v: protocol, id: me.deviceId, name: os.hostname(), port: getPort() }))
for (const address of broadcastAddresses()) {
try {
socket.send(payload, 0, payload.length, port, address)
} catch {}
}
pruneDevices()
}
function start(onDevices) {
stop()
devicesCb = onDevices
socket = dgram.createSocket({ type: 'udp4', reuseAddr: true })
socket.on('error', () => stop())
socket.on('message', (buf, rinfo) => {
let msg = null
try {
msg = JSON.parse(buf.toString('utf8'))
} catch {}
if (!msg || msg.t !== tag || msg.v !== protocol || !msg.id) return
if (msg.id === identity().deviceId) return // our own broadcast
const previous = seen.get(msg.id)
const next = { id: msg.id, name: String(msg.name || rinfo.address), address: rinfo.address, port: Number(msg.port) || 0, at: Date.now() }
const changed = !previous || previous.address !== next.address || previous.port !== next.port || previous.name !== next.name
seen.set(msg.id, next)
if (changed) emitDevices()
})
socket.bind(port, () => {
try {
socket.setBroadcast(true)
} catch {}
beacon()
timer = setInterval(beacon, beaconMs)
})
}
function stop() {
if (timer) {
clearInterval(timer)
timer = null
}
if (socket) {
const closing = socket
socket = null
try {
closing.close()
} catch {}
}
}
return { start, stop, listDevices }
}
module.exports = { createDiscovery }