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:
2026-10-01 23:52:18 +02:00
co-authored by Claude Opus 5.5
parent 08aa5268d0
commit 819db06bc6
19 changed files with 208 additions and 78 deletions
+19 -11
View File
@@ -167,6 +167,20 @@ export async function startProxy(config: ProxyConfig): Promise<{ port: number; s
if (route === 'config' && req.method === 'GET') {
return { servers: config.servers, any: config.allowAny, stun: stunPort, turn: stunPort != null && config.turn };
}
// What a client needs for screen sharing: the STUN port and, with the relay on, credentials
// for it. Open to any origin, because the desktop app asks from outside this site; checking
// the origin would not keep a script out anyway. The quotas are what limit use.
if (route === 'relay' && req.method === 'GET') {
if (stunPort == null) throw new HttpError(404, 'No STUN or relay here');
if (!config.turn) return { port: stunPort };
const addr = addressOf(req);
const used = (identityUse.get(addr) ?? 0) + 1;
identityUse.set(addr, used);
if (used > 20) throw new HttpError(429, 'Too many requests, try again in a minute');
// The username is the time it runs out; the relay recomputes the password from it
const username = String(Math.floor(Date.now() / 1000) + CREDENTIAL_TTL);
return { port: stunPort, username, credential: turnCredential(turnSecret, username), ttl: CREDENTIAL_TTL };
}
if (req.method !== 'POST') throw new HttpError(404, 'Not found');
if (!originOk(req)) throw new HttpError(403, 'Origin not allowed');
const body = await readJson(req);
@@ -176,16 +190,6 @@ export async function startProxy(config: ProxyConfig): Promise<{ port: number; s
identityUse.set(addr, used);
if (used > 20) throw new HttpError(429, 'Too many requests, try again in a minute');
}
if (route === 'turn') {
if (stunPort == null || !config.turn) throw new HttpError(404, 'No relay here');
const addr = addressOf(req);
const used = (identityUse.get(addr) ?? 0) + 1;
identityUse.set(addr, used);
if (used > 20) throw new HttpError(429, 'Too many requests, try again in a minute');
// The username is the time it runs out; the relay recomputes the password from it
const username = String(Math.floor(Date.now() / 1000) + CREDENTIAL_TTL);
return { username, credential: turnCredential(turnSecret, username), ttl: CREDENTIAL_TTL, port: stunPort };
}
switch (route) {
// Nothing is stored here: the browser keeps its identities and sends one along when it connects
case 'identity/create':
@@ -234,7 +238,11 @@ export async function startProxy(config: ProxyConfig): Promise<{ port: number; s
const work = pathname.startsWith('/api/')
? api(req, pathname.slice(5)).then(result => {
const data = Buffer.from(JSON.stringify(result));
res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': data.length, 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff' });
res.writeHead(200, {
'Content-Type': 'application/json', 'Content-Length': data.length, 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff',
// The desktop app reads this one from another origin
...(pathname === '/api/relay' ? { 'Access-Control-Allow-Origin': '*' } : {})
});
res.end(data);
})
: serveStatic(req, res, pathname);