From 13edd56a44ef28706ad604df86e8eb605386735b Mon Sep 17 00:00:00 2001 From: sourabhxyz Date: Thu, 15 Oct 2026 12:16:56 +0530 Subject: [PATCH] Prefer a known device ID over the stripped-label match Resolve the microphone by exact label, then by a device ID that names a real capture device, and only then by the label without Chromium's transport suffix, so the name heuristic cannot override a precise identifier. Move the matching into a pure function and replace the source-text assertions with a test that compiles it with swiftc and checks the results against a fixed device list, following the lifecycle test. Co-Authored-By: Claude Opus 5.5 --- ...creenCaptureKitRecorder.microphone.test.ts | 100 ++++++++++++++++++ .../native/ScreenCaptureKitRecorder.swift | 54 ++++++---- .../native/ScreenCaptureKitRecorder.test.ts | 20 ---- 3 files changed, 134 insertions(+), 40 deletions(-) create mode 100644 electron/native/ScreenCaptureKitRecorder.microphone.test.ts diff --git a/electron/native/ScreenCaptureKitRecorder.microphone.test.ts b/electron/native/ScreenCaptureKitRecorder.microphone.test.ts new file mode 100644 index 00000000..b1d35ed4 --- /dev/null +++ b/electron/native/ScreenCaptureKitRecorder.microphone.test.ts @@ -0,0 +1,100 @@ +import { spawnSync } from "node:child_process"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +// Compile the production matching functions and run them against a fixed +// device list, so no real microphone or permission prompt is involved. +describe.skipIf(process.platform !== "darwin")("recorder microphone selection", () => { + let directory: string; + let executable: string; + beforeAll(() => { + directory = mkdtempSync(join(tmpdir(), "recordly-microphone-")); + executable = join(directory, "matcher"); + const source = readFileSync( + new URL("./ScreenCaptureKitRecorder.swift", import.meta.url), + "utf8", + ); + const matching = source.slice( + source.indexOf("private static func matchMicrophoneDeviceID"), + source.indexOf("private func supportsNativeMicrophoneCapture"), + ); + writeFileSync( + join(directory, "main.swift"), + ` +import Foundation +enum Matcher { +${matching} +} +extension Matcher { + static func run(_ arguments: [String]) -> String? { + switch arguments[0] { + case "undecorated": + return undecoratedMicrophoneName(forLabel: arguments[1]) + default: + return matchMicrophoneDeviceID( + label: arguments[1], + deviceId: arguments[2], + devices: [ + (name: "Shure MV7", uniqueID: "usb-mv7"), + (name: "MacBook Pro Microphone", uniqueID: "built-in"), + (name: "Foo", uniqueID: "foo"), + (name: "Foo (Pro)", uniqueID: "foo-pro"), + ] + ) + } + } +} +print(Matcher.run(Array(CommandLine.arguments.dropFirst())) ?? "") +`, + ); + const build = spawnSync( + "swiftc", + [ + "-module-cache-path", + join(directory, "cache"), + join(directory, "main.swift"), + "-o", + executable, + ], + { encoding: "utf8", timeout: 60_000 }, + ); + expect(build.status, build.stderr).toBe(0); + }, 60_000); + afterAll(() => { + if (directory) rmSync(directory, { recursive: true, force: true }); + }); + + const run = (...args: string[]) => { + const result = spawnSync(executable, args, { encoding: "utf8", timeout: 5_000 }); + expect(result.status, result.stderr).toBe(0); + return result.stdout.trim(); + }; + + it.each([ + ["Shure MV7 (14ed:1012)", "Shure MV7"], + ["Mic (Built-in)", "Mic"], + ["Name (A) (B)", "Name (A)"], + ["(Only)", ""], + ["Plain", ""], + ["Mic (unclosed", ""], + ["", ""], + ])("strips the Chromium suffix from %j", (label, expected) => { + expect(run("undecorated", label)).toBe(expected); + }); + + it.each([ + // Chromium's deviceId is a per-origin hash that never matches a uniqueID. + ["Shure MV7 (14ed:1012)", "d4488ddaa3f0f10c", "usb-mv7"], + ["Foo (Pro)", "", "foo-pro"], + ["Shure MV7", "built-in", "usb-mv7"], + ["Foo (USB)", "built-in", "built-in"], + ["Foo (USB)", "", "foo"], + ["Default - MacBook Pro Microphone (Built-in)", "default", ""], + ["Unknown Mic (Virtual)", "", ""], + ["", "", ""], + ])("resolves label %j with device ID %j to %j", (label, deviceId, expected) => { + expect(run("match", label, deviceId)).toBe(expected); + }); +}); diff --git a/electron/native/ScreenCaptureKitRecorder.swift b/electron/native/ScreenCaptureKitRecorder.swift index c2c2d075..a9da8f91 100644 --- a/electron/native/ScreenCaptureKitRecorder.swift +++ b/electron/native/ScreenCaptureKitRecorder.swift @@ -855,20 +855,37 @@ final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate { } private static func resolveMicrophoneCaptureDeviceID(config: CaptureConfig) -> String? { - let audioDevices = AVCaptureDevice.devices(for: .audio) + let audioDevices = AVCaptureDevice.devices(for: .audio).map { + (name: $0.localizedName, uniqueID: $0.uniqueID) + } + return matchMicrophoneDeviceID( + label: config.microphoneLabel, + deviceId: config.microphoneDeviceId, + devices: audioDevices + ) + } - if let microphoneLabel = config.microphoneLabel?.trimmingCharacters(in: .whitespacesAndNewlines), !microphoneLabel.isEmpty { - for candidateName in microphoneNameCandidates(forLabel: microphoneLabel) { - if let matchedDevice = audioDevices.first(where: { $0.localizedName == candidateName }) { - return matchedDevice.uniqueID - } - } + /// Matches the exact label first, then a known device ID, and only then the + /// label without its transport suffix, because that last step is a name + /// heuristic and must not override a precise identifier. + private static func matchMicrophoneDeviceID( + label: String?, + deviceId: String?, + devices: [(name: String, uniqueID: String)] + ) -> String? { + let label = label?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if !label.isEmpty, let matchedDevice = devices.first(where: { $0.name == label }) { + return matchedDevice.uniqueID } - if let microphoneDeviceId = config.microphoneDeviceId?.trimmingCharacters(in: .whitespacesAndNewlines), !microphoneDeviceId.isEmpty { - if audioDevices.contains(where: { $0.uniqueID == microphoneDeviceId }) { - return microphoneDeviceId - } + let deviceId = deviceId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if !deviceId.isEmpty, devices.contains(where: { $0.uniqueID == deviceId }) { + return deviceId + } + + if let undecoratedName = undecoratedMicrophoneName(forLabel: label), + let matchedDevice = devices.first(where: { $0.name == undecoratedName }) { + return matchedDevice.uniqueID } return nil @@ -877,17 +894,14 @@ final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate { /// The label comes from Chromium's `enumerateDevices()`, which appends a /// transport suffix to the CoreAudio name — "Shure MV7 (14ed:1012)", /// "MacBook Pro Microphone (Built-in)", "BlackHole 16ch (Virtual)" — so it - /// rarely equals `localizedName` as-is. Without the undecorated candidate the + /// rarely equals `localizedName` as-is. Without the undecorated name the /// selection is silently ignored and the system default input is recorded. - private static func microphoneNameCandidates(forLabel label: String) -> [String] { - var candidates = [label] - if label.hasSuffix(")"), let suffixStart = label.range(of: " (", options: .backwards) { - let undecoratedName = label[.. String? { + guard label.hasSuffix(")"), let suffixStart = label.range(of: " (", options: .backwards) else { + return nil } - return candidates + let undecoratedName = label[.. Bool { diff --git a/electron/native/ScreenCaptureKitRecorder.test.ts b/electron/native/ScreenCaptureKitRecorder.test.ts index e96a96a7..edaba7ee 100644 --- a/electron/native/ScreenCaptureKitRecorder.test.ts +++ b/electron/native/ScreenCaptureKitRecorder.test.ts @@ -117,23 +117,3 @@ describe("ScreenCaptureKitRecorder first frame timing", () => { expect(audioGuard).toBeLessThan(callback.indexOf("if outputType == .audio")); }); }); - -describe("ScreenCaptureKitRecorder microphone selection", () => { - const resolver = recorderSource.slice( - recorderSource.indexOf("private static func resolveMicrophoneCaptureDeviceID"), - recorderSource.indexOf("private func supportsNativeMicrophoneCapture"), - ); - - it("matches the selected microphone by its undecorated Chromium label", () => { - // Chromium reports "Shure MV7 (14ed:1012)"; CoreAudio names it "Shure MV7". - expect(resolver).toContain( - "for candidateName in microphoneNameCandidates(forLabel: microphoneLabel)", - ); - expect(resolver).toContain("$0.localizedName == candidateName"); - expect(resolver).toContain('label.range(of: " (", options: .backwards)'); - }); - - it("prefers the raw label so device names ending in parentheses still match", () => { - expect(resolver).toContain("var candidates = [label]"); - }); -});