cat / goq
clone

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

release/v0.1.0-pins
goq / portal / input-runtime.mjs
input-runtime.mjs 8.4K
1import {2 HeldInputState,3 MAX_HELD_KEYS,4 MAX_HELD_MOUSE_BUTTONS,5 PointerMotionBuffer,6 restoreRejectedPointerMotion,7} from './input-state.mjs';89export const RELIABLE_INPUT_QUEUE_LIMIT = 128;10export const IN_FLIGHT_INPUT_RESTORE_RESERVE = 1;11export const RELIABLE_INPUT_ENQUEUE_LIMIT = RELIABLE_INPUT_QUEUE_LIMIT12 - IN_FLIGHT_INPUT_RESTORE_RESERVE;13export const POINTER_MOTION_BARRIER_RESERVE = 2;14export const RELEASE_INPUT_RESERVE = MAX_HELD_KEYS15 + MAX_HELD_MOUSE_BUTTONS16 + POINTER_MOTION_BARRIER_RESERVE;17export const REGULAR_RELIABLE_INPUT_LIMIT = RELIABLE_INPUT_ENQUEUE_LIMIT18 - RELEASE_INPUT_RESERVE;19export const INPUT_RETRY_MS = 8;2021export 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 }3940 const reliableInputQueue = [];41 const heldInputs = new HeldInputState();42 const pendingPointerMotion = new PointerMotionBuffer();43 let pendingGamepadInput = null;44 let inputPumpRunning = false;45 let inputPumpScheduled = false;4647 function clear() {48 reliableInputQueue.length = 0;49 heldInputs.clear();50 resetControllerActivation();51 pendingPointerMotion.clear();52 pendingGamepadInput = null;53 }5455 function pointerAvailable() {56 const capabilities = getCapabilities();57 return capabilities.relativePointer || capabilities.absolutePointer;58 }5960 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 }7273 function hasPending() {74 return reliableInputQueue.length > 075 || pendingPointerMotion.pending76 || pendingGamepadInput !== null;77 }7879 function schedulePump() {80 if (inputPumpRunning || inputPumpScheduled) return;81 inputPumpScheduled = true;82 scheduleTimeout(() => {83 inputPumpScheduled = false;84 void pump();85 }, 0);86 }8788 function signalFatal(reason, error = null) {89 if (isConnected()) void onFatal({ reason, error });90 }9192 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 }137138 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 }145146 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 }152153 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 }168169 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 }190191 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 }223224 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}