Add a STUN responder to the proxy for screen sharing between browsers
The browser build uses it automatically, so no outside server is contacted. Covered by unit tests and a two-browser step in the web E2E. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
// A bare browser window for the web E2E: no preload, so the page runs as it would in a browser.
|
||||
const { app, BrowserWindow, session } = require('electron');
|
||||
const { app, BrowserWindow, desktopCapturer, session } = require('electron');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
@@ -11,6 +11,12 @@ app.whenReady().then(() => {
|
||||
item.setSavePath(file);
|
||||
item.once('done', (_ev, state) => { if (state === 'completed') fs.writeFileSync(file + '.done', ''); });
|
||||
});
|
||||
// Stands in for the browser's own "choose what to share" dialog: always the first screen
|
||||
session.defaultSession.setDisplayMediaRequestHandler(async (_request, callback) => {
|
||||
let sources = [];
|
||||
for (let i = 0; i < 10 && !sources.length; i++) sources = await desktopCapturer.getSources({ types: ['screen'] });
|
||||
try { callback(sources.length ? { video: sources[0] } : {}); } catch { /* refused */ }
|
||||
});
|
||||
const win = new BrowserWindow({ width: 1280, height: 800, webPreferences: { contextIsolation: true, sandbox: true, nodeIntegration: false } });
|
||||
win.loadURL(process.env.MUMH5_WEB_URL);
|
||||
});
|
||||
|
||||
+33
-2
@@ -42,7 +42,7 @@ function within<T>(p: Promise<T>, label: string, ms = 10000): Promise<T> {
|
||||
const proxyPort = 18000 + Math.floor(Math.random() * 1000);
|
||||
const { ELECTRON_RUN_AS_NODE, ...env } = process.env;
|
||||
const proxy = spawn(process.execPath, [path.join(root, 'dist-proxy/proxy.mjs')], {
|
||||
env: { ...env, MUMH5_PORT: String(proxyPort), MUMH5_SERVERS: `${target}=Test Server` }, stdio: ['ignore', 'pipe', 'inherit']
|
||||
env: { ...env, MUMH5_PORT: String(proxyPort), MUMH5_SERVERS: `${target}=Test Server`, MUMH5_STUN_PORT: String(proxyPort + 1000), MUMH5_STUN_BIND: '127.0.0.1' }, stdio: ['ignore', 'pipe', 'inherit']
|
||||
});
|
||||
await within(new Promise<void>((res, rej) => {
|
||||
proxy.stdout.on('data', d => { if (String(d).includes('listening')) res(); });
|
||||
@@ -50,12 +50,14 @@ await within(new Promise<void>((res, rej) => {
|
||||
}), 'proxy start');
|
||||
|
||||
const downloads = mkdtempSync(path.join(tmpdir(), 'mumh5-e2e-dl-'));
|
||||
const app = await electron.launch({
|
||||
const browser = () => electron.launch({
|
||||
executablePath: electronPath as unknown as string,
|
||||
args: [path.join(root, 'test/e2e/web-shell.cjs'), `--user-data-dir=${mkdtempSync(path.join(tmpdir(), 'mumh5-e2e-'))}`, '--ozone-platform=x11',
|
||||
'--use-fake-device-for-media-stream', '--use-fake-ui-for-media-stream'],
|
||||
env: { ...env, MUMH5_WEB_URL: `http://127.0.0.1:${proxyPort}/`, MUMH5_DOWNLOADS: downloads } as Record<string, string>
|
||||
});
|
||||
const app = await browser();
|
||||
let second: Awaited<ReturnType<typeof browser>> | null = null;
|
||||
const bob = await headless('bob');
|
||||
const name = `webalice${Date.now() % 100000}`;
|
||||
try {
|
||||
@@ -160,9 +162,38 @@ try {
|
||||
assert.equal(hash(), before, 'same certificate after a reload');
|
||||
console.log('ok: identity kept across a reload');
|
||||
|
||||
// Screen sharing between two browsers: the proxy's own STUN is used without any setting
|
||||
assert.equal((await (await fetch(`http://127.0.0.1:${proxyPort}/api/config`)).json()).stun, proxyPort + 1000);
|
||||
second = await browser();
|
||||
const page2 = await second.firstWindow();
|
||||
page2.on('console', m => { if (m.type() === 'error') console.log('[page2]', m.text()); });
|
||||
await page2.setViewportSize({ width: 1280, height: 800 });
|
||||
await page2.getByRole('button', { name: /Create a new identity/ }).click();
|
||||
await page2.getByLabel('Name', { exact: true }).fill(`webcarol${Date.now() % 100000}`);
|
||||
await page2.getByRole('button', { name: 'Create', exact: true }).click();
|
||||
await page2.getByRole('button', { name: 'Skip for now' }).click();
|
||||
await page2.getByRole('button', { name: 'Done' }).click();
|
||||
await page2.getByTitle('Add a server').click();
|
||||
await page2.getByRole('button', { name: 'Save and connect' }).click();
|
||||
await page2.getByText(/Connected/).first().waitFor();
|
||||
|
||||
await page.locator('.me').getByRole('button', { name: 'Share your screen' }).click();
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
const dialog = page.getByRole('dialog', { name: 'Share your screen' });
|
||||
await dialog.getByRole('button', { name: 'Choose a screen or window...' }).click();
|
||||
await page.waitForFunction(() => (document.querySelector('.preview video') as HTMLVideoElement | null)?.videoWidth! > 0, null, { timeout: 60000 });
|
||||
await dialog.getByRole('button', { name: 'Start sharing' }).click();
|
||||
await page.getByText('You are sharing your screen').waitFor();
|
||||
await page2.locator('.stage .tile', { hasText: name }).click();
|
||||
await page2.getByRole('button', { name: 'Continue' }).click();
|
||||
await page2.waitForFunction(() => (document.querySelector('.spot video') as HTMLVideoElement | null)?.videoWidth! > 0, null, { timeout: 30000 });
|
||||
await page.getByText('You are sharing your screen, 1 watching').waitFor();
|
||||
console.log('ok: screen sharing between two browsers');
|
||||
|
||||
console.log('WEB E2E PASSED');
|
||||
} finally {
|
||||
bob.disconnect();
|
||||
await app.close().catch(() => {});
|
||||
await second?.close().catch(() => {});
|
||||
proxy.kill();
|
||||
}
|
||||
|
||||
+44
-1
@@ -2,8 +2,10 @@
|
||||
// real Mumble server and are skipped unless MUMBLE_TEST_HOST is set (see server.test.ts).
|
||||
import { test } from 'node:test';
|
||||
import net from 'node:net';
|
||||
import dgram from 'node:dgram';
|
||||
import assert from 'node:assert/strict';
|
||||
import { proxyLine } from '../electron/tls-transport.ts';
|
||||
import { stunResponse } from '../server/proxy.ts';
|
||||
import { startProxy, defaults, parseServers, isPrivateAddress, type ProxyConfig } from '../server/proxy.ts';
|
||||
import { WebSocket as WsClient } from 'ws';
|
||||
import { WebSocketTransport } from '../src/core/ws-transport.ts';
|
||||
@@ -40,7 +42,7 @@ test('refuses to start without allowed servers', async () => {
|
||||
|
||||
test('config lists the allowed servers', async () => {
|
||||
await withProxy({ servers: parseServers('voice.example.org=Friends') }, async base => {
|
||||
assert.deepEqual(await (await fetch(`${base}/api/config`)).json(), { servers: [{ host: 'voice.example.org', port: 64738, label: 'Friends' }], any: false });
|
||||
assert.deepEqual(await (await fetch(`${base}/api/config`)).json(), { servers: [{ host: 'voice.example.org', port: 64738, label: 'Friends' }], any: false, stun: null });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -64,6 +66,47 @@ test('identity create, describe, export and import round trip', async () => {
|
||||
});
|
||||
});
|
||||
|
||||
// A binding request: type, length 0, magic cookie, 12 byte transaction id
|
||||
const stunRequest = () => Uint8Array.from([0, 1, 0, 0, 0x21, 0x12, 0xa4, 0x42, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]);
|
||||
|
||||
test('STUN answers carry the sender address, masked as the protocol asks', () => {
|
||||
const v4 = stunResponse(stunRequest(), '203.0.113.7', 54321)!;
|
||||
assert.deepEqual([...v4.subarray(0, 4)], [1, 1, 0, 12]);
|
||||
assert.deepEqual([...v4.subarray(4, 20)], [...stunRequest().subarray(4, 20)]);
|
||||
assert.deepEqual([...v4.subarray(20, 26)], [0, 0x20, 0, 8, 0, 1]);
|
||||
assert.equal(((v4[26] << 8) | v4[27]) ^ 0x2112, 54321);
|
||||
assert.deepEqual([...v4.subarray(28)].map((b, i) => b ^ [0x21, 0x12, 0xa4, 0x42][i]), [203, 0, 113, 7]);
|
||||
// An IPv4 sender seen through an IPv6 socket is still IPv4
|
||||
assert.deepEqual([...stunResponse(stunRequest(), '::ffff:203.0.113.7', 54321)!], [...v4]);
|
||||
const v6 = stunResponse(stunRequest(), '2001:db8::7', 1)!;
|
||||
assert.equal(v6[25], 2);
|
||||
const mask = stunRequest().subarray(4, 20);
|
||||
assert.deepEqual([...v6.subarray(28)].map((b, i) => b ^ mask[i]), [0x20, 0x01, 0x0d, 0xb8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 7]);
|
||||
// Not a binding request
|
||||
assert.equal(stunResponse(new Uint8Array(20), '203.0.113.7', 1), null);
|
||||
assert.equal(stunResponse(stunRequest().subarray(0, 12), '203.0.113.7', 1), null);
|
||||
});
|
||||
|
||||
test('the proxy answers STUN over UDP and announces the port', async () => {
|
||||
const proxy = await startProxy({ ...defaults, port: 0, origins: ['*'], servers: parseServers('voice.example.org'), stunPort: 0, stunBind: '127.0.0.1' });
|
||||
try {
|
||||
assert.ok(proxy.stunPort);
|
||||
assert.equal((await (await fetch(`http://127.0.0.1:${proxy.port}/api/config`)).json()).stun, proxy.stunPort);
|
||||
const client = dgram.createSocket('udp4');
|
||||
const answer = await new Promise<Buffer>((resolve, reject) => {
|
||||
client.once('message', resolve);
|
||||
client.once('error', reject);
|
||||
client.send(stunRequest(), proxy.stunPort!, '127.0.0.1');
|
||||
setTimeout(() => reject(new Error('no STUN answer')), 3000);
|
||||
});
|
||||
assert.equal(((answer[26] << 8) | answer[27]) ^ 0x2112, client.address().port);
|
||||
assert.deepEqual([...answer.subarray(28)].map((b, i) => b ^ [0x21, 0x12, 0xa4, 0x42][i]), [127, 0, 0, 1]);
|
||||
client.close();
|
||||
} finally {
|
||||
await proxy.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('requests from other origins are refused', async () => {
|
||||
await withProxy({ servers: parseServers('voice.example.org'), origins: [] }, async base => {
|
||||
const cross = await post(base, 'identity/create', { name: 'x' }, { Origin: 'https://evil.example' });
|
||||
|
||||
Reference in New Issue
Block a user