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.js168
-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.js103
-rw-r--r--packages/meshbay-client/src/preload.js23
-rw-r--r--packages/meshbay-client/src/transcripts.js18
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)),