aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-client/src
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-client/src')
-rw-r--r--packages/meshbay-client/src/argon2-wasm.js2
-rw-r--r--packages/meshbay-client/src/cast-chromecast.js48
-rw-r--r--packages/meshbay-client/src/cast-relay.js151
-rw-r--r--packages/meshbay-client/src/keyring.js2
-rw-r--r--packages/meshbay-client/src/main.js52
-rw-r--r--packages/meshbay-client/src/preload.js13
-rw-r--r--packages/meshbay-client/src/transcripts.js2
7 files changed, 245 insertions, 25 deletions
diff --git a/packages/meshbay-client/src/argon2-wasm.js b/packages/meshbay-client/src/argon2-wasm.js
index c68e994..4660414 100644
--- a/packages/meshbay-client/src/argon2-wasm.js
+++ b/packages/meshbay-client/src/argon2-wasm.js
@@ -1,3 +1,5 @@
+// SPDX-License-Identifier: LGPL-3.0-or-later
+// Part of MeshBay's protocol layer, under the LGPL so that any client may use it.
/**
* Argon2id for the main process, from the page's own WebAssembly build.
*
diff --git a/packages/meshbay-client/src/cast-chromecast.js b/packages/meshbay-client/src/cast-chromecast.js
index 55deb5b..8c6c9c6 100644
--- a/packages/meshbay-client/src/cast-chromecast.js
+++ b/packages/meshbay-client/src/cast-chromecast.js
@@ -41,12 +41,31 @@ const TEXT_TRACK_STYLE = Object.freeze({
});
/**
- * What to hand `player.load()` for a stream, with or without subtitles.
+ * What to hand `player.load()` for a stream, with or without subtitles, or
+ * for a whole file the relay serves.
*
* `streamType: 'LIVE'` because the relay has no beginning to seek back to —
* the film's own timeline lives on this side, and a seek restarts the relay.
+ * A file is the relay's to describe (`file`: its type and what to show with
+ * it), never the page's: a photo is not a stream at all, and a music track is
+ * an ordinary buffered file the receiver seeks in itself.
*/
-function mediaFor(mediaUrl, subtitle) {
+function mediaFor(mediaUrl, subtitle, file) {
+ if (file && file.contentType.startsWith('image/')) {
+ return { contentId: mediaUrl, contentType: file.contentType, streamType: 'NONE',
+ metadata: { metadataType: 4 } }; // PhotoMediaMetadata
+ }
+ if (file) {
+ const meta = file.meta || {};
+ return {
+ contentId: mediaUrl, contentType: file.contentType, streamType: 'BUFFERED',
+ metadata: {
+ metadataType: 3, // MusicTrackMediaMetadata
+ title: meta.title || '', artist: meta.artist || '', albumName: meta.album || '',
+ images: meta.cover ? [{ url: meta.cover }] : [],
+ },
+ };
+ }
const media = {
contentId: mediaUrl,
contentType: 'video/mp4',
@@ -67,11 +86,14 @@ function mediaFor(mediaUrl, subtitle) {
return media;
}
-function loadOptionsFor(subtitle) {
- return {
+function loadOptionsFor(subtitle, startAt) {
+ const options = {
autoplay: true,
activeTrackIds: subtitle && subtitle.url ? [TEXT_TRACK_ID] : [],
};
+ // Where in a track to begin: a cast started mid-song carries on from there.
+ if (startAt > 0) options.currentTime = startAt;
+ return options;
}
class CastChromecast {
@@ -139,7 +161,7 @@ class CastChromecast {
}
}
- async connect(deviceId, mediaUrl, subtitle) {
+ async connect(deviceId, mediaUrl, subtitle, file, startAt) {
const device = this._devices.get(deviceId);
if (!device) throw new Error(`Unknown device: ${deviceId}`);
@@ -179,7 +201,7 @@ class CastChromecast {
debug(`[cast-chromecast] loading with ${subtitle?.url ? 'subtitles' : 'no subtitles'}`);
const status = await new Promise((resolve, reject) => {
- player.load(mediaFor(mediaUrl, subtitle), loadOptionsFor(subtitle),
+ player.load(mediaFor(mediaUrl, subtitle, file), loadOptionsFor(subtitle, startAt),
(err, s) => {
if (err) return reject(err);
resolve(s);
@@ -191,12 +213,12 @@ class CastChromecast {
return { deviceName: device.name, playerState: status.playerState };
}
- async reload(mediaUrl, subtitle) {
+ async reload(mediaUrl, subtitle, file, startAt) {
if (!this._player) throw new Error('Not connected');
debug(`[cast-chromecast] reloading stream on "${this._connectedDevice?.name}"`
+ ` with ${subtitle?.url ? 'subtitles' : 'no subtitles'}`);
const status = await new Promise((resolve, reject) => {
- this._player.load(mediaFor(mediaUrl, subtitle), loadOptionsFor(subtitle),
+ this._player.load(mediaFor(mediaUrl, subtitle, file), loadOptionsFor(subtitle, startAt),
(err, s) => {
if (err) return reject(err);
resolve(s);
@@ -250,6 +272,16 @@ class CastChromecast {
return { playerState: status && status.playerState };
}
+ /** Where in a track the receiver should be; a film seeks by restarting the relay instead. */
+ async seek(seconds) {
+ if (!this._player) throw new Error('Not connected');
+ const status = await new Promise((resolve, reject) => {
+ this._player.seek(Math.max(0, Number(seconds) || 0), (err, s) => (err ? reject(err) : resolve(s)));
+ });
+ this._noteStatus(status);
+ return { playerState: status && status.playerState };
+ }
+
async play() {
if (!this._player) throw new Error('Not connected');
const status = await new Promise((resolve, reject) => {
diff --git a/packages/meshbay-client/src/cast-relay.js b/packages/meshbay-client/src/cast-relay.js
index 58eb9ac..ed20b33 100644
--- a/packages/meshbay-client/src/cast-relay.js
+++ b/packages/meshbay-client/src/cast-relay.js
@@ -15,6 +15,12 @@
* a media element, so unlike the stream it needs CORS headers to be readable
* at all.
*
+ * A whole file — a photo, or a music track with its cover at `/cover` — is
+ * served at `/file`, one at a time: the page decrypts it and hands it over in
+ * pieces (begin, writes, end), and the receiver loads it as a picture or as
+ * music. Ranges are honoured there, which is what a receiver seeks with. The
+ * relay starts for a file when it is not already running.
+ *
* Mitigations:
* · Bind to the LAN interface, never 0.0.0.0
* · Fixed port range (19550-19553), opened only during active cast
@@ -50,6 +56,16 @@ const util = require('node:util');
const debug = util.debuglog('cast-relay');
const RING_CAP = 64;
+// What the receiver is ever told a photo is. The page re-encodes every photo
+// to JPEG, so this list is a guard, not a menu.
+const FILE_TYPES = new Set([
+ 'image/jpeg', 'image/png', 'image/webp',
+ 'audio/mpeg', 'audio/mp4', 'audio/aac', 'audio/flac', 'audio/ogg', 'audio/opus',
+ 'audio/wav', 'audio/webm',
+]);
+// A track is a few megabytes, a long lossless one a few hundred; this only
+// stops a page from filling the machine's memory.
+const FILE_MAX_BYTES = 1024 * 1024 * 1024;
const BACKPRESSURE_HIGH = 8 * 1024 * 1024;
const MOOF = 0x6d6f6f66;
const PORT_BASE = 19550;
@@ -163,6 +179,9 @@ class CastRelay {
this._bytesSent = 0;
this._subtitle = null;
this._subtitleVersion = 0;
+ this._file = null;
+ this._fileVersion = 0;
+ this._incoming = null;
}
get active() { return this._server !== null; }
@@ -185,6 +204,70 @@ class CastRelay {
+ `?t=${this._token}&v=${this._subtitleVersion}`;
}
+ /**
+ * Where the current file can be fetched, or null when there is none.
+ * Versioned for the same reason as the subtitle: a receiver handed the same
+ * address twice shows what it already has.
+ */
+ get fileUrl() {
+ if (!this._server || !this._file) return null;
+ return `http://${this._lanIP}:${this._port}/file`
+ + `?t=${this._token}&v=${this._fileVersion}`;
+ }
+
+ get fileType() { return this._file ? this._file.type : null; }
+
+ /** What the receiver is told the file is: title, artist, album, cover. */
+ get fileMeta() {
+ if (!this._file) return null;
+ const { title, artist, album } = this._file.meta;
+ const cover = this._file.cover
+ ? `http://${this._lanIP}:${this._port}/cover?t=${this._token}&v=${this._fileVersion}`
+ : null;
+ return { title, artist, album, cover };
+ }
+
+ /**
+ * A file arrives in three steps, so that a large one never has to cross in
+ * a single message: `beginFile` names it, `writeFile` appends, `endFile`
+ * puts it up and answers its address. The relay starts if nothing has.
+ */
+ async beginFile({ type, size, cover, title, artist, album }) {
+ if (!FILE_TYPES.has(type)) throw new Error(`Not something a receiver shows: ${type}`);
+ if (!(size > 0 && size <= FILE_MAX_BYTES)) throw new Error(`Bad file size: ${size}`);
+ if (cover && !FILE_TYPES.has(cover.type)) throw new Error(`Bad cover type: ${cover.type}`);
+ if (!this._server) await this.start({});
+ const text = (v) => (typeof v === 'string' ? v.slice(0, 500) : '');
+ this._incoming = {
+ type, size, parts: [], received: 0,
+ cover: cover && cover.type.startsWith('image/')
+ ? { bytes: Buffer.from(cover.bytes), type: cover.type } : null,
+ meta: { title: text(title), artist: text(artist), album: text(album) },
+ };
+ return true;
+ }
+
+ writeFile(bytes) {
+ const f = this._incoming;
+ if (!f) throw new Error('No file is being sent');
+ const buf = Buffer.from(bytes);
+ if (f.received + buf.length > f.size) throw new Error('More bytes than announced');
+ f.parts.push(buf);
+ f.received += buf.length;
+ return true;
+ }
+
+ endFile() {
+ const f = this._incoming;
+ this._incoming = null;
+ if (!f) throw new Error('No file is being sent');
+ if (f.received !== f.size) throw new Error(`File cut short: ${f.received} of ${f.size} bytes`);
+ this._file = { bytes: Buffer.concat(f.parts), type: f.type, cover: f.cover, meta: f.meta };
+ this._fileVersion++;
+ debug(`[cast-relay] file set: ${f.size} bytes, ${f.type}, v${this._fileVersion}`);
+ return { url: this.fileUrl };
+ }
+
get subtitle() {
if (!this._subtitle) return null;
return {
@@ -232,6 +315,8 @@ class CastRelay {
this._chunkCount = 0;
this._bytesSent = 0;
this._subtitle = null;
+ this._file = null;
+ this._incoming = null;
this.setSubtitle(subtitle);
const server = http.createServer((req, res) => this._handle(req, res));
@@ -323,6 +408,8 @@ class CastRelay {
this._token = null;
this._initSegment = null;
this._subtitle = null;
+ this._file = null;
+ this._incoming = null;
this._ring = [];
this._accum.reset();
this._fragCount = 0;
@@ -366,6 +453,16 @@ class CastRelay {
return;
}
+ if (url.pathname === '/file') {
+ this._handleFile(req, res);
+ return;
+ }
+
+ if (url.pathname === '/cover') {
+ this._handleCover(res);
+ return;
+ }
+
if (url.pathname !== '/stream.mp4') {
res.writeHead(404);
res.end();
@@ -419,6 +516,60 @@ class CastRelay {
res.end(this._subtitle.vtt);
debug(`[cast-relay] subtitle served: ${this._subtitle.vtt.length} bytes`);
}
+
+ /**
+ * The whole file, or the one range asked for: a receiver seeks in a track
+ * by asking for the bytes from there on.
+ */
+ _handleFile(req, res) {
+ const f = this._file;
+ if (!f) {
+ res.writeHead(404, CORS_HEADERS);
+ res.end();
+ return;
+ }
+ const total = f.bytes.length;
+ const base = {
+ ...CORS_HEADERS,
+ 'Content-Type': f.type,
+ 'Cache-Control': 'no-store',
+ 'Accept-Ranges': 'bytes',
+ };
+ const m = /^bytes=(\d*)-(\d*)$/.exec(req.headers.range || '');
+ if (m && (m[1] !== '' || m[2] !== '')) {
+ let from = m[1] === '' ? total - Number(m[2]) : Number(m[1]);
+ let to = m[1] === '' || m[2] === '' ? total - 1 : Math.min(Number(m[2]), total - 1);
+ from = Math.max(0, from);
+ if (from > to || from >= total) {
+ res.writeHead(416, { ...base, 'Content-Range': `bytes */${total}` });
+ res.end();
+ return;
+ }
+ res.writeHead(206, { ...base, 'Content-Range': `bytes ${from}-${to}/${total}`,
+ 'Content-Length': to - from + 1 });
+ res.end(f.bytes.subarray(from, to + 1));
+ return;
+ }
+ res.writeHead(200, { ...base, 'Content-Length': total });
+ res.end(f.bytes);
+ debug(`[cast-relay] file served: ${total} bytes`);
+ }
+
+ _handleCover(res) {
+ const c = this._file && this._file.cover;
+ if (!c) {
+ res.writeHead(404, CORS_HEADERS);
+ res.end();
+ return;
+ }
+ res.writeHead(200, {
+ ...CORS_HEADERS,
+ 'Content-Type': c.type,
+ 'Content-Length': c.bytes.length,
+ 'Cache-Control': 'no-store',
+ });
+ res.end(c.bytes);
+ }
}
module.exports = CastRelay;
diff --git a/packages/meshbay-client/src/keyring.js b/packages/meshbay-client/src/keyring.js
index ec37fd4..c9997aa 100644
--- a/packages/meshbay-client/src/keyring.js
+++ b/packages/meshbay-client/src/keyring.js
@@ -1,3 +1,5 @@
+// SPDX-License-Identifier: LGPL-3.0-or-later
+// Part of MeshBay's protocol layer, under the LGPL so that any client may use it.
/**
* The account's keys in the desktop application: the bundle master key `M` and
* the identity on every node, held here and never handed to the page.
diff --git a/packages/meshbay-client/src/main.js b/packages/meshbay-client/src/main.js
index fa4f3ec..f582b57 100644
--- a/packages/meshbay-client/src/main.js
+++ b/packages/meshbay-client/src/main.js
@@ -1269,11 +1269,11 @@ function registerBridge() {
//
// The renderer never sees the session token. It names an operation and this
// process executes it — the same pattern as hub:fetch. The token is read
- // from the daemon's data directory, cached for the lifetime of this process,
- // and never exposed through the preload.
+ // from the daemon's data directory on every call and never exposed through
+ // the preload. Not cached: the daemon writes a new one each run and deletes
+ // it on stop, so a cached copy answered 401 after every node restart, and an
+ // operation asked before any page had called detect() had none at all.
- let _nodeToken = null;
- let _nodePort = 18000;
let _nodePairingCode = null;
function nodeConfigPath() {
@@ -1305,16 +1305,20 @@ function registerBridge() {
}
}
+ function nodeEndpoint() {
+ const nc = readNodeConfig();
+ const token = readNodeToken(nc ? nc.dataDir : meshbayDataDir());
+ return token ? { token, port: nc ? nc.uiPort : 18000 } : null;
+ }
+
handle('node:detect', async () => {
const nc = readNodeConfig();
if (!nc) return { detected: false, configured: false };
const token = readNodeToken(nc.dataDir);
if (!token) return { detected: false, configured: true };
- _nodeToken = token;
- _nodePort = nc.uiPort;
try {
const r = await fetch(
- `http://127.0.0.1:${_nodePort}/api/status?t=${_nodeToken}`,
+ `http://127.0.0.1:${nc.uiPort}/api/status?t=${token}`,
{ signal: AbortSignal.timeout(3000) });
if (!r.ok) return { detected: false, configured: true };
const status = await r.json();
@@ -1889,8 +1893,6 @@ function registerBridge() {
const READY = ['running', 'waiting_for_node_key', 'waiting_for_account',
'waiting_for_hub', 'starting'];
if (!READY.includes(status.status)) return null;
- _nodeToken = token;
- _nodePort = port;
return { pk_node_ed25519: status.pk_node_ed25519 || '', status: status.status,
version: status.version || '' };
} catch { return null; }
@@ -2235,10 +2237,11 @@ function registerBridge() {
handle('node:op', async (_e, name, args) => {
const op = Object.hasOwn(NODE_OPS, String(name)) ? NODE_OPS[String(name)] : null;
if (!op) throw new Error(`Refused: unknown node operation ${String(name)}`);
- if (!_nodeToken) throw new Error('Node not detected');
+ const endpoint = nodeEndpoint();
+ if (!endpoint) throw new Error('Node not detected');
const [method, apiPath, body] = await op(anObject(args));
const sep = apiPath.includes('?') ? '&' : '?';
- const url = `http://127.0.0.1:${_nodePort}${apiPath}${sep}t=${_nodeToken}`;
+ const url = `http://127.0.0.1:${endpoint.port}${apiPath}${sep}t=${endpoint.token}`;
const init = { method };
if (body !== undefined && body !== null) {
init.headers = { 'Content-Type': 'application/json' };
@@ -2333,16 +2336,31 @@ function registerBridge() {
handle('cast:devices', async () => castChromecast.devices());
- handle('cast:chromecast:connect', async (_e, { deviceId, mediaUrl, subtitle }) => {
- return castChromecast.connect(deviceId, mediaUrl,
- subtitle === undefined ? castRelay.subtitle : subtitle);
+ // A whole file for the receiver — a photo, a music track — in three steps,
+ // so that a large one never crosses in a single message.
+ handle('cast:file:begin', async (_e, f) => castRelay.beginFile(f || {}));
+ handle('cast:file:write', async (_e, data) => castRelay.writeFile(Buffer.from(data)));
+ handle('cast:file:end', async () => castRelay.endFile());
+
+ // The relay's file is loaded as what the relay says it is, anything else as
+ // the stream; the page does not get to say which.
+ const loadOf = (mediaUrl, subtitle) => (
+ mediaUrl && mediaUrl === castRelay.fileUrl
+ ? { subtitle: null, file: { contentType: castRelay.fileType, meta: castRelay.fileMeta } }
+ : { subtitle: subtitle === undefined ? castRelay.subtitle : subtitle, file: null });
+
+ handle('cast:chromecast:connect', async (_e, { deviceId, mediaUrl, subtitle, startAt }) => {
+ const l = loadOf(mediaUrl, subtitle);
+ return castChromecast.connect(deviceId, mediaUrl, l.subtitle, l.file, l.file ? startAt : 0);
});
- handle('cast:chromecast:reload', async (_e, { mediaUrl, subtitle }) => {
- return castChromecast.reload(mediaUrl,
- subtitle === undefined ? castRelay.subtitle : subtitle);
+ handle('cast:chromecast:reload', async (_e, { mediaUrl, subtitle, startAt }) => {
+ const l = loadOf(mediaUrl, subtitle);
+ return castChromecast.reload(mediaUrl, l.subtitle, l.file, l.file ? startAt : 0);
});
+ handle('cast:chromecast:seek', async (_e, seconds) => castChromecast.seek(seconds));
+
// The player becomes a remote while casting: these drive the receiver.
handle('cast:chromecast:pause', async () => castChromecast.pause());
handle('cast:chromecast:play', async () => castChromecast.play());
diff --git a/packages/meshbay-client/src/preload.js b/packages/meshbay-client/src/preload.js
index 3af6c10..0de76db 100644
--- a/packages/meshbay-client/src/preload.js
+++ b/packages/meshbay-client/src/preload.js
@@ -178,6 +178,18 @@ contextBridge.exposeInMainWorld('meshbay', {
push: (data) => ipcRenderer.invoke('cast:push', data),
stop: () => ipcRenderer.invoke('cast:stop'),
subtitle: (sub) => ipcRenderer.invoke('cast:subtitle', sub),
+ // A file in pieces of a few megabytes, as the Android bridge sends it.
+ file: async (f) => {
+ const bytes = f.bytes;
+ await ipcRenderer.invoke('cast:file:begin', {
+ type: f.type, size: bytes.length, cover: f.cover || null,
+ title: f.title, artist: f.artist, album: f.album,
+ });
+ for (let at = 0; at < bytes.length; at += 4 * 1024 * 1024) {
+ await ipcRenderer.invoke('cast:file:write', bytes.subarray(at, at + 4 * 1024 * 1024));
+ }
+ return ipcRenderer.invoke('cast:file:end');
+ },
finish: () => ipcRenderer.invoke('cast:finish'),
status: () => ipcRenderer.invoke('cast:status'),
scan: () => ipcRenderer.invoke('cast:scan'),
@@ -187,6 +199,7 @@ contextBridge.exposeInMainWorld('meshbay', {
chromecastDisconnect: () => ipcRenderer.invoke('cast:chromecast:disconnect'),
chromecastPause: () => ipcRenderer.invoke('cast:chromecast:pause'),
chromecastPlay: () => ipcRenderer.invoke('cast:chromecast:play'),
+ chromecastSeek: (seconds) => ipcRenderer.invoke('cast:chromecast:seek', seconds),
},
// A sink that writes to disk as chunks arrive, never a buffer handed over at
diff --git a/packages/meshbay-client/src/transcripts.js b/packages/meshbay-client/src/transcripts.js
index 8b0f6ef..a58cb24 100644
--- a/packages/meshbay-client/src/transcripts.js
+++ b/packages/meshbay-client/src/transcripts.js
@@ -1,3 +1,5 @@
+// SPDX-License-Identifier: LGPL-3.0-or-later
+// Part of MeshBay's protocol layer, under the LGPL so that any client may use it.
/**
* What a node identity signs, built here from named fields — never bytes the
* page chose.