From d4a21ff36fa2c88cdcf7b9e6d49fb8856eeaf22d Mon Sep 17 00:00:00 2001 From: Snekxs <26660858+snekxs@users.noreply.github.com> Date: Thu, 5 Nov 2026 00:32:54 -0700 Subject: [PATCH 1/3] fix(devices): list a receiver's connected mouse as a mouse, not a keyboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A LIGHTSPEED receiver is claimed as a keyboard by VID/PID alone, so the listing shows a mouse on one as "USB Receiver · Keyboard" with keyboard art until the receiver is opened and its pairing slots answer (#1282). Once a mouse client owns the device that answer is ground truth, not a guess, so the listing now takes it instead of re-deriving the keyboard claim. --- src/device/controller.ts | 3 ++- src/device/device-clients.test.ts | 31 ++++++++++++++++++++++++++++++- src/device/device-clients.ts | 16 ++++++++++++++++ 3 files changed, 48 insertions(+), 2 deletions(-) diff --git a/src/device/controller.ts b/src/device/controller.ts index 326f0542..5ba884e0 100644 --- a/src/device/controller.ts +++ b/src/device/controller.ts @@ -14,6 +14,7 @@ import { deviceBrand, isKeyboardClient, type KeyboardClient, + listingClient, listLogicalDevices, logicalDeviceGroups, pickLogicalDevice, @@ -3054,7 +3055,7 @@ async function sidebarEntries(devices: HIDDevice[]): Promise { ? "bridge" : "webhid"; const selected = group.some((member) => member === activeDevice); - const client = await createSupportedClient(device); + const client = await listingClient(device, selected ? active : null); if (!client) { const keyboard = createKeyboardClient(device); if (!keyboard) continue; diff --git a/src/device/device-clients.test.ts b/src/device/device-clients.test.ts index a5a87426..1ab78527 100644 --- a/src/device/device-clients.test.ts +++ b/src/device/device-clients.test.ts @@ -2,7 +2,7 @@ import assert from "node:assert/strict"; import test from "node:test"; import { describeHidDevice } from "./hid-diagnostics.ts"; -import { createKeyboardClient, createMouseClient, createSupportedClient, listLogicalDevices, logicalDeviceGroups, prepareAutoConnectClient } from "./device-clients.ts"; +import { createKeyboardClient, createMouseClient, createSupportedClient, listingClient, listLogicalDevices, logicalDeviceGroups, prepareAutoConnectClient } from "./device-clients.ts"; function supportedWebHidDevice(options: { vendorId?: number; @@ -191,3 +191,32 @@ test("a shared Logitech receiver routes keyboard-first with the mouse registry i assert.ok(mouse); assert.equal(mouse.device, receiver); }); + +test("a connected mouse is listed as a mouse despite its receiver's keyboard claim", async () => { + const receiver = { + productName: "USB Receiver", + vendorId: 0x046d, + productId: 0xc54d, + collections: [ + { usagePage: 0xff00, usage: 1, inputReports: [], outputReports: [], featureReports: [], children: [] }, + { usagePage: 0xff00, usage: 2, inputReports: [], outputReports: [], featureReports: [], children: [] }, + ], + opened: false, + } as unknown as HIDDevice; + + // Nothing has read the receiver's pairing slots, so the keyboard claim still + // owns the listing (#1282). + assert.ok(createKeyboardClient(receiver)); + assert.equal(await listingClient(receiver, null), null); + + // A mouse client has opened this receiver and found a sensor behind it; the + // listing must show that mouse, not a "USB Receiver · Keyboard" card (#1362). + const mouse = await createMouseClient(receiver); + assert.ok(mouse); + assert.equal(await listingClient(receiver, mouse), mouse); + + // Another device's live client does not reclassify this receiver. + const other = await createMouseClient(supportedWebHidDevice()); + assert.ok(other); + assert.equal(await listingClient(receiver, other), null); +}); diff --git a/src/device/device-clients.ts b/src/device/device-clients.ts index f7d3defa..4e12cfbc 100644 --- a/src/device/device-clients.ts +++ b/src/device/device-clients.ts @@ -88,6 +88,22 @@ export async function createSupportedClient(device: HIDDevice): Promise { + if (active !== null && active.device === device) return active; + return createSupportedClient(device); +} + export async function prepareAutoConnectClient(client: SupportedClient): Promise { // Check the transport before loading the Razer modules: every non-bridge // client takes this path on connect, and none of them is a Razer client. From 8c212ef1a84c99a4d6bbdb3f1c10eaff3859a0af Mon Sep 17 00:00:00 2001 From: snekxs <26660858+snekxs@users.noreply.github.com> Date: Thu, 5 Nov 2026 01:49:48 -0600 Subject: [PATCH 2/3] fix(devices): remember receivers that answered as a mouse MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A shared receiver exposes the same VID/PID/name and interfaces whether a keyboard or a mouse is paired, so device matching stays keyboard-first (#1282). The app never remembered that a receiver turned out to carry a mouse, so every listing, reconnect and switch went back to the keyboard guess: the Devices card read "USB Receiver · Keyboard", connect showed the keyboard reading state before falling back, and #1364 only covered the card of the open device. - mouse-receivers.ts records a receiver (VID:PID:name) and the usage pages of the interface that read as a mouse, in localStorage, once a mouse client reads it. A failed mouse activation drops the record, so a newly paired keyboard is probed first again. - createSupportedClient and clientSupportScore send a remembered receiver through that interface and demote its keyboard-claimed siblings, so the sidebar, Devices home, reconnect and select all agree. - The Keychron Link-KM (3434:d026/d029) has the same overlap: VIA claims its raw ff60 interface (score 900) over the M6 on ffc1 (score 7). A failed keyboard read now falls back to a sibling mouse interface of the same receiver, as the Logitech path already did for its own interface. --- src/device/controller.ts | 18 ++++++- src/device/device-clients.test.ts | 82 +++++++++++++++++++++++++++- src/device/device-clients.ts | 41 +++++++++++++- src/device/mouse-receivers.ts | 89 +++++++++++++++++++++++++++++++ 4 files changed, 227 insertions(+), 3 deletions(-) create mode 100644 src/device/mouse-receivers.ts diff --git a/src/device/controller.ts b/src/device/controller.ts index 5ba884e0..4a55438f 100644 --- a/src/device/controller.ts +++ b/src/device/controller.ts @@ -13,16 +13,19 @@ import { describeHidDevice, deviceBrand, isKeyboardClient, + isSharedReceiver, type KeyboardClient, listingClient, listLogicalDevices, logicalDeviceGroups, pickLogicalDevice, + siblingMouseClient, prepareAutoConnectClient, type PulsarClient, type SupportedClient, } from "./device-clients"; import { closestDpiOption } from "./dpi-presets"; +import { forgetMouseReceiver, rememberMouseReceiver } from "./mouse-receivers"; import { lunafurySettingLabel, lunafurySettingPriority } from "./lunafury-labels.ts"; import { lunaFuryLightningPriority, stageLunaFuryProfile } from "./lunafury-profile.ts"; import { formatHex, hidTraffic, isMark, markHidActivity, startHidCapture, type HidTrafficEntry } from "./hid-diagnostics"; @@ -3168,6 +3171,11 @@ async function waitForControllerIdle(): Promise { } } +/** The WebHID devices this origin may open, or none when WebHID is missing or refuses. */ +async function authorizedHidDevices(): Promise { + return await navigator.hid?.getDevices().catch(() => []) ?? []; +} + /** A wedged OpenMouse Bridge rejects getDevices(); toast it instead of failing silently. */ async function authorizedDeviceGroups(): Promise { try { @@ -3769,6 +3777,9 @@ async function activateClientNow(client: SupportedClient): Promise { lastSleepSeconds = status.sleepTimeout ?? keychron.getSleepOptions()[0] ?? 60; } deviceStatuses.set(client.device, status); + // A receiver the keyboard drivers claim just answered as a mouse: remember + // it so it lists and reconnects as one from now on (#1362). + if (await isSharedReceiver(client.device, await authorizedHidDevices())) rememberMouseReceiver(client.device); // Optional setters/getters are runtime capabilities of the selected // client. Populate them before rendering so controls such as RAWM angle // tuning are editable on the first status snapshot. @@ -3796,6 +3807,8 @@ async function activateClientNow(client: SupportedClient): Promise { setConnectionButtons(false, "Add device"); pushToast("success", st("ctl.connectedTo", { name: statusNameForClient(client) })); } catch (error) { + // The receiver may carry a keyboard now; let the next connect probe that first. + forgetMouseReceiver(client.device); // A failed activation must not leave the closed client as the active one: // that hides the empty state and blocks reconnectAuthorizedDevice. if (active === client) { @@ -3829,9 +3842,12 @@ async function activateKeyboardClientNow(client: KeyboardClient): Promise keyboardIdentity = await client.readIdentity(); keyboardPolling = client.readPolling ? await client.readPolling() : null; } catch (error) { + // A shared receiver with a mouse behind it: a Logitech receiver answers + // "no supported keyboard paired" and its own interface opens as the mouse; + // a Keychron Link-KM's mouse sits on another interface of the receiver. const receiverMouse = (error instanceof Error && /no supported keyboard paired/i.test(error.message)) ? await createMouseClient(client.device).catch(() => null) - : null; + : await siblingMouseClient(client.device, await authorizedHidDevices()).catch(() => null); if (receiverMouse) { await client.close().catch(() => undefined); keyboardBusy = false; diff --git a/src/device/device-clients.test.ts b/src/device/device-clients.test.ts index 1ab78527..1f549a3f 100644 --- a/src/device/device-clients.test.ts +++ b/src/device/device-clients.test.ts @@ -2,7 +2,8 @@ import assert from "node:assert/strict"; import test from "node:test"; import { describeHidDevice } from "./hid-diagnostics.ts"; -import { createKeyboardClient, createMouseClient, createSupportedClient, listingClient, listLogicalDevices, logicalDeviceGroups, prepareAutoConnectClient } from "./device-clients.ts"; +import { forgetMouseReceiver, rememberMouseReceiver } from "./mouse-receivers.ts"; +import { clientSupportScore, createKeyboardClient, createMouseClient, createSupportedClient, isSharedReceiver, listingClient, listLogicalDevices, logicalDeviceGroups, pickLogicalDevice, prepareAutoConnectClient, siblingMouseClient } from "./device-clients.ts"; function supportedWebHidDevice(options: { vendorId?: number; @@ -220,3 +221,82 @@ test("a connected mouse is listed as a mouse despite its receiver's keyboard cla assert.ok(other); assert.equal(await listingClient(receiver, other), null); }); + +test("a receiver that answered as a mouse stays a mouse until it fails as one", async () => { + const receiverInterface = (usage: number) => ({ + productName: "USB Receiver", + vendorId: 0x046d, + productId: 0xc54d, + collections: [{ usagePage: 0xff00, usage, inputReports: [], outputReports: [], featureReports: [], children: [] }], + opened: false, + } as unknown as HIDDevice); + const shortInterface = receiverInterface(1); + const longInterface = receiverInterface(2); + assert.equal(await createSupportedClient(longInterface), null); + assert.equal(await clientSupportScore(longInterface), 900); + assert.equal(await isSharedReceiver(longInterface, [shortInterface, longInterface]), true); + + // Activation read a mouse behind one interface: every interface of that + // receiver now lists and connects as the mouse, with no live client needed. + rememberMouseReceiver(shortInterface); + try { + const mouse = await createSupportedClient(longInterface); + assert.ok(mouse); + assert.equal(mouse.device, longInterface); + assert.notEqual(await listingClient(longInterface, null), null); + assert.notEqual(await clientSupportScore(longInterface), 900); + + // A different receiver model keeps the keyboard-first guess. + const otherReceiver = { ...longInterface, productId: 0xc539 } as unknown as HIDDevice; + assert.equal(await createSupportedClient(otherReceiver), null); + } finally { + forgetMouseReceiver(shortInterface); + } + + // A failed mouse activation forgets it, so a newly paired keyboard is found again. + assert.equal(await createSupportedClient(longInterface), null); +}); + +test("a Keychron Link-KM lists its mouse interface once that interface answered", async () => { + const linkInterface = (usagePage: number, usage: number, reports: { input: number; output: number } | null) => ({ + productName: "Keychron Link-KM Type C", + vendorId: 0x3434, + productId: 0xd029, + collections: [{ + usagePage, + usage, + inputReports: reports ? [{ reportId: reports.input }] : [], + outputReports: reports ? [{ reportId: reports.output }] : [], + featureReports: [], + children: [], + }], + opened: false, + } as unknown as HIDDevice); + // VIA raw HID, which the VIA keyboard driver claims, and the M6 command interface. + const via = linkInterface(0xff60, 0x61, null); + const m6 = linkInterface(0xffc1, 0x01, { input: 0xb4, output: 0xb3 }); + const devices = [via, m6]; + const [group] = await logicalDeviceGroups(devices); + assert.equal(group.length, 2); + + // Unread, the VIA claim outranks the mouse interface (#1282 ordering). + assert.ok(createKeyboardClient(via)); + assert.equal(await pickLogicalDevice(group), via); + assert.equal(await isSharedReceiver(m6, devices), true); + // A failed VIA read falls back to the receiver's mouse interface. + const fallback = await siblingMouseClient(via, devices); + assert.ok(fallback); + assert.equal(fallback.device, m6); + + rememberMouseReceiver(m6); + try { + // The listing and reconnect pick the M6 interface; VIA keeps its claim on + // its own interface but no longer represents the receiver. + assert.equal(await pickLogicalDevice(group), m6); + assert.equal(await clientSupportScore(via), 0); + assert.ok(await createSupportedClient(m6)); + } finally { + forgetMouseReceiver(m6); + } + assert.equal(await pickLogicalDevice(group), via); +}); diff --git a/src/device/device-clients.ts b/src/device/device-clients.ts index 4e12cfbc..3215dcf5 100644 --- a/src/device/device-clients.ts +++ b/src/device/device-clients.ts @@ -9,6 +9,7 @@ import { SteelSeriesKeyboardClient } from "./steelseries-keyboard.ts"; import { KeychronKeyboardClient } from "./keychron-keyboard.ts"; import { keychronHallEffectEnabled } from "./keychron-flag.ts"; import { ViaKeyboardClient } from "./via-keyboard.ts"; +import { mouseReceiverRole, receiverKey } from "./mouse-receivers.ts"; import type { PulsarClient, SupportedClient } from "@openmouse/protocol/drivers/registry"; export { describeHidDevice } from "./hid-diagnostics.ts"; export { type PulsarClient, type SupportedClient }; @@ -84,6 +85,9 @@ export async function createMouseClient(device: HIDDevice): Promise { + // The interface of a receiver that already answered as a mouse is not a + // keyboard guess any more. + if (mouseReceiverRole(device) === "mouse") return createMouseClient(device); if (createKeyboardClient(device)) return null; return createMouseClient(device); } @@ -143,7 +147,11 @@ export function createKeyboardClient(device: HIDDevice): KeyboardClient | null { export async function clientSupportScore(device: HIDDevice): Promise { // Keyboard command interfaces win over generic mouse HID++/Razer registries. - if (createKeyboardClient(device)) return 900; + // A remembered mouse receiver lists and connects through the interface that + // answered as a mouse; its keyboard-claimed siblings step aside. + const receiverRole = mouseReceiverRole(device); + if (receiverRole === "mouse") return 20_000; + if (createKeyboardClient(device)) return receiverRole === "sibling" ? 0 : 900; if (await bridgeRazerClient(device) !== null) return 10_000; return (await loadRegistry()).clientSupportScore(device); } @@ -201,6 +209,37 @@ export async function pickLogicalDevice(group: HIDDevice[]): Promise return group[bestIndex]!; } +/** + * Whether a keyboard claim on this device is a guess about a shared receiver: + * the device itself also matches a mouse driver (a Logitech receiver), or + * another interface of the same receiver does (a Keychron Link-KM). + */ +export async function isSharedReceiver(device: HIDDevice, devices: HIDDevice[]): Promise { + const key = receiverKey(device); + const interfaces = [device, ...devices.filter((other) => other !== device && receiverKey(other) === key)]; + if (!interfaces.some((member) => createKeyboardClient(member) !== null)) return false; + for (const member of interfaces) if (await createMouseClient(member) !== null) return true; + return false; +} + +/** + * The best mouse client among another interface of a keyboard-claimed + * receiver, for when the keyboard read fails: a Link-KM exposes VIA on `ff60` + * and an M6 behind its `ffc1` interface. + */ +export async function siblingMouseClient(device: HIDDevice, devices: HIDDevice[]): Promise { + const key = receiverKey(device); + let best: { client: SupportedClient; score: number } | null = null; + for (const other of devices) { + if (other === device || receiverKey(other) !== key || createKeyboardClient(other)) continue; + const client = await createMouseClient(other); + if (!client) continue; + const score = await clientSupportScore(other); + if (!best || score > best.score) best = { client, score }; + } + return best?.client ?? null; +} + /** Supported devices for the sidebar; multi-path drivers collapse via their module. */ export async function listLogicalDevices(devices: HIDDevice[] = []): Promise { const groups = await logicalDeviceGroups(devices); diff --git a/src/device/mouse-receivers.ts b/src/device/mouse-receivers.ts new file mode 100644 index 00000000..43767b66 --- /dev/null +++ b/src/device/mouse-receivers.ts @@ -0,0 +1,89 @@ +/** + * Receivers the keyboard drivers claim but that turned out to carry a mouse. + * + * A shared receiver exposes the same VID/PID/name and interfaces whether a + * keyboard or a mouse is paired: a Logitech receiver's HID++ collections match + * the Logitech keyboard driver, and a Keychron Link-KM's raw `ff60` interface + * matches VIA. Device matching is keyboard-first until the receiver has been + * read (#1282). Once a mouse client has opened one and read its status, that is + * no longer a guess: remembering it, and which interface answered, lets the + * listing, the connect order and the status text treat the receiver as the + * mouse it is, instead of re-probing it as a keyboard on every connect and + * drawing it as "USB Receiver · Keyboard" (#1362). + * + * Keyed by VID:PID:name, the same identity that groups a receiver's + * interfaces, and kept in localStorage so the first listing after a reload is + * right as well. + */ +const STORAGE_KEY = "openmouse.mouse-receivers"; + +/** Receiver key → usage pages of the interface that read as a mouse. */ +let known: Map | null = null; + +export function receiverKey(device: HIDDevice): string { + return [device.vendorId, device.productId, device.productName ?? ""].join(":"); +} + +function storage(): Storage | null { + try { + return typeof localStorage === "undefined" ? null : localStorage; + } catch { + return null; + } +} + +function load(): Map { + if (known) return known; + known = new Map(); + try { + const parsed: unknown = JSON.parse(storage()?.getItem(STORAGE_KEY) ?? "{}"); + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + for (const [key, pages] of Object.entries(parsed)) { + if (Array.isArray(pages) && pages.every((page) => typeof page === "number")) known.set(key, pages); + } + } + } catch { + // A corrupt entry only costs the keyboard-first guess; start over. + } + return known; +} + +function save(entries: Map): void { + try { + storage()?.setItem(STORAGE_KEY, JSON.stringify(Object.fromEntries(entries))); + } catch { + // Storage can be full or blocked; the in-memory copy still serves this session. + } +} + +function usagePages(device: HIDDevice): number[] { + return [...new Set((device.collections ?? []).map((collection) => collection.usagePage))].sort((a, b) => a - b); +} + +/** + * How a remembered receiver's interface relates to its mouse: `"mouse"` for + * the interface that answered as one, `"sibling"` for another interface of the + * same receiver, `null` when the receiver is not remembered. + */ +export function mouseReceiverRole(device: HIDDevice): "mouse" | "sibling" | null { + const pages = load().get(receiverKey(device)); + if (!pages) return null; + return usagePages(device).some((page) => pages.includes(page)) ? "mouse" : "sibling"; +} + +/** Record that a mouse client read this interface of a keyboard-claimed receiver. */ +export function rememberMouseReceiver(device: HIDDevice): void { + const entries = load(); + const key = receiverKey(device); + const pages = usagePages(device); + if (entries.get(key)?.join() === pages.join()) return; + entries.set(key, pages); + save(entries); +} + +/** Drop the record, so the next connect probes keyboard-first again. */ +export function forgetMouseReceiver(device: HIDDevice): void { + const entries = load(); + if (!entries.delete(receiverKey(device))) return; + save(entries); +} From 4c6d1f8697b25f9edddd491408b0cfb7f0c56f13 Mon Sep 17 00:00:00 2001 From: snekxs <26660858+snekxs@users.noreply.github.com> Date: Thu, 5 Nov 2026 14:25:23 -0600 Subject: [PATCH 3/3] fix(devices): scope receiver memory to the answering interface Review follow-ups on the receiver memory: - Key Bridge receivers by Bridge's own session key, as logical groups do, so a second receiver of the same model over Bridge is not taken for the one that was read. - Record the answering interface by page/usage, not page alone. A Logitech receiver's short and long HID++ interfaces both sit on 0xff00, so both counted as the mouse and tied. The mouse interface also keeps the driver's own score on top, so its preference between interfaces (Bolt's long-report collection) still counts. - The sibling fallback after a failed keyboard read connects the mouse but no longer records it. Only "no supported keyboard paired" proves there is no keyboard; a keyboard that failed to answer would otherwise be pushed aside on every later connect. --- src/device/controller.ts | 18 ++++++++++--- src/device/device-clients.test.ts | 23 ++++++++++++---- src/device/device-clients.ts | 4 ++- src/device/mouse-receivers.ts | 44 +++++++++++++++++++------------ 4 files changed, 62 insertions(+), 27 deletions(-) diff --git a/src/device/controller.ts b/src/device/controller.ts index 4a55438f..73b089a9 100644 --- a/src/device/controller.ts +++ b/src/device/controller.ts @@ -3726,7 +3726,13 @@ async function readReceiverRegisterF1( } /** Activate a mouse client and remember it as the preferred device. */ -async function activateClientNow(client: SupportedClient): Promise { +/** + * `rememberReceiver: false` connects a receiver's mouse without recording it: + * the sibling fallback after a failed keyboard read cannot tell "no keyboard + * paired" from a keyboard that failed to answer, so the record would push a + * real keyboard aside on every later connect. + */ +async function activateClientNow(client: SupportedClient, options: { rememberReceiver?: boolean } = {}): Promise { while (refreshInProgress) { await new Promise((resolve) => window.setTimeout(resolve, 25)); } @@ -3779,7 +3785,9 @@ async function activateClientNow(client: SupportedClient): Promise { deviceStatuses.set(client.device, status); // A receiver the keyboard drivers claim just answered as a mouse: remember // it so it lists and reconnects as one from now on (#1362). - if (await isSharedReceiver(client.device, await authorizedHidDevices())) rememberMouseReceiver(client.device); + if (options.rememberReceiver !== false && await isSharedReceiver(client.device, await authorizedHidDevices())) { + rememberMouseReceiver(client.device); + } // Optional setters/getters are runtime capabilities of the selected // client. Populate them before rendering so controls such as RAWM angle // tuning are editable on the first status snapshot. @@ -3845,7 +3853,8 @@ async function activateKeyboardClientNow(client: KeyboardClient): Promise // A shared receiver with a mouse behind it: a Logitech receiver answers // "no supported keyboard paired" and its own interface opens as the mouse; // a Keychron Link-KM's mouse sits on another interface of the receiver. - const receiverMouse = (error instanceof Error && /no supported keyboard paired/i.test(error.message)) + const noKeyboardPaired = error instanceof Error && /no supported keyboard paired/i.test(error.message); + const receiverMouse = noKeyboardPaired ? await createMouseClient(client.device).catch(() => null) : await siblingMouseClient(client.device, await authorizedHidDevices()).catch(() => null); if (receiverMouse) { @@ -3857,7 +3866,8 @@ async function activateKeyboardClientNow(client: KeyboardClient): Promise deviceStatusText = st("ctl.opening"); readStatus = st("ctl.reading", { name: statusNameForClient(receiverMouse) }); emit(); - await activateClientNow(receiverMouse); + // Only "no supported keyboard paired" proves there is no keyboard to find. + await activateClientNow(receiverMouse, { rememberReceiver: noKeyboardPaired }); return; } await client.close().catch(() => undefined); diff --git a/src/device/device-clients.test.ts b/src/device/device-clients.test.ts index 1f549a3f..f564b6e4 100644 --- a/src/device/device-clients.test.ts +++ b/src/device/device-clients.test.ts @@ -236,21 +236,34 @@ test("a receiver that answered as a mouse stays a mouse until it fails as one", assert.equal(await clientSupportScore(longInterface), 900); assert.equal(await isSharedReceiver(longInterface, [shortInterface, longInterface]), true); - // Activation read a mouse behind one interface: every interface of that - // receiver now lists and connects as the mouse, with no live client needed. - rememberMouseReceiver(shortInterface); + // Activation read a mouse on the long interface: that interface now lists + // and connects as the mouse, with no live client needed, and represents the + // receiver. Both interfaces share page 0xff00, so the record must name the + // usage too, or they would tie. + rememberMouseReceiver(longInterface); try { const mouse = await createSupportedClient(longInterface); assert.ok(mouse); assert.equal(mouse.device, longInterface); assert.notEqual(await listingClient(longInterface, null), null); - assert.notEqual(await clientSupportScore(longInterface), 900); + assert.ok(await clientSupportScore(longInterface) > 20_000); + assert.equal(await clientSupportScore(shortInterface), 0); + const [group] = await logicalDeviceGroups([shortInterface, longInterface]); + assert.equal(await pickLogicalDevice(group), longInterface); // A different receiver model keeps the keyboard-first guess. const otherReceiver = { ...longInterface, productId: 0xc539 } as unknown as HIDDevice; assert.equal(await createSupportedClient(otherReceiver), null); + + // Bridge keys each receiver by its own session key: a second receiver of + // the same model over Bridge is not the one that was read. + const bridgeReceiver = (key: string) => ({ ...longInterface, openMouseTransport: "bridge", key } as unknown as HIDDevice); + rememberMouseReceiver(bridgeReceiver("046d:c54d:A")); + assert.equal(await clientSupportScore(bridgeReceiver("046d:c54d:B")), 900); + assert.ok(await clientSupportScore(bridgeReceiver("046d:c54d:A")) > 20_000); + forgetMouseReceiver(bridgeReceiver("046d:c54d:A")); } finally { - forgetMouseReceiver(shortInterface); + forgetMouseReceiver(longInterface); } // A failed mouse activation forgets it, so a newly paired keyboard is found again. diff --git a/src/device/device-clients.ts b/src/device/device-clients.ts index 3215dcf5..5bc89ac6 100644 --- a/src/device/device-clients.ts +++ b/src/device/device-clients.ts @@ -150,7 +150,9 @@ export async function clientSupportScore(device: HIDDevice): Promise { // A remembered mouse receiver lists and connects through the interface that // answered as a mouse; its keyboard-claimed siblings step aside. const receiverRole = mouseReceiverRole(device); - if (receiverRole === "mouse") return 20_000; + // Keep the driver's own score on top, so its preference between interfaces + // (Bolt's long-report collection) still counts. + if (receiverRole === "mouse") return 20_000 + (await loadRegistry()).clientSupportScore(device); if (createKeyboardClient(device)) return receiverRole === "sibling" ? 0 : 900; if (await bridgeRazerClient(device) !== null) return 10_000; return (await loadRegistry()).clientSupportScore(device); diff --git a/src/device/mouse-receivers.ts b/src/device/mouse-receivers.ts index 43767b66..d77f5265 100644 --- a/src/device/mouse-receivers.ts +++ b/src/device/mouse-receivers.ts @@ -11,16 +11,21 @@ * mouse it is, instead of re-probing it as a keyboard on every connect and * drawing it as "USB Receiver · Keyboard" (#1362). * - * Keyed by VID:PID:name, the same identity that groups a receiver's - * interfaces, and kept in localStorage so the first listing after a reload is - * right as well. + * Keyed by the same identity that groups a receiver's interfaces, and kept in + * localStorage so the first listing after a reload is right as well. */ const STORAGE_KEY = "openmouse.mouse-receivers"; -/** Receiver key → usage pages of the interface that read as a mouse. */ -let known: Map | null = null; +/** Receiver key → signature of the interface that read as a mouse. */ +let known: Map | null = null; +/** + * One receiver, matching how logical device groups are keyed: Bridge's own + * session key (vendor/product[:serial]) or WebHID's vendor/product/name. + */ export function receiverKey(device: HIDDevice): string { + const bridge = device as { openMouseTransport?: string; key?: string }; + if (bridge.openMouseTransport === "bridge") return `bridge:${bridge.key ?? ""}`; return [device.vendorId, device.productId, device.productName ?? ""].join(":"); } @@ -32,14 +37,14 @@ function storage(): Storage | null { } } -function load(): Map { +function load(): Map { if (known) return known; known = new Map(); try { const parsed: unknown = JSON.parse(storage()?.getItem(STORAGE_KEY) ?? "{}"); if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { - for (const [key, pages] of Object.entries(parsed)) { - if (Array.isArray(pages) && pages.every((page) => typeof page === "number")) known.set(key, pages); + for (const [key, signature] of Object.entries(parsed)) { + if (typeof signature === "string") known.set(key, signature); } } } catch { @@ -48,7 +53,7 @@ function load(): Map { return known; } -function save(entries: Map): void { +function save(entries: Map): void { try { storage()?.setItem(STORAGE_KEY, JSON.stringify(Object.fromEntries(entries))); } catch { @@ -56,8 +61,13 @@ function save(entries: Map): void { } } -function usagePages(device: HIDDevice): number[] { - return [...new Set((device.collections ?? []).map((collection) => collection.usagePage))].sort((a, b) => a - b); +/** + * The top-level collections (page/usage) of one interface. A Logitech + * receiver's short and long HID++ interfaces share page 0xff00 and differ only + * in usage, so the page alone cannot tell them apart. + */ +function interfaceSignature(device: HIDDevice): string { + return [...new Set((device.collections ?? []).map((collection) => `${collection.usagePage}/${collection.usage}`))].sort().join(","); } /** @@ -66,18 +76,18 @@ function usagePages(device: HIDDevice): number[] { * same receiver, `null` when the receiver is not remembered. */ export function mouseReceiverRole(device: HIDDevice): "mouse" | "sibling" | null { - const pages = load().get(receiverKey(device)); - if (!pages) return null; - return usagePages(device).some((page) => pages.includes(page)) ? "mouse" : "sibling"; + const signature = load().get(receiverKey(device)); + if (signature === undefined) return null; + return interfaceSignature(device) === signature ? "mouse" : "sibling"; } /** Record that a mouse client read this interface of a keyboard-claimed receiver. */ export function rememberMouseReceiver(device: HIDDevice): void { const entries = load(); const key = receiverKey(device); - const pages = usagePages(device); - if (entries.get(key)?.join() === pages.join()) return; - entries.set(key, pages); + const signature = interfaceSignature(device); + if (entries.get(key) === signature) return; + entries.set(key, signature); save(entries); }