fix(desktop): allow renderer camera capture (#73558)

* fix(desktop): allow camera capture through the permission handlers

The session permission hooks were written for the voice composer and denied
video outright, so any renderer getUserMedia({video}) failed with
NotAllowedError before the OS was ever consulted.

Rename isAudioCapturePermission to isMediaCapturePermission and accept video
alongside audio in both the request and check handlers. The OS capture
permission still applies, so the user keeps a real allow/deny.

* build(desktop): declare camera usage for signed macOS builds

A hardened-runtime build needs the camera entitlement and an
NSCameraUsageDescription string, or the packaged app is killed on first
camera access instead of prompting.
This commit is contained in:
brooklyn! 2026-07-28 14:50:52 -05:00 committed by GitHub
commit c8cdeb435f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 29 additions and 27 deletions

View file

@ -10,5 +10,7 @@
<true/>
<key>com.apple.security.device.audio-input</key>
<true/>
<key>com.apple.security.device.camera</key>
<true/>
</dict>
</plist>

View file

@ -10,5 +10,7 @@
<true/>
<key>com.apple.security.device.audio-input</key>
<true/>
<key>com.apple.security.device.camera</key>
<true/>
</dict>
</plist>

View file

@ -5510,18 +5510,22 @@ function installContextMenu(window) {
})
}
// Microphone capture for the voice composer. The renderer drives mic access
// Microphone and camera capture. The voice composer drives mic access and
// renderer features (e.g. desktop plugins) can drive camera access, both
// through getUserMedia, which Chromium gates behind these two session hooks.
//
// The naive `details.mediaTypes.includes('audio')` check works on macOS but
// breaks on Windows: Chromium frequently fires the mic permission request with
// an empty/undefined `mediaTypes`, so the strict check denies it and
// getUserMedia throws NotAllowedError ("Microphone permission was denied").
// We therefore treat an audio-capture request as allowed whenever it's the
// 'media'/'audioCapture' permission AND mediaTypes either includes 'audio' OR
// is empty/absent (the Windows case). Video is still denied.
function isAudioCapturePermission(permission, details) {
if (permission === 'audioCapture') {
// breaks on Windows: Chromium frequently fires the request with an empty or
// undefined `mediaTypes`, so a strict check denies it and getUserMedia throws
// NotAllowedError. We therefore allow the capture permissions and treat absent
// metadata as allowed.
//
// Granting here is not the last gate: the OS still applies its own capture
// permission (macOS TCC prompts on first use, per the NSMicrophone/NSCamera
// usage strings), so the user keeps a real allow/deny and can revoke it in
// System Settings afterwards.
function isMediaCapturePermission(permission, details) {
if (permission === 'audioCapture' || permission === 'videoCapture') {
return true
}
@ -5531,38 +5535,31 @@ function isAudioCapturePermission(permission, details) {
const mediaTypes = details?.mediaTypes
// Windows: mediaTypes is often empty for a capture request. Don't deny on
// missing metadata.
if (!Array.isArray(mediaTypes) || mediaTypes.length === 0) {
// Windows: mediaTypes is often empty for a mic request. Don't deny on
// missing metadata. (A video request would carry mediaTypes:['video'].)
return true
}
return mediaTypes.includes('audio') && !mediaTypes.includes('video')
return mediaTypes.includes('audio') || mediaTypes.includes('video')
}
function installMediaPermissions() {
// Async request handler: the prompt-style path (most platforms).
session.defaultSession.setPermissionRequestHandler((_webContents, permission, callback, details) => {
callback(isAudioCapturePermission(permission, details))
callback(isMediaCapturePermission(permission, details))
})
// Synchronous check handler: Chromium consults this for getUserMedia on
// Windows in addition to (or instead of) the request handler. Without it,
// the check defaults to false and the mic is denied before the request
// the check defaults to false and capture is denied before the request
// handler ever runs.
session.defaultSession.setPermissionCheckHandler((_webContents, permission, _origin, details) => {
if (permission === 'media' || permission === ('audioCapture' as any) /* todo: is this needed? */) {
// details.mediaType is a single string here (not the mediaTypes array).
const mediaType = details?.mediaType
if (mediaType === 'video') {
return false
}
return true
}
return false
session.defaultSession.setPermissionCheckHandler((_webContents, permission) => {
return (
permission === 'media' ||
permission === ('audioCapture' as any) /* todo: is this needed? */ ||
permission === ('videoCapture' as any)
)
})
}

View file

@ -220,6 +220,7 @@
"CFBundleExecutable": "Hermes",
"CFBundleName": "Hermes",
"NSAudioCaptureUsageDescription": "Hermes uses audio capture for voice conversations.",
"NSCameraUsageDescription": "Hermes uses the camera when a plugin or feature you enable requests it.",
"NSMicrophoneUsageDescription": "Hermes uses the microphone for voice input and voice conversations."
},
"gatekeeperAssess": false,