Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/overlay/electron/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -361,6 +361,7 @@ app.whenReady().then(() => {
axis: { file: 'axis-window.html', width: 280, height: 90 },
roll: { file: 'roll-window.html', width: 240, height: 170 },
controllers: { file: 'controllers-window.html', width: 320, height: 460 },
'multi-controllers': { file: 'multi-controllers-window.html', width: 340, height: 500 },
};

ipcMain.handle('open-hud-window', async (_event, { kind, profile, greenScreen }) => {
Expand Down
68 changes: 51 additions & 17 deletions apps/overlay/src/js/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@

import * as THREE from 'three';
import { ControllerOverlay, detectControllerType, PROFILES, GyroGimbal } from '@usersfirst/controller-visualizer';
import { ControllerRegistry, SensorFusion, analyzeImuStep, SteamControllerDriver, ControllerManager } from '@usersfirst/controller-core';
import { ControllerRegistry, SensorFusion, analyzeImuStep, SteamControllerDriver, ControllerManager, isPresentableEntry } from '@usersfirst/controller-core';
import { recordStep, buildReport, exportReport, stepsForEntry, parseImuSamples,
areasForSteps, filterStepsByAreas, AREA_LABELS, STEP_AREAS } from './test-report.js';

Expand Down Expand Up @@ -169,10 +169,11 @@ const listManager = new ControllerManager({ slotIds: ['_ovl'] });
async function initControllerList() {
if (!navigator.hid) return;
try {
const approved = await navigator.hid.getDevices();
for (const d of approved) {
if (ControllerRegistry.isKnownDevice(d)) await listManager.poolDevice(d);
}
// WebHID-first: pool every approved, known HID device (no Gamepad-API gate).
// Shared with the multi/lobby apps so all three boot HID the same way — this
// used to be an inline loop here that drifted from the gated version those
// apps called. See ControllerManager.autoPoolApprovedHid.
await listManager.autoPoolApprovedHid();
listManager.wireHidHotplug();
} catch (e) { console.warn('[overlay] controller-pool init failed', e); }
}
Expand Down Expand Up @@ -1096,7 +1097,7 @@ function scheduleGyroConnect() {
if (gyroActive) return;
console.log('Auto-connecting gyro for', currentControllerType, '...');
try {
await connectControllerGyro();
await connectControllerGyro(false); // boot auto-connect: no gesture → getDevices only, never the blocking scan
if (gyroActive) {
console.log('Gyro auto-connected successfully');
} else {
Expand All @@ -1123,7 +1124,7 @@ function cancelGyroConnect() {
* Step 2: requestDevice() — triggers Electron's select-hid-device handler
* which auto-approves. Also works in browsers with user gesture.
*/
async function connectControllerGyro() {
async function connectControllerGyro(allowRequest = true) {
if (hidDevice && gyroActive) return;
if (!navigator.hid) return;

Expand Down Expand Up @@ -1159,8 +1160,12 @@ async function connectControllerGyro() {
console.log('connectControllerGyro: getDevices failed:', err.message);
}

// Step 2: requestDevice() if no granted device
if (!device) {
// Step 2: requestDevice() if no granted device — only when the caller allows
// it (a real user gesture). The boot auto-connect timer has no gesture, so a
// requestDevice there just throws "Must be handling a user gesture" (and in
// Electron would run the blocking system HID scan); skip it and let the user's
// explicit Connect click do the granting.
if (!device && allowRequest) {
console.log('connectControllerGyro: trying requestDevice()...');
try {
const devices = await navigator.hid.requestDevice({ filters });
Expand Down Expand Up @@ -1208,7 +1213,12 @@ async function finishGyroConnect(device) {

// Steam Controller Puck: the picked handle may be a sibling that never emits
// STATE reports — designate the same-vid:pid interface that IS receiving them.
if (entry.driver?.constructor?.needsSiblingFanout) {
// Only reroute a SILENT handle, though: the 2026 Puck is a multi-receiver and
// each paired body streams on its own interface, so a streaming handle is
// already its own unit. Rerouting a live handle to rx[0] would make selecting
// the second controller silently snap back to the first (see the multi-Steam
// per-unit rollup below).
if (entry.driver?.constructor?.needsSiblingFanout && !(entry.hidActiveSince > 0)) {
const rx = pool().filter((e) => forVp(e) && e.hidActiveSince > 0);
if (rx.length) entry = rx[0];
}
Expand Down Expand Up @@ -1372,17 +1382,21 @@ function overlayControllerRows() {
rows.push({ key: 'active', sortId: deviceIdFor(hidDevice), name: _ctrlName(vp, hidDevice.productName), state: 'SELECTED',
conn, vp, serial: serialForDevice(vp, conn), active: _padActive(readGamepad()), selected: true });
}
// Pool entries → rows. A fan-out device (Steam Controller Puck: 5 same-vid:pid
// HID interfaces on ONE physical unit) rolls up to a SINGLE row; other handles
// are one row each. Siblings of the SELECTED controller are hidden.
// TODO: multiple Steam Controllers on one Puck will need per-unit rollup (issue).
// Pool entries → rows. A fan-out device (Steam Controller Puck) exposes several
// same-vid:pid HID interfaces, but — crucially — the 2026 Puck is a MULTI-
// receiver: each paired controller body streams STATE on its OWN vendor
// interface (verified: body#1→MI_02, body#2→MI_03). So a fan-out interface is
// its own logical controller *iff it is actually streaming* (hidActiveSince>0);
// the Puck's silent siblings (keyboard/mouse collections, unpaired receiver
// slots) are NOT controllers and must not appear as phantom rows. Non-fan-out
// handles are one row each as before. (This replaces the old fan:vid:pid rollup
// that collapsed every Puck interface — and thus BOTH bodies — into one row.)
const groups = new Map();
for (const entry of listManager._hidPool.values()) {
const d = entry.device;
if (d === hidDevice) continue; // the SELECTED device is now pooled too — shown as its own row above
const isFanout = !!(entry.driver && entry.driver.constructor && entry.driver.constructor.needsSiblingFanout);
if (isFanout && hidDevice && d.vendorId === hidDevice.vendorId && d.productId === hidDevice.productId) continue;
const gk = isFanout ? ('fan:' + d.vendorId + ':' + d.productId) : ('dev:' + deviceIdFor(d));
if (!isPresentableEntry(entry)) continue; // hide idle Puck siblings (shared core filter)
const gk = 'dev:' + deviceIdFor(d); // per-interface row (per-unit for the multi-receiver Puck)
const a = _padActive(entry.synthetic);
const g = groups.get(gk);
if (g) { g.active = g.active || a; } else groups.set(gk, { device: d, entry, active: a });
Expand All @@ -1405,6 +1419,22 @@ function overlayControllerRows() {
// Stable order by first-sighting (deviceId) so SELECTING a controller does NOT
// reorder the list — the row you clicked stays put instead of jumping to top.
rows.sort((x, y) => x.sortId - y.sortId);
// Disambiguate identical display names: two Steam Controller bodies on one
// multi-receiver Puck share name + vid:pid + serial (the serial is the PUCK's,
// not the body's — see [[multi-steam-controller]]), so the rows are otherwise
// indistinguishable. Append a stable ' #n' by first-sighting order (sortId,
// already applied) so the user can tell them apart. Also covers any two
// truly-identical pads (e.g. two DS4s). Single-of-a-kind names are untouched.
const nameCounts = new Map();
for (const r of rows) nameCounts.set(r.name, (nameCounts.get(r.name) || 0) + 1);
const nameSeen = new Map();
for (const r of rows) {
if (nameCounts.get(r.name) > 1) {
const n = (nameSeen.get(r.name) || 0) + 1;
nameSeen.set(r.name, n);
r.name = `${r.name} #${n}`;
}
}
return rows;
}

Expand Down Expand Up @@ -1456,6 +1486,10 @@ function forwardControllerList() {
const _PHANTOM_MS = 3000;
function evictPhantoms(now) {
for (const entry of [...listManager._hidPool.values()]) {
// Never evict a fan-out (Steam Puck) interface for silence: it's present as
// long as the dongle is plugged, just idle until a body powers on. Evicting
// it would drop a receiver slot that a controller is about to stream into.
if (entry.driver?.constructor?.needsSiblingFanout) continue;
if (entry.lastRawReportAt === 0 && typeof entry.pooledAt === 'number' && (now - entry.pooledAt) > _PHANTOM_MS) {
const dev = entry.device;
console.log('[overlay] evicting phantom (no reports in ' + Math.round((now - entry.pooledAt)) + 'ms):',
Expand Down
61 changes: 51 additions & 10 deletions apps/overlay/src/js/lobby-app.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,14 @@
// into the game (sync-controller-core).
// ============================================================

import { ControllerManager, ControllerRegistry } from '@usersfirst/controller-core';
import { ControllerManager, ControllerRegistry, MAX_CONTROLLERS, playerSlotIds } from '@usersfirst/controller-core';

const SLOT_IDS = ['P1', 'P2', 'P3', 'P4'];
// Pre-allocate a generous slot pool (shared core constant) so MORE controllers
// than there are seats can be RECOGNIZED (ACTIVE) and wait in the lounge for a
// seat to open — the modes cap seats at 4 (versus 2v2), but a 5th+ controller
// should still be seen and queue, not silently do nothing (it had nowhere to go
// with only 4 slots). Only claimed slots cost anything.
const SLOT_IDS = playerSlotIds();
const manager = new ControllerManager({ slotIds: SLOT_IDS });
window.__manager = manager;

Expand Down Expand Up @@ -281,22 +286,52 @@ function updateControllerCount() {
const el = $('ctrl-count'); if (!el) return;
if (pairMsg && performance.now() < pairMsgUntil) { el.textContent = pairMsg; return; }
const claimed = manager.slots.filter((s) => s.state === 'claimed').length;
const pooled = manager._hidPool.size;
// Count only usable pooled controllers — excludes idle Steam Puck receiver
// interfaces (shared core filter), same as the list.
const pooled = manager.presentablePoolEntries().length;
el.textContent = (claimed === 0 && pooled === 0)
? 'no controllers — press a button or Pair →'
: `${claimed} in use${pooled ? ` · ${pooled} available` : ''}`;
}
// Approve/pool the next controller (main.js auto-selects a not-yet-picked one).
// requestDevice needs the click as its user gesture; the Steam Controller is
// HID-only, so it can only be recognized after this. Gives visible feedback:
// after boot auto-pools everything, there's often nothing new to pair, and the
// old silent no-op read as "the button is broken".
// Approve/pool the next controller. requestDevice needs the click as its user
// gesture; the Steam Controller is HID-only, so it can only be recognized after
// this. Gives visible feedback: after boot auto-pools everything, there's often
// nothing new to pair.
//
// Environment-aware, so a redundant click doesn't stall (issue seen in Electron):
// • Browser — the requestDevice picker IS the only way to grant access, so
// always allow it (prompt: true).
// • Electron — the app auto-grants + auto-pools, so requestDevice is only
// needed for the FIRST grant on a fresh profile. It also enumerates every
// system HID device and briefly blocks. So: pool an already-approved device
// cheaply; only run the scan when nothing is paired yet (first-time setup)
// or the user explicitly asks for it by clicking again ("force scan").
const IS_ELECTRON = navigator.userAgent.includes('Electron');
let _forceScanUntil = 0;
async function pairController() {
const free = SLOT_IDS.find((id) => manager.getSlot(id).state !== 'claimed') || SLOT_IDS[0];
const before = manager._hidPool.size;
flashPairMsg('pairing…'); openCtrlPanel(true);
try {
const dev = await manager.connectHidForSlot(free);
let dev;
if (!IS_ELECTRON) {
dev = await manager.connectHidForSlot(free, { prompt: true });
} else {
dev = await manager.connectHidForSlot(free, { prompt: false }); // cheap: pool an already-approved device
if (!dev) {
const nothingPairedYet = manager._hidPool.size === 0; // fresh profile — scan is justified
const armed = performance.now() < _forceScanUntil; // user clicked again to force it
if (nothingPairedYet || armed) {
_forceScanUntil = 0;
dev = await manager.connectHidForSlot(free, { prompt: true }); // the (blocking) system HID scan
} else {
const n = manager.presentablePoolEntries().length;
_forceScanUntil = performance.now() + 5000;
flashPairMsg(n ? `✓ ${n} connected — click again to scan for a new one` : 'no controller found — click again to scan');
return;
}
}
}
const after = manager._hidPool.size;
if (dev) flashPairMsg('paired ✓ ' + (dev.productName || 'controller'));
else if (after > before) flashPairMsg('paired ✓');
Expand Down Expand Up @@ -374,7 +409,9 @@ function ctrlEntries(pads) {
});
}
let hi = 0;
for (const entry of manager._hidPool.values()) {
// presentablePoolEntries hides idle Steam Puck receiver interfaces (kept pooled
// for power-on, but not usable controllers) — shared core filter.
for (const entry of manager.presentablePoolEntries()) {
const nm = (entry.driver && entry.driver.entry && entry.driver.entry.name) || (entry.device && entry.device.productName) || 'Controller';
items.push({
// index in the key: two identical-vid:pid devices (real DS4 + GameSir spoof)
Expand Down Expand Up @@ -529,6 +566,10 @@ async function boot() {
$('btn-pair').addEventListener('click', pairController);
$('btn-pair-global').addEventListener('click', pairController);
$('btn-ctrl-list').addEventListener('click', toggleCtrlPanel);
$('btn-close-app').addEventListener('click', () => {
if (window.electronAPI && window.electronAPI.quit) window.electronAPI.quit();
else if (window.close) window.close();
});

// Per-unit serial/MAC inventory from the Electron main process (no-op on web).
if (window.electronAPI) {
Expand Down
Loading
Loading