- commit
- 0e5a5dcdb87c4b1b05c0ee9170600217f47ee8d5
- parent
- a745dc94714ef6040026d5dacc3b1f129f1337f6
- author
- Dami <dami.etoile@gmail.com>
| A | portal/ | 122 | +++++++++++++++ |
| A | portal/ | 239 | ++++++++++++++++++++++++++++++ |
| M | portal/ | 114 | +++----------- |
@@ -0,0 +1,122 @@
1+import {2+ ControllerActionRepeater,3+ GamepadEscapeHold,4+ LatestControllerStatePublisher,5+ controllerStateSignature,6+ disconnectedControllerState,7+ maskGamepadEscapeChord,8+ normalizeGamepad,9+ selectPreferredController,10+ toGamepadInputState,11+} from './controller-state.mjs';12+13+export function createControllerRuntime({14+ getGamepads = () => globalThis.navigator?.getGamepads?.() ?? [],15+ schedulePoll = (callback) => requestAnimationFrame(callback),16+ isRemoteRoute = () => false,17+ sendRemoteState = () => {},18+ onNavigate = () => {},19+ onActivate = () => {},20+ onBack = () => {},21+ onStatus = () => {},22+ onExit = () => {},23+ warn = (...args) => console.warn(...args),24+ publisher = new LatestControllerStatePublisher(),25+ actions = new ControllerActionRepeater(),26+ escape = new GamepadEscapeHold(1000),27+} = {}) {28+ let sequence = 0;29+ let selectedIndex = null;30+ let currentState = disconnectedControllerState();31+ let lastSignature = controllerStateSignature(currentState);32+ let observer = null;33+34+ publisher.setHandler((state) => {35+ if (observer) {36+ try {37+ observer(state);38+ } catch (error) {39+ warn('controller observer failed:', error);40+ }41+ }42+ // Route state whenever the remote control path is active. Disconnected and43+ // non-standard samples intentionally map to neutral so controller loss44+ // cannot leave held input on the host.45+ if (!isRemoteRoute()) return;46+ sendRemoteState(maskGamepadEscapeChord(toGamepadInputState(state)));47+ });48+49+ function publishCurrentState() {50+ publisher.publish(currentState);51+ }52+53+ function poll(nowMs) {54+ let gamepads = [];55+ try {56+ gamepads = Array.from(getGamepads() ?? []).filter(Boolean);57+ } catch (error) {58+ warn('gamepad poll failed:', error);59+ }60+ const gamepad = selectPreferredController(gamepads, selectedIndex);61+ const nextIndex = gamepad?.index ?? null;62+ if (nextIndex !== selectedIndex) {63+ selectedIndex = nextIndex;64+ actions.reset();65+ escape.reset();66+ lastSignature = '';67+ }68+69+ currentState = normalizeGamepad(gamepad, sequence + 1);70+ const signature = controllerStateSignature(currentState);71+ if (signature !== lastSignature) {72+ sequence += 1;73+ currentState = normalizeGamepad(gamepad, sequence);74+ lastSignature = signature;75+ publisher.publish(currentState);76+ onStatus(currentState);77+ }78+79+ const remoteRoute = isRemoteRoute()80+ && currentState.connected81+ && currentState.mapping === 'standard';82+ if (remoteRoute) {83+ if (escape.update(currentState, nowMs)) {84+ onExit();85+ actions.reset();86+ }87+ } else {88+ escape.reset();89+ for (const action of actions.update(currentState, nowMs)) {90+ if (action.type === 'navigate') onNavigate(action.direction);91+ else if (action.type === 'activate') onActivate();92+ else if (action.type === 'back') onBack();93+ }94+ }95+ schedulePoll(poll);96+ }97+98+ function start() {99+ schedulePoll(poll);100+ }101+102+ function setObserver(nextObserver) {103+ if (nextObserver !== null && typeof nextObserver !== 'function') {104+ throw new TypeError('observer must be a function or null');105+ }106+ observer = nextObserver;107+ }108+109+ return Object.freeze({110+ poll,111+ publishCurrentState,112+ resetEscape: () => escape.reset(),113+ setObserver,114+ start,115+ get latest() {116+ return publisher.latest;117+ },118+ get current() {119+ return currentState;120+ },121+ });122+}@@ -0,0 +1,239 @@
1+import assert from 'node:assert/strict';2+import { readFileSync } from 'node:fs';3+import test from 'node:test';4+5+import { createControllerRuntime } from './controller-runtime.mjs';6+import { neutralGamepadInputState } from './controller-state.mjs';7+8+const main = readFileSync(new URL('./main.js', import.meta.url), 'utf8');9+10+function gamepad({11+ index = 0,12+ id = 'test-pad',13+ mapping = 'standard',14+ timestamp = 1,15+ axes = [0, 0, 0, 0],16+ pressed = [],17+ values = {},18+} = {}) {19+ const buttons = Array(17).fill(0);20+ for (const button of pressed) buttons[button] = 1;21+ for (const [button, value] of Object.entries(values)) buttons[Number(button)] = value;22+ return {23+ connected: true,24+ index,25+ id,26+ mapping,27+ timestamp,28+ axes,29+ buttons,30+ };31+}32+33+function harness() {34+ let pads = [];35+ let remoteRoute = false;36+ const scheduled = [];37+ const sent = [];38+ const navigated = [];39+ const statuses = [];40+ const warnings = [];41+ let activations = 0;42+ let backs = 0;43+ let exits = 0;44+ const runtime = createControllerRuntime({45+ getGamepads: () => pads,46+ schedulePoll: (callback) => scheduled.push(callback),47+ isRemoteRoute: () => remoteRoute,48+ sendRemoteState: (state) => sent.push(state),49+ onNavigate: (direction) => navigated.push(direction),50+ onActivate: () => { activations++; },51+ onBack: () => { backs++; },52+ onStatus: (state) => statuses.push(state),53+ onExit: () => { exits++; },54+ warn: (...args) => warnings.push(args),55+ });56+ return {57+ runtime,58+ scheduled,59+ sent,60+ navigated,61+ statuses,62+ warnings,63+ get activations() { return activations; },64+ get backs() { return backs; },65+ get exits() { return exits; },66+ setPads(next) { pads = next; },67+ setRemoteRoute(next) { remoteRoute = next; },68+ };69+}70+71+function flushPublisher() {72+ return new Promise((resolve) => setImmediate(resolve));73+}74+75+test('polling selects a standard controller and publishes only signature changes', async () => {76+ const fixture = harness();77+ const observed = [];78+ fixture.runtime.setObserver((state) => observed.push(state));79+ fixture.setRemoteRoute(true);80+ fixture.setPads([gamepad({ timestamp: 10, axes: [0.25, 0, 0, 0] })]);81+82+ fixture.runtime.poll(0);83+ await flushPublisher();84+ assert.equal(fixture.statuses.length, 1);85+ assert.equal(fixture.statuses[0].sequence, 1);86+ assert.equal(fixture.runtime.latest.sequence, 1);87+ assert.equal(observed.length, 1);88+ assert.equal(fixture.sent.length, 1);89+ assert.equal(fixture.sent[0].left_x, 8192);90+ assert.equal(fixture.scheduled.length, 1);91+92+ fixture.setPads([gamepad({ timestamp: 999, axes: [0.25, 0, 0, 0] })]);93+ fixture.runtime.poll(16);94+ await flushPublisher();95+ assert.equal(fixture.statuses.length, 1);96+ assert.equal(observed.length, 1);97+ assert.equal(fixture.runtime.latest.sequence, 1);98+99+ fixture.setPads([gamepad({ timestamp: 1000, axes: [0.5, 0, 0, 0] })]);100+ fixture.runtime.poll(32);101+ await flushPublisher();102+ assert.equal(fixture.statuses.length, 2);103+ assert.equal(fixture.statuses[1].sequence, 2);104+ assert.equal(fixture.runtime.latest.sequence, 2);105+ assert.equal(observed.length, 2);106+});107+108+test('local routing preserves navigation repeat and A/B edge actions', () => {109+ const fixture = harness();110+ fixture.setPads([gamepad({ pressed: [0, 1, 12] })]);111+112+ fixture.runtime.poll(0);113+ assert.deepEqual(fixture.navigated, ['up']);114+ assert.equal(fixture.activations, 1);115+ assert.equal(fixture.backs, 1);116+117+ fixture.runtime.poll(100);118+ assert.deepEqual(fixture.navigated, ['up']);119+ assert.equal(fixture.activations, 1);120+ assert.equal(fixture.backs, 1);121+122+ fixture.runtime.poll(360);123+ assert.deepEqual(fixture.navigated, ['up', 'up']);124+ assert.equal(fixture.activations, 1);125+ assert.equal(fixture.backs, 1);126+});127+128+test('remote routing reserves Back+Start and exits after one uninterrupted second', async () => {129+ const fixture = harness();130+ fixture.setRemoteRoute(true);131+ fixture.setPads([gamepad({ pressed: [8, 9, 12] })]);132+133+ fixture.runtime.poll(0);134+ fixture.runtime.poll(999);135+ assert.equal(fixture.exits, 0);136+ fixture.runtime.poll(1000);137+ assert.equal(fixture.exits, 1);138+ fixture.runtime.poll(2000);139+ assert.equal(fixture.exits, 1);140+ assert.deepEqual(fixture.navigated, []);141+ await flushPublisher();142+ assert.equal(fixture.sent.length, 1);143+ assert.equal(fixture.sent[0].back, false);144+ assert.equal(fixture.sent[0].start, false);145+ assert.equal(fixture.sent[0].dpad_up, true);146+147+ fixture.setPads([gamepad()]);148+ fixture.runtime.poll(2001);149+ fixture.setPads([gamepad({ pressed: [8, 9] })]);150+ fixture.runtime.poll(2100);151+ fixture.runtime.poll(3100);152+ assert.equal(fixture.exits, 2);153+});154+155+test('controller loss publishes a neutral remote state while the control route stays active', async () => {156+ const fixture = harness();157+ fixture.setRemoteRoute(true);158+ fixture.setPads([gamepad({ pressed: [0], axes: [1, 0, 0, 0] })]);159+ fixture.runtime.poll(0);160+ await flushPublisher();161+ assert.equal(fixture.sent[0].a, true);162+163+ fixture.setPads([]);164+ fixture.runtime.poll(16);165+ await flushPublisher();166+ assert.deepEqual(fixture.sent[1], neutralGamepadInputState());167+ assert.equal(fixture.statuses.at(-1).connected, false);168+});169+170+test('manual publication preserves observer and remote routing without changing sequence', async () => {171+ const fixture = harness();172+ fixture.setRemoteRoute(true);173+ fixture.setPads([gamepad({ pressed: [0] })]);174+ fixture.runtime.poll(0);175+ await flushPublisher();176+ const sequence = fixture.runtime.latest.sequence;177+ const observed = [];178+ fixture.runtime.setObserver((state) => observed.push(state));179+180+ fixture.runtime.publishCurrentState();181+ await flushPublisher();182+ assert.equal(fixture.runtime.latest.sequence, sequence);183+ assert.equal(observed.length, 1);184+ assert.equal(fixture.sent.length, 2);185+ assert.throws(() => fixture.runtime.setObserver('invalid'), /observer must be a function or null/);186+});187+188+test('observer and gamepad polling failures are contained without stopping the loop', async () => {189+ const fixture = harness();190+ fixture.setRemoteRoute(true);191+ fixture.setPads([gamepad()]);192+ fixture.runtime.setObserver(() => { throw new Error('observer boom'); });193+ fixture.runtime.poll(0);194+ await flushPublisher();195+ assert.equal(fixture.sent.length, 1);196+ assert.match(String(fixture.warnings[0][1]), /observer boom/);197+198+ const scheduled = [];199+ const warnings = [];200+ const runtime = createControllerRuntime({201+ getGamepads: () => { throw new Error('poll boom'); },202+ schedulePoll: (callback) => scheduled.push(callback),203+ warn: (...args) => warnings.push(args),204+ });205+ runtime.poll(16);206+ assert.equal(scheduled.length, 1);207+ assert.equal(warnings[0][0], 'gamepad poll failed:');208+ assert.match(String(warnings[0][1]), /poll boom/);209+});210+211+test('start schedules the first poll and resetEscape cancels an in-progress hold', () => {212+ const fixture = harness();213+ fixture.runtime.start();214+ assert.equal(fixture.scheduled.length, 1);215+216+ fixture.setRemoteRoute(true);217+ fixture.setPads([gamepad({ pressed: [8, 9] })]);218+ fixture.runtime.poll(0);219+ fixture.runtime.resetEscape();220+ fixture.runtime.poll(1000);221+ assert.equal(fixture.exits, 0);222+ fixture.runtime.poll(2000);223+ assert.equal(fixture.exits, 1);224+});225+226+test('Portal delegates controller state while retaining DOM navigation and polling start', () => {227+ assert.match(main, /import \{ createControllerRuntime \} from '\.\/controller-runtime\.mjs';/);228+ assert.match(main, /controllerRuntime = createControllerRuntime\(\{/);229+ assert.match(main, /onNavigate: navigateControllerFocus/);230+ assert.match(main, /onActivate: activateControllerFocus/);231+ assert.match(main, /onBack: controllerBack/);232+ assert.match(main, /onStatus: updateControllerStatus/);233+ assert.match(main, /requestAnimationFrame\(controllerRuntime\.poll\)/);234+ assert.match(main, /function controllerScope\(\)/);235+ assert.match(main, /function setControllerFocus\(element\)/);236+ assert.doesNotMatch(main, /const controllerPublisher =/);237+ assert.doesNotMatch(main, /let controllerSequence =/);238+ assert.doesNotMatch(main, /function pollControllers\(/);239+});@@ -19,17 +19,9 @@ import {
1919 createAudioPipelineSession,2020 } from './audio-pipeline.mjs';2121 import {22- ControllerActionRepeater,23- GamepadEscapeHold,24- LatestControllerStatePublisher,2522 chooseDirectionalIndex,26- controllerStateSignature,27- disconnectedControllerState,28- maskGamepadEscapeChord,29- normalizeGamepad,30- selectPreferredController,31- toGamepadInputState,3223 } from './controller-state.mjs';24+import { createControllerRuntime } from './controller-runtime.mjs';3325 import {3426 constrainStreamWindowResize,3527 fitInitialStreamWindow,@@ -61,6 +53,7 @@ let streamWindowResizeTimer = null;
6153 let activePointerSession = null;6254 let controllerActivationInProgress = false;6355 let controlRuntime = null;56+let controllerRuntime = null;6457 6558 function applyPointerPositionFeedback(message, session) {6659 if (session !== activePointerSession || !connectionState.inputCapabilities.pointerPositionFeedback) return;@@ -651,8 +644,8 @@ controlRuntime = createControlRuntime({
651644 },652645 eventTarget: document,653646 },654- publishController: () => publishCurrentControllerState(),655- resetControllerEscape: () => controllerEscape.reset(),647+ publishController: () => controllerRuntime?.publishCurrentState(),648+ resetControllerEscape: () => controllerRuntime?.resetEscape(),656649 onChange: () => updateControlUI(),657650 onReleaseFailure: () => setStatus('err', 'cursor release failed · quit app'),658651 });@@ -1192,31 +1185,6 @@ for (const id of ['enrollment-pin', 'intro-pin', 'pin-input']) {
11921185 }11931186 11941187 // ─── Gamepad polling, UI navigation, and latest-state routing ─────────────────1195-const controllerPublisher = new LatestControllerStatePublisher();1196-const controllerActions = new ControllerActionRepeater();1197-const controllerEscape = new GamepadEscapeHold(1000);1198-let controllerSequence = 0;1199-let selectedControllerIndex = null;1200-let currentControllerState = disconnectedControllerState();1201-let lastControllerSignature = controllerStateSignature(currentControllerState);1202-let externalControllerObserver = null;1203-1204-controllerPublisher.setHandler((state) => {1205- if (externalControllerObserver) {1206- try { externalControllerObserver(state); } catch (error) { console.warn('controller observer failed:', error); }1207- }1208- if (!connectionState.connected1209- || !controlRuntime.active1210- || !connectionState.inputCapabilities.gamepad) return;1211- const inputState = maskGamepadEscapeChord(toGamepadInputState(state));1212- if (!controlRuntime.acceptsControllerInput(inputState)) return;1213- inputRuntime.send({ t: 'gp', state: inputState });1214-});1215-1216-function publishCurrentControllerState() {1217- controllerPublisher.publish(currentControllerState);1218-}1219-12201188 function controllerScope() {12211189 const tap = document.getElementById('tap-overlay');12221190 if (!tap.classList.contains('hidden')) return tap;@@ -1299,55 +1267,24 @@ function updateControllerStatus(state) {
12991267 status.classList.toggle('warning', !standard);13001268 }13011269 1302-function pollControllers(nowMs) {1303- let gamepads = [];1304- try {1305- gamepads = typeof navigator.getGamepads === 'function'1306- ? Array.from(navigator.getGamepads()).filter(Boolean)1307- : [];1308- } catch (error) {1309- console.warn('gamepad poll failed:', error);1310- }1311- const gamepad = selectPreferredController(gamepads, selectedControllerIndex);1312- const nextIndex = gamepad?.index ?? null;1313- if (nextIndex !== selectedControllerIndex) {1314- selectedControllerIndex = nextIndex;1315- controllerActions.reset();1316- controllerEscape.reset();1317- lastControllerSignature = '';1318- }1319-1320- currentControllerState = normalizeGamepad(gamepad, controllerSequence + 1);1321- const signature = controllerStateSignature(currentControllerState);1322- if (signature !== lastControllerSignature) {1323- controllerSequence += 1;1324- currentControllerState = normalizeGamepad(gamepad, controllerSequence);1325- lastControllerSignature = signature;1326- controllerPublisher.publish(currentControllerState);1327- updateControllerStatus(currentControllerState);1328- }1329-1330- const remoteRoute = connectionState.connected1270+controllerRuntime = createControllerRuntime({1271+ schedulePoll: (callback) => requestAnimationFrame(callback),1272+ isRemoteRoute: () => connectionState.connected13311273 && controlRuntime.active1332- && connectionState.inputCapabilities.gamepad1333- && currentControllerState.connected1334- && currentControllerState.mapping === 'standard';1335- if (remoteRoute) {1336- if (controllerEscape.update(currentControllerState, nowMs)) {1337- controlRuntime.exit();1338- controllerActions.reset();1339- setControllerFocus(document.getElementById('control-toggle'));1340- }1341- } else {1342- controllerEscape.reset();1343- for (const action of controllerActions.update(currentControllerState, nowMs)) {1344- if (action.type === 'navigate') navigateControllerFocus(action.direction);1345- else if (action.type === 'activate') activateControllerFocus();1346- else if (action.type === 'back') controllerBack();1347- }1348- }1349- requestAnimationFrame(pollControllers);1350-}1274+ && connectionState.inputCapabilities.gamepad,1275+ sendRemoteState: (state) => {1276+ if (!controlRuntime.acceptsControllerInput(state)) return;1277+ inputRuntime.send({ t: 'gp', state });1278+ },1279+ onNavigate: navigateControllerFocus,1280+ onActivate: activateControllerFocus,1281+ onBack: controllerBack,1282+ onStatus: updateControllerStatus,1283+ onExit: () => {1284+ controlRuntime.exit();1285+ setControllerFocus(document.getElementById('control-toggle'));1286+ },1287+});13511288 13521289 document.addEventListener('pointerdown', () => {13531290 document.querySelectorAll('.controller-focus').forEach((item) => item.classList.remove('controller-focus'));@@ -1399,11 +1336,8 @@ Object.assign(window, {
13991336 openEnrollmentReset, cancelEnrollmentReset, confirmEnrollmentReset,14001337 derivePortalIdentity, chooseInvitationFile, confirmInvitation, cancelInvitation,14011338 sigilController: Object.freeze({1402- getLatestState: () => controllerPublisher.latest,1403- setObserver: (observer) => {1404- if (observer !== null && typeof observer !== 'function') throw new TypeError('observer must be a function or null');1405- externalControllerObserver = observer;1406- },1339+ getLatestState: () => controllerRuntime.latest,1340+ setObserver: controllerRuntime.setObserver,14071341 }),14081342 });14091343 @@ -1426,7 +1360,7 @@ async function initialize() {
14261360 status.textContent = 'could not load enrollment';14271361 }14281362 if (!connectionState.developmentMode) scanFido();1429- requestAnimationFrame(pollControllers);1363+ requestAnimationFrame(controllerRuntime.poll);14301364 }14311365 14321366 initialize();