summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-client
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-client')
-rw-r--r--packages/meshbay-client/package-lock.json192
-rw-r--r--packages/meshbay-client/package.json9
-rw-r--r--packages/meshbay-client/src/cast-chromecast.js157
-rw-r--r--packages/meshbay-client/src/cast-relay.js293
-rw-r--r--packages/meshbay-client/src/main.js59
-rw-r--r--packages/meshbay-client/src/preload.js16
6 files changed, 720 insertions, 6 deletions
diff --git a/packages/meshbay-client/package-lock.json b/packages/meshbay-client/package-lock.json
index b1ad68a..5b705b9 100644
--- a/packages/meshbay-client/package-lock.json
+++ b/packages/meshbay-client/package-lock.json
@@ -8,6 +8,10 @@
"name": "meshbay-client",
"version": "0.1.0",
"license": "AGPL-3.0-or-later",
+ "dependencies": {
+ "bonjour-service": "^1.4.4",
+ "castv2-client": "^1.2.0"
+ },
"devDependencies": {
"electron": "^42.0.0",
"electron-builder": "^25.0.0"
@@ -383,6 +387,12 @@
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
+ "node_modules/@leichtgewicht/ip-codec": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.5.tgz",
+ "integrity": "sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw==",
+ "license": "MIT"
+ },
"node_modules/@malept/cross-spawn-promise": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@malept/cross-spawn-promise/-/cross-spawn-promise-2.0.0.tgz",
@@ -478,6 +488,69 @@
"node": ">=14"
}
},
+ "node_modules/@protobufjs/aspromise": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz",
+ "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/@protobufjs/base64": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz",
+ "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/@protobufjs/codegen": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz",
+ "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/@protobufjs/eventemitter": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz",
+ "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/@protobufjs/fetch": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz",
+ "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "@protobufjs/aspromise": "^1.1.1"
+ }
+ },
+ "node_modules/@protobufjs/float": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz",
+ "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/@protobufjs/inquire": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.2.tgz",
+ "integrity": "sha512-pa0vFRuws4wkvaXKK1uXZMAwAX4/t8ANaJo45iw/oQHNQ9q5xUzwgFmVJGXiga2BeN+zpX7Vf9vmsiIa2J+MUw==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/@protobufjs/path": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz",
+ "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/@protobufjs/pool": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz",
+ "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/@protobufjs/utf8": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.2.tgz",
+ "integrity": "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==",
+ "license": "BSD-3-Clause"
+ },
"node_modules/@sindresorhus/is": {
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz",
@@ -564,6 +637,12 @@
"@types/node": "*"
}
},
+ "node_modules/@types/long": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/@types/long/-/long-4.0.2.tgz",
+ "integrity": "sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==",
+ "license": "MIT"
+ },
"node_modules/@types/ms": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
@@ -575,7 +654,6 @@
"version": "24.13.3",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz",
"integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==",
- "dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~7.18.0"
@@ -1004,6 +1082,16 @@
"bluebird": "^3.5.5"
}
},
+ "node_modules/bonjour-service": {
+ "version": "1.4.4",
+ "resolved": "https://registry.npmjs.org/bonjour-service/-/bonjour-service-1.4.4.tgz",
+ "integrity": "sha512-jCZcVv7eoc4QesRscwEZtSROBen+6LpKAmBIsQYQrsAeVHLyMXWX/t6eIV5KiRZYNUBl8eVqImEEMQ8L5+c/Kw==",
+ "license": "MIT",
+ "dependencies": {
+ "fast-deep-equal": "^3.1.3",
+ "multicast-dns": "^7.2.5"
+ }
+ },
"node_modules/brace-expansion": {
"version": "5.0.9",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
@@ -1233,6 +1321,41 @@
"node": ">= 0.4"
}
},
+ "node_modules/castv2": {
+ "version": "0.1.10",
+ "resolved": "https://registry.npmjs.org/castv2/-/castv2-0.1.10.tgz",
+ "integrity": "sha512-3QWevHrjT22KdF08Y2a217IYCDQDP7vEJaY4n0lPBeC5UBYbMFMadDfVTsaQwq7wqsEgYUHElPGm3EO1ey+TNw==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^4.1.1",
+ "protobufjs": "^6.8.8"
+ }
+ },
+ "node_modules/castv2-client": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/castv2-client/-/castv2-client-1.2.0.tgz",
+ "integrity": "sha512-2diOsC0vSSxa3QEOgoGBy9fZRHzNXatHz464Kje2OpwQ7GM5vulyrD0gLFOQ1P4rgLAFsYiSGQl4gK402nEEuA==",
+ "license": "MIT",
+ "dependencies": {
+ "castv2": "~0.1.4",
+ "debug": "^2.2.0"
+ }
+ },
+ "node_modules/castv2-client/node_modules/debug": {
+ "version": "2.6.9",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
+ "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "2.0.0"
+ }
+ },
+ "node_modules/castv2-client/node_modules/ms": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
+ "license": "MIT"
+ },
"node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
@@ -1611,7 +1734,6 @@
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
- "dev": true,
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
@@ -1791,6 +1913,18 @@
"node": ">=8"
}
},
+ "node_modules/dns-packet": {
+ "version": "5.6.1",
+ "resolved": "https://registry.npmjs.org/dns-packet/-/dns-packet-5.6.1.tgz",
+ "integrity": "sha512-l4gcSouhcgIKRvyy99RNVOgxXiicE+2jZoNmaNmZ6JXiGajBOJAesk1OBlJuM5k2c+eudGdLxDqXuPCKIj6kpw==",
+ "license": "MIT",
+ "dependencies": {
+ "@leichtgewicht/ip-codec": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/dotenv": {
"version": "16.6.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
@@ -2062,7 +2196,6 @@
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
- "dev": true,
"license": "MIT"
},
"node_modules/fast-json-stable-stringify": {
@@ -2945,6 +3078,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/long": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/long/-/long-4.0.0.tgz",
+ "integrity": "sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA==",
+ "license": "Apache-2.0"
+ },
"node_modules/lowercase-keys": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz",
@@ -3254,9 +3393,21 @@
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
- "dev": true,
"license": "MIT"
},
+ "node_modules/multicast-dns": {
+ "version": "7.2.5",
+ "resolved": "https://registry.npmjs.org/multicast-dns/-/multicast-dns-7.2.5.tgz",
+ "integrity": "sha512-2eznPJP8z2BFLX50tf0LuODrpINqP1RVIm/CObbTcBRITQgmC/TjcREF1NeTBzIcR5XO/ukWo+YHOjBbFwIupg==",
+ "license": "MIT",
+ "dependencies": {
+ "dns-packet": "^5.2.2",
+ "thunky": "^1.0.2"
+ },
+ "bin": {
+ "multicast-dns": "cli.js"
+ }
+ },
"node_modules/negotiator": {
"version": "0.6.4",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz",
@@ -3620,6 +3771,32 @@
"node": ">=10"
}
},
+ "node_modules/protobufjs": {
+ "version": "6.11.6",
+ "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-6.11.6.tgz",
+ "integrity": "sha512-k8BHqgPBOtrlougZZqF2uUk5Z7bN8f0wj+3e8M3hvtSv0NBAz4VBy5f6R5Nxq/l+i7mRFTgNZb2trxqTpHNY/A==",
+ "hasInstallScript": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "@protobufjs/aspromise": "^1.1.2",
+ "@protobufjs/base64": "^1.1.2",
+ "@protobufjs/codegen": "^2.0.4",
+ "@protobufjs/eventemitter": "^1.1.0",
+ "@protobufjs/fetch": "^1.1.0",
+ "@protobufjs/float": "^1.0.2",
+ "@protobufjs/inquire": "^1.1.0",
+ "@protobufjs/path": "^1.1.2",
+ "@protobufjs/pool": "^1.1.0",
+ "@protobufjs/utf8": "^1.1.0",
+ "@types/long": "^4.0.1",
+ "@types/node": ">=13.7.0",
+ "long": "^4.0.0"
+ },
+ "bin": {
+ "pbjs": "bin/pbjs",
+ "pbts": "bin/pbts"
+ }
+ },
"node_modules/pump": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
@@ -4192,6 +4369,12 @@
"fs-extra": "^10.0.0"
}
},
+ "node_modules/thunky": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/thunky/-/thunky-1.1.0.tgz",
+ "integrity": "sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==",
+ "license": "MIT"
+ },
"node_modules/tmp": {
"version": "0.2.7",
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz",
@@ -4251,7 +4434,6 @@
"version": "7.18.2",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
- "dev": true,
"license": "MIT"
},
"node_modules/unique-filename": {
diff --git a/packages/meshbay-client/package.json b/packages/meshbay-client/package.json
index 61d75d7..b9c5a50 100644
--- a/packages/meshbay-client/package.json
+++ b/packages/meshbay-client/package.json
@@ -1,7 +1,7 @@
{
"name": "meshbay-client",
"version": "0.1.0",
- "description": "MeshBay desktop client \u2014 the interface ships with the application, not from the hub",
+ "description": "MeshBay desktop client — the interface ships with the application, not from the hub",
"license": "AGPL-3.0-or-later",
"main": "src/main.js",
"private": true,
@@ -39,5 +39,12 @@
"python3-meshbay-common"
]
}
+ },
+ "dependencies": {
+ "bonjour-service": "^1.4.4",
+ "castv2-client": "^1.2.0"
+ },
+ "overrides": {
+ "protobufjs": "^7.5.5"
}
}
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