From 881a4b1e53fa2d79c7f2b4e83572f2691ccbd795 Mon Sep 17 00:00:00 2001 From: mingkun968 <314882579+mingkun968@users.noreply.github.com> Date: Thu, 1 Oct 2026 18:46:22 +0800 Subject: [PATCH] fix(live-voice): keep the microphone policy out of unrelated permissions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit installLiveMicrophonePermissionHandlers runs on session.defaultSession, which also carries the main renderer. Installing a permission handler replaces the platform default for every permission on that session, so limiting the policy to `media` was never what actually happened: everything else was denied too. clipboard-sanitized-write was denied with them, which made every navigator.clipboard.writeText in the UI reject with "Write permission denied" — the transcript copy button, the table copy button and the session id copy all failed silently, and the failure looked like a clipboard problem rather than a permission-scope one. Non-media permissions now keep the platform default; `media` stays gated to the trusted main frame with an active microphone lease. The Live Voice permission test asserts the clipboard permission so the shadowing cannot come back. --- .../main/live-voice/microphone-permissions.ts | 11 +++++++++++ apps/desktop/test/live-voice-permission.test.mjs | 7 ++++++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/apps/desktop/electron/main/live-voice/microphone-permissions.ts b/apps/desktop/electron/main/live-voice/microphone-permissions.ts index de55edc37..a3d298b6d 100644 --- a/apps/desktop/electron/main/live-voice/microphone-permissions.ts +++ b/apps/desktop/electron/main/live-voice/microphone-permissions.ts @@ -25,6 +25,15 @@ export function installLiveMicrophonePermissionHandlers(input: { hasReservation: (owner: LiveOwner) => boolean; }): void { input.targetSession.setPermissionRequestHandler((contents, permission, callback, details) => { + // Installing a handler replaces the platform default for every permission on + // the session, and this session also carries the main renderer. Keep the + // microphone policy to `media` so unrelated web APIs keep working: denying + // the rest silently broke the app's own copy buttons, which need + // `clipboard-sanitized-write`. + if (permission !== "media") { + callback(true); + return; + } const owner = requestOwner(input.getMainWindow(), contents, details.requestingUrl, details.isMainFrame); const allowed = allowsLiveMicrophonePermission({ permission, @@ -37,6 +46,8 @@ export function installLiveMicrophonePermissionHandlers(input: { }); input.targetSession.setPermissionCheckHandler((contents, permission, requestingOrigin, details) => { + // Same scope rule as the request handler above. + if (permission !== "media") return true; const owner = contents ? checkOwner(input.getMainWindow(), contents, requestingOrigin) : null; return allowsLiveMicrophonePermission({ permission, diff --git a/apps/desktop/test/live-voice-permission.test.mjs b/apps/desktop/test/live-voice-permission.test.mjs index 62637ca01..1a34283f1 100644 --- a/apps/desktop/test/live-voice-permission.test.mjs +++ b/apps/desktop/test/live-voice-permission.test.mjs @@ -54,7 +54,12 @@ test("Live Voice media permission requires the trusted main frame and an active assert.equal(requestDecision({ requestingUrl: trustedUrl, isMainFrame: false, mediaTypes: ["audio"] }), false); assert.equal(requestDecision({ requestingUrl: "https://outside.example", isMainFrame: true, mediaTypes: ["audio"] }), false); assert.equal(checkDecision({ mediaType: "audio" }, "https://outside.example"), false); - assert.equal(requestDecision({ requestingUrl: trustedUrl, isMainFrame: true, mediaTypes: ["audio"] }, "notifications"), false); + // Regression: the microphone policy must not shadow permissions it does not own. + // The default session carries the main renderer, where the transcript and table + // copy buttons rely on `clipboard-sanitized-write`; denying it broke them. + assert.equal(requestDecision({ requestingUrl: trustedUrl, isMainFrame: true, mediaTypes: ["audio"] }, "notifications"), true); + assert.equal(requestDecision({ requestingUrl: trustedUrl, isMainFrame: true, mediaTypes: ["audio"] }, "clipboard-sanitized-write"), true); + assert.equal(checkDecision({ mediaType: "unknown" }, "http://localhost:5173", "clipboard-sanitized-write"), true); assert.equal(requestDecision({ requestingUrl: trustedUrl, isMainFrame: true, mediaTypes: ["audio"] }, "media", {}), false); leaseActive = false; assert.equal(requestDecision({ requestingUrl: trustedUrl, isMainFrame: true, mediaTypes: ["audio"] }), false);