diff --git a/apps/overlay/electron/main.js b/apps/overlay/electron/main.js index cbfa9d8..1ae964b 100644 --- a/apps/overlay/electron/main.js +++ b/apps/overlay/electron/main.js @@ -88,15 +88,39 @@ const dragOffsets = new Map(); // BrowserWindow.id -> { dx, dy } const INVENTORY_VIDS = new Set([0x054c, 0x057e, 0x28de, 0x045e, 0x3537, 0x2dc8, 0x0f0d]); const hidControllers = new Map(); // key -> { vendorId, productId, name, serialNumber, connected } +// #106: prefer node-hid for the inventory when available — the MAIN process can +// enumerate serials + a per-unit `path` at launch with NO user gesture (the +// renderer's WebHID requestDevice scan needs transient activation, which the +// #104 auto-opened list lacks, so its rows showed no serials). Guarded: if the +// native module isn't built for this Electron ABI it stays null and we degrade +// to the Electron HID-event inventory below — no regression. +let nodeHid = null; +try { + nodeHid = require('node-hid'); + console.log('[inventory] node-hid loaded — gesture-free serial enumeration active'); +} catch (e) { + console.log('[inventory] node-hid unavailable, using Electron HID events —', e.message); +} + // Drop XInput slot indices ("01"/"02") and other too-short non-serials so they // aren't mistaken for a per-unit id. function sanitizeSerial(s) { const t = s ? String(s).trim() : ''; return t.length >= 6 ? t : null; } -function hidKey(d) { return d.guid || d.deviceId || `${d.vendorId}:${d.productId}`; } +function hidKey(d) { + // Prefer a physical-unit key (vid:pid:serial) so a multi-interface device + // collapses to ONE inventory entry — the Steam Puck exposes ~11 HID interfaces + // that all share a single serial, which would otherwise be 11 rows (#106). + const serial = sanitizeSerial(d.serialNumber); + if (serial) return `${d.vendorId}:${d.productId}:${serial}`; + return d.guid || d.deviceId || `${d.vendorId}:${d.productId}`; +} -function upsertHidController(d, connected) { +function upsertHidController(d, connected, fromNodeHid = false) { + // When node-hid is the source of truth, ignore the Electron HID events so the + // two sources don't produce duplicate/rekeyed inventory entries. + if (nodeHid && !fromNodeHid) return; if (!d || !INVENTORY_VIDS.has(d.vendorId)) return; const key = hidKey(d); const prev = hidControllers.get(key) || {}; @@ -108,8 +132,79 @@ function upsertHidController(d, connected) { serialNumber: sanitizeSerial(d.serialNumber) || prev.serialNumber || null, connected, }); - console.log('[inventory] HID', connected ? 'present' : 'gone', '-', - (d.name || d.productId), 'serial=', d.serialNumber || '(none)'); + if (!fromNodeHid) { + console.log('[inventory] HID', connected ? 'present' : 'gone', '-', + (d.name || d.productId), 'serial=', d.serialNumber || '(none)'); + } +} + +// #106 win-2: some pads (the USB DualShock 4, 054c:05c4) leave iSerialNumber +// empty but expose their Bluetooth MAC via a HID feature report — one Chromium +// blocklists in WebHID (why the renderer can't read it), but node-hid in main +// can. Probe once per device path and cache; the report id + layout are +// per-controller (hardware-verified: DS4 report 0x12, MAC in payload bytes 1..6 +// little-endian). Returns a 12-hex-char MAC (matching node-hid's serialNumber +// format for pads that DO expose one) or null. +function macProbeFor(vendorId, productId) { + if (vendorId === 0x054c && productId === 0x05c4) return { reportId: 0x12, length: 16 }; // DualShock 4 + return null; // DualSense/Switch/Steam already surface serialNumber +} +const _macProbeCache = new Map(); // path -> mac | null (avoids re-opening each poll) +function probeUnitMac(device) { + if (!nodeHid || !device.path) return null; + if (_macProbeCache.has(device.path)) return _macProbeCache.get(device.path); + const probe = macProbeFor(device.vendorId, device.productId); + if (!probe) { _macProbeCache.set(device.path, null); return null; } + let mac = null; + try { + const h = new nodeHid.HID(device.path); + let bytes; + try { bytes = h.getFeatureReport(probe.reportId, probe.length); } + finally { h.close(); } + const raw = (bytes || []).slice(1, 7).reverse(); // 6 MAC bytes, little-endian -> MSB first + if (raw.length === 6 && raw.some((b) => b)) { + mac = raw.map((b) => b.toString(16).padStart(2, '0')).join(''); + } + } catch (e) { + console.log('[inventory] MAC probe failed —', device.product || device.productId, '-', e.message); + } + _macProbeCache.set(device.path, mac); + if (mac) console.log('[inventory] MAC probe:', device.product || device.productId, '→', mac); + return mac; +} + +// #106: rebuild the inventory from node-hid's live device list. `devices()` +// returns metadata (vid/pid/path/serialNumber/product) WITHOUT opening the +// device, so it never contends with the renderer's WebHID handles. No gesture +// required. `path` is a stable per-unit id (distinguishes two same-vid:pid +// units the renderer's WebHID handle cannot). Broadcasts only on change so the +// polling interval doesn't spam snapshots. +function enumerateHidViaNodeHid() { + if (!nodeHid) return; + let devices; + try { devices = nodeHid.devices(); } + catch (e) { console.log('[inventory] node-hid devices() failed —', e.message); return; } + const before = JSON.stringify([...hidControllers.values()]); + hidControllers.clear(); + for (const d of devices) { + // Skip non-controllers sharing a vendor (e.g. Microsoft 045e keyboards/mice + // and productId-0 virtual HID collections that carry no name or serial). + if (!INVENTORY_VIDS.has(d.vendorId) || !d.productId) continue; + // #106 win-2: for pads that expose no iSerialNumber (USB DS4), fall back to + // their MAC read from a feature report — a stable per-unit id. + const serial = sanitizeSerial(d.serialNumber) || probeUnitMac(d); + upsertHidController({ + vendorId: d.vendorId, + productId: d.productId, + name: d.product || null, + serialNumber: serial, + deviceId: d.path, // node-hid path → stable per-unit key via hidKey() + }, true, true); + } + if (JSON.stringify([...hidControllers.values()]) !== before) { + console.log('[inventory] node-hid enumeration:', hidControllers.size, 'controller(s)'); + broadcastHidControllers(); + } } function hidControllerList() { return [...hidControllers.values()]; } @@ -275,6 +370,14 @@ app.commandLine.appendSwitch('disable-features', 'AutofillServerCommunication,Au app.whenReady().then(() => { createWindow(); + // #106: populate the serial inventory immediately (no gesture) and poll for + // hot-plug/removal. node-hid enumeration is cheap and read-only; the interval + // only broadcasts when the device set actually changes. + if (nodeHid) { + enumerateHidViaNodeHid(); + setInterval(enumerateHidViaNodeHid, 2000); + } + // Create tray icon if asset exists try { createTray(); diff --git a/apps/overlay/forge.config.js b/apps/overlay/forge.config.js index e200c87..28dc62c 100644 --- a/apps/overlay/forge.config.js +++ b/apps/overlay/forge.config.js @@ -40,5 +40,10 @@ module.exports = { // }, // }, ], - plugins: [], + plugins: [ + // #106: node-hid is a native module — unpack its .node binary out of the + // asar archive so it's loadable at runtime. Forge's package/make step also + // rebuilds native modules for the target Electron ABI via @electron/rebuild. + { name: '@electron-forge/plugin-auto-unpack-natives', config: {} }, + ], }; diff --git a/apps/overlay/package.json b/apps/overlay/package.json index 26011be..2294fb1 100644 --- a/apps/overlay/package.json +++ b/apps/overlay/package.json @@ -14,11 +14,13 @@ "start:lobby": "electron . --lobby", "make": "electron-forge make", "make:dmg": "npm run package && bash scripts/make-dmg.sh", - "package": "electron-forge package" + "package": "electron-forge package", + "rebuild": "electron-rebuild -f -w node-hid" }, "dependencies": { "@usersfirst/controller-core": "*", "@usersfirst/controller-visualizer": "*", + "node-hid": "^3.1.2", "three": "0.161.0" }, "devDependencies": { @@ -26,6 +28,8 @@ "@electron-forge/maker-dmg": "^7.6.0", "@electron-forge/maker-squirrel": "^7.11.1", "@electron-forge/maker-zip": "^7.6.0", + "@electron-forge/plugin-auto-unpack-natives": "^7.6.0", + "@electron/rebuild": "^3.7.0", "electron": "^33.2.1" }, "author": "Pete Gordon", diff --git a/package-lock.json b/package-lock.json index 74a9179..f003ed7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,6 +23,7 @@ "dependencies": { "@usersfirst/controller-core": "*", "@usersfirst/controller-visualizer": "*", + "node-hid": "^3.1.2", "three": "0.161.0" }, "devDependencies": { @@ -30,6 +31,8 @@ "@electron-forge/maker-dmg": "^7.6.0", "@electron-forge/maker-squirrel": "^7.11.1", "@electron-forge/maker-zip": "^7.6.0", + "@electron-forge/plugin-auto-unpack-natives": "^7.6.0", + "@electron/rebuild": "^3.7.0", "electron": "^33.2.1" } }, @@ -211,6 +214,20 @@ "node": ">= 16.4.0" } }, + "node_modules/@electron-forge/plugin-auto-unpack-natives": { + "version": "7.11.2", + "resolved": "https://registry.npmjs.org/@electron-forge/plugin-auto-unpack-natives/-/plugin-auto-unpack-natives-7.11.2.tgz", + "integrity": "sha512-ktMQxO80vGjqVXVSFha+kWJscBmJaWud4Kqu7rKkbrCphr1zqaG+8EDNbkpGzV/+mhKOLuA5qII1hJ69gFNpdA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@electron-forge/plugin-base": "7.11.2", + "@electron-forge/shared-types": "7.11.2" + }, + "engines": { + "node": ">= 16.4.0" + } + }, "node_modules/@electron-forge/plugin-base": { "version": "7.11.2", "resolved": "https://registry.npmjs.org/@electron-forge/plugin-base/-/plugin-base-7.11.2.tgz", @@ -5340,6 +5357,12 @@ "node": ">=10" } }, + "node_modules/node-addon-api": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-3.2.1.tgz", + "integrity": "sha512-mmcei9JghVNDYydghQmeDX8KoAm0FAiYyIcUt/N4nhyAipB17pllZQDOJD2fotxABnt4Mdz+dKTO7eftLg4d0A==", + "license": "MIT" + }, "node_modules/node-api-version": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/node-api-version/-/node-api-version-0.2.1.tgz", @@ -5371,6 +5394,23 @@ } } }, + "node_modules/node-hid": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/node-hid/-/node-hid-3.3.0.tgz", + "integrity": "sha512-j+dFgJLRAE0nufQKXk3IfS6T6YuHhCgMvz4TrG0sgtb6DSCdYpfJ1etcdmeCmPQjUgO+yo32ktVrRliNs/+fmg==", + "hasInstallScript": true, + "license": "(MIT OR X11)", + "dependencies": { + "node-addon-api": "^3.2.1", + "pkg-prebuilds": "^1.0.0" + }, + "bin": { + "hid-showdevices": "src/show-devices.js" + }, + "engines": { + "node": ">=10.16" + } + }, "node_modules/node-releases": { "version": "2.0.46", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.46.tgz", @@ -5805,6 +5845,19 @@ "node": ">=0.10.0" } }, + "node_modules/pkg-prebuilds": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/pkg-prebuilds/-/pkg-prebuilds-1.1.0.tgz", + "integrity": "sha512-jyai+KTQ2OwbN6iRYw88XbYOMgtpoSYJpjYebx7d9ihqz3txNi3ucsBt3va0iVWe6svSlaqpijMHFF/eJCMZzg==", + "license": "MIT", + "bin": { + "pkg-prebuilds-copy": "bin/copy.mjs", + "pkg-prebuilds-verify": "bin/verify.mjs" + }, + "engines": { + "node": ">= 14.15.0" + } + }, "node_modules/plist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/plist/-/plist-3.1.1.tgz", @@ -7520,7 +7573,7 @@ }, "packages/core": { "name": "@usersfirst/controller-core", - "version": "0.2.2", + "version": "0.3.2", "license": "MIT" }, "packages/visualizer": {