- commit
- 2371dd2c77ac201560dcea44dc04c07534ead37e
- parent
- f1e56973ae130866e280149daed19a941906200a
- author
- Dami <dami.etoile@gmail.com>
| M | portal/ | 10 | - |
| A | portal/ | 205 | +++++++++++ |
| A | portal/ | 241 | +++++++++++++ |
| M | portal/ | 562 | ++++++++++++++---------------- |
@@ -1,5 +1,4 @@
11 import assert from 'node:assert/strict';2-import { readFile } from 'node:fs/promises';32 import test from 'node:test';43 54 import {@@ -7,15 +6,6 @@ import {
76 disconnectRejectedRustConnection,87 } from './connection-attempt.mjs';98 10-const main = await readFile(new URL('./main.js', import.meta.url), 'utf8');11-12-test('connection diagnostics recognize grouped media v3', () => {13- assert.match(14- main,15- /streamTransportMode = \[\s*'grouped-v3',\s*'independent-v2',\s*'reliable-v1',\s*'reliable-v0',\s*\]/,16- );17-});18-199 test('only an explicitly connected result owns committed Rust state', () => {2010 assert.equal(committedRustConnection({ connected: true }), true);2111 assert.equal(committedRustConnection({ connected: false }), false);@@ -0,0 +1,205 @@
1+import {2+ committedRustConnection,3+ disconnectRejectedRustConnection,4+} from './connection-attempt.mjs';5+6+export const MEDIA_TRANSPORT_ALLOWLIST = Object.freeze([7+ 'grouped-v3',8+ 'independent-v2',9+ 'reliable-v1',10+ 'reliable-v0',11+]);12+13+export function normalizeMediaTransport(value) {14+ return MEDIA_TRANSPORT_ALLOWLIST.includes(value) ? value : 'unknown';15+}16+17+function emptyInputCapabilities() {18+ return {19+ relativePointer: false,20+ pointerPositionFeedback: false,21+ absolutePointer: false,22+ keyboard: false,23+ text: false,24+ gamepad: false,25+ control: false,26+ };27+}28+29+function normalizeInputCapabilities(result) {30+ const capabilities = {31+ relativePointer: result.relative_pointer_available === true,32+ pointerPositionFeedback: result.pointer_position_feedback_available === true,33+ absolutePointer: result.absolute_pointer_available === true,34+ keyboard: result.keyboard_available === true,35+ text: result.text_available === true,36+ gamepad: result.gamepad_available === true,37+ control: result.control_available === true,38+ };39+ capabilities.control = capabilities.control40+ && (capabilities.relativePointer41+ || capabilities.absolutePointer42+ || capabilities.keyboard43+ || capabilities.text44+ || capabilities.gamepad);45+ return capabilities;46+}47+48+function connectionStatus(result, capabilities) {49+ return [50+ 'connected',51+ capabilities.control ? null : 'view only',52+ result.development_mode ? 'dev direct-node' : null,53+ ].filter(Boolean).join(' · ');54+}55+56+function validateGeneration(result) {57+ if (result.connected58+ && (!Number.isSafeInteger(result.media_generation) || result.media_generation <= 0)) {59+ throw new Error('host returned an invalid media generation');60+ }61+ if (result.connected62+ && result.audio_available === true63+ && (!Number.isSafeInteger(result.audio_generation) || result.audio_generation <= 0)) {64+ throw new Error('host returned an invalid audio generation');65+ }66+}67+68+const noop = () => {};69+const asyncNoop = async () => {};70+71+export function newConnectionState({72+ invokeCommand,73+ createChannel = null,74+ beforeConnect = noop,75+ createAttempt = asyncNoop,76+ validateConnectedResult = noop,77+ activateAttempt = noop,78+ closeAttempt = noop,79+ teardownAttempt = asyncNoop,80+ beforeDisconnect = asyncNoop,81+ teardownConnection = asyncNoop,82+ onStatus = noop,83+ onConnected = noop,84+ onDisconnected = noop,85+ onFailure = noop,86+ onDisconnectError = noop,87+ onNativeDisconnectError = noop,88+} = {}) {89+ if (typeof invokeCommand !== 'function') {90+ throw new TypeError('invokeCommand must be a function');91+ }92+93+ const state = {94+ connected: false,95+ connecting: false,96+ disconnecting: false,97+ developmentMode: false,98+ inputCapabilities: emptyInputCapabilities(),99+ mediaTransport: 'unknown',100+ };101+102+ const snapshot = () => ({103+ connected: state.connected,104+ connecting: state.connecting,105+ disconnecting: state.disconnecting,106+ developmentMode: state.developmentMode,107+ inputCapabilities: { ...state.inputCapabilities },108+ mediaTransport: state.mediaTransport,109+ });110+111+ async function connect({ pin = '' } = {}) {112+ if (state.connecting || state.connected) return false;113+ state.connecting = true;114+ let attempt = null;115+ let rustConnectionCommitted = false;116+ try {117+ const normalizedPin = String(pin).trim();118+ if (!state.developmentMode && !normalizedPin) {119+ onStatus('err', 'enter pin');120+ return false;121+ }122+123+ beforeConnect();124+ onStatus('pending', 'connecting...');125+ attempt = await createAttempt({ createChannel });126+ const connectionArgs = attempt?.connectionArgs ?? {};127+ const result = await invokeCommand('iroh_client_connect', {128+ pin: normalizedPin,129+ ...connectionArgs,130+ });131+ rustConnectionCommitted = committedRustConnection(result);132+ validateGeneration(result);133+ validateConnectedResult(result, attempt);134+135+ if (!result.connected) {136+ closeAttempt(attempt, { committed: false, rejected: true });137+ await teardownAttempt(attempt);138+ onStatus('err', 'failed');139+ return false;140+ }141+142+ activateAttempt(result, attempt);143+ state.connected = true;144+ state.disconnecting = false;145+ state.inputCapabilities = normalizeInputCapabilities(result);146+ state.mediaTransport = normalizeMediaTransport(result.media_transport);147+ onStatus('ok', connectionStatus(result, state.inputCapabilities), {148+ result,149+ attempt,150+ state: snapshot(),151+ });152+ await onConnected({ result, attempt, state: snapshot() });153+ rustConnectionCommitted = false;154+ return true;155+ } catch (error) {156+ closeAttempt(attempt, { committed: rustConnectionCommitted, error });157+ if (rustConnectionCommitted) {158+ try {159+ await disconnectRejectedRustConnection(invokeCommand, rustConnectionCommitted);160+ } catch (disconnectError) {161+ onNativeDisconnectError(disconnectError);162+ }163+ }164+ await teardownAttempt(attempt);165+ onFailure({ error, attempt, state: snapshot() });166+ onStatus('err', 'error');167+ return false;168+ } finally {169+ state.connecting = false;170+ }171+ }172+173+ async function disconnect() {174+ if (state.disconnecting) return false;175+ state.disconnecting = true;176+ await beforeDisconnect({ state: snapshot() });177+ try {178+ await invokeCommand('iroh_client_disconnect');179+ } catch (error) {180+ onDisconnectError(error);181+ }182+ await teardownConnection();183+ state.connected = false;184+ state.inputCapabilities = emptyInputCapabilities();185+ state.mediaTransport = 'unknown';186+ await onDisconnected({ state: snapshot() });187+ state.disconnecting = false;188+ return true;189+ }190+191+ return {192+ get connected() { return state.connected; },193+ get connecting() { return state.connecting; },194+ get disconnecting() { return state.disconnecting; },195+ get developmentMode() { return state.developmentMode; },196+ get inputCapabilities() { return { ...state.inputCapabilities }; },197+ get mediaTransport() { return state.mediaTransport; },198+ snapshot,199+ setDevelopmentMode(enabled) {200+ state.developmentMode = enabled === true;201+ },202+ connect,203+ disconnect,204+ };205+}@@ -0,0 +1,241 @@
1+import assert from 'node:assert/strict';2+import test from 'node:test';3+4+import {5+ MEDIA_TRANSPORT_ALLOWLIST,6+ newConnectionState,7+ normalizeMediaTransport,8+} from './connection-state.mjs';9+10+function connectedResult(overrides = {}) {11+ return {12+ connected: true,13+ media_generation: 1,14+ audio_available: false,15+ audio_generation: null,16+ relative_pointer_available: false,17+ pointer_position_feedback_available: false,18+ absolute_pointer_available: false,19+ keyboard_available: true,20+ text_available: false,21+ gamepad_available: false,22+ control_available: true,23+ media_transport: 'grouped-v3',24+ development_mode: false,25+ ...overrides,26+ };27+}28+29+function harness(results = [connectedResult()]) {30+ const commands = [];31+ const attempts = [];32+ const statuses = [];33+ const events = [];34+ let nextResult = 0;35+ const state = newConnectionState({36+ invokeCommand: async (command, args) => {37+ commands.push({ command, args });38+ if (command === 'iroh_client_connect') return results[nextResult++];39+ return undefined;40+ },41+ createChannel: class FakeChannel {},42+ createAttempt: async ({ createChannel }) => {43+ const attempt = {44+ id: attempts.length + 1,45+ connectionArgs: { frameChannel: new createChannel() },46+ };47+ attempts.push(attempt);48+ events.push(`create:${attempt.id}`);49+ return attempt;50+ },51+ validateConnectedResult: (_result, attempt) => events.push(`validate:${attempt.id}`),52+ activateAttempt: (_result, attempt) => events.push(`activate:${attempt.id}`),53+ closeAttempt: (attempt, context) => events.push(54+ `close:${attempt?.id ?? 'none'}:${context.committed ? 'committed' : 'uncommitted'}`,55+ ),56+ teardownAttempt: async (attempt) => events.push(`teardown:${attempt?.id ?? 'none'}`),57+ beforeDisconnect: async () => events.push('before-disconnect'),58+ teardownConnection: async () => events.push('teardown-connection'),59+ onStatus: (kind, detail) => statuses.push([kind, detail]),60+ onConnected: async ({ attempt }) => events.push(`connected:${attempt.id}`),61+ onDisconnected: async () => events.push('disconnected'),62+ onFailure: ({ error }) => events.push(`failure:${error.message}`),63+ });64+ return { state, commands, attempts, statuses, events };65+}66+67+test('pins the current transport allowlist including the known iroh-moq gap', () => {68+ assert.deepEqual([...MEDIA_TRANSPORT_ALLOWLIST], [69+ 'grouped-v3',70+ 'independent-v2',71+ 'reliable-v1',72+ 'reliable-v0',73+ ]);74+ for (const transport of MEDIA_TRANSPORT_ALLOWLIST) {75+ assert.equal(normalizeMediaTransport(transport), transport);76+ }77+ assert.equal(normalizeMediaTransport('iroh-moq'), 'unknown');78+ assert.equal(normalizeMediaTransport(null), 'unknown');79+});80+81+test('production mode rejects an empty pin while development mode permits it', async () => {82+ const production = harness();83+ assert.equal(await production.state.connect({ pin: ' ' }), false);84+ assert.deepEqual(production.commands, []);85+ assert.deepEqual(production.statuses, [['err', 'enter pin']]);86+ assert.equal(production.state.connecting, false);87+88+ const development = harness([connectedResult({ development_mode: true })]);89+ development.state.setDevelopmentMode(true);90+ assert.equal(await development.state.connect({ pin: ' ' }), true);91+ assert.equal(development.commands[0].args.pin, '');92+ assert.deepEqual(development.statuses, [93+ ['pending', 'connecting...'],94+ ['ok', 'connected · dev direct-node'],95+ ]);96+});97+98+test('connect is single-flight', async () => {99+ let release;100+ const pending = new Promise((resolve) => { release = resolve; });101+ let connectCalls = 0;102+ const state = newConnectionState({103+ invokeCommand: async (command) => {104+ if (command === 'iroh_client_connect') {105+ connectCalls += 1;106+ await pending;107+ return connectedResult();108+ }109+ return undefined;110+ },111+ });112+ const first = state.connect({ pin: '1234' });113+ assert.equal(state.connecting, true);114+ assert.equal(await state.connect({ pin: '1234' }), false);115+ release();116+ assert.equal(await first, true);117+ assert.equal(connectCalls, 1);118+});119+120+test('invalid connected generations close committed native state exactly once', async () => {121+ for (const invalid of [122+ connectedResult({ media_generation: 0 }),123+ connectedResult({ audio_available: true, audio_generation: 0 }),124+ ]) {125+ const current = harness([invalid]);126+ assert.equal(await current.state.connect({ pin: '1234' }), false);127+ assert.deepEqual(current.commands.map(({ command }) => command), [128+ 'iroh_client_connect',129+ 'iroh_client_disconnect',130+ ]);131+ assert.ok(current.events.includes('close:1:committed'));132+ assert.match(current.events.at(-1), /^failure:host returned an invalid/);133+ }134+});135+136+test('a rejected result tears down without disconnecting uncommitted native state', async () => {137+ const current = harness([{ connected: false }]);138+ assert.equal(await current.state.connect({ pin: '1234' }), false);139+ assert.deepEqual(current.commands.map(({ command }) => command), ['iroh_client_connect']);140+ assert.deepEqual(current.events, [141+ 'create:1',142+ 'validate:1',143+ 'close:1:uncommitted',144+ 'teardown:1',145+ ]);146+ assert.deepEqual(current.statuses.at(-1), ['err', 'failed']);147+});148+149+test('successful connect normalizes capabilities and forces inconsistent control view-only', async () => {150+ const current = harness([connectedResult({151+ keyboard_available: false,152+ control_available: true,153+ })]);154+ assert.equal(await current.state.connect({ pin: ' 1234 ' }), true);155+ assert.deepEqual(current.state.inputCapabilities, {156+ relativePointer: false,157+ pointerPositionFeedback: false,158+ absolutePointer: false,159+ keyboard: false,160+ text: false,161+ gamepad: false,162+ control: false,163+ });164+ assert.equal(current.state.mediaTransport, 'grouped-v3');165+ assert.deepEqual(current.statuses, [166+ ['pending', 'connecting...'],167+ ['ok', 'connected · view only'],168+ ]);169+ assert.deepEqual(current.commands[0].args.pin, '1234');170+});171+172+test('disconnect is single-flight and resets capabilities and transport', async () => {173+ let release;174+ const pending = new Promise((resolve) => { release = resolve; });175+ const current = harness();176+ assert.equal(await current.state.connect({ pin: '1234' }), true);177+ current.events.length = 0;178+179+ const state = newConnectionState({180+ invokeCommand: async (command) => {181+ if (command === 'iroh_client_connect') return connectedResult();182+ await pending;183+ },184+ });185+ await state.connect({ pin: '1234' });186+ const first = state.disconnect();187+ assert.equal(state.disconnecting, true);188+ assert.equal(await state.disconnect(), false);189+ release();190+ assert.equal(await first, true);191+ assert.equal(state.connected, false);192+ assert.equal(state.mediaTransport, 'unknown');193+ assert.equal(state.inputCapabilities.control, false);194+});195+196+test('connect disconnect connect creates fresh attempts and channels', async () => {197+ const current = harness([connectedResult(), connectedResult({ media_generation: 2 })]);198+ assert.equal(await current.state.connect({ pin: '1234' }), true);199+ assert.equal(await current.state.disconnect(), true);200+ assert.equal(await current.state.connect({ pin: '1234' }), true);201+ assert.equal(current.attempts.length, 2);202+ assert.notEqual(203+ current.attempts[0].connectionArgs.frameChannel,204+ current.attempts[1].connectionArgs.frameChannel,205+ );206+ assert.deepEqual(current.commands.map(({ command }) => command), [207+ 'iroh_client_connect',208+ 'iroh_client_disconnect',209+ 'iroh_client_connect',210+ ]);211+});212+213+test('connect failures clear connecting and report lifecycle order', async () => {214+ const statuses = [];215+ const events = [];216+ const state = newConnectionState({217+ invokeCommand: async () => { throw new Error('offline'); },218+ createAttempt: async () => {219+ events.push('create');220+ return { connectionArgs: {} };221+ },222+ closeAttempt: () => events.push('close'),223+ teardownAttempt: async () => events.push('teardown'),224+ onFailure: ({ error }) => events.push(`failure:${error.message}`),225+ onStatus: (kind, detail) => statuses.push(`${kind}:${detail}`),226+ });227+ assert.equal(await state.connect({ pin: '1234' }), false);228+ assert.equal(state.connecting, false);229+ assert.deepEqual(events, ['create', 'close', 'teardown', 'failure:offline']);230+ assert.deepEqual(statuses, ['pending:connecting...', 'err:error']);231+});232+233+test('connected and disconnected callbacks follow status and teardown boundaries', async () => {234+ const current = harness();235+ assert.equal(await current.state.connect({ pin: '1234' }), true);236+ assert.deepEqual(current.events, ['create:1', 'validate:1', 'activate:1', 'connected:1']);237+ current.events.length = 0;238+ assert.equal(await current.state.disconnect(), true);239+ assert.deepEqual(current.events, ['before-disconnect', 'teardown-connection', 'disconnected']);240+ assert.deepEqual(current.statuses.at(-1), ['ok', 'connected']);241+});@@ -28,10 +28,7 @@ import {
2828 stageLegacyFrame,2929 } from './frame-session.mjs';3030 import { parsePointerFeedbackMessage } from './pointer-feedback.mjs';31-import {32- committedRustConnection,33- disconnectRejectedRustConnection,34-} from './connection-attempt.mjs';31+import { newConnectionState } from './connection-state.mjs';3532 import {3633 audioMinusVideoSkewMs,3734 audioOutputTimelineFromStats,@@ -87,22 +84,9 @@ import {
8784 import { mapKey } from './keyboard-map.mjs';8885 8986 let controlMode = false;90-let connected = false;91-let connecting = false;92-let developmentConnectionMode = false;9387 let enrollmentReady = false;9488 let pendingInvitationSummary = null;9589 let currentEnrollmentStatus = null;96-let disconnecting = false;97-let inputCapabilities = {98- relativePointer: false,99- pointerPositionFeedback: false,100- absolutePointer: false,101- keyboard: false,102- text: false,103- gamepad: false,104- control: false,105-};10690 let fittedStreamAspect = null;10791 let pendingStreamFit = null;10892 let lastObservedWindowSize = null;@@ -173,7 +157,7 @@ function newPointerSession() {
173157 }174158 175159 function applyPointerPositionFeedback(message, session) {176- if (session !== activePointerSession || !inputCapabilities.pointerPositionFeedback) return;160+ if (session !== activePointerSession || !connectionState.inputCapabilities.pointerPositionFeedback) return;177161 const surface = currentPointerSurfaceSize();178162 if (surface === null) return;179163 const feedback = validatePointerPositionFeedback(message, surface);@@ -195,13 +179,13 @@ function handlePointerPositionFeedback(message, session) {
195179 remotePointerVisible = false;196180 renderRemotePointer();197181 console.error(session.failureDetail);198- if (connected) void disconnect();182+ if (connectionState.connected) void disconnect();199183 return;200184 }201185 const feedback = envelope.feedback;202186 session.received = true;203187 session.latest = feedback;204- if (connected) applyPointerPositionFeedback(feedback, session);188+ if (connectionState.connected) applyPointerPositionFeedback(feedback, session);205189 } catch (error) {206190 session.failed = true;207191 session.failureDetail = `Pointer feedback failed: ${error}`;@@ -209,7 +193,7 @@ function handlePointerPositionFeedback(message, session) {
209193 remotePointerVisible = false;210194 renderRemotePointer();211195 console.error('invalid pointer-position feedback:', error);212- if (connected) void disconnect();196+ if (connectionState.connected) void disconnect();213197 }214198 }215199 @@ -222,7 +206,7 @@ let adaptiveDecision = null;
222206 223207 function updatePanelVisibility() {224208 const streamSection = document.getElementById('stream-section');225- streamSection.style.display = connected ? '' : 'none';209+ streamSection.style.display = connectionState.connected ? '' : 'none';226210 }227211 228212 // ─── Status ──────────────────────────────────────────────────────────────────@@ -238,7 +222,7 @@ function setStatus(state, text) {
238222 function showIntro() {239223 document.getElementById('intro').classList.remove('hidden');240224 setTimeout(() => {241- const target = developmentConnectionMode225+ const target = connectionState.developmentMode242226 ? document.getElementById('intro-connect')243227 : document.getElementById('intro-pin');244228 target?.focus();@@ -275,7 +259,7 @@ function showInvitationSummary(value) {
275259 }276260 277261 async function refreshEnrollment() {278- if (developmentConnectionMode) {262+ if (connectionState.developmentMode) {279263 enrollmentReady = true;280264 hideEnrollment();281265 showIntro();@@ -401,7 +385,7 @@ async function cancelInvitation() {
401385 402386 async function checkDevelopmentConnectionMode() {403387 const mode = await invoke('development_connection_mode');404- developmentConnectionMode = mode.enabled;388+ connectionState.setDevelopmentMode(mode.enabled);405389 if (!mode.enabled) return;406390 407391 const badge = document.getElementById('dev-connect-badge');@@ -423,24 +407,24 @@ async function checkDevelopmentConnectionMode() {
423407 424408 // ─── Intro actions ────────────────────────────────────────────────────────────425409 async function introConnect() {426- if (connecting || connected) return;427- if (!developmentConnectionMode && !enrollmentReady) {410+ if (connectionState.connecting || connectionState.connected) return;411+ if (!connectionState.developmentMode && !enrollmentReady) {428412 showEnrollment();429413 return;430414 }431415 const pin = document.getElementById('intro-pin').value.trim();432416 const status = document.getElementById('intro-status');433- if (!developmentConnectionMode && !pin) {417+ if (!connectionState.developmentMode && !pin) {434418 status.className = 'overlay-status err';435419 status.textContent = 'enter pin';436420 return;437421 }438422 hideIntro();439- if (!developmentConnectionMode) showTap();423+ if (!connectionState.developmentMode) showTap();440424 document.getElementById('pin-input').value = pin;441425 await connectHost();442- if (!developmentConnectionMode) hideTap();443- if (!connected) {426+ if (!connectionState.developmentMode) hideTap();427+ if (!connectionState.connected) {444428 showIntro();445429 status.className = 'overlay-status err';446430 status.textContent = 'connection failed';@@ -733,217 +717,154 @@ async function toggleAudioMute() {
733717 }734718 }735719 736-async function connectHost() {737- if (connecting || connected) return;738- connecting = true;739- let rustConnectionCommitted = false;740- let pointerSession = null;741- let frameSession = null;742- try {743- const pin = document.getElementById('pin-input').value.trim();744- if (!developmentConnectionMode && !pin) {745- setStatus('err', 'enter pin');746- return;747- }748- primeAudioOutputForActivation();749- setStatus('pending', 'connecting...');750- // Reset before opening the channel so early frames cannot race a751- // post-connect reset and disappear from the live diagnostics.752- if (activeFrameSession) activeFrameSession.closing = true;753- activeFrameSession = null;754- activeFrameGeneration = null;755- adaptiveFeedbackPublisher.stop();756- adaptiveFeedbackAvailable = false;757- adaptiveFeedbackError = null;758- adaptiveDecision = null;759- resetStreamTelemetry();760- pointerSurfaceDimensions = null;761- remotePointerPosition = null;762- remotePointerVisible = false;763- const Channel = window.__TAURI__.core.Channel;764- if (typeof Channel !== 'function') throw new Error('Tauri binary channels are unavailable');765- resetAudioTelemetry();766- const audioSession = newAudioSession();767- frameSession = newFrameSession();768- pointerSession = newPointerSession();769- activeAudioSession = audioSession;770- activeFrameSession = frameSession;771- activePointerSession = pointerSession;772- const audioSupport = await initializeAudioPipeline(audioSession);773- if (!audioSupport.supported) setAudioState('unavailable', audioSupport.error);774- activeFrameChannel = new Channel((message) => handleBinaryFrameMessage(message, frameSession));775- activeAudioChannel = new Channel((message) => handleBinaryAudioMessage(message, audioSession));776- activePointerChannel = new Channel(777- (message) => handlePointerPositionFeedback(message, pointerSession),778- );779- const result = await invoke('iroh_client_connect', {780- pin,720+async function createConnectionAttempt({ createChannel: Channel }) {721+ // Reset before opening the channel so early frames cannot race a722+ // post-connect reset and disappear from the live diagnostics.723+ if (activeFrameSession) activeFrameSession.closing = true;724+ activeFrameSession = null;725+ activeFrameGeneration = null;726+ adaptiveFeedbackPublisher.stop();727+ adaptiveFeedbackAvailable = false;728+ adaptiveFeedbackError = null;729+ adaptiveDecision = null;730+ resetStreamTelemetry();731+ pointerSurfaceDimensions = null;732+ remotePointerPosition = null;733+ remotePointerVisible = false;734+ if (typeof Channel !== 'function') throw new Error('Tauri binary channels are unavailable');735+ resetAudioTelemetry();736+ const audioSession = newAudioSession();737+ const frameSession = newFrameSession();738+ const pointerSession = newPointerSession();739+ activeAudioSession = audioSession;740+ activeFrameSession = frameSession;741+ activePointerSession = pointerSession;742+ const audioSupport = await initializeAudioPipeline(audioSession);743+ if (!audioSupport.supported) setAudioState('unavailable', audioSupport.error);744+ activeFrameChannel = new Channel((message) => handleBinaryFrameMessage(message, frameSession));745+ activeAudioChannel = new Channel((message) => handleBinaryAudioMessage(message, audioSession));746+ activePointerChannel = new Channel(747+ (message) => handlePointerPositionFeedback(message, pointerSession),748+ );749+ return {750+ audioSession,751+ frameSession,752+ pointerSession,753+ audioSupport,754+ connectionArgs: {781755 frameChannel: activeFrameChannel,782756 audioChannel: activeAudioChannel,783757 pointerChannel: activePointerChannel,784758 audioSupported: audioSupport.supported,785- });786- rustConnectionCommitted = committedRustConnection(result);787- if (result.connected788- && (!Number.isSafeInteger(result.media_generation) || result.media_generation <= 0)) {789- throw new Error('host returned an invalid media generation');790- }791- if (result.connected792- && result.audio_available === true793- && (!Number.isSafeInteger(result.audio_generation) || result.audio_generation <= 0)) {794- throw new Error('host returned an invalid audio generation');795- }796- if (pointerSession.failed) {797- throw new Error(pointerSession.failureDetail || 'host returned invalid pointer-position feedback');798- }799- if (frameSession.failed) {800- throw new Error(frameSession.failureDetail || 'host returned invalid frame data');801- }802- const connectedPointerSurfaceDimensions = result.connected803- ? validatePointerSurfaceDimensions(result.pointer_surface_dimensions)804- : null;805- const pointerFeedbackAvailable = result.connected806- && result.pointer_position_feedback_available === true;807- if (pointerFeedbackAvailable && connectedPointerSurfaceDimensions === null) {808- throw new Error('host offered pointer feedback without a native pointer surface');809- }810- const initialPointerFeedback = pointerFeedbackAvailable && pointerSession.received811- ? validatePointerPositionFeedback(pointerSession.latest, connectedPointerSurfaceDimensions)812- : null;813- if (result.connected) {814- activateConnectedFrameSession(frameSession, result.media_generation);815- adaptiveFeedbackAvailable = result.adaptive_feedback_available === true;816- adaptiveFeedbackError = typeof result.adaptive_feedback_error === 'string'817- ? result.adaptive_feedback_error : null;818- adaptiveFeedbackPublisher.start(result.media_generation, adaptiveFeedbackAvailable);819- connected = true;820- disconnecting = false;821- inputCapabilities = {822- relativePointer: result.relative_pointer_available === true,823- pointerPositionFeedback: pointerFeedbackAvailable,824- absolutePointer: result.absolute_pointer_available === true,825- keyboard: result.keyboard_available === true,826- text: result.text_available === true,827- gamepad: result.gamepad_available === true,828- control: result.control_available === true,829- };830- streamTransportMode = [831- 'grouped-v3',832- 'independent-v2',833- 'reliable-v1',834- 'reliable-v0',835- ].includes(result.media_transport)836- ? result.media_transport837- : 'unknown';838- pointerSurfaceDimensions = connectedPointerSurfaceDimensions;839- if (!inputCapabilities.pointerPositionFeedback) {840- remotePointerPosition = null;841- remotePointerVisible = false;842- } else if (initialPointerFeedback !== null) {843- remotePointerPosition = initialPointerFeedback.position;844- remotePointerVisible = initialPointerFeedback.pointer_visible;845- }846- // Treat inconsistent metadata as view-only. Rust reports every847- // fields explicitly; this keeps the webview fail-closed as well.848- inputCapabilities.control = inputCapabilities.control849- && (inputCapabilities.relativePointer850- || inputCapabilities.absolutePointer851- || inputCapabilities.keyboard852- || inputCapabilities.text853- || inputCapabilities.gamepad);854- controlMode = false;855- clearPendingInput();856- const connectionStatus = [857- 'connected',858- inputCapabilities.control ? null : 'view only',859- result.development_mode ? 'dev direct-node' : null,860- ].filter(Boolean).join(' · ');861- setStatus('ok', connectionStatus);862- audioSession.expectedGeneration = Number.isSafeInteger(result.audio_generation)863- && result.audio_generation > 0864- ? result.audio_generation : null;865- const pendingAudioTerminal = takeAudioTerminalState(866- audioSession.pendingTerminalStates,867- audioSession.expectedGeneration,868- );869- if (pendingAudioTerminal !== null) {870- audioSession.failed = true;871- audioSession.failureDetail = pendingAudioTerminal.error || 'Audio connection ended';872- }873- audioAvailable = result.audio_available === true;874- if (audioAvailable && audioSession.failed) {875- disableAudioForSession(audioSession.failureDetail || 'Audio output failed', audioSession);876- } else if (audioAvailable) {877- setAudioState(878- audioContext?.state === 'running' ? 'priming' : 'blocked',879- audioContext?.state === 'running'880- ? 'Waiting for bounded Opus prebuffer'881- : 'WebKit suspended audio output; activate the audio button to retry',882- );883- } else {884- await teardownAudioPipeline(false);885- setAudioState('unavailable', result.audio_error || audioSupport.error || 'host audio unavailable');886- }887- updatePanelVisibility();888- document.getElementById('node-id-text').textContent = result.host_node_id.substring(0, 16) + '...';889- document.getElementById('frame-canvas').style.display = 'block';890- document.getElementById('placeholder').classList.add('hidden');891- document.getElementById('control-bar').classList.add('visible');892- void fitWindowToIncomingStream();893- updateControlUI();894- // Land controller users on the deliberate view-only/control boundary.895- // The connect button is hidden at this point, so retaining its focus896- // would make the next A press fall back to an unrelated top-bar action.897- setTimeout(() => setControllerFocus(document.getElementById('control-toggle')), 0);898- document.getElementById('action-connect').style.display = 'none';899- document.getElementById('action-disconnect').textContent = 'disconnect';900- document.getElementById('action-disconnect').classList.remove('hidden');901- document.getElementById('pin-label').style.display = 'none';902- document.getElementById('pin-input').style.display = 'none';903- document.getElementById('controller-pin-main').style.display = 'none';904- rustConnectionCommitted = false;905- } else {906- frameSession.closing = true;907- activeFrameSession = null;908- activeFrameGeneration = null;909- activeFrameChannel = null;910- activePointerChannel = null;911- activePointerSession = null;912- await teardownAudioPipeline();913- setStatus('err', 'failed');914- }915- } catch (e) {916- if (frameSession) frameSession.closing = true;917- if (activeFrameSession === frameSession) activeFrameSession = null;918- activeFrameGeneration = null;919- adaptiveFeedbackPublisher.stop();920- adaptiveFeedbackAvailable = false;921- adaptiveDecision = null;922- if (rustConnectionCommitted) {923- // Rust commits its active-connection guard before returning. A client-side924- // validation failure must close that exact committed attempt directly;925- // calling disconnect() here would race its UI teardown recursively.926- if (pointerSession) pointerSession.closing = true;927- try {928- await disconnectRejectedRustConnection(invoke, rustConnectionCommitted);929- } catch (disconnectError) {930- console.error('failed to close rejected connection:', disconnectError);931- }932- }933- activeFrameChannel = null;934- activePointerChannel = null;935- activePointerSession = null;936- await teardownAudioPipeline();937- console.error('connection failed:', e);938- setStatus('err', 'error');939- } finally {940- connecting = false;759+ },760+ };761+}762+763+function validateConnectedAttempt(result, attempt) {764+ if (attempt.pointerSession.failed) {765+ throw new Error(attempt.pointerSession.failureDetail || 'host returned invalid pointer-position feedback');766+ }767+ if (attempt.frameSession.failed) {768+ throw new Error(attempt.frameSession.failureDetail || 'host returned invalid frame data');769+ }770+ attempt.pointerSurface = result.connected771+ ? validatePointerSurfaceDimensions(result.pointer_surface_dimensions)772+ : null;773+ attempt.pointerFeedbackAvailable = result.connected774+ && result.pointer_position_feedback_available === true;775+ if (attempt.pointerFeedbackAvailable && attempt.pointerSurface === null) {776+ throw new Error('host offered pointer feedback without a native pointer surface');941777 }778+ attempt.initialPointerFeedback = attempt.pointerFeedbackAvailable && attempt.pointerSession.received779+ ? validatePointerPositionFeedback(attempt.pointerSession.latest, attempt.pointerSurface)780+ : null;942781 }943782 944-async function disconnect() {945- if (disconnecting) return;946- disconnecting = true;783+function activateConnectedAttempt(result, attempt) {784+ activateConnectedFrameSession(attempt.frameSession, result.media_generation);785+ adaptiveFeedbackAvailable = result.adaptive_feedback_available === true;786+ adaptiveFeedbackError = typeof result.adaptive_feedback_error === 'string'787+ ? result.adaptive_feedback_error : null;788+ adaptiveFeedbackPublisher.start(result.media_generation, adaptiveFeedbackAvailable);789+}790+791+function closeConnectionAttempt(attempt, { committed = false } = {}) {792+ if (attempt?.frameSession) attempt.frameSession.closing = true;793+ if (activeFrameSession === attempt?.frameSession) activeFrameSession = null;794+ activeFrameGeneration = null;795+ adaptiveFeedbackPublisher.stop();796+ adaptiveFeedbackAvailable = false;797+ adaptiveDecision = null;798+ if (committed && attempt?.pointerSession) attempt.pointerSession.closing = true;799+}800+801+async function teardownConnectionAttempt() {802+ activeFrameChannel = null;803+ activePointerChannel = null;804+ activePointerSession = null;805+ await teardownAudioPipeline();806+}807+808+function updateAcceptedConnectionState({ attempt, state }) {809+ pointerSurfaceDimensions = attempt.pointerSurface;810+ if (!state.inputCapabilities.pointerPositionFeedback) {811+ remotePointerPosition = null;812+ remotePointerVisible = false;813+ } else if (attempt.initialPointerFeedback !== null) {814+ remotePointerPosition = attempt.initialPointerFeedback.position;815+ remotePointerVisible = attempt.initialPointerFeedback.pointer_visible;816+ }817+ controlMode = false;818+ clearPendingInput();819+}820+821+async function showConnectedAttempt({ result, attempt }) {822+ const { audioSession, audioSupport } = attempt;823+ audioSession.expectedGeneration = Number.isSafeInteger(result.audio_generation)824+ && result.audio_generation > 0825+ ? result.audio_generation : null;826+ const pendingAudioTerminal = takeAudioTerminalState(827+ audioSession.pendingTerminalStates,828+ audioSession.expectedGeneration,829+ );830+ if (pendingAudioTerminal !== null) {831+ audioSession.failed = true;832+ audioSession.failureDetail = pendingAudioTerminal.error || 'Audio connection ended';833+ }834+ audioAvailable = result.audio_available === true;835+ if (audioAvailable && audioSession.failed) {836+ disableAudioForSession(audioSession.failureDetail || 'Audio output failed', audioSession);837+ } else if (audioAvailable) {838+ setAudioState(839+ audioContext?.state === 'running' ? 'priming' : 'blocked',840+ audioContext?.state === 'running'841+ ? 'Waiting for bounded Opus prebuffer'842+ : 'WebKit suspended audio output; activate the audio button to retry',843+ );844+ } else {845+ await teardownAudioPipeline(false);846+ setAudioState('unavailable', result.audio_error || audioSupport.error || 'host audio unavailable');847+ }848+ updatePanelVisibility();849+ document.getElementById('node-id-text').textContent = result.host_node_id.substring(0, 16) + '...';850+ document.getElementById('frame-canvas').style.display = 'block';851+ document.getElementById('placeholder').classList.add('hidden');852+ document.getElementById('control-bar').classList.add('visible');853+ void fitWindowToIncomingStream();854+ updateControlUI();855+ // Land controller users on the deliberate view-only/control boundary.856+ // The connect button is hidden at this point, so retaining its focus857+ // would make the next A press fall back to an unrelated top-bar action.858+ setTimeout(() => setControllerFocus(document.getElementById('control-toggle')), 0);859+ document.getElementById('action-connect').style.display = 'none';860+ document.getElementById('action-disconnect').textContent = 'disconnect';861+ document.getElementById('action-disconnect').classList.remove('hidden');862+ document.getElementById('pin-label').style.display = 'none';863+ document.getElementById('pin-input').style.display = 'none';864+ document.getElementById('controller-pin-main').style.display = 'none';865+}866+867+async function prepareDisconnect() {947868 if (activeFrameSession) activeFrameSession.closing = true;948869 activeFrameSession = null;949870 activeFrameGeneration = null;@@ -962,25 +883,18 @@ async function disconnect() {
962883 // Give release commands a short, finite opportunity to cross the bounded963884 // Tauri queue before closing it. A broken stream cannot stall disconnect.964885 await waitForInputDrain(250);965- try {966- await invoke('iroh_client_disconnect');967- } catch (e) { console.error(e); }886+}887+888+async function teardownConnectedResources() {968889 activeFrameChannel = null;969890 activePointerChannel = null;970891 activePointerSession = null;971892 await teardownAudioPipeline();972893 videoPipeline.teardown();973- connected = false;894+}895+896+async function showDisconnectedState() {974897 controlMode = false;975- inputCapabilities = {976- relativePointer: false,977- pointerPositionFeedback: false,978- absolutePointer: false,979- keyboard: false,980- text: false,981- gamepad: false,982- control: false,983- };984898 pointerSurfaceDimensions = null;985899 fittedStreamAspect = null;986900 pendingStreamFit = null;@@ -999,13 +913,42 @@ async function disconnect() {
999913 document.getElementById('control-bar').classList.remove('visible');1000914 document.getElementById('action-connect').style.display = '';1001915 document.getElementById('action-disconnect').classList.add('hidden');1002- if (!developmentConnectionMode) {916+ if (!connectionState.developmentMode) {1003917 document.getElementById('pin-label').style.display = '';1004918 document.getElementById('pin-input').style.display = '';1005919 document.getElementById('controller-pin-main').style.display = '';1006920 }1007921 document.getElementById('intro-status').textContent = '';1008- disconnecting = false;922+}923+924+const connectionState = newConnectionState({925+ invokeCommand: invoke,926+ createChannel: window.__TAURI__.core.Channel,927+ beforeConnect: primeAudioOutputForActivation,928+ createAttempt: createConnectionAttempt,929+ validateConnectedResult: validateConnectedAttempt,930+ activateAttempt: activateConnectedAttempt,931+ closeAttempt: closeConnectionAttempt,932+ teardownAttempt: teardownConnectionAttempt,933+ beforeDisconnect: prepareDisconnect,934+ teardownConnection: teardownConnectedResources,935+ onStatus: (state, detail, context) => {936+ if (state === 'ok') updateAcceptedConnectionState(context);937+ setStatus(state, detail);938+ },939+ onConnected: showConnectedAttempt,940+ onDisconnected: showDisconnectedState,941+ onFailure: ({ error }) => console.error('connection failed:', error),942+ onDisconnectError: (error) => console.error(error),943+ onNativeDisconnectError: (error) => console.error('failed to close rejected connection:', error),944+});945+946+async function connectHost() {947+ await connectionState.connect({ pin: document.getElementById('pin-input').value });948+}949+950+async function disconnect() {951+ await connectionState.disconnect();1009952 }1010953 1011954 // ─── FIDO scan ────────────────────────────────────────────────────────────────@@ -1064,7 +1007,6 @@ let networkDiagnostics = null;
10641007 let lastTransportFps = 0;10651008 let lastFrontendSendFps = 0;10661009 let streamPathMode = 'unknown';1067-let streamTransportMode = 'unknown';10681010 let streamRttMs = null;10691011 let lastMediaSequence = null;10701012 @@ -1128,7 +1070,7 @@ const videoPipeline = createVideoPipelineSession({
11281070 sampleAudioSkew: sampleAvSkew,11291071 onFormatChanged: () => {11301072 sizeCanvasToIncomingStream();1131- if (connected) void fitWindowToIncomingStream();1073+ if (connectionState.connected) void fitWindowToIncomingStream();11321074 },11331075 });11341076 @@ -1149,7 +1091,6 @@ function resetStreamTelemetry() {
11491091 lastTransportFps = 0;11501092 lastFrontendSendFps = 0;11511093 streamPathMode = 'unknown';1152- streamTransportMode = 'unknown';11531094 streamRttMs = null;11541095 lastMediaSequence = null;11551096 adaptiveDecision = null;@@ -1230,7 +1171,7 @@ function updateStreamStats() {
12301171 document.getElementById('stream-decoder-output-cadence').textContent = formatCadence(video.decoderOutputIntervals);12311172 document.getElementById('stream-present-cadence').textContent = formatCadence(video.presentationIntervals);12321173 document.getElementById('stream-codec').textContent = video.activeCodec;1233- document.getElementById('stream-transport').textContent = streamTransportMode;1174+ document.getElementById('stream-transport').textContent = connectionState.mediaTransport;12341175 document.getElementById('stream-path').textContent = streamPathMode;12351176 document.getElementById('stream-rtt').textContent = Number.isFinite(streamRttMs)12361177 ? `${streamRttMs.toFixed(1)} ms`@@ -1270,7 +1211,7 @@ function updateStreamStats() {
12701211 }12711212 12721213 setInterval(() => {1273- if (connected) updateStreamStats();1214+ if (connectionState.connected) updateStreamStats();12741215 }, 250);12751216 12761217 function clientChromeHeight() {@@ -1317,7 +1258,7 @@ async function fitWindowToIncomingStream() {
13171258 height: frameHeight,13181259 epoch: activeVideoFormatEpoch,13191260 } = videoPipeline.format;1320- if (!connected || frameWidth < 1 || frameHeight < 1) return false;1261+ if (!connectionState.connected || frameWidth < 1 || frameHeight < 1) return false;13211262 const aspect = streamAspectKey(frameWidth, frameHeight);13221263 if (fittedStreamAspect === aspect || pendingStreamFit?.aspect === aspect) return false;13231264 const request = { aspect, generation: activeFrameGeneration, epoch: activeVideoFormatEpoch };@@ -1335,7 +1276,7 @@ async function fitWindowToIncomingStream() {
13351276 ? streamAspectKey(currentFormat.width, currentFormat.height)13361277 : null;13371278 const current = pendingStreamFit === request1338- && connected1279+ && connectionState.connected13391280 && activeFrameGeneration === request.generation13401281 && currentFormat.epoch === request.epoch13411282 && currentAspect === request.aspect;@@ -1352,7 +1293,7 @@ function scheduleStreamWindowAspectCorrection() {
13521293 streamWindowResizeTimer = setTimeout(() => {13531294 streamWindowResizeTimer = null;13541295 const { width: frameWidth, height: frameHeight } = videoPipeline.format;1355- if (!connected || frameWidth < 1 || frameHeight < 1) return;1296+ if (!connectionState.connected || frameWidth < 1 || frameHeight < 1) return;13561297 const current = { width: window.innerWidth, height: window.innerHeight };13571298 let geometry;13581299 try {@@ -1409,7 +1350,7 @@ function failFrameSession(session, error) {
14091350 session.failed = true;14101351 session.failureDetail = `Frame delivery failed: ${error}`;14111352 console.error(session.failureDetail);1412- if (connected) void disconnect();1353+ if (connectionState.connected) void disconnect();14131354 }14141355 14151356 function handleBinaryFrameMessage(message, session) {@@ -1617,7 +1558,7 @@ listen('frame-error', (event) => {
16171558 16181559 listen('audio-state', (event) => {16191560 const session = activeAudioSession;1620- if (disconnecting || !session || event.payload?.available !== false) return;1561+ if (connectionState.disconnecting || !session || event.payload?.available !== false) return;16211562 if (session.expectedGeneration === null) {16221563 try {16231564 stageAudioTerminalState(session.pendingTerminalStates, event.payload);@@ -1644,7 +1585,9 @@ listen('audio-stats', (event) => {
16441585 16451586 // ─── Input ────────────────────────────────────────────────────────────────────16461587 async function toggleControl() {1647- if (!connected || !inputCapabilities.control || controlTransitionInProgress) return;1588+ if (!connectionState.connected1589+ || !connectionState.inputCapabilities.control1590+ || controlTransitionInProgress) return;16481591 // Capture synchronous controller provenance before cursor acquisition yields.16491592 // The DOM click dispatcher does not await this async handler.16501593 const controllerInitiated = controllerActivationInProgress;@@ -1673,7 +1616,10 @@ async function toggleControl() {
16731616 }16741617 return;16751618 }1676- if (!acquired || disconnecting || !connected || !inputCapabilities.control) {1619+ if (!acquired1620+ || connectionState.disconnecting1621+ || !connectionState.connected1622+ || !connectionState.inputCapabilities.control) {16771623 exitControlAfterBrowserPointerLockFailure();16781624 return;16791625 }@@ -1697,26 +1643,28 @@ async function toggleControl() {
16971643 }16981644 16991645 function queueNeutralControllerState() {1700- if (!connected || !inputCapabilities.gamepad) return false;1646+ if (!connectionState.connected || !connectionState.inputCapabilities.gamepad) return false;17011647 return sendInput({ t: 'gp', state: neutralGamepadInputState() });17021648 }17031649 17041650 function describeInputCapabilities() {17051651 const accepted = [];1706- if (inputCapabilities.relativePointer) accepted.push('relative pointer');1707- else if (inputCapabilities.absolutePointer) accepted.push('pointer');1708- if (inputCapabilities.keyboard) accepted.push('keyboard');1709- if (inputCapabilities.text) accepted.push('text');1710- if (inputCapabilities.gamepad) accepted.push('gamepad');1652+ const capabilities = connectionState.inputCapabilities;1653+ if (capabilities.relativePointer) accepted.push('relative pointer');1654+ else if (capabilities.absolutePointer) accepted.push('pointer');1655+ if (capabilities.keyboard) accepted.push('keyboard');1656+ if (capabilities.text) accepted.push('text');1657+ if (capabilities.gamepad) accepted.push('gamepad');17111658 return accepted.length > 0 ? accepted.join(' + ') : 'view only';17121659 }17131660 17141661 function pointerInputAvailable() {1715- return inputCapabilities.relativePointer || inputCapabilities.absolutePointer;1662+ const capabilities = connectionState.inputCapabilities;1663+ return capabilities.relativePointer || capabilities.absolutePointer;17161664 }17171665 17181666 async function requestRelativePointerLock() {1719- if (!inputCapabilities.relativePointer) return true;1667+ if (!connectionState.inputCapabilities.relativePointer) return true;17201668 try {17211669 const nativeResult = await setNativeCursorGrab(true);17221670 // Older commands returned no value, so only an explicit false disables@@ -1846,15 +1794,15 @@ function currentPointerSurfaceSize() {
18461794 pointerSurfaceDimensions,18471795 frameWidth,18481796 frameHeight,1849- inputCapabilities.relativePointer,1797+ connectionState.inputCapabilities.relativePointer,18501798 );18511799 }18521800 18531801 function renderRemotePointer() {18541802 const surface = currentPointerSurfaceSize();18551803 const visible = controlMode1856- && inputCapabilities.relativePointer1857- && inputCapabilities.pointerPositionFeedback1804+ && connectionState.inputCapabilities.relativePointer1805+ && connectionState.inputCapabilities.pointerPositionFeedback18581806 && remotePointerPosition !== null18591807 && remotePointerVisible18601808 && surface !== null;@@ -1877,21 +1825,22 @@ function releasePointerLock() {
18771825 18781826 function updateControlUI() {18791827 const el = document.getElementById('control-toggle');1880- const available = connected && inputCapabilities.control;1828+ const capabilities = connectionState.inputCapabilities;1829+ const available = connectionState.connected && capabilities.control;18811830 if (!available) controlMode = false;18821831 el.textContent = available1883- ? `${controlMode ? 'controlling' : 'take control'} · ${describeInputCapabilities()}${controlMode && inputCapabilities.relativePointer ? ' · Ctrl+Alt+Esc to exit' : controlMode && inputCapabilities.gamepad ? ' · hold Back+Start to exit' : ''}`1832+ ? `${controlMode ? 'controlling' : 'take control'} · ${describeInputCapabilities()}${controlMode && capabilities.relativePointer ? ' · Ctrl+Alt+Esc to exit' : controlMode && capabilities.gamepad ? ' · hold Back+Start to exit' : ''}`18841833 : 'view only · input unavailable';18851834 el.classList.toggle('active', controlMode);18861835 el.classList.toggle('disabled', !available);18871836 el.setAttribute('aria-disabled', available ? 'false' : 'true');18881837 document.body.classList.toggle(18891838 'native-pointer-control',1890- controlMode && inputCapabilities.relativePointer,1839+ controlMode && capabilities.relativePointer,18911840 );18921841 canvas.classList.toggle(18931842 'relative-control',1894- controlMode && inputCapabilities.relativePointer,1843+ controlMode && capabilities.relativePointer,18951844 );18961845 renderRemotePointer();18971846 const streamControl = document.getElementById('stream-control');@@ -1936,14 +1885,15 @@ function clearPendingInput() {
19361885 }19371886 19381887 function inputEventAvailable(event) {1939- if (!inputCapabilities.control) return false;1940- if (event.t === 'mm') return inputCapabilities.absolutePointer;1941- if (event.t === 'mr') return inputCapabilities.relativePointer;1942- if (event.t === 'mp') return inputCapabilities.relativePointer;1888+ const capabilities = connectionState.inputCapabilities;1889+ if (!capabilities.control) return false;1890+ if (event.t === 'mm') return capabilities.absolutePointer;1891+ if (event.t === 'mr') return capabilities.relativePointer;1892+ if (event.t === 'mp') return capabilities.relativePointer;19431893 if (['mc', 'md', 'mu', 'ms'].includes(event.t)) return pointerInputAvailable();1944- if (['kd', 'ku', 'kt'].includes(event.t)) return inputCapabilities.keyboard;1945- if (event.t === 'tx') return inputCapabilities.text;1946- if (event.t === 'gp') return inputCapabilities.gamepad;1894+ if (['kd', 'ku', 'kt'].includes(event.t)) return capabilities.keyboard;1895+ if (event.t === 'tx') return capabilities.text;1896+ if (event.t === 'gp') return capabilities.gamepad;19471897 return false;19481898 }19491899 @@ -1991,7 +1941,7 @@ function sendInput(event, { release = false } = {}) {
19911941 // Defer teardown until the caller has removed any transition it could19921942 // not enqueue from held-state tracking.19931943 queueMicrotask(() => {1994- if (connected) void disconnect();1944+ if (connectionState.connected) void disconnect();19951945 });19961946 return false;19971947 }@@ -2082,7 +2032,7 @@ async function pumpInputQueue() {
20822032 } catch (error) {20832033 console.error('input restore failed:', error);20842034 clearPendingInput();2085- if (connected) void disconnect();2035+ if (connectionState.connected) void disconnect();20862036 return;20872037 }20882038 await new Promise((resolve) => setTimeout(resolve, INPUT_RETRY_MS));@@ -2096,7 +2046,7 @@ async function pumpInputQueue() {
20962046 20972047 window.addEventListener('mousemove', (e) => {20982048 if (!controlMode || !pointerInputAvailable()) return;2099- if (inputCapabilities.relativePointer) {2049+ if (connectionState.inputCapabilities.relativePointer) {21002050 const movement = scaleRelativePointerDelta(21012051 e.movementX,21022052 e.movementY,@@ -2107,19 +2057,21 @@ window.addEventListener('mousemove', (e) => {
21072057 }, { capture: true });21082058 21092059 canvas.addEventListener('mousemove', (e) => {2110- if (!controlMode || !inputCapabilities.absolutePointer || inputCapabilities.relativePointer) return;2060+ if (!controlMode2061+ || !connectionState.inputCapabilities.absolutePointer2062+ || connectionState.inputCapabilities.relativePointer) return;21112063 const { x, y } = scaleCoords(e.clientX, e.clientY);21122064 sendInput({ t: 'mm', x, y });21132065 });21142066 21152067 function handleMouseDown(e) {21162068 if (!controlMode || !pointerInputAvailable()) return;2117- if (inputCapabilities.relativePointer) e.stopPropagation();2069+ if (connectionState.inputCapabilities.relativePointer) e.stopPropagation();21182070 else if (e.target !== canvas) return;21192071 const btn = browserMouseButtonCode(e.button);21202072 if (btn === null) return;21212073 e.preventDefault();2122- if (inputCapabilities.relativePointer && document.pointerLockElement !== canvas) {2074+ if (connectionState.inputCapabilities.relativePointer && document.pointerLockElement !== canvas) {21232075 void requestBrowserPointerLock().catch((error) => {21242076 console.warn('browser pointer lock retry failed:', error);21252077 });@@ -2131,7 +2083,7 @@ function handleMouseDown(e) {
21312083 window.addEventListener('mousedown', handleMouseDown, { capture: true });21322084 21332085 function releaseMouseButton(e) {2134- if (!connected || !pointerInputAvailable()) return;2086+ if (!connectionState.connected || !pointerInputAvailable()) return;21352087 const btn = browserMouseButtonCode(e.button);21362088 if (btn === null) return;21372089 const release = heldInputs.takeMouseButtonRelease(btn);@@ -2141,21 +2093,21 @@ function releaseMouseButton(e) {
21412093 }21422094 21432095 window.addEventListener('mouseup', (e) => {2144- if (controlMode && inputCapabilities.relativePointer) e.stopPropagation();2096+ if (controlMode && connectionState.inputCapabilities.relativePointer) e.stopPropagation();21452097 releaseMouseButton(e);21462098 }, { capture: true });21472099 21482100 window.addEventListener('contextmenu', (e) => {21492101 if (!controlMode || !pointerInputAvailable()) return;2150- if (!inputCapabilities.relativePointer && e.target !== canvas) return;2102+ if (!connectionState.inputCapabilities.relativePointer && e.target !== canvas) return;21512103 e.preventDefault();21522104 }, { capture: true });21532105 21542106 window.addEventListener('wheel', (e) => {21552107 if (!controlMode || !pointerInputAvailable()) return;2156- if (!inputCapabilities.relativePointer && e.target !== canvas) return;2108+ if (!connectionState.inputCapabilities.relativePointer && e.target !== canvas) return;21572109 e.preventDefault();2158- if (inputCapabilities.relativePointer) e.stopPropagation();2110+ if (connectionState.inputCapabilities.relativePointer) e.stopPropagation();21592111 sendInput({ t: 'ms', dx: Math.round(e.deltaX), dy: Math.round(e.deltaY) });21602112 }, { passive: false, capture: true });21612113 @@ -2172,12 +2124,12 @@ window.addEventListener('keydown', (e) => {
21722124 return;21732125 }21742126 const printableText = e.key.length === 1 && !e.ctrlKey && !e.altKey && !e.metaKey;2175- if (printableText && inputCapabilities.text) {2127+ if (printableText && connectionState.inputCapabilities.text) {21762128 e.preventDefault();21772129 sendInput({ t: 'tx', s: e.key });21782130 return;21792131 }2180- if (!inputCapabilities.keyboard) return;2132+ if (!connectionState.inputCapabilities.keyboard) return;21812133 const k = mapKey(e);21822134 if (k) {21832135 e.preventDefault();@@ -2224,7 +2176,7 @@ function renderPinPad() {
22242176 }22252177 22262178 function openPinPad(targetId, returnFocus = document.activeElement) {2227- if (developmentConnectionMode) return;2179+ if (connectionState.developmentMode) return;22282180 const target = document.getElementById(targetId);22292181 if (!(target instanceof HTMLInputElement) || target.disabled) return;22302182 activePinInput = target;@@ -2289,7 +2241,9 @@ controllerPublisher.setHandler((state) => {
22892241 if (externalControllerObserver) {22902242 try { externalControllerObserver(state); } catch (error) { console.warn('controller observer failed:', error); }22912243 }2292- if (!connected || !controlMode || !inputCapabilities.gamepad) return;2244+ if (!connectionState.connected2245+ || !controlMode2246+ || !connectionState.inputCapabilities.gamepad) return;22932247 const inputState = maskGamepadEscapeChord(toGamepadInputState(state));22942248 if (!controllerActivationGate.accepts(inputState)) return;22952249 sendInput({ t: 'gp', state: inputState });@@ -2409,9 +2363,9 @@ function pollControllers(nowMs) {
24092363 updateControllerStatus(currentControllerState);24102364 }24112365 2412- const remoteRoute = connected2366+ const remoteRoute = connectionState.connected24132367 && controlMode2414- && inputCapabilities.gamepad2368+ && connectionState.inputCapabilities.gamepad24152369 && currentControllerState.connected24162370 && currentControllerState.mapping === 'standard';24172371 if (remoteRoute) {@@ -2447,12 +2401,12 @@ window.addEventListener('resize', () => {
24472401 });24482402 24492403 window.addEventListener('focus', () => {2450- if (!controlMode || !inputCapabilities.relativePointer) return;2404+ if (!controlMode || !connectionState.inputCapabilities.relativePointer) return;24512405 // WebKit rebuilds its native cursor rectangles after the window-level focus24522406 // event. Reassert from the webview on the next frame so the final rectangle24532407 // is the transparent one used during relative control.24542408 requestAnimationFrame(() => {2455- if (!controlMode || !inputCapabilities.relativePointer) return;2409+ if (!controlMode || !connectionState.inputCapabilities.relativePointer) return;24562410 void setNativeCursorGrab(true).catch((error) => {24572411 console.warn('native cursor re-hide after focus failed:', error);24582412 });@@ -2512,7 +2466,7 @@ async function initialize() {
25122466 status.className = 'overlay-status err';25132467 status.textContent = 'could not load enrollment';25142468 }2515- if (!developmentConnectionMode) scanFido();2469+ if (!connectionState.developmentMode) scanFido();25162470 requestAnimationFrame(pollControllers);25172471 }25182472