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 control runtime

Dami committed a745dc9
commit
a745dc94714ef6040026d5dacc3b1f129f1337f6
parent
0dc7222601f0241c600163f3f5c977905e8cf172
author
Dami <dami.etoile@gmail.com>
4 files changed, +704 -251
Aportal/control-runtime.mjs290+++++++++++++++++++++++++
Aportal/control-runtime.test.mjs354++++++++++++++++++++++++++++++
Mportal/input-runtime.test.mjs2+-
Mportal/main.js309+++++---------------------
A portal/control-runtime.mjs +290 -0
@@ -0,0 +1,290 @@
1+import { browserPointerLockLossRequiresControlExit } from './input-state.mjs';2+import { ControllerActivationGate, neutralGamepadInputState } from './controller-state.mjs';3+4+export const BROWSER_POINTER_LOCK_TIMEOUT_MS = 500;5+export const NATIVE_CURSOR_RELEASE_RETRY_DELAYS_MS = Object.freeze([0, 16, 50]);6+7+export function createControlRuntime({8+ getConnection,9+ inputRuntime,10+ invokeCursorGrab,11+ pointerLock,12+ publishController,13+ resetControllerEscape,14+ onChange,15+ onReleaseFailure,16+ pointerLockTimeoutMs = BROWSER_POINTER_LOCK_TIMEOUT_MS,17+ scheduleTimeout = globalThis.setTimeout,18+ cancelTimeout = globalThis.clearTimeout,19+ logger = console,20+} = {}) {21+ if (typeof getConnection !== 'function') throw new TypeError('getConnection must be a function');22+ if (!inputRuntime || typeof inputRuntime.send !== 'function') {23+ throw new TypeError('inputRuntime must provide input queue operations');24+ }25+ if (26+ typeof inputRuntime.clear !== 'function'27+ || typeof inputRuntime.releaseHeld !== 'function'28+ || typeof inputRuntime.drain !== 'function'29+ ) {30+ throw new TypeError('inputRuntime must provide clear, release, and drain operations');31+ }32+ if (typeof invokeCursorGrab !== 'function') throw new TypeError('invokeCursorGrab must be a function');33+ if (!pointerLock || typeof pointerLock.getOwner !== 'function') {34+ throw new TypeError('pointerLock must provide an ownership surface');35+ }36+ if (37+ typeof pointerLock.eventTarget?.addEventListener !== 'function'38+ || typeof pointerLock.eventTarget?.removeEventListener !== 'function'39+ ) {40+ throw new TypeError('pointerLock must provide an event target');41+ }42+ if (typeof publishController !== 'function') throw new TypeError('publishController must be a function');43+ if (typeof resetControllerEscape !== 'function') {44+ throw new TypeError('resetControllerEscape must be a function');45+ }46+ if (typeof onChange !== 'function') throw new TypeError('onChange must be a function');47+ if (typeof onReleaseFailure !== 'function') {48+ throw new TypeError('onReleaseFailure must be a function');49+ }50+51+ const controllerActivationGate = new ControllerActivationGate();52+ let controlMode = false;53+ let controlTransitionInProgress = false;54+ let controlTransitionGeneration = 0;55+ let nativeCursorCommand = Promise.resolve();56+ let browserPointerLockRequired = true;57+58+ function connection() {59+ return getConnection();60+ }61+62+ function queueNeutralControllerState() {63+ const state = connection();64+ if (!state.connected || !state.capabilities.gamepad) return false;65+ return inputRuntime.send({ t: 'gp', state: neutralGamepadInputState() });66+ }67+68+ function setNativeCursorGrab(grab) {69+ const command = nativeCursorCommand.then(() => invokeCursorGrab(grab));70+ nativeCursorCommand = command.catch(() => {});71+ return command;72+ }73+74+ function waitForBrowserPointerLockOwnership(timeoutMs = pointerLockTimeoutMs) {75+ if (pointerLock.getOwner() === pointerLock.target) return Promise.resolve();76+ return new Promise((resolve, reject) => {77+ let timeoutId;78+ const cleanup = () => {79+ cancelTimeout(timeoutId);80+ pointerLock.eventTarget.removeEventListener('pointerlockchange', handleChange);81+ pointerLock.eventTarget.removeEventListener('pointerlockerror', handleError);82+ };83+ const settle = (callback, value) => {84+ cleanup();85+ callback(value);86+ };87+ const handleChange = () => {88+ if (pointerLock.getOwner() === pointerLock.target) {89+ settle(resolve);90+ } else {91+ settle(reject, new Error('browser Pointer Lock ownership was lost'));92+ }93+ };94+ const handleError = () => {95+ settle(reject, new Error('browser Pointer Lock request was rejected'));96+ };97+ pointerLock.eventTarget.addEventListener('pointerlockchange', handleChange);98+ pointerLock.eventTarget.addEventListener('pointerlockerror', handleError);99+ timeoutId = scheduleTimeout(() => {100+ settle(reject, new Error('browser Pointer Lock ownership timed out'));101+ }, timeoutMs);102+ });103+ }104+105+ async function releaseNativeCursorGrab(releaseGeneration) {106+ let lastError = null;107+ for (const delayMs of NATIVE_CURSOR_RELEASE_RETRY_DELAYS_MS) {108+ if (releaseGeneration !== controlTransitionGeneration || controlMode) return false;109+ if (delayMs > 0) {110+ await new Promise((resolve) => scheduleTimeout(resolve, delayMs));111+ if (releaseGeneration !== controlTransitionGeneration || controlMode) return false;112+ }113+ try {114+ await setNativeCursorGrab(false);115+ return true;116+ } catch (error) {117+ lastError = error;118+ }119+ }120+ logger.error('native cursor release failed after bounded retries:', lastError);121+ if (!controlMode && releaseGeneration === controlTransitionGeneration) {122+ onReleaseFailure(lastError);123+ }124+ return false;125+ }126+127+ function releasePointerLock() {128+ const releaseGeneration = controlTransitionGeneration;129+ void releaseNativeCursorGrab(releaseGeneration);130+ if (131+ pointerLock.getOwner() !== pointerLock.target132+ || typeof pointerLock.exit !== 'function'133+ ) return;134+ try { pointerLock.exit(); } catch (_) {}135+ }136+137+ function exit({ resetEscape = false } = {}) {138+ controlTransitionGeneration += 1;139+ controlTransitionInProgress = false;140+ controllerActivationGate.reset();141+ if (resetEscape) resetControllerEscape();142+ queueNeutralControllerState();143+ inputRuntime.releaseHeld();144+ controlMode = false;145+ releasePointerLock();146+ onChange();147+ }148+149+ function exitAfterBrowserPointerLockFailure() {150+ if (!browserPointerLockRequired || (!controlMode && !controlTransitionInProgress)) {151+ return false;152+ }153+ exit({ resetEscape: true });154+ return true;155+ }156+157+ async function requestBrowserPointerLock() {158+ if (!browserPointerLockRequired) return;159+ try {160+ if (typeof pointerLock.request !== 'function') {161+ throw new Error('browser Pointer Lock is unavailable');162+ }163+ const request = pointerLock.request();164+ if (request && typeof request.then === 'function') await request;165+ await waitForBrowserPointerLockOwnership();166+ // Pointer Lock and the native window grab are separate on Linux. Reassert167+ // the native state only after the browser proves ownership.168+ await setNativeCursorGrab(true);169+ } catch (error) {170+ exitAfterBrowserPointerLockFailure();171+ throw error;172+ }173+ }174+175+ async function requestRelativePointerLock() {176+ if (!connection().capabilities.relativePointer) return true;177+ try {178+ const nativeResult = await setNativeCursorGrab(true);179+ // Older commands returned no value, so only an explicit false disables180+ // the browser fallback.181+ browserPointerLockRequired = nativeResult !== false;182+ await requestBrowserPointerLock();183+ return true;184+ } catch (error) {185+ logger.warn('relative pointer lock unavailable:', error);186+ return false;187+ }188+ }189+190+ async function toggle({ controllerInitiated = false } = {}) {191+ const initial = connection();192+ if (!initial.connected || !initial.capabilities.control || controlTransitionInProgress) return;193+ if (controlMode) {194+ exit();195+ return;196+ }197+198+ const transitionGeneration = controlTransitionGeneration + 1;199+ controlTransitionGeneration = transitionGeneration;200+ controlTransitionInProgress = true;201+ if (controllerInitiated) controllerActivationGate.arm();202+ const acquired = await requestRelativePointerLock();203+ const ownsTransition = transitionGeneration === controlTransitionGeneration;204+ if (!ownsTransition) {205+ // A cancellation may have released before a late browser request took206+ // ownership. Do not disturb a newer transition, but do not leave an207+ // orphaned lock behind when control is otherwise idle.208+ if (209+ !controlMode210+ && !controlTransitionInProgress211+ && pointerLock.getOwner() === pointerLock.target212+ ) {213+ releasePointerLock();214+ }215+ return;216+ }217+ const current = connection();218+ if (!acquired219+ || current.disconnecting220+ || !current.connected221+ || !current.capabilities.control) {222+ exitAfterBrowserPointerLockFailure();223+ return;224+ }225+ controlTransitionInProgress = false;226+ controlMode = true;227+ // Preserve the distinct controller branch: it adds a neutral state before228+ // republishing the current controller snapshot through the activation gate.229+ if (controllerInitiated) {230+ queueNeutralControllerState();231+ publishController();232+ } else {233+ publishController();234+ }235+ onChange();236+ }237+238+ function handleBrowserPointerLockChange() {239+ if (!browserPointerLockLossRequiresControlExit({240+ browserPointerLockRequired,241+ pointerLockElement: pointerLock.getOwner(),242+ expectedElement: pointerLock.target,243+ controlMode,244+ controlTransitionInProgress,245+ })) return false;246+ return exitAfterBrowserPointerLockFailure();247+ }248+249+ async function prepareDisconnect(timeoutMs = 250) {250+ exit();251+ await inputRuntime.drain(timeoutMs);252+ }253+254+ function resetAcceptedConnection() {255+ controlMode = false;256+ inputRuntime.clear();257+ }258+259+ function resetDisconnected() {260+ controlMode = false;261+ inputRuntime.clear();262+ onChange();263+ }264+265+ function setInactiveIfUnavailable(available) {266+ if (!available) controlMode = false;267+ return controlMode;268+ }269+270+ return Object.freeze({271+ acceptsControllerInput: (state) => controllerActivationGate.accepts(state),272+ exit,273+ handleBrowserPointerLockChange,274+ prepareDisconnect,275+ queueNeutralControllerState,276+ reassertNativeGrab: () => setNativeCursorGrab(true),277+ releasePointerLock,278+ requestBrowserPointerLock,279+ resetAcceptedConnection,280+ resetControllerActivation: () => controllerActivationGate.reset(),281+ resetDisconnected,282+ setInactiveIfUnavailable,283+ toggle,284+ get active() { return controlMode; },285+ get activationGateActive() { return controllerActivationGate.active; },286+ get browserPointerLockRequired() { return browserPointerLockRequired; },287+ get generation() { return controlTransitionGeneration; },288+ get transitioning() { return controlTransitionInProgress; },289+ });290+}
A portal/control-runtime.test.mjs +354 -0
@@ -0,0 +1,354 @@
1+import assert from 'node:assert/strict';2+import { readFile } from 'node:fs/promises';3+import test from 'node:test';4+5+import {6+ BROWSER_POINTER_LOCK_TIMEOUT_MS,7+ NATIVE_CURSOR_RELEASE_RETRY_DELAYS_MS,8+ createControlRuntime,9+} from './control-runtime.mjs';10+11+const main = await readFile(new URL('./main.js', import.meta.url), 'utf8');12+13+function deferred() {14+ let resolve;15+ let reject;16+ const promise = new Promise((resolvePromise, rejectPromise) => {17+ resolve = resolvePromise;18+ reject = rejectPromise;19+ });20+ return { promise, resolve, reject };21+}22+23+async function eventually(assertion, timeoutMs = 300) {24+ const deadline = performance.now() + timeoutMs;25+ let lastError;26+ while (performance.now() < deadline) {27+ try {28+ assertion();29+ return;30+ } catch (error) {31+ lastError = error;32+ await new Promise((resolve) => setTimeout(resolve, 1));33+ }34+ }35+ throw lastError;36+}37+38+class FakeEventTarget {39+ constructor() {40+ this.listeners = new Map();41+ }42+43+ addEventListener(type, listener) {44+ const listeners = this.listeners.get(type) ?? new Set();45+ listeners.add(listener);46+ this.listeners.set(type, listeners);47+ }48+49+ removeEventListener(type, listener) {50+ this.listeners.get(type)?.delete(listener);51+ }52+53+ dispatch(type) {54+ for (const listener of [...(this.listeners.get(type) ?? [])]) listener();55+ }56+}57+58+function newHarness({59+ invokeCursorGrab = async () => false,60+ pointerRequest,61+ pointerLockTimeoutMs,62+ scheduleTimeout,63+} = {}) {64+ const target = {};65+ const eventTarget = new FakeEventTarget();66+ let owner = null;67+ let connected = true;68+ let disconnecting = false;69+ const capabilities = {70+ control: true,71+ relativePointer: true,72+ absolutePointer: false,73+ gamepad: true,74+ };75+ const sent = [];76+ const calls = {77+ clear: 0,78+ releaseHeld: 0,79+ drains: [],80+ publish: 0,81+ resetEscape: 0,82+ change: 0,83+ releaseFailures: [],84+ exits: 0,85+ };86+ const inputRuntime = {87+ send: (event) => { sent.push(event); return true; },88+ clear: () => { calls.clear += 1; },89+ releaseHeld: () => { calls.releaseHeld += 1; },90+ drain: async (timeoutMs) => { calls.drains.push(timeoutMs); },91+ };92+ const pointerLock = {93+ target,94+ eventTarget,95+ getOwner: () => owner,96+ request: pointerRequest ?? (() => {97+ owner = target;98+ eventTarget.dispatch('pointerlockchange');99+ }),100+ exit: () => {101+ calls.exits += 1;102+ owner = null;103+ eventTarget.dispatch('pointerlockchange');104+ },105+ };106+ const warnings = [];107+ const errors = [];108+ const runtime = createControlRuntime({109+ getConnection: () => ({ connected, disconnecting, capabilities }),110+ inputRuntime,111+ invokeCursorGrab,112+ pointerLock,113+ publishController: () => { calls.publish += 1; },114+ resetControllerEscape: () => { calls.resetEscape += 1; },115+ onChange: () => { calls.change += 1; },116+ onReleaseFailure: (error) => calls.releaseFailures.push(error),117+ pointerLockTimeoutMs,118+ scheduleTimeout,119+ logger: {120+ warn: (...args) => warnings.push(args),121+ error: (...args) => errors.push(args),122+ },123+ });124+ return {125+ runtime,126+ calls,127+ capabilities,128+ sent,129+ warnings,130+ errors,131+ eventTarget,132+ target,133+ pointerLock,134+ get owner() { return owner; },135+ set owner(value) { owner = value; },136+ set connected(value) { connected = value; },137+ set disconnecting(value) { disconnecting = value; },138+ };139+}140+141+test('explicit native false bypasses browser lock and controller activation stays gated until A release', async () => {142+ let pointerRequests = 0;143+ const harness = newHarness({144+ invokeCursorGrab: async () => false,145+ pointerRequest: () => { pointerRequests += 1; },146+ });147+148+ await harness.runtime.toggle({ controllerInitiated: true });149+150+ assert.equal(harness.runtime.active, true);151+ assert.equal(harness.runtime.browserPointerLockRequired, false);152+ assert.equal(pointerRequests, 0);153+ assert.equal(harness.calls.publish, 1);154+ assert.equal(harness.sent.length, 1);155+ assert.equal(harness.sent[0].t, 'gp');156+ assert.equal(harness.runtime.activationGateActive, true);157+ assert.equal(harness.runtime.acceptsControllerInput({ a: true }), false);158+ assert.equal(harness.runtime.acceptsControllerInput({ a: false }), true);159+ assert.equal(harness.runtime.activationGateActive, false);160+});161+162+test('ordinary toggle exit queues neutral and releases held input without resetting controller escape', async () => {163+ const harness = newHarness({ invokeCursorGrab: async () => false });164+ await harness.runtime.toggle();165+ await harness.runtime.toggle();166+ await eventually(() => assert.equal(harness.sent.length, 1));167+168+ assert.equal(harness.runtime.active, false);169+ assert.equal(harness.sent[0].t, 'gp');170+ assert.equal(harness.calls.releaseHeld, 1);171+ assert.equal(harness.calls.resetEscape, 0);172+ assert.equal(harness.calls.change, 2);173+});174+175+test('browser acquisition reasserts native grab after ownership and publishes non-controller state', async () => {176+ const grabs = [];177+ const harness = newHarness({178+ invokeCursorGrab: async (grab) => { grabs.push(grab); return true; },179+ });180+181+ await harness.runtime.toggle();182+183+ assert.deepEqual(grabs, [true, true]);184+ assert.equal(harness.owner, harness.target);185+ assert.equal(harness.runtime.active, true);186+ assert.equal(harness.calls.publish, 1);187+ assert.equal(harness.sent.length, 0);188+ assert.equal(harness.calls.change, 1);189+});190+191+test('native cursor commands remain serialized', async () => {192+ const first = deferred();193+ const invocations = [];194+ const harness = newHarness({195+ invokeCursorGrab: (grab) => {196+ invocations.push(grab);197+ return invocations.length === 1 ? first.promise : Promise.resolve(true);198+ },199+ });200+201+ const one = harness.runtime.reassertNativeGrab();202+ const two = harness.runtime.reassertNativeGrab();203+ await Promise.resolve();204+ assert.deepEqual(invocations, [true]);205+ first.resolve(true);206+ await Promise.all([one, two]);207+ assert.deepEqual(invocations, [true, true]);208+});209+210+test('browser ownership timeout exits the transition and resets controller escape', async () => {211+ const harness = newHarness({212+ invokeCursorGrab: async () => true,213+ pointerRequest: () => {},214+ pointerLockTimeoutMs: 5,215+ });216+217+ await harness.runtime.toggle();218+219+ assert.equal(harness.runtime.active, false);220+ assert.equal(harness.runtime.transitioning, false);221+ assert.equal(harness.calls.resetEscape, 1);222+ assert.equal(harness.calls.releaseHeld, 1);223+ assert.equal(harness.calls.change, 1);224+ assert.match(harness.warnings[0][1].message, /timed out/);225+});226+227+test('native-only post-acquisition rejection retains the existing transition-state quirk', async () => {228+ const nativeGrab = deferred();229+ const harness = newHarness({ invokeCursorGrab: async () => nativeGrab.promise });230+ const entering = harness.runtime.toggle();231+ await Promise.resolve();232+ harness.disconnecting = true;233+ nativeGrab.resolve(false);234+ await entering;235+236+ assert.equal(harness.runtime.active, false);237+ assert.equal(harness.runtime.transitioning, true);238+ assert.equal(harness.calls.change, 0);239+ assert.equal(harness.calls.releaseHeld, 0);240+});241+242+test('losing a browser-owned lock exits active control exactly once', async () => {243+ const harness = newHarness({ invokeCursorGrab: async () => true });244+ await harness.runtime.toggle();245+ harness.owner = null;246+247+ assert.equal(harness.runtime.handleBrowserPointerLockChange(), true);248+ assert.equal(harness.runtime.active, false);249+ assert.equal(harness.calls.resetEscape, 1);250+ assert.equal(harness.calls.releaseHeld, 1);251+ assert.equal(harness.runtime.handleBrowserPointerLockChange(), false);252+});253+254+test('a native-only session ignores browser ownership changes', async () => {255+ const harness = newHarness({ invokeCursorGrab: async () => false });256+ await harness.runtime.toggle();257+258+ assert.equal(harness.runtime.handleBrowserPointerLockChange(), false);259+ assert.equal(harness.runtime.active, true);260+ assert.equal(harness.calls.resetEscape, 0);261+});262+263+test('late acquisition cannot reactivate a cancelled transition or leave browser ownership behind', async () => {264+ const firstGrab = deferred();265+ let grabCalls = 0;266+ const harness = newHarness({267+ invokeCursorGrab: async () => {268+ grabCalls += 1;269+ if (grabCalls === 1) return firstGrab.promise;270+ return true;271+ },272+ });273+274+ const entering = harness.runtime.toggle();275+ await Promise.resolve();276+ harness.runtime.exit();277+ firstGrab.resolve(true);278+ await entering;279+ await eventually(() => assert.equal(harness.owner, null));280+281+ assert.equal(harness.runtime.active, false);282+ assert.equal(harness.runtime.transitioning, false);283+ assert.ok(harness.calls.exits >= 1);284+ assert.equal(harness.calls.publish, 0);285+});286+287+test('native cursor release uses exactly three bounded retries before reporting failure', async () => {288+ let acquisition = true;289+ let releaseAttempts = 0;290+ const delays = [];291+ const harness = newHarness({292+ invokeCursorGrab: async (grab) => {293+ if (grab) return acquisition ? false : true;294+ releaseAttempts += 1;295+ throw new Error(`release-${releaseAttempts}`);296+ },297+ scheduleTimeout: (callback, delay) => {298+ delays.push(delay);299+ return setTimeout(callback, 0);300+ },301+ });302+ await harness.runtime.toggle();303+ acquisition = false;304+305+ harness.runtime.exit();306+ await eventually(() => assert.equal(harness.calls.releaseFailures.length, 1));307+308+ assert.equal(releaseAttempts, 3);309+ assert.deepEqual(delays, [16, 50]);310+ assert.deepEqual(NATIVE_CURSOR_RELEASE_RETRY_DELAYS_MS, [0, 16, 50]);311+ assert.equal(harness.errors[0][0], 'native cursor release failed after bounded retries:');312+});313+314+test('disconnect exits control and awaits the requested finite input drain', async () => {315+ const harness = newHarness({ invokeCursorGrab: async () => false });316+ await harness.runtime.toggle();317+ await harness.runtime.prepareDisconnect(250);318+319+ assert.equal(harness.runtime.active, false);320+ assert.equal(harness.calls.releaseHeld, 1);321+ assert.deepEqual(harness.calls.drains, [250]);322+ assert.equal(harness.calls.change, 2);323+});324+325+test('accepted and disconnected resets preserve their distinct UI notification behavior', () => {326+ const harness = newHarness();327+328+ harness.runtime.resetAcceptedConnection();329+ assert.equal(harness.calls.clear, 1);330+ assert.equal(harness.calls.change, 0);331+332+ harness.runtime.resetDisconnected();333+ assert.equal(harness.calls.clear, 2);334+ assert.equal(harness.calls.change, 1);335+});336+337+test('runtime constants pin existing browser timeout and disconnect retry policy', () => {338+ assert.equal(BROWSER_POINTER_LOCK_TIMEOUT_MS, 500);339+ assert.deepEqual(NATIVE_CURSOR_RELEASE_RETRY_DELAYS_MS, [0, 16, 50]);340+});341+342+test('Portal delegates control state while retaining DOM presentation and registration', () => {343+ assert.match(main, /controlRuntime = createControlRuntime\(\{/);344+ assert.match(345+ main,346+ /document\.addEventListener\('pointerlockchange', handleBrowserPointerLockChange\)/,347+ );348+ assert.match(main, /const controlling = controlRuntime\.setInactiveIfUnavailable\(available\)/);349+ assert.match(main, /controlRuntime\.requestBrowserPointerLock\(\)/);350+ assert.match(main, /controlRuntime\.reassertNativeGrab\(\)/);351+ assert.doesNotMatch(main, /let controlMode =/);352+ assert.doesNotMatch(main, /let nativeCursorCommand =/);353+ assert.doesNotMatch(main, /function releaseNativeCursorGrab\(/);354+});
M portal/input-runtime.test.mjs +1 -1
M portal/main.js +59 -250
@@ -1,5 +1,4 @@
11 import {2- browserPointerLockLossRequiresControlExit,32 mapCanvasPointToSurface,43 resolvePointerSurfaceSize,54 scaleRelativePointerDelta,
@@ -8,6 +7,7 @@ import {
87 browserMouseButtonCode,98 } from './input-state.mjs';109 import { createInputRuntime } from './input-runtime.mjs';10+import { createControlRuntime } from './control-runtime.mjs';1111 import { formatVideoDiscardTelemetry } from './frame-stats.mjs';1212 import { networkDiagnosticsPresentation } from './network-diagnostics.mjs';1313 import { newPointerSession, parsePointerFeedbackMessage } from './pointer-feedback.mjs';
@@ -19,7 +19,6 @@ import {
1919 createAudioPipelineSession,2020 } from './audio-pipeline.mjs';2121 import {22- ControllerActivationGate,2322 ControllerActionRepeater,2423 GamepadEscapeHold,2524 LatestControllerStatePublisher,
@@ -27,7 +26,6 @@ import {
2726 controllerStateSignature,2827 disconnectedControllerState,2928 maskGamepadEscapeChord,30- neutralGamepadInputState,3129 normalizeGamepad,3230 selectPreferredController,3331 toGamepadInputState,
@@ -53,7 +51,6 @@ import {
5351 } from './enrollment.mjs';5452 import { mapKey } from './keyboard-map.mjs';5553 56-let controlMode = false;5754 let enrollmentReady = false;5855 let pendingInvitationSummary = null;5956 let currentEnrollmentStatus = null;
@@ -63,11 +60,7 @@ let lastObservedWindowSize = null;
6360 let streamWindowResizeTimer = null;6461 let activePointerSession = null;6562 let controllerActivationInProgress = false;66-let controlTransitionInProgress = false;67-let controlTransitionGeneration = 0;68-let nativeCursorCommand = Promise.resolve();69-let browserPointerLockRequired = true;70-const controllerActivationGate = new ControllerActivationGate();63+let controlRuntime = null;7164 7265 function applyPointerPositionFeedback(message, session) {7366 if (session !== activePointerSession || !connectionState.inputCapabilities.pointerPositionFeedback) return;
@@ -117,7 +110,7 @@ const inputRuntime = createInputRuntime({
117110 getCapabilities: () => connectionState.inputCapabilities,118111 isConnected: () => connectionState.connected,119112 onFatal: () => { void disconnect(); },120- resetControllerActivation: () => controllerActivationGate.reset(),113+ resetControllerActivation: () => controlRuntime?.resetControllerActivation(),121114 });122115 let videoPipeline = null;123116 let streamRuntime = null;
@@ -497,8 +490,7 @@ function updateAcceptedConnectionState({ attempt, state }) {
497490 session.remotePosition = attempt.initialPointerFeedback.position;498491 session.remoteVisible = attempt.initialPointerFeedback.pointer_visible;499492 }500- controlMode = false;501- inputRuntime.clear();493+ controlRuntime.resetAcceptedConnection();502494 }503495 504496 async function showConnectedAttempt({ result, attempt }) {
@@ -527,17 +519,9 @@ async function showConnectedAttempt({ result, attempt }) {
527519 528520 async function prepareDisconnect() {529521 streamRuntime.prepareDisconnect();530- controlTransitionGeneration += 1;531- controlTransitionInProgress = false;532- controllerActivationGate.reset();533- queueNeutralControllerState();534- inputRuntime.releaseHeld();535- controlMode = false;536- releasePointerLock();537- updateControlUI();538522 // Give release commands a short, finite opportunity to cross the bounded539523 // Tauri queue before closing it. A broken stream cannot stall disconnect.540- await inputRuntime.drain(250);524+ await controlRuntime.prepareDisconnect(250);541525 }542526 543527 async function teardownConnectedResources() {
@@ -549,14 +533,12 @@ async function teardownConnectedResources() {
549533 }550534 551535 async function showDisconnectedState() {552- controlMode = false;553536 fittedStreamAspect = null;554537 pendingStreamFit = null;555538 lastObservedWindowSize = null;556539 if (streamWindowResizeTimer !== null) clearTimeout(streamWindowResizeTimer);557540 streamWindowResizeTimer = null;558- inputRuntime.clear();559- updateControlUI();541+ controlRuntime.resetDisconnected();560542 updatePanelVisibility();561543 setStatus('', 'idle');562544 document.getElementById('frame-canvas').style.display = 'none';
@@ -647,6 +629,33 @@ let hasWebCodecs = ('VideoDecoder' in window);
647629 const canvas = document.getElementById('frame-canvas');648630 const remotePointer = document.getElementById('remote-pointer');649631 const ctx = canvas.getContext('2d');632+controlRuntime = createControlRuntime({633+ getConnection: () => ({634+ connected: connectionState.connected,635+ disconnecting: connectionState.disconnecting,636+ capabilities: connectionState.inputCapabilities,637+ }),638+ inputRuntime,639+ invokeCursorGrab: (grab) => invoke('set_client_cursor_grab', { grab }),640+ pointerLock: {641+ target: canvas,642+ getOwner: () => document.pointerLockElement,643+ request: () => {644+ if (typeof canvas.requestPointerLock !== 'function') {645+ throw new Error('browser Pointer Lock is unavailable');646+ }647+ return canvas.requestPointerLock();648+ },649+ exit: () => {650+ if (typeof document.exitPointerLock === 'function') document.exitPointerLock();651+ },652+ eventTarget: document,653+ },654+ publishController: () => publishCurrentControllerState(),655+ resetControllerEscape: () => controllerEscape.reset(),656+ onChange: () => updateControlUI(),657+ onReleaseFailure: () => setStatus('err', 'cursor release failed · quit app'),658+});650659 videoPipeline = createVideoPipelineSession({651660 hasWebCodecs,652661 canvas,
@@ -919,66 +928,9 @@ listen('audio-stats', (event) => {
919928 920929 // ─── Input ────────────────────────────────────────────────────────────────────921930 async function toggleControl() {922- if (!connectionState.connected923- || !connectionState.inputCapabilities.control924- || controlTransitionInProgress) return;925931 // Capture synchronous controller provenance before cursor acquisition yields.926932 // The DOM click dispatcher does not await this async handler.927- const controllerInitiated = controllerActivationInProgress;928- if (controlMode) {929- controlTransitionGeneration += 1;930- controllerActivationGate.reset();931- queueNeutralControllerState();932- inputRuntime.releaseHeld();933- controlMode = false;934- releasePointerLock();935- } else {936- const transitionGeneration = controlTransitionGeneration + 1;937- controlTransitionGeneration = transitionGeneration;938- controlTransitionInProgress = true;939- if (controllerInitiated) controllerActivationGate.arm();940- const acquired = await requestRelativePointerLock();941- const ownsTransition = transitionGeneration === controlTransitionGeneration;942- if (!ownsTransition) {943- // A cancellation may have released before a late browser request took944- // ownership. Do not disturb a newer transition, but do not leave an945- // orphaned lock behind when control is otherwise idle.946- if (!controlMode947- && !controlTransitionInProgress948- && document.pointerLockElement === canvas) {949- releasePointerLock();950- }951- return;952- }953- if (!acquired954- || connectionState.disconnecting955- || !connectionState.connected956- || !connectionState.inputCapabilities.control) {957- exitControlAfterBrowserPointerLockFailure();958- return;959- }960- controlTransitionInProgress = false;961- controlMode = true;962- // A used to cross the local control boundary must not also become an963- // accidental remote A press. Its release forwards the current snapshot,964- // without requiring noisy analog axes to be exactly zero.965- if (controllerInitiated) {966- queueNeutralControllerState();967- // A may have been released while native cursor acquisition was pending,968- // when controller publications were still local-only. Re-submit the969- // latest snapshot through the gate so that release takes effect now;970- // a still-held A remains suppressed and leaves the queued neutral state.971- publishCurrentControllerState();972- } else {973- publishCurrentControllerState();974- }975- }976- updateControlUI();977-}978-979-function queueNeutralControllerState() {980- if (!connectionState.connected || !connectionState.inputCapabilities.gamepad) return false;981- return inputRuntime.send({ t: 'gp', state: neutralGamepadInputState() });933+ await controlRuntime.toggle({ controllerInitiated: controllerActivationInProgress });982934 }983935 984936 function describeInputCapabilities() {
@@ -997,131 +949,12 @@ function pointerInputAvailable() {
997949 return capabilities.relativePointer || capabilities.absolutePointer;998950 }999951 1000-async function requestRelativePointerLock() {1001- if (!connectionState.inputCapabilities.relativePointer) return true;1002- try {1003- const nativeResult = await setNativeCursorGrab(true);1004- // Older commands returned no value, so only an explicit false disables1005- // the browser fallback. Current macOS builds return false because1006- // CoreGraphics owns relative capture and cursor visibility there.1007- browserPointerLockRequired = nativeResult !== false;1008- await requestBrowserPointerLock();1009- return true;1010- } catch (error) {1011- console.warn('relative pointer lock unavailable:', error);1012- return false;1013- }1014-}1015-1016-async function requestBrowserPointerLock() {1017- if (!browserPointerLockRequired) return;1018- try {1019- if (typeof canvas.requestPointerLock !== 'function') {1020- throw new Error('browser Pointer Lock is unavailable');1021- }1022- const request = canvas.requestPointerLock();1023- if (request && typeof request.then === 'function') await request;1024- await waitForBrowserPointerLockOwnership();1025- // Pointer Lock and the native window grab are separate on Linux. Reassert1026- // the native state only after the browser proves canvas ownership.1027- await setNativeCursorGrab(true);1028- } catch (error) {1029- exitControlAfterBrowserPointerLockFailure();1030- throw error;1031- }1032-}1033-1034-function waitForBrowserPointerLockOwnership(timeoutMs = 500) {1035- if (document.pointerLockElement === canvas) return Promise.resolve();1036- return new Promise((resolve, reject) => {1037- let timeoutId;1038- const cleanup = () => {1039- clearTimeout(timeoutId);1040- document.removeEventListener('pointerlockchange', handleChange);1041- document.removeEventListener('pointerlockerror', handleError);1042- };1043- const settle = (callback, value) => {1044- cleanup();1045- callback(value);1046- };1047- const handleChange = () => {1048- if (document.pointerLockElement === canvas) {1049- settle(resolve);1050- } else {1051- settle(reject, new Error('browser Pointer Lock ownership was lost'));1052- }1053- };1054- const handleError = () => {1055- settle(reject, new Error('browser Pointer Lock request was rejected'));1056- };1057- document.addEventListener('pointerlockchange', handleChange);1058- document.addEventListener('pointerlockerror', handleError);1059- timeoutId = setTimeout(() => {1060- settle(reject, new Error('browser Pointer Lock ownership timed out'));1061- }, timeoutMs);1062- });1063-}1064-1065-function exitControlAfterBrowserPointerLockFailure() {1066- if (!browserPointerLockRequired || (!controlMode && !controlTransitionInProgress)) {1067- return false;1068- }1069- controlTransitionGeneration += 1;1070- controlTransitionInProgress = false;1071- controllerActivationGate.reset();1072- controllerEscape.reset();1073- queueNeutralControllerState();1074- inputRuntime.releaseHeld();1075- controlMode = false;1076- releasePointerLock();1077- updateControlUI();1078- return true;1079-}1080-1081952 function handleBrowserPointerLockChange() {1082- if (!browserPointerLockLossRequiresControlExit({1083- browserPointerLockRequired,1084- pointerLockElement: document.pointerLockElement,1085- expectedElement: canvas,1086- controlMode,1087- controlTransitionInProgress,1088- })) return;1089- exitControlAfterBrowserPointerLockFailure();953+ controlRuntime.handleBrowserPointerLockChange();1090954 }1091955 1092956 document.addEventListener('pointerlockchange', handleBrowserPointerLockChange);1093957 1094-function setNativeCursorGrab(grab) {1095- const command = nativeCursorCommand.then(1096- () => invoke('set_client_cursor_grab', { grab }),1097- );1098- nativeCursorCommand = command.catch(() => {});1099- return command;1100-}1101-1102-async function releaseNativeCursorGrab(releaseGeneration) {1103- const retryDelaysMs = [0, 16, 50];1104- let lastError = null;1105- for (const delayMs of retryDelaysMs) {1106- if (releaseGeneration !== controlTransitionGeneration || controlMode) return false;1107- if (delayMs > 0) {1108- await new Promise((resolve) => setTimeout(resolve, delayMs));1109- if (releaseGeneration !== controlTransitionGeneration || controlMode) return false;1110- }1111- try {1112- await setNativeCursorGrab(false);1113- return true;1114- } catch (error) {1115- lastError = error;1116- }1117- }1118- console.error('native cursor release failed after bounded retries:', lastError);1119- if (!controlMode && releaseGeneration === controlTransitionGeneration) {1120- setStatus('err', 'cursor release failed · quit app');1121- }1122- return false;1123-}1124-1125958 function currentPointerSurfaceSize() {1126959 const { width: frameWidth, height: frameHeight } = videoPipeline.format;1127960 return resolvePointerSurfaceSize(
@@ -1135,7 +968,7 @@ function currentPointerSurfaceSize() {
1135968 function renderRemotePointer() {1136969 const session = activePointerSession;1137970 const surface = currentPointerSurfaceSize();1138- const visible = controlMode971+ const visible = controlRuntime.active1139972 && connectionState.inputCapabilities.relativePointer1140973 && connectionState.inputCapabilities.pointerPositionFeedback1141974 && session?.remotePosition !== null
@@ -1151,31 +984,24 @@ function renderRemotePointer() {
1151984 + (session.remotePosition.y / surface.height) * canvasRect.height}px`;1152985 }1153986 1154-function releasePointerLock() {1155- const releaseGeneration = controlTransitionGeneration;1156- void releaseNativeCursorGrab(releaseGeneration);1157- if (document.pointerLockElement !== canvas || typeof document.exitPointerLock !== 'function') return;1158- try { document.exitPointerLock(); } catch (_) {}1159-}1160-1161987 function updateControlUI() {1162988 const el = document.getElementById('control-toggle');1163989 const capabilities = connectionState.inputCapabilities;1164990 const available = connectionState.connected && capabilities.control;1165- if (!available) controlMode = false;991+ const controlling = controlRuntime.setInactiveIfUnavailable(available);1166992 el.textContent = available1167- ? `${controlMode ? 'controlling' : 'take control'} · ${describeInputCapabilities()}${controlMode && capabilities.relativePointer ? ' · Ctrl+Alt+Esc to exit' : controlMode && capabilities.gamepad ? ' · hold Back+Start to exit' : ''}`993+ ? `${controlling ? 'controlling' : 'take control'} · ${describeInputCapabilities()}${controlling && capabilities.relativePointer ? ' · Ctrl+Alt+Esc to exit' : controlling && capabilities.gamepad ? ' · hold Back+Start to exit' : ''}`1168994 : 'view only · input unavailable';1169- el.classList.toggle('active', controlMode);995+ el.classList.toggle('active', controlling);1170996 el.classList.toggle('disabled', !available);1171997 el.setAttribute('aria-disabled', available ? 'false' : 'true');1172998 document.body.classList.toggle(1173999 'native-pointer-control',1174- controlMode && capabilities.relativePointer,1000+ controlling && capabilities.relativePointer,11751001 );11761002 canvas.classList.toggle(11771003 'relative-control',1178- controlMode && capabilities.relativePointer,1004+ controlling && capabilities.relativePointer,11791005 );11801006 renderRemotePointer();11811007 const streamControl = document.getElementById('stream-control');
@@ -1195,7 +1021,7 @@ function scaleCoords(clientX, clientY) {
11951021 }11961022 11971023 window.addEventListener('mousemove', (e) => {1198- if (!controlMode || !pointerInputAvailable()) return;1024+ if (!controlRuntime.active || !pointerInputAvailable()) return;11991025 if (connectionState.inputCapabilities.relativePointer) {12001026 const movement = scaleRelativePointerDelta(12011027 e.movementX,
@@ -1207,7 +1033,7 @@ window.addEventListener('mousemove', (e) => {
12071033 }, { capture: true });12081034 12091035 canvas.addEventListener('mousemove', (e) => {1210- if (!controlMode1036+ if (!controlRuntime.active12111037 || !connectionState.inputCapabilities.absolutePointer12121038 || connectionState.inputCapabilities.relativePointer) return;12131039 const { x, y } = scaleCoords(e.clientX, e.clientY);
@@ -1215,14 +1041,14 @@ canvas.addEventListener('mousemove', (e) => {
12151041 });12161042 12171043 function handleMouseDown(e) {1218- if (!controlMode || !pointerInputAvailable()) return;1044+ if (!controlRuntime.active || !pointerInputAvailable()) return;12191045 if (connectionState.inputCapabilities.relativePointer) e.stopPropagation();12201046 else if (e.target !== canvas) return;12211047 const btn = browserMouseButtonCode(e.button);12221048 if (btn === null) return;12231049 e.preventDefault();12241050 if (connectionState.inputCapabilities.relativePointer && document.pointerLockElement !== canvas) {1225- void requestBrowserPointerLock().catch((error) => {1051+ void controlRuntime.requestBrowserPointerLock().catch((error) => {12261052 console.warn('browser pointer lock retry failed:', error);12271053 });12281054 }
@@ -1243,18 +1069,18 @@ function releaseMouseButton(e) {
12431069 }12441070 12451071 window.addEventListener('mouseup', (e) => {1246- if (controlMode && connectionState.inputCapabilities.relativePointer) e.stopPropagation();1072+ if (controlRuntime.active && connectionState.inputCapabilities.relativePointer) e.stopPropagation();12471073 releaseMouseButton(e);12481074 }, { capture: true });12491075 12501076 window.addEventListener('contextmenu', (e) => {1251- if (!controlMode || !pointerInputAvailable()) return;1077+ if (!controlRuntime.active || !pointerInputAvailable()) return;12521078 if (!connectionState.inputCapabilities.relativePointer && e.target !== canvas) return;12531079 e.preventDefault();12541080 }, { capture: true });12551081 12561082 window.addEventListener('wheel', (e) => {1257- if (!controlMode || !pointerInputAvailable()) return;1083+ if (!controlRuntime.active || !pointerInputAvailable()) return;12581084 if (!connectionState.inputCapabilities.relativePointer && e.target !== canvas) return;12591085 e.preventDefault();12601086 if (connectionState.inputCapabilities.relativePointer) e.stopPropagation();
@@ -1262,15 +1088,10 @@ window.addEventListener('wheel', (e) => {
12621088 }, { passive: false, capture: true });12631089 12641090 window.addEventListener('keydown', (e) => {1265- if (!controlMode) return;1091+ if (!controlRuntime.active) return;12661092 if (e.key === 'Escape' && e.ctrlKey && e.altKey) {12671093 e.preventDefault();1268- controllerActivationGate.reset();1269- queueNeutralControllerState();1270- inputRuntime.releaseHeld();1271- controlMode = false;1272- releasePointerLock();1273- updateControlUI();1094+ controlRuntime.exit();12741095 return;12751096 }12761097 const printableText = e.key.length === 1 && !e.ctrlKey && !e.altKey && !e.metaKey;
@@ -1303,15 +1124,8 @@ window.addEventListener('keyup', (e) => {
13031124 });13041125 13051126 window.addEventListener('blur', () => {1306- if (!controlMode && !controlTransitionInProgress) return;1307- controlTransitionGeneration += 1;1308- controlTransitionInProgress = false;1309- controllerActivationGate.reset();1310- queueNeutralControllerState();1311- inputRuntime.releaseHeld();1312- controlMode = false;1313- releasePointerLock();1314- updateControlUI();1127+ if (!controlRuntime.active && !controlRuntime.transitioning) return;1128+ controlRuntime.exit();13151129 });13161130 13171131 // ─── Controller PIN entry ─────────────────────────────────────────────────────
@@ -1392,10 +1206,10 @@ controllerPublisher.setHandler((state) => {
13921206 try { externalControllerObserver(state); } catch (error) { console.warn('controller observer failed:', error); }13931207 }13941208 if (!connectionState.connected1395- || !controlMode1209+ || !controlRuntime.active13961210 || !connectionState.inputCapabilities.gamepad) return;13971211 const inputState = maskGamepadEscapeChord(toGamepadInputState(state));1398- if (!controllerActivationGate.accepts(inputState)) return;1212+ if (!controlRuntime.acceptsControllerInput(inputState)) return;13991213 inputRuntime.send({ t: 'gp', state: inputState });14001214 });14011215
@@ -1514,18 +1328,13 @@ function pollControllers(nowMs) {
15141328 }15151329 15161330 const remoteRoute = connectionState.connected1517- && controlMode1331+ && controlRuntime.active15181332 && connectionState.inputCapabilities.gamepad15191333 && currentControllerState.connected15201334 && currentControllerState.mapping === 'standard';15211335 if (remoteRoute) {15221336 if (controllerEscape.update(currentControllerState, nowMs)) {1523- controllerActivationGate.reset();1524- queueNeutralControllerState();1525- inputRuntime.releaseHeld();1526- controlMode = false;1527- releasePointerLock();1528- updateControlUI();1337+ controlRuntime.exit();15291338 controllerActions.reset();15301339 setControllerFocus(document.getElementById('control-toggle'));15311340 }
@@ -1551,13 +1360,13 @@ window.addEventListener('resize', () => {
15511360 });15521361 15531362 window.addEventListener('focus', () => {1554- if (!controlMode || !connectionState.inputCapabilities.relativePointer) return;1363+ if (!controlRuntime.active || !connectionState.inputCapabilities.relativePointer) return;15551364 // WebKit rebuilds its native cursor rectangles after the window-level focus15561365 // event. Reassert from the webview on the next frame so the final rectangle15571366 // is the transparent one used during relative control.15581367 requestAnimationFrame(() => {1559- if (!controlMode || !connectionState.inputCapabilities.relativePointer) return;1560- void setNativeCursorGrab(true).catch((error) => {1368+ if (!controlRuntime.active || !connectionState.inputCapabilities.relativePointer) return;1369+ void controlRuntime.reassertNativeGrab().catch((error) => {15611370 console.warn('native cursor re-hide after focus failed:', error);15621371 });15631372 });