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 | 168 | ||||
| -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 | 103 | ||||
| -rw-r--r-- | packages/meshbay-client/src/preload.js | 23 | ||||
| -rw-r--r-- | packages/meshbay-client/src/transcripts.js | 18 |
7 files changed, 388 insertions, 79 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 1355cdd..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,30 +86,39 @@ 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 { constructor() { this._bonjour = null; this._browser = null; + this._scanTimer = null; this._devices = new Map(); this._client = null; this._player = null; this._connectedDevice = null; } - async discover() { + /** + * Start a scan and return at once; `devices()` reads what it has found so far. + * + * The scan runs its full length because a receiver coming back from a reset + * can take several seconds to answer, but most answer within two, and a + * picker that waits the full length to show any of them is a picker that is + * slow every single time. So the page polls and lists each one as it lands. + * A scan started over an unfinished one replaces it, timer included. + */ + startScan() { this._devices.clear(); - - if (this._browser) { - this._browser.stop(); - this._browser = null; - } + this._stopScan(); if (!this._bonjour) { this._bonjour = new Bonjour(); @@ -98,33 +126,42 @@ class CastChromecast { debug('[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 }); - debug(`[cast-chromecast] discovered: "${name}" at ${host}:${port}`); - } - }); + 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; - setTimeout(() => { - if (this._browser) { - this._browser.stop(); - this._browser = null; - } - const devices = Array.from(this._devices.values()); - debug(`[cast-chromecast] scan complete: ${devices.length} device(s)`); - resolve(devices); - }, SCAN_DURATION_MS); + if (host && id) { + this._devices.set(id, { id, name, host, port }); + debug(`[cast-chromecast] discovered: "${name}" at ${host}:${port}`); + } }); + + this._scanTimer = setTimeout(() => { + this._stopScan(); + debug(`[cast-chromecast] scan complete: ${this._devices.size} device(s)`); + }, SCAN_DURATION_MS); + } + + devices() { + return { + devices: Array.from(this._devices.values()), + scanning: this._browser !== null, + }; + } + + _stopScan() { + clearTimeout(this._scanTimer); + this._scanTimer = null; + if (this._browser) { + this._browser.stop(); + this._browser = null; + } } - 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}`); @@ -159,32 +196,35 @@ class CastChromecast { player.on('status', (status) => { debug(`[cast-chromecast] player status: ${status.playerState}`); + this._noteStatus(status); }); 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); }); }); + this._noteStatus(status); debug(`[cast-chromecast] loaded on "${device.name}", state: ${status.playerState}`); 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); }); }); + this._noteStatus(status); debug(`[cast-chromecast] reloaded, state: ${status.playerState}`); return { playerState: status.playerState }; } @@ -200,10 +240,65 @@ class CastChromecast { this._cleanup(); } + /** + * What the receiver last said, kept so the page can show where the + * *television* is: its playhead is not the local one, which started earlier + * and drifts. `currentTime` is on the stream's timeline — zero at the point + * the relay started — and the page adds that start. + */ + _noteStatus(status) { + if (!status) return; + this._lastStatus = { + playerState: status.playerState || null, + idleReason: status.idleReason || null, + position: Number(status.currentTime) || 0, + at: Date.now(), + }; + } + + /** The receiver's position now: extrapolated while it plays, as its own clock does. */ + _position() { + const s = this._lastStatus; + if (!s) return null; + return s.playerState === 'PLAYING' ? s.position + (Date.now() - s.at) / 1000 : s.position; + } + + async pause() { + if (!this._player) throw new Error('Not connected'); + const status = await new Promise((resolve, reject) => { + this._player.pause((err, s) => (err ? reject(err) : resolve(s))); + }); + this._noteStatus(status); + 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) => { + this._player.play((err, s) => (err ? reject(err) : resolve(s))); + }); + this._noteStatus(status); + return { playerState: status && status.playerState }; + } + getStatus() { + const s = this._lastStatus; return { connected: this._client !== null, deviceName: this._connectedDevice?.name || null, + playerState: s ? s.playerState : null, + idleReason: s ? s.idleReason : null, + position: this._client !== null ? this._position() : null, }; } @@ -214,6 +309,7 @@ class CastChromecast { this._client = null; this._player = null; this._connectedDevice = null; + this._lastStatus = null; } } 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 c1ff540..f582b57 100644 --- a/packages/meshbay-client/src/main.js +++ b/packages/meshbay-client/src/main.js @@ -20,8 +20,8 @@ 'use strict'; -const { app, BrowserWindow, dialog, ipcMain, Menu, protocol, safeStorage, - shell, Tray } = +const { app, BrowserWindow, dialog, ipcMain, Menu, powerSaveBlocker, protocol, + safeStorage, shell, Tray } = require('electron'); const { execFile, spawn } = require('node:child_process'); const crypto = require('node:crypto'); @@ -1144,19 +1144,12 @@ function registerBridge() { return true; }); - // A folder chosen here is one the person pointed at in a dialog this process - // drew, which is the consent that sharing it needs: the node is given it - // without asking again. A folder the page names that was not chosen here is - // confirmed natively before the node hears of it (`node:op`). - const pickedFolders = new Set(); - handle('root:choose', async () => { const result = await dialog.showOpenDialog(mainWindow, { properties: ['openDirectory', 'createDirectory'], }); if (result.canceled || !result.filePaths.length) return null; const chosen = result.filePaths[0]; - pickedFolders.add(path.resolve(chosen)); return { path: chosen, name: path.basename(chosen) }; }); @@ -1276,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() { @@ -1312,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(); @@ -1896,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; } @@ -2185,38 +2180,28 @@ function registerBridge() { const group = (a) => `/api/groups/${anId(a.groupId, 'the group')}`; const root = (a) => `${group(a)}/roots/${encodeURIComponent(aText(a.rootName, 'the folder name'))}`; - // Sharing a folder the person did not choose in this process's dialog. - async function confirmFolder(folder) { - if (!pickedFolders.has(path.resolve(folder))) { - await confirmOrRefuse('native.folder_confirm', { path: folder }); - } - } - const NODE_OPS = { status: () => ['GET', '/api/status'], groups: () => ['GET', '/api/groups'], indexStatus: () => ['GET', '/api/index-status'], groupIndexStatus: (a) => ['GET', `${group(a)}/index-status`], reload: () => ['POST', '/api/reload'], - attachGroup: async (a) => { + attachGroup: (a) => { const body = { name: aText(a.name, 'the group name'), shared_dir: aText(a.path, 'the folder', 4096), writable: a.writable !== false, // The person's choice on the creation form; the node never // takes it from the hub. join_policy: a.joinPolicy === 'open' ? 'open' : 'invite' }; - await confirmFolder(body.shared_dir); return ['POST', '/api/groups/attach', body]; }, detachGroup: (a) => ['POST', '/api/groups/detach', { name: aText(a.name, 'the group name') }], - addRoot: async (a) => { + addRoot: (a) => { const body = { path: aText(a.path, 'the folder', 4096) }; if (a.name) body.name = aText(a.name, 'the folder name'); if (a.writable !== undefined) body.writable = Boolean(a.writable); if (a.removable !== undefined) body.removable = Boolean(a.removable); - const target = `${group(a)}/roots`; - await confirmFolder(body.path); - return ['POST', target, body]; + return ['POST', `${group(a)}/roots`, body]; }, updateRoot: (a) => ['PATCH', root(a), anObject(a.updates)], ejectRoot: (a) => ['PUT', `${root(a)}/eject`], @@ -2252,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' }; @@ -2321,6 +2307,19 @@ function registerBridge() { return true; }); + // The screen may sleep while music plays; the machine may not, or the + // connection to the node goes with it. + let playbackBlocker = null; + handle('playback:keep-alive', async (_event, on) => { + if (on === true && playbackBlocker === null) { + playbackBlocker = powerSaveBlocker.start('prevent-app-suspension'); + } else if (on !== true && playbackBlocker !== null) { + powerSaveBlocker.stop(playbackBlocker); + playbackBlocker = null; + } + return true; + }); + handle('cast:status', async () => ({ active: castRelay.active, url: castRelay.url, @@ -2330,20 +2329,42 @@ function registerBridge() { // ── Chromecast discovery + control ────────────────────────────────────── - handle('cast:discover', async () => { - return castChromecast.discover(); + handle('cast:scan', async () => { + castChromecast.startScan(); + return true; }); - handle('cast:chromecast:connect', async (_e, { deviceId, mediaUrl, subtitle }) => { - return castChromecast.connect(deviceId, mediaUrl, - subtitle === undefined ? castRelay.subtitle : subtitle); + handle('cast:devices', async () => castChromecast.devices()); + + // 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()); + 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 e9c34d2..0de76db 100644 --- a/packages/meshbay-client/src/preload.js +++ b/packages/meshbay-client/src/preload.js @@ -165,6 +165,11 @@ contextBridge.exposeInMainWorld('meshbay', { serviceMode: (action) => ipcRenderer.invoke('node:service-mode', action), }, + // Music is playing: the machine must not idle-sleep under it. + playback: { + keepAlive: (on) => ipcRenderer.invoke('playback:keep-alive', on === true), + }, + // 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. @@ -173,12 +178,28 @@ 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'), - discover: () => ipcRenderer.invoke('cast:discover'), + scan: () => ipcRenderer.invoke('cast:scan'), + devices: () => ipcRenderer.invoke('cast:devices'), chromecastConnect: (opts) => ipcRenderer.invoke('cast:chromecast:connect', opts), chromecastReload: (opts) => ipcRenderer.invoke('cast:chromecast:reload', opts), 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 0b6d0c0..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. @@ -30,6 +32,20 @@ function lenPrefixed(prefix, parts) { return Buffer.concat(chunks); } +// The signed operations this application asks a node to perform +// (meshbay_common/adminop.py). A list, not a pattern: what widens a node's +// sharing — `root_add`, `root_update`, `group_attach`, gone from MNP 6.0 — is +// never signed here, so a node older than that cannot be driven into it by a +// script in the page either. +const ADMIN_OPS = new Set([ + 'file_delete', 'dir_delete', 'invite_create', 'invite_link_create', + 'invite_cancel', 'member_revoke', 'apps_enabled', 'set_scan_settings', + 'tmdb_config', 'tmdb_enabled', 'tmdb_override', 'tmdb_rematch', + 'musicbrainz_enabled', 'root_remove', 'root_eject', 'root_plug', + 'app_directories', 'chat_directory', 'chat_link_preview', 'search_listed', + 'chat_epoch', +]); + // ── Field checks ────────────────────────────────────────────────────────── // // Shapes, not trust: what is checked here is that a field is what its name @@ -143,7 +159,7 @@ function transcriptFor(kind, f, ctx) { } case 'admin': { const op = String(fields.op ?? ''); - if (!/^[a-z_]{1,32}$/.test(op)) refuse('not an operation'); + if (!ADMIN_OPS.has(op)) refuse('not an operation'); return lenPrefixed(PREFIX.admin, [ enc(op), enc(sameNode()), enc(groupId(fields.groupId)), enc(text(fields.subject, 'the subject', 16384)), |