#12 — same user on two devices now shows as two independent tiles (was: LiveKit kicked the older connection, "audio jumps to whichever joined last"): - LiveKit identity is now the per-connection mesh peerId, not the user id. /api/meetings/token + guest-token mint identity=peerId when the client supplies it (anti-hijack: never mint another live user's peerId). Web maps SFU tracks by identity==peerId, keeping peerIdForUid as a fallback for the transition/native. - Mesh dedup (dropDupPeers) now keys on a stable per-device clientId (persisted, sent on meeting-join, echoed by the server) instead of user id — so two real devices keep separate tiles while a same-device reconnect ghost still collapses. Verified in a real browser: 2 devices -> 2 tiles; same-device reconnect -> 1. - Native: plugin gains reconnectRoom(); after the native WebView joins the mesh it re-homes the LiveKit media onto its peerId identity. syncVideoTiles keys by peerId. Token-identity + anti-hijack + clientId echo verified by a server test. #5 — iOS live transcript (WKWebView has no Web Speech API, so an iOS participant was never transcribed; desktop already works): - native-call plugin transcribes the local mic with SFSpeechRecognizer, fed by a LiveKit AudioRenderer on the local mic track (reuses the call's open mic — no 2nd AVAudioEngine). Finalized segments -> 'transcript' event -> web sends meeting-transcript (same server assembly as desktop). startSR/stopSR use the native recognizer on native calls; Web Speech API path unchanged elsewhere. - NSSpeechRecognitionUsageDescription added to the iOS Info.plist. Native pieces (#12 reconnect, #5 transcript) need a Codemagic build; the web+server half is verified and deploys now (already fixes the reported laptop+phone case). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+30
-7
@@ -1038,12 +1038,25 @@ route('POST', '/api/meetings/token', async (req, res) => {
|
||||
const u = await currentUser(req);
|
||||
if (!u) return json(res, 401, { error: 'unauthorized' });
|
||||
if (!LIVEKIT_ENABLED) return json(res, 501, { error: 'sfu not configured' });
|
||||
const { room } = await readBody(req);
|
||||
const rm = String(room || '').trim();
|
||||
const body = await readBody(req);
|
||||
const rm = String(body.room || '').trim();
|
||||
if (!/^[A-Za-z0-9._-]{4,64}$/.test(rm)) return json(res, 400, { error: 'invalid room' });
|
||||
// #12 Multi-device: mint the token with identity = the caller's mesh peerId (unique per connection) when it's
|
||||
// supplied, so the SAME user joining from two devices no longer collides on LiveKit — which allows exactly
|
||||
// one connection per identity, so with identity=userId the older device was kicked ("audio jumps to whichever
|
||||
// joined last"). Falls back to the user id when no peerId is passed (e.g. a native OUTGOING token fetched
|
||||
// before the WebView has joined the mesh; the plugin reconnects with a peerId token once it has one). The
|
||||
// client got its peerId from `meeting-joined`. Anti-hijack: refuse a peerId that's a DIFFERENT live user's.
|
||||
let identity = u.id;
|
||||
const pid = (typeof body.peerId === 'string') ? body.peerId.trim() : '';
|
||||
if (/^[A-Za-z0-9_-]{4,64}$/.test(pid)) {
|
||||
let ok = true;
|
||||
try { const peers = require('./presence').meetingRooms.get(rm); const pr = peers && peers.get(pid); if (pr && pr.uid && pr.uid !== u.id) ok = false; } catch (_) {}
|
||||
if (ok) identity = pid;
|
||||
}
|
||||
const metadata = JSON.stringify({ avatarUrl: u.avatar_url || '' });
|
||||
const token = livekitToken(u.id, u.name || u.email, rm, metadata);
|
||||
json(res, 200, { token, url: LIVEKIT_URL, identity: u.id, name: u.name || u.email });
|
||||
const token = livekitToken(identity, u.name || u.email, rm, metadata);
|
||||
json(res, 200, { token, url: LIVEKIT_URL, identity, name: u.name || u.email });
|
||||
});
|
||||
|
||||
// GUEST (no-login) LiveKit token — lets an external person invited by link join a meeting without a
|
||||
@@ -1051,7 +1064,8 @@ route('POST', '/api/meetings/token', async (req, res) => {
|
||||
// meeting, so a token can't be created for an arbitrary/expired code. Identity is a throwaway guest id.
|
||||
route('POST', '/api/meetings/guest-token', async (req, res) => {
|
||||
if (!LIVEKIT_ENABLED) return json(res, 501, { error: 'sfu not configured' });
|
||||
const { room, name, identity } = await readBody(req);
|
||||
const body = await readBody(req);
|
||||
const { room, name, identity } = body;
|
||||
const rm = String(room || '').trim();
|
||||
if (!/^\d{6}$/.test(rm)) return json(res, 400, { error: 'invalid room' });
|
||||
const live = (() => { try { return require('./presence').meetingRooms.has(rm); } catch (_) { return false; } })();
|
||||
@@ -1071,8 +1085,17 @@ route('POST', '/api/meetings/guest-token', async (req, res) => {
|
||||
// signaling (meeting-join guestId) — that mapping is how their media attaches to their tile.
|
||||
const gid = (typeof identity === 'string' && /^guest-[a-z0-9]+$/i.test(identity)) ? identity.slice(0, 64) : ('guest-' + crypto.randomBytes(8).toString('hex'));
|
||||
const gname = String(name || 'Guest').slice(0, 60);
|
||||
const token = livekitToken(gid, gname, rm, JSON.stringify({ guest: true }));
|
||||
json(res, 200, { token, url: LIVEKIT_URL, identity: gid, name: gname });
|
||||
// #12 Multi-device (same as /api/meetings/token): prefer the guest's per-connection mesh peerId as the
|
||||
// LiveKit identity so two devices don't collide; fall back to the throwaway guest id. Anti-hijack guarded.
|
||||
let lkid = gid;
|
||||
const pid = (typeof body.peerId === 'string') ? body.peerId.trim() : '';
|
||||
if (/^[A-Za-z0-9_-]{4,64}$/.test(pid)) {
|
||||
let ok = true;
|
||||
try { const peers = require('./presence').meetingRooms.get(rm); const pr = peers && peers.get(pid); if (pr && pr.uid && pr.uid !== gid) ok = false; } catch (_) {}
|
||||
if (ok) lkid = pid;
|
||||
}
|
||||
const token = livekitToken(lkid, gname, rm, JSON.stringify({ guest: true }));
|
||||
json(res, 200, { token, url: LIVEKIT_URL, identity: lkid, name: gname });
|
||||
});
|
||||
|
||||
// Decline an incoming 1:1 call: drops the caller, posts a "Call declined" line, clears the call.
|
||||
|
||||
Reference in New Issue
Block a user