From bd637d01416ff56f4149a3e12b79bb07ba32b642 Mon Sep 17 00:00:00 2001 From: timmy Date: Sun, 16 Aug 2026 03:24:10 +0000 Subject: [PATCH] fix: enable same-origin voice capture (Closes #933) --- frontend/voice-issue-capture.js | 10 ++++++++- src/main.py | 2 +- tests/test_security_headers.py | 5 ++++- tests/test_voice_issue_capture.py | 37 +++++++++++++++++++++++++++++++ 4 files changed, 51 insertions(+), 3 deletions(-) diff --git a/frontend/voice-issue-capture.js b/frontend/voice-issue-capture.js index 9265ec7..b58d67a 100644 --- a/frontend/voice-issue-capture.js +++ b/frontend/voice-issue-capture.js @@ -62,7 +62,15 @@ function createVoiceIssueCapture({ elements.start.hidden = false; elements.stop.hidden = true; }; - recognition.start(); + try { + recognition.start(); + } catch (_) { + recognition = null; + elements.start.hidden = false; + elements.stop.hidden = true; + elements.status.textContent = 'Voice capture could not start. Your draft and transcript are unchanged.'; + return; + } elements.start.hidden = true; elements.stop.hidden = false; elements.status.textContent = 'Listening… Tap stop when you are finished.'; diff --git a/src/main.py b/src/main.py index dc7c2a0..11d11ac 100644 --- a/src/main.py +++ b/src/main.py @@ -1364,7 +1364,7 @@ async def enforce_browser_security_boundary(request: Request, call_next): response.headers["X-Content-Type-Options"] = "nosniff" response.headers["Referrer-Policy"] = "no-referrer" response.headers["Permissions-Policy"] = ( - "camera=(), microphone=(), geolocation=(), payment=(), usb=()" + "camera=(), microphone=(self), geolocation=(), payment=(), usb=()" ) response.headers["X-Frame-Options"] = "DENY" if dashboard_auth.mode() == dashboard_auth.OPERATOR_MODE: diff --git a/tests/test_security_headers.py b/tests/test_security_headers.py index c51de7b..ce94ece 100644 --- a/tests/test_security_headers.py +++ b/tests/test_security_headers.py @@ -14,7 +14,10 @@ REQUIRED_HEADERS = { def assert_browser_security_boundary(response: httpx.Response) -> None: for name, value in REQUIRED_HEADERS.items(): assert response.headers[name] == value - assert "camera=()" in response.headers["permissions-policy"] + permissions_policy = response.headers["permissions-policy"] + assert "camera=()" in permissions_policy + assert "microphone=(self)" in permissions_policy + assert "microphone=()" not in permissions_policy policy = response.headers["content-security-policy"] assert "default-src 'self'" in policy assert "script-src 'self'" in policy diff --git a/tests/test_voice_issue_capture.py b/tests/test_voice_issue_capture.py index f02f4e2..568d8f4 100644 --- a/tests/test_voice_issue_capture.py +++ b/tests/test_voice_issue_capture.py @@ -296,6 +296,43 @@ process.stdout.write(JSON.stringify({{ } +def test_recognition_start_rejection_restores_controls_and_preserves_draft(): + script = f""" +const {{createVoiceIssueCapture}} = require({json.dumps(str(VOICE_CAPTURE))}); +class Element {{ + constructor() {{ this.hidden=false; this.value=''; this.textContent=''; this.listeners={{}}; }} + addEventListener(name, callback) {{ this.listeners[name]=callback; }} + click() {{ this.listeners.click?.({{currentTarget:this}}); }} dispatchEvent() {{}} +}} +class Recognition {{ + start() {{ throw new Error('microphone blocked by browser policy'); }} + stop() {{}} abort() {{}} +}} +const elements=Object.fromEntries( + ['root','start','stop','review','transcript','append','replace','status','title','body'].map(key=>[key,new Element()]) +); +elements.title.value='Existing title'; +elements.body.value='Existing note'; +elements.transcript.value='Recovered transcript.'; +elements.start.hidden=false; elements.stop.hidden=true; +createVoiceIssueCapture({{Recognition,elements}}); +elements.start.click(); +process.stdout.write(JSON.stringify({{ + startHidden:elements.start.hidden, stopHidden:elements.stop.hidden, + status:elements.status.textContent, title:elements.title.value, + body:elements.body.value, transcript:elements.transcript.value +}})); +""" + assert run_node(script) == { + "startHidden": False, + "stopHidden": True, + "status": "Voice capture could not start. Your draft and transcript are unchanged.", + "title": "Existing title", + "body": "Existing note", + "transcript": "Recovered transcript.", + } + + def test_stop_error_and_sheet_close_release_recognition_without_losing_transcript(): script = f""" const {{createVoiceIssueCapture}} = require({json.dumps(str(VOICE_CAPTURE))}); -- 2.43.0