From f2bec6850b1c01e8edd0f2953c780b4a092f20fc Mon Sep 17 00:00:00 2001 From: marcmy <21000174+marcmy@users.noreply.github.com> Date: Sun, 11 Oct 2026 21:35:53 -0400 Subject: [PATCH 1/3] Migrate USB discovery to v3 while preserving mouse backends --- .github/workflows/ci.yml | 35 +++++ docs/usb-v3-hardware-test.md | 35 +++++ forge.config.js | 4 +- package-lock.json | 200 +++++++++++++++++++++++--- package.json | 5 +- scripts/smoke-usb.cjs | 52 +++++++ src/lib/mouseBackends/runtime.js | 10 +- src/lib/mouseBackends/startupProbe.js | 6 +- src/lib/mouseBackends/usbDiscovery.js | 11 ++ src/main.js | 2 +- test/mouseBackendRuntime.test.js | 38 ++--- test/pulsarStartupProbe.test.js | 6 +- test/run-tests.js | 1 + test/usbV3Discovery.test.js | 98 +++++++++++++ 14 files changed, 447 insertions(+), 56 deletions(-) create mode 100644 docs/usb-v3-hardware-test.md create mode 100644 scripts/smoke-usb.cjs create mode 100644 src/lib/mouseBackends/usbDiscovery.js create mode 100644 test/usbV3Discovery.test.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4360b629..7e474c5d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,6 +33,8 @@ jobs: node --check src/bootstrap.js node --check src/main.js - run: npm test + - name: Smoke-test real USB v3 native enumeration + run: node scripts/smoke-usb.cjs - name: Smoke-test electron-store ESM with real Electron run: npx --no-install electron scripts/smoke-electron-store.cjs @@ -158,6 +160,30 @@ jobs: - name: Build Windows installer run: npm run make + - name: Smoke-test USB v3 from packaged Electron + shell: pwsh + run: | + $packageDirs = @(Get-ChildItem -LiteralPath 'out' -Directory | Where-Object { $_.Name -like '*-win32-x64' }) + if ($packageDirs.Count -ne 1) { + throw "Expected exactly one packaged win32-x64 directory; found $($packageDirs.Count)" + } + $packageRoot = $packageDirs[0].FullName + $exe = Join-Path $packageRoot 'razerautopollingrate.exe' + $script = Join-Path $packageRoot 'resources\app\scripts\smoke-usb.cjs' + if (-not (Test-Path -LiteralPath $script -PathType Leaf)) { + throw "Packaged USB smoke script missing: $script" + } + $env:ELECTRON_RUN_AS_NODE = '1' + try { + $process = Start-Process -FilePath $exe -ArgumentList ('"' + $script + '"') -Wait -PassThru -NoNewWindow + if ($process.ExitCode -ne 0) { + throw "Packaged USB v3 smoke test failed with exit code $($process.ExitCode)" + } + } + finally { + Remove-Item Env:ELECTRON_RUN_AS_NODE -ErrorAction SilentlyContinue + } + - name: Generate SHA256 hashes shell: pwsh run: | @@ -204,6 +230,15 @@ jobs: if-no-files-found: error retention-days: 14 + - name: Upload portable Windows test build + if: github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch' + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 + with: + name: RazerAutoPollingRate-Windows-portable-${{ github.sha }} + path: out/*-win32-x64/** + if-no-files-found: error + retention-days: 14 + - name: Upload Scoop package if: github.event_name == 'push' && github.ref == 'refs/heads/main' && steps.rolling.outputs.rolling_release == 'true' uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 diff --git a/docs/usb-v3-hardware-test.md b/docs/usb-v3-hardware-test.md new file mode 100644 index 00000000..cc63a0cd --- /dev/null +++ b/docs/usb-v3-hardware-test.md @@ -0,0 +1,35 @@ +# USB v3 test build + +The migration uses `usb.getDevices()` for asynchronous enumeration and keeps +the current selection rules and hardware transports. Windows CrazyLight +communication continues through the existing HID bridge. USB enumeration does +not open or claim devices. No USB v2 fallback is shipped. + +## Run the portable build + +Download the `RazerAutoPollingRate-Windows-portable-` artifact from the +PR's successful CI run. Exit the installed app from its tray menu, extract the +ZIP completely, and run `razerautopollingrate.exe` in the extracted directory. +It uses the existing saved settings. Run only one copy at a time. + +## Pulsar X2 CrazyLight checks + +1. Wireless: verify the receiver and active mouse are recognized; read the + profile, polling rate, battery and Turbo status. Confirm no `listDevices` + or native-module load error appears. +2. Exercise an existing game rule, then return to the desktop. Verify the + polling rate changes and reads back correctly, and Turbo behaves as before. + Record the starting rate and restore it after testing. +3. Disconnect and reconnect the receiver. Confirm background checks recover + and the tray reflects the mouse again after movement. +4. Sleep and wake the mouse and the PC. Confirm detection and automation resume. +5. Wired: confirm the wired mouse takes priority over its attached receiver; + its reported rate and battery remain visible, with the existing read-only + behavior. Unplug the cable and confirm wireless selection resumes. + +If a Razer mouse is available, also check its discovery, rate readback, rate +changes, reconnect and sleep/wake. CI cannot validate physical USB transfers. + +To return to the stable build, exit the portable copy and start the installed +app again. Report the tested commit, connection mode and diagnostic log if any +check fails. Keep the PR unmerged until hardware testing succeeds. \ No newline at end of file diff --git a/forge.config.js b/forge.config.js index e4994cd9..0011d56e 100644 --- a/forge.config.js +++ b/forge.config.js @@ -3,7 +3,9 @@ module.exports = { icon: 'src/assets/app', electronZipDir: process.env.ELECTRON_ZIP_DIR || undefined }, - rebuildConfig: {}, + // USB v3 ships its platform N-API binary in an optional dependency. + // Keep node-hid's existing rebuild behavior, but do not recompile USB. + rebuildConfig: { ignoreModules: ['usb'] }, makers: [ { name: '@electron-forge/maker-squirrel', diff --git a/package-lock.json b/package-lock.json index df16059f..5adaa237 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,7 @@ "electron-squirrel-startup": "^1.0.0", "electron-store": "^11.0.2", "node-hid": "3.4.0", - "usb": "^2.11.0" + "usb": "^3.2.1" }, "devDependencies": { "@electron-forge/cli": "8.0.1", @@ -669,6 +669,166 @@ "node": ">= 12.13.0" } }, + "node_modules/@node-usb/usb-darwin-arm64": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@node-usb/usb-darwin-arm64/-/usb-darwin-arm64-3.2.1.tgz", + "integrity": "sha512-t4IzR3en2dRLaY7aFrzmTBywtsQkVOEiLvNAoqMVag7JrsRu9iYPKYwBnudQ7vDvuPnhMxv6r4PqJD6EBC4sEA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.22.0 < 13 || >= 14.17.0 < 15 || >= 15.12.0 < 16 || >= 16.0.0" + } + }, + "node_modules/@node-usb/usb-darwin-x64": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@node-usb/usb-darwin-x64/-/usb-darwin-x64-3.2.1.tgz", + "integrity": "sha512-rgDmI5d7BeEs3tAv5XppJ8wi7Ft4afqVL06MIPKOsZHtR38NQhwWIzr3rXkkh2I6e05wuuPRQ3ApeGcbQQxSHA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.22.0 < 13 || >= 14.17.0 < 15 || >= 15.12.0 < 16 || >= 16.0.0" + } + }, + "node_modules/@node-usb/usb-linux-arm-gnueabihf": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@node-usb/usb-linux-arm-gnueabihf/-/usb-linux-arm-gnueabihf-3.2.1.tgz", + "integrity": "sha512-EMp0j3089f0TPV5pRCE7fNey9678fJ7ep7SsRBq6JQ8dU+Ow2x1cH2H3GMiXf9ibbyygCtxDy10PbllizJGZEw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.22.0 < 13 || >= 14.17.0 < 15 || >= 15.12.0 < 16 || >= 16.0.0" + } + }, + "node_modules/@node-usb/usb-linux-arm64-gnu": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@node-usb/usb-linux-arm64-gnu/-/usb-linux-arm64-gnu-3.2.1.tgz", + "integrity": "sha512-Va2Pu+CAfnLFI1RzfABPZHKAAkOnvrZ2jjWSabG3RgIAONOAWA7m/t0xBHnGsb+6l8UD7IcGAHt6UNN9WPNz+g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.22.0 < 13 || >= 14.17.0 < 15 || >= 15.12.0 < 16 || >= 16.0.0" + } + }, + "node_modules/@node-usb/usb-linux-arm64-musl": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@node-usb/usb-linux-arm64-musl/-/usb-linux-arm64-musl-3.2.1.tgz", + "integrity": "sha512-UrjSpuWSWUL3Bsew/PC9JmyNkNzdW+odMhPLHcGsxRaZ2HiXEK5o3Xax+5X9Tz/V58MqaB3uIJ3/hUr0PkH8WQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.22.0 < 13 || >= 14.17.0 < 15 || >= 15.12.0 < 16 || >= 16.0.0" + } + }, + "node_modules/@node-usb/usb-linux-x64-gnu": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@node-usb/usb-linux-x64-gnu/-/usb-linux-x64-gnu-3.2.1.tgz", + "integrity": "sha512-9q4UxzAIwSXCVr/r8Uy6YEMeHh0HymNBs8OEYz/e21bVSYJO01Q79ItZPbXIc3tOApEJ0sUutnZmGgfO03lpyA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.22.0 < 13 || >= 14.17.0 < 15 || >= 15.12.0 < 16 || >= 16.0.0" + } + }, + "node_modules/@node-usb/usb-linux-x64-musl": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@node-usb/usb-linux-x64-musl/-/usb-linux-x64-musl-3.2.1.tgz", + "integrity": "sha512-mMKMRV3+eIBku+JzcWR+LSSWnB3rCvE3ZJQ03K+1ClKL4RlseFy645g6EsubN/5Dfs6pEBT/b7vt+2XODnsuEw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.22.0 < 13 || >= 14.17.0 < 15 || >= 15.12.0 < 16 || >= 16.0.0" + } + }, + "node_modules/@node-usb/usb-win32-arm64-msvc": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@node-usb/usb-win32-arm64-msvc/-/usb-win32-arm64-msvc-3.2.1.tgz", + "integrity": "sha512-SBUxcBglxL6sMFc+5UnWaBx8YMdQ1/Tg/PCePwT4c+NSiP+Ee1Ze8cvCPkW5tR5tjLdHc46Si6suweFi5AX+yA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.22.0 < 13 || >= 14.17.0 < 15 || >= 15.12.0 < 16 || >= 16.0.0" + } + }, + "node_modules/@node-usb/usb-win32-ia32-msvc": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@node-usb/usb-win32-ia32-msvc/-/usb-win32-ia32-msvc-3.2.1.tgz", + "integrity": "sha512-9yjDIzw0fVTA2MNtd8O8wkBB6aS7ePLRH/kRSCbht+uxGrTnD+X/ZyfjpCibTn6BqK/NSdo6SGqRlYEu5PF6rA==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.22.0 < 13 || >= 14.17.0 < 15 || >= 15.12.0 < 16 || >= 16.0.0" + } + }, + "node_modules/@node-usb/usb-win32-x64-msvc": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@node-usb/usb-win32-x64-msvc/-/usb-win32-x64-msvc-3.2.1.tgz", + "integrity": "sha512-yOh9d9ystwKqnkX1feOa+jPu4OxmGLKHoSLoU5iD/ifNLXsQemKiWPchhBguTloXLKHZ6HiGRhgAoR6FGTc7OQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.22.0 < 13 || >= 14.17.0 < 15 || >= 15.12.0 < 16 || >= 16.0.0" + } + }, "node_modules/@octokit/auth-token": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-4.0.0.tgz", @@ -2250,17 +2410,6 @@ "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/node-gyp-build": { - "version": "4.8.4", - "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", - "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", - "license": "MIT", - "bin": { - "node-gyp-build": "bin.js", - "node-gyp-build-optional": "optional.js", - "node-gyp-build-test": "build-test.js" - } - }, "node_modules/node-gyp/node_modules/undici": { "version": "6.29.0", "resolved": "https://registry.npmjs.org/undici/-/undici-6.29.0.tgz", @@ -2890,18 +3039,27 @@ } }, "node_modules/usb": { - "version": "2.18.0", - "resolved": "https://registry.npmjs.org/usb/-/usb-2.18.0.tgz", - "integrity": "sha512-klAJPUTaSxRvTgrIh7om6UTrgRxdzioD+rJc/MaoiN8+OGdqRzai39tR00asnoCesPNikItWB9zBZoo0pJsWaA==", - "hasInstallScript": true, + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/usb/-/usb-3.2.1.tgz", + "integrity": "sha512-A/H2ImRjhEYz3V3EpidTkpdQnVW2iBH1GNaAZjF01O2Upz3aGbI+1cV0el49sF7931xOU4hB6fcNO6/YN+/POg==", "license": "MIT", "dependencies": { - "@types/w3c-web-usb": "^1.0.6", - "node-addon-api": "^8.0.0", - "node-gyp-build": "^4.5.0" + "@types/w3c-web-usb": "^1.0.14" }, "engines": { - "node": ">=12.22.0 <13.0 || >=14.17.0" + "node": ">= 12.22.0 < 13 || >= 14.17.0 < 15 || >= 15.12.0 < 16 || >= 16.0.0" + }, + "optionalDependencies": { + "@node-usb/usb-darwin-arm64": "3.2.1", + "@node-usb/usb-darwin-x64": "3.2.1", + "@node-usb/usb-linux-arm-gnueabihf": "3.2.1", + "@node-usb/usb-linux-arm64-gnu": "3.2.1", + "@node-usb/usb-linux-arm64-musl": "3.2.1", + "@node-usb/usb-linux-x64-gnu": "3.2.1", + "@node-usb/usb-linux-x64-musl": "3.2.1", + "@node-usb/usb-win32-arm64-msvc": "3.2.1", + "@node-usb/usb-win32-ia32-msvc": "3.2.1", + "@node-usb/usb-win32-x64-msvc": "3.2.1" } }, "node_modules/when-exit": { @@ -3032,4 +3190,4 @@ "license": "MIT" } } -} +} \ No newline at end of file diff --git a/package.json b/package.json index 5cde05ee..08c38755 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,7 @@ "electron-squirrel-startup": "^1.0.0", "electron-store": "^11.0.2", "node-hid": "3.4.0", - "usb": "^2.11.0" + "usb": "^3.2.1" }, "devDependencies": { "@electron-forge/cli": "8.0.1", @@ -44,7 +44,6 @@ "extract-zip": "npm:@electron-internal/extract-zip@^1.0.4" }, "allowScripts": { - "usb@2.18.0": true, "electron-winstaller@5.4.0": true } -} +} \ No newline at end of file diff --git a/scripts/smoke-usb.cjs b/scripts/smoke-usb.cjs new file mode 100644 index 00000000..3e699c9f --- /dev/null +++ b/scripts/smoke-usb.cjs @@ -0,0 +1,52 @@ +'use strict'; + +// Run from source with Node, then from resources/app with the packaged +// executable and ELECTRON_RUN_AS_NODE=1. Never open/claim a device or write. +const assert = require('node:assert/strict'); +const { version } = require('usb/package.json'); +const { usb, WebUSB } = require('usb'); +const { listUsbDevices } = require('../src/lib/mouseBackends/usbDiscovery'); +const { createPreferredMouseBackend } = require('../src/lib/mouseBackends/runtime'); +const { getUsbIdentity } = require('../src/lib/mouseBackends/discovery'); + +async function smoke() { + assert.match(version, /^3\./); + assert.equal(typeof usb.getDevices, 'function'); + assert.equal(typeof WebUSB, 'function'); + // This reaches the actual platform binary, including in the packaged app. + const devices = await listUsbDevices(); + assert.ok(Array.isArray(devices)); + for (const device of devices) { + const identity = getUsbIdentity(device); + assert.ok(Number.isInteger(identity.vendorId)); + assert.ok(Number.isInteger(identity.productId)); + } + + // Exercise production selection and backend construction without a mouse + // attached to CI; neither backend sends commands until discover/open. + const pulsar = await createPreferredMouseBackend({ + getDevices: async () => [ + { vendorId: 0x3710, productId: 0x5406 }, + { vendorId: 0x3710, productId: 0x3524, serialNumber: 'smoke-wired' }, + ], + }); + assert.equal(pulsar.selectedMouse.productId, 0x3524); + assert.equal(pulsar.backend.id, 'pulsar-x2-crazylight'); + assert.equal(pulsar.backend.canWrite, false); + + const razer = await createPreferredMouseBackend({ + getDevices: async () => [ + { vendorId: 0x3710, productId: 0x5406 }, + { vendorId: 0x1532, productId: 0x00e5 }, + ], + }); + assert.equal(razer.path, 'razer'); + assert.equal(razer.backend.id, 'razer'); + await assert.rejects(() => createPreferredMouseBackend({ getDevices: async () => [] }), /No supported/); + console.log(`USB ${version} native enumeration and backend selection OK (${devices.length} attached devices; Electron ${process.versions.electron || 'not used'})`); +} + +smoke().catch((error) => { + console.error('USB v3 smoke test failed:', error); + process.exitCode = 1; +}); \ No newline at end of file diff --git a/src/lib/mouseBackends/runtime.js b/src/lib/mouseBackends/runtime.js index 8476c1c4..311a0010 100644 --- a/src/lib/mouseBackends/runtime.js +++ b/src/lib/mouseBackends/runtime.js @@ -1,6 +1,6 @@ 'use strict'; -const { getDeviceList } = require('usb'); +const { listUsbDevices } = require('./usbDiscovery'); const { classifyUsbDevices, choosePreferredMousePath } = require('./discovery'); const { createPulsarCrazyLightBackend } = require('./pulsarCrazyLight'); const { createRazerBackend } = require('./razer'); @@ -92,14 +92,14 @@ function fallbackMouseFromDiscovery(discovery) { return null; } -function createPreferredMouseBackend(options = {}) { - const listDevices = options.getDeviceList || getDeviceList; +async function createPreferredMouseBackend(options = {}) { + const listDevices = options.getDevices || listUsbDevices; const makeRazerBackend = options.createRazerBackend || createRazerBackend; const makePulsarBackend = options.createPulsarBackend || createPulsarCrazyLightBackend; const log = options.log || (() => {}); const onDiagnostic = options.onDiagnostic || (() => {}); - const discovery = classifyUsbDevices(listDevices()); + const discovery = classifyUsbDevices(await listDevices()); for (const entry of discovery.unknownPulsar) { onDiagnostic('pulsar_usb_detected', { @@ -111,7 +111,7 @@ function createPreferredMouseBackend(options = {}) { const fallbackMouse = fallbackMouseFromDiscovery(discovery); const activityTracker = options.activityTracker - || (!options.getDeviceList ? getSharedMouseActivityTracker({ + || (!options.getDevices ? getSharedMouseActivityTracker({ log, onDiagnostic, onActiveMouseChanged: options.onActiveMouseChanged, diff --git a/src/lib/mouseBackends/startupProbe.js b/src/lib/mouseBackends/startupProbe.js index 27a6763d..19ecfb1e 100644 --- a/src/lib/mouseBackends/startupProbe.js +++ b/src/lib/mouseBackends/startupProbe.js @@ -1,6 +1,6 @@ 'use strict'; -const { getDeviceList } = require('usb'); +const { listUsbDevices } = require('./usbDiscovery'); const { classifyUsbDevices } = require('./discovery'); const { createPulsarCrazyLightBackend } = require('./pulsarCrazyLight'); const { crazyLightConnection } = require('./pulsarCrazyLightIdentity'); @@ -16,11 +16,11 @@ function formatUsbId(identity) { } async function runCrazyLightProbe(options = {}) { - const listDevices = options.getDeviceList || getDeviceList; + const listDevices = options.getDevices || listUsbDevices; const makeBackend = options.createBackend || createPulsarCrazyLightBackend; const log = options.log || (() => {}); - const devices = listDevices(); + const devices = await listDevices(); const discovery = classifyUsbDevices(devices); for (const entry of discovery.unknownPulsar) { diff --git a/src/lib/mouseBackends/usbDiscovery.js b/src/lib/mouseBackends/usbDiscovery.js new file mode 100644 index 00000000..6b2a5a4d --- /dev/null +++ b/src/lib/mouseBackends/usbDiscovery.js @@ -0,0 +1,11 @@ +'use strict'; + +const { usb } = require('usb'); + +// USB v3's unrestricted instance lists attached devices without opening or +// claiming them. Keep enumeration fresh for each check so reconnects are seen. +async function listUsbDevices() { + return usb.getDevices(); +} + +module.exports = { listUsbDevices }; \ No newline at end of file diff --git a/src/main.js b/src/main.js index 1047eb1c..a0ce5e1b 100644 --- a/src/main.js +++ b/src/main.js @@ -1867,7 +1867,7 @@ async function checkPollingRate(firstRun) { return; } - const backendSelection = createPreferredMouseBackend({ + const backendSelection = await createPreferredMouseBackend({ log, onDiagnostic: (event, details) => recordDiagnosticEvent(event, details, { verbose: true }), }); diff --git a/test/mouseBackendRuntime.test.js b/test/mouseBackendRuntime.test.js index 64c8c9c9..369ad708 100644 --- a/test/mouseBackendRuntime.test.js +++ b/test/mouseBackendRuntime.test.js @@ -20,10 +20,10 @@ function fakeBackend(id) { return { id, canWrite: true }; } -test('runtime selects the wired CrazyLight ahead of its receiver and passes its exact identity', () => { +test('runtime selects the wired CrazyLight ahead of its receiver and passes its exact identity', async () => { let created; - const result = createPreferredMouseBackend({ - getDeviceList: () => [rawUsb(0x3710, 0x5406), rawUsb(0x3710, 0x3524, 'wire-serial')], + const result = await createPreferredMouseBackend({ + getDevices: () => [rawUsb(0x3710, 0x5406), rawUsb(0x3710, 0x3524, 'wire-serial')], createPulsarBackend: (options) => { created = options; return fakeBackend('pulsar-x2-crazylight'); }, }); assert.equal(result.selectedMouse.productId, 0x3524); @@ -31,10 +31,10 @@ test('runtime selects the wired CrazyLight ahead of its receiver and passes its assert.equal(created.preferredSerialNumber, 'wire-serial'); }); -test('runtime falls back to the first supported Razer identity when no activity is known', () => { +test('runtime falls back to the first supported Razer identity when no activity is known', async () => { const created = []; - const result = createPreferredMouseBackend({ - getDeviceList: () => [ + const result = await createPreferredMouseBackend({ + getDevices: () => [ rawUsb(0x3710, 0x5406), rawUsb(0x1532, 0x00e5), ], @@ -56,10 +56,10 @@ test('runtime falls back to the first supported Razer identity when no activity assert.equal(created[0][1].preferredProductId, 0x00e5); }); -test('runtime targets the exact active Razer model instead of any Razer dongle', () => { +test('runtime targets the exact active Razer model instead of any Razer dongle', async () => { const created = []; - const result = createPreferredMouseBackend({ - getDeviceList: () => [ + const result = await createPreferredMouseBackend({ + getDevices: () => [ rawUsb(0x1532, 0x00e5), rawUsb(0x1532, 0x00be), rawUsb(0x3710, 0x5406), @@ -91,10 +91,10 @@ test('runtime targets the exact active Razer model instead of any Razer dongle', assert.equal(created[0][1].preferredSerialNumber, 'deathadder-1'); }); -test('runtime prefers the Pulsar identity selected by mouse activity', () => { +test('runtime prefers the Pulsar identity selected by mouse activity', async () => { const created = []; - const result = createPreferredMouseBackend({ - getDeviceList: () => [ + const result = await createPreferredMouseBackend({ + getDevices: () => [ rawUsb(0x3710, 0x5406), rawUsb(0x1532, 0x00e5), ], @@ -122,10 +122,10 @@ test('runtime prefers the Pulsar identity selected by mouse activity', () => { assert.equal(created.filter(([kind]) => kind === 'pulsar').length, 1); }); -test('runtime enables writes only for the hardware-validated CrazyLight identity', () => { +test('runtime enables writes only for the hardware-validated CrazyLight identity', async () => { let factoryOptions = null; - const result = createPreferredMouseBackend({ - getDeviceList: () => [rawUsb(0x3710, 0x5406)], + const result = await createPreferredMouseBackend({ + getDevices: () => [rawUsb(0x3710, 0x5406)], createRazerBackend: () => { throw new Error('Razer backend must not be created'); }, @@ -140,12 +140,12 @@ test('runtime enables writes only for the hardware-validated CrazyLight identity assert.equal(factoryOptions.allowHardwareValidationWrites, true); }); -test('runtime never constructs a protocol backend for an unknown Pulsar PID', () => { +test('runtime never constructs a protocol backend for an unknown Pulsar PID', async () => { const diagnostics = []; let factoryCalls = 0; - assert.throws(() => createPreferredMouseBackend({ - getDeviceList: () => [rawUsb(0x3710, 0x9999)], + await assert.rejects(() => createPreferredMouseBackend({ + getDevices: () => [rawUsb(0x3710, 0x9999)], createRazerBackend: () => { factoryCalls += 1; return fakeBackend('razer'); @@ -208,7 +208,7 @@ test('presentation-aware backend keeps probing but hides identity after a failed assert.equal(backend.deviceInfo.productName, null); }); -test('presentation-aware backend becomes visible again only after activity clears the latch', () => { +test('presentation-aware backend becomes visible again only after activity clears the latch', async () => { let suppressed = true; const selectedMouse = { backend: 'razer', diff --git a/test/pulsarStartupProbe.test.js b/test/pulsarStartupProbe.test.js index b7393763..f7d5a187 100644 --- a/test/pulsarStartupProbe.test.js +++ b/test/pulsarStartupProbe.test.js @@ -17,7 +17,7 @@ test('startup probe reports unknown Pulsar identity without constructing a proto const lines = []; const result = await runCrazyLightProbe({ - getDeviceList: () => [rawUsb(0x3710, 0x7777)], + getDevices: () => [rawUsb(0x3710, 0x7777)], createBackend: () => { backendConstructed = true; throw new Error('must not be called'); @@ -36,7 +36,7 @@ test('startup probe queries validated CrazyLight and reports profile and polling let probeCalls = 0; const result = await runCrazyLightProbe({ - getDeviceList: () => [rawUsb(0x3710, 0x5406)], + getDevices: () => [rawUsb(0x3710, 0x5406)], createBackend: () => ({ async probe() { probeCalls += 1; @@ -63,7 +63,7 @@ test('startup probe queries validated CrazyLight and reports profile and polling test('startup probe preserves probe errors as diagnostics instead of hiding them', async () => { const lines = []; const result = await runCrazyLightProbe({ - getDeviceList: () => [rawUsb(0x3710, 0x5406)], + getDevices: () => [rawUsb(0x3710, 0x5406)], createBackend: () => ({ async probe() { throw new Error('claimInterface failed'); diff --git a/test/run-tests.js b/test/run-tests.js index 06854867..bc61d524 100644 --- a/test/run-tests.js +++ b/test/run-tests.js @@ -21,6 +21,7 @@ require('./pulsarCrazyLightHid.test'); require('./pulsarCrazyLightProtocol.test'); require('./pulsarDiscovery.test'); require('./pulsarStartupProbe.test'); +require('./usbV3Discovery.test'); require('./pulsarWriteValidation.test'); require('./razerBackend.test'); require('./rates.test'); diff --git a/test/usbV3Discovery.test.js b/test/usbV3Discovery.test.js new file mode 100644 index 00000000..57b595e6 --- /dev/null +++ b/test/usbV3Discovery.test.js @@ -0,0 +1,98 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { createPreferredMouseBackend } = require('../src/lib/mouseBackends/runtime'); +const { runCrazyLightProbe } = require('../src/lib/mouseBackends/startupProbe'); + +// USB v3 exposes WebUSB identity fields, not legacy deviceDescriptor fields. +const wired = { vendorId: 0x3710, productId: 0x3524, serialNumber: 'wired-mouse' }; +const receiver = { vendorId: 0x3710, productId: 0x5406, serialNumber: 'receiver' }; +const razer = { vendorId: 0x1532, productId: 0x00e5, serialNumber: 'razer-mouse' }; + +test('runtime waits for USB v3 discovery before selecting the wired CrazyLight', async () => { + let finishDiscovery; + let constructed = false; + const selection = createPreferredMouseBackend({ + getDevices: () => new Promise((resolve) => { finishDiscovery = resolve; }), + activityTracker: {}, + createPulsarBackend: (options) => { + constructed = true; + return { id: 'pulsar', selectedProduct: options.preferredProductId, selectedSerial: options.preferredSerialNumber }; + }, + }); + assert.equal(constructed, false); + finishDiscovery([receiver, wired]); + const result = await selection; + assert.equal(result.path, 'pulsar'); + assert.equal(result.selectedMouse.productId, 0x3524); + assert.equal(result.backend.selectedProduct, 0x3524); + assert.equal(result.backend.selectedSerial, 'wired-mouse'); +}); + +test('USB v3 enumeration preserves Razer priority and the active mouse override', async () => { + const options = { + getDevices: async () => [receiver, razer], + activityTracker: {}, + createRazerBackend: () => ({ id: 'razer' }), + createPulsarBackend: () => ({ id: 'pulsar' }), + }; + assert.equal((await createPreferredMouseBackend(options)).path, 'razer'); + const active = await createPreferredMouseBackend({ + ...options, + activityTracker: { choosePreferredMouse: () => ({ backend: 'pulsar', ...receiver }) }, + }); + assert.equal(active.path, 'pulsar'); + assert.equal(active.selectedMouse.serialNumber, 'receiver'); +}); + +test('each runtime selection refreshes devices after a disconnect or reconnect', async () => { + let devices = [receiver]; + const options = { + getDevices: async () => devices, + activityTracker: {}, + createPulsarBackend: () => ({ id: 'pulsar' }), + }; + assert.equal((await createPreferredMouseBackend(options)).selectedMouse.productId, 0x5406); + devices = []; + await assert.rejects(() => createPreferredMouseBackend(options), /No supported Razer or Pulsar/); + devices = [wired]; + assert.equal((await createPreferredMouseBackend(options)).selectedMouse.productId, 0x3524); +}); + +test('USB v3 startup probe selects the wired identity and remains read-only', async () => { + const result = await runCrazyLightProbe({ + getDevices: async () => [receiver, wired], + createBackend: (options) => { + assert.equal(options.preferredProductId, 0x3524); + assert.notEqual(options.allowHardwareValidationWrites, true); + return { probe: async () => ({ name: 'Pulsar X2 CrazyLight', activeProfile: 2, pollingRate: 1000 }) }; + }, + }); + assert.equal(result.error, null); + assert.equal(result.probe.pollingRate, 1000); +}); + +test('USB v3 discovery failures remain visible and do not construct a backend', async () => { + const failure = new Error('native USB enumeration failed'); + const options = { + getDevices: async () => { throw failure; }, + activityTracker: {}, + createRazerBackend: () => assert.fail('must not construct a Razer backend'), + createPulsarBackend: () => assert.fail('must not construct a Pulsar backend'), + createBackend: () => assert.fail('must not construct a probe backend'), + }; + await assert.rejects(() => createPreferredMouseBackend(options), (error) => error === failure); + await assert.rejects(() => runCrazyLightProbe(options), (error) => error === failure); +}); + +test('unknown USB v3 Pulsar identities do not receive protocol commands', async () => { + const options = { + getDevices: async () => [{ vendorId: 0x3710, productId: 0x9999 }], + activityTracker: {}, + createPulsarBackend: () => assert.fail('unsupported mouse must not receive commands'), + createBackend: () => assert.fail('unsupported mouse must not be probed'), + }; + await assert.rejects(() => createPreferredMouseBackend(options), /No supported Razer or Pulsar/); + const result = await runCrazyLightProbe(options); + assert.equal(result.discovery.unknownPulsar.length, 1); + assert.equal(result.probe, null); +}); \ No newline at end of file From ca884344d63fc0fd42b69c9d6c3a6ee707582890 Mon Sep 17 00:00:00 2001 From: marcmy <21000174+marcmy@users.noreply.github.com> Date: Sun, 11 Oct 2026 21:42:59 -0400 Subject: [PATCH 2/3] Preserve Windows HID access and descriptor-only discovery under USB v3 --- docs/usb-v3-hardware-test.md | 8 +- package-lock.json | 2 +- package.json | 2 +- scripts/smoke-usb.cjs | 5 +- src/lib/mouseBackends/razer.js | 14 +++- src/lib/mouseBackends/razerHid.js | 104 ++++++++++++++++++++++++ src/lib/mouseBackends/usbDiscovery.js | 8 +- test/razerBackend.test.js | 111 ++++++++++++++++++++++++++ test/run-tests.js | 1 + test/usbDiscovery.test.js | 22 +++++ test/usbV3Discovery.test.js | 2 +- 11 files changed, 267 insertions(+), 12 deletions(-) create mode 100644 src/lib/mouseBackends/razerHid.js create mode 100644 test/usbDiscovery.test.js diff --git a/docs/usb-v3-hardware-test.md b/docs/usb-v3-hardware-test.md index cc63a0cd..77163acc 100644 --- a/docs/usb-v3-hardware-test.md +++ b/docs/usb-v3-hardware-test.md @@ -2,8 +2,10 @@ The migration uses `usb.getDevices()` for asynchronous enumeration and keeps the current selection rules and hardware transports. Windows CrazyLight -communication continues through the existing HID bridge. USB enumeration does -not open or claim devices. No USB v2 fallback is shipped. +communication continues through the existing HID bridge. Razer devices using +Windows' HID driver send the same feature reports through node-hid; devices +bound to WinUSB retain WebUSB. USB enumeration returns only vendor/product IDs, +without invoking native getters that open devices. No USB v2 fallback is shipped. ## Run the portable build @@ -32,4 +34,4 @@ changes, reconnect and sleep/wake. CI cannot validate physical USB transfers. To return to the stable build, exit the portable copy and start the installed app again. Report the tested commit, connection mode and diagnostic log if any -check fails. Keep the PR unmerged until hardware testing succeeds. \ No newline at end of file +check fails. Keep the PR unmerged until hardware testing succeeds. diff --git a/package-lock.json b/package-lock.json index 5adaa237..184d3160 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3190,4 +3190,4 @@ "license": "MIT" } } -} \ No newline at end of file +} diff --git a/package.json b/package.json index 08c38755..63d64fe7 100644 --- a/package.json +++ b/package.json @@ -46,4 +46,4 @@ "allowScripts": { "electron-winstaller@5.4.0": true } -} \ No newline at end of file +} diff --git a/scripts/smoke-usb.cjs b/scripts/smoke-usb.cjs index 3e699c9f..15315d90 100644 --- a/scripts/smoke-usb.cjs +++ b/scripts/smoke-usb.cjs @@ -5,6 +5,7 @@ const assert = require('node:assert/strict'); const { version } = require('usb/package.json'); const { usb, WebUSB } = require('usb'); +const hid = require('node-hid'); const { listUsbDevices } = require('../src/lib/mouseBackends/usbDiscovery'); const { createPreferredMouseBackend } = require('../src/lib/mouseBackends/runtime'); const { getUsbIdentity } = require('../src/lib/mouseBackends/discovery'); @@ -21,6 +22,8 @@ async function smoke() { assert.ok(Number.isInteger(identity.vendorId)); assert.ok(Number.isInteger(identity.productId)); } + // Both Windows mouse transports depend on the shipped HID native binary. + assert.ok(Array.isArray(await hid.devicesAsync(0x1532, 0))); // Exercise production selection and backend construction without a mouse // attached to CI; neither backend sends commands until discover/open. @@ -49,4 +52,4 @@ async function smoke() { smoke().catch((error) => { console.error('USB v3 smoke test failed:', error); process.exitCode = 1; -}); \ No newline at end of file +}); diff --git a/src/lib/mouseBackends/razer.js b/src/lib/mouseBackends/razer.js index 34cd0186..a78168bb 100644 --- a/src/lib/mouseBackends/razer.js +++ b/src/lib/mouseBackends/razer.js @@ -1,6 +1,7 @@ 'use strict'; const { WebUSB } = require('usb'); +const { findRazerHidDevice } = require('./razerHid'); const { dongles } = require('../devices'); const { retryImmediately } = require('../retryImmediately'); const { @@ -34,6 +35,7 @@ function createRazerBackend(options = {}) { const sleep = options.sleep || defaultSleep; const log = options.log || (() => {}); const onDiagnostic = options.onDiagnostic || (() => {}); + const platform = options.platform || (options.createWebUsb ? 'webusb' : process.platform); const preferredProductId = Number.isInteger(options.preferredProductId) ? options.preferredProductId : null; @@ -73,10 +75,16 @@ function createRazerBackend(options = {}) { } async function discover() { - const webUsb = createWebUsb(selectSupportedDevice); - try { - const discovered = await webUsb.requestDevice({ filters: [{}] }); + // nusb in USB v3 cannot claim standard Windows HID drivers. Continue + // sending the same feature reports through HIDAPI for those devices. + const hidDevice = platform === 'win32' ? await findRazerHidDevice({ + hidApi: options.hidApi, + inspectHidCaps: options.inspectHidCaps, + devicesFound: selectSupportedDevice, + log, + }) : null; + const discovered = hidDevice || await createWebUsb(selectSupportedDevice).requestDevice({ filters: [{}] }); if (!isSupportedRazerDevice(discovered)) { throw new Error('No compatible Razer HyperPolling dongle found'); } diff --git a/src/lib/mouseBackends/razerHid.js b/src/lib/mouseBackends/razerHid.js new file mode 100644 index 00000000..bb02eea4 --- /dev/null +++ b/src/lib/mouseBackends/razerHid.js @@ -0,0 +1,104 @@ +'use strict'; + +const { dongles } = require('../devices'); +const { inspectWindowsHidCaps } = require('./windowsHidCaps'); + +const REPORT_LENGTH = 90; +const capsCache = new Map(); + +function interfaceNumber(device) { + if (Number.isInteger(device.interface) && device.interface >= 0) return device.interface; + const match = String(device.path || '').match(/&mi_([0-9a-f]{2})(?:[&#\\]|$)/i); + return match ? parseInt(match[1], 16) : null; +} + +// Present HID feature operations through the existing WebUSB-shaped transport +// so the Razer packet format, timing, retries and readback remain unchanged. +async function findRazerHidDevice(options) { + const hidApi = options.hidApi || require('node-hid'); + const inspectCaps = options.inspectHidCaps || inspectWindowsHidCaps; + const log = options.log || (() => {}); + const infos = await hidApi.devicesAsync(0x1532, 0); + const candidates = []; + for (const info of infos) { + const model = dongles[info.productId]; + if (info.vendorId !== 0x1532 || !model || !info.path + || interfaceNumber(info) !== (model.interfaceIndex ?? 0)) continue; + try { + let caps = capsCache.get(info.path); + if (!caps) { + caps = await inspectCaps(info.path); + capsCache.set(info.path, caps); + } + if (caps.featureReportByteLength < REPORT_LENGTH + 1) continue; + candidates.push({ ...info, productName: info.product || null, caps }); + } catch (error) { + log(`Razer HID caps unavailable: ${error.message}`, true); + } + } + + // Prefer the vendor collection when several collections share an interface. + candidates.sort((a, b) => Number(b.usagePage >= 0xff00) - Number(a.usagePage >= 0xff00)); + const selected = options.devicesFound(candidates); + if (!selected) return null; // A WinUSB-bound device may still use WebUSB. + const index = dongles[selected.productId].interfaceIndex ?? 0; + let handle = null; + + function requireHandle() { + if (!handle) throw new Error('Razer HID device is not open'); + } + + function validateSetup(setup, request) { + requireHandle(); + if (setup.requestType !== 'class' || setup.recipient !== 'interface' + || setup.request !== request || setup.value !== 0x300 || setup.index !== index) { + throw new Error('Unsupported Razer HID feature transfer'); + } + } + + return { + vendorId: selected.vendorId, + productId: selected.productId, + productName: selected.productName, + serialNumber: selected.serialNumber || null, + configuration: { interfaces: [{ interfaceNumber: index }] }, + async open() { + try { + handle = await hidApi.HIDAsync.open(selected.path); + } catch (error) { + capsCache.delete(selected.path); + throw error; + } + }, + async claimInterface(value) { + requireHandle(); + if (value !== index) throw new Error('Wrong Razer HID protocol interface'); + }, + async releaseInterface() {}, + async controlTransferOut(setup, packet) { + validateSetup(setup, 0x09); + if (packet.length !== REPORT_LENGTH) throw new Error('Invalid Razer HID packet length'); + const report = Buffer.alloc(selected.caps.featureReportByteLength); + Buffer.from(packet).copy(report, 1); // Report ID 0 precedes the 90-byte payload. + const written = await handle.sendFeatureReport([...report]); + if (written < report.length) throw new Error(`Razer HID short feature write (${written} bytes)`); + return { status: 'ok', bytesWritten: packet.length }; + }, + async controlTransferIn(setup, length) { + validateSetup(setup, 0x01); + if (length !== REPORT_LENGTH) throw new Error('Invalid Razer HID reply length'); + const report = Buffer.from(await handle.getFeatureReport(0, selected.caps.featureReportByteLength)); + if (report.length < length + 1) throw new Error(`Razer HID short feature reply (${report.length} bytes)`); + if (report[0] !== 0) throw new Error('Unexpected Razer HID feature report ID'); + const payload = Uint8Array.from(report.subarray(1, length + 1)); + return { status: 'ok', data: new DataView(payload.buffer) }; + }, + async close() { + const opened = handle; + handle = null; + if (opened) await opened.close(); + }, + }; +} + +module.exports = { findRazerHidDevice }; diff --git a/src/lib/mouseBackends/usbDiscovery.js b/src/lib/mouseBackends/usbDiscovery.js index 6b2a5a4d..9f558d5e 100644 --- a/src/lib/mouseBackends/usbDiscovery.js +++ b/src/lib/mouseBackends/usbDiscovery.js @@ -5,7 +5,11 @@ const { usb } = require('usb'); // USB v3's unrestricted instance lists attached devices without opening or // claiming them. Keep enumeration fresh for each check so reconnects are seen. async function listUsbDevices() { - return usb.getDevices(); + const devices = await usb.getDevices(); + // Native v3 serial/configuration getters can open the device. Legacy raw + // enumeration supplied only descriptors; preserve that behavior, including + // the serial-free identities used by Windows Raw Input presentation recovery. + return devices.map((device) => ({ vendorId: device.vendorId, productId: device.productId })); } -module.exports = { listUsbDevices }; \ No newline at end of file +module.exports = { listUsbDevices }; diff --git a/test/razerBackend.test.js b/test/razerBackend.test.js index 7a50ea75..9a1ad52f 100644 --- a/test/razerBackend.test.js +++ b/test/razerBackend.test.js @@ -72,6 +72,117 @@ function createBackend(deviceOrDevices, diagnostics = [], options = {}) { }); } +let windowsFixtureId = 0; +function windowsFixture(productId = 0x00e5, interfaceNumber = 3) { + const calls = []; + const replies = []; + const info = { vendorId: 0x1532, productId, interface: interfaceNumber, + path: `razer-hid-test-${windowsFixtureId++}`, serialNumber: 'B', product: 'Razer HID test', + usagePage: 0xff00, usage: 1, release: 1 }; + const hidApi = { + devicesAsync: async () => [info], + HIDAsync: { open: async (path) => { + calls.push(['open', path]); + return { + sendFeatureReport: async (bytes) => { calls.push(['send', Buffer.from(bytes)]); return bytes.length; }, + getFeatureReport: async (reportId, length) => { calls.push(['read', reportId, length]); return replies.shift(); }, + close: async () => { calls.push(['close']); }, + }; + } }, + }; + const backend = createRazerBackend({ + platform: 'win32', hidApi, + inspectHidCaps: async () => ({ usagePage: 0xff00, usage: 1, + inputReportByteLength: 0, outputReportByteLength: 0, featureReportByteLength: 91 }), + createWebUsb: () => assert.fail('standard Windows HID must not use WinUSB'), + preferredProductId: productId, preferredSerialNumber: 'B', + sleep: async () => {}, + }); + return { backend, hidApi, info, calls, replies }; +} + +test('Windows Razer uses HID feature reports without a driver replacement', async () => { + const { backend, calls, replies } = windowsFixture(); + const response = Buffer.alloc(91); + response[10] = 0x02; // leading report ID, then protocol polling byte 9 + replies.push(response); + await backend.discover(); + await backend.open(); + assert.equal(await backend.getPollingRate(), 4000); + await backend.close(); + assert.equal(backend.deviceInfo.interfaceNumber, 3); + const send = calls.find(([name]) => name === 'send')[1]; + assert.equal(send.length, 91); + assert.equal(send[0], 0); // unnumbered HID feature report ID + assert.deepEqual([...send.subarray(1, 9)], [0, 0x1f, 0, 0, 0, 1, 0, 0xc0]); + assert.deepEqual(calls.find(([name]) => name === 'read'), ['read', 0, 91]); + assert.equal(calls.filter(([name]) => name === 'close').length, 1); +}); + +test('Windows Razer keeps the two-stage rate write and subsequent readback', async () => { + const { backend, calls, replies } = windowsFixture(); + const readback = Buffer.alloc(91); + readback[10] = 0x02; + replies.push(Buffer.alloc(91), Buffer.alloc(91), readback); + await backend.discover(); + await backend.open(); + assert.equal(await backend.setPollingRate(4000), 4000); + await backend.close(); + const sends = calls.filter(([name]) => name === 'send').map(([, bytes]) => bytes); + assert.equal(sends.length, 3); + assert.deepEqual([...sends[0].subarray(1, 9)], [0, 0x1f, 0, 0, 0, 2, 0, 0x40]); + assert.equal(sends[0][9], 0); + assert.equal(sends[0][10], 0x02); + assert.deepEqual([...sends[1].subarray(1, 9)], [0, 0x1f, 0, 0, 0, 2, 0, 0x40]); + assert.equal(sends[1][9], 1); + assert.equal(sends[1][10], 0x02); +}); + +test('Windows Razer chooses its exact HID product, serial and protocol interface', async () => { + const { backend, hidApi, info, calls } = windowsFixture(); + hidApi.devicesAsync = async () => [ + { ...info, interface: 0, path: 'wrong-interface' }, + { ...info, serialNumber: 'A', path: 'wrong-serial' }, + { ...info, productId: 0x00be, path: 'wrong-product' }, + info, + ]; + await backend.discover(); + await backend.open(); + assert.deepEqual(calls[0], ['open', info.path]); + assert.equal(backend.deviceInfo.productName, 'Razer HID test'); + await backend.close(); +}); + +test('Windows Razer rejects short feature replies and can reopen after close', async () => { + const { backend, calls, replies } = windowsFixture(0x00be, 0); + replies.push(Buffer.alloc(5), Buffer.alloc(5), Buffer.alloc(5)); + await backend.discover(); + await backend.open(); + await assert.rejects(() => backend.getPollingRate(), /short/i); + await backend.close(); + const response = Buffer.alloc(91); + response[10] = 0x08; + replies.push(response); + await backend.discover(); + await backend.open(); + assert.equal(await backend.getPollingRate(), 1000); + await backend.close(); + assert.equal(calls.filter(([name]) => name === 'open').length, 2); + assert.equal(calls.filter(([name]) => name === 'close').length, 2); +}); + +test('Windows Razer retains WebUSB for a device bound to WinUSB rather than HID', async () => { + const device = createFakeDevice(0x00e5, [dataViewWithPollingByte(0x02)]); + const backend = createBackend(device, [], { + platform: 'win32', + hidApi: { devicesAsync: async () => [] }, + }); + await backend.discover(); + await backend.open(); + assert.equal(await backend.getPollingRate(), 4000); + await backend.close(); +}); + test('Razer backend honors the active mouse product identity when multiple known dongles are connected', async () => { const viper = createFakeDevice(0x00e5, [], 'viper'); const deathadder = createFakeDevice(0x00be, [], 'deathadder'); diff --git a/test/run-tests.js b/test/run-tests.js index bc61d524..c5329994 100644 --- a/test/run-tests.js +++ b/test/run-tests.js @@ -22,6 +22,7 @@ require('./pulsarCrazyLightProtocol.test'); require('./pulsarDiscovery.test'); require('./pulsarStartupProbe.test'); require('./usbV3Discovery.test'); +require('./usbDiscovery.test'); require('./pulsarWriteValidation.test'); require('./razerBackend.test'); require('./rates.test'); diff --git a/test/usbDiscovery.test.js b/test/usbDiscovery.test.js new file mode 100644 index 00000000..d023b3b9 --- /dev/null +++ b/test/usbDiscovery.test.js @@ -0,0 +1,22 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { usb } = require('usb'); +const { listUsbDevices } = require('../src/lib/mouseBackends/usbDiscovery'); + +test('USB discovery reads IDs without invoking fallible native serial or configuration getters', async () => { + const getDevices = usb.getDevices; + let opened = false; + usb.getDevices = async () => [{ + vendorId: 0x3710, + productId: 0x5406, + get serialNumber() { opened = true; throw new Error('HID driver cannot be opened by nusb'); }, + get configuration() { opened = true; throw new Error('must not open while enumerating'); }, + }]; + try { + const devices = await listUsbDevices(); + assert.deepEqual(devices, [{ vendorId: 0x3710, productId: 0x5406 }]); + assert.equal(opened, false); + } finally { + usb.getDevices = getDevices; + } +}); diff --git a/test/usbV3Discovery.test.js b/test/usbV3Discovery.test.js index 57b595e6..25a2fa66 100644 --- a/test/usbV3Discovery.test.js +++ b/test/usbV3Discovery.test.js @@ -95,4 +95,4 @@ test('unknown USB v3 Pulsar identities do not receive protocol commands', async const result = await runCrazyLightProbe(options); assert.equal(result.discovery.unknownPulsar.length, 1); assert.equal(result.probe, null); -}); \ No newline at end of file +}); From 72eeb3fccafc3277c2f50f0b063ec0c926fb7d70 Mon Sep 17 00:00:00 2001 From: marcmy <21000174+marcmy@users.noreply.github.com> Date: Sun, 11 Oct 2026 21:44:49 -0400 Subject: [PATCH 3/3] Preserve exact Razer serial selection across HID and WinUSB --- src/lib/mouseBackends/razer.js | 23 ++++++++++++++++++++--- src/lib/mouseBackends/razerHid.js | 7 ++++--- test/razerBackend.test.js | 15 +++++++++++++++ 3 files changed, 39 insertions(+), 6 deletions(-) diff --git a/src/lib/mouseBackends/razer.js b/src/lib/mouseBackends/razer.js index a78168bb..42fa3f11 100644 --- a/src/lib/mouseBackends/razer.js +++ b/src/lib/mouseBackends/razer.js @@ -30,6 +30,12 @@ function isSupportedRazerDevice(device) { && dongles[device.productId] !== undefined); } +function optionalSerialNumber(device) { + // nusb's serial getter may try to open a HID-bound device. Failure to read + // optional metadata must not hide an accessible exact match later in a list. + try { return device.serialNumber || null; } catch { return null; } +} + function createRazerBackend(options = {}) { const createWebUsb = options.createWebUsb || defaultCreateWebUsb; const sleep = options.sleep || defaultSleep; @@ -66,7 +72,7 @@ function createRazerBackend(options = {}) { if (preferredSerialNumber) { const exactSerial = matchingProduct.find( - (candidate) => candidate.serialNumber === preferredSerialNumber, + (candidate) => optionalSerialNumber(candidate) === preferredSerialNumber, ); if (exactSerial) return exactSerial; } @@ -84,7 +90,18 @@ function createRazerBackend(options = {}) { devicesFound: selectSupportedDevice, log, }) : null; - const discovered = hidDevice || await createWebUsb(selectSupportedDevice).requestDevice({ filters: [{}] }); + let discovered = hidDevice; + if (!hidDevice || (preferredSerialNumber && hidDevice.serialNumber !== preferredSerialNumber)) { + try { + discovered = await createWebUsb(selectSupportedDevice).requestDevice({ filters: [{}] }); + // Keep a usable HID fallback when neither transport has the exact + // serial. Never choose a HID-bound nusb device that cannot be claimed. + if (hidDevice && optionalSerialNumber(discovered) !== preferredSerialNumber) discovered = hidDevice; + } catch (error) { + if (!hidDevice || error.name !== 'NotFoundError') throw error; + discovered = hidDevice; + } + } if (!isSupportedRazerDevice(discovered)) { throw new Error('No compatible Razer HyperPolling dongle found'); } @@ -94,7 +111,7 @@ function createRazerBackend(options = {}) { onDiagnostic('razer_device_selected', { vendorId: device.vendorId, productId: device.productId, - serialNumber: device.serialNumber || null, + serialNumber: optionalSerialNumber(device), preferredProductId, preferredSerialNumber, }); diff --git a/src/lib/mouseBackends/razerHid.js b/src/lib/mouseBackends/razerHid.js index bb02eea4..a3f70db4 100644 --- a/src/lib/mouseBackends/razerHid.js +++ b/src/lib/mouseBackends/razerHid.js @@ -17,6 +17,7 @@ function interfaceNumber(device) { async function findRazerHidDevice(options) { const hidApi = options.hidApi || require('node-hid'); const inspectCaps = options.inspectHidCaps || inspectWindowsHidCaps; + const cache = options.hidApi || options.inspectHidCaps ? new Map() : capsCache; const log = options.log || (() => {}); const infos = await hidApi.devicesAsync(0x1532, 0); const candidates = []; @@ -25,10 +26,10 @@ async function findRazerHidDevice(options) { if (info.vendorId !== 0x1532 || !model || !info.path || interfaceNumber(info) !== (model.interfaceIndex ?? 0)) continue; try { - let caps = capsCache.get(info.path); + let caps = cache.get(info.path); if (!caps) { caps = await inspectCaps(info.path); - capsCache.set(info.path, caps); + cache.set(info.path, caps); } if (caps.featureReportByteLength < REPORT_LENGTH + 1) continue; candidates.push({ ...info, productName: info.product || null, caps }); @@ -66,7 +67,7 @@ async function findRazerHidDevice(options) { try { handle = await hidApi.HIDAsync.open(selected.path); } catch (error) { - capsCache.delete(selected.path); + cache.delete(selected.path); throw error; } }, diff --git a/test/razerBackend.test.js b/test/razerBackend.test.js index 9a1ad52f..0896eec2 100644 --- a/test/razerBackend.test.js +++ b/test/razerBackend.test.js @@ -183,6 +183,21 @@ test('Windows Razer retains WebUSB for a device bound to WinUSB rather than HID' await backend.close(); }); +test('an exact Razer serial on WinUSB takes priority over a different HID mouse', async () => { + const { hidApi, info } = windowsFixture(); + hidApi.devicesAsync = async () => [{ ...info, serialNumber: 'A' }]; + const preferred = createFakeDevice(0x00e5, [dataViewWithPollingByte(0x02)], 'B'); + const backend = createBackend(preferred, [], { + platform: 'win32', hidApi, + preferredProductId: 0x00e5, preferredSerialNumber: 'B', + inspectHidCaps: async () => ({ featureReportByteLength: 91 }), + }); + assert.equal(await backend.discover(), preferred); + await backend.open(); + assert.equal(await backend.getPollingRate(), 4000); + await backend.close(); +}); + test('Razer backend honors the active mouse product identity when multiple known dongles are connected', async () => { const viper = createFakeDevice(0x00e5, [], 'viper'); const deathadder = createFakeDevice(0x00be, [], 'deathadder');