From 50580571f6fe95574df9c2ec77741e53dc5a4504 Mon Sep 17 00:00:00 2001 From: Shubhadeep Date: Mon, 10 Aug 2026 09:56:27 +0530 Subject: [PATCH 1/2] feat(razer): mark WebHID-inaccessible models native-only The DeathAdder V4 Pro (0xbe/0xbf) and the Viper Ultimate wireless receiver (0x007b) carry their control channel on a collection Chrome classifies as a plain mouse and strips feature reports from, so sendFeatureReport fails on every interface no matter how many are granted (confirmed on hardware with Synapse stopped; the dongle case was also reported on openmouse#45). The registry entries stay so the native HAL transport still has a config, but RAZER_REGISTRY_FILTERS now excludes nativeOnly products so the WebHID picker stops offering them as dead entries. --- src/drivers/razer/devices.test.ts | 3 +++ src/drivers/vendors.ts | 7 ++++++- src/razer/devices.ts | 20 +++++++++++++++++--- 3 files changed, 26 insertions(+), 4 deletions(-) diff --git a/src/drivers/razer/devices.test.ts b/src/drivers/razer/devices.test.ts index 79590e29..b46f2c23 100644 --- a/src/drivers/razer/devices.test.ts +++ b/src/drivers/razer/devices.test.ts @@ -338,10 +338,13 @@ test("the catch-all filter does not widen a filter that was deliberately narrowe assert.equal(broad.has(productId), false, `0x${productId.toString(16)} is filtered twice`); } // Every registry id still reaches the picker, through one filter or another. + // The one exception is a `nativeOnly` product, whose control channel WebHID + // can never expose and which must therefore stay out of the picker. const offered = SUPPORTED_HID_FILTERS .filter((filter) => filter.vendorId === VENDOR_ID.razer && filter.productId !== undefined) .map((filter) => filter.productId); for (const productId of RAZER_PRODUCT_IDS) { + if (RAZER_PRODUCTS.get(productId)?.nativeOnly) continue; assert.ok(offered.includes(productId), `0x${productId.toString(16)} is not offered in the picker`); } }); diff --git a/src/drivers/vendors.ts b/src/drivers/vendors.ts index 92f504ca..cb4b5e50 100644 --- a/src/drivers/vendors.ts +++ b/src/drivers/vendors.ts @@ -3,7 +3,7 @@ import { LOGITECH_BOLT_PRODUCT_IDS, LOGITECH_DIRECT_PRODUCT_IDS, } from "@openmouse/protocol/logitech"; -import { RAZER_PRODUCT_IDS } from "@openmouse/protocol/razer-devices"; +import { RAZER_PRODUCTS, RAZER_PRODUCT_IDS } from "@openmouse/protocol/razer-devices"; import { NINJUTSO_LEGACY_MOUSE_PRODUCT_IDS, NINJUTSO_LEGACY_RECEIVER_PRODUCT_IDS, @@ -103,9 +103,14 @@ const RAZER_NARROWED_PRODUCT_IDS: ReadonlySet = new Set([ * revision, so the whole device is requested and the picker offers each * interface: the driver rejects the ones that cannot answer, and a model whose * first entry never replies is added again on another entry. + * + * Products flagged `nativeOnly` in the registry are left out: their control + * channel is on a Chrome-protected collection, so no granted interface can + * ever answer and offering them just produces a dead picker entry. */ export const RAZER_REGISTRY_FILTERS: HIDDeviceFilter[] = RAZER_PRODUCT_IDS .filter((productId) => !RAZER_NARROWED_PRODUCT_IDS.has(productId)) + .filter((productId) => !RAZER_PRODUCTS.get(productId)?.nativeOnly) .map((productId) => ({ vendorId: VENDOR_ID.razer, productId })); // The DeathAdder V2 keeps the Essential family's split interface layout, so it diff --git a/src/razer/devices.ts b/src/razer/devices.ts index 8572f773..42bb2308 100644 --- a/src/razer/devices.ts +++ b/src/razer/devices.ts @@ -111,6 +111,14 @@ export interface RazerProduct { asymmetricLiftOff: boolean; /** Confirmed against real hardware by this project. */ verified: boolean; + /** + * The control channel sits on a collection Chrome classifies as a plain mouse + * and strips feature reports from, so WebHID can never reach it no matter how + * many interfaces are granted. The registry entry stays so the native HAL + * transport still has a config, but `../vendors.ts` must not offer the id in + * the WebHID picker. + */ + nativeOnly?: boolean; } /** Everything a preset supplies. The transaction id is deliberately not here. */ @@ -415,7 +423,10 @@ const PRODUCT_DEFINITIONS: ReadonlyArray<[number, Omit Date: Mon, 10 Aug 2026 10:36:38 +0530 Subject: [PATCH 2/2] feat(razer): refuse nativeOnly models in the WebHID driver A previously granted nativeOnly device can still enumerate through navigator.hid.getDevices() (auto-reconnect, shared grants across origins), so isSupported must reject it regardless of the interface shape. This keeps the picker filter and the claim check consistent. --- src/drivers/razer/hid.test.ts | 20 ++++++++++++++++++++ src/drivers/razer/hid.ts | 4 ++++ 2 files changed, 24 insertions(+) diff --git a/src/drivers/razer/hid.test.ts b/src/drivers/razer/hid.test.ts index 58dd3205..7ba1b829 100644 --- a/src/drivers/razer/hid.test.ts +++ b/src/drivers/razer/hid.test.ts @@ -523,6 +523,26 @@ test("the DeathAdder V2 is accepted on either interface shape", () => { assert.equal(RazerHidClient.isSupported(vendor), true); }); +test("a nativeOnly model is never accepted, whatever the interface shape", () => { + // The DeathAdder V4 Pro 0x00be has its control channel on a collection the + // browser refuses to expose. A granted device may still enumerate through + // getDevices()/auto-reconnect, so isSupported must refuse it regardless. + // Arrange + const mouse = { + vendorId: 0x1532, + productId: 0x00be, + collections: [{ usagePage: 0x01, usage: 0x02, featureReports: [], children: [] }], + } as unknown as HIDDevice; + const vendor = { + ...mouse, + collections: [{ usagePage: 0xffc0, usage: 0x01, featureReports: [], children: [] }], + } as unknown as HIDDevice; + + // Act / Assert + assert.equal(RazerHidClient.isSupported(mouse), false); + assert.equal(RazerHidClient.isSupported(vendor), false); +}); + test("the DeathAdder V2 caps DPI at 20000", () => { // Arrange const { client } = fakeMouse({ tracking: 0, liftOff: 16, landing: 11, asymmetric: false }, { productId: 0x0084 }); diff --git a/src/drivers/razer/hid.ts b/src/drivers/razer/hid.ts index 169b0f4d..ea2d3363 100644 --- a/src/drivers/razer/hid.ts +++ b/src/drivers/razer/hid.ts @@ -116,6 +116,10 @@ export class RazerHidClient { static isSupported(device: HIDDevice): boolean { const product = RAZER_PRODUCTS.get(device.productId); if (device.vendorId !== VENDOR_ID.razer || !product) return false; + // A nativeOnly model's control channel sits on a collection the browser + // refuses to expose, so the WebHID driver must never claim it even when a + // previously granted device enumerates (auto-reconnect, shared grants). + if (product.nativeOnly) return false; return isMouseControlInterface(device) || (product.vendorControlInterface === true && hasVendorCollection(device)); }