Camera sharing, and the proxy's relay for the desktop app
- A camera is a second source in the share dialog; streams carry their kind, so icons and wording follow. Browsers without screen capture (phones) start on the camera. - Settings, Voice: the address of a mumh5 web version as relay; its STUN and relay are then offered with every stream and take over when no direct connection comes up. - The proxy hands out relay credentials at GET /api/relay, readable from any origin. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -4,6 +4,7 @@ import { _electron as electron, type Page } from 'playwright-core';
|
||||
import electronPath from 'electron';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync } from 'node:fs';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
@@ -45,6 +46,7 @@ async function join(page: Page, name: string) {
|
||||
await page.getByText(/Connected/).first().waitFor();
|
||||
}
|
||||
|
||||
const cleanup: (() => void)[] = [];
|
||||
const alice = await launch();
|
||||
const bob = await launch();
|
||||
const aliceName = `share-a-${tag}`, bobName = `share-b-${tag}`;
|
||||
@@ -243,6 +245,39 @@ try {
|
||||
assert.equal(await alice.page.locator('.stage .status').count(), 0);
|
||||
console.log('ok: stopping ends the stream for viewers');
|
||||
|
||||
// Camera instead of the screen, and a relay for networks without direct connections: both
|
||||
// instances point at a mumh5 proxy, and the viewer is limited to the relay over TCP
|
||||
const relayPort = 19000 + Math.floor(Math.random() * 500);
|
||||
const relay = spawn(process.execPath, ['server/main.ts'], {
|
||||
cwd: root, stdio: ['ignore', 'pipe', 'inherit'],
|
||||
env: { ...env, MUMH5_PORT: String(relayPort), MUMH5_ALLOW_ANY: '1', MUMH5_ALLOW_PRIVATE: '1', MUMH5_STUN_PORT: String(relayPort + 500), MUMH5_STUN_BIND: '127.0.0.1', MUMH5_TURN_IP: '127.0.0.1' }
|
||||
});
|
||||
cleanup.push(() => relay.kill());
|
||||
await new Promise<void>((res, rej) => { relay.stdout.on('data', d => { if (String(d).includes('listening')) res(); }); relay.on('exit', c => rej(new Error(`proxy exited with ${c}`))); });
|
||||
for (const p of [alice.page, bob.page]) {
|
||||
await p.getByTitle('Settings').click();
|
||||
await p.getByLabel('Relay').fill(`http://127.0.0.1:${relayPort}`);
|
||||
await p.getByLabel('Relay').blur();
|
||||
await p.getByRole('button', { name: 'Done', exact: true }).click();
|
||||
}
|
||||
await bob.page.evaluate(() => localStorage.setItem('mumh5.iceDebug', 'relay-tcp'));
|
||||
|
||||
await shareBtn.click();
|
||||
await picker.getByRole('tab', { name: 'Camera' }).click();
|
||||
await alice.page.waitForFunction(() => (document.querySelector('.preview video') as HTMLVideoElement | null)?.videoWidth! > 0, null, { timeout: 15000 });
|
||||
assert.equal(await picker.getByLabel('Sound').count(), 0, 'no extra sound with a camera');
|
||||
await startBtn.click();
|
||||
await alice.page.locator('.sidebar .row.user.self').getByTitle('You are sharing your camera').waitFor();
|
||||
const cam = bob.page.locator('.sidebar').getByRole('button', { name: `Watch the camera of ${aliceName}`, exact: true });
|
||||
await cam.waitFor();
|
||||
await cam.click();
|
||||
await bob.page.waitForFunction(() => (document.querySelector('.spot video') as HTMLVideoElement | null)?.videoWidth! > 0, null, { timeout: 20000 });
|
||||
const camStats = await nerd(bob.page, [/^relayed \(TURN\), reached over TCP/, /^0 direct, 0 through STUN, [1-9]\d* relayed/]);
|
||||
await alice.page.locator('.me').getByRole('button', { name: 'Stop sharing your camera' }).click();
|
||||
await cam.waitFor({ state: 'detached' });
|
||||
await bob.page.evaluate(() => localStorage.removeItem('mumh5.iceDebug'));
|
||||
console.log(`ok: camera shared, watched through the relay set in the desktop app (${camStats.match(/Picture \| [^|]+/)?.[0]})`);
|
||||
|
||||
// A sharer who disconnects takes the stream with them
|
||||
await shareBtn.click();
|
||||
await pickAndStart();
|
||||
@@ -260,6 +295,7 @@ try {
|
||||
|
||||
console.log('SHARE E2E PASSED');
|
||||
} finally {
|
||||
for (const c of cleanup) c();
|
||||
await alice.app.close().catch(() => {});
|
||||
await bob.app.close().catch(() => {});
|
||||
}
|
||||
|
||||
@@ -212,6 +212,18 @@ try {
|
||||
await stats.getByText(/^\d+ kbit\/s$/).first().waitFor({ timeout: 8000 });
|
||||
console.log('ok: screen sharing to a browser without direct UDP, through the relay over TCP');
|
||||
|
||||
// A camera instead, still watched through the relay over TCP
|
||||
await page.locator('.me').getByRole('button', { name: 'Stop sharing your screen' }).click();
|
||||
await page2.getByRole('region', { name: 'Screen share' }).waitFor({ state: 'detached' });
|
||||
await page.locator('.me').getByRole('button', { name: 'Share your screen or camera' }).click();
|
||||
await dialog.getByRole('tab', { name: 'Camera' }).click();
|
||||
await page.waitForFunction(() => (document.querySelector('.preview video') as HTMLVideoElement | null)?.videoWidth! > 0, null, { timeout: 30000 });
|
||||
await dialog.getByRole('button', { name: 'Start sharing' }).click();
|
||||
await page2.locator('.sidebar').getByRole('button', { name: `Watch the camera of ${name}`, exact: true }).click();
|
||||
await page2.waitForFunction(() => (document.querySelector('.spot video') as HTMLVideoElement | null)?.videoWidth! > 0, null, { timeout: 30000 });
|
||||
await page.locator('.stage .status', { hasText: '1 watching' }).waitFor();
|
||||
console.log('ok: camera shared between two browsers, through the relay');
|
||||
|
||||
console.log('WEB E2E PASSED');
|
||||
} finally {
|
||||
bob.disconnect();
|
||||
|
||||
+5
-2
@@ -111,9 +111,12 @@ test('the proxy answers STUN over UDP and announces the port', async () => {
|
||||
// Relay credentials for people on this site: a username that expires and its password
|
||||
const base = `http://127.0.0.1:${proxy.port}`;
|
||||
assert.equal((await (await fetch(`${base}/api/config`)).json()).turn, true);
|
||||
const cred = await (await post(base, 'turn', {})).json();
|
||||
// What clients need for screen sharing: the port, and relay credentials that expire.
|
||||
// Readable from any origin, since the desktop app asks from outside this site.
|
||||
const res = await fetch(`${base}/api/relay`, { headers: { Origin: 'app://mumh5' } });
|
||||
assert.equal(res.headers.get('access-control-allow-origin'), '*');
|
||||
const cred = await res.json();
|
||||
assert.ok(Number(cred.username) > Date.now() / 1000 && cred.credential.length > 20 && cred.port === proxy.stunPort);
|
||||
assert.equal((await post(base, 'turn', {}, { Origin: 'https://evil.example' })).status, 200, 'origins are open in this test');
|
||||
assert.deepEqual([...answer.subarray(28)].map((b, i) => b ^ [0x21, 0x12, 0xa4, 0x42][i]), [127, 0, 0, 1]);
|
||||
client.close();
|
||||
} finally {
|
||||
|
||||
Reference in New Issue
Block a user