diff --git a/apps/desktop/electron/entitlements.mac.inherit.plist b/apps/desktop/electron/entitlements.mac.inherit.plist
index 53fdf0fc4370..a3defc5f8cfa 100644
--- a/apps/desktop/electron/entitlements.mac.inherit.plist
+++ b/apps/desktop/electron/entitlements.mac.inherit.plist
@@ -10,5 +10,7 @@
com.apple.security.device.audio-input
+ com.apple.security.device.camera
+
diff --git a/apps/desktop/electron/entitlements.mac.plist b/apps/desktop/electron/entitlements.mac.plist
index 53fdf0fc4370..a3defc5f8cfa 100644
--- a/apps/desktop/electron/entitlements.mac.plist
+++ b/apps/desktop/electron/entitlements.mac.plist
@@ -10,5 +10,7 @@
com.apple.security.device.audio-input
+ com.apple.security.device.camera
+
diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts
index ce2d09c8839e..dc4efb801a5f 100644
--- a/apps/desktop/electron/main.ts
+++ b/apps/desktop/electron/main.ts
@@ -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)
+ )
})
}
diff --git a/apps/desktop/package.json b/apps/desktop/package.json
index 91e51e6fbf02..01f0e45eb7b0 100644
--- a/apps/desktop/package.json
+++ b/apps/desktop/package.json
@@ -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,