diff options
Diffstat (limited to 'packages/meshbay-client/src')
| -rw-r--r-- | packages/meshbay-client/src/argon2-wasm.js | 2 | ||||
| -rw-r--r-- | packages/meshbay-client/src/cast-chromecast.js | 48 | ||||
| -rw-r--r-- | packages/meshbay-client/src/cast-relay.js | 151 | ||||
| -rw-r--r-- | packages/meshbay-client/src/keyring.js | 2 | ||||
| -rw-r--r-- | packages/meshbay-client/src/main.js | 52 | ||||
| -rw-r--r-- | packages/meshbay-client/src/preload.js | 13 | ||||
| -rw-r--r-- | packages/meshbay-client/src/transcripts.js | 2 |
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. |