#12 multi-device tiles + #5 native iOS transcript

#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:
2026-08-14 16:36:18 +05:30
parent c322448774
commit 68ff3a878b
5 changed files with 260 additions and 35 deletions
+30 -7
View File
@@ -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.