voice: fail loud on insecure-context join (mic blocked over HTTP) instead of silent reject — TDD
getUserMedia is only exposed in a secure context (HTTPS, or the localhost exemption). Reached over plain HTTP on a LAN IP — the dev server from a phone at http://192.168.x.x:8000 — iOS/Android leave navigator.mediaDevices undefined, so join() fetched TURN creds (a confusing 200) then silently rejected deep in a .then() with no mic prompt. Desktop works because 127.0.0.1 IS a secure context. (Not a regression — voice never worked on the HTTP dev server from mobile.) - voice-mesh.js: _micSupported() seam + an early INSECURE_CONTEXT reject in join() before any network, so the failure is fast + diagnosable. - burger-btn.js: bindVoiceBtn catches the rejected join, rolls back the optimistic .in-call/dataset.inCall (so the next click retries), and surfaces a Brief — 'Voice needs HTTPS (or localhost) — your browser blocked the mic here.' — instead of failing invisibly. - VoiceMeshSpec: +2 specs (join rejects INSECURE_CONTEXT; btn rolls back + Briefs on reject). Jasmine green. Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -120,6 +120,26 @@
|
|||||||
// INACTIVE click is left to the delegated fan handler's 2-pulse flash.
|
// INACTIVE click is left to the delegated fan handler's 2-pulse flash.
|
||||||
// No stopPropagation on active — the delegated handler then closes the
|
// No stopPropagation on active — the delegated handler then closes the
|
||||||
// fan (its existing .active behaviour).
|
// 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() {
|
function bindVoiceBtn() {
|
||||||
var vbtn = document.getElementById('id_voice_btn');
|
var vbtn = document.getElementById('id_voice_btn');
|
||||||
if (!vbtn) return;
|
if (!vbtn) return;
|
||||||
@@ -132,7 +152,10 @@
|
|||||||
if (!vbtn.dataset.inCall) {
|
if (!vbtn.dataset.inCall) {
|
||||||
vbtn.dataset.inCall = '1';
|
vbtn.dataset.inCall = '1';
|
||||||
vbtn.classList.add('in-call');
|
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 {
|
} else {
|
||||||
var muted = window.VoiceRoom.toggleMute();
|
var muted = window.VoiceRoom.toggleMute();
|
||||||
vbtn.classList.toggle('muted', muted);
|
vbtn.classList.toggle('muted', muted);
|
||||||
|
|||||||
@@ -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) {
|
VoiceRoom.prototype.join = function (roomId) {
|
||||||
var self = this;
|
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) {
|
return this._fetchTurn().then(function (creds) {
|
||||||
if (creds && creds.iceServers) self.iceServers = creds.iceServers;
|
if (creds && creds.iceServers) self.iceServers = creds.iceServers;
|
||||||
return navigator.mediaDevices.getUserMedia({
|
return navigator.mediaDevices.getUserMedia({
|
||||||
|
|||||||
@@ -93,3 +93,66 @@ describe('voice-mesh mute', function () {
|
|||||||
expect(seen.muted).toBe(true);
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -93,3 +93,66 @@ describe('voice-mesh mute', function () {
|
|||||||
expect(seen.muted).toBe(true);
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user