Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 2 additions & 35 deletions electron/ipc/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ import { requestMacCursorAccessibilityAccess } from "../native-bridge/cursor/rec
import { findPipeWireCursorHelperPath } from "../native-bridge/cursor/recording/pipeWireCursorRecordingSession";
import type { CursorRecordingSession } from "../native-bridge/cursor/recording/session";
import { toHelperRect } from "../native-bridge/helperCoordinates";
import { scoreDeviceNameMatch } from "../recording/deviceNameMatching";
import {
isSalvageableFragmentedCapture,
NATIVE_WINDOWS_SALVAGEABLE_OUTPUT_BYTES,
Expand Down Expand Up @@ -960,40 +961,6 @@ function isWindowsGraphicsCaptureOsSupported() {
return Number.isFinite(build) && build >= 19041;
}

function normalizeNativeDeviceName(value: string) {
return value
.toLowerCase()
.replace(/[^a-z0-9]+/g, " ")
.trim();
}

function scoreNativeDeviceName(candidateName: string, candidateId: string, requestedName?: string) {
const candidate = normalizeNativeDeviceName(candidateName);
const id = normalizeNativeDeviceName(candidateId);
const requested = normalizeNativeDeviceName(requestedName ?? "");
if (!requested) {
return 0;
}
if (candidate === requested) {
return 1000;
}
if (candidate.includes(requested) || requested.includes(candidate)) {
return 900;
}
if (id.includes(requested) || requested.includes(id)) {
return 800;
}

return requested
.split(/\s+/)
.filter((word) => word.length > 1 && !["camera", "webcam", "video", "input"].includes(word))
.reduce((score, word) => {
if (candidate.includes(word)) return score + 100;
if (id.includes(word)) return score + 50;
return score;
}, 0);
}

function queryDirectShowVideoInputRegistry() {
return new Promise<string>((resolve) => {
const proc = spawn(
Expand Down Expand Up @@ -1038,7 +1005,7 @@ async function resolveDirectShowWebcamClsid(deviceName?: string) {
let best: { clsid: string; friendlyName?: string; score: number } | null = null;
for (const entry of entries) {
if (!entry.clsid) continue;
const score = scoreNativeDeviceName(entry.friendlyName ?? "", entry.clsid, deviceName);
const score = scoreDeviceNameMatch(entry.friendlyName ?? "", entry.clsid, deviceName);
if (!best || score > best.score) {
best = { clsid: entry.clsid, friendlyName: entry.friendlyName, score };
}
Expand Down
80 changes: 46 additions & 34 deletions electron/native/wgc-capture/src/webcam_capture.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -33,17 +33,37 @@ std::wstring readAllocatedString(IMFActivate* activate, REFGUID key) {
return result;
}

bool containsInsensitive(const std::wstring& haystack, const std::wstring& needle) {
/**
* Does one of these appear inside the other as WHOLE WORDS?
*
* Plain containment answered for devices that merely share a spelling: a
* requested "Logi" is inside "Logitech", and "Micro" inside "Microphone",
* neither of them as a word. Matching on that resolved a camera nobody asked
* for -- and resolving one is exactly what stops the request reaching the
* DirectShow fallback, where the cameras Media Foundation cannot enumerate live.
*
* Both sides arrive normalized, so a boundary is the start of the string, its
* end, or a space.
*/
bool containsAsWords(const std::wstring& haystack, const std::wstring& needle) {
if (haystack.empty() || needle.empty()) {
return false;
}
size_t pos = haystack.find(needle);
while (pos != std::wstring::npos) {
const bool startsOnBoundary = pos == 0 || haystack[pos - 1] == L' ';
const size_t after = pos + needle.size();
const bool endsOnBoundary = after == haystack.size() || haystack[after] == L' ';
if (startsOnBoundary && endsOnBoundary) {
return true;
}
pos = haystack.find(needle, pos + 1);
}
return false;
}

std::wstring lowerHaystack = haystack;
std::wstring lowerNeedle = needle;
std::transform(lowerHaystack.begin(), lowerHaystack.end(), lowerHaystack.begin(), ::towlower);
std::transform(lowerNeedle.begin(), lowerNeedle.end(), lowerNeedle.begin(), ::towlower);
return lowerHaystack.find(lowerNeedle) != std::wstring::npos ||
lowerNeedle.find(lowerHaystack) != std::wstring::npos;
bool containsInsensitive(const std::wstring& haystack, const std::wstring& needle) {
return containsAsWords(haystack, needle) || containsAsWords(needle, haystack);
}

std::wstring normalizeDeviceName(const std::wstring& value) {
Expand All @@ -67,23 +87,25 @@ std::wstring normalizeDeviceName(const std::wstring& value) {
return normalized;
}

std::vector<std::wstring> splitWords(const std::wstring& value) {
std::vector<std::wstring> words;
size_t start = 0;
while (start < value.size()) {
const size_t end = value.find(L' ', start);
const auto word = value.substr(start, end == std::wstring::npos ? std::wstring::npos : end - start);
if (word.size() > 1 && word != L"camera" && word != L"webcam" && word != L"video" && word != L"input") {
words.push_back(word);
}
if (end == std::wstring::npos) {
break;
}
start = end + 1;
}
return words;
}

/**
* How well a candidate answers a requested name, or 0 for "not this one".
*
* Only decisive matches count: the names being equal once normalized, or one
* containing the other -- which is the ordinary case, since Chromium appends USB
* ids to what the driver reports.
*
* A further tier used to score shared WORDS, to bridge names differing more than
* that. It bridged names that were not the same device. "Logi Capture" and
* "Logitech StreamCam" share no word, yet "logi" sits inside "logitech" and that
* scored high enough to win -- so asking for a camera Media Foundation cannot
* enumerate opened a DIFFERENT camera, instead of returning nothing and letting
* the DirectShow fallback find the real one (getopenscreen/openscreen#405).
*
* Returning 0 is what makes that fallback reachable, so it is a real answer
* rather than a weak match. Keep this in step with
* `electron/recording/deviceNameMatching.ts`, which states the same rules for
* the Electron side and carries their unit tests.
*/
int deviceMatchScore(
const std::wstring& candidateName,
const std::wstring& candidateLink,
Expand All @@ -105,16 +127,6 @@ int deviceMatchScore(
if (containsInsensitive(normalizedLink, normalizedRequestedName)) {
score = std::max(score, 800);
}

int wordScore = 0;
for (const auto& word : splitWords(normalizedRequestedName)) {
if (normalizedName.find(word) != std::wstring::npos) {
wordScore += 100;
} else if (normalizedLink.find(word) != std::wstring::npos) {
wordScore += 50;
}
}
score = std::max(score, wordScore);
}

if (!normalizedRequestedId.empty()) {
Expand Down
90 changes: 90 additions & 0 deletions electron/recording/deviceNameMatching.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { describe, expect, it } from "vitest";
import { normalizeDeviceName, scoreDeviceNameMatch } from "./deviceNameMatching";

describe("normalizeDeviceName", () => {
it("reduces punctuation and case to single-spaced lowercase", () => {
expect(normalizeDeviceName("Camera (NVIDIA Broadcast)")).toBe("camera nvidia broadcast");
expect(normalizeDeviceName("Logitech StreamCam (046d:0893)")).toBe(
"logitech streamcam 046d 0893",
);
});
});

describe("scoreDeviceNameMatch", () => {
it("scores an exact name highest", () => {
expect(
scoreDeviceNameMatch("Camera (NVIDIA Broadcast)", "{clsid}", "Camera (NVIDIA Broadcast)"),
).toBe(1000);
});

// What Chromium hands over is the driver's name plus USB ids, so one side
// being the other with decoration is the ordinary case, not a near miss.
it("matches through the USB ids Chromium appends", () => {
expect(
scoreDeviceNameMatch("Logitech StreamCam", "{clsid}", "Logitech StreamCam (046d:0893)"),
).toBe(900);
});

it("matches when the platform name is the longer of the two", () => {
expect(
scoreDeviceNameMatch("Logitech HD Pro Webcam C920", "{clsid}", "HD Pro Webcam C920"),
).toBe(900);
});

/**
* The bug this module exists to end. "Logi Capture" and "Logitech StreamCam"
* are two different real devices sharing no word — but "logi" is inside
* "logitech", and the word-scoring tier took that for a match, opening the
* wrong camera instead of letting the caller fall through to the provider
* that had the right one.
*/
it("refuses a word that is merely inside another word", () => {
expect(scoreDeviceNameMatch("Logitech StreamCam", "{clsid}", "Logi Capture")).toBe(0);
});

it("refuses the microphone form of the same mistake", () => {
expect(scoreDeviceNameMatch("Microphone (Logitech PRO X)", "{id}", "Micro Studio")).toBe(0);
});

/**
* Sharing one distinctive word is no longer enough on its own. Two Logitech
* devices are still two devices, and answering "some Logitech thing" is how
* the wrong one got opened.
*/
it("refuses a partial match on a shared brand", () => {
expect(scoreDeviceNameMatch("Logitech StreamCam", "{clsid}", "Logitech BRIO")).toBe(0);
});

it("scores nothing when no name was requested", () => {
expect(scoreDeviceNameMatch("Logitech StreamCam", "{clsid}", undefined)).toBe(0);
expect(scoreDeviceNameMatch("Logitech StreamCam", "{clsid}", " ")).toBe(0);
});

/**
* "Micro" is inside "Microphone", "Logi" inside "Logitech" — spelled there,
* but not as a word. Containment used to take either for a match and resolve
* a device nobody asked for.
*/
it("refuses a request merely spelled inside a longer word", () => {
expect(scoreDeviceNameMatch("Microphone (Logitech StreamCam)", "{id}", "Micro")).toBe(0);
expect(scoreDeviceNameMatch("Logitech StreamCam", "{clsid}", "Logi")).toBe(0);
});

/**
* `[^a-z0-9]` stripped every non-Latin letter, so two different Japanese
* cameras both normalized to "a" and matched each other at 1000.
*/
it("keeps non-Latin names apart", () => {
expect(scoreDeviceNameMatch("カメラ A", "{clsid}", "ウェブカメラ A")).toBe(0);
expect(scoreDeviceNameMatch("Веб-камера 1", "{clsid}", "Веб-камера 2")).toBe(0);
});

it("still matches identical non-Latin names", () => {
expect(scoreDeviceNameMatch("カメラ A", "{clsid}", "カメラ A")).toBe(1000);
expect(scoreDeviceNameMatch("摄像头(罗技)", "{clsid}", "摄像头(罗技)")).toBe(1000);
});

it("falls back to the identifier when the friendly name says nothing", () => {
expect(scoreDeviceNameMatch("", "usb elgato facecam 0fd9", "Elgato Facecam")).toBe(800);
});
});
106 changes: 106 additions & 0 deletions electron/recording/deviceNameMatching.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
/**
* Matching a device the user picked in the browser against one Windows can open.
*
* The two sides name the same hardware differently — Chromium appends USB ids
* ("Logitech StreamCam (046d:0893)") while DirectShow, Media Foundation and
* WASAPI report the driver's friendly name — so the match cannot be equality.
* It can, however, be *decisive*: every real pairing observed is one name
* containing the other, and nothing weaker is trusted.
*
* There used to be a further tier that scored shared WORDS, meant to bridge
* names that differ more than that. It bridged names that were not the same
* device at all. "Logi Capture" and "Logitech StreamCam" share no word, but
* "logi" is inside "logitech" — enough to win, so choosing a camera Media
* Foundation cannot see opened a different camera instead of falling through to
* the provider that would have found the right one. The microphone side reached
* the same place by the same road, "micro" sitting inside the "microphone" that
* opens nearly every Windows endpoint name (getopenscreen/openscreen#404, #405).
*
* Dropping that tier costs nothing measurable: on the reporter's machine every
* camera and microphone resolves at 800 or above without it. What it buys is
* that "I could not find it" is now reachable — and a caller that hears it can
* try another provider, or say so, instead of recording the wrong device.
*
* This lives here rather than in `electron/ipc/handlers.ts` because that module
* calls `app.getPath()` while being imported and cannot be loaded from a test.
* The C++ helpers carry their own copy of these rules, covered by the
* Windows-only scripts in `scripts/` that drive the real binary.
*/

/**
* Lowercase, letters and digits only, single-spaced — the shape both sides
* compare in.
*
* Unicode-aware, and not `[^a-z0-9]`: that stripped every non-Latin letter, so a
* Japanese "カメラ A" and "ウェブカメラ A" both collapsed to "a" and matched each
* other exactly, at the highest score there is. The C++ helpers use
* `std::iswalnum` on wide characters and never had that flaw; this is the copy
* that did.
*/
export function normalizeDeviceName(value: string) {
return value
.toLowerCase()
.replace(/[^\p{L}\p{N}]+/gu, " ")
.trim();
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

/**
* Does `needle` appear in `haystack` as whole words?
*
* Plain containment answers for devices that merely share a spelling: a
* requested "Micro" is inside "Microphone (Logitech StreamCam)", and "Logi"
* inside "Logitech", neither of them as a word. Both resolved a device nobody
* asked for — and resolving one is precisely what stops the caller from falling
* through to the provider that had the right one.
*
* Both sides are normalized, so a boundary is the start of the string, its end,
* or a space.
*/
function containsAsWords(haystack: string, needle: string) {
if (!haystack || !needle) {
return false;
}
for (let at = haystack.indexOf(needle); at !== -1; at = haystack.indexOf(needle, at + 1)) {
const startsOnBoundary = at === 0 || haystack[at - 1] === " ";
const after = at + needle.length;
const endsOnBoundary = after === haystack.length || haystack[after] === " ";
if (startsOnBoundary && endsOnBoundary) {
return true;
}
}
return false;
}

/**
* How well a candidate device answers a requested name, or 0 for "not this one"
* — which callers must treat as a real answer rather than a weak match.
*
* @param candidateName The device's own name, as the platform reports it.
* @param candidateId Its stable identifier — a CLSID, a symbolic link — which
* sometimes carries the model name when the friendly name does not.
* @param requestedName What the user picked, as the browser labelled it.
*/
export function scoreDeviceNameMatch(
candidateName: string,
candidateId: string,
requestedName?: string,
) {
const candidate = normalizeDeviceName(candidateName);
const id = normalizeDeviceName(candidateId);
const requested = normalizeDeviceName(requestedName ?? "");
if (!requested) {
return 0;
}
if (candidate === requested) {
return 1000;
}
// One name being the other plus decoration is the ordinary case, and the only
// inexact match worth trusting — provided the shared part is whole words.
if (containsAsWords(candidate, requested) || containsAsWords(requested, candidate)) {
return 900;
}
if (containsAsWords(id, requested) || containsAsWords(requested, id)) {
return 800;
}
return 0;
}
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@
"test:wgc-mixed-audio:win": "node scripts/test-windows-wgc-helper.mjs --system-audio --microphone",
"test:wgc-audio-timeline:win": "node scripts/test-windows-audio-timeline.mjs",
"test:wgc-webcam:win": "node scripts/test-windows-wgc-helper.mjs --webcam",
"test:wgc-camera-selection:win": "node scripts/test-windows-camera-selection.mjs",
"test:wgc-full:win": "node scripts/test-windows-wgc-helper.mjs --webcam --system-audio --microphone",
"capture:openscreen-preview": "node scripts/capture-openscreen-preview.mjs",
"inspect:cursor-click-bounce": "node scripts/inspect-native-cursor-click-bounce.mjs",
Expand Down
Loading
Loading