diff options
Diffstat (limited to 'packages/meshbay-client/src')
| -rw-r--r-- | packages/meshbay-client/src/cast-chromecast.js | 157 | ||||
| -rw-r--r-- | packages/meshbay-client/src/cast-relay.js | 293 | ||||
| -rw-r--r-- | packages/meshbay-client/src/main.js | 59 | ||||
| -rw-r--r-- | packages/meshbay-client/src/preload.js | 16 |
4 files changed, 525 insertions, 0 deletions
diff --git a/packages/meshbay-client/src/cast-chromecast.js b/packages/meshbay-client/src/cast-chromecast.js new file mode 100644 index 0000000..50f79fd --- /dev/null +++ b/packages/meshbay-client/src/cast-chromecast.js @@ -0,0 +1,157 @@ +'use strict'; + +const Bonjour = require('bonjour-service').Bonjour; +const CastClient = require('castv2-client').Client; +const DefaultMediaReceiver = require('castv2-client').DefaultMediaReceiver; + +const SCAN_DURATION_MS = 6000; + +class CastChromecast { + constructor() { + this._bonjour = null; + this._browser = null; + this._devices = new Map(); + this._client = null; + this._player = null; + this._connectedDevice = null; + } + + async discover() { + this._devices.clear(); + + if (this._browser) { + this._browser.stop(); + this._browser = null; + } + + if (!this._bonjour) { + this._bonjour = new Bonjour(); + } + + console.log('[cast-chromecast] scanning for devices...'); + + return new Promise((resolve) => { + this._browser = this._bonjour.find({ type: 'googlecast' }, (service) => { + const id = service.txt?.id || service.name; + const name = service.txt?.fn || service.name; + const host = service.addresses?.find((a) => /^\d+\.\d+\.\d+\.\d+$/.test(a)) + || (service.referer && service.referer.address); + const port = service.port || 8009; + + if (host && id) { + this._devices.set(id, { id, name, host, port }); + console.log(`[cast-chromecast] discovered: "${name}" at ${host}:${port}`); + } + }); + + setTimeout(() => { + if (this._browser) { + this._browser.stop(); + this._browser = null; + } + const devices = Array.from(this._devices.values()); + console.log(`[cast-chromecast] scan complete: ${devices.length} device(s)`); + resolve(devices); + }, SCAN_DURATION_MS); + }); + } + + async connect(deviceId, mediaUrl) { + const device = this._devices.get(deviceId); + if (!device) throw new Error(`Unknown device: ${deviceId}`); + + await this.disconnect(); + + console.log(`[cast-chromecast] connecting to "${device.name}" (${device.host}:${device.port})`); + + const client = new CastClient(); + + await new Promise((resolve, reject) => { + client.on('error', (err) => { + console.log(`[cast-chromecast] client error: ${err.message}`); + this._cleanup(); + }); + client.connect(device.host, () => resolve()); + setTimeout(() => reject(new Error('Connection timeout')), 10000); + }); + + this._client = client; + this._connectedDevice = device; + + const player = await new Promise((resolve, reject) => { + client.launch(DefaultMediaReceiver, (err, p) => { + if (err) return reject(err); + resolve(p); + }); + }); + + this._player = player; + + player.on('status', (status) => { + console.log(`[cast-chromecast] player status: ${status.playerState}`); + }); + + const media = { + contentId: mediaUrl, + contentType: 'video/mp4', + streamType: 'LIVE', + }; + + const status = await new Promise((resolve, reject) => { + player.load(media, { autoplay: true }, (err, s) => { + if (err) return reject(err); + resolve(s); + }); + }); + + console.log(`[cast-chromecast] loaded on "${device.name}", state: ${status.playerState}`); + return { deviceName: device.name, playerState: status.playerState }; + } + + async reload(mediaUrl) { + if (!this._player) throw new Error('Not connected'); + console.log(`[cast-chromecast] reloading stream on "${this._connectedDevice?.name}"`); + const media = { + contentId: mediaUrl, + contentType: 'video/mp4', + streamType: 'LIVE', + }; + const status = await new Promise((resolve, reject) => { + this._player.load(media, { autoplay: true }, (err, s) => { + if (err) return reject(err); + resolve(s); + }); + }); + console.log(`[cast-chromecast] reloaded, state: ${status.playerState}`); + return { playerState: status.playerState }; + } + + async disconnect() { + if (this._player) { + try { + await new Promise((resolve) => { + this._player.stop(() => resolve()); + }); + } catch { /* already stopped */ } + } + this._cleanup(); + } + + getStatus() { + return { + connected: this._client !== null, + deviceName: this._connectedDevice?.name || null, + }; + } + + _cleanup() { + if (this._client) { + try { this._client.close(); } catch { /* ignore */ } + } + this._client = null; + this._player = null; + this._connectedDevice = null; + } +} + +module.exports = CastChromecast; diff --git a/packages/meshbay-client/src/cast-relay.js b/packages/meshbay-client/src/cast-relay.js new file mode 100644 index 0000000..12939c4 --- /dev/null +++ b/packages/meshbay-client/src/cast-relay.js @@ -0,0 +1,293 @@ +/** + * Local HTTP relay for LAN casting. + * + * Re-serves decrypted fMP4 segments from the renderer over HTTP so that a + * Chromecast, Smart TV or any player on the same Wi-Fi can stream the video. + * + * The renderer sends raw byte chunks from the WebRTC DataChannel — these are + * arbitrary-sized slices of the fMP4 stream, NOT aligned to MP4 box boundaries. + * MSE handles this internally, but external players (VLC, Chromecast) need + * properly framed fMP4 fragments. The BoxAccumulator reassembles the byte + * stream and emits complete moof+mdat pairs. + * + * Mitigations: + * · Bind to the LAN interface, never 0.0.0.0 + * · Fixed port range (19550-19553), opened only during active cast + * · Unguessable token in the URL (32 hex chars) + * · Cache-Control: no-store on every response + * · Server destroyed when playback stops — zero residual surface + */ + +'use strict'; + +const crypto = require('node:crypto'); +const http = require('node:http'); +const os = require('node:os'); + +const RING_CAP = 64; +const BACKPRESSURE_HIGH = 8 * 1024 * 1024; +const MOOF = 0x6d6f6f66; +const PORT_BASE = 19550; +const PORT_COUNT = 4; + +function lanAddress() { + const ifaces = os.networkInterfaces(); + for (const name of Object.keys(ifaces)) { + for (const iface of ifaces[name]) { + if (!iface.internal && iface.family === 'IPv4') { + return iface.address; + } + } + } + return '127.0.0.1'; +} + +class BoxAccumulator { + constructor() { + this._buf = Buffer.alloc(0); + this._synced = false; + } + + push(data) { + this._buf = Buffer.concat([this._buf, data]); + const fragments = []; + + if (!this._synced) { + const idx = this._findMoof(); + if (idx === -1) return fragments; + console.log(`[cast-relay] box sync: found first moof at byte offset ${idx}, discarded ${idx} bytes`); + this._buf = this._buf.subarray(idx); + this._synced = true; + } + + while (this._buf.length >= 8) { + const size = this._buf.readUInt32BE(0); + const type = this._buf.readUInt32BE(4); + + if (size < 8) { + console.log(`[cast-relay] box sync lost: invalid size ${size}, rescanning`); + this._synced = false; + const idx = this._findMoof(); + if (idx === -1) return fragments; + this._buf = this._buf.subarray(idx); + this._synced = true; + continue; + } + + if (type === MOOF) { + if (this._buf.length < size + 8) break; + const mdatSize = this._buf.readUInt32BE(size); + const pairSize = size + mdatSize; + if (this._buf.length < pairSize) break; + + fragments.push(Buffer.from(this._buf.subarray(0, pairSize))); + this._buf = this._buf.subarray(pairSize); + } else { + if (this._buf.length < size) break; + this._buf = this._buf.subarray(size); + } + } + + return fragments; + } + + reset() { + this._buf = Buffer.alloc(0); + this._synced = false; + } + + _findMoof() { + for (let i = 0; i <= this._buf.length - 8; i++) { + if (this._buf.readUInt32BE(i + 4) === MOOF) { + const size = this._buf.readUInt32BE(i); + if (size >= 8 && size < 1_000_000) { + return i; + } + } + } + return -1; + } +} + +class CastRelay { + constructor() { + this._server = null; + this._port = null; + this._token = null; + this._lanIP = null; + this._initSegment = null; + this._ring = []; + this._clients = new Set(); + this._accum = new BoxAccumulator(); + this._fragCount = 0; + this._chunkCount = 0; + this._bytesSent = 0; + } + + get active() { return this._server !== null; } + + get url() { + if (!this._server) return null; + return `http://${this._lanIP}:${this._port}/stream.mp4?t=${this._token}`; + } + + async start({ codec, initSegment }) { + if (this._server) await this.stop(); + + this._token = crypto.randomBytes(16).toString('hex'); + this._lanIP = lanAddress(); + this._initSegment = initSegment ? Buffer.from(initSegment) : null; + this._ring = []; + this._clients = new Set(); + this._accum = new BoxAccumulator(); + this._fragCount = 0; + this._chunkCount = 0; + this._bytesSent = 0; + + const server = http.createServer((req, res) => this._handle(req, res)); + + let bound = false; + for (let i = 0; i < PORT_COUNT && !bound; i++) { + const port = PORT_BASE + i; + try { + await new Promise((resolve, reject) => { + const onError = (err) => { + server.removeListener('error', onError); + reject(err); + }; + server.on('error', onError); + server.listen(port, this._lanIP, () => { + server.removeListener('error', onError); + this._port = port; + resolve(); + }); + }); + bound = true; + } catch (err) { + if (err.code !== 'EADDRINUSE') throw err; + console.log(`[cast-relay] port ${port} busy, trying next`); + } + } + if (!bound) throw new Error('All cast relay ports are in use'); + + this._server = server; + console.log(`[cast-relay] started on ${this.url}`); + console.log(`[cast-relay] init segment: ${this._initSegment ? this._initSegment.length + ' bytes' : 'none'}`); + return { url: this.url, port: this._port, token: this._token }; + } + + pushSegment(data) { + const buf = Buffer.isBuffer(data) ? data : Buffer.from(data); + this._chunkCount++; + + const fragments = this._accum.push(buf); + + for (const frag of fragments) { + this._fragCount++; + if (this._fragCount <= 3 || this._fragCount % 50 === 0) { + console.log(`[cast-relay] fragment #${this._fragCount}: ${frag.length} bytes (from ${this._chunkCount} chunks), ${this._clients.size} client(s)`); + } + + if (this._ring.length >= RING_CAP) { + this._ring.shift(); + } + this._ring.push(frag); + + for (const res of this._clients) { + if (res.writableLength > BACKPRESSURE_HIGH) { + console.log(`[cast-relay] backpressure: dropping fragment for slow client`); + continue; + } + res.write(frag); + this._bytesSent += frag.length; + } + } + } + + finish() { + console.log('[cast-relay] finishing stream'); + for (const res of this._clients) { + try { res.end(); } catch { /* already closed */ } + } + } + + async stop() { + console.log(`[cast-relay] stopping — ${this._chunkCount} chunks, ${this._fragCount} fragments, ${(this._bytesSent / 1048576).toFixed(1)} MB sent`); + for (const res of this._clients) { + try { res.end(); } catch { /* already closed */ } + } + this._clients.clear(); + + if (this._server) { + const srv = this._server; + this._server = null; + await new Promise((resolve) => srv.close(resolve)); + } + + this._port = null; + this._token = null; + this._initSegment = null; + this._ring = []; + this._accum.reset(); + this._fragCount = 0; + this._chunkCount = 0; + this._bytesSent = 0; + } + + _handle(req, res) { + if (req.method !== 'GET') { + res.writeHead(405); + res.end(); + return; + } + + let url; + try { + url = new URL(req.url, `http://${req.headers.host}`); + } catch { + res.writeHead(400); + res.end(); + return; + } + + if (url.searchParams.get('t') !== this._token) { + res.writeHead(403); + res.end(); + return; + } + + if (url.pathname !== '/stream.mp4') { + res.writeHead(404); + res.end(); + return; + } + + let sent = 0; + res.writeHead(200, { + 'Content-Type': 'video/mp4', + 'Cache-Control': 'no-store', + 'Accept-Ranges': 'none', + 'Connection': 'keep-alive', + }); + + if (this._initSegment) { + res.write(this._initSegment); + sent += this._initSegment.length; + } + + for (const frag of this._ring) { + res.write(frag); + sent += frag.length; + } + + console.log(`[cast-relay] client connected from ${req.socket.remoteAddress} — sent init + ${this._ring.length} fragments (${(sent / 1024).toFixed(0)} KB)`); + + this._clients.add(res); + req.on('close', () => { + this._clients.delete(res); + console.log(`[cast-relay] client disconnected, ${this._clients.size} remaining`); + }); + } +} + +module.exports = CastRelay; diff --git a/packages/meshbay-client/src/main.js b/packages/meshbay-client/src/main.js index f2ec645..45e8462 100644 --- a/packages/meshbay-client/src/main.js +++ b/packages/meshbay-client/src/main.js @@ -351,6 +351,11 @@ function createWindow() { // renderer parses decrypted content from nodes, which is attacker-controlled // input, so it is treated as hostile even though it is our own code. +const CastRelay = require('./cast-relay.js'); +const castRelay = new CastRelay(); +const CastChromecast = require('./cast-chromecast.js'); +const castChromecast = new CastChromecast(); + function registerBridge() { ipcMain.handle('hub:set', async (_e, base) => { const url = String(base || '').trim().replace(/\/+$/, ''); @@ -738,6 +743,60 @@ function registerBridge() { _nodePairingCode = code || null; return true; }); + + // ── LAN cast relay ────────────────────────────────────────────────────── + // + // A local HTTP server that re-serves decrypted fMP4 segments so a + // Chromecast or Smart TV on the same Wi-Fi can stream the video. The + // renderer feeds it segments via IPC; the relay serves them over HTTP. + // Same trust boundary as the MSE player and the download-to-disk path. + + ipcMain.handle('cast:start', async (_e, opts) => { + return castRelay.start({ + codec: opts.codec, + initSegment: opts.initSegment ? Buffer.from(opts.initSegment) : null, + }); + }); + + ipcMain.handle('cast:push', async (_e, data) => { + castRelay.pushSegment(Buffer.from(data)); + return true; + }); + + ipcMain.handle('cast:stop', async () => { + await castRelay.stop(); + return true; + }); + + ipcMain.handle('cast:finish', async () => { + castRelay.finish(); + return true; + }); + + ipcMain.handle('cast:status', async () => ({ + active: castRelay.active, + url: castRelay.url, + chromecast: castChromecast.getStatus(), + })); + + // ── Chromecast discovery + control ────────────────────────────────────── + + ipcMain.handle('cast:discover', async () => { + return castChromecast.discover(); + }); + + ipcMain.handle('cast:chromecast:connect', async (_e, { deviceId, mediaUrl }) => { + return castChromecast.connect(deviceId, mediaUrl); + }); + + ipcMain.handle('cast:chromecast:reload', async (_e, { mediaUrl }) => { + return castChromecast.reload(mediaUrl); + }); + + ipcMain.handle('cast:chromecast:disconnect', async () => { + await castChromecast.disconnect(); + return true; + }); } /** diff --git a/packages/meshbay-client/src/preload.js b/packages/meshbay-client/src/preload.js index 04dfe44..e9fd375 100644 --- a/packages/meshbay-client/src/preload.js +++ b/packages/meshbay-client/src/preload.js @@ -40,6 +40,7 @@ contextBridge.exposeInMainWorld('meshbay', { nodeAdmin: true, localFolders: true, nativeSave: true, + lanCast: true, }, // Ask the main process to call the hub. The renderer has an `app://` origin, @@ -90,6 +91,21 @@ contextBridge.exposeInMainWorld('meshbay', { setPairingCode: (code) => ipcRenderer.invoke('node:set-pairing-code', code), }, + // LAN cast relay. The main process runs a local HTTP server and the + // renderer feeds it decrypted segments. A Chromecast or Smart TV on the + // same Wi-Fi plays from the URL. + cast: { + start: (opts) => ipcRenderer.invoke('cast:start', opts), + push: (data) => ipcRenderer.invoke('cast:push', data), + stop: () => ipcRenderer.invoke('cast:stop'), + finish: () => ipcRenderer.invoke('cast:finish'), + status: () => ipcRenderer.invoke('cast:status'), + discover: () => ipcRenderer.invoke('cast:discover'), + chromecastConnect: (opts) => ipcRenderer.invoke('cast:chromecast:connect', opts), + chromecastReload: (opts) => ipcRenderer.invoke('cast:chromecast:reload', opts), + chromecastDisconnect: () => ipcRenderer.invoke('cast:chromecast:disconnect'), + }, + // A sink that writes to disk as chunks arrive, never a buffer handed over at // the end. `auto` uses the remembered folder without a dialog, which is what // "save automatically" means; without one, or when the person asked to be |