#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:
+64
-25
@@ -4173,7 +4173,7 @@ async function setupNativeCall(){
|
||||
}catch(e){ console.warn('[callkit] answerCall failed:', e); } });
|
||||
// Enforce the muted-by-default rule against the plugin's actual mic once the room connects (belt-and-braces
|
||||
// for builds whose plugin still connects the mic live). meetMic is the source of truth for the mic button.
|
||||
NC.addListener('callConnected', ()=>{ if(meetNative){ try{ NC.setMuted({ muted:!meetMic }); }catch(_){} } });
|
||||
NC.addListener('callConnected', ()=>{ if(meetNative){ try{ NC.setMuted({ muted:!meetMic }); }catch(_){} if(meetCam){ try{ NC.setCamera({ on:true }); }catch(_){} } } }); // re-applies mic/cam after a #12 media reconnect too
|
||||
NC.addListener('callError', (e)=>{ console.warn('[callkit] call error:', (e&&e.error)||''); });
|
||||
// Muted from the CallKit system UI → reflect it in the meeting window's mic button.
|
||||
NC.addListener('setMuted', (e)=>{ if(!e||meetState!=='call'||!meetNative) return; meetMic=!e.muted; try{ updateMicBtn(); setTileMute('__local', !meetMic); meetSend({type:'meeting-state', muted:!meetMic, camOff:!meetCam}); }catch(_){} });
|
||||
@@ -4199,6 +4199,19 @@ async function callkitReportOutgoing(uuid, room, kind, peerName, hasVideo){
|
||||
let tk={}; try{ tk=await postJSON('/api/meetings/token',{ room }); }catch(_){}
|
||||
try{ NC.reportOutgoingCall({ callUUID:uuid, room, kind:kind||'dm', peerName:peerName||'Call', hasVideo:!!hasVideo, url:tk.url||'', token:tk.token||'' }); }catch(_){}
|
||||
}
|
||||
// #12: once the native WebView has joined the mesh (so it owns a per-connection peerId), reconnect the plugin's
|
||||
// LiveKit media with a token whose identity = that peerId. The plugin connected INSTANTLY on answer using the
|
||||
// push token (identity=userId) for zero-latency audio; this swaps it to the unique peerId identity a beat later
|
||||
// so two devices of the same user no longer collide (LiveKit = one connection per identity). callConnected
|
||||
// re-fires after the reconnect, which re-applies mic/cam. No-op on older plugin builds (no reconnectRoom) — they
|
||||
// stay on the userId identity, which is still correct for a single device.
|
||||
async function bzNativeReconnectMedia(){
|
||||
if(!meetNative || !meetMyId) return;
|
||||
const NC=nativeCallPlugin(); if(!NC || !NC.reconnectRoom) return;
|
||||
let tk={}; try{ tk=await postJSON('/api/meetings/token',{ room:meetRoom, peerId:meetMyId }); }catch(_){ return; }
|
||||
if(!tk || !tk.token) return;
|
||||
try{ const p=NC.reconnectRoom({ url:tk.url||'', token:tk.token }); if(p&&p.catch) p.catch(()=>{}); }catch(_){}
|
||||
}
|
||||
// End the CallKit call (remote hung up / we left / call ended). Safe no-op off-CallKit.
|
||||
function callkitEnd(uuid){ const NC=nativeCallPlugin(); if(!NC||!uuid) return; try{ NC.endCall({ callUUID:uuid }); }catch(_){} }
|
||||
// Ring CallKit from a WebSocket call event — a 2nd, reliable path alongside the VoIP push for when the app is
|
||||
@@ -4823,6 +4836,7 @@ const meetMuted=new Map(); // peerId|'__local' -> muted (for the tile mic-off
|
||||
const meetNames=new Map(); // peerId -> name (peers that arrive before their offer)
|
||||
const meetAvatars=new Map(); // peerId -> avatar URL (for participant-tile profile pics)
|
||||
const meetPeerUids=new Map();// peerId -> user id (to tell when an invitee has joined)
|
||||
const meetPeerClients=new Map();// peerId -> stable per-device clientId (#12: dedup a reconnecting DEVICE without collapsing a genuine 2nd device of the same user)
|
||||
const meetCamOff=new Map(); // peerId -> camera off? (a disabled remote track still arrives, so show the avatar)
|
||||
const meetInvited=new Map(); // user id -> {name, timer} for invitees who haven't joined yet (#6)
|
||||
let meetReturn=null; // {kind:'dm'|'group', id} — chat to land on when the call ends (null = meetings tab)
|
||||
@@ -4837,9 +4851,13 @@ let meetMultiShare=false; // host setting: allow several people to share at
|
||||
let meetRec=null; // active composite recording (host) {rec, stop()}
|
||||
let meetTranscribe=false; // am I subscribed to a transcript copy
|
||||
let meetRoomTx=false; // is the room transcription active (≥1 subscriber → all mics transcribe)
|
||||
let meetSR=null; // my SpeechRecognition instance
|
||||
let meetSR=null; // my SpeechRecognition instance ('native' sentinel when the iOS plugin transcribes)
|
||||
let _meetTxListener=null; // #5: native 'transcript' event listener handle (iOS native calls)
|
||||
let meetStageId=null; // which shared screen is currently on the stage (peerId|'__local')
|
||||
function meetSend(o){ try{ if(meetWs && meetWs.readyState===1) meetWs.send(JSON.stringify(o)); }catch(_){} }
|
||||
// #12: a stable per-DEVICE id (persisted). Sent on meeting-join so the mesh can tell a reconnecting device
|
||||
// (evict its ghost peer) apart from a genuine SECOND device of the same user (keep both tiles).
|
||||
function bzDeviceId(){ try{ let id=localStorage.getItem('bzc_device_id'); if(!id){ id=(window.crypto&&crypto.randomUUID)?crypto.randomUUID():('dev-'+Date.now().toString(36)+Math.random().toString(36).slice(2,10)); localStorage.setItem('bzc_device_id', id); } return id; }catch(_){ return 'dev-'+Math.random().toString(36).slice(2,12); } }
|
||||
|
||||
// ================= LiveKit SFU media plane (feature-flagged) =================
|
||||
// When the server reports sfu:true, meeting MEDIA flows through LiveKit instead of the P2P mesh:
|
||||
@@ -4855,8 +4873,8 @@ async function sfuConnect(){
|
||||
const LK=await sfuLoadLib(); SFU.lib=LK;
|
||||
// Guests (external link joiners, not signed in) get an unauthenticated guest token for this room.
|
||||
const tk=(ME&&ME.guest)
|
||||
? await postJSON('/api/meetings/guest-token',{ room:meetRoom, name:ME.name, identity:ME.id }) // identity must match the guestId sent over signaling
|
||||
: await postJSON('/api/meetings/token',{ room:meetRoom }); // per-user, per-room join credential
|
||||
? await postJSON('/api/meetings/guest-token',{ room:meetRoom, name:ME.name, identity:ME.id, peerId:meetMyId }) // identity=peerId (#12 multi-device); guestId kept for back-compat
|
||||
: await postJSON('/api/meetings/token',{ room:meetRoom, peerId:meetMyId }); // #12: identity = this connection's mesh peerId, so two devices of one user don't collide on LiveKit
|
||||
// adaptiveStream + dynacast BOTH off: we attach media via manual srcObject (not track.attach), so
|
||||
// LiveKit can't observe tile visibility. With dynacast on, a screen-share layer was paused unless a
|
||||
// camera track was also flowing → the screen showed blank / only when the camera was on, and was
|
||||
@@ -4878,7 +4896,10 @@ function sfuRebuild(pid){
|
||||
addTile(pid, st, meetNames.get(pid)||'Guest', false); setTileScreen(pid, !!p.screen); meetWatchStream(pid, st);
|
||||
}
|
||||
function sfuAttach(pub, track, participant, _try){
|
||||
let pid=peerIdForUid(participant.identity);
|
||||
// #12: LiveKit identity is now the mesh peerId (unique per connection), so a track maps straight to its
|
||||
// tile. peerIdForUid is kept as a fallback for any participant still on the old identity=userId scheme
|
||||
// (e.g. a native device on an older plugin build, before it reconnects with its peerId token).
|
||||
let pid = meetPeers.has(participant.identity) ? participant.identity : peerIdForUid(participant.identity);
|
||||
if(!pid){ // uid→peerId map (from the mesh join) may lag the LiveKit track — retry a few times…
|
||||
if((_try||0)<6){ setTimeout(()=>sfuAttach(pub,track,participant,(_try||0)+1), 400); return; }
|
||||
// …then fall back: a NATIVE (CallKit + LiveKit) participant joins LiveKit but NOT our WS mesh, so it has
|
||||
@@ -4900,7 +4921,7 @@ function sfuAttach(pub, track, participant, _try){
|
||||
sfuRebuild(pid); updateShareMode();
|
||||
}
|
||||
function sfuDetach(pub, track, participant){
|
||||
let pid=peerIdForUid(participant.identity); if(!pid) pid='lk:'+participant.identity; const p=SFU.peers.get(pid); if(!p) return; const mt=track.mediaStreamTrack;
|
||||
let pid = meetPeers.has(participant.identity) ? participant.identity : (peerIdForUid(participant.identity) || ('lk:'+participant.identity)); const p=SFU.peers.get(pid); if(!p) return; const mt=track.mediaStreamTrack;
|
||||
if(p.audio===mt) p.audio=null; else if(p.screen===mt){ p.screen=null; meetSharers.delete(pid); setTileScreen(pid,false); } else if(p.cam===mt) p.cam=null;
|
||||
sfuRebuild(pid); updateShareMode();
|
||||
}
|
||||
@@ -5586,16 +5607,16 @@ function meetWatchStream(id, stream){
|
||||
}
|
||||
function meetUnwatch(id){ const r=meetVU.get(id); if(!r) return; try{cancelAnimationFrame(r.raf);}catch(_){} try{r.src.disconnect();}catch(_){} try{r.ctx.close();}catch(_){} meetVU.delete(id); const t=document.getElementById('meet-tile-'+id); if(t) t.classList.remove('speaking'); }
|
||||
function meetUnwatchAll(){ for(const id of Array.from(meetVU.keys())) meetUnwatch(id); }
|
||||
// #5: the same person showing up as several tiles. Each browser tab / reload / reconnect gets a NEW
|
||||
// peerId, so a stale session left a ghost tile behind. One person (uid) = one tile: when a uid reappears
|
||||
// on a new peerId, drop their older peer entirely.
|
||||
function dropDupPeers(uid, keepPid){
|
||||
if(!uid) return;
|
||||
for(const [pid,u] of [...meetPeerUids]){
|
||||
if(u!==uid || pid===keepPid) continue;
|
||||
// A stale ghost tile: the SAME DEVICE reconnected (reload / dropped WS) on a new peerId before its old
|
||||
// peer-left arrived. Dedup by the stable per-device clientId — NOT by user id — so a genuine SECOND device
|
||||
// of the same person (#12 multi-device) keeps its own independent tile instead of being collapsed.
|
||||
function dropDupPeers(clientId, keepPid){
|
||||
if(!clientId) return; // no device id (older client / guest race) → don't collapse; peer-left will clean up
|
||||
for(const [pid,c] of [...meetPeerClients]){
|
||||
if(c!==clientId || pid===keepPid) continue;
|
||||
const p=meetPeers.get(pid); if(p){ try{ p.pc&&p.pc.close(); }catch(_){} meetPeers.delete(pid); }
|
||||
if(SFU.on){ try{ sfuDropPeer(pid); }catch(_){} }
|
||||
meetSharers.delete(pid); meetPeerUids.delete(pid); meetNames.delete(pid); meetAvatars.delete(pid);
|
||||
meetSharers.delete(pid); meetPeerUids.delete(pid); meetPeerClients.delete(pid); meetNames.delete(pid); meetAvatars.delete(pid);
|
||||
removeTile(pid);
|
||||
}
|
||||
}
|
||||
@@ -5756,14 +5777,27 @@ async function uploadRecording(blob, durMs){
|
||||
// names); each subscriber gets a private copy. Unsubscribing only drops YOUR copy, not others'.
|
||||
function toggleTranscribe(){ meetTranscribe=!meetTranscribe; meetSend({type:'meeting-transcribe', on:meetTranscribe}); updateTransBtn(); toast(meetTranscribe?'Transcript on — your private copy is saved to Past meetings after the call':'You left the transcript — your copy is being saved'); }
|
||||
function applyRoomTx(active){ if(active===meetRoomTx) return; meetRoomTx=active; if(active) startSR(); else stopSR(); transcribeNotice(active); }
|
||||
function startSR(){ if(meetSR) return; const SR=window.SpeechRecognition||window.webkitSpeechRecognition; if(!SR){ if(meetTranscribe) toast('Live transcript needs Chrome or Edge'); return; }
|
||||
function startSR(){ if(meetSR) return;
|
||||
// #5: on an iOS NATIVE call the WKWebView has no Web Speech API — transcribe via the native plugin
|
||||
// (SFSpeechRecognizer tapping the call's mic). Its 'transcript' events feed the same meeting-transcript path.
|
||||
if(meetNative){ const NC=nativeCallPlugin(); if(NC && NC.startTranscription){
|
||||
meetSR='native';
|
||||
try{ _meetTxListener=NC.addListener('transcript', (e)=>{ const text=((e&&e.text)||'').trim(); if(text) meetSend({type:'meeting-transcript', text}); }); }catch(_){}
|
||||
try{ const p=NC.startTranscription(); if(p&&p.catch) p.catch(()=>{}); }catch(_){}
|
||||
return;
|
||||
}}
|
||||
const SR=window.SpeechRecognition||window.webkitSpeechRecognition; if(!SR){ if(meetTranscribe) toast('Live transcript needs Chrome or Edge'); return; }
|
||||
try{ meetSR=new SR(); }catch(_){ return; }
|
||||
meetSR.continuous=true; meetSR.interimResults=false; meetSR.lang='en-US';
|
||||
meetSR.onresult=(e)=>{ for(let i=e.resultIndex;i<e.results.length;i++){ const r=e.results[i]; if(r.isFinal){ const text=((r[0]&&r[0].transcript)||'').trim(); if(text) meetSend({type:'meeting-transcript', text}); } } };
|
||||
meetSR.onerror=()=>{}; meetSR.onend=()=>{ if(meetRoomTx){ try{ meetSR.start(); }catch(_){} } };
|
||||
try{ meetSR.start(); }catch(_){}
|
||||
}
|
||||
function stopSR(){ if(meetSR){ try{ meetSR.onend=null; meetSR.stop(); }catch(_){} meetSR=null; } }
|
||||
function _removeTxListener(){ const h=_meetTxListener; _meetTxListener=null; if(!h) return; try{ if(h.remove) h.remove(); else if(h.then) h.then(x=>{ try{ x&&x.remove&&x.remove(); }catch(_){} }); }catch(_){} }
|
||||
function stopSR(){
|
||||
if(meetSR==='native'){ meetSR=null; _removeTxListener(); const NC=nativeCallPlugin(); if(NC&&NC.stopTranscription){ try{ const p=NC.stopTranscription(); if(p&&p.catch) p.catch(()=>{}); }catch(_){} } return; }
|
||||
if(meetSR){ try{ meetSR.onend=null; meetSR.stop(); }catch(_){} meetSR=null; }
|
||||
}
|
||||
function updateTransBtn(){ const b=document.getElementById('meetTransBtn'); if(!b) return; b.classList.toggle('on', meetTranscribe); b.title=meetTranscribe?'Stop my transcript':'Transcribe (your private copy)'; }
|
||||
function transcribeNotice(on){ let el=document.getElementById('txNotice'); if(on){ if(!el){ el=document.createElement('div'); el.id='txNotice'; el.className='tx-notice'; el.innerHTML=ic('fileText',12)+' Transcribing'; document.body.appendChild(el); } } else if(el){ el.remove(); } }
|
||||
// iOS blocks media/WebRTC audio playback until a user gesture, so remote call audio stays SILENT until you
|
||||
@@ -5811,8 +5845,11 @@ function bzNativeSyncTiles(){
|
||||
if(r.top < maxBottom && (r.top + h) > maxBottom) h = maxBottom - r.top; // legacy clamp
|
||||
if(h<2){ el.classList.remove('bz-hasvid'); return; }
|
||||
let uid, local=false, name='', muted=false, screen=false, camOn=false;
|
||||
if(id==='__local'){ uid=(ME&&ME.id)||''; local=true; name=(ME&&ME.name)||'You'; muted=!meetMic; camOn=!!meetCam; }
|
||||
else { uid=meetPeerUids.get(id)||''; name=meetNames.get(id)||'Guest'; muted=!!meetMuted.get(id); screen=meetSharers.has(id); camOn=(meetCamOff.get(id)!==true); }
|
||||
// #12: the plugin resolves a remote LiveKit participant by this key, and identities are now the mesh
|
||||
// peerId (per connection) — so pass the tile's peerId (`id`), not the user id. (__local uses the local
|
||||
// participant directly, so its key is irrelevant.)
|
||||
if(id==='__local'){ uid=meetMyId||(ME&&ME.id)||''; local=true; name=(ME&&ME.name)||'You'; muted=!meetMic; camOn=!!meetCam; }
|
||||
else { uid=id; name=meetNames.get(id)||'Guest'; muted=!!meetMuted.get(id); screen=meetSharers.has(id); camOn=(meetCamOff.get(id)!==true); }
|
||||
if(!uid){ el.classList.remove('bz-hasvid'); return; }
|
||||
// hole-punch: on tiles that have native video, hide the web avatar/bg so the video (behind) shows through.
|
||||
el.classList.toggle('bz-hasvid', bzHolePunch && (screen || camOn));
|
||||
@@ -5887,11 +5924,11 @@ async function enterMeeting(code, audioOnly, opts){
|
||||
renderCallConnecting(); // branded "Connecting…" until the room is created/joined (esp. on slow links)
|
||||
meetWs=new WebSocket((location.protocol==='https:'?'wss://':'ws://')+location.host+'/ws');
|
||||
meetWs.onmessage=onMeetMsg;
|
||||
meetWs.onopen=()=>{ if(code){ meetRoom=code; renderCall(); meetSend({type:'meeting-join', room:code, name:(ME.name||ME.email||'Guest'), guestId:(ME&&ME.guest)?ME.id:undefined}); } else { meetSend({type:'meeting-create'}); } };
|
||||
meetWs.onopen=()=>{ if(code){ meetRoom=code; renderCall(); meetSend({type:'meeting-join', room:code, name:(ME.name||ME.email||'Guest'), guestId:(ME&&ME.guest)?ME.id:undefined, clientId:bzDeviceId()}); } else { meetSend({type:'meeting-create'}); } };
|
||||
}
|
||||
async function onMeetMsg(e){
|
||||
let m; try{ m=JSON.parse(e.data); }catch(_){ return; }
|
||||
if(m.type==='meeting-created'){ meetRoom=m.room; renderCall(); meetSend({type:'meeting-join', room:m.room, name:(ME.name||ME.email||'Guest'), guestId:(ME&&ME.guest)?ME.id:undefined}); if(meetAnnounceGroup){ const g=meetAnnounceGroup; meetAnnounceGroup=null; try{ postJSON('/api/messages',{group:g, body:'📹 Started a group call — join with code '+m.room}); }catch(_){} } return; }
|
||||
if(m.type==='meeting-created'){ meetRoom=m.room; renderCall(); meetSend({type:'meeting-join', room:m.room, name:(ME.name||ME.email||'Guest'), guestId:(ME&&ME.guest)?ME.id:undefined, clientId:bzDeviceId()}); if(meetAnnounceGroup){ const g=meetAnnounceGroup; meetAnnounceGroup=null; try{ postJSON('/api/messages',{group:g, body:'📹 Started a group call — join with code '+m.room}); }catch(_){} } return; }
|
||||
if(m.type==='meeting-joined'){
|
||||
meetMyId=m.peerId;
|
||||
if(_inLobby){ _inLobby=false; renderCall(); } // admitted from the lobby → replace the "waiting…" screen with the call
|
||||
@@ -5899,13 +5936,15 @@ async function onMeetMsg(e){
|
||||
meetWatchStream('__local', meetLocalStream); // active-speaker detection on my own mic
|
||||
// Existing peers OFFER to me (their offers carry their tracks incl. any active screen share);
|
||||
// I just set up the connections and wait. Avoids the "newcomer can't receive screen" bug.
|
||||
for(const p of (m.peers||[])){ meetNames.set(p.peerId,p.name); if(p.avatar) meetAvatars.set(p.peerId,p.avatar); if(p.uid){ meetPeerUids.set(p.peerId,p.uid); meetInviteJoined(p.uid); dropDupPeers(p.uid, p.peerId); } meetMakePeer(p.peerId,p.name); }
|
||||
for(const p of (m.peers||[])){ meetNames.set(p.peerId,p.name); if(p.avatar) meetAvatars.set(p.peerId,p.avatar); if(p.clientId) meetPeerClients.set(p.peerId,p.clientId); if(p.uid){ meetPeerUids.set(p.peerId,p.uid); meetInviteJoined(p.uid); } dropDupPeers(p.clientId, p.peerId); meetMakePeer(p.peerId,p.name); }
|
||||
if(_dmCallWaiting && !(m.peers&&m.peers.length)) addWaitingTile(_dmCallWaiting.name, _dmCallWaiting.avatar); // #7: show who we're calling while it rings
|
||||
// SFU: connect to LiveKit for media once (peer uid→peerId map is populated above). Mic/cam are
|
||||
// off at join, so nothing publishes yet — toggleMic/toggleCam publish on demand.
|
||||
if(SFU.on && !meetNative){ try{ await sfuConnect(); }catch(err){ if(ME&&ME.guest){ toast((err&&err.message)||'This meeting link has expired or isn’t active.'); leaveMeeting(true); return; } const e2=document.getElementById('meetErr'); if(e2) e2.textContent='Could not connect meeting media'; } }
|
||||
// native: the plugin already holds the LiveKit media (one connection per identity) — we joined the mesh
|
||||
// for the UI only. Media is native; tell peers our mic is live.
|
||||
// for the UI only. Now that we have our peerId, reconnect the plugin's media to a peerId-identity token
|
||||
// (#12 multi-device) so this device is a unique LiveKit participant.
|
||||
if(meetNative){ try{ bzNativeReconnectMedia(); }catch(_){} }
|
||||
meetSend({type:'meeting-state', muted:!meetMic, camOff:!meetCam}); // tell existing peers my state
|
||||
if(meetIsHost) meetSend({type:'meeting-host', to:meetMyId}); // announce host so others know
|
||||
// #4a: now that we're actually in the call (post-admission, media connected), honor the mic/cam the
|
||||
@@ -5927,7 +5966,7 @@ async function onMeetMsg(e){
|
||||
if(m.type==='meeting-peer-joined'){
|
||||
stopRingback(); removeWaitingTile(); _dmCallWaiting=null; // #17/#7: they answered → stop ring + drop the ringing tile
|
||||
try{ playJoinChime(); toast((m.name||'Someone')+' joined the call'); }catch(_){} // #7: sound + a brief who-joined note
|
||||
meetNames.set(m.peerId,m.name); if(m.avatar) meetAvatars.set(m.peerId,m.avatar); if(m.uid){ meetPeerUids.set(m.peerId,m.uid); meetInviteJoined(m.uid); dropDupPeers(m.uid, m.peerId); }
|
||||
meetNames.set(m.peerId,m.name); if(m.avatar) meetAvatars.set(m.peerId,m.avatar); if(m.clientId) meetPeerClients.set(m.peerId,m.clientId); if(m.uid){ meetPeerUids.set(m.peerId,m.uid); meetInviteJoined(m.uid); } dropDupPeers(m.clientId, m.peerId);
|
||||
const pc=meetMakePeer(m.peerId,m.name); // I'm an existing peer → I OFFER to the newcomer (carries my screen)
|
||||
if(pc){ try{ const offer=await pc.createOffer(); await pc.setLocalDescription(offer); meetSend({type:'meeting-signal',to:m.peerId,data:{sdp:pc.localDescription}}); }catch(_){} } // (SFU: LiveKit handles media, no offer)
|
||||
meetSend({type:'meeting-state', muted:!meetMic, camOff:!meetCam});
|
||||
@@ -5942,7 +5981,7 @@ async function onMeetMsg(e){
|
||||
if(m.type==='meeting-peer-screen'){ if(m.on) meetSharers.add(m.from); else meetSharers.delete(m.from); setTileScreen(m.from, !!m.on); refreshMeetPanel(); return; }
|
||||
if(m.type==='meeting-sharemode'){ meetMultiShare=!!m.multi; refreshMeetPanel(); return; }
|
||||
if(m.type==='meeting-muteall'){ if(meetMic && meetLocalStream){ meetMic=false; meetLocalStream.getAudioTracks().forEach(t=>t.enabled=false); updateMicBtn(); setTileMute('__local', true); meetSend({type:'meeting-state', muted:true, camOff:!meetCam}); } toast('You were muted by the host'); return; }
|
||||
if(m.type==='meeting-peer-left'){ const p=meetPeers.get(m.peerId); if(p){ try{p.pc.close();}catch(_){} meetPeers.delete(m.peerId);} if(SFU.on) sfuDropPeer(m.peerId); meetSharers.delete(m.peerId); meetPeerUids.delete(m.peerId); meetNames.delete(m.peerId); meetAvatars.delete(m.peerId); removeTile(m.peerId); refreshMeetPanel(); return; } // #4b: drop uid/name too, else they stay in hereUids and never reappear under "Add people"
|
||||
if(m.type==='meeting-peer-left'){ const p=meetPeers.get(m.peerId); if(p){ try{p.pc.close();}catch(_){} meetPeers.delete(m.peerId);} if(SFU.on) sfuDropPeer(m.peerId); meetSharers.delete(m.peerId); meetPeerUids.delete(m.peerId); meetPeerClients.delete(m.peerId); meetNames.delete(m.peerId); meetAvatars.delete(m.peerId); removeTile(m.peerId); refreshMeetPanel(); return; } // #4b: drop uid/name too, else they stay in hereUids and never reappear under "Add people"
|
||||
if(m.type==='meeting-signal'){
|
||||
const from=m.from, d=m.data||{};
|
||||
if(d.sdp){
|
||||
@@ -6036,7 +6075,7 @@ function leaveMeeting(forced){
|
||||
meetSend({type:'meeting-leave'});
|
||||
if(SFU.on) sfuDisconnect(); // tear down the LiveKit room
|
||||
meetUnwatchAll(); meetSharers.clear();
|
||||
meetPeers.forEach(p=>{ try{p.pc.close();}catch(_){} }); meetPeers.clear(); meetNames.clear(); meetAvatars.clear(); meetPeerUids.clear(); meetCamOff.clear(); meetInvited.forEach(e=>{ if(e.timer) clearTimeout(e.timer); }); meetInvited.clear(); meetMuted.clear();
|
||||
meetPeers.forEach(p=>{ try{p.pc.close();}catch(_){} }); meetPeers.clear(); meetNames.clear(); meetAvatars.clear(); meetPeerUids.clear(); meetPeerClients.clear(); meetCamOff.clear(); meetInvited.forEach(e=>{ if(e.timer) clearTimeout(e.timer); }); meetInvited.clear(); meetMuted.clear();
|
||||
if(meetLocalStream){ try{ meetLocalStream.getTracks().forEach(t=>t.stop()); }catch(_){} meetLocalStream=null; }
|
||||
if(meetWs){ try{ meetWs.close(); }catch(_){} meetWs=null; }
|
||||
meetRoom=null; meetMyId=null; meetState='idle'; meetIsHost=false; meetHostId=null; meetRailLive(false); resetMeetChat(); _inLobby=false;
|
||||
|
||||
+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.
|
||||
|
||||
+4
-3
@@ -58,9 +58,9 @@ function finishMeetingJoin(ws, room, peers) {
|
||||
const hostUserId = roomHost.get(room);
|
||||
const avatar = ws._meetingAvatar || null;
|
||||
const isHost = !!(ws._meetingUserId && hostUserId && ws._meetingUserId === hostUserId);
|
||||
ws.send(JSON.stringify({ type: 'meeting-joined', room, peerId, isHost, peers: [...peers.entries()].map(([id, p]) => ({ peerId: id, name: p.name, avatar: p.avatar || null, uid: p.uid || null })) }));
|
||||
for (const [, p] of peers) { if (p.ws.readyState === 1) p.ws.send(JSON.stringify({ type: 'meeting-peer-joined', peerId, name, avatar, uid: ws._meetingUserId || null })); }
|
||||
peers.set(peerId, { ws, name, avatar, uid: ws._meetingUserId || null });
|
||||
ws.send(JSON.stringify({ type: 'meeting-joined', room, peerId, isHost, peers: [...peers.entries()].map(([id, p]) => ({ peerId: id, name: p.name, avatar: p.avatar || null, uid: p.uid || null, clientId: p.clientId || null })) }));
|
||||
for (const [, p] of peers) { if (p.ws.readyState === 1) p.ws.send(JSON.stringify({ type: 'meeting-peer-joined', peerId, name, avatar, uid: ws._meetingUserId || null, clientId: ws._clientId || null })); }
|
||||
peers.set(peerId, { ws, name, avatar, uid: ws._meetingUserId || null, clientId: ws._clientId || null });
|
||||
noteRoomStat(room, ws, peers.size); // #7: track the high-water participant count for the call log
|
||||
if (ws._meetingUserId) { try { require('./calls').markDmAnswered(room, ws._meetingUserId); } catch (_) {} CHAT.broadcastPresence(ws._meetingUserId); }
|
||||
const tsubs = transcriptSubs.get(room); if (tsubs && tsubs.size > 0) ws.send(JSON.stringify({ type: 'meeting-transcribe-state', active: true }));
|
||||
@@ -142,6 +142,7 @@ async function handle(ws, m, req) {
|
||||
const peerId = A.token(6);
|
||||
const name = String(m.name || 'Guest').slice(0, 60);
|
||||
ws.kind = 'meeting'; ws._meetingRoom = room; ws._peerId = peerId; ws._peerName = name;
|
||||
ws._clientId = (typeof m.clientId === 'string' && m.clientId) ? m.clientId.slice(0, 64) : null; // #12: stable per-device id → dedup a reconnecting device without collapsing a real 2nd device
|
||||
// Host = the meeting's creator. roomHost is set on call/meeting creation; scheduled meetings fall back to created_by.
|
||||
let hostUserId = roomHost.get(room);
|
||||
if (hostUserId === undefined) { try { const s = await R.scheduledMeetings.byCode(room); if (s) { hostUserId = s.created_by; roomHost.set(room, hostUserId); } } catch (_) {} }
|
||||
|
||||
Reference in New Issue
Block a user