aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-client/src/cast-relay.js
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-21 13:52:11 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-21 13:52:11 +0200
commit8730281d739d9ed1d6f2d366772582eea8ba0294 (patch)
tree4df2f3c8d42dc17147bcdec23109aa9c0f1b5b35 /packages/meshbay-client/src/cast-relay.js
parentae52b69b14a6997d01aad66f49fbea7b5ca2dfe6 (diff)
downloadmeshbay-8730281d739d9ed1d6f2d366772582eea8ba0294.tar.gz
feat: LAN Wi-Fi casting to Chromecast via local HTTP relay
Re-serve decrypted fMP4 video over HTTP on the LAN so a Chromecast can play the stream. The relay runs in the Electron main process — same trust boundary as downloads and MSE playback. - cast-relay.js: HTTP server with BoxAccumulator (reassembles WebRTC chunks into moof+mdat pairs), ring buffer, backpressure, finish() for clean end-of-stream, fixed port range 19550-19553 - cast-chromecast.js: mDNS discovery (bonjour-service) + CASTV2 protocol (castv2-client), connect/reload/disconnect lifecycle - Seek-aware: relay restarts on every seek, Chromecast reloads new URL; generation counter prevents stale async errors from killing active restarts; landingPlayheadRef suppresses programmatic seeking events - Device picker in video top bar with scan, device selection, copy-URL fallback, and cast status indicator - IPC bridge (main/preload/platform) for start/push/stop/finish/status/ discover/chromecastConnect/chromecastReload/chromecastDisconnect - Phase 3 design doc for DLNA/Smart TV in docs/cast-smart-tv.md Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-client/src/cast-relay.js')
-rw-r--r--packages/meshbay-client/src/cast-relay.js293
1 files changed, 293 insertions, 0 deletions
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;