diff --git a/src/apps/epic/static/apps/epic/burger-btn.js b/src/apps/epic/static/apps/epic/burger-btn.js index b0aaab7..c76943e 100644 --- a/src/apps/epic/static/apps/epic/burger-btn.js +++ b/src/apps/epic/static/apps/epic/burger-btn.js @@ -120,6 +120,26 @@ // INACTIVE click is left to the delegated fan handler's 2-pulse flash. // No stopPropagation on active — the delegated handler then closes the // fan (its existing .active behaviour). + // Surface a join failure to the user instead of failing silently — most + // often the secure-context block (INSECURE_CONTEXT) when the dev server is + // reached over plain HTTP from a phone. Prefers the Brief banner; falls + // back to console. + function _voiceJoinFailed(vbtn, e) { + vbtn.classList.remove('in-call'); + delete vbtn.dataset.inCall; // let the next click retry the join + var msg = (e && e.code === 'INSECURE_CONTEXT') + ? 'Voice needs HTTPS (or localhost) — your browser blocked the mic here.' + : 'Couldn’t start voice — mic unavailable or permission denied.'; + if (window.Brief && typeof window.Brief.showBanner === 'function') { + window.Brief.showBanner({ + title: 'Voice', line_text: msg, kind: 'NUDGE', + post_url: '', created_at: '', + }); + } else if (window.console && console.warn) { + console.warn('[voice] ' + msg, e || ''); + } + } + function bindVoiceBtn() { var vbtn = document.getElementById('id_voice_btn'); if (!vbtn) return; @@ -132,7 +152,10 @@ if (!vbtn.dataset.inCall) { vbtn.dataset.inCall = '1'; vbtn.classList.add('in-call'); - window.VoiceRoom.join(roomId); + var p = window.VoiceRoom.join(roomId); + if (p && typeof p.catch === 'function') { + p.catch(function (e) { _voiceJoinFailed(vbtn, e); }); + } } else { var muted = window.VoiceRoom.toggleMute(); vbtn.classList.toggle('muted', muted); diff --git a/src/apps/voice/static/apps/voice/voice-mesh.js b/src/apps/voice/static/apps/voice/voice-mesh.js index 3c0898d..5a00f04 100644 --- a/src/apps/voice/static/apps/voice/voice-mesh.js +++ b/src/apps/voice/static/apps/voice/voice-mesh.js @@ -170,8 +170,27 @@ } }; + // getUserMedia is only exposed in a secure context — HTTPS, or the + // localhost/127.0.0.1 exemption. Over plain HTTP on a LAN IP (e.g. the dev + // server reached from a phone at http://192.168.x.x:8000) iOS/Android leave + // `navigator.mediaDevices` undefined, so the mic call would throw with no + // permission prompt. Pulled into a method so it's a clean test seam. + VoiceRoom.prototype._micSupported = function () { + return !!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia); + }; + VoiceRoom.prototype.join = function (roomId) { var self = this; + // Fail fast + LOUD when the mic can't be reached — otherwise join would + // fetch TURN creds (a confusing 200) and then silently reject deep in a + // .then() with no prompt. Callers surface `err.code` to the user. + if (!this._micSupported()) { + var err = new Error( + 'Voice needs a secure context (HTTPS, or localhost) — your ' + + 'browser blocked the mic here. Open the HTTPS site to talk.'); + err.code = 'INSECURE_CONTEXT'; + return Promise.reject(err); + } return this._fetchTurn().then(function (creds) { if (creds && creds.iceServers) self.iceServers = creds.iceServers; return navigator.mediaDevices.getUserMedia({ diff --git a/src/static/tests/VoiceMeshSpec.js b/src/static/tests/VoiceMeshSpec.js index c69b111..b8e5c09 100644 --- a/src/static/tests/VoiceMeshSpec.js +++ b/src/static/tests/VoiceMeshSpec.js @@ -93,3 +93,66 @@ describe('voice-mesh mute', function () { expect(seen.muted).toBe(true); }); }); + +// Secure-context join guard — getUserMedia is undefined over plain HTTP on a +// LAN IP (dev server reached from a phone), so join must fail fast + loud +// rather than fetch TURN creds and silently reject with no mic prompt +// (diagnosed 2026-05-29). +describe('voice-mesh join guard', function () { + var vr; + beforeEach(function () { vr = window.VoiceRoom; }); + afterEach(function () { + vr.localStream = null; vr.muted = false; vr._onState = null; + }); + + it('rejects with INSECURE_CONTEXT when the mic is unsupported', function (done) { + spyOn(vr, '_micSupported').and.returnValue(false); + vr.join('mysea-test').then(function () { + done.fail('join should reject without a secure-context mic'); + }, function (e) { + expect(e.code).toBe('INSECURE_CONTEXT'); + done(); + }); + }); +}); + +// bindVoiceBtn (burger-btn.js) must surface a failed join + roll back the +// optimistic in-call state so the next click can retry. +describe('voice btn join failure surfacing', function () { + var vbtn, origVR, origBrief, banner; + + beforeEach(function () { + vbtn = document.createElement('button'); + vbtn.id = 'id_voice_btn'; + vbtn.classList.add('active'); + vbtn.setAttribute('data-room-id', 'mysea-x'); + document.body.appendChild(vbtn); + origVR = window.VoiceRoom; + origBrief = window.Brief; + window.VoiceRoom = { + join: function () { return Promise.reject({ code: 'INSECURE_CONTEXT' }); }, + }; + banner = jasmine.createSpy('showBanner'); + window.Brief = { showBanner: banner }; + bindVoiceBtn(); + }); + + afterEach(function () { + window.VoiceRoom = origVR; + window.Brief = origBrief; + vbtn.remove(); + }); + + it('rolls back .in-call + surfaces a Brief when join rejects', function (done) { + vbtn.click(); + expect(vbtn.classList.contains('in-call')).toBe(true); // optimistic + setTimeout(function () { + expect(vbtn.classList.contains('in-call')).toBe(false); + expect(vbtn.dataset.inCall).toBeUndefined(); + expect(banner).toHaveBeenCalled(); + expect(banner.calls.mostRecent().args[0].line_text) + .toContain('HTTPS'); + done(); + }, 0); + }); +}); diff --git a/src/static_src/tests/VoiceMeshSpec.js b/src/static_src/tests/VoiceMeshSpec.js index c69b111..b8e5c09 100644 --- a/src/static_src/tests/VoiceMeshSpec.js +++ b/src/static_src/tests/VoiceMeshSpec.js @@ -93,3 +93,66 @@ describe('voice-mesh mute', function () { expect(seen.muted).toBe(true); }); }); + +// Secure-context join guard — getUserMedia is undefined over plain HTTP on a +// LAN IP (dev server reached from a phone), so join must fail fast + loud +// rather than fetch TURN creds and silently reject with no mic prompt +// (diagnosed 2026-05-29). +describe('voice-mesh join guard', function () { + var vr; + beforeEach(function () { vr = window.VoiceRoom; }); + afterEach(function () { + vr.localStream = null; vr.muted = false; vr._onState = null; + }); + + it('rejects with INSECURE_CONTEXT when the mic is unsupported', function (done) { + spyOn(vr, '_micSupported').and.returnValue(false); + vr.join('mysea-test').then(function () { + done.fail('join should reject without a secure-context mic'); + }, function (e) { + expect(e.code).toBe('INSECURE_CONTEXT'); + done(); + }); + }); +}); + +// bindVoiceBtn (burger-btn.js) must surface a failed join + roll back the +// optimistic in-call state so the next click can retry. +describe('voice btn join failure surfacing', function () { + var vbtn, origVR, origBrief, banner; + + beforeEach(function () { + vbtn = document.createElement('button'); + vbtn.id = 'id_voice_btn'; + vbtn.classList.add('active'); + vbtn.setAttribute('data-room-id', 'mysea-x'); + document.body.appendChild(vbtn); + origVR = window.VoiceRoom; + origBrief = window.Brief; + window.VoiceRoom = { + join: function () { return Promise.reject({ code: 'INSECURE_CONTEXT' }); }, + }; + banner = jasmine.createSpy('showBanner'); + window.Brief = { showBanner: banner }; + bindVoiceBtn(); + }); + + afterEach(function () { + window.VoiceRoom = origVR; + window.Brief = origBrief; + vbtn.remove(); + }); + + it('rolls back .in-call + surfaces a Brief when join rejects', function (done) { + vbtn.click(); + expect(vbtn.classList.contains('in-call')).toBe(true); // optimistic + setTimeout(function () { + expect(vbtn.classList.contains('in-call')).toBe(false); + expect(vbtn.dataset.inCall).toBeUndefined(); + expect(banner).toHaveBeenCalled(); + expect(banner.calls.mostRecent().args[0].line_text) + .toContain('HTTPS'); + done(); + }, 0); + }); +});