Repository: electron/electron
Pull request: fix: video and audio capture should be separate

#### Description of Change

Closes https://github.com/electron/electron/issues/42713.

Fixes an issue where calling `navigator.mediaDevices.enumerateDevices` hid all devices if audio permission was denied but video permission granted, and if audio permissions were granted but video denied there was no effect. This was happening because we were always passing `blink::PermissionType::AUDIO_CAPTURE` to `CheckPermission` because we believed it was processed similarly for both, but it does matter which one we pass because that logic falls down to here:

https://github.com/electron/electron/blob/c3b4cd987c618ee72d32f996a8f3ae9e3e80c2bf/shell/browser/electron_permission_manager.cc#L314-L322

and so the user's preference won't filter back up correctly via `session.setPermissionCheckHandler`. This fixes that issue.

#### Checklist
<!-- Remove items that do not apply. For completed items, change [ ] to [x]. -->

- [x] PR description included and stakeholders cc'd
- [x] `npm test` passes
- [x] tests are [changed or added](https://github.com/electron/electron/blob/main/docs/development/testing.md)
- [x] [PR release notes](https://github.com/electron/clerk/blob/main/README.md) describe the change in a way relevant to app developers, and are [capitalized, punctuated, and past tense](https://github.com/electron/clerk/blob/main/README.md#examples).

#### Release Notes

Notes: Fixes an issue where `navigator.mediaDevices.enumerateDevices`  could return broken results in some cases after calling `session.setPermissionCheckHandler`

Changed files:
filename: shell/browser/web_contents_permission_helper.cc
patch: @@ -282,10 +282,10 @@ bool WebContentsPermissionHelper::CheckMediaAccessPermission(
   base::Value::Dict details;
   details.Set("securityOrigin", security_origin.GetURL().spec());
   details.Set("mediaType", MediaStreamTypeToString(type));
-  // The permission type doesn't matter here, AUDIO_CAPTURE/VIDEO_CAPTURE
-  // are presented as same type in content_converter.h.
-  return CheckPermission(blink::PermissionType::AUDIO_CAPTURE,
-                         std::move(details));
+  auto blink_type = type == blink::mojom::MediaStreamType::DEVICE_AUDIO_CAPTURE
+                        ? blink::PermissionType::AUDIO_CAPTURE
+                        : blink::PermissionType::VIDEO_CAPTURE;
+  return CheckPermission(blink_type, std::move(details));
 }
 
 bool WebContentsPermissionHelper::CheckSerialAccessPermission(

filename: spec/chromium-spec.ts
patch: @@ -1770,14 +1770,48 @@ describe('chromium features', () => {
       expect(labels.some((l: any) => l)).to.be.true();
     });
 
-    it('does not return labels of enumerated devices when permission denied', async () => {
+    it('does not return labels of enumerated devices when all permission denied', async () => {
       session.defaultSession.setPermissionCheckHandler(() => false);
       const w = new BrowserWindow({ show: false });
       w.loadFile(path.join(fixturesPath, 'pages', 'blank.html'));
       const labels = await w.webContents.executeJavaScript('navigator.mediaDevices.enumerateDevices().then(ds => ds.map(d => d.label))');
       expect(labels.some((l: any) => l)).to.be.false();
     });
 
+    it('does not return labels of enumerated audio devices when permission denied', async () => {
+      session.defaultSession.setPermissionCheckHandler((wc, permission, origin, { mediaType }) => {
+        return permission !== 'media' || mediaType !== 'audio';
+      });
+      const w = new BrowserWindow({ show: false });
+      w.loadFile(path.join(fixturesPath, 'pages', 'blank.html'));
+      const devices = await w.webContents.executeJavaScript(
+        `navigator.mediaDevices.enumerateDevices().then(ds => ds.map(d => {
+          return ({ label: d.label, kind: d.kind })
+        }));
+      `);
+      const audioDevices = devices.filter((d: any) => d.kind === 'audioinput');
+      expect(audioDevices.some((d: any) => d.label)).to.be.false();
+      const videoDevices = devices.filter((d: any) => d.kind === 'videoinput');
+      expect(videoDevices.some((d: any) => d.label)).to.be.true();
+    });
+
+    it('does not return labels of enumerated video devices when permission denied', async () => {
+      session.defaultSession.setPermissionCheckHandler((wc, permission, origin, { mediaType }) => {
+        return permission !== 'media' || mediaType !== 'video';
+      });
+      const w = new BrowserWindow({ show: false });
+      w.loadFile(path.join(fixturesPath, 'pages', 'blank.html'));
+      const devices = await w.webContents.executeJavaScript(
+        `navigator.mediaDevices.enumerateDevices().then(ds => ds.map(d => {
+          return ({ label: d.label, kind: d.kind })
+        }));
+      `);
+      const audioDevices = devices.filter((d: any) => d.kind === 'audioinput');
+      expect(audioDevices.some((d: any) => d.label)).to.be.true();
+      const videoDevices = devices.filter((d: any) => d.kind === 'videoinput');
+      expect(videoDevices.some((d: any) => d.label)).to.be.false();
+    });
+
     it('returns the same device ids across reloads', async () => {
       const ses = session.fromPartition('persist:media-device-id');
       const w = new BrowserWindow({