diff --git a/src/device/controller.ts b/src/device/controller.ts index 326f0542..73b089a9 100644 --- a/src/device/controller.ts +++ b/src/device/controller.ts @@ -13,15 +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"; @@ -3054,7 +3058,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; @@ -3167,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 { @@ -3717,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)); } @@ -3768,6 +3783,11 @@ 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 (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. @@ -3795,6 +3815,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) { @@ -3828,9 +3850,13 @@ async function activateKeyboardClientNow(client: KeyboardClient): Promise keyboardIdentity = await client.readIdentity(); keyboardPolling = client.readPolling ? await client.readPolling() : null; } catch (error) { - const receiverMouse = (error instanceof Error && /no supported keyboard paired/i.test(error.message)) + // 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 noKeyboardPaired = error instanceof Error && /no supported keyboard paired/i.test(error.message); + const receiverMouse = noKeyboardPaired ? await createMouseClient(client.device).catch(() => null) - : null; + : await siblingMouseClient(client.device, await authorizedHidDevices()).catch(() => null); if (receiverMouse) { await client.close().catch(() => undefined); keyboardBusy = false; @@ -3840,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 a5a87426..f564b6e4 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, 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; @@ -191,3 +192,124 @@ 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); +}); + +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 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.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(longInterface); + } + + // 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 f7d3defa..5bc89ac6 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,10 +85,29 @@ 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); } +/** + * The client a listing entry should present for one device. + * + * `createSupportedClient` is only a descriptor-level guess: the Logitech + * keyboard driver claims every receiver PID that exposes a HID++ collection, + * so a receiver whose pairing slots have not been read yet lists keyboard-first + * (#1282). A mouse client that already owns the device is not a guess — the + * receiver answered with a sensor behind it — so it wins. Without that, a + * mouse on a LIGHTSPEED receiver keeps being listed as "USB Receiver · + * Keyboard" with keyboard art even while its mouse page is open (#1362). + */ +export async function listingClient(device: HIDDevice, active: SupportedClient | null): 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. @@ -127,7 +147,13 @@ 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); + // 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); } @@ -185,6 +211,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..d77f5265 --- /dev/null +++ b/src/device/mouse-receivers.ts @@ -0,0 +1,99 @@ +/** + * 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 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 → 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(":"); +} + +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, signature] of Object.entries(parsed)) { + if (typeof signature === "string") known.set(key, signature); + } + } + } 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. + } +} + +/** + * 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(","); +} + +/** + * 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 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 signature = interfaceSignature(device); + if (entries.get(key) === signature) return; + entries.set(key, signature); + 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); +}