From 6fac91959e51cc2e78781d4c4930d15b67ccd1ff Mon Sep 17 00:00:00 2001 From: Bryce Smith Date: Wed, 21 Oct 2026 21:06:28 -0400 Subject: [PATCH] fix(device): preserve working Razer connections on hotplug --- src/device/controller.ts | 32 ++++++++++++--- src/device/device-clients.test.ts | 66 ++++++++++++++++++++++++++++++- src/device/device-clients.ts | 9 ++++- 3 files changed, 100 insertions(+), 7 deletions(-) diff --git a/src/device/controller.ts b/src/device/controller.ts index 7ce0bbf8..2fd3725c 100644 --- a/src/device/controller.ts +++ b/src/device/controller.ts @@ -15,6 +15,7 @@ import { listLogicalDevices, logicalDeviceGroups, pickLogicalDevice, + prepareAutoConnectClient, type PulsarClient, type SupportedClient, } from "./device-clients"; @@ -3504,10 +3505,34 @@ async function loadKeyboardTrigger(client: KeyboardClient): Promise { } /** Queue a device activation so only one keyboard/mouse switch runs at a time. */ -function activateClient(target: SupportedClient | KeyboardClient): Promise { +function activateClient(target: SupportedClient | KeyboardClient, autoConnect = false): Promise { const run = activationQueue.then(async () => { activationInProgress = true; try { + if (autoConnect && !isKeyboardClient(target)) { + if (target.device === activeDevice) { + await refreshSidebar(); + return; + } + const prepared = await prepareAutoConnectClient(target); + if (!prepared) { + await refreshSidebar(); + return; + } + // A probe opens the candidate; discard it if unplugged while it answered. + if (prepared !== target) { + const devices = await navigator.hid?.getDevices().catch(() => []) ?? []; + if (!devices.includes(prepared.device)) { + if (activeDevice !== prepared.device) await prepared.close().catch(() => undefined); + await refreshSidebar(); + return; + } + } + target = prepared; + deviceStatusText = st("ctl.newDevice"); + readStatus = st("ctl.reading", { name: statusNameForClient(target) }); + emit(); + } if (isKeyboardClient(target)) await activateKeyboardClientNow(target); else await activateClientNow(target); } finally { @@ -3611,10 +3636,7 @@ function handleHidConnect(event: HIDConnectionEvent): void { return; } - deviceStatusText = st("ctl.newDevice"); - readStatus = st("ctl.reading", { name: statusNameForClient(client) }); - emit(); - void activateClient(client).catch((error: unknown) => { + void activateClient(client, true).catch((error: unknown) => { deviceStatusText = st("ctl.connFailed"); readStatus = error instanceof Error ? error.message : st("ctl.unableRead"); toastForError("Connection failed", error); diff --git a/src/device/device-clients.test.ts b/src/device/device-clients.test.ts index 462d219a..cb344ace 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 { createSupportedClient, listLogicalDevices, logicalDeviceGroups } from "./device-clients.ts"; +import { createSupportedClient, listLogicalDevices, logicalDeviceGroups, prepareAutoConnectClient } from "./device-clients.ts"; function supportedWebHidDevice(options: { vendorId?: number; @@ -108,3 +108,67 @@ test("Bridge devices keep their native per-physical-device identity", () => { assert.equal(logicalDeviceGroups(devices).length, 2); assert.equal(listLogicalDevices(devices).length, 2); }); + +function answeringBridgeRazer(key: string, answers: boolean, usages = [[0x01, 0x02]]): HIDDevice { + let reply = new Uint8Array(90); + const device = { + ...bridgeDevice(key, "Razer Basilisk V3 Pro 35K"), + productId: 0x00cc, + collections: usages.map(([usagePage, usage]) => ({ + usagePage, usage, inputReports: [], outputReports: [], featureReports: [], children: [], + })), + opened: false, + async open() { this.opened = true; }, + async close() { this.opened = false; }, + async sendFeatureReport(reportId: number, data: Uint8Array) { + assert.equal(reportId, 0); + assert.equal(data[6], 0x00); + assert.equal(data[7], 0x81, "automatic selection must only probe firmware"); + if (!answers) throw new Error("HidD_SetFeature: Incorrect function"); + reply = new Uint8Array(data); + reply[0] = 0x02; + }, + async receiveFeatureReport() { return new DataView(reply.buffer); }, + }; + return device as unknown as HIDDevice; +} + +test("automatic Bridge selection rejects a dead Razer sibling without closing the working interface", async (t) => { + Object.defineProperty(globalThis, "window", { configurable: true, value: { setTimeout } }); + t.after(() => Reflect.deleteProperty(globalThis, "window")); + const working = answeringBridgeRazer("working", true); + await working.open(); + const sibling = answeringBridgeRazer("sibling", false, [[0x01, 0x80], [0x0c, 0x01]]); + const candidate = createSupportedClient(sibling); + assert.ok(candidate); + + assert.equal(await prepareAutoConnectClient(candidate), null); + assert.equal(sibling.opened, false); + assert.equal(working.opened, true); +}); + +test("automatic Bridge selection keeps answering Razer interfaces regardless of collection shape", async (t) => { + Object.defineProperty(globalThis, "window", { configurable: true, value: { setTimeout } }); + t.after(() => Reflect.deleteProperty(globalThis, "window")); + for (const usages of [[[0x01, 0x02]], [[0x0c, 0x01]], []]) { + const device = answeringBridgeRazer("working", true, usages); + const candidate = createSupportedClient(device); + assert.ok(candidate); + const prepared = await prepareAutoConnectClient(candidate); + assert.ok(prepared); + assert.equal(prepared.device, device); + assert.equal(device.opened, true); + await prepared.close(); + } +}); + +test("automatic selection leaves specialized Razer and WebHID clients on their existing paths", async () => { + for (const device of [ + { ...bridgeDevice("v4"), productId: 0x00e5 }, + supportedWebHidDevice(), + ]) { + const client = createSupportedClient(device as HIDDevice); + assert.ok(client); + assert.equal(await prepareAutoConnectClient(client), client); + } +}); diff --git a/src/device/device-clients.ts b/src/device/device-clients.ts index 6a1fbcdc..db18a7d9 100644 --- a/src/device/device-clients.ts +++ b/src/device/device-clients.ts @@ -6,7 +6,7 @@ import { COBRA_PRODUCT_ID, RazerCobraHidClient, } from "@openmouse/protocol/drivers/razer/cobra-hid"; -import { RazerHidClient } from "@openmouse/protocol/drivers/razer/hid"; +import { RazerHidClient, razerProbeControlInterface } from "@openmouse/protocol/drivers/razer/hid"; import { RazerViperHidClient, VIPER_PRODUCT_ID, @@ -52,6 +52,13 @@ export function createSupportedClient(device: HIDDevice): SupportedClient | null return registryCreateSupportedClient(device) ?? bridgeRazerClient(device); } +export async function prepareAutoConnectClient(client: SupportedClient): Promise { + if (!(client instanceof RazerHidClient) + || (client.device as NativeBridgeDevice).openMouseTransport !== "bridge") return client; + // Bridge can expose a sibling with the same VID/PID that cannot answer commands. + return razerProbeControlInterface([client.device]); +} + export type { KeyboardClient }; export function isKeyboardClient(client: SupportedClient | KeyboardClient): client is KeyboardClient {