diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4360b62..7e474c5 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 0000000..77163ac --- /dev/null +++ b/docs/usb-v3-hardware-test.md @@ -0,0 +1,37 @@ +# 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. 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 + +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. diff --git a/forge.config.js b/forge.config.js index e4994cd..0011d56 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 df16059..184d316 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": { diff --git a/package.json b/package.json index 5cde05e..63d64fe 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 } } diff --git a/scripts/smoke-usb.cjs b/scripts/smoke-usb.cjs new file mode 100644 index 0000000..15315d9 --- /dev/null +++ b/scripts/smoke-usb.cjs @@ -0,0 +1,55 @@ +'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 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'); + +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)); + } + // 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. + 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; +}); diff --git a/src/lib/mouseBackends/razer.js b/src/lib/mouseBackends/razer.js index 34cd018..42fa3f1 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 { @@ -29,11 +30,18 @@ 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; 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; @@ -64,7 +72,7 @@ function createRazerBackend(options = {}) { if (preferredSerialNumber) { const exactSerial = matchingProduct.find( - (candidate) => candidate.serialNumber === preferredSerialNumber, + (candidate) => optionalSerialNumber(candidate) === preferredSerialNumber, ); if (exactSerial) return exactSerial; } @@ -73,10 +81,27 @@ 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; + 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'); } @@ -86,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 new file mode 100644 index 0000000..a3f70db --- /dev/null +++ b/src/lib/mouseBackends/razerHid.js @@ -0,0 +1,105 @@ +'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 cache = options.hidApi || options.inspectHidCaps ? new Map() : capsCache; + 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 = cache.get(info.path); + if (!caps) { + caps = await inspectCaps(info.path); + cache.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) { + cache.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/runtime.js b/src/lib/mouseBackends/runtime.js index 8476c1c..311a001 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 27a6763..19ecfb1 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 0000000..9f558d5 --- /dev/null +++ b/src/lib/mouseBackends/usbDiscovery.js @@ -0,0 +1,15 @@ +'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() { + 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 }; diff --git a/src/main.js b/src/main.js index 1047eb1..a0ce5e1 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 64c8c9c..369ad70 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 b739376..f7d5a18 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/razerBackend.test.js b/test/razerBackend.test.js index 7a50ea7..0896eec 100644 --- a/test/razerBackend.test.js +++ b/test/razerBackend.test.js @@ -72,6 +72,132 @@ 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('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'); diff --git a/test/run-tests.js b/test/run-tests.js index 0685486..c532999 100644 --- a/test/run-tests.js +++ b/test/run-tests.js @@ -21,6 +21,8 @@ require('./pulsarCrazyLightHid.test'); 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 0000000..d023b3b --- /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 new file mode 100644 index 0000000..25a2fa6 --- /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); +});