From 1ed5263fbc1a606dacc442dc30a728b055afb980 Mon Sep 17 00:00:00 2001 From: Russell Ballestrini Date: Sun, 7 Jun 2026 11:27:24 -0400 Subject: [PATCH] zebra-spaces: session fingerprint + sharper camera-error breadcrumbs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Telemetry for tickets 0001 (fedora chrome silence) and 0002 (fedora firefox cannot share camera). Every logLine call already ships to zebra-spaces-signal via client-log; these two additions thicken the diagnostic payload at the points each ticket needs. 1. ONE-SHOT SESSION FINGERPRINT on entry. After the 'joined as ...' line we now emit: session: ua=chrome/desktop ctx={sr=48000 baseLat=0.0107 sinkSupp=1 sink=default} devs={out=4 in=3 cam=2} picked={mic=abc12345 cam=def67890 spk=} - ua family + mobile/desktop bucket (the bug shape often falls cleanly along browser-family lines: fxhp-phone-firefox vs fedora-chrome are different problem classes) - audioCtx sampleRate, baseLatency, setSinkId support, current sinkId — directly answers the 0001 hypothesis: if sink ≠ the picked spk= shortprefix, we know the audioCtx isn't routed to the user's chosen device even though the dropdown says so. - device counts (audiooutput / audioinput / videoinput) — tells us if a saved-deviceId is pointed at a device that's gone (0002). - 8-char prefix of the saved mic/cam/spk deviceIds — enough to correlate with the picker selection without leaking the full id. Wrapped + caught so a hostile UA can't break entry. Empty fields shown as 'n/a' / ''. 2. CAMERA-OPEN ERROR now logs e.name + constraints summary instead of bare e.message. Firefox returns NotFoundError vs OverconstrainedError vs NotAllowedError vs NotReadableError with nearly identical .message strings — name disambiguates the actual failure mode (device gone vs constraint mismatch vs permission denied vs camera held by another app). Constraints summary includes the resolution we asked for and the first 8 chars of the saved deviceId so a saved-id mismatch is obvious on inspection. AUTO-RECOVERY for OverconstrainedError+saved-deviceId: retry once without the deviceId constraint so the user gets default camera instead of a permanent fail. The most likely 0002 cause is a saved cameraDeviceId pointing at a removed/renamed device, and making the user re-pick from the dropdown after every system reboot would be hostile. --- web/zebra-spaces.html | 83 ++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 79 insertions(+), 4 deletions(-) diff --git a/web/zebra-spaces.html b/web/zebra-spaces.html index 2b158d5..01a726b 100644 --- a/web/zebra-spaces.html +++ b/web/zebra-spaces.html @@ -4560,7 +4560,36 @@ async function sfuPublishCamera(){ * speaker can choose camera-on while still using a different audio * input (monitor source, music mode, etc) */ stream = await navigator.mediaDevices.getUserMedia({ video: videoConstraints, audio: false }); - } catch(e){ logLine('err','camera open cancelled: '+e.message); fsm.send('FAILED', { error: e.message }); fsm.send('DONE'); return; } + } catch(e){ + /* surface e.name + the constraints we tried so signal-server telemetry + * can disambiguate NotFoundError (device gone) vs OverconstrainedError + * (saved deviceId or 720p/16:9 not supported) vs NotAllowedError (user + * declined permission) vs NotReadableError (camera held by another app). + * Defect #0002 fedora firefox: e.message alone wasn't enough to pick + * the right branch. */ + const exact = videoConstraints.deviceId && videoConstraints.deviceId.exact; + logLine('err', + 'camera open cancelled: '+(e.name||'Error')+' — '+e.message+ + ' (constraints: 1280x720@30 16:9'+(exact?' deviceId='+String(exact).slice(0,8):'')+')' + ); + /* OverconstrainedError + a saved deviceId is the canonical "saved + * device gone or renamed" case — retry once without deviceId so the + * user gets default camera rather than a permanent fail. */ + if (e.name === 'OverconstrainedError' && exact){ + logLine('', 'camera open retry without saved deviceId'); + try { + const fallbackConstraints = Object.assign({}, videoConstraints); + delete fallbackConstraints.deviceId; + stream = await navigator.mediaDevices.getUserMedia({ video: fallbackConstraints, audio: false }); + logLine('', 'camera open recovered with default device'); + } catch(e2){ + logLine('err','camera open retry failed: '+(e2.name||'Error')+' — '+e2.message); + fsm.send('FAILED', { error: e2.message }); fsm.send('DONE'); return; + } + } else { + fsm.send('FAILED', { error: e.message }); fsm.send('DONE'); return; + } + } fsm.send('ACQUIRED', { stream }); sfuCameraStream = stream; const pc = new RTCPeerConnection(rtcConfig); @@ -6029,6 +6058,52 @@ async function handleSignal(raw){ broadcastSpotlight(); reorderTiles(); logLine('', 'joined as '+myRole+' — uuid '+myUUID); + /* one-shot session fingerprint for telemetry (see docs/tickets/ + * 0001 + 0002): captures the browser + audio/video device state + * at entry so a reporter's signal-server log carries enough + * context to disambiguate browser-specific defects. Mobile + UA + * fields land first because the bug shape often correlates with + * a single UA family (fxhp-phone-firefox, fedora-chrome, etc). + * Best-effort — wrapped + caught so a hostile UA doesn't break + * entry. */ + (async () => { + try { + const ua = navigator.userAgent || ''; + const isMobile = /Mobi|Android|iPhone|iPad|iPod/.test(ua); + const browser = + /Firefox\//.test(ua) ? 'firefox' : + /Edg\//.test(ua) ? 'edge' : + /Chrome\//.test(ua) ? 'chrome' : + /Safari\//.test(ua) ? 'safari' : 'other'; + let sr = 'n/a', bl = 'n/a', sinkSupp = false, ctxSink = 'n/a'; + try { + if (audioCtx){ + sr = audioCtx.sampleRate; + bl = (audioCtx.baseLatency != null) ? audioCtx.baseLatency.toFixed(4) : 'n/a'; + sinkSupp = typeof audioCtx.setSinkId === 'function'; + ctxSink = (audioCtx.sinkId === undefined) ? 'n/a' : (audioCtx.sinkId || 'default'); + } + } catch(_){} + let outCount = 0, inCount = 0, camCount = 0; + try { + const devs = await navigator.mediaDevices.enumerateDevices(); + for (const d of devs){ + if (d.kind === 'audiooutput') outCount++; + else if (d.kind === 'audioinput') inCount++; + else if (d.kind === 'videoinput') camCount++; + } + } catch(_){} + const pickShort = id => id ? String(id).slice(0,8) : ''; + logLine('', + 'session: ua='+browser+(isMobile?'/mobile':'/desktop')+ + ' ctx={sr='+sr+' baseLat='+bl+' sinkSupp='+(sinkSupp?1:0)+' sink='+ctxSink+'}'+ + ' devs={out='+outCount+' in='+inCount+' cam='+camCount+'}'+ + ' picked={mic='+pickShort(micDeviceId)+ + ' cam='+pickShort(cameraDeviceId)+ + ' spk='+pickShort(speakerDeviceId)+'}' + ); + } catch(_){} + })(); /* dot, leave/mute button visibility, sec-room reveal, entry-row * hide and status text are all driven by applyCallStateUI() * from the 'joined' state. Welcome already dispatched WELCOME @@ -8008,9 +8083,9 @@ logLine('', 'ready — pick a handle, type a rendezvous code, enter the space');