From 0a333f61ae341f5ece883044626817394f75ac21 Mon Sep 17 00:00:00 2001 From: Pete Gordon Date: Mon, 6 Jul 2026 21:53:07 -0400 Subject: [PATCH 1/3] overlay(#106): gesture-free serials via node-hid enumeration in main MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The renderer's WebHID serial scan needs a user gesture (Blink), so the #104 auto-opened list showed no serials. node-hid enumerates in MAIN — per-device path + serialNumber, read-only (no open, no contention with WebHID handles), and NO gesture. Populates the existing hidControllers inventory at launch + polls for hot-plug; broadcasts only on change. - Guarded require: if the native module isn't built for this Electron ABI it stays null and we fall back to the Electron HID-event inventory (no regression). - hidKey now prefers vid:pid:serial so a multi-interface device collapses to one entry (the Steam Puck exposes ~11 interfaces sharing one serial). - Skips productId-0 / nameless vendor-shared junk (e.g. Microsoft 045e HID). - forge: @electron-forge/plugin-auto-unpack-natives so the .node binary is loadable from the packaged asar; `npm run rebuild` helper for local dev. FEASIBILITY VERIFIED under Node on this Windows machine: node-hid installed from a prebuilt (2s, no compiler) and enumerated the real pads gesture-free with matching serials — Steam FXB9960202571, Switch 98e255badd18, DS4 (none) — and the dedup collapsed the Puck's 11 interfaces to 1. NEEDS verify under Electron 33 runtime + packaged build. This delivers win #1 (gesture-free serials); per-unit same-transport auto-select (win #2) still needs the handle->path bridge — noted. --- apps/overlay/electron/main.js | 73 +++++++++++++++++++++++++++++++++-- apps/overlay/forge.config.js | 7 +++- apps/overlay/package.json | 6 ++- 3 files changed, 80 insertions(+), 6 deletions(-) diff --git a/apps/overlay/electron/main.js b/apps/overlay/electron/main.js index cbfa9d8..0294408 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,41 @@ 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: 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; + upsertHidController({ + vendorId: d.vendorId, + productId: d.productId, + name: d.product || null, + serialNumber: d.serialNumber || null, + 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 +332,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", From 6c88086fba8ae475127dbfe3fca9efae7daf1614 Mon Sep 17 00:00:00 2001 From: Pete Gordon Date: Mon, 6 Jul 2026 21:55:30 -0400 Subject: [PATCH 2/3] chore(#106): update lockfile for node-hid + auto-unpack-natives plugin --- package-lock.json | 55 ++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 54 insertions(+), 1 deletion(-) 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": { From 8560fda6ff1d06fe4a304e42be9e9c928ac69389 Mon Sep 17 00:00:00 2001 From: Pete Gordon Date: Mon, 6 Jul 2026 22:17:32 -0400 Subject: [PATCH 3/3] =?UTF-8?q?overlay(#106):=20feature-report=20MAC=20pro?= =?UTF-8?q?be=20=E2=80=94=20per-unit=20id=20for=20serial-less=20pads=20(US?= =?UTF-8?q?B=20DS4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The USB DualShock 4 leaves iSerialNumber empty (WebHID + node-hid both saw serial=(none)), but it exposes its Bluetooth MAC via HID feature report 0x12 — a report Chromium blocklists in WebHID (why the renderer can't read it) but node-hid in main can. On enumeration, when a pad has no serialNumber, probe the MAC (per-controller report id; DS4 0x12, bytes 1..6 little-endian), format it as 12 hex chars to match node-hid's serial format, and use it as the unit's id. Probe once per device path and cache (no re-open on the 2s poll). HARDWARE-VERIFIED on this Windows machine (probe succeeded while the overlay had the pad open over WebHID — shared feature-report reads work): the DS4 now shows serial 90fba6ba591c; Steam FXB9960202571 / Switch 98e255badd18 unchanged; dedup intact. Gives each physical unit an id in the inventory, incl. the serial-less DS4. NOTE: full two-identical-same-transport AUTO-SELECT still needs the renderer WebHID-handle -> unit bridge (grant-time correlation) — remaining #106 follow-up. --- apps/overlay/electron/main.js | 40 ++++++++++++++++++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/apps/overlay/electron/main.js b/apps/overlay/electron/main.js index 0294408..1ae964b 100644 --- a/apps/overlay/electron/main.js +++ b/apps/overlay/electron/main.js @@ -138,6 +138,41 @@ function upsertHidController(d, connected, fromNodeHid = false) { } } +// #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 @@ -155,11 +190,14 @@ function enumerateHidViaNodeHid() { // 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: d.serialNumber || null, + serialNumber: serial, deviceId: d.path, // node-hid path → stable per-unit key via hidKey() }, true, true); }