cat / goq
clone

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

ci/pipeline-ergonomics
goq / portal / control-runtime.mjs
control-runtime.mjs 10.8K
1import { browserPointerLockLossRequiresControlExit } from './input-state.mjs';2import { ControllerActivationGate, neutralGamepadInputState } from './controller-state.mjs';34export const BROWSER_POINTER_LOCK_TIMEOUT_MS = 500;5export const NATIVE_CURSOR_RELEASE_RETRY_DELAYS_MS = Object.freeze([0, 16, 50]);67export function createControlRuntime({8 getConnection,9 inputRuntime,10 invokeCursorGrab,11 pointerLock,12 publishController,13 resetControllerEscape,14 onChange,15 onExit = () => {},16 onReleaseFailure,17 pointerLockTimeoutMs = BROWSER_POINTER_LOCK_TIMEOUT_MS,18 scheduleTimeout = globalThis.setTimeout,19 cancelTimeout = globalThis.clearTimeout,20 logger = console,21} = {}) {22 if (typeof getConnection !== 'function') throw new TypeError('getConnection must be a function');23 if (!inputRuntime || typeof inputRuntime.send !== 'function') {24 throw new TypeError('inputRuntime must provide input queue operations');25 }26 if (27 typeof inputRuntime.clear !== 'function'28 || typeof inputRuntime.releaseHeld !== 'function'29 || typeof inputRuntime.drain !== 'function'30 ) {31 throw new TypeError('inputRuntime must provide clear, release, and drain operations');32 }33 if (typeof invokeCursorGrab !== 'function') throw new TypeError('invokeCursorGrab must be a function');34 if (!pointerLock || typeof pointerLock.getOwner !== 'function') {35 throw new TypeError('pointerLock must provide an ownership surface');36 }37 if (38 typeof pointerLock.eventTarget?.addEventListener !== 'function'39 || typeof pointerLock.eventTarget?.removeEventListener !== 'function'40 ) {41 throw new TypeError('pointerLock must provide an event target');42 }43 if (typeof publishController !== 'function') throw new TypeError('publishController must be a function');44 if (typeof resetControllerEscape !== 'function') {45 throw new TypeError('resetControllerEscape must be a function');46 }47 if (typeof onChange !== 'function') throw new TypeError('onChange must be a function');48 if (typeof onExit !== 'function') throw new TypeError('onExit must be a function');49 if (typeof onReleaseFailure !== 'function') {50 throw new TypeError('onReleaseFailure must be a function');51 }5253 const controllerActivationGate = new ControllerActivationGate();54 let controlMode = false;55 let controlTransitionInProgress = false;56 let controlTransitionGeneration = 0;57 let nativeCursorCommand = Promise.resolve();58 let hostReleaseCommand = Promise.resolve();59 let browserPointerLockRequired = true;6061 function connection() {62 return getConnection();63 }6465 function queueNeutralControllerState() {66 const state = connection();67 if (!state.connected || !state.capabilities.gamepad) return false;68 return inputRuntime.send({ t: 'gp', state: neutralGamepadInputState() });69 }7071 function setNativeCursorGrab(grab) {72 const command = nativeCursorCommand.then(() => invokeCursorGrab(grab));73 nativeCursorCommand = command.catch(() => {});74 return command;75 }7677 function waitForBrowserPointerLockOwnership(timeoutMs = pointerLockTimeoutMs) {78 if (pointerLock.getOwner() === pointerLock.target) return Promise.resolve();79 return new Promise((resolve, reject) => {80 let timeoutId;81 const cleanup = () => {82 cancelTimeout(timeoutId);83 pointerLock.eventTarget.removeEventListener('pointerlockchange', handleChange);84 pointerLock.eventTarget.removeEventListener('pointerlockerror', handleError);85 };86 const settle = (callback, value) => {87 cleanup();88 callback(value);89 };90 const handleChange = () => {91 if (pointerLock.getOwner() === pointerLock.target) {92 settle(resolve);93 } else {94 settle(reject, new Error('browser Pointer Lock ownership was lost'));95 }96 };97 const handleError = () => {98 settle(reject, new Error('browser Pointer Lock request was rejected'));99 };100 pointerLock.eventTarget.addEventListener('pointerlockchange', handleChange);101 pointerLock.eventTarget.addEventListener('pointerlockerror', handleError);102 timeoutId = scheduleTimeout(() => {103 settle(reject, new Error('browser Pointer Lock ownership timed out'));104 }, timeoutMs);105 });106 }107108 async function releaseNativeCursorGrab(releaseGeneration) {109 let lastError = null;110 for (const delayMs of NATIVE_CURSOR_RELEASE_RETRY_DELAYS_MS) {111 if (releaseGeneration !== controlTransitionGeneration || controlMode) return false;112 if (delayMs > 0) {113 await new Promise((resolve) => scheduleTimeout(resolve, delayMs));114 if (releaseGeneration !== controlTransitionGeneration || controlMode) return false;115 }116 try {117 await setNativeCursorGrab(false);118 return true;119 } catch (error) {120 lastError = error;121 }122 }123 logger.error('native cursor release failed after bounded retries:', lastError);124 if (!controlMode && releaseGeneration === controlTransitionGeneration) {125 onReleaseFailure(lastError);126 }127 return false;128 }129130 function releasePointerLock() {131 const releaseGeneration = controlTransitionGeneration;132 void releaseNativeCursorGrab(releaseGeneration);133 if (134 pointerLock.getOwner() !== pointerLock.target135 || typeof pointerLock.exit !== 'function'136 ) return;137 try { pointerLock.exit(); } catch (_) {}138 }139140 function exit({ resetEscape = false, releaseHostFocus = true } = {}) {141 if (!controlMode && !controlTransitionInProgress) return false;142 controlTransitionGeneration += 1;143 controlTransitionInProgress = false;144 controllerActivationGate.reset();145 if (resetEscape) resetControllerEscape();146 queueNeutralControllerState();147 inputRuntime.releaseHeld();148 controlMode = false;149 releasePointerLock();150 onChange();151 if (releaseHostFocus) {152 const releaseGeneration = controlTransitionGeneration;153 hostReleaseCommand = Promise.resolve(inputRuntime.drain(250))154 .then(() => {155 if (releaseGeneration !== controlTransitionGeneration || controlMode) return false;156 return onExit();157 })158 .catch((error) => {159 logger.error('host focus release failed after local neutralization:', error);160 return false;161 });162 }163 return true;164 }165166 function exitAfterBrowserPointerLockFailure() {167 if (!browserPointerLockRequired || (!controlMode && !controlTransitionInProgress)) {168 return false;169 }170 exit({ resetEscape: true });171 return true;172 }173174 async function requestBrowserPointerLock() {175 if (!browserPointerLockRequired) return;176 try {177 if (typeof pointerLock.request !== 'function') {178 throw new Error('browser Pointer Lock is unavailable');179 }180 const request = pointerLock.request();181 if (request && typeof request.then === 'function') await request;182 await waitForBrowserPointerLockOwnership();183 // Pointer Lock and the native window grab are separate on Linux. Reassert184 // the native state only after the browser proves ownership.185 await setNativeCursorGrab(true);186 } catch (error) {187 exitAfterBrowserPointerLockFailure();188 throw error;189 }190 }191192 async function requestRelativePointerLock() {193 if (!connection().capabilities.relativePointer) return true;194 try {195 const nativeResult = await setNativeCursorGrab(true);196 // Older commands returned no value, so only an explicit false disables197 // the browser fallback.198 browserPointerLockRequired = nativeResult !== false;199 await requestBrowserPointerLock();200 return true;201 } catch (error) {202 logger.warn('relative pointer lock unavailable:', error);203 return false;204 }205 }206207 async function toggle({ controllerInitiated = false } = {}) {208 const initial = connection();209 if (!initial.connected || !initial.capabilities.control || controlTransitionInProgress) return;210 if (controlMode) {211 exit();212 return;213 }214215 const transitionGeneration = controlTransitionGeneration + 1;216 controlTransitionGeneration = transitionGeneration;217 controlTransitionInProgress = true;218 if (controllerInitiated) controllerActivationGate.arm();219 const acquired = await requestRelativePointerLock();220 const ownsTransition = transitionGeneration === controlTransitionGeneration;221 if (!ownsTransition) {222 // A cancellation may have released before a late browser request took223 // ownership. Do not disturb a newer transition, but do not leave an224 // orphaned lock behind when control is otherwise idle.225 if (226 !controlMode227 && !controlTransitionInProgress228 && pointerLock.getOwner() === pointerLock.target229 ) {230 releasePointerLock();231 }232 return;233 }234 const current = connection();235 if (!acquired236 || current.disconnecting237 || !current.connected238 || !current.capabilities.control) {239 exitAfterBrowserPointerLockFailure();240 return;241 }242 controlTransitionInProgress = false;243 controlMode = true;244 // Preserve the distinct controller branch: it adds a neutral state before245 // republishing the current controller snapshot through the activation gate.246 if (controllerInitiated) {247 queueNeutralControllerState();248 publishController();249 } else {250 publishController();251 }252 onChange();253 }254255 function handleBrowserPointerLockChange() {256 if (!browserPointerLockLossRequiresControlExit({257 browserPointerLockRequired,258 pointerLockElement: pointerLock.getOwner(),259 expectedElement: pointerLock.target,260 controlMode,261 controlTransitionInProgress,262 })) return false;263 return exitAfterBrowserPointerLockFailure();264 }265266 async function prepareDisconnect(timeoutMs = 250) {267 exit();268 await hostReleaseCommand;269 await inputRuntime.drain(timeoutMs);270 }271272 function resetAcceptedConnection() {273 controlMode = false;274 inputRuntime.clear();275 }276277 function resetDisconnected() {278 controlMode = false;279 inputRuntime.clear();280 onChange();281 }282283 function setInactiveIfUnavailable(available) {284 if (!available) controlMode = false;285 return controlMode;286 }287288 return Object.freeze({289 acceptsControllerInput: (state) => controllerActivationGate.accepts(state),290 exit,291 handleBrowserPointerLockChange,292 prepareDisconnect,293 queueNeutralControllerState,294 reassertNativeGrab: () => setNativeCursorGrab(true),295 releasePointerLock,296 requestBrowserPointerLock,297 resetAcceptedConnection,298 resetControllerActivation: () => controllerActivationGate.reset(),299 resetDisconnected,300 setInactiveIfUnavailable,301 toggle,302 get active() { return controlMode; },303 get activationGateActive() { return controllerActivationGate.active; },304 get browserPointerLockRequired() { return browserPointerLockRequired; },305 get generation() { return controlTransitionGeneration; },306 get transitioning() { return controlTransitionInProgress; },307 });308}