cat / goq
clone

turns a linux box into a private steam streaming appliance: plug in, tap your key, play from another computer

refactor(portal): extract input runtime

Dami committed 0dc7222
commit
0dc7222601f0241c600163f3f5c977905e8cf172
parent
6d8197b1c12fb0132375a158b2ba3e3e258c1db9
author
Dami <dami.etoile@gmail.com>
3 files changed, +574 -213
Aportal/input-runtime.mjs237+++++++++++++++++++++++
Aportal/input-runtime.test.mjs306++++++++++++++++++++++++++++++
Mportal/main.js244+++---------------------
A portal/input-runtime.mjs +237 -0
@@ -0,0 +1,237 @@
1+import {2+ HeldInputState,3+ MAX_HELD_KEYS,4+ MAX_HELD_MOUSE_BUTTONS,5+ PointerMotionBuffer,6+ restoreRejectedPointerMotion,7+} from './input-state.mjs';8+9+export const RELIABLE_INPUT_QUEUE_LIMIT = 128;10+export const IN_FLIGHT_INPUT_RESTORE_RESERVE = 1;11+export const RELIABLE_INPUT_ENQUEUE_LIMIT = RELIABLE_INPUT_QUEUE_LIMIT12+ - IN_FLIGHT_INPUT_RESTORE_RESERVE;13+export const POINTER_MOTION_BARRIER_RESERVE = 2;14+export const RELEASE_INPUT_RESERVE = MAX_HELD_KEYS15+ + MAX_HELD_MOUSE_BUTTONS16+ + POINTER_MOTION_BARRIER_RESERVE;17+export const REGULAR_RELIABLE_INPUT_LIMIT = RELIABLE_INPUT_ENQUEUE_LIMIT18+ - RELEASE_INPUT_RESERVE;19+export const INPUT_RETRY_MS = 8;20+21+export function createInputRuntime({22+ invokeCommand,23+ getCapabilities,24+ isConnected,25+ onFatal,26+ resetControllerActivation,27+ scheduleTimeout = globalThis.setTimeout,28+ scheduleMicrotask = globalThis.queueMicrotask,29+ now = () => performance.now(),30+ logger = console,31+} = {}) {32+ if (typeof invokeCommand !== 'function') throw new TypeError('invokeCommand must be a function');33+ if (typeof getCapabilities !== 'function') throw new TypeError('getCapabilities must be a function');34+ if (typeof isConnected !== 'function') throw new TypeError('isConnected must be a function');35+ if (typeof onFatal !== 'function') throw new TypeError('onFatal must be a function');36+ if (typeof resetControllerActivation !== 'function') {37+ throw new TypeError('resetControllerActivation must be a function');38+ }39+40+ const reliableInputQueue = [];41+ const heldInputs = new HeldInputState();42+ const pendingPointerMotion = new PointerMotionBuffer();43+ let pendingGamepadInput = null;44+ let inputPumpRunning = false;45+ let inputPumpScheduled = false;46+47+ function clear() {48+ reliableInputQueue.length = 0;49+ heldInputs.clear();50+ resetControllerActivation();51+ pendingPointerMotion.clear();52+ pendingGamepadInput = null;53+ }54+55+ function pointerAvailable() {56+ const capabilities = getCapabilities();57+ return capabilities.relativePointer || capabilities.absolutePointer;58+ }59+60+ function inputEventAvailable(event) {61+ const capabilities = getCapabilities();62+ if (!capabilities.control) return false;63+ if (event.t === 'mm') return capabilities.absolutePointer;64+ if (event.t === 'mr') return capabilities.relativePointer;65+ if (event.t === 'mp') return capabilities.relativePointer;66+ if (['mc', 'md', 'mu', 'ms'].includes(event.t)) return pointerAvailable();67+ if (['kd', 'ku', 'kt'].includes(event.t)) return capabilities.keyboard;68+ if (event.t === 'tx') return capabilities.text;69+ if (event.t === 'gp') return capabilities.gamepad;70+ return false;71+ }72+73+ function hasPending() {74+ return reliableInputQueue.length > 075+ || pendingPointerMotion.pending76+ || pendingGamepadInput !== null;77+ }78+79+ function schedulePump() {80+ if (inputPumpRunning || inputPumpScheduled) return;81+ inputPumpScheduled = true;82+ scheduleTimeout(() => {83+ inputPumpScheduled = false;84+ void pump();85+ }, 0);86+ }87+88+ function signalFatal(reason, error = null) {89+ if (isConnected()) void onFatal({ reason, error });90+ }91+92+ function send(event, { release = false } = {}) {93+ if (!inputEventAvailable(event)) return false;94+ if (event.t === 'gp') {95+ // Controller samples are state, not transitions. A single latest-value slot96+ // bounds latency and guarantees a neutral snapshot can replace stale input.97+ pendingGamepadInput = event;98+ } else if (event.t === 'mm') {99+ // Absolute pointer motion is latest-value data; stale coordinates are not100+ // useful and must not create an invocation backlog.101+ pendingPointerMotion.setAbsolute(event);102+ } else if (event.t === 'mr') {103+ // Relative motion is displacement. Coalesce every sample into one104+ // constant-size total; the pump emits protocol-bounded chunks so105+ // throttling preserves distance instead of dropping motion.106+ if (!pendingPointerMotion.addRelative(event.dx, event.dy)) return false;107+ } else {108+ const queueLimit = release ? RELIABLE_INPUT_ENQUEUE_LIMIT : REGULAR_RELIABLE_INPUT_LIMIT;109+ const pointerTransition = ['mp', 'mc', 'md', 'mu', 'ms'].includes(event.t);110+ const barrierLength = pointerTransition ? pendingPointerMotion.barrierLength : 0;111+ const tail = reliableInputQueue.at(-1);112+ const coalesceScroll = event.t === 'ms' && barrierLength === 0 && tail?.t === 'ms';113+ const requiredSlots = coalesceScroll ? 0 : barrierLength + 1;114+ if (reliableInputQueue.length + requiredSlots > queueLimit) {115+ logger.error(release116+ ? 'input release reserve exhausted; closing control session'117+ : 'reliable input queue full; closing control session');118+ // Defer teardown until the caller has removed any transition it could119+ // not enqueue from held-state tracking.120+ scheduleMicrotask(() => signalFatal(121+ release ? 'input release reserve exhausted' : 'reliable input queue full',122+ ));123+ return false;124+ }125+ if (coalesceScroll) {126+ tail.dx = Math.max(-1000000, Math.min(1000000, tail.dx + event.dx));127+ tail.dy = Math.max(-1000000, Math.min(1000000, tail.dy + event.dy));128+ } else if (pointerTransition) {129+ reliableInputQueue.push(...pendingPointerMotion.takeBarrierBefore(event));130+ } else {131+ reliableInputQueue.push(event);132+ }133+ }134+ schedulePump();135+ return true;136+ }137+138+ function releaseHeld() {139+ for (const release of heldInputs.releaseEvents()) {140+ // Ordinary reliable events cannot consume this reserved capacity.141+ send(release, { release: true });142+ }143+ heldInputs.clear();144+ }145+146+ async function drain(timeoutMs) {147+ const deadline = now() + timeoutMs;148+ while ((hasPending() || inputPumpRunning) && now() < deadline) {149+ await new Promise((resolve) => scheduleTimeout(resolve, INPUT_RETRY_MS));150+ }151+ }152+153+ function takePending() {154+ if (reliableInputQueue.length > 0) {155+ return { event: reliableInputQueue.shift(), reliable: true };156+ }157+ const pointerMotionEvent = pendingPointerMotion.take();158+ if (pointerMotionEvent !== null) {159+ return { event: pointerMotionEvent, reliable: false };160+ }161+ if (pendingGamepadInput !== null) {162+ const event = pendingGamepadInput;163+ pendingGamepadInput = null;164+ return { event, reliable: false };165+ }166+ return null;167+ }168+169+ function restorePending(item) {170+ if (item.reliable) {171+ if (reliableInputQueue.length >= RELIABLE_INPUT_QUEUE_LIMIT) {172+ throw new Error('reliable input restore reserve exhausted');173+ }174+ reliableInputQueue.unshift(item.event);175+ } else if (item.event.t === 'mm' || item.event.t === 'mr') {176+ // A transition queued during the invoke must remain after the rejected177+ // motion. With no transition, relative displacement merges while stale178+ // absolute state yields to any newer latest position.179+ restoreRejectedPointerMotion(180+ reliableInputQueue,181+ pendingPointerMotion,182+ item.event,183+ RELIABLE_INPUT_QUEUE_LIMIT,184+ );185+ } else if (item.event.t === 'gp') {186+ // Keep a newer controller sample, especially a neutral release state.187+ if (pendingGamepadInput === null) pendingGamepadInput = item.event;188+ }189+ }190+191+ async function pump() {192+ if (inputPumpRunning) return;193+ inputPumpRunning = true;194+ try {195+ while (hasPending()) {196+ const item = takePending();197+ if (!item) break;198+ let accepted;199+ try {200+ accepted = await invokeCommand('iroh_client_send_input', { event: item.event });201+ } catch (error) {202+ logger.warn('input send failed:', error);203+ clear();204+ return;205+ }206+ if (!accepted) {207+ try {208+ restorePending(item);209+ } catch (error) {210+ logger.error('input restore failed:', error);211+ clear();212+ signalFatal('input restore failed', error);213+ return;214+ }215+ await new Promise((resolve) => scheduleTimeout(resolve, INPUT_RETRY_MS));216+ }217+ }218+ } finally {219+ inputPumpRunning = false;220+ if (hasPending()) schedulePump();221+ }222+ }223+224+ return Object.freeze({225+ clear,226+ drain,227+ hasPending,228+ inputEventAvailable,229+ pointerAvailable,230+ releaseHeld,231+ send,232+ takeKeyRelease: (id) => heldInputs.takeKeyRelease(id),233+ takeMouseButtonRelease: (button) => heldInputs.takeMouseButtonRelease(button),234+ trackKey: (id, value) => heldInputs.trackKey(id, value),235+ trackMouseButton: (button) => heldInputs.trackMouseButton(button),236+ });237+}
A portal/input-runtime.test.mjs +306 -0
@@ -0,0 +1,306 @@
1+import assert from 'node:assert/strict';2+import { readFile } from 'node:fs/promises';3+import test from 'node:test';4+5+import {6+ INPUT_RETRY_MS,7+ REGULAR_RELIABLE_INPUT_LIMIT,8+ RELIABLE_INPUT_ENQUEUE_LIMIT,9+ createInputRuntime,10+} from './input-runtime.mjs';11+12+const main = await readFile(new URL('./main.js', import.meta.url), 'utf8');13+14+const fullCapabilities = Object.freeze({15+ control: true,16+ absolutePointer: true,17+ relativePointer: true,18+ keyboard: true,19+ text: true,20+ gamepad: true,21+});22+23+function newHarness({24+ capabilities = fullCapabilities,25+ invokeCommand = async () => true,26+ connected = true,27+ scheduleTimeout,28+ scheduleMicrotask,29+} = {}) {30+ const fatal = [];31+ const errors = [];32+ const warnings = [];33+ let activationResets = 0;34+ const runtime = createInputRuntime({35+ invokeCommand,36+ getCapabilities: () => capabilities,37+ isConnected: () => connected,38+ onFatal: (detail) => fatal.push(detail),39+ resetControllerActivation: () => { activationResets += 1; },40+ scheduleTimeout,41+ scheduleMicrotask,42+ logger: {43+ error: (...args) => errors.push(args),44+ warn: (...args) => warnings.push(args),45+ },46+ });47+ return {48+ runtime,49+ fatal,50+ errors,51+ warnings,52+ get activationResets() { return activationResets; },53+ };54+}55+56+test('capability filtering preserves the current event routing contract', () => {57+ const capabilities = {58+ control: true,59+ absolutePointer: false,60+ relativePointer: true,61+ keyboard: false,62+ text: true,63+ gamepad: false,64+ };65+ const { runtime } = newHarness({66+ capabilities,67+ scheduleTimeout: () => 0,68+ });69+70+ assert.equal(runtime.pointerAvailable(), true);71+ assert.equal(runtime.inputEventAvailable({ t: 'mm' }), false);72+ assert.equal(runtime.inputEventAvailable({ t: 'mr' }), true);73+ assert.equal(runtime.inputEventAvailable({ t: 'mp' }), true);74+ assert.equal(runtime.inputEventAvailable({ t: 'md' }), true);75+ assert.equal(runtime.inputEventAvailable({ t: 'kd' }), false);76+ assert.equal(runtime.inputEventAvailable({ t: 'tx' }), true);77+ assert.equal(runtime.inputEventAvailable({ t: 'gp' }), false);78+ capabilities.control = false;79+ assert.equal(runtime.inputEventAvailable({ t: 'tx' }), false);80+});81+82+test('reliable transitions run before motion and only the latest gamepad state survives', async () => {83+ const delivered = [];84+ const { runtime } = newHarness({85+ invokeCommand: async (_command, { event }) => {86+ delivered.push(event);87+ return true;88+ },89+ });90+91+ runtime.send({ t: 'gp', state: { a: true } });92+ runtime.send({ t: 'gp', state: { a: false } });93+ runtime.send({ t: 'mr', dx: 4, dy: -2 });94+ runtime.send({ t: 'mr', dx: 3, dy: 1 });95+ runtime.send({ t: 'kd', k: 'a' });96+ await runtime.drain(200);97+98+ assert.deepEqual(delivered, [99+ { t: 'kd', k: 'a' },100+ { t: 'mr', dx: 7, dy: -1 },101+ { t: 'gp', state: { a: false } },102+ ]);103+});104+105+test('absolute pointer state is latest-value-wins', async () => {106+ const delivered = [];107+ const { runtime } = newHarness({108+ invokeCommand: async (_command, { event }) => {109+ delivered.push(event);110+ return true;111+ },112+ });113+114+ runtime.send({ t: 'mm', x: 10, y: 20 });115+ runtime.send({ t: 'mm', x: 30, y: 40 });116+ await runtime.drain(200);117+118+ assert.deepEqual(delivered, [{ t: 'mm', x: 30, y: 40 }]);119+});120+121+test('pointer transition barriers drain every motion chunk and adjacent scroll coalesces', async () => {122+ const delivered = [];123+ const { runtime } = newHarness({124+ invokeCommand: async (_command, { event }) => {125+ delivered.push(event);126+ return true;127+ },128+ });129+130+ runtime.send({ t: 'mr', dx: 50_000, dy: -50_000 });131+ runtime.send({ t: 'md', b: 1 });132+ runtime.send({ t: 'ms', dx: 900_000, dy: -900_000 });133+ runtime.send({ t: 'ms', dx: 900_000, dy: -900_000 });134+ await runtime.drain(200);135+136+ assert.deepEqual(delivered, [137+ { t: 'mr', dx: 32_767, dy: -32_767 },138+ { t: 'mr', dx: 17_233, dy: -17_233 },139+ { t: 'md', b: 1 },140+ { t: 'ms', dx: 1_000_000, dy: -1_000_000 },141+ ]);142+});143+144+test('rejected in-flight motion is restored before a transition queued behind it', async () => {145+ const delivered = [];146+ let startFirst;147+ const firstStarted = new Promise((resolve) => { startFirst = resolve; });148+ let rejectFirst;149+ const firstResult = new Promise((resolve) => { rejectFirst = resolve; });150+ let attempts = 0;151+ const { runtime } = newHarness({152+ invokeCommand: async (_command, { event }) => {153+ delivered.push(event);154+ attempts += 1;155+ if (attempts === 1) {156+ startFirst();157+ return firstResult;158+ }159+ return true;160+ },161+ });162+163+ runtime.send({ t: 'mr', dx: 50_000, dy: 0 });164+ await firstStarted;165+ runtime.send({ t: 'md', b: 1 });166+ rejectFirst(false);167+ await runtime.drain(300);168+169+ assert.deepEqual(delivered, [170+ { t: 'mr', dx: 32_767, dy: 0 },171+ { t: 'mr', dx: 32_767, dy: 0 },172+ { t: 'mr', dx: 17_233, dy: 0 },173+ { t: 'md', b: 1 },174+ ]);175+});176+177+test('a rejected gamepad sample cannot replace newer controller state', async () => {178+ const delivered = [];179+ let startFirst;180+ const firstStarted = new Promise((resolve) => { startFirst = resolve; });181+ let rejectFirst;182+ const firstResult = new Promise((resolve) => { rejectFirst = resolve; });183+ let attempts = 0;184+ const { runtime } = newHarness({185+ invokeCommand: async (_command, { event }) => {186+ delivered.push(event);187+ attempts += 1;188+ if (attempts === 1) {189+ startFirst();190+ return firstResult;191+ }192+ return true;193+ },194+ });195+196+ runtime.send({ t: 'gp', state: { a: true } });197+ await firstStarted;198+ runtime.send({ t: 'gp', state: { a: false } });199+ rejectFirst(false);200+ await runtime.drain(300);201+202+ assert.deepEqual(delivered, [203+ { t: 'gp', state: { a: true } },204+ { t: 'gp', state: { a: false } },205+ ]);206+});207+208+test('regular and release queue limits retain their reserved capacity and fail asynchronously', async () => {209+ const scheduled = [];210+ const microtasks = [];211+ const harness = newHarness({212+ scheduleTimeout: (callback, delay) => {213+ scheduled.push({ callback, delay });214+ return scheduled.length;215+ },216+ scheduleMicrotask: (callback) => microtasks.push(callback),217+ });218+ const { runtime } = harness;219+220+ for (let index = 0; index < REGULAR_RELIABLE_INPUT_LIMIT; index += 1) {221+ assert.equal(runtime.send({ t: 'kd', k: `key-${index}` }), true);222+ }223+ assert.equal(runtime.send({ t: 'kd', k: 'ordinary-overflow' }), false);224+ assert.equal(harness.fatal.length, 0);225+ microtasks.shift()();226+ assert.equal(harness.fatal[0].reason, 'reliable input queue full');227+228+ for (229+ let index = REGULAR_RELIABLE_INPUT_LIMIT;230+ index < RELIABLE_INPUT_ENQUEUE_LIMIT;231+ index += 1232+ ) {233+ assert.equal(runtime.send({ t: 'ku', k: `release-${index}` }, { release: true }), true);234+ }235+ assert.equal(runtime.send({ t: 'ku', k: 'release-overflow' }, { release: true }), false);236+ microtasks.shift()();237+ assert.equal(harness.fatal[1].reason, 'input release reserve exhausted');238+ assert.equal(scheduled.length, 1, 'one pending pump owns the queue');239+});240+241+test('held transitions release once in key-then-button insertion order', async () => {242+ const delivered = [];243+ const { runtime } = newHarness({244+ invokeCommand: async (_command, { event }) => {245+ delivered.push(event);246+ return true;247+ },248+ });249+250+ assert.equal(runtime.trackKey('KeyA', 'a'), 'tracked');251+ assert.equal(runtime.trackKey('KeyA', 'a'), 'repeat');252+ assert.equal(runtime.trackKey('KeyB', 'b'), 'tracked');253+ assert.equal(runtime.trackMouseButton(2), true);254+ assert.equal(runtime.trackMouseButton(1), true);255+ runtime.releaseHeld();256+ runtime.releaseHeld();257+ await runtime.drain(200);258+259+ assert.deepEqual(delivered, [260+ { t: 'ku', k: 'a' },261+ { t: 'ku', k: 'b' },262+ { t: 'mu', b: 2 },263+ { t: 'mu', b: 1 },264+ ]);265+});266+267+test('transport exceptions clear all pending state without escalating disconnect', async () => {268+ let calls = 0;269+ const harness = newHarness({270+ invokeCommand: async () => {271+ calls += 1;272+ throw new Error('transport closed');273+ },274+ });275+ const { runtime } = harness;276+277+ runtime.trackKey('KeyA', 'a');278+ runtime.send({ t: 'kd', k: 'a' });279+ runtime.send({ t: 'gp', state: { a: true } });280+ await runtime.drain(200);281+282+ assert.equal(calls, 1);283+ assert.equal(runtime.hasPending(), false);284+ assert.equal(runtime.takeKeyRelease('KeyA'), null);285+ assert.equal(harness.activationResets, 1);286+ assert.equal(harness.fatal.length, 0);287+ assert.equal(harness.warnings[0][0], 'input send failed:');288+});289+290+test('retry cadence remains eight milliseconds', () => {291+ assert.equal(INPUT_RETRY_MS, 8);292+});293+294+test('Portal delegates queue ownership while retaining DOM input wiring', () => {295+ assert.match(main, /const inputRuntime = createInputRuntime\(\{/);296+ assert.match(297+ main,298+ /resetControllerActivation: \(\) => controllerActivationGate\.reset\(\)/,299+ );300+ assert.match(main, /window\.addEventListener\('mousedown', handleMouseDown/);301+ assert.match(main, /inputRuntime\.trackMouseButton\(btn\)/);302+ assert.match(main, /inputRuntime\.trackKey\(keyId, k\)/);303+ assert.doesNotMatch(main, /const reliableInputQueue =/);304+ assert.doesNotMatch(main, /function sendInput\(/);305+ assert.doesNotMatch(main, /new HeldInputState\(/);306+});
M portal/main.js +31 -213
@@ -1,17 +1,13 @@
11 import {2- HeldInputState,3- MAX_HELD_KEYS,4- MAX_HELD_MOUSE_BUTTONS,5- PointerMotionBuffer,62 browserPointerLockLossRequiresControlExit,73 mapCanvasPointToSurface,84 resolvePointerSurfaceSize,9- restoreRejectedPointerMotion,105 scaleRelativePointerDelta,116 validatePointerSurfaceDimensions,127 validatePointerPositionFeedback,138 browserMouseButtonCode,149 } from './input-state.mjs';10+import { createInputRuntime } from './input-runtime.mjs';1511 import { formatVideoDiscardTelemetry } from './frame-stats.mjs';1612 import { networkDiagnosticsPresentation } from './network-diagnostics.mjs';1713 import { newPointerSession, parsePointerFeedbackMessage } from './pointer-feedback.mjs';
@@ -116,6 +112,13 @@ function handlePointerPositionFeedback(message, session) {
116112 117113 const invoke = (...args) => window.__TAURI__.core.invoke(...args);118114 const listen = (...args) => window.__TAURI__.event.listen(...args);115+const inputRuntime = createInputRuntime({116+ invokeCommand: invoke,117+ getCapabilities: () => connectionState.inputCapabilities,118+ isConnected: () => connectionState.connected,119+ onFatal: () => { void disconnect(); },120+ resetControllerActivation: () => controllerActivationGate.reset(),121+});119122 let videoPipeline = null;120123 let streamRuntime = null;121124 const audioPipeline = createAudioPipelineSession({
@@ -495,7 +498,7 @@ function updateAcceptedConnectionState({ attempt, state }) {
495498 session.remoteVisible = attempt.initialPointerFeedback.pointer_visible;496499 }497500 controlMode = false;498- clearPendingInput();501+ inputRuntime.clear();499502 }500503 501504 async function showConnectedAttempt({ result, attempt }) {
@@ -528,13 +531,13 @@ async function prepareDisconnect() {
528531 controlTransitionInProgress = false;529532 controllerActivationGate.reset();530533 queueNeutralControllerState();531- releaseHeldInputs();534+ inputRuntime.releaseHeld();532535 controlMode = false;533536 releasePointerLock();534537 updateControlUI();535538 // Give release commands a short, finite opportunity to cross the bounded536539 // Tauri queue before closing it. A broken stream cannot stall disconnect.537- await waitForInputDrain(250);540+ await inputRuntime.drain(250);538541 }539542 540543 async function teardownConnectedResources() {
@@ -552,7 +555,7 @@ async function showDisconnectedState() {
552555 lastObservedWindowSize = null;553556 if (streamWindowResizeTimer !== null) clearTimeout(streamWindowResizeTimer);554557 streamWindowResizeTimer = null;555- clearPendingInput();558+ inputRuntime.clear();556559 updateControlUI();557560 updatePanelVisibility();558561 setStatus('', 'idle');
@@ -926,7 +929,7 @@ async function toggleControl() {
926929 controlTransitionGeneration += 1;927930 controllerActivationGate.reset();928931 queueNeutralControllerState();929- releaseHeldInputs();932+ inputRuntime.releaseHeld();930933 controlMode = false;931934 releasePointerLock();932935 } else {
@@ -975,7 +978,7 @@ async function toggleControl() {
975978 976979 function queueNeutralControllerState() {977980 if (!connectionState.connected || !connectionState.inputCapabilities.gamepad) return false;978- return sendInput({ t: 'gp', state: neutralGamepadInputState() });981+ return inputRuntime.send({ t: 'gp', state: neutralGamepadInputState() });979982 }980983 981984 function describeInputCapabilities() {
@@ -1068,7 +1071,7 @@ function exitControlAfterBrowserPointerLockFailure() {
10681071 controllerActivationGate.reset();10691072 controllerEscape.reset();10701073 queueNeutralControllerState();1071- releaseHeldInputs();1074+ inputRuntime.releaseHeld();10721075 controlMode = false;10731076 releasePointerLock();10741077 updateControlUI();
@@ -1191,191 +1194,6 @@ function scaleCoords(clientX, clientY) {
11911194 }) ?? { x: 0, y: 0 };11921195 }11931196 1194-const RELIABLE_INPUT_QUEUE_LIMIT = 128;1195-const IN_FLIGHT_INPUT_RESTORE_RESERVE = 1;1196-const RELIABLE_INPUT_ENQUEUE_LIMIT = RELIABLE_INPUT_QUEUE_LIMIT1197- - IN_FLIGHT_INPUT_RESTORE_RESERVE;1198-const POINTER_MOTION_BARRIER_RESERVE = 2;1199-const RELEASE_INPUT_RESERVE = MAX_HELD_KEYS1200- + MAX_HELD_MOUSE_BUTTONS1201- + POINTER_MOTION_BARRIER_RESERVE;1202-const REGULAR_RELIABLE_INPUT_LIMIT = RELIABLE_INPUT_ENQUEUE_LIMIT - RELEASE_INPUT_RESERVE;1203-const INPUT_RETRY_MS = 8;1204-const reliableInputQueue = [];1205-const heldInputs = new HeldInputState();1206-const pendingPointerMotion = new PointerMotionBuffer();1207-let pendingGamepadInput = null;1208-let inputPumpRunning = false;1209-let inputPumpScheduled = false;1210-1211-function clearPendingInput() {1212- reliableInputQueue.length = 0;1213- heldInputs.clear();1214- controllerActivationGate.reset();1215- pendingPointerMotion.clear();1216- pendingGamepadInput = null;1217-}1218-1219-function inputEventAvailable(event) {1220- const capabilities = connectionState.inputCapabilities;1221- if (!capabilities.control) return false;1222- if (event.t === 'mm') return capabilities.absolutePointer;1223- if (event.t === 'mr') return capabilities.relativePointer;1224- if (event.t === 'mp') return capabilities.relativePointer;1225- if (['mc', 'md', 'mu', 'ms'].includes(event.t)) return pointerInputAvailable();1226- if (['kd', 'ku', 'kt'].includes(event.t)) return capabilities.keyboard;1227- if (event.t === 'tx') return capabilities.text;1228- if (event.t === 'gp') return capabilities.gamepad;1229- return false;1230-}1231-1232-function hasPendingInput() {1233- return reliableInputQueue.length > 01234- || pendingPointerMotion.pending1235- || pendingGamepadInput !== null;1236-}1237-1238-function scheduleInputPump() {1239- if (inputPumpRunning || inputPumpScheduled) return;1240- inputPumpScheduled = true;1241- setTimeout(() => {1242- inputPumpScheduled = false;1243- pumpInputQueue();1244- }, 0);1245-}1246-1247-function sendInput(event, { release = false } = {}) {1248- if (!inputEventAvailable(event)) return false;1249- if (event.t === 'gp') {1250- // Controller samples are state, not transitions. A single latest-value slot1251- // bounds latency and guarantees a neutral snapshot can replace stale input.1252- pendingGamepadInput = event;1253- } else if (event.t === 'mm') {1254- // Absolute pointer motion is latest-value data; stale coordinates are not1255- // useful and must not create an invocation backlog.1256- pendingPointerMotion.setAbsolute(event);1257- } else if (event.t === 'mr') {1258- // Relative motion is displacement. Coalesce every sample into one1259- // constant-size total; the pump emits protocol-bounded chunks so1260- // throttling preserves distance instead of dropping motion.1261- if (!pendingPointerMotion.addRelative(event.dx, event.dy)) return false;1262- } else {1263- const queueLimit = release ? RELIABLE_INPUT_ENQUEUE_LIMIT : REGULAR_RELIABLE_INPUT_LIMIT;1264- const pointerTransition = ['mp', 'mc', 'md', 'mu', 'ms'].includes(event.t);1265- const barrierLength = pointerTransition ? pendingPointerMotion.barrierLength : 0;1266- const tail = reliableInputQueue.at(-1);1267- const coalesceScroll = event.t === 'ms' && barrierLength === 0 && tail?.t === 'ms';1268- const requiredSlots = coalesceScroll ? 0 : barrierLength + 1;1269- if (reliableInputQueue.length + requiredSlots > queueLimit) {1270- console.error(release1271- ? 'input release reserve exhausted; closing control session'1272- : 'reliable input queue full; closing control session');1273- // Defer teardown until the caller has removed any transition it could1274- // not enqueue from held-state tracking.1275- queueMicrotask(() => {1276- if (connectionState.connected) void disconnect();1277- });1278- return false;1279- }1280- if (coalesceScroll) {1281- tail.dx = Math.max(-1000000, Math.min(1000000, tail.dx + event.dx));1282- tail.dy = Math.max(-1000000, Math.min(1000000, tail.dy + event.dy));1283- } else if (pointerTransition) {1284- reliableInputQueue.push(...pendingPointerMotion.takeBarrierBefore(event));1285- } else {1286- reliableInputQueue.push(event);1287- }1288- }1289- scheduleInputPump();1290- return true;1291-}1292-1293-function releaseHeldInputs() {1294- for (const release of heldInputs.releaseEvents()) {1295- // Ordinary reliable events cannot consume this reserved capacity.1296- sendInput(release, { release: true });1297- }1298- heldInputs.clear();1299-}1300-1301-async function waitForInputDrain(timeoutMs) {1302- const deadline = performance.now() + timeoutMs;1303- while ((hasPendingInput() || inputPumpRunning) && performance.now() < deadline) {1304- await new Promise((resolve) => setTimeout(resolve, INPUT_RETRY_MS));1305- }1306-}1307-1308-function takePendingInput() {1309- if (reliableInputQueue.length > 0) {1310- return { event: reliableInputQueue.shift(), reliable: true };1311- }1312- const pointerMotionEvent = pendingPointerMotion.take();1313- if (pointerMotionEvent !== null) {1314- return { event: pointerMotionEvent, reliable: false };1315- }1316- if (pendingGamepadInput !== null) {1317- const event = pendingGamepadInput;1318- pendingGamepadInput = null;1319- return { event, reliable: false };1320- }1321- return null;1322-}1323-1324-function restorePendingInput(item) {1325- if (item.reliable) {1326- if (reliableInputQueue.length >= RELIABLE_INPUT_QUEUE_LIMIT) {1327- throw new Error('reliable input restore reserve exhausted');1328- }1329- reliableInputQueue.unshift(item.event);1330- } else if (item.event.t === 'mm' || item.event.t === 'mr') {1331- // A transition queued during the invoke must remain after the rejected1332- // motion. With no transition, relative displacement merges while stale1333- // absolute state yields to any newer latest position.1334- restoreRejectedPointerMotion(1335- reliableInputQueue,1336- pendingPointerMotion,1337- item.event,1338- RELIABLE_INPUT_QUEUE_LIMIT,1339- );1340- } else if (item.event.t === 'gp') {1341- // Keep a newer controller sample, especially a neutral release state.1342- if (pendingGamepadInput === null) pendingGamepadInput = item.event;1343- }1344-}1345-1346-async function pumpInputQueue() {1347- if (inputPumpRunning) return;1348- inputPumpRunning = true;1349- try {1350- while (hasPendingInput()) {1351- const item = takePendingInput();1352- if (!item) break;1353- let accepted;1354- try {1355- accepted = await invoke('iroh_client_send_input', { event: item.event });1356- } catch (e) {1357- console.warn('input send failed:', e);1358- clearPendingInput();1359- return;1360- }1361- if (!accepted) {1362- try {1363- restorePendingInput(item);1364- } catch (error) {1365- console.error('input restore failed:', error);1366- clearPendingInput();1367- if (connectionState.connected) void disconnect();1368- return;1369- }1370- await new Promise((resolve) => setTimeout(resolve, INPUT_RETRY_MS));1371- }1372- }1373- } finally {1374- inputPumpRunning = false;1375- if (hasPendingInput()) scheduleInputPump();1376- }1377-}1378-13791197 window.addEventListener('mousemove', (e) => {13801198 if (!controlMode || !pointerInputAvailable()) return;13811199 if (connectionState.inputCapabilities.relativePointer) {
@@ -1383,7 +1201,7 @@ window.addEventListener('mousemove', (e) => {
13831201 e.movementX,13841202 e.movementY,13851203 );1386- sendInput({ t: 'mr', ...movement });1204+ inputRuntime.send({ t: 'mr', ...movement });13871205 return;13881206 }13891207 }, { capture: true });
@@ -1393,7 +1211,7 @@ canvas.addEventListener('mousemove', (e) => {
13931211 || !connectionState.inputCapabilities.absolutePointer13941212 || connectionState.inputCapabilities.relativePointer) return;13951213 const { x, y } = scaleCoords(e.clientX, e.clientY);1396- sendInput({ t: 'mm', x, y });1214+ inputRuntime.send({ t: 'mm', x, y });13971215 });13981216 13991217 function handleMouseDown(e) {
@@ -1408,8 +1226,8 @@ function handleMouseDown(e) {
14081226 console.warn('browser pointer lock retry failed:', error);14091227 });14101228 }1411- if (!heldInputs.trackMouseButton(btn)) return;1412- if (!sendInput({ t: 'md', b: btn })) heldInputs.takeMouseButtonRelease(btn);1229+ if (!inputRuntime.trackMouseButton(btn)) return;1230+ if (!inputRuntime.send({ t: 'md', b: btn })) inputRuntime.takeMouseButtonRelease(btn);14131231 }14141232 14151233 window.addEventListener('mousedown', handleMouseDown, { capture: true });
@@ -1418,10 +1236,10 @@ function releaseMouseButton(e) {
14181236 if (!connectionState.connected || !pointerInputAvailable()) return;14191237 const btn = browserMouseButtonCode(e.button);14201238 if (btn === null) return;1421- const release = heldInputs.takeMouseButtonRelease(btn);1239+ const release = inputRuntime.takeMouseButtonRelease(btn);14221240 if (!release) return;14231241 e.preventDefault();1424- sendInput(release, { release: true });1242+ inputRuntime.send(release, { release: true });14251243 }14261244 14271245 window.addEventListener('mouseup', (e) => {
@@ -1440,7 +1258,7 @@ window.addEventListener('wheel', (e) => {
14401258 if (!connectionState.inputCapabilities.relativePointer && e.target !== canvas) return;14411259 e.preventDefault();14421260 if (connectionState.inputCapabilities.relativePointer) e.stopPropagation();1443- sendInput({ t: 'ms', dx: Math.round(e.deltaX), dy: Math.round(e.deltaY) });1261+ inputRuntime.send({ t: 'ms', dx: Math.round(e.deltaX), dy: Math.round(e.deltaY) });14441262 }, { passive: false, capture: true });14451263 14461264 window.addEventListener('keydown', (e) => {
@@ -1449,7 +1267,7 @@ window.addEventListener('keydown', (e) => {
14491267 e.preventDefault();14501268 controllerActivationGate.reset();14511269 queueNeutralControllerState();1452- releaseHeldInputs();1270+ inputRuntime.releaseHeld();14531271 controlMode = false;14541272 releasePointerLock();14551273 updateControlUI();
@@ -1458,7 +1276,7 @@ window.addEventListener('keydown', (e) => {
14581276 const printableText = e.key.length === 1 && !e.ctrlKey && !e.altKey && !e.metaKey;14591277 if (printableText && connectionState.inputCapabilities.text) {14601278 e.preventDefault();1461- sendInput({ t: 'tx', s: e.key });1279+ inputRuntime.send({ t: 'tx', s: e.key });14621280 return;14631281 }14641282 if (!connectionState.inputCapabilities.keyboard) return;
@@ -1466,22 +1284,22 @@ window.addEventListener('keydown', (e) => {
14661284 if (k) {14671285 e.preventDefault();14681286 const keyId = e.code || e.key;1469- const tracking = heldInputs.trackKey(keyId, k);1287+ const tracking = inputRuntime.trackKey(keyId, k);14701288 if (tracking === 'repeat') return;14711289 if (tracking === 'full') {14721290 console.error('held key limit reached; refusing additional key transition');14731291 return;14741292 }1475- if (!sendInput({ t: 'kd', k })) heldInputs.takeKeyRelease(keyId);1293+ if (!inputRuntime.send({ t: 'kd', k })) inputRuntime.takeKeyRelease(keyId);14761294 }14771295 });14781296 14791297 window.addEventListener('keyup', (e) => {14801298 const keyId = e.code || e.key;1481- const release = heldInputs.takeKeyRelease(keyId);1299+ const release = inputRuntime.takeKeyRelease(keyId);14821300 if (!release) return;14831301 e.preventDefault();1484- sendInput(release, { release: true });1302+ inputRuntime.send(release, { release: true });14851303 });14861304 14871305 window.addEventListener('blur', () => {
@@ -1490,7 +1308,7 @@ window.addEventListener('blur', () => {
14901308 controlTransitionInProgress = false;14911309 controllerActivationGate.reset();14921310 queueNeutralControllerState();1493- releaseHeldInputs();1311+ inputRuntime.releaseHeld();14941312 controlMode = false;14951313 releasePointerLock();14961314 updateControlUI();
@@ -1578,7 +1396,7 @@ controllerPublisher.setHandler((state) => {
15781396 || !connectionState.inputCapabilities.gamepad) return;15791397 const inputState = maskGamepadEscapeChord(toGamepadInputState(state));15801398 if (!controllerActivationGate.accepts(inputState)) return;1581- sendInput({ t: 'gp', state: inputState });1399+ inputRuntime.send({ t: 'gp', state: inputState });15821400 });15831401 15841402 function publishCurrentControllerState() {
@@ -1704,7 +1522,7 @@ function pollControllers(nowMs) {
17041522 if (controllerEscape.update(currentControllerState, nowMs)) {17051523 controllerActivationGate.reset();17061524 queueNeutralControllerState();1707- releaseHeldInputs();1525+ inputRuntime.releaseHeld();17081526 controlMode = false;17091527 releasePointerLock();17101528 updateControlUI();