zebra-report: deploy silent screen-share recovery

This commit is contained in:
russell@unturf.com 2026-06-04 13:44:32 -04:00
parent be57d7769d
commit 385ccf5723
No known key found for this signature in database

View file

@ -2495,14 +2495,27 @@ async function sfuPublishScreen(){
if (s.track.kind === 'video') setSenderMaxBitrate(s, 6000000);
if (s.track.kind === 'audio') setSenderMaxBitrate(s, 256000);
}
/* Screen share CANNOT auto-rebuild like mic + camera — getDisplayMedia
* requires a fresh user gesture (transient activation). Auto-calling
* sfuPublishScreen() after a 'failed' state just fires the picker
* with no gesture and gets rejected. Instead: tear down on failed
* + surface the share button so the user can re-trigger themselves. */
/* Screen share recovery is asymmetric to mic/camera. getDisplayMedia
* needs a fresh user gesture, so we CAN'T auto-fire the picker after
* a 'failed'. BUT: the existing MediaStream usually survives a PC
* failure — the browser's "you are sharing" indicator stays on, the
* tracks remain in readyState=live. In that case we can rebuild the
* RTCPeerConnection while reusing sfuScreenStream, no picker needed,
* no gesture needed. Only fall back to manual re-share if the
* underlying tracks have actually ended (user clicked browser-native
* "stop sharing", source window closed, etc.) — fox 2026-06-04. */
pc.onconnectionstatechange = () => {
if (pc.connectionState !== 'failed' || sfuScreenPC !== pc) return;
logLine('err', 'sfu screen publish PC failed — tap "share screen" to re-share (browsers need a fresh click for screen capture)');
const tracksLive = sfuScreenStream && sfuScreenStream.getTracks().every(t => t.readyState === 'live');
if (tracksLive){
logLine('err', 'sfu screen publish PC failed — tracks still live, rebuilding silently');
sfuRebuildScreenPC().catch(e => {
logLine('err','silent screen rebuild failed: '+e.message+' — tap "share screen" to re-share');
sfuUnpublishScreen().catch(()=>{});
});
return;
}
logLine('err', 'sfu screen publish PC failed — tracks ended; tap "share screen" to re-share (browsers need a fresh click for screen capture)');
sfuUnpublishScreen().catch(()=>{});
};
logLine('', 'sfu: sharing screen as '+sfuScreenPeerID);
@ -2512,6 +2525,70 @@ async function sfuPublishScreen(){
$('btn-screen-share').classList.add('hidden');
$('btn-screen-stop').classList.remove('hidden');
}
/* sfuRebuildScreenPC — silent recovery path used by the screen
* publisher's onconnectionstatechange when the PC fails BUT the
* underlying MediaStream is still alive. Reuses sfuScreenStream
* verbatim, builds a fresh PC, re-publishes to the SFU. No
* getDisplayMedia call → no user gesture required → no picker
* popping up mid-share. Caller decides what to do on failure (the
* onconnectionstatechange handler falls back to sfuUnpublishScreen
* which surfaces the manual "share screen" button). */
async function sfuRebuildScreenPC(){
if (!sfuScreenStream || !myKeys || !roomID) throw new Error('no live stream to rebuild against');
/* tear down the dead PC first; sfuScreenPC must be null'd BEFORE
* pc.close() so the dead PC's stale onconnectionstatechange (which
* may fire one more time during teardown) sees sfuScreenPC===null
* and bails out instead of recursing into another rebuild. */
const oldPC = sfuScreenPC;
const oldPid = sfuScreenPeerID;
sfuScreenPC = null;
sfuScreenPeerID = null;
if (oldPC){ try { oldPC.close(); } catch(_){} }
/* unpublish the old peer_id at the SFU so it doesn't keep a stale
* publisher entry around that fights the new one for the same
* (pubkey, kind=screen) slot. */
if (oldPid){
try { await fetch(SFU_BASE + '/unpublish?room=' + encodeURIComponent(roomID) + '&peer=' + oldPid, { method:'POST' }); } catch(_){}
}
const pc = new RTCPeerConnection(rtcConfig);
for (const tr of sfuScreenStream.getTracks()){
pc.addTrack(tr, sfuScreenStream);
}
const offer = await pc.createOffer();
offer.sdp = preferStereoOpus(offer.sdp, 256000, { music: true });
await pc.setLocalDescription(offer);
await waitForIceGathering(pc);
const url = SFU_BASE + '/publish?room=' + encodeURIComponent(roomID)
+ '&pub=' + myKeys.pubHex + '&kind=screen';
const res = await fetch(url, { method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({ sdp: pc.localDescription.sdp }) });
if (res.status === 403){ pc.close(); handleBlocked('publish-screen'); throw new Error('blocked'); }
if (!res.ok){ pc.close(); throw new Error('sfu publish-screen http '+res.status); }
const ans = await res.json();
await pc.setRemoteDescription({ type:'answer', sdp: ans.sdp });
sfuScreenPC = pc; sfuScreenPeerID = ans.peer_id;
for (const s of pc.getSenders()){
if (!s.track) continue;
if (s.track.kind === 'video') setSenderMaxBitrate(s, 6000000);
if (s.track.kind === 'audio') setSenderMaxBitrate(s, 256000);
}
pc.onconnectionstatechange = () => {
if (pc.connectionState !== 'failed' || sfuScreenPC !== pc) return;
const tracksLive = sfuScreenStream && sfuScreenStream.getTracks().every(t => t.readyState === 'live');
if (tracksLive){
logLine('err', 'sfu screen publish PC failed — tracks still live, rebuilding silently');
sfuRebuildScreenPC().catch(e => {
logLine('err','silent screen rebuild failed: '+e.message+' — tap "share screen" to re-share');
sfuUnpublishScreen().catch(()=>{});
});
return;
}
logLine('err', 'sfu screen publish PC failed — tracks ended; tap "share screen" to re-share (browsers need a fresh click for screen capture)');
sfuUnpublishScreen().catch(()=>{});
};
logLine('', 'sfu: screen PC rebuilt silently — sharing as '+sfuScreenPeerID);
}
async function sfuUnpublishScreen(){
if (!sfuScreenPC && !sfuScreenStream) return;
const pid = sfuScreenPeerID;
@ -5576,8 +5653,8 @@ logLine('', 'ready — pick a handle, type a rendezvous code, enter the space');
<footer style="margin:2.2rem auto 0;font-size:0.65rem;color:#999;line-height:1.7;word-break:break-all;font-family:monospace">
<span id="pi-seal" style="color:#777;cursor:default;user-select:none" title="">page integrity</span> &nbsp;·&nbsp; built <span class="stamp-date">2026-06-04</span><br>
md5 <span class="stamp-md5">3063dce0ff1ca278da701ab8ab0d625c</span><br>
sha256 <span class="stamp-sha">f49ab5aca111dea8bd89e6d7a9627ea14fbb27215b867b4c6b1267b66b1d0975</span><br>
md5 <span class="stamp-md5">b8b69ddba03d2bdcee2e24c83a119b8c</span><br>
sha256 <span class="stamp-sha">e79516aedb1b8a5a087cce2a23135f1d72d83ce22ccf517b9905635af26b471a</span><br>
<span style="color:#bbb">hashes are of this page with these two fields zeroed — to verify, blank them and re-hash</span><br>
<span style="color:#bbb">one self-contained file — <strong>save a copy</strong> and verify against these hashes; point at your own servers with ?signal= and ?turncred=, or <a href="host-your-own.html" style="color:#999">host your own community</a></span>
</footer>