diff --git a/packages/desktop-electron/src/main/browser/controller.ts b/packages/desktop-electron/src/main/browser/controller.ts index 4c87be9f6..594fc2e1b 100644 --- a/packages/desktop-electron/src/main/browser/controller.ts +++ b/packages/desktop-electron/src/main/browser/controller.ts @@ -6,6 +6,7 @@ import { computeViewBounds, deriveBrowserState, displayDecision, + isDefaultGrantedPermission, parseNavigable, safeExternalUrl, } from "./logic" @@ -89,9 +90,13 @@ export class BrowserViewController { this.openExternal(url) }) - // Deny every permission request by default — a v1 content viewer should not - // silently grant camera/mic/geolocation/etc. - wc.session.setPermissionRequestHandler((_wc, _permission, callback) => callback(false)) + // Apply the shared permission policy (logic.ts) to BOTH actual requests and + // navigator.permissions.query checks, so the queried state and the request + // outcome always agree. + wc.session.setPermissionRequestHandler((_wc, permission, callback) => + callback(isDefaultGrantedPermission(permission)), + ) + wc.session.setPermissionCheckHandler((_wc, permission) => isDefaultGrantedPermission(permission)) } private openExternal(url: string) { diff --git a/packages/desktop-electron/src/main/browser/logic.test.ts b/packages/desktop-electron/src/main/browser/logic.test.ts index 8616ab255..07d563f96 100644 --- a/packages/desktop-electron/src/main/browser/logic.test.ts +++ b/packages/desktop-electron/src/main/browser/logic.test.ts @@ -4,6 +4,7 @@ import { computeViewBounds, deriveBrowserState, displayDecision, + isDefaultGrantedPermission, parseNavigable, safeExternalUrl, type BrowserStateSnapshot, @@ -136,3 +137,32 @@ describe("displayDecision", () => { expect(displayDecision({ isHost: false, hasLiveHost: true, claim: false })).toBe("drop") }) }) + +describe("isDefaultGrantedPermission", () => { + test("grants exactly what a fresh Chrome grants without prompting", () => { + // Verified against real Chrome: these four return "granted" by default. + for (const p of ["clipboard-sanitized-write", "background-sync", "sensors", "payment-handler"]) { + expect(isDefaultGrantedPermission(p)).toBe(true) + } + }) + + test("grants the user-gesture permissions Chrome allows without a prompt (fullscreen, pointer lock)", () => { + for (const p of ["fullscreen", "pointerLock"]) { + expect(isDefaultGrantedPermission(p)).toBe(true) + } + }) + + test("denies sensitive / prompt-type permissions (Electron would default them to granted)", () => { + // "media" is what camera AND microphone queries arrive as. "midi" belongs + // here: Chrome gates Web MIDI behind a prompt (Chrome 124+), so it must NOT + // be auto-granted. + for (const p of ["media", "geolocation", "notifications", "clipboard-read", "persistent-storage", "midi", "midiSysex"]) { + expect(isDefaultGrantedPermission(p)).toBe(false) + } + }) + + test("denies unknown permissions", () => { + expect(isDefaultGrantedPermission("unknown")).toBe(false) + expect(isDefaultGrantedPermission("")).toBe(false) + }) +}) diff --git a/packages/desktop-electron/src/main/browser/logic.ts b/packages/desktop-electron/src/main/browser/logic.ts index 1fc224c34..4e26d50ee 100644 --- a/packages/desktop-electron/src/main/browser/logic.ts +++ b/packages/desktop-electron/src/main/browser/logic.ts @@ -32,6 +32,35 @@ export function safeExternalUrl(url: string): string | null { return EXTERNAL_SCHEMES.has(scheme) ? url : null } +/** + * Permission policy for the embedded browser, shared by the request handler and + * the check handler (navigator.permissions.query) so the queried state and a + * request outcome always agree. Grant exactly what a fresh Chrome grants without + * a prompt; deny the rest — Electron's boolean handler can't express Chrome's + * "prompt", and "denied" is the faithful substitute for the impossible "granted" + * (Electron's default). Strings are Electron's permission names — camera and + * microphone both arrive as "media". The set is measured against a fresh real + * Chrome; the per-permission rationale (why midi is denied, payment-handler + * granted) is in the PR description. + */ +const DEFAULT_GRANTED_PERMISSIONS = new Set([ + "clipboard-sanitized-write", // navigator.clipboard.writeText: granted by default + "background-sync", // granted by default + "sensors", // accelerometer / gyroscope / magnetometer: granted by default + "payment-handler", // navigator.permissions.query("payment-handler"): granted by default in Chrome + // Request-only (not exposed via permissions.query): Chrome allows these on a + // user gesture WITHOUT a prompt, so denying them would break ordinary browsing + // (e.g. fullscreen video) and be a tell. "fullscreen" is measurement-confirmed + // to reach the request handler; "pointerLock" is Electron's documented request + // permission name for the same allow-on-gesture API. + "fullscreen", + "pointerLock", +]) + +export function isDefaultGrantedPermission(permission: string): boolean { + return DEFAULT_GRANTED_PERMISSIONS.has(permission) +} + /** * Convert a CSS-pixel viewport rect (reported by the renderer) into the * device-independent pixel bounds a WebContentsView expects. The renderer is