cat / goq
clone

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

Merge pull request #89 from FelineStateMachine/feat/portal-module-tree

FelineStateMachine committed 1f447d4
Refactor Portal into focused runtime modules
commit
1f447d46610428431323b13a7efd769854788807
parent
10aa184f10ed2ffe7513c3bdad947d2a15291c18
parent
ba151294f391dcbd3cb7a7ae42d922f28af0eaa1
author
FelineStateMachine <dami.etoile@gmail.com>
25 files changed, +5711 -2140
Aportal/audio-pipeline.mjs498++++++
Aportal/audio-pipeline.test.mjs377++++
Mportal/audio-ui.mjs34+
Mportal/audio-ui.test.mjs53+-
Mportal/connection-attempt.test.mjs10-
Aportal/connection-state.mjs205++
Aportal/connection-state.test.mjs241+++
Aportal/control-runtime.mjs290+++
Aportal/control-runtime.test.mjs354++++
Aportal/controller-runtime.mjs122+
Aportal/controller-runtime.test.mjs239+++
Mportal/decoder-recovery.test.mjs14+-
Aportal/input-runtime.mjs237+++
Aportal/input-runtime.test.mjs306++++
Aportal/keyboard-map.mjs14+
Aportal/keyboard-map.test.mjs38+
Mportal/main.js2557+++++-------------------------
Mportal/pointer-feedback.mjs90+
Mportal/pointer-feedback.test.mjs235+++-
Aportal/stream-runtime.mjs361++++
Aportal/stream-runtime.test.mjs337++++
Aportal/video-pipeline.mjs518++++++
Aportal/video-pipeline.test.mjs304++++
Aportal/window-runtime.mjs159++
Aportal/window-runtime.test.mjs258+++
A portal/audio-pipeline.mjs +498 -0
@@ -0,0 +1,498 @@
1+import {2+ audioMinusVideoSkewMs,3+ audioOutputTimelineFromStats,4+ exactMediaTimestampMicros,5+ isCurrentAvSyncEpoch,6+ nextAvSyncEpoch,7+ projectAudioMediaPtsMicros,8+} from './av-sync.mjs';9+import {10+ isCurrentAudioDelivery,11+ isCurrentAudioGeneration,12+ parseAudioEnvelope,13+ stageAudioTerminalState,14+ takeAudioTerminalState,15+} from './audio-envelope.mjs';16+import { newAudioSession } from './audio-ui.mjs';17+18+export const AUDIO_SAMPLE_RATE = 48000;19+20+const AUDIO_DECODER_CONFIG = Object.freeze({21+ codec: 'opus',22+ sampleRate: AUDIO_SAMPLE_RATE,23+ numberOfChannels: 2,24+});25+const MAX_AUDIO_DECODE_QUEUE_SIZE = 3;26+27+export function createAudioPipelineSession({28+ invokeCommand,29+ onUpdate = () => {},30+ resetAvSync = () => {},31+ isDisconnecting = () => false,32+ hasAudioDecoder = () => typeof globalThis.AudioDecoder === 'function',33+ hasAudioWorklet = () => typeof globalThis.AudioWorkletNode === 'function',34+ getAudioContextConstructor = () => globalThis.AudioContext || globalThis.webkitAudioContext,35+ probeDecoderSupport = (config) => globalThis.AudioDecoder.isConfigSupported(config),36+ createDecoder = (callbacks) => new globalThis.AudioDecoder(callbacks),37+ createWorkletNode = (context, name, options) => new globalThis.AudioWorkletNode(38+ context,39+ name,40+ options,41+ ),42+ createEncodedChunk = (init) => new globalThis.EncodedAudioChunk(init),43+} = {}) {44+ if (typeof invokeCommand !== 'function') throw new TypeError('invokeCommand must be a function');45+46+ let activeSession = newAudioSession();47+48+ function update() {49+ onUpdate(activeSession);50+ }51+52+ function setState(state, detail = state) {53+ activeSession.state = state;54+ activeSession.stateDetail = detail;55+ update();56+ }57+58+ function resetSyncTelemetry(session = activeSession, { recoverRing = false } = {}) {59+ if (!session) return;60+ session.audioTimeline = null;61+ session.avSyncEpoch = nextAvSyncEpoch(session.avSyncEpoch);62+ try {63+ session.workletNode?.port.postMessage({64+ type: recoverRing ? 'recover' : 'av-sync-epoch',65+ avSyncEpoch: session.avSyncEpoch,66+ });67+ } catch (_) {}68+ }69+70+ function sampleSkew(videoPtsMicros, presentationTimeMs) {71+ const session = activeSession;72+ if (73+ exactMediaTimestampMicros(videoPtsMicros) === null74+ || !session75+ || session.failed76+ || !session.audioTimeline77+ || !session.available78+ || session.context?.state !== 'running'79+ || typeof session.context.getOutputTimestamp !== 'function'80+ ) return null;81+82+ let outputTimestamp;83+ try {84+ outputTimestamp = session.context.getOutputTimestamp();85+ } catch (_) {86+ return null;87+ }88+ const audioPtsMicros = projectAudioMediaPtsMicros(89+ session.audioTimeline,90+ outputTimestamp,91+ presentationTimeMs,92+ );93+ return audioMinusVideoSkewMs(audioPtsMicros, videoPtsMicros);94+ }95+96+ function resetTelemetry(session = activeSession) {97+ session.packetsReceived = 0;98+ session.decoderDropped = 0;99+ session.bufferedFrames = 0;100+ session.underflows = 0;101+ session.underflowDurationMicros = 0;102+ session.silentDurationMicros = 0;103+ session.transportDropped = 0;104+ session.frontendDropped = 0;105+ resetAvSync(session);106+ update();107+ }108+109+ function acknowledge(generation, deliveryId) {110+ void invokeCommand('iroh_client_ack_audio', { generation, deliveryId }).catch((error) => {111+ console.warn('audio acknowledgment failed:', error);112+ });113+ }114+115+ function requestNativeStop(session) {116+ const generation = session?.expectedGeneration;117+ if (session !== activeSession118+ || !Number.isSafeInteger(generation)119+ || generation < 0120+ || session.stopRequested) return;121+ session.stopRequested = true;122+ void invokeCommand('iroh_client_stop_audio', { expectedGeneration: generation }).catch((error) => {123+ console.warn('audio stop failed:', error);124+ });125+ }126+127+ function disable(detail, session = activeSession) {128+ if (!session || session !== activeSession) return;129+ resetAvSync(session);130+ session.failed = true;131+ session.failureDetail = detail;132+ session.available = false;133+ if (session.decoder) {134+ try { session.decoder.close(); } catch (_) {}135+ session.decoder = null;136+ }137+ session.messageTracker.clear();138+ try { session.workletNode?.port.postMessage({ type: 'clear' }); } catch (_) {}139+ requestNativeStop(session);140+ setState('error', detail);141+ }142+143+ function configureDecoder(session = activeSession) {144+ if (session.decoder) {145+ try { session.decoder.close(); } catch (_) {}146+ }147+ session.decoder = createDecoder({148+ output: (audioData) => {149+ try {150+ if (session !== activeSession || session.failed) return;151+ const workletNode = session?.workletNode;152+ if (!workletNode || audioData.numberOfChannels !== 2) {153+ throw new Error(`unexpected decoded audio channel count: ${audioData.numberOfChannels}`);154+ }155+ const timestampMicros = exactMediaTimestampMicros(audioData.timestamp);156+ if (timestampMicros === null) throw new Error('decoded audio has an invalid media timestamp');157+ // Reserve ownership before allocating/copying PCM. The MessagePort has158+ // no useful queue-depth API, so this makes overload latest-frame-wins159+ // at a hard ceiling of three decoded messages.160+ const messageId = session.messageTracker.reserve();161+ if (messageId === null) {162+ update();163+ return;164+ }165+ const channels = [];166+ const transfers = [];167+ try {168+ for (let planeIndex = 0; planeIndex < 2; planeIndex++) {169+ const channel = new Float32Array(audioData.numberOfFrames);170+ audioData.copyTo(channel, { planeIndex, format: 'f32-planar' });171+ channels.push(channel);172+ transfers.push(channel.buffer);173+ }174+ workletNode.port.postMessage({175+ type: 'samples',176+ id: messageId,177+ channels,178+ timestampMicros,179+ }, transfers);180+ } catch (error) {181+ session.messageTracker.accept(messageId);182+ throw error;183+ }184+ } catch (error) {185+ disable(`Decoded audio output failed: ${error}`, session);186+ } finally {187+ audioData.close();188+ }189+ },190+ error: (error) => {191+ disable(`Opus decoder failed: ${error}`, session);192+ },193+ });194+ session.decoder.configure(AUDIO_DECODER_CONFIG);195+ }196+197+ function primeOutputForActivation() {198+ const session = activeSession;199+ const AudioContextConstructor = getAudioContextConstructor();200+ if (typeof AudioContextConstructor !== 'function') return;201+ if (!session.context || session.context.state === 'closed') {202+ try {203+ session.context = new AudioContextConstructor({204+ latencyHint: 'interactive',205+ sampleRate: AUDIO_SAMPLE_RATE,206+ });207+ } catch (_) {208+ return;209+ }210+ }211+ // This function is deliberately synchronous and is called before the first212+ // await in a click/keyboard connect handler, preserving WebKit user activation.213+ void session.context.resume().catch(() => {});214+ }215+216+ async function initialize(session) {217+ if (session.decoder) {218+ try { session.decoder.close(); } catch (_) {}219+ session.decoder = null;220+ }221+ if (session.workletNode) {222+ try { session.workletNode.disconnect(); } catch (_) {}223+ session.workletNode = null;224+ }225+ session.messageTracker.clear();226+ if (!hasAudioDecoder()) {227+ return { supported: false, error: 'WebCodecs AudioDecoder is unavailable' };228+ }229+ if (!hasAudioWorklet()) {230+ return { supported: false, error: 'AudioWorklet is unavailable' };231+ }232+ let support;233+ try {234+ support = await probeDecoderSupport(AUDIO_DECODER_CONFIG);235+ } catch (error) {236+ return { supported: false, error: `Opus capability probe failed: ${error}` };237+ }238+ if (!support.supported) {239+ return { supported: false, error: 'WebCodecs Opus decoding is unsupported' };240+ }241+ if (!session.context) {242+ return { supported: false, error: 'Web Audio output is unavailable' };243+ }244+ try {245+ if (session.context.sampleRate !== AUDIO_SAMPLE_RATE) {246+ throw new Error(`audio output opened at ${session.context.sampleRate} Hz instead of 48000 Hz`);247+ }248+ await session.context.audioWorklet.addModule(new URL('./audio-worklet.js', import.meta.url));249+ session.workletNode = createWorkletNode(session.context, 'sigil-audio-processor', {250+ numberOfInputs: 0,251+ numberOfOutputs: 1,252+ outputChannelCount: [2],253+ });254+ session.workletNode.port.onmessage = (event) => {255+ if (event.data?.type === 'accepted') {256+ session.messageTracker.accept(event.data.id);257+ if (session === activeSession) update();258+ } else if (event.data?.type === 'stats') {259+ if (!isCurrentAvSyncEpoch(event.data.avSyncEpoch, session.avSyncEpoch)) return;260+ session.workletDroppedFrames = Number.isSafeInteger(event.data.droppedFrames)261+ && event.data.droppedFrames >= 0262+ ? event.data.droppedFrames : session.workletDroppedFrames;263+ session.workletRecoveryDiscardedFrames = Number.isSafeInteger(264+ event.data.recoveryDiscardedFrames,265+ ) && event.data.recoveryDiscardedFrames >= 0266+ ? event.data.recoveryDiscardedFrames : session.workletRecoveryDiscardedFrames;267+ session.audioTimeline = audioOutputTimelineFromStats(event.data);268+ if (session !== activeSession) return;269+ session.bufferedFrames = Number.isSafeInteger(event.data.bufferedFrames)270+ ? event.data.bufferedFrames : session.bufferedFrames;271+ session.underflows = Number.isSafeInteger(event.data.underflows)272+ ? event.data.underflows : session.underflows;273+ session.underflowDurationMicros = Number.isFinite(event.data.underflowDurationMicros)274+ && event.data.underflowDurationMicros >= 0275+ ? event.data.underflowDurationMicros : session.underflowDurationMicros;276+ session.silentDurationMicros = Number.isFinite(event.data.silentDurationMicros)277+ && event.data.silentDurationMicros >= 0278+ ? event.data.silentDurationMicros : session.silentDurationMicros;279+ if (session.available && !session.muted) {280+ setState(event.data.started ? 'playing' : 'priming', 'bounded Opus playback');281+ } else {282+ update();283+ }284+ } else if (event.data?.type === 'error') {285+ disable(`AudioWorklet failed: ${event.data.error}`, session);286+ }287+ };288+ session.workletNode.port.onmessageerror = () => {289+ disable('AudioWorklet returned an unreadable message', session);290+ };291+ session.workletNode.onprocessorerror = () => {292+ disable('AudioWorklet processor stopped unexpectedly', session);293+ };294+ session.workletNode.connect(session.context.destination);295+ session.workletNode.port.postMessage({ type: 'mute', muted: session.muted });296+ configureDecoder(session);297+ await session.context.resume();298+ setState(299+ session.context.state === 'running' ? 'negotiating' : 'blocked',300+ session.context.state === 'running'301+ ? 'Opus output ready'302+ : 'WebKit suspended audio output; activate the audio button to retry',303+ );304+ return { supported: true, error: null };305+ } catch (error) {306+ await teardown(false);307+ return { supported: false, error: `Audio initialization failed: ${error}` };308+ }309+ }310+311+ async function teardown(resetStatus = true) {312+ const session = activeSession;313+ resetAvSync(session);314+ const decoder = session.decoder;315+ const workletNode = session.workletNode;316+ const context = session.context;317+ // Replace the active owner before closing resources so delayed callbacks318+ // cannot mutate the disconnected UI or a successor session. Seed the next319+ // dormant session with the user's mute preference across reconnects.320+ activeSession = newAudioSession({ muted: session.muted });321+ session.expectedGeneration = null;322+ session.channel = null;323+ session.available = false;324+ session.messageTracker.clear();325+ session.decoder = null;326+ session.workletNode = null;327+ session.context = null;328+ if (decoder) {329+ try { decoder.close(); } catch (_) {}330+ }331+ if (workletNode) {332+ try { workletNode.port.postMessage({ type: 'clear' }); } catch (_) {}333+ try { workletNode.disconnect(); } catch (_) {}334+ }335+ if (context && context.state !== 'closed') {336+ try { await context.close(); } catch (_) {}337+ }338+ if (resetStatus) update();339+ }340+341+ async function toggleMute() {342+ const session = activeSession;343+ if (!session.available) return;344+ session.muted = !session.muted;345+ try {346+ if (!session.muted && session.context?.state !== 'running') await session.context?.resume();347+ session.workletNode?.port.postMessage({ type: 'mute', muted: session.muted });348+ if (!session.muted && session.context?.state !== 'running') {349+ setState('blocked', 'WebKit suspended audio output; click the audio button to retry');350+ } else {351+ update();352+ }353+ } catch (error) {354+ setState('error', `Audio output activation failed: ${error}`);355+ }356+ }357+358+ function prepareForConnection() {359+ activeSession = newAudioSession({ muted: activeSession.muted });360+ activeSession.acceptsNativeEvents = true;361+ // Keep this synchronous and before createConnectionAttempt's first await so362+ // WebKit observes the originating click/keyboard user activation.363+ primeOutputForActivation();364+ }365+366+ function beginAttempt() {367+ const session = activeSession;368+ resetTelemetry(session);369+ return session;370+ }371+372+ async function createAttemptChannel(Channel, session = activeSession) {373+ const support = await initialize(session);374+ if (!support.supported) setState('unavailable', support.error);375+ session.channel = new Channel((message) => handleBinaryMessage(message, session));376+ return { session, support, channel: session.channel };377+ }378+379+ function releaseAttempt(session) {380+ if (session && session !== activeSession) session.channel = null;381+ }382+383+ async function acceptConnectedResult(result, { session, support }) {384+ session.expectedGeneration = Number.isSafeInteger(result.audio_generation)385+ && result.audio_generation > 0386+ ? result.audio_generation : null;387+ const pendingTerminal = takeAudioTerminalState(388+ session.pendingTerminalStates,389+ session.expectedGeneration,390+ );391+ if (pendingTerminal !== null) {392+ session.failed = true;393+ session.failureDetail = pendingTerminal.error || 'Audio connection ended';394+ }395+ session.available = result.audio_available === true;396+ if (session.available && session.failed) {397+ disable(session.failureDetail || 'Audio output failed', session);398+ } else if (session.available) {399+ setState(400+ session.context?.state === 'running' ? 'priming' : 'blocked',401+ session.context?.state === 'running'402+ ? 'Waiting for bounded Opus prebuffer'403+ : 'WebKit suspended audio output; activate the audio button to retry',404+ );405+ } else {406+ await teardown(false);407+ setState('unavailable', result.audio_error || support.error || 'host audio unavailable');408+ }409+ }410+411+ function handleBinaryMessage(message, session) {412+ let packet;413+ try {414+ packet = parseAudioEnvelope(message);415+ } catch (error) {416+ console.error('invalid audio packet:', error);417+ disable(`Audio packet failed: ${error}`, session);418+ return;419+ }420+421+ // Every valid delivery is released using the token embedded in that exact422+ // message. A delayed delivery from an old connection is acknowledged but423+ // cannot enter the current decoder.424+ acknowledge(packet.generation, packet.deliveryId);425+ if (session !== activeSession426+ || session.failed427+ || !isCurrentAudioDelivery(packet, session.expectedGeneration)) return;428+429+ try {430+ session.packetsReceived++;431+ if (!session.decoder || session.decoder.state === 'closed') {432+ session.decoderDropped++;433+ update();434+ return;435+ }436+ const queuedAudioPackets = session.decoder.decodeQueueSize;437+ if (packet.discontinuity || queuedAudioPackets >= MAX_AUDIO_DECODE_QUEUE_SIZE) {438+ resetAvSync(session, { recoverRing: true });439+ session.decoderDropped += queuedAudioPackets;440+ configureDecoder(session);441+ }442+ session.decoder.decode(createEncodedChunk({443+ type: 'key',444+ timestamp: packet.ptsMicros,445+ duration: (packet.frameSamples * 1_000_000) / packet.sampleRate,446+ data: packet.data,447+ }));448+ update();449+ } catch (error) {450+ console.error('undecodable audio packet:', error);451+ disable(`Audio packet failed: ${error}`, session);452+ }453+ }454+455+ function handleNativeState(payload) {456+ const session = activeSession;457+ if (isDisconnecting()458+ || !session.acceptsNativeEvents459+ || payload?.available !== false) return;460+ if (session.expectedGeneration === null) {461+ try {462+ stageAudioTerminalState(session.pendingTerminalStates, payload);463+ } catch (error) {464+ console.error('invalid pre-connect audio terminal state:', error);465+ }466+ return;467+ }468+ if (!isCurrentAudioGeneration(payload?.generation, session.expectedGeneration)) return;469+ disable(payload.error || 'Audio connection ended', session);470+ }471+472+ function handleNativeStats(payload) {473+ const session = activeSession;474+ if (!isCurrentAudioGeneration(payload?.generation, session.expectedGeneration)) return;475+ session.transportDropped = Number.isSafeInteger(payload?.sequence_dropped_total)476+ ? payload.sequence_dropped_total : session.transportDropped;477+ session.frontendDropped = Number.isSafeInteger(payload?.frontend_dropped_total)478+ ? payload.frontend_dropped_total : session.frontendDropped;479+ update();480+ }481+482+ return Object.freeze({483+ acceptConnectedResult,484+ beginAttempt,485+ createAttemptChannel,486+ handleNativeState,487+ handleNativeStats,488+ prepareForConnection,489+ releaseAttempt,490+ resetSyncTelemetry,491+ sampleSkew,492+ teardown,493+ toggleMute,494+ get session() {495+ return activeSession;496+ },497+ });498+}
A portal/audio-pipeline.test.mjs +377 -0
@@ -0,0 +1,377 @@
1+import assert from 'node:assert/strict';2+import test from 'node:test';3+4+import {5+ AUDIO_SAMPLE_RATE,6+ createAudioPipelineSession,7+} from './audio-pipeline.mjs';8+import {9+ AUDIO_CHANNEL_PREFIX_LENGTH,10+ AUDIO_ENVELOPE_HEADER_LENGTH,11+} from './audio-envelope.mjs';12+13+const PACKET_BASE = AUDIO_CHANNEL_PREFIX_LENGTH;14+15+function envelope({16+ generation = 7n,17+ deliveryId = 11n,18+ flags = 0,19+ pts = 120000n,20+} = {}) {21+ const payload = Uint8Array.of(0xf8, 0xff, 0xfe);22+ const buffer = new ArrayBuffer(23+ AUDIO_CHANNEL_PREFIX_LENGTH + AUDIO_ENVELOPE_HEADER_LENGTH + payload.length,24+ );25+ const bytes = new Uint8Array(buffer);26+ const view = new DataView(buffer);27+ bytes.set([0x53, 0x47, 0x41, 0x43]);28+ view.setUint16(4, 1, false);29+ view.setUint16(6, AUDIO_CHANNEL_PREFIX_LENGTH, false);30+ view.setBigUint64(8, generation, false);31+ view.setBigUint64(16, deliveryId, false);32+ bytes.set([0x53, 0x47, 0x41, 0x31], PACKET_BASE);33+ view.setUint16(PACKET_BASE + 4, 1, false);34+ bytes[PACKET_BASE + 6] = AUDIO_ENVELOPE_HEADER_LENGTH;35+ bytes[PACKET_BASE + 7] = 1;36+ bytes[PACKET_BASE + 8] = flags;37+ bytes[PACKET_BASE + 9] = 2;38+ view.setUint32(PACKET_BASE + 12, AUDIO_SAMPLE_RATE, false);39+ view.setUint16(PACKET_BASE + 16, 960, false);40+ view.setUint32(PACKET_BASE + 20, payload.length, false);41+ view.setBigUint64(PACKET_BASE + 24, 42n, false);42+ view.setBigUint64(PACKET_BASE + 32, 123456n, false);43+ view.setBigInt64(PACKET_BASE + 40, pts, false);44+ bytes.set(payload, PACKET_BASE + AUDIO_ENVELOPE_HEADER_LENGTH);45+ return buffer;46+}47+48+function harness({49+ disconnecting = () => false,50+ hasAudioDecoder = () => true,51+ hasAudioWorklet = () => true,52+} = {}) {53+ const invokes = [];54+ const updates = [];55+ const avResets = [];56+ const decoders = [];57+ const worklets = [];58+ const contexts = [];59+ const chunks = [];60+61+ class FakeContext {62+ constructor(options) {63+ this.options = options;64+ this.sampleRate = options.sampleRate;65+ this.state = 'suspended';66+ this.destination = {};67+ this.modules = [];68+ this.resumeCalls = 0;69+ this.closeCalls = 0;70+ this.audioWorklet = {71+ addModule: async (url) => { this.modules.push(url); },72+ };73+ contexts.push(this);74+ }75+76+ async resume() {77+ this.resumeCalls++;78+ this.state = 'running';79+ }80+81+ async close() {82+ this.closeCalls++;83+ this.state = 'closed';84+ }85+86+ getOutputTimestamp() {87+ return { contextTime: 4, performanceTime: 1000 };88+ }89+ }90+91+ class FakeChannel {92+ constructor(callback) {93+ this.callback = callback;94+ }95+ }96+97+ const pipeline = createAudioPipelineSession({98+ invokeCommand: async (command, args) => { invokes.push({ command, args }); },99+ onUpdate: (session) => updates.push(session),100+ resetAvSync: (...args) => {101+ avResets.push(args);102+ pipeline.resetSyncTelemetry(...args);103+ },104+ isDisconnecting: disconnecting,105+ hasAudioDecoder,106+ hasAudioWorklet,107+ getAudioContextConstructor: () => FakeContext,108+ probeDecoderSupport: async (config) => ({ supported: config.codec === 'opus' }),109+ createDecoder: (callbacks) => {110+ const decoder = {111+ callbacks,112+ state: 'configured',113+ decodeQueueSize: 0,114+ configured: null,115+ decoded: [],116+ closeCalls: 0,117+ configure(config) { this.configured = config; },118+ decode(chunk) { this.decoded.push(chunk); },119+ close() { this.closeCalls++; this.state = 'closed'; },120+ };121+ decoders.push(decoder);122+ return decoder;123+ },124+ createWorkletNode: (context, name, options) => {125+ const node = {126+ context,127+ name,128+ options,129+ connected: null,130+ disconnectCalls: 0,131+ port: {132+ messages: [],133+ postMessage(message, transfers) { this.messages.push({ message, transfers }); },134+ onmessage: null,135+ onmessageerror: null,136+ },137+ connect(destination) { this.connected = destination; },138+ disconnect() { this.disconnectCalls++; },139+ onprocessorerror: null,140+ };141+ worklets.push(node);142+ return node;143+ },144+ createEncodedChunk: (init) => {145+ const chunk = { ...init };146+ chunks.push(chunk);147+ return chunk;148+ },149+ });150+151+ return {152+ pipeline,153+ FakeChannel,154+ invokes,155+ updates,156+ avResets,157+ decoders,158+ worklets,159+ contexts,160+ chunks,161+ };162+}163+164+async function initializeAttempt(fixture) {165+ fixture.pipeline.prepareForConnection();166+ const primedSession = fixture.pipeline.session;167+ fixture.pipeline.beginAttempt();168+ const attempt = await fixture.pipeline.createAttemptChannel(fixture.FakeChannel, primedSession);169+ return { ...attempt, primedSession };170+}171+172+test('connection preparation synchronously primes WebKit audio and initializes bounded Opus resources', async () => {173+ const fixture = harness();174+ const initial = fixture.pipeline.session;175+ initial.muted = true;176+177+ fixture.pipeline.prepareForConnection();178+ const session = fixture.pipeline.session;179+ assert.notEqual(session, initial);180+ assert.equal(session.muted, true);181+ assert.equal(session.acceptsNativeEvents, true);182+ assert.equal(fixture.contexts.length, 1);183+ assert.equal(fixture.contexts[0].resumeCalls, 1);184+185+ fixture.pipeline.beginAttempt();186+ const attempt = await fixture.pipeline.createAttemptChannel(fixture.FakeChannel, session);187+ assert.equal(attempt.session, session);188+ assert.deepEqual(attempt.support, { supported: true, error: null });189+ assert.equal(attempt.channel, session.channel);190+ assert.equal(fixture.decoders[0].configured.codec, 'opus');191+ assert.equal(fixture.decoders[0].configured.sampleRate, AUDIO_SAMPLE_RATE);192+ assert.equal(fixture.worklets[0].name, 'sigil-audio-processor');193+ assert.deepEqual(fixture.worklets[0].port.messages[0].message, { type: 'mute', muted: true });194+ assert.equal(session.state, 'negotiating');195+ assert.equal(session.stateDetail, 'Opus output ready');196+});197+198+test('capability checks preserve the exact WebCodecs and AudioWorklet fallback errors', async () => {199+ const missingDecoder = harness({ hasAudioDecoder: () => false });200+ missingDecoder.pipeline.prepareForConnection();201+ let session = missingDecoder.pipeline.beginAttempt();202+ let attempt = await missingDecoder.pipeline.createAttemptChannel(203+ missingDecoder.FakeChannel,204+ session,205+ );206+ assert.deepEqual(attempt.support, {207+ supported: false,208+ error: 'WebCodecs AudioDecoder is unavailable',209+ });210+211+ const missingWorklet = harness({ hasAudioWorklet: () => false });212+ missingWorklet.pipeline.prepareForConnection();213+ session = missingWorklet.pipeline.beginAttempt();214+ attempt = await missingWorklet.pipeline.createAttemptChannel(missingWorklet.FakeChannel, session);215+ assert.deepEqual(attempt.support, {216+ supported: false,217+ error: 'AudioWorklet is unavailable',218+ });219+});220+221+test('deliveries always acknowledge but only the current generation enters the bounded decoder', async () => {222+ const fixture = harness();223+ const attempt = await initializeAttempt(fixture);224+ await fixture.pipeline.acceptConnectedResult({225+ audio_generation: 7,226+ audio_available: true,227+ audio_error: null,228+ }, attempt);229+230+ attempt.channel.callback(envelope({ generation: 8n, deliveryId: 80n }));231+ attempt.channel.callback(envelope({ generation: 7n, deliveryId: 70n }));232+ await Promise.resolve();233+ assert.deepEqual(fixture.invokes.slice(0, 2), [234+ { command: 'iroh_client_ack_audio', args: { generation: 8, deliveryId: 80 } },235+ { command: 'iroh_client_ack_audio', args: { generation: 7, deliveryId: 70 } },236+ ]);237+ assert.equal(fixture.decoders[0].decoded.length, 1);238+ assert.equal(fixture.chunks[0].timestamp, 120000);239+ assert.equal(fixture.chunks[0].duration, 20000);240+241+ let audioDataClosed = false;242+ fixture.decoders[0].callbacks.output({243+ numberOfChannels: 2,244+ numberOfFrames: 2,245+ timestamp: 120000,246+ copyTo: (channel, { planeIndex }) => channel.fill(planeIndex + 1),247+ close: () => { audioDataClosed = true; },248+ });249+ const samples = fixture.worklets[0].port.messages.at(-1);250+ assert.equal(samples.message.type, 'samples');251+ assert.deepEqual([...samples.message.channels[0]], [1, 1]);252+ assert.deepEqual([...samples.message.channels[1]], [2, 2]);253+ assert.equal(samples.transfers.length, 2);254+ assert.equal(audioDataClosed, true);255+256+ fixture.decoders[0].decodeQueueSize = 3;257+ attempt.channel.callback(envelope({ generation: 7n, deliveryId: 71n }));258+ assert.equal(fixture.decoders.length, 2);259+ assert.equal(fixture.decoders[0].closeCalls, 1);260+ assert.equal(attempt.session.decoderDropped, 3);261+ assert.equal(fixture.decoders[1].decoded.length, 1);262+ assert.equal(fixture.avResets.at(-1)[1].recoverRing, true);263+ assert.deepEqual(fixture.worklets[0].port.messages.at(-1).message, {264+ type: 'recover',265+ avSyncEpoch: attempt.session.avSyncEpoch,266+ });267+});268+269+test('worklet telemetry is epoch-gated and projects signed audio/video skew', async () => {270+ const fixture = harness();271+ const attempt = await initializeAttempt(fixture);272+ await fixture.pipeline.acceptConnectedResult({273+ audio_generation: 7,274+ audio_available: true,275+ audio_error: null,276+ }, attempt);277+ const session = attempt.session;278+ const worklet = fixture.worklets[0];279+280+ worklet.port.onmessage({ data: {281+ type: 'stats',282+ avSyncEpoch: session.avSyncEpoch + 1,283+ bufferedFrames: 99,284+ } });285+ assert.equal(session.bufferedFrames, 0);286+287+ worklet.port.onmessage({ data: {288+ type: 'stats',289+ avSyncEpoch: session.avSyncEpoch,290+ droppedFrames: 11,291+ recoveryDiscardedFrames: 12,292+ renderedMediaEndPtsMicros: 2_000_000,293+ outputContextTimeEndSeconds: 4,294+ bufferedFrames: 960,295+ underflows: 2,296+ underflowDurationMicros: 10_000,297+ silentDurationMicros: 20_000,298+ started: true,299+ } });300+ assert.equal(session.state, 'playing');301+ assert.equal(session.workletDroppedFrames, 11);302+ assert.equal(session.workletRecoveryDiscardedFrames, 12);303+ assert.equal(session.bufferedFrames, 960);304+ assert.equal(fixture.pipeline.sampleSkew(1_975_000, 1000), 25);305+306+ fixture.pipeline.resetSyncTelemetry(session, { recoverRing: true });307+ assert.equal(session.audioTimeline, null);308+ assert.deepEqual(worklet.port.messages.at(-1).message, {309+ type: 'recover',310+ avSyncEpoch: session.avSyncEpoch,311+ });312+ assert.equal(fixture.pipeline.sampleSkew(1_975_000, 1000), null);313+});314+315+test('native terminal state is staged before connect and teardown isolates delayed callbacks', async () => {316+ const fixture = harness();317+ fixture.pipeline.prepareForConnection();318+ fixture.pipeline.handleNativeState({ generation: 9, available: false, error: 'ended early' });319+ assert.equal(fixture.pipeline.session.pendingTerminalStates.length, 1);320+ const session = fixture.pipeline.beginAttempt();321+ const attempt = await fixture.pipeline.createAttemptChannel(fixture.FakeChannel, session);322+ const oldSession = attempt.session;323+ const oldWorklet = fixture.worklets[0];324+ await fixture.pipeline.acceptConnectedResult({325+ audio_generation: 9,326+ audio_available: true,327+ audio_error: null,328+ }, attempt);329+ await Promise.resolve();330+ assert.equal(oldSession.failed, true);331+ assert.equal(oldSession.failureDetail, 'ended early');332+ assert.deepEqual(fixture.invokes.at(-1), {333+ command: 'iroh_client_stop_audio',334+ args: { expectedGeneration: 9 },335+ });336+337+ oldSession.muted = true;338+ await fixture.pipeline.teardown();339+ const successor = fixture.pipeline.session;340+ assert.notEqual(successor, oldSession);341+ assert.equal(successor.muted, true);342+ assert.equal(fixture.contexts[0].closeCalls, 1);343+ assert.equal(oldWorklet.disconnectCalls, 1);344+ const updateCount = fixture.updates.length;345+ oldWorklet.port.onmessage({ data: { type: 'accepted', id: 1 } });346+ assert.equal(fixture.updates.length, updateCount);347+});348+349+test('native stats and mute toggles update only the exact active generation', async () => {350+ const fixture = harness();351+ const attempt = await initializeAttempt(fixture);352+ await fixture.pipeline.acceptConnectedResult({353+ audio_generation: 7,354+ audio_available: true,355+ audio_error: null,356+ }, attempt);357+ fixture.pipeline.handleNativeStats({358+ generation: 6,359+ sequence_dropped_total: 99,360+ frontend_dropped_total: 98,361+ });362+ assert.equal(attempt.session.transportDropped, 0);363+ fixture.pipeline.handleNativeStats({364+ generation: 7,365+ sequence_dropped_total: 4,366+ frontend_dropped_total: 5,367+ });368+ assert.equal(attempt.session.transportDropped, 4);369+ assert.equal(attempt.session.frontendDropped, 5);370+371+ await fixture.pipeline.toggleMute();372+ assert.equal(attempt.session.muted, true);373+ assert.deepEqual(fixture.worklets[0].port.messages.at(-1).message, {374+ type: 'mute',375+ muted: true,376+ });377+});
M portal/audio-ui.mjs +34 -0
M portal/audio-ui.test.mjs +52 -1
@@ -1,7 +1,58 @@
11 import assert from 'node:assert/strict';22 import test from 'node:test';33 4-import { audioButtonPresentation } from './audio-ui.mjs';4+import { audioButtonPresentation, newAudioSession } from './audio-ui.mjs';5+6+test('new audio sessions pin all resource, channel, status, and telemetry defaults', () => {7+ const session = newAudioSession();8+9+ assert.deepEqual({10+ ...session,11+ messageTracker: undefined,12+ }, {13+ expectedGeneration: null,14+ acceptsNativeEvents: false,15+ pendingTerminalStates: [],16+ failed: false,17+ failureDetail: null,18+ stopRequested: false,19+ channel: null,20+ context: null,21+ decoder: null,22+ workletNode: null,23+ available: false,24+ muted: false,25+ state: 'unavailable',26+ stateDetail: 'not connected',27+ packetsReceived: 0,28+ decoderDropped: 0,29+ bufferedFrames: 0,30+ underflows: 0,31+ underflowDurationMicros: 0,32+ silentDurationMicros: 0,33+ transportDropped: 0,34+ frontendDropped: 0,35+ workletDroppedFrames: 0,36+ workletRecoveryDiscardedFrames: 0,37+ audioTimeline: null,38+ avSyncEpoch: 0,39+ messageTracker: undefined,40+ });41+ assert.equal(session.messageTracker.size, 0);42+ assert.equal(session.messageTracker.droppedMessages, 0);43+});44+45+test('new audio sessions preserve seeded mute state and isolate mutable state', () => {46+ const muted = newAudioSession({ muted: true });47+ const fresh = newAudioSession();48+49+ muted.pendingTerminalStates.push({ generation: 7 });50+ assert.equal(muted.muted, true);51+ assert.equal(fresh.muted, false);52+ assert.deepEqual(fresh.pendingTerminalStates, []);53+ assert.notEqual(muted.pendingTerminalStates, fresh.pendingTerminalStates);54+ assert.notEqual(muted.messageTracker, fresh.messageTracker);55+});556 657 test('audio button uses a speaker glyph with a mute action while playing', () => {758 assert.deepEqual(audioButtonPresentation({
M portal/connection-attempt.test.mjs +0 -10
A portal/connection-state.mjs +205 -0
@@ -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+}
A portal/connection-state.test.mjs +241 -0
@@ -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+});
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+});
A portal/controller-runtime.mjs +122 -0
@@ -0,0 +1,122 @@
1+import {2+ ControllerActionRepeater,3+ GamepadEscapeHold,4+ LatestControllerStatePublisher,5+ controllerStateSignature,6+ disconnectedControllerState,7+ maskGamepadEscapeChord,8+ normalizeGamepad,9+ selectPreferredController,10+ toGamepadInputState,11+} from './controller-state.mjs';12+13+export function createControllerRuntime({14+ getGamepads = () => globalThis.navigator?.getGamepads?.() ?? [],15+ schedulePoll = (callback) => requestAnimationFrame(callback),16+ isRemoteRoute = () => false,17+ sendRemoteState = () => {},18+ onNavigate = () => {},19+ onActivate = () => {},20+ onBack = () => {},21+ onStatus = () => {},22+ onExit = () => {},23+ warn = (...args) => console.warn(...args),24+ publisher = new LatestControllerStatePublisher(),25+ actions = new ControllerActionRepeater(),26+ escape = new GamepadEscapeHold(1000),27+} = {}) {28+ let sequence = 0;29+ let selectedIndex = null;30+ let currentState = disconnectedControllerState();31+ let lastSignature = controllerStateSignature(currentState);32+ let observer = null;33+34+ publisher.setHandler((state) => {35+ if (observer) {36+ try {37+ observer(state);38+ } catch (error) {39+ warn('controller observer failed:', error);40+ }41+ }42+ // Route state whenever the remote control path is active. Disconnected and43+ // non-standard samples intentionally map to neutral so controller loss44+ // cannot leave held input on the host.45+ if (!isRemoteRoute()) return;46+ sendRemoteState(maskGamepadEscapeChord(toGamepadInputState(state)));47+ });48+49+ function publishCurrentState() {50+ publisher.publish(currentState);51+ }52+53+ function poll(nowMs) {54+ let gamepads = [];55+ try {56+ gamepads = Array.from(getGamepads() ?? []).filter(Boolean);57+ } catch (error) {58+ warn('gamepad poll failed:', error);59+ }60+ const gamepad = selectPreferredController(gamepads, selectedIndex);61+ const nextIndex = gamepad?.index ?? null;62+ if (nextIndex !== selectedIndex) {63+ selectedIndex = nextIndex;64+ actions.reset();65+ escape.reset();66+ lastSignature = '';67+ }68+69+ currentState = normalizeGamepad(gamepad, sequence + 1);70+ const signature = controllerStateSignature(currentState);71+ if (signature !== lastSignature) {72+ sequence += 1;73+ currentState = normalizeGamepad(gamepad, sequence);74+ lastSignature = signature;75+ publisher.publish(currentState);76+ onStatus(currentState);77+ }78+79+ const remoteRoute = isRemoteRoute()80+ && currentState.connected81+ && currentState.mapping === 'standard';82+ if (remoteRoute) {83+ if (escape.update(currentState, nowMs)) {84+ onExit();85+ actions.reset();86+ }87+ } else {88+ escape.reset();89+ for (const action of actions.update(currentState, nowMs)) {90+ if (action.type === 'navigate') onNavigate(action.direction);91+ else if (action.type === 'activate') onActivate();92+ else if (action.type === 'back') onBack();93+ }94+ }95+ schedulePoll(poll);96+ }97+98+ function start() {99+ schedulePoll(poll);100+ }101+102+ function setObserver(nextObserver) {103+ if (nextObserver !== null && typeof nextObserver !== 'function') {104+ throw new TypeError('observer must be a function or null');105+ }106+ observer = nextObserver;107+ }108+109+ return Object.freeze({110+ poll,111+ publishCurrentState,112+ resetEscape: () => escape.reset(),113+ setObserver,114+ start,115+ get latest() {116+ return publisher.latest;117+ },118+ get current() {119+ return currentState;120+ },121+ });122+}
A portal/controller-runtime.test.mjs +239 -0
@@ -0,0 +1,239 @@
1+import assert from 'node:assert/strict';2+import { readFileSync } from 'node:fs';3+import test from 'node:test';4+5+import { createControllerRuntime } from './controller-runtime.mjs';6+import { neutralGamepadInputState } from './controller-state.mjs';7+8+const main = readFileSync(new URL('./main.js', import.meta.url), 'utf8');9+10+function gamepad({11+ index = 0,12+ id = 'test-pad',13+ mapping = 'standard',14+ timestamp = 1,15+ axes = [0, 0, 0, 0],16+ pressed = [],17+ values = {},18+} = {}) {19+ const buttons = Array(17).fill(0);20+ for (const button of pressed) buttons[button] = 1;21+ for (const [button, value] of Object.entries(values)) buttons[Number(button)] = value;22+ return {23+ connected: true,24+ index,25+ id,26+ mapping,27+ timestamp,28+ axes,29+ buttons,30+ };31+}32+33+function harness() {34+ let pads = [];35+ let remoteRoute = false;36+ const scheduled = [];37+ const sent = [];38+ const navigated = [];39+ const statuses = [];40+ const warnings = [];41+ let activations = 0;42+ let backs = 0;43+ let exits = 0;44+ const runtime = createControllerRuntime({45+ getGamepads: () => pads,46+ schedulePoll: (callback) => scheduled.push(callback),47+ isRemoteRoute: () => remoteRoute,48+ sendRemoteState: (state) => sent.push(state),49+ onNavigate: (direction) => navigated.push(direction),50+ onActivate: () => { activations++; },51+ onBack: () => { backs++; },52+ onStatus: (state) => statuses.push(state),53+ onExit: () => { exits++; },54+ warn: (...args) => warnings.push(args),55+ });56+ return {57+ runtime,58+ scheduled,59+ sent,60+ navigated,61+ statuses,62+ warnings,63+ get activations() { return activations; },64+ get backs() { return backs; },65+ get exits() { return exits; },66+ setPads(next) { pads = next; },67+ setRemoteRoute(next) { remoteRoute = next; },68+ };69+}70+71+function flushPublisher() {72+ return new Promise((resolve) => setImmediate(resolve));73+}74+75+test('polling selects a standard controller and publishes only signature changes', async () => {76+ const fixture = harness();77+ const observed = [];78+ fixture.runtime.setObserver((state) => observed.push(state));79+ fixture.setRemoteRoute(true);80+ fixture.setPads([gamepad({ timestamp: 10, axes: [0.25, 0, 0, 0] })]);81+82+ fixture.runtime.poll(0);83+ await flushPublisher();84+ assert.equal(fixture.statuses.length, 1);85+ assert.equal(fixture.statuses[0].sequence, 1);86+ assert.equal(fixture.runtime.latest.sequence, 1);87+ assert.equal(observed.length, 1);88+ assert.equal(fixture.sent.length, 1);89+ assert.equal(fixture.sent[0].left_x, 8192);90+ assert.equal(fixture.scheduled.length, 1);91+92+ fixture.setPads([gamepad({ timestamp: 999, axes: [0.25, 0, 0, 0] })]);93+ fixture.runtime.poll(16);94+ await flushPublisher();95+ assert.equal(fixture.statuses.length, 1);96+ assert.equal(observed.length, 1);97+ assert.equal(fixture.runtime.latest.sequence, 1);98+99+ fixture.setPads([gamepad({ timestamp: 1000, axes: [0.5, 0, 0, 0] })]);100+ fixture.runtime.poll(32);101+ await flushPublisher();102+ assert.equal(fixture.statuses.length, 2);103+ assert.equal(fixture.statuses[1].sequence, 2);104+ assert.equal(fixture.runtime.latest.sequence, 2);105+ assert.equal(observed.length, 2);106+});107+108+test('local routing preserves navigation repeat and A/B edge actions', () => {109+ const fixture = harness();110+ fixture.setPads([gamepad({ pressed: [0, 1, 12] })]);111+112+ fixture.runtime.poll(0);113+ assert.deepEqual(fixture.navigated, ['up']);114+ assert.equal(fixture.activations, 1);115+ assert.equal(fixture.backs, 1);116+117+ fixture.runtime.poll(100);118+ assert.deepEqual(fixture.navigated, ['up']);119+ assert.equal(fixture.activations, 1);120+ assert.equal(fixture.backs, 1);121+122+ fixture.runtime.poll(360);123+ assert.deepEqual(fixture.navigated, ['up', 'up']);124+ assert.equal(fixture.activations, 1);125+ assert.equal(fixture.backs, 1);126+});127+128+test('remote routing reserves Back+Start and exits after one uninterrupted second', async () => {129+ const fixture = harness();130+ fixture.setRemoteRoute(true);131+ fixture.setPads([gamepad({ pressed: [8, 9, 12] })]);132+133+ fixture.runtime.poll(0);134+ fixture.runtime.poll(999);135+ assert.equal(fixture.exits, 0);136+ fixture.runtime.poll(1000);137+ assert.equal(fixture.exits, 1);138+ fixture.runtime.poll(2000);139+ assert.equal(fixture.exits, 1);140+ assert.deepEqual(fixture.navigated, []);141+ await flushPublisher();142+ assert.equal(fixture.sent.length, 1);143+ assert.equal(fixture.sent[0].back, false);144+ assert.equal(fixture.sent[0].start, false);145+ assert.equal(fixture.sent[0].dpad_up, true);146+147+ fixture.setPads([gamepad()]);148+ fixture.runtime.poll(2001);149+ fixture.setPads([gamepad({ pressed: [8, 9] })]);150+ fixture.runtime.poll(2100);151+ fixture.runtime.poll(3100);152+ assert.equal(fixture.exits, 2);153+});154+155+test('controller loss publishes a neutral remote state while the control route stays active', async () => {156+ const fixture = harness();157+ fixture.setRemoteRoute(true);158+ fixture.setPads([gamepad({ pressed: [0], axes: [1, 0, 0, 0] })]);159+ fixture.runtime.poll(0);160+ await flushPublisher();161+ assert.equal(fixture.sent[0].a, true);162+163+ fixture.setPads([]);164+ fixture.runtime.poll(16);165+ await flushPublisher();166+ assert.deepEqual(fixture.sent[1], neutralGamepadInputState());167+ assert.equal(fixture.statuses.at(-1).connected, false);168+});169+170+test('manual publication preserves observer and remote routing without changing sequence', async () => {171+ const fixture = harness();172+ fixture.setRemoteRoute(true);173+ fixture.setPads([gamepad({ pressed: [0] })]);174+ fixture.runtime.poll(0);175+ await flushPublisher();176+ const sequence = fixture.runtime.latest.sequence;177+ const observed = [];178+ fixture.runtime.setObserver((state) => observed.push(state));179+180+ fixture.runtime.publishCurrentState();181+ await flushPublisher();182+ assert.equal(fixture.runtime.latest.sequence, sequence);183+ assert.equal(observed.length, 1);184+ assert.equal(fixture.sent.length, 2);185+ assert.throws(() => fixture.runtime.setObserver('invalid'), /observer must be a function or null/);186+});187+188+test('observer and gamepad polling failures are contained without stopping the loop', async () => {189+ const fixture = harness();190+ fixture.setRemoteRoute(true);191+ fixture.setPads([gamepad()]);192+ fixture.runtime.setObserver(() => { throw new Error('observer boom'); });193+ fixture.runtime.poll(0);194+ await flushPublisher();195+ assert.equal(fixture.sent.length, 1);196+ assert.match(String(fixture.warnings[0][1]), /observer boom/);197+198+ const scheduled = [];199+ const warnings = [];200+ const runtime = createControllerRuntime({201+ getGamepads: () => { throw new Error('poll boom'); },202+ schedulePoll: (callback) => scheduled.push(callback),203+ warn: (...args) => warnings.push(args),204+ });205+ runtime.poll(16);206+ assert.equal(scheduled.length, 1);207+ assert.equal(warnings[0][0], 'gamepad poll failed:');208+ assert.match(String(warnings[0][1]), /poll boom/);209+});210+211+test('start schedules the first poll and resetEscape cancels an in-progress hold', () => {212+ const fixture = harness();213+ fixture.runtime.start();214+ assert.equal(fixture.scheduled.length, 1);215+216+ fixture.setRemoteRoute(true);217+ fixture.setPads([gamepad({ pressed: [8, 9] })]);218+ fixture.runtime.poll(0);219+ fixture.runtime.resetEscape();220+ fixture.runtime.poll(1000);221+ assert.equal(fixture.exits, 0);222+ fixture.runtime.poll(2000);223+ assert.equal(fixture.exits, 1);224+});225+226+test('Portal delegates controller state while retaining DOM navigation and polling start', () => {227+ assert.match(main, /import \{ createControllerRuntime \} from '\.\/controller-runtime\.mjs';/);228+ assert.match(main, /controllerRuntime = createControllerRuntime\(\{/);229+ assert.match(main, /onNavigate: navigateControllerFocus/);230+ assert.match(main, /onActivate: activateControllerFocus/);231+ assert.match(main, /onBack: controllerBack/);232+ assert.match(main, /onStatus: updateControllerStatus/);233+ assert.match(main, /requestAnimationFrame\(controllerRuntime\.poll\)/);234+ assert.match(main, /function controllerScope\(\)/);235+ assert.match(main, /function setControllerFocus\(element\)/);236+ assert.doesNotMatch(main, /const controllerPublisher =/);237+ assert.doesNotMatch(main, /let controllerSequence =/);238+ assert.doesNotMatch(main, /function pollControllers\(/);239+});
M portal/decoder-recovery.test.mjs +7 -7
@@ -7,19 +7,19 @@ import {
77 DecoderRecoveryState,88 } from './decoder-recovery.mjs';99 10-const main = await readFile(new URL('./main.js', import.meta.url), 'utf8');10+const pipeline = await readFile(new URL('./video-pipeline.mjs', import.meta.url), 'utf8');1111 12-test('connect wiring uses the recovery state instead of removed legacy state', () => {13- assert.doesNotMatch(main, /waitingForDecoderKeyframe/);14- assert.match(main, /decoderRecovery\.reset\(\);/);12+test('video pipeline uses the recovery state instead of removed legacy state', () => {13+ assert.doesNotMatch(pipeline, /waitingForDecoderKeyframe/);14+ assert.match(pipeline, /decoderRecovery\.reset\(\);/);1515 });1616 1717 test('decoder callbacks cannot mutate a replacement decoder session', () => {1818 assert.match(19- main,19+ pipeline,2020 /output: \(frame\) => \{\s*if \(videoDecoder !== decoder\) \{\s*frame\.close\(\);\s*return;/,2121 );22- assert.match(main, /error: \(e\) => \{\s*if \(videoDecoder !== decoder\) return;/);22+ assert.match(pipeline, /error: \(error\) => \{\s*if \(videoDecoder !== decoder\) return;/);2323 });2424 2525 test('a delivered discontinuity keyframe resets without requesting its replacement', () => {
@@ -37,7 +37,7 @@ test('a delivered discontinuity keyframe resets without requesting its replaceme
3737 assert.equal(recovery.confirmKeyframeEnqueued(true), true);3838 assert.deepEqual(requests, []);3939 assert.match(40- main,40+ pipeline,4141 /enterDecoderRecovery\(DECODER_RECOVERY_REASONS\.DISCONTINUITY, \{\s*requestKeyframe: !keyframe,/,4242 );4343 });
A portal/input-runtime.mjs +237 -0
@@ -0,0 +1,237 @@
1+import {2+ HeldInputState,3+ MAX_HELD_KEYS,4+ MAX_HELD_MOUSE_BUTTONS,5+ PointerMotionBuffer,6+ restoreRejectedPointerMotion,7+} from './input-state.mjs';8+9+export const RELIABLE_INPUT_QUEUE_LIMIT = 128;10+export const IN_FLIGHT_INPUT_RESTORE_RESERVE = 1;11+export const RELIABLE_INPUT_ENQUEUE_LIMIT = RELIABLE_INPUT_QUEUE_LIMIT12+ - IN_FLIGHT_INPUT_RESTORE_RESERVE;13+export const POINTER_MOTION_BARRIER_RESERVE = 2;14+export const RELEASE_INPUT_RESERVE = MAX_HELD_KEYS15+ + MAX_HELD_MOUSE_BUTTONS16+ + POINTER_MOTION_BARRIER_RESERVE;17+export const REGULAR_RELIABLE_INPUT_LIMIT = RELIABLE_INPUT_ENQUEUE_LIMIT18+ - RELEASE_INPUT_RESERVE;19+export const INPUT_RETRY_MS = 8;20+21+export function createInputRuntime({22+ invokeCommand,23+ getCapabilities,24+ isConnected,25+ onFatal,26+ resetControllerActivation,27+ scheduleTimeout = globalThis.setTimeout,28+ scheduleMicrotask = globalThis.queueMicrotask,29+ now = () => performance.now(),30+ logger = console,31+} = {}) {32+ if (typeof invokeCommand !== 'function') throw new TypeError('invokeCommand must be a function');33+ if (typeof getCapabilities !== 'function') throw new TypeError('getCapabilities must be a function');34+ if (typeof isConnected !== 'function') throw new TypeError('isConnected must be a function');35+ if (typeof onFatal !== 'function') throw new TypeError('onFatal must be a function');36+ if (typeof resetControllerActivation !== 'function') {37+ throw new TypeError('resetControllerActivation must be a function');38+ }39+40+ const reliableInputQueue = [];41+ const heldInputs = new HeldInputState();42+ const pendingPointerMotion = new PointerMotionBuffer();43+ let pendingGamepadInput = null;44+ let inputPumpRunning = false;45+ let inputPumpScheduled = false;46+47+ function clear() {48+ reliableInputQueue.length = 0;49+ heldInputs.clear();50+ resetControllerActivation();51+ pendingPointerMotion.clear();52+ pendingGamepadInput = null;53+ }54+55+ function pointerAvailable() {56+ const capabilities = getCapabilities();57+ return capabilities.relativePointer || capabilities.absolutePointer;58+ }59+60+ function inputEventAvailable(event) {61+ const capabilities = getCapabilities();62+ if (!capabilities.control) return false;63+ if (event.t === 'mm') return capabilities.absolutePointer;64+ if (event.t === 'mr') return capabilities.relativePointer;65+ if (event.t === 'mp') return capabilities.relativePointer;66+ if (['mc', 'md', 'mu', 'ms'].includes(event.t)) return pointerAvailable();67+ if (['kd', 'ku', 'kt'].includes(event.t)) return capabilities.keyboard;68+ if (event.t === 'tx') return capabilities.text;69+ if (event.t === 'gp') return capabilities.gamepad;70+ return false;71+ }72+73+ function hasPending() {74+ return reliableInputQueue.length > 075+ || pendingPointerMotion.pending76+ || pendingGamepadInput !== null;77+ }78+79+ function schedulePump() {80+ if (inputPumpRunning || inputPumpScheduled) return;81+ inputPumpScheduled = true;82+ scheduleTimeout(() => {83+ inputPumpScheduled = false;84+ void pump();85+ }, 0);86+ }87+88+ function signalFatal(reason, error = null) {89+ if (isConnected()) void onFatal({ reason, error });90+ }91+92+ function send(event, { release = false } = {}) {93+ if (!inputEventAvailable(event)) return false;94+ if (event.t === 'gp') {95+ // Controller samples are state, not transitions. A single latest-value slot96+ // bounds latency and guarantees a neutral snapshot can replace stale input.97+ pendingGamepadInput = event;98+ } else if (event.t === 'mm') {99+ // Absolute pointer motion is latest-value data; stale coordinates are not100+ // useful and must not create an invocation backlog.101+ pendingPointerMotion.setAbsolute(event);102+ } else if (event.t === 'mr') {103+ // Relative motion is displacement. Coalesce every sample into one104+ // constant-size total; the pump emits protocol-bounded chunks so105+ // throttling preserves distance instead of dropping motion.106+ if (!pendingPointerMotion.addRelative(event.dx, event.dy)) return false;107+ } else {108+ const queueLimit = release ? RELIABLE_INPUT_ENQUEUE_LIMIT : REGULAR_RELIABLE_INPUT_LIMIT;109+ const pointerTransition = ['mp', 'mc', 'md', 'mu', 'ms'].includes(event.t);110+ const barrierLength = pointerTransition ? pendingPointerMotion.barrierLength : 0;111+ const tail = reliableInputQueue.at(-1);112+ const coalesceScroll = event.t === 'ms' && barrierLength === 0 && tail?.t === 'ms';113+ const requiredSlots = coalesceScroll ? 0 : barrierLength + 1;114+ if (reliableInputQueue.length + requiredSlots > queueLimit) {115+ logger.error(release116+ ? 'input release reserve exhausted; closing control session'117+ : 'reliable input queue full; closing control session');118+ // Defer teardown until the caller has removed any transition it could119+ // not enqueue from held-state tracking.120+ scheduleMicrotask(() => signalFatal(121+ release ? 'input release reserve exhausted' : 'reliable input queue full',122+ ));123+ return false;124+ }125+ if (coalesceScroll) {126+ tail.dx = Math.max(-1000000, Math.min(1000000, tail.dx + event.dx));127+ tail.dy = Math.max(-1000000, Math.min(1000000, tail.dy + event.dy));128+ } else if (pointerTransition) {129+ reliableInputQueue.push(...pendingPointerMotion.takeBarrierBefore(event));130+ } else {131+ reliableInputQueue.push(event);132+ }133+ }134+ schedulePump();135+ return true;136+ }137+138+ function releaseHeld() {139+ for (const release of heldInputs.releaseEvents()) {140+ // Ordinary reliable events cannot consume this reserved capacity.141+ send(release, { release: true });142+ }143+ heldInputs.clear();144+ }145+146+ async function drain(timeoutMs) {147+ const deadline = now() + timeoutMs;148+ while ((hasPending() || inputPumpRunning) && now() < deadline) {149+ await new Promise((resolve) => scheduleTimeout(resolve, INPUT_RETRY_MS));150+ }151+ }152+153+ function takePending() {154+ if (reliableInputQueue.length > 0) {155+ return { event: reliableInputQueue.shift(), reliable: true };156+ }157+ const pointerMotionEvent = pendingPointerMotion.take();158+ if (pointerMotionEvent !== null) {159+ return { event: pointerMotionEvent, reliable: false };160+ }161+ if (pendingGamepadInput !== null) {162+ const event = pendingGamepadInput;163+ pendingGamepadInput = null;164+ return { event, reliable: false };165+ }166+ return null;167+ }168+169+ function restorePending(item) {170+ if (item.reliable) {171+ if (reliableInputQueue.length >= RELIABLE_INPUT_QUEUE_LIMIT) {172+ throw new Error('reliable input restore reserve exhausted');173+ }174+ reliableInputQueue.unshift(item.event);175+ } else if (item.event.t === 'mm' || item.event.t === 'mr') {176+ // A transition queued during the invoke must remain after the rejected177+ // motion. With no transition, relative displacement merges while stale178+ // absolute state yields to any newer latest position.179+ restoreRejectedPointerMotion(180+ reliableInputQueue,181+ pendingPointerMotion,182+ item.event,183+ RELIABLE_INPUT_QUEUE_LIMIT,184+ );185+ } else if (item.event.t === 'gp') {186+ // Keep a newer controller sample, especially a neutral release state.187+ if (pendingGamepadInput === null) pendingGamepadInput = item.event;188+ }189+ }190+191+ async function pump() {192+ if (inputPumpRunning) return;193+ inputPumpRunning = true;194+ try {195+ while (hasPending()) {196+ const item = takePending();197+ if (!item) break;198+ let accepted;199+ try {200+ accepted = await invokeCommand('iroh_client_send_input', { event: item.event });201+ } catch (error) {202+ logger.warn('input send failed:', error);203+ clear();204+ return;205+ }206+ if (!accepted) {207+ try {208+ restorePending(item);209+ } catch (error) {210+ logger.error('input restore failed:', error);211+ clear();212+ signalFatal('input restore failed', error);213+ return;214+ }215+ await new Promise((resolve) => scheduleTimeout(resolve, INPUT_RETRY_MS));216+ }217+ }218+ } finally {219+ inputPumpRunning = false;220+ if (hasPending()) schedulePump();221+ }222+ }223+224+ return Object.freeze({225+ clear,226+ drain,227+ hasPending,228+ inputEventAvailable,229+ pointerAvailable,230+ releaseHeld,231+ send,232+ takeKeyRelease: (id) => heldInputs.takeKeyRelease(id),233+ takeMouseButtonRelease: (button) => heldInputs.takeMouseButtonRelease(button),234+ trackKey: (id, value) => heldInputs.trackKey(id, value),235+ trackMouseButton: (button) => heldInputs.trackMouseButton(button),236+ });237+}
A portal/input-runtime.test.mjs +306 -0
@@ -0,0 +1,306 @@
1+import assert from 'node:assert/strict';2+import { readFile } from 'node:fs/promises';3+import test from 'node:test';4+5+import {6+ INPUT_RETRY_MS,7+ REGULAR_RELIABLE_INPUT_LIMIT,8+ RELIABLE_INPUT_ENQUEUE_LIMIT,9+ createInputRuntime,10+} from './input-runtime.mjs';11+12+const main = await readFile(new URL('./main.js', import.meta.url), 'utf8');13+14+const fullCapabilities = Object.freeze({15+ control: true,16+ absolutePointer: true,17+ relativePointer: true,18+ keyboard: true,19+ text: true,20+ gamepad: true,21+});22+23+function newHarness({24+ capabilities = fullCapabilities,25+ invokeCommand = async () => true,26+ connected = true,27+ scheduleTimeout,28+ scheduleMicrotask,29+} = {}) {30+ const fatal = [];31+ const errors = [];32+ const warnings = [];33+ let activationResets = 0;34+ const runtime = createInputRuntime({35+ invokeCommand,36+ getCapabilities: () => capabilities,37+ isConnected: () => connected,38+ onFatal: (detail) => fatal.push(detail),39+ resetControllerActivation: () => { activationResets += 1; },40+ scheduleTimeout,41+ scheduleMicrotask,42+ logger: {43+ error: (...args) => errors.push(args),44+ warn: (...args) => warnings.push(args),45+ },46+ });47+ return {48+ runtime,49+ fatal,50+ errors,51+ warnings,52+ get activationResets() { return activationResets; },53+ };54+}55+56+test('capability filtering preserves the current event routing contract', () => {57+ const capabilities = {58+ control: true,59+ absolutePointer: false,60+ relativePointer: true,61+ keyboard: false,62+ text: true,63+ gamepad: false,64+ };65+ const { runtime } = newHarness({66+ capabilities,67+ scheduleTimeout: () => 0,68+ });69+70+ assert.equal(runtime.pointerAvailable(), true);71+ assert.equal(runtime.inputEventAvailable({ t: 'mm' }), false);72+ assert.equal(runtime.inputEventAvailable({ t: 'mr' }), true);73+ assert.equal(runtime.inputEventAvailable({ t: 'mp' }), true);74+ assert.equal(runtime.inputEventAvailable({ t: 'md' }), true);75+ assert.equal(runtime.inputEventAvailable({ t: 'kd' }), false);76+ assert.equal(runtime.inputEventAvailable({ t: 'tx' }), true);77+ assert.equal(runtime.inputEventAvailable({ t: 'gp' }), false);78+ capabilities.control = false;79+ assert.equal(runtime.inputEventAvailable({ t: 'tx' }), false);80+});81+82+test('reliable transitions run before motion and only the latest gamepad state survives', async () => {83+ const delivered = [];84+ const { runtime } = newHarness({85+ invokeCommand: async (_command, { event }) => {86+ delivered.push(event);87+ return true;88+ },89+ });90+91+ runtime.send({ t: 'gp', state: { a: true } });92+ runtime.send({ t: 'gp', state: { a: false } });93+ runtime.send({ t: 'mr', dx: 4, dy: -2 });94+ runtime.send({ t: 'mr', dx: 3, dy: 1 });95+ runtime.send({ t: 'kd', k: 'a' });96+ await runtime.drain(200);97+98+ assert.deepEqual(delivered, [99+ { t: 'kd', k: 'a' },100+ { t: 'mr', dx: 7, dy: -1 },101+ { t: 'gp', state: { a: false } },102+ ]);103+});104+105+test('absolute pointer state is latest-value-wins', async () => {106+ const delivered = [];107+ const { runtime } = newHarness({108+ invokeCommand: async (_command, { event }) => {109+ delivered.push(event);110+ return true;111+ },112+ });113+114+ runtime.send({ t: 'mm', x: 10, y: 20 });115+ runtime.send({ t: 'mm', x: 30, y: 40 });116+ await runtime.drain(200);117+118+ assert.deepEqual(delivered, [{ t: 'mm', x: 30, y: 40 }]);119+});120+121+test('pointer transition barriers drain every motion chunk and adjacent scroll coalesces', async () => {122+ const delivered = [];123+ const { runtime } = newHarness({124+ invokeCommand: async (_command, { event }) => {125+ delivered.push(event);126+ return true;127+ },128+ });129+130+ runtime.send({ t: 'mr', dx: 50_000, dy: -50_000 });131+ runtime.send({ t: 'md', b: 1 });132+ runtime.send({ t: 'ms', dx: 900_000, dy: -900_000 });133+ runtime.send({ t: 'ms', dx: 900_000, dy: -900_000 });134+ await runtime.drain(200);135+136+ assert.deepEqual(delivered, [137+ { t: 'mr', dx: 32_767, dy: -32_767 },138+ { t: 'mr', dx: 17_233, dy: -17_233 },139+ { t: 'md', b: 1 },140+ { t: 'ms', dx: 1_000_000, dy: -1_000_000 },141+ ]);142+});143+144+test('rejected in-flight motion is restored before a transition queued behind it', async () => {145+ const delivered = [];146+ let startFirst;147+ const firstStarted = new Promise((resolve) => { startFirst = resolve; });148+ let rejectFirst;149+ const firstResult = new Promise((resolve) => { rejectFirst = resolve; });150+ let attempts = 0;151+ const { runtime } = newHarness({152+ invokeCommand: async (_command, { event }) => {153+ delivered.push(event);154+ attempts += 1;155+ if (attempts === 1) {156+ startFirst();157+ return firstResult;158+ }159+ return true;160+ },161+ });162+163+ runtime.send({ t: 'mr', dx: 50_000, dy: 0 });164+ await firstStarted;165+ runtime.send({ t: 'md', b: 1 });166+ rejectFirst(false);167+ await runtime.drain(300);168+169+ assert.deepEqual(delivered, [170+ { t: 'mr', dx: 32_767, dy: 0 },171+ { t: 'mr', dx: 32_767, dy: 0 },172+ { t: 'mr', dx: 17_233, dy: 0 },173+ { t: 'md', b: 1 },174+ ]);175+});176+177+test('a rejected gamepad sample cannot replace newer controller state', async () => {178+ const delivered = [];179+ let startFirst;180+ const firstStarted = new Promise((resolve) => { startFirst = resolve; });181+ let rejectFirst;182+ const firstResult = new Promise((resolve) => { rejectFirst = resolve; });183+ let attempts = 0;184+ const { runtime } = newHarness({185+ invokeCommand: async (_command, { event }) => {186+ delivered.push(event);187+ attempts += 1;188+ if (attempts === 1) {189+ startFirst();190+ return firstResult;191+ }192+ return true;193+ },194+ });195+196+ runtime.send({ t: 'gp', state: { a: true } });197+ await firstStarted;198+ runtime.send({ t: 'gp', state: { a: false } });199+ rejectFirst(false);200+ await runtime.drain(300);201+202+ assert.deepEqual(delivered, [203+ { t: 'gp', state: { a: true } },204+ { t: 'gp', state: { a: false } },205+ ]);206+});207+208+test('regular and release queue limits retain their reserved capacity and fail asynchronously', async () => {209+ const scheduled = [];210+ const microtasks = [];211+ const harness = newHarness({212+ scheduleTimeout: (callback, delay) => {213+ scheduled.push({ callback, delay });214+ return scheduled.length;215+ },216+ scheduleMicrotask: (callback) => microtasks.push(callback),217+ });218+ const { runtime } = harness;219+220+ for (let index = 0; index < REGULAR_RELIABLE_INPUT_LIMIT; index += 1) {221+ assert.equal(runtime.send({ t: 'kd', k: `key-${index}` }), true);222+ }223+ assert.equal(runtime.send({ t: 'kd', k: 'ordinary-overflow' }), false);224+ assert.equal(harness.fatal.length, 0);225+ microtasks.shift()();226+ assert.equal(harness.fatal[0].reason, 'reliable input queue full');227+228+ for (229+ let index = REGULAR_RELIABLE_INPUT_LIMIT;230+ index < RELIABLE_INPUT_ENQUEUE_LIMIT;231+ index += 1232+ ) {233+ assert.equal(runtime.send({ t: 'ku', k: `release-${index}` }, { release: true }), true);234+ }235+ assert.equal(runtime.send({ t: 'ku', k: 'release-overflow' }, { release: true }), false);236+ microtasks.shift()();237+ assert.equal(harness.fatal[1].reason, 'input release reserve exhausted');238+ assert.equal(scheduled.length, 1, 'one pending pump owns the queue');239+});240+241+test('held transitions release once in key-then-button insertion order', async () => {242+ const delivered = [];243+ const { runtime } = newHarness({244+ invokeCommand: async (_command, { event }) => {245+ delivered.push(event);246+ return true;247+ },248+ });249+250+ assert.equal(runtime.trackKey('KeyA', 'a'), 'tracked');251+ assert.equal(runtime.trackKey('KeyA', 'a'), 'repeat');252+ assert.equal(runtime.trackKey('KeyB', 'b'), 'tracked');253+ assert.equal(runtime.trackMouseButton(2), true);254+ assert.equal(runtime.trackMouseButton(1), true);255+ runtime.releaseHeld();256+ runtime.releaseHeld();257+ await runtime.drain(200);258+259+ assert.deepEqual(delivered, [260+ { t: 'ku', k: 'a' },261+ { t: 'ku', k: 'b' },262+ { t: 'mu', b: 2 },263+ { t: 'mu', b: 1 },264+ ]);265+});266+267+test('transport exceptions clear all pending state without escalating disconnect', async () => {268+ let calls = 0;269+ const harness = newHarness({270+ invokeCommand: async () => {271+ calls += 1;272+ throw new Error('transport closed');273+ },274+ });275+ const { runtime } = harness;276+277+ runtime.trackKey('KeyA', 'a');278+ runtime.send({ t: 'kd', k: 'a' });279+ runtime.send({ t: 'gp', state: { a: true } });280+ await runtime.drain(200);281+282+ assert.equal(calls, 1);283+ assert.equal(runtime.hasPending(), false);284+ assert.equal(runtime.takeKeyRelease('KeyA'), null);285+ assert.equal(harness.activationResets, 1);286+ assert.equal(harness.fatal.length, 0);287+ assert.equal(harness.warnings[0][0], 'input send failed:');288+});289+290+test('retry cadence remains eight milliseconds', () => {291+ assert.equal(INPUT_RETRY_MS, 8);292+});293+294+test('Portal delegates queue ownership while retaining DOM input wiring', () => {295+ assert.match(main, /const inputRuntime = createInputRuntime\(\{/);296+ assert.match(297+ main,298+ /resetControllerActivation: \(\) => controlRuntime\?\.resetControllerActivation\(\)/,299+ );300+ assert.match(main, /window\.addEventListener\('mousedown', handleMouseDown/);301+ assert.match(main, /inputRuntime\.trackMouseButton\(btn\)/);302+ assert.match(main, /inputRuntime\.trackKey\(keyId, k\)/);303+ assert.doesNotMatch(main, /const reliableInputQueue =/);304+ assert.doesNotMatch(main, /function sendInput\(/);305+ assert.doesNotMatch(main, /new HeldInputState\(/);306+});
A portal/keyboard-map.mjs +14 -0
A portal/keyboard-map.test.mjs +38 -0
M portal/main.js +436 -2121
@@ -1,253 +1,86 @@
11 import {2- parseAnnexBNals,3- nalsToLengthPrefixed,4- buildAvcDescription,5- avcCodecStr,6- h264NalType,7- buildHvcDescription,8- hevcCodecStr,9- hevcNalType,10- parseAv1Obus,11- av1CodecStr,12- buildAv1Description,13-} from './codecs.js';14-import {15- HeldInputState,16- MAX_HELD_KEYS,17- MAX_HELD_MOUSE_BUTTONS,18- PointerMotionBuffer,19- browserPointerLockLossRequiresControlExit,202 mapCanvasPointToSurface,213 resolvePointerSurfaceSize,22- restoreRejectedPointerMotion,234 scaleRelativePointerDelta,245 validatePointerSurfaceDimensions,256 validatePointerPositionFeedback,267 browserMouseButtonCode,278 } from './input-state.mjs';28-import { parseFrameEnvelope } from './frame-envelope.mjs';29-import {30- formatVideoDiscardTelemetry,31- isCurrentFrameGeneration,32- normalizeFrameStatsPayload,33-} from './frame-stats.mjs';9+import { createInputRuntime } from './input-runtime.mjs';10+import { createControlRuntime } from './control-runtime.mjs';11+import { formatVideoDiscardTelemetry } from './frame-stats.mjs';3412 import { networkDiagnosticsPresentation } from './network-diagnostics.mjs';3513 import {36- FRAME_CHANNEL_CAPACITY,37- activateFrameSession,38- isActiveFrameSession,39- newFrameSession,40- stageFrameAcknowledgment,41- stageLegacyFrame,42-} from './frame-session.mjs';43-import { parsePointerFeedbackMessage } from './pointer-feedback.mjs';44-import {45- committedRustConnection,46- disconnectRejectedRustConnection,47-} from './connection-attempt.mjs';48-import {49- audioMinusVideoSkewMs,50- audioOutputTimelineFromStats,51- exactMediaTimestampMicros,52- formatSignedMilliseconds,53- isCurrentAvSyncEpoch,54- nextAvSyncEpoch,55- projectAudioMediaPtsMicros,56-} from './av-sync.mjs';57-import {58- isCurrentAudioDelivery,59- isCurrentAudioGeneration,60- parseAudioEnvelope,61- stageAudioTerminalState,62- takeAudioTerminalState,63-} from './audio-envelope.mjs';64-import { BoundedAudioMessageTracker } from './audio-ring.mjs';14+ createPointerFeedbackRuntime,15+ newPointerSession,16+} from './pointer-feedback.mjs';17+import { newConnectionState } from './connection-state.mjs';18+import { formatSignedMilliseconds } from './av-sync.mjs';6519 import { audioButtonPresentation } from './audio-ui.mjs';6620 import {67- BoundedCadenceWindow,68- BoundedLatencyWindow,69- BoundedValueWindow,70- LatestFramePresenter,71- RollingRateWindow,72-} from './stream-metrics.mjs';21+ AUDIO_SAMPLE_RATE,22+ createAudioPipelineSession,23+} from './audio-pipeline.mjs';7324 import {74- ControllerActivationGate,75- ControllerActionRepeater,76- GamepadEscapeHold,77- LatestControllerStatePublisher,7825 chooseDirectionalIndex,79- controllerStateSignature,80- disconnectedControllerState,81- maskGamepadEscapeChord,82- neutralGamepadInputState,83- normalizeGamepad,84- selectPreferredController,85- toGamepadInputState,8626 } from './controller-state.mjs';27+import { createControllerRuntime } from './controller-runtime.mjs';28+import { createWindowRuntime } from './window-runtime.mjs';8729 import {88- constrainStreamWindowResize,89- fitInitialStreamWindow,90- fitStreamSurface,91- streamAspectKey,92-} from './window-geometry.mjs';93-import { VideoFormatTransitionGuard } from './video-format-transition.mjs';94-import { buildVideoDecoderConfig } from './video-decoder-config.mjs';95-import {96- DECODER_RECOVERY_REASONS,97- DecoderRecoveryState,98-} from './decoder-recovery.mjs';30+ createVideoPipelineSession,31+} from './video-pipeline.mjs';9932 import {100- AdaptiveFeedbackPublisher,10133 formatAdaptiveDecision,102- normalizeAdaptiveDecisionEnvelope,10334 } from './adaptive-feedback.mjs';35+import { createStreamRuntime } from './stream-runtime.mjs';10436 import {10537 formatInvitationExpiry,10638 grantLabel,10739 normalizeInvitationSummary,10840 shortPeerFingerprint,10941 } from './enrollment.mjs';42+import { mapKey } from './keyboard-map.mjs';11043 111-let controlMode = false;112-let connected = false;113-let connecting = false;114-let developmentConnectionMode = false;11544 let enrollmentReady = false;11645 let pendingInvitationSummary = null;11746 let currentEnrollmentStatus = null;118-let disconnecting = false;119-let inputCapabilities = {120- relativePointer: false,121- pointerPositionFeedback: false,122- absolutePointer: false,123- keyboard: false,124- text: false,125- gamepad: false,126- control: false,127-};128-let frameWidth = 0;129-let frameHeight = 0;130-let fittedStreamAspect = null;131-let pendingStreamFit = null;132-let lastObservedWindowSize = null;133-let streamWindowResizeTimer = null;134-let pointerSurfaceDimensions = null;135-let remotePointerPosition = null;136-let remotePointerVisible = false;137-let activeFrameChannel = null;138-let activeFrameGeneration = null;139-let activeFrameSession = null;140-let activeVideoFormatEpoch = 0;141-let activeAudioChannel = null;142-let activePointerChannel = null;14347 let activePointerSession = null;144-let activeAudioSession = null;14548 let controllerActivationInProgress = false;146-let controlTransitionInProgress = false;147-let controlTransitionGeneration = 0;148-let nativeCursorCommand = Promise.resolve();149-let browserPointerLockRequired = true;150-const controllerActivationGate = new ControllerActivationGate();151-152-const AUDIO_DECODER_CONFIG = Object.freeze({153- codec: 'opus',154- sampleRate: 48000,155- numberOfChannels: 2,156-});157-const MAX_AUDIO_DECODE_QUEUE_SIZE = 3;158-let audioContext = null;159-let audioWorkletNode = null;160-let audioDecoder = null;161-let audioAvailable = false;162-let audioMuted = false;163-let audioState = 'unavailable';164-let audioStateDetail = 'not connected';165-let audioPacketsReceived = 0;166-let audioDecoderDropped = 0;167-let audioBufferedFrames = 0;168-let audioUnderflows = 0;169-let audioUnderflowDurationMicros = 0;170-let audioSilentDurationMicros = 0;171-let audioTransportDropped = 0;172-let audioFrontendDropped = 0;173-174-function newAudioSession() {175- return {176- expectedGeneration: null,177- pendingTerminalStates: [],178- failed: false,179- failureDetail: null,180- stopRequested: false,181- workletNode: null,182- workletDroppedFrames: 0,183- workletRecoveryDiscardedFrames: 0,184- audioTimeline: null,185- avSyncEpoch: 0,186- messageTracker: new BoundedAudioMessageTracker(),187- };188-}189-190-function newPointerSession() {191- return {192- received: false,193- latest: null,194- failed: false,195- failureDetail: null,196- closing: false,197- };198-}199-200-function applyPointerPositionFeedback(message, session) {201- if (session !== activePointerSession || !inputCapabilities.pointerPositionFeedback) return;202- const surface = currentPointerSurfaceSize();203- if (surface === null) return;204- const feedback = validatePointerPositionFeedback(message, surface);205- remotePointerPosition = feedback.position;206- remotePointerVisible = feedback.pointer_visible;207- renderRemotePointer();208-}209-210-function handlePointerPositionFeedback(message, session) {211- if (session !== activePointerSession || session.failed || session.closing) return;212- try {213- const envelope = parsePointerFeedbackMessage(message);214- if (envelope.type === 'terminal') {215- session.failed = true;216- session.failureDetail = envelope.reason === 'eof'217- ? 'Pointer feedback ended'218- : 'Pointer feedback was malformed';219- remotePointerPosition = null;220- remotePointerVisible = false;221- renderRemotePointer();222- console.error(session.failureDetail);223- if (connected) void disconnect();224- return;225- }226- const feedback = envelope.feedback;227- session.received = true;228- session.latest = feedback;229- if (connected) applyPointerPositionFeedback(feedback, session);230- } catch (error) {231- session.failed = true;232- session.failureDetail = `Pointer feedback failed: ${error}`;233- remotePointerPosition = null;234- remotePointerVisible = false;235- renderRemotePointer();236- console.error('invalid pointer-position feedback:', error);237- if (connected) void disconnect();238- }239-}49+let controlRuntime = null;50+let controllerRuntime = null;24051 24152 const invoke = (...args) => window.__TAURI__.core.invoke(...args);24253 const listen = (...args) => window.__TAURI__.event.listen(...args);243-const adaptiveFeedbackPublisher = new AdaptiveFeedbackPublisher({ invokeCommand: invoke });244-let adaptiveFeedbackAvailable = false;245-let adaptiveFeedbackError = null;246-let adaptiveDecision = null;54+const pointerFeedbackRuntime = createPointerFeedbackRuntime({55+ getActiveSession: () => activePointerSession,56+ getCapabilities: () => ({57+ connected: connectionState.connected,58+ pointerPositionFeedback: connectionState.inputCapabilities.pointerPositionFeedback,59+ }),60+ getSurface: currentPointerSurfaceSize,61+ render: renderRemotePointer,62+ disconnect,63+ logger: console,64+});65+const inputRuntime = createInputRuntime({66+ invokeCommand: invoke,67+ getCapabilities: () => connectionState.inputCapabilities,68+ isConnected: () => connectionState.connected,69+ onFatal: () => { void disconnect(); },70+ resetControllerActivation: () => controlRuntime?.resetControllerActivation(),71+});72+let videoPipeline = null;73+let streamRuntime = null;74+const audioPipeline = createAudioPipelineSession({75+ invokeCommand: invoke,76+ onUpdate: updateAudioUI,77+ resetAvSync: (...args) => videoPipeline?.resetAvSync(...args),78+ isDisconnecting: () => connectionState.disconnecting,79+});24780 24881 function updatePanelVisibility() {24982 const streamSection = document.getElementById('stream-section');250- streamSection.style.display = connected ? '' : 'none';83+ streamSection.style.display = connectionState.connected ? '' : 'none';25184 }25285 25386 // ─── Status ──────────────────────────────────────────────────────────────────
@@ -263,7 +96,7 @@ function setStatus(state, text) {
26396 function showIntro() {26497 document.getElementById('intro').classList.remove('hidden');26598 setTimeout(() => {266- const target = developmentConnectionMode99+ const target = connectionState.developmentMode267100 ? document.getElementById('intro-connect')268101 : document.getElementById('intro-pin');269102 target?.focus();
@@ -300,7 +133,7 @@ function showInvitationSummary(value) {
300133 }301134 302135 async function refreshEnrollment() {303- if (developmentConnectionMode) {136+ if (connectionState.developmentMode) {304137 enrollmentReady = true;305138 hideEnrollment();306139 showIntro();
@@ -426,7 +259,7 @@ async function cancelInvitation() {
426259 427260 async function checkDevelopmentConnectionMode() {428261 const mode = await invoke('development_connection_mode');429- developmentConnectionMode = mode.enabled;262+ connectionState.setDevelopmentMode(mode.enabled);430263 if (!mode.enabled) return;431264 432265 const badge = document.getElementById('dev-connect-badge');
@@ -448,24 +281,24 @@ async function checkDevelopmentConnectionMode() {
448281 449282 // ─── Intro actions ────────────────────────────────────────────────────────────450283 async function introConnect() {451- if (connecting || connected) return;452- if (!developmentConnectionMode && !enrollmentReady) {284+ if (connectionState.connecting || connectionState.connected) return;285+ if (!connectionState.developmentMode && !enrollmentReady) {453286 showEnrollment();454287 return;455288 }456289 const pin = document.getElementById('intro-pin').value.trim();457290 const status = document.getElementById('intro-status');458- if (!developmentConnectionMode && !pin) {291+ if (!connectionState.developmentMode && !pin) {459292 status.className = 'overlay-status err';460293 status.textContent = 'enter pin';461294 return;462295 }463296 hideIntro();464- if (!developmentConnectionMode) showTap();297+ if (!connectionState.developmentMode) showTap();465298 document.getElementById('pin-input').value = pin;466299 await connectHost();467- if (!developmentConnectionMode) hideTap();468- if (!connected) {300+ if (!connectionState.developmentMode) hideTap();301+ if (!connectionState.connected) {469302 showIntro();470303 status.className = 'overlay-status err';471304 status.textContent = 'connection failed';
@@ -474,6 +307,7 @@ async function introConnect() {
474307 475308 // ─── Client ───────────────────────────────────────────────────────────────────476309 function updateAudioUI() {310+ const session = audioPipeline.session;477311 const toggle = document.getElementById('audio-toggle');478312 const status = document.getElementById('stream-audio-state');479313 const webviewReceived = document.getElementById('stream-audio-webview-received-packets');
@@ -488,534 +322,176 @@ function updateAudioUI() {
488322 const silence = document.getElementById('stream-audio-silence-ms');489323 if (toggle) {490324 const presentation = audioButtonPresentation({491- available: audioAvailable,492- muted: audioMuted,493- state: audioState,494- detail: audioStateDetail,325+ available: session.available,326+ muted: session.muted,327+ state: session.state,328+ detail: session.stateDetail,495329 });496330 toggle.textContent = presentation.glyph;497- toggle.classList.toggle('active', audioAvailable && !audioMuted);498- toggle.classList.toggle('disabled', !audioAvailable);499- toggle.setAttribute('aria-disabled', audioAvailable ? 'false' : 'true');331+ toggle.classList.toggle('active', session.available && !session.muted);332+ toggle.classList.toggle('disabled', !session.available);333+ toggle.setAttribute('aria-disabled', session.available ? 'false' : 'true');500334 toggle.setAttribute('aria-label', presentation.ariaLabel);501335 toggle.title = presentation.title;502336 }503337 if (status) {504- status.textContent = audioMuted && audioAvailable ? 'muted' : audioState;505- status.title = audioStateDetail;338+ status.textContent = session.muted && session.available ? 'muted' : session.state;339+ status.title = session.stateDetail;506340 }507- if (webviewReceived) webviewReceived.textContent = `${audioPacketsReceived} packets`;508- if (transportMissing) transportMissing.textContent = `${audioTransportDropped} packets`;509- if (ipcDropped) ipcDropped.textContent = `${audioFrontendDropped} packets`;510- if (decoderDropped) decoderDropped.textContent = `${audioDecoderDropped} packets`;341+ if (webviewReceived) webviewReceived.textContent = `${session.packetsReceived} packets`;342+ if (transportMissing) transportMissing.textContent = `${session.transportDropped} packets`;343+ if (ipcDropped) ipcDropped.textContent = `${session.frontendDropped} packets`;344+ if (decoderDropped) decoderDropped.textContent = `${session.decoderDropped} packets`;511345 if (pcmHandoffDropped) {512- const droppedBlocks = activeAudioSession?.messageTracker.droppedMessages ?? 0;513- pcmHandoffDropped.textContent = `${droppedBlocks} blocks`;346+ pcmHandoffDropped.textContent = `${session.messageTracker.droppedMessages} blocks`;514347 }515348 if (ringOverflow) {516- const droppedFrames = activeAudioSession?.workletDroppedFrames ?? 0;517- ringOverflow.textContent = `${(droppedFrames * 1000 / AUDIO_DECODER_CONFIG.sampleRate).toFixed(1)} ms`;349+ ringOverflow.textContent = `${(session.workletDroppedFrames * 1000 / AUDIO_SAMPLE_RATE).toFixed(1)} ms`;518350 }519351 if (ringRecoveryDiscarded) {520- const droppedFrames = activeAudioSession?.workletRecoveryDiscardedFrames ?? 0;521- ringRecoveryDiscarded.textContent = `${(droppedFrames * 1000 / AUDIO_DECODER_CONFIG.sampleRate).toFixed(1)} ms`;352+ ringRecoveryDiscarded.textContent = `${(session.workletRecoveryDiscardedFrames * 1000 / AUDIO_SAMPLE_RATE).toFixed(1)} ms`;522353 }523354 if (ringBuffered) {524- ringBuffered.textContent = `${(audioBufferedFrames * 1000 / AUDIO_DECODER_CONFIG.sampleRate).toFixed(1)} ms`;355+ ringBuffered.textContent = `${(session.bufferedFrames * 1000 / AUDIO_SAMPLE_RATE).toFixed(1)} ms`;525356 }526357 if (underruns) {527- underruns.textContent = `${audioUnderflows} events / ${(audioUnderflowDurationMicros / 1000).toFixed(1)} ms`;358+ underruns.textContent = `${session.underflows} events / ${(session.underflowDurationMicros / 1000).toFixed(1)} ms`;528359 }529- if (silence) silence.textContent = `${(audioSilentDurationMicros / 1000).toFixed(1)} ms`;360+ if (silence) silence.textContent = `${(session.silentDurationMicros / 1000).toFixed(1)} ms`;530361 }531362 532-function setAudioState(state, detail = state) {533- audioState = state;534- audioStateDetail = detail;535- updateAudioUI();536-}537-538-function resetAudioTelemetry() {539- audioPacketsReceived = 0;540- audioDecoderDropped = 0;541- audioBufferedFrames = 0;542- audioUnderflows = 0;543- audioUnderflowDurationMicros = 0;544- audioSilentDurationMicros = 0;545- audioTransportDropped = 0;546- audioFrontendDropped = 0;547- resetAvSyncTelemetry(activeAudioSession);548- updateAudioUI();549-}550-551-function configureAudioDecoder(session = activeAudioSession) {552- if (audioDecoder) {553- try { audioDecoder.close(); } catch (_) {}554- }555- audioDecoder = new AudioDecoder({556- output: (audioData) => {557- try {558- if (session !== activeAudioSession || session.failed) return;559- const workletNode = session?.workletNode;560- if (!workletNode || audioData.numberOfChannels !== 2) {561- throw new Error(`unexpected decoded audio channel count: ${audioData.numberOfChannels}`);562- }563- const timestampMicros = exactMediaTimestampMicros(audioData.timestamp);564- if (timestampMicros === null) throw new Error('decoded audio has an invalid media timestamp');565- // Reserve ownership before allocating/copying PCM. The MessagePort has566- // no useful queue-depth API, so this makes overload latest-frame-wins567- // at a hard ceiling of three decoded messages.568- const messageId = session.messageTracker.reserve();569- if (messageId === null) {570- updateAudioUI();571- return;572- }573- const channels = [];574- const transfers = [];575- try {576- for (let planeIndex = 0; planeIndex < 2; planeIndex++) {577- const channel = new Float32Array(audioData.numberOfFrames);578- audioData.copyTo(channel, { planeIndex, format: 'f32-planar' });579- channels.push(channel);580- transfers.push(channel.buffer);581- }582- workletNode.port.postMessage({583- type: 'samples',584- id: messageId,585- channels,586- timestampMicros,587- }, transfers);588- } catch (error) {589- session.messageTracker.accept(messageId);590- throw error;591- }592- } catch (error) {593- disableAudioForSession(`Decoded audio output failed: ${error}`, session);594- } finally {595- audioData.close();596- }597- },598- error: (error) => {599- disableAudioForSession(`Opus decoder failed: ${error}`, session);363+async function toggleAudioMute() {364+ await audioPipeline.toggleMute();365+}366+367+async function createConnectionAttempt({ createChannel: Channel }) {368+ // Reset before opening the channel so early frames cannot race a369+ // post-connect reset and disappear from the live diagnostics.370+ streamRuntime.prepareConnection();371+ if (typeof Channel !== 'function') throw new Error('Tauri binary channels are unavailable');372+ const audioSession = audioPipeline.beginAttempt();373+ const frameSession = streamRuntime.openFrameSession();374+ const pointerSession = newPointerSession();375+ activePointerSession = pointerSession;376+ const audioAttempt = await audioPipeline.createAttemptChannel(Channel, audioSession);377+ const frameChannel = new Channel(378+ (message) => streamRuntime.handleBinaryFrame(message, frameSession),379+ );380+ pointerSession.channel = new Channel(381+ (message) => pointerFeedbackRuntime.handleMessage(message, pointerSession),382+ );383+ return {384+ audioSession: audioAttempt.session,385+ frameSession,386+ pointerSession,387+ audioSupport: audioAttempt.support,388+ connectionArgs: {389+ frameChannel,390+ audioChannel: audioAttempt.channel,391+ pointerChannel: pointerSession.channel,392+ audioSupported: audioAttempt.support.supported,600393 },601- });602- audioDecoder.configure(AUDIO_DECODER_CONFIG);394+ };603395 }604396 605-function primeAudioOutputForActivation() {606- const AudioContextConstructor = window.AudioContext || window.webkitAudioContext;607- if (typeof AudioContextConstructor !== 'function') return;608- if (!audioContext || audioContext.state === 'closed') {609- try {610- audioContext = new AudioContextConstructor({ latencyHint: 'interactive', sampleRate: 48000 });611- } catch (_) {612- return;613- }397+function validateConnectedAttempt(result, attempt) {398+ if (attempt.pointerSession.failed) {399+ throw new Error(attempt.pointerSession.failureDetail || 'host returned invalid pointer-position feedback');400+ }401+ if (attempt.frameSession.failed) {402+ throw new Error(attempt.frameSession.failureDetail || 'host returned invalid frame data');614403 }615- // This function is deliberately synchronous and is called before the first616- // await in a click/keyboard connect handler, preserving WebKit user activation.617- void audioContext.resume().catch(() => {});404+ attempt.pointerSession.surfaceDimensions = result.connected405+ ? validatePointerSurfaceDimensions(result.pointer_surface_dimensions)406+ : null;407+ attempt.pointerFeedbackAvailable = result.connected408+ && result.pointer_position_feedback_available === true;409+ if (attempt.pointerFeedbackAvailable && attempt.pointerSession.surfaceDimensions === null) {410+ throw new Error('host offered pointer feedback without a native pointer surface');411+ }412+ attempt.initialPointerFeedback = attempt.pointerFeedbackAvailable && attempt.pointerSession.received413+ ? validatePointerPositionFeedback(414+ attempt.pointerSession.latest,415+ attempt.pointerSession.surfaceDimensions,416+ )417+ : null;618418 }619419 620-async function initializeAudioPipeline(session) {621- if (audioDecoder) {622- try { audioDecoder.close(); } catch (_) {}623- audioDecoder = null;624- }625- if (audioWorkletNode) {626- try { audioWorkletNode.disconnect(); } catch (_) {}627- audioWorkletNode = null;628- }629- session.messageTracker.clear();630- if (typeof AudioDecoder !== 'function') {631- return { supported: false, error: 'WebCodecs AudioDecoder is unavailable' };632- }633- if (typeof AudioWorkletNode !== 'function') {634- return { supported: false, error: 'AudioWorklet is unavailable' };635- }636- let support;637- try {638- support = await AudioDecoder.isConfigSupported(AUDIO_DECODER_CONFIG);639- } catch (error) {640- return { supported: false, error: `Opus capability probe failed: ${error}` };641- }642- if (!support.supported) {643- return { supported: false, error: 'WebCodecs Opus decoding is unsupported' };644- }645- if (!audioContext) {646- return { supported: false, error: 'Web Audio output is unavailable' };647- }648- try {649- if (audioContext.sampleRate !== 48000) {650- throw new Error(`audio output opened at ${audioContext.sampleRate} Hz instead of 48000 Hz`);651- }652- await audioContext.audioWorklet.addModule(new URL('./audio-worklet.js', import.meta.url));653- audioWorkletNode = new AudioWorkletNode(audioContext, 'sigil-audio-processor', {654- numberOfInputs: 0,655- numberOfOutputs: 1,656- outputChannelCount: [2],657- });658- session.workletNode = audioWorkletNode;659- audioWorkletNode.port.onmessage = (event) => {660- if (event.data?.type === 'accepted') {661- session.messageTracker.accept(event.data.id);662- if (session === activeAudioSession) updateAudioUI();663- } else if (event.data?.type === 'stats') {664- if (!isCurrentAvSyncEpoch(event.data.avSyncEpoch, session.avSyncEpoch)) return;665- session.workletDroppedFrames = Number.isSafeInteger(event.data.droppedFrames)666- && event.data.droppedFrames >= 0667- ? event.data.droppedFrames : session.workletDroppedFrames;668- session.workletRecoveryDiscardedFrames = Number.isSafeInteger(669- event.data.recoveryDiscardedFrames,670- ) && event.data.recoveryDiscardedFrames >= 0671- ? event.data.recoveryDiscardedFrames : session.workletRecoveryDiscardedFrames;672- session.audioTimeline = audioOutputTimelineFromStats(event.data);673- if (session !== activeAudioSession) return;674- audioBufferedFrames = Number.isSafeInteger(event.data.bufferedFrames)675- ? event.data.bufferedFrames : audioBufferedFrames;676- audioUnderflows = Number.isSafeInteger(event.data.underflows)677- ? event.data.underflows : audioUnderflows;678- audioUnderflowDurationMicros = Number.isFinite(event.data.underflowDurationMicros)679- && event.data.underflowDurationMicros >= 0680- ? event.data.underflowDurationMicros : audioUnderflowDurationMicros;681- audioSilentDurationMicros = Number.isFinite(event.data.silentDurationMicros)682- && event.data.silentDurationMicros >= 0683- ? event.data.silentDurationMicros : audioSilentDurationMicros;684- if (audioAvailable && !audioMuted) {685- setAudioState(event.data.started ? 'playing' : 'priming', 'bounded Opus playback');686- } else {687- updateAudioUI();688- }689- } else if (event.data?.type === 'error') {690- disableAudioForSession(`AudioWorklet failed: ${event.data.error}`, session);691- }692- };693- audioWorkletNode.port.onmessageerror = () => {694- disableAudioForSession('AudioWorklet returned an unreadable message', session);695- };696- audioWorkletNode.onprocessorerror = () => {697- disableAudioForSession('AudioWorklet processor stopped unexpectedly', session);698- };699- audioWorkletNode.connect(audioContext.destination);700- audioWorkletNode.port.postMessage({ type: 'mute', muted: audioMuted });701- configureAudioDecoder(session);702- await audioContext.resume();703- setAudioState(704- audioContext.state === 'running' ? 'negotiating' : 'blocked',705- audioContext.state === 'running'706- ? 'Opus output ready'707- : 'WebKit suspended audio output; activate the audio button to retry',708- );709- return { supported: true, error: null };710- } catch (error) {711- await teardownAudioPipeline(false);712- return { supported: false, error: `Audio initialization failed: ${error}` };713- }420+function activateConnectedAttempt(result, attempt) {421+ streamRuntime.activateFrameSession(attempt.frameSession, {422+ generation: result.media_generation,423+ adaptiveFeedbackAvailable: result.adaptive_feedback_available === true,424+ adaptiveFeedbackError: result.adaptive_feedback_error,425+ });714426 }715427 716-async function teardownAudioPipeline(resetStatus = true) {717- const session = activeAudioSession;718- resetAvSyncTelemetry(session);719- activeAudioSession = null;720- activeAudioChannel = null;721- audioAvailable = false;722- if (session) {723- session.expectedGeneration = null;724- session.messageTracker.clear();725- session.workletNode = null;726- }727- if (audioDecoder) {728- try { audioDecoder.close(); } catch (_) {}729- audioDecoder = null;730- }731- if (audioWorkletNode) {732- try { audioWorkletNode.port.postMessage({ type: 'clear' }); } catch (_) {}733- try { audioWorkletNode.disconnect(); } catch (_) {}734- audioWorkletNode = null;735- }736- const context = audioContext;737- audioContext = null;738- if (context && context.state !== 'closed') {739- try { await context.close(); } catch (_) {}740- }741- audioBufferedFrames = 0;742- if (resetStatus) setAudioState('unavailable', 'not connected');428+function closeConnectionAttempt(attempt, { committed = false } = {}) {429+ streamRuntime.closeFrameSession(attempt?.frameSession);430+ if (committed && attempt?.pointerSession) attempt.pointerSession.closing = true;743431 }744432 745-async function toggleAudioMute() {746- if (!audioAvailable) return;747- audioMuted = !audioMuted;748- try {749- if (!audioMuted && audioContext?.state !== 'running') await audioContext?.resume();750- audioWorkletNode?.port.postMessage({ type: 'mute', muted: audioMuted });751- if (!audioMuted && audioContext?.state !== 'running') {752- setAudioState('blocked', 'WebKit suspended audio output; click the audio button to retry');753- } else {754- updateAudioUI();755- }756- } catch (error) {757- setAudioState('error', `Audio output activation failed: ${error}`);758- }433+async function teardownConnectionAttempt(attempt) {434+ const pointerSession = attempt?.pointerSession ?? activePointerSession;435+ if (activePointerSession === pointerSession) activePointerSession = null;436+ if (pointerSession) pointerSession.channel = null;437+ audioPipeline.releaseAttempt(attempt?.audioSession);438+ await audioPipeline.teardown();759439 }760440 761-async function connectHost() {762- if (connecting || connected) return;763- connecting = true;764- let rustConnectionCommitted = false;765- let pointerSession = null;766- let frameSession = null;767- try {768- const pin = document.getElementById('pin-input').value.trim();769- if (!developmentConnectionMode && !pin) {770- setStatus('err', 'enter pin');771- return;772- }773- primeAudioOutputForActivation();774- setStatus('pending', 'connecting...');775- // Reset before opening the channel so early frames cannot race a776- // post-connect reset and disappear from the live diagnostics.777- if (activeFrameSession) activeFrameSession.closing = true;778- activeFrameSession = null;779- activeFrameGeneration = null;780- adaptiveFeedbackPublisher.stop();781- adaptiveFeedbackAvailable = false;782- adaptiveFeedbackError = null;783- adaptiveDecision = null;784- resetStreamTelemetry();785- pointerSurfaceDimensions = null;786- remotePointerPosition = null;787- remotePointerVisible = false;788- const Channel = window.__TAURI__.core.Channel;789- if (typeof Channel !== 'function') throw new Error('Tauri binary channels are unavailable');790- resetAudioTelemetry();791- const audioSession = newAudioSession();792- frameSession = newFrameSession();793- pointerSession = newPointerSession();794- activeAudioSession = audioSession;795- activeFrameSession = frameSession;796- activePointerSession = pointerSession;797- const audioSupport = await initializeAudioPipeline(audioSession);798- if (!audioSupport.supported) setAudioState('unavailable', audioSupport.error);799- activeFrameChannel = new Channel((message) => handleBinaryFrameMessage(message, frameSession));800- activeAudioChannel = new Channel((message) => handleBinaryAudioMessage(message, audioSession));801- activePointerChannel = new Channel(802- (message) => handlePointerPositionFeedback(message, pointerSession),803- );804- const result = await invoke('iroh_client_connect', {805- pin,806- frameChannel: activeFrameChannel,807- audioChannel: activeAudioChannel,808- pointerChannel: activePointerChannel,809- audioSupported: audioSupport.supported,810- });811- rustConnectionCommitted = committedRustConnection(result);812- if (result.connected813- && (!Number.isSafeInteger(result.media_generation) || result.media_generation <= 0)) {814- throw new Error('host returned an invalid media generation');815- }816- if (result.connected817- && result.audio_available === true818- && (!Number.isSafeInteger(result.audio_generation) || result.audio_generation <= 0)) {819- throw new Error('host returned an invalid audio generation');820- }821- if (pointerSession.failed) {822- throw new Error(pointerSession.failureDetail || 'host returned invalid pointer-position feedback');823- }824- if (frameSession.failed) {825- throw new Error(frameSession.failureDetail || 'host returned invalid frame data');826- }827- const connectedPointerSurfaceDimensions = result.connected828- ? validatePointerSurfaceDimensions(result.pointer_surface_dimensions)829- : null;830- const pointerFeedbackAvailable = result.connected831- && result.pointer_position_feedback_available === true;832- if (pointerFeedbackAvailable && connectedPointerSurfaceDimensions === null) {833- throw new Error('host offered pointer feedback without a native pointer surface');834- }835- const initialPointerFeedback = pointerFeedbackAvailable && pointerSession.received836- ? validatePointerPositionFeedback(pointerSession.latest, connectedPointerSurfaceDimensions)837- : null;838- if (result.connected) {839- activateConnectedFrameSession(frameSession, result.media_generation);840- adaptiveFeedbackAvailable = result.adaptive_feedback_available === true;841- adaptiveFeedbackError = typeof result.adaptive_feedback_error === 'string'842- ? result.adaptive_feedback_error : null;843- adaptiveFeedbackPublisher.start(result.media_generation, adaptiveFeedbackAvailable);844- connected = true;845- disconnecting = false;846- inputCapabilities = {847- relativePointer: result.relative_pointer_available === true,848- pointerPositionFeedback: pointerFeedbackAvailable,849- absolutePointer: result.absolute_pointer_available === true,850- keyboard: result.keyboard_available === true,851- text: result.text_available === true,852- gamepad: result.gamepad_available === true,853- control: result.control_available === true,854- };855- streamTransportMode = [856- 'grouped-v3',857- 'independent-v2',858- 'reliable-v1',859- 'reliable-v0',860- ].includes(result.media_transport)861- ? result.media_transport862- : 'unknown';863- pointerSurfaceDimensions = connectedPointerSurfaceDimensions;864- if (!inputCapabilities.pointerPositionFeedback) {865- remotePointerPosition = null;866- remotePointerVisible = false;867- } else if (initialPointerFeedback !== null) {868- remotePointerPosition = initialPointerFeedback.position;869- remotePointerVisible = initialPointerFeedback.pointer_visible;870- }871- // Treat inconsistent metadata as view-only. Rust reports every872- // fields explicitly; this keeps the webview fail-closed as well.873- inputCapabilities.control = inputCapabilities.control874- && (inputCapabilities.relativePointer875- || inputCapabilities.absolutePointer876- || inputCapabilities.keyboard877- || inputCapabilities.text878- || inputCapabilities.gamepad);879- controlMode = false;880- clearPendingInput();881- const connectionStatus = [882- 'connected',883- inputCapabilities.control ? null : 'view only',884- result.development_mode ? 'dev direct-node' : null,885- ].filter(Boolean).join(' · ');886- setStatus('ok', connectionStatus);887- audioSession.expectedGeneration = Number.isSafeInteger(result.audio_generation)888- && result.audio_generation > 0889- ? result.audio_generation : null;890- const pendingAudioTerminal = takeAudioTerminalState(891- audioSession.pendingTerminalStates,892- audioSession.expectedGeneration,893- );894- if (pendingAudioTerminal !== null) {895- audioSession.failed = true;896- audioSession.failureDetail = pendingAudioTerminal.error || 'Audio connection ended';897- }898- audioAvailable = result.audio_available === true;899- if (audioAvailable && audioSession.failed) {900- disableAudioForSession(audioSession.failureDetail || 'Audio output failed', audioSession);901- } else if (audioAvailable) {902- setAudioState(903- audioContext?.state === 'running' ? 'priming' : 'blocked',904- audioContext?.state === 'running'905- ? 'Waiting for bounded Opus prebuffer'906- : 'WebKit suspended audio output; activate the audio button to retry',907- );908- } else {909- await teardownAudioPipeline(false);910- setAudioState('unavailable', result.audio_error || audioSupport.error || 'host audio unavailable');911- }912- updatePanelVisibility();913- document.getElementById('node-id-text').textContent = result.host_node_id.substring(0, 16) + '...';914- document.getElementById('frame-canvas').style.display = 'block';915- document.getElementById('placeholder').classList.add('hidden');916- document.getElementById('control-bar').classList.add('visible');917- void fitWindowToIncomingStream();918- updateControlUI();919- // Land controller users on the deliberate view-only/control boundary.920- // The connect button is hidden at this point, so retaining its focus921- // would make the next A press fall back to an unrelated top-bar action.922- setTimeout(() => setControllerFocus(document.getElementById('control-toggle')), 0);923- document.getElementById('action-connect').style.display = 'none';924- document.getElementById('action-disconnect').textContent = 'disconnect';925- document.getElementById('action-disconnect').classList.remove('hidden');926- document.getElementById('pin-label').style.display = 'none';927- document.getElementById('pin-input').style.display = 'none';928- document.getElementById('controller-pin-main').style.display = 'none';929- rustConnectionCommitted = false;930- } else {931- frameSession.closing = true;932- activeFrameSession = null;933- activeFrameGeneration = null;934- activeFrameChannel = null;935- activePointerChannel = null;936- activePointerSession = null;937- await teardownAudioPipeline();938- setStatus('err', 'failed');939- }940- } catch (e) {941- if (frameSession) frameSession.closing = true;942- if (activeFrameSession === frameSession) activeFrameSession = null;943- activeFrameGeneration = null;944- adaptiveFeedbackPublisher.stop();945- adaptiveFeedbackAvailable = false;946- adaptiveDecision = null;947- if (rustConnectionCommitted) {948- // Rust commits its active-connection guard before returning. A client-side949- // validation failure must close that exact committed attempt directly;950- // calling disconnect() here would race its UI teardown recursively.951- if (pointerSession) pointerSession.closing = true;952- try {953- await disconnectRejectedRustConnection(invoke, rustConnectionCommitted);954- } catch (disconnectError) {955- console.error('failed to close rejected connection:', disconnectError);956- }957- }958- activeFrameChannel = null;959- activePointerChannel = null;960- activePointerSession = null;961- await teardownAudioPipeline();962- console.error('connection failed:', e);963- setStatus('err', 'error');964- } finally {965- connecting = false;441+function updateAcceptedConnectionState({ attempt, state }) {442+ const session = attempt.pointerSession;443+ if (!state.inputCapabilities.pointerPositionFeedback) {444+ session.remotePosition = null;445+ session.remoteVisible = false;446+ } else if (attempt.initialPointerFeedback !== null) {447+ session.remotePosition = attempt.initialPointerFeedback.position;448+ session.remoteVisible = attempt.initialPointerFeedback.pointer_visible;966449 }450+ controlRuntime.resetAcceptedConnection();967451 }968452 969-async function disconnect() {970- if (disconnecting) return;971- disconnecting = true;972- if (activeFrameSession) activeFrameSession.closing = true;973- activeFrameSession = null;974- activeFrameGeneration = null;975- adaptiveFeedbackPublisher.stop();976- adaptiveFeedbackAvailable = false;977- adaptiveFeedbackError = null;978- adaptiveDecision = null;979- controlTransitionGeneration += 1;980- controlTransitionInProgress = false;981- controllerActivationGate.reset();982- queueNeutralControllerState();983- releaseHeldInputs();984- controlMode = false;985- releasePointerLock();453+async function showConnectedAttempt({ result, attempt }) {454+ await audioPipeline.acceptConnectedResult(result, {455+ session: attempt.audioSession,456+ support: attempt.audioSupport,457+ });458+ updatePanelVisibility();459+ document.getElementById('node-id-text').textContent = result.host_node_id.substring(0, 16) + '...';460+ document.getElementById('frame-canvas').style.display = 'block';461+ document.getElementById('placeholder').classList.add('hidden');462+ document.getElementById('control-bar').classList.add('visible');463+ void windowRuntime.fitWindowToIncomingStream();986464 updateControlUI();465+ // Land controller users on the deliberate view-only/control boundary.466+ // The connect button is hidden at this point, so retaining its focus467+ // would make the next A press fall back to an unrelated top-bar action.468+ setTimeout(() => setControllerFocus(document.getElementById('control-toggle')), 0);469+ document.getElementById('action-connect').style.display = 'none';470+ document.getElementById('action-disconnect').textContent = 'disconnect';471+ document.getElementById('action-disconnect').classList.remove('hidden');472+ document.getElementById('pin-label').style.display = 'none';473+ document.getElementById('pin-input').style.display = 'none';474+ document.getElementById('controller-pin-main').style.display = 'none';475+}476+477+async function prepareDisconnect() {478+ streamRuntime.prepareDisconnect();987479 // Give release commands a short, finite opportunity to cross the bounded988480 // Tauri queue before closing it. A broken stream cannot stall disconnect.989- await waitForInputDrain(250);990- try {991- await invoke('iroh_client_disconnect');992- } catch (e) { console.error(e); }993- activeFrameChannel = null;994- activePointerChannel = null;481+ await controlRuntime.prepareDisconnect(250);482+}483+484+async function teardownConnectedResources() {485+ const pointerSession = activePointerSession;995486 activePointerSession = null;996- await teardownAudioPipeline();997- teardownDecoderPipeline();998- connected = false;999- controlMode = false;1000- inputCapabilities = {1001- relativePointer: false,1002- pointerPositionFeedback: false,1003- absolutePointer: false,1004- keyboard: false,1005- text: false,1006- gamepad: false,1007- control: false,1008- };1009- pointerSurfaceDimensions = null;1010- fittedStreamAspect = null;1011- pendingStreamFit = null;1012- lastObservedWindowSize = null;1013- if (streamWindowResizeTimer !== null) clearTimeout(streamWindowResizeTimer);1014- streamWindowResizeTimer = null;1015- remotePointerPosition = null;1016- remotePointerVisible = false;1017- clearPendingInput();1018- updateControlUI();487+ if (pointerSession) pointerSession.channel = null;488+ await audioPipeline.teardown();489+ streamRuntime.teardown();490+}491+492+async function showDisconnectedState() {493+ windowRuntime.reset();494+ controlRuntime.resetDisconnected();1019495 updatePanelVisibility();1020496 setStatus('', 'idle');1021497 document.getElementById('frame-canvas').style.display = 'none';
@@ -1024,13 +500,42 @@ async function disconnect() {
1024500 document.getElementById('control-bar').classList.remove('visible');1025501 document.getElementById('action-connect').style.display = '';1026502 document.getElementById('action-disconnect').classList.add('hidden');1027- if (!developmentConnectionMode) {503+ if (!connectionState.developmentMode) {1028504 document.getElementById('pin-label').style.display = '';1029505 document.getElementById('pin-input').style.display = '';1030506 document.getElementById('controller-pin-main').style.display = '';1031507 }1032508 document.getElementById('intro-status').textContent = '';1033- disconnecting = false;509+}510+511+const connectionState = newConnectionState({512+ invokeCommand: invoke,513+ createChannel: window.__TAURI__.core.Channel,514+ beforeConnect: audioPipeline.prepareForConnection,515+ createAttempt: createConnectionAttempt,516+ validateConnectedResult: validateConnectedAttempt,517+ activateAttempt: activateConnectedAttempt,518+ closeAttempt: closeConnectionAttempt,519+ teardownAttempt: teardownConnectionAttempt,520+ beforeDisconnect: prepareDisconnect,521+ teardownConnection: teardownConnectedResources,522+ onStatus: (state, detail, context) => {523+ if (state === 'ok') updateAcceptedConnectionState(context);524+ setStatus(state, detail);525+ },526+ onConnected: showConnectedAttempt,527+ onDisconnected: showDisconnectedState,528+ onFailure: ({ error }) => console.error('connection failed:', error),529+ onDisconnectError: (error) => console.error(error),530+ onNativeDisconnectError: (error) => console.error('failed to close rejected connection:', error),531+});532+533+async function connectHost() {534+ await connectionState.connect({ pin: document.getElementById('pin-input').value });535+}536+537+async function disconnect() {538+ await connectionState.disconnect();1034539 }1035540 1036541 // ─── FIDO scan ────────────────────────────────────────────────────────────────
@@ -1068,279 +573,90 @@ function togglePanel(force) {
1068573 1069574 // ─── WebCodecs detection ──────────────────────────────────────────────────────1070575 let hasWebCodecs = ('VideoDecoder' in window);1071-let videoDecoder = null;1072-let decoderConfigured = false;1073576 1074577 (async function detectWebCodecs() {1075578 await invoke('set_webcodecs_available', { available: hasWebCodecs });1076579 })();1077580 1078581 // ─── Frame decoding ───────────────────────────────────────────────────────────1079-let activeCodec = 'h264';1080-let droppedFrames = 0;1081-let transportDroppedFrames = 0;1082-let transportObjectDroppedFrames = null;1083-let transportLateObjectDroppedFrames = null;1084-let frontendDroppedFrames = 0;1085-let frontendQueueDroppedFrames = null;1086-let frontendResyncDroppedFrames = null;1087-let frontendQueueStats = null;1088-let frontendResyncStats = null;1089-let transportIntervalStats = null;1090-let frontendIpcSendDurationStats = null;1091-let rustTimingWindow = null;1092-let networkDiagnostics = null;1093-let receivedFrames = 0;1094-let decoderInputFrames = 0;1095-let decoderOutputFrames = 0;1096-let presentedFrames = 0;1097-let presentationDroppedFrames = 0;1098-let lastTransportFps = 0;1099-let lastFrontendSendFps = 0;1100-const frontendDeliveryRate = new RollingRateWindow();1101-const decoderInputRate = new RollingRateWindow();1102-const decoderOutputRate = new RollingRateWindow();1103-const presentationRate = new RollingRateWindow();1104-const frontendDeliveryCadence = new BoundedCadenceWindow();1105-const decoderInputCadence = new BoundedCadenceWindow();1106-const decoderOutputCadence = new BoundedCadenceWindow();1107-const presentationCadence = new BoundedCadenceWindow();1108-const decodeLatency = new BoundedLatencyWindow();1109-const clientPresentationLatency = new BoundedLatencyWindow();1110-const drawLatency = new BoundedLatencyWindow();1111-const avSkew = new BoundedValueWindow();1112-const decodeTimings = new Map();1113-const MAX_DECODE_TIMINGS = 8;1114-let streamPathMode = 'unknown';1115-let streamTransportMode = 'unknown';1116-let streamRttMs = null;1117-let lastMediaSequence = null;1118-const MAX_DECODE_QUEUE_SIZE = 2;1119-1120-function requestDecoderKeyframe(reason) {1121- if (!Number.isSafeInteger(activeFrameGeneration) || activeFrameGeneration < 1) return;1122- void invoke('iroh_client_request_keyframe', {1123- generation: activeFrameGeneration,1124- reason,1125- }).catch((error) => {1126- console.warn(`keyframe request failed (${reason}):`, error);1127- });1128-}1129-1130-const decoderRecovery = new DecoderRecoveryState({1131- onKeyframeRequest: requestDecoderKeyframe,1132-});1133-const videoFormatTransition = new VideoFormatTransitionGuard();1134-1135-function enterDecoderRecovery(reason, { restart = false, requestKeyframe = true } = {}) {1136- const result = restart1137- ? decoderRecovery.restart(reason)1138- : decoderRecovery.enter(reason, { requestKeyframe });1139- // Closing WebCodecs and clearing the latest-frame presenter is part of the1140- // recovery boundary. Do it immediately rather than allowing already queued1141- // frames from a poisoned GOP to continue reaching the canvas.1142- teardownDecoderPipeline();1143- return result;1144-}1145-1146-function finishVideoChunkEnqueue(keyframe, succeeded) {1147- if (succeeded) {1148- if (keyframe) decoderRecovery.confirmKeyframeEnqueued(true);1149- return;1150- }1151- // A synchronous decode rejection is a decoder-error episode if playback1152- // was previously healthy. If recovery is already active, enter() coalesces1153- // with its existing request and leaves the keyframe gate closed.1154- enterDecoderRecovery(DECODER_RECOVERY_REASONS.DECODER_ERROR);1155-}1156-1157-function resetAvSyncTelemetry(session = activeAudioSession, { recoverRing = false } = {}) {1158- avSkew.reset();1159- if (!session) return;1160- session.audioTimeline = null;1161- session.avSyncEpoch = nextAvSyncEpoch(session.avSyncEpoch);1162- try {1163- session.workletNode?.port.postMessage({1164- type: recoverRing ? 'recover' : 'av-sync-epoch',1165- avSyncEpoch: session.avSyncEpoch,1166- });1167- } catch (_) {}1168-}1169-1170-function sampleAvSkew(videoPtsMicros, presentationTimeMs) {1171- const session = activeAudioSession;1172- if (1173- exactMediaTimestampMicros(videoPtsMicros) === null1174- || !session1175- || session.failed1176- || !session.audioTimeline1177- || !audioAvailable1178- || audioContext?.state !== 'running'1179- || typeof audioContext.getOutputTimestamp !== 'function'1180- ) return;1181-1182- let outputTimestamp;1183- try {1184- outputTimestamp = audioContext.getOutputTimestamp();1185- } catch (_) {1186- return;1187- }1188- const audioPtsMicros = projectAudioMediaPtsMicros(1189- session.audioTimeline,1190- outputTimestamp,1191- presentationTimeMs,1192- );1193- const skewMs = audioMinusVideoSkewMs(audioPtsMicros, videoPtsMicros);1194- if (skewMs !== null) avSkew.record(skewMs, presentationTimeMs);1195-}1196-1197582 const canvas = document.getElementById('frame-canvas');1198583 const remotePointer = document.getElementById('remote-pointer');1199584 const ctx = canvas.getContext('2d');1200-let lastVideoDrawCompletedAtMs = null;1201-const framePresenter = new LatestFramePresenter({1202- requestFrame: (callback) => requestAnimationFrame(callback),1203- cancelFrame: (handle) => cancelAnimationFrame(handle),1204- setTimer: (callback, delayMs) => setTimeout(callback, delayMs),1205- cancelTimer: (handle) => clearTimeout(handle),1206- now: () => performance.now(),1207- fallbackDelayMs: 25,1208- draw: (frame) => {1209- const startedAt = performance.now();1210- ctx.drawImage(frame, 0, 0, canvas.width, canvas.height);1211- const completedAt = performance.now();1212- drawLatency.record(completedAt - startedAt, completedAt);1213- lastVideoDrawCompletedAtMs = completedAt;585+const windowRuntime = createWindowRuntime({586+ isConnected: () => connectionState.connected,587+ getFormat: () => videoPipeline.format,588+ getGeneration: () => streamRuntime.generation,589+ getChromeHeight: () => {590+ const topbar = document.querySelector('.topbar');591+ const bottombar = document.querySelector('.bottombar');592+ return Math.max(0, (topbar?.offsetHeight ?? 0) + (bottombar?.offsetHeight ?? 0));1214593 },1215- onPresent: (metadata, animationFrameTime) => {1216- const now = lastVideoDrawCompletedAtMs ?? animationFrameTime;1217- lastVideoDrawCompletedAtMs = null;1218- presentedFrames++;1219- presentationRate.record(now);1220- presentationCadence.record(now);1221- if (metadata && Number.isFinite(metadata.receivedAt)) {1222- clientPresentationLatency.record(now - metadata.receivedAt, now);1223- }1224- if (metadata) sampleAvSkew(metadata.mediaPtsMicros, now);594+ getScreenBounds: () => ({595+ width: window.screen.availWidth,596+ height: window.screen.availHeight,597+ }),598+ getWindowSize: () => ({ width: window.innerWidth, height: window.innerHeight }),599+ getSurfaceBounds: () => {600+ const main = canvas.parentElement;601+ if (!main) return null;602+ const bounds = main.getBoundingClientRect();603+ return { width: bounds.width, height: bounds.height };1225604 },1226- onDrop: () => { presentationDroppedFrames++; },605+ setSurfaceSize: ({ width, height }) => {606+ canvas.style.width = `${width}px`;607+ canvas.style.height = `${height}px`;608+ },609+ applyNativeGeometry: (geometry, unmaximize) => invoke('set_client_window_size', {610+ logicalWidth: geometry.width,611+ logicalHeight: geometry.height,612+ unmaximize,613+ }),1227614 });1228-1229-function teardownDecoderPipeline() {1230- resetAvSyncTelemetry();1231- framePresenter.clear();1232- decodeTimings.clear();1233- if (videoDecoder) {1234- try { videoDecoder.close(); } catch (_) {}1235- }1236- videoDecoder = null;1237- decoderConfigured = false;1238-}1239-1240-function resetStreamTelemetry() {1241- teardownDecoderPipeline();1242- decoderRecovery.reset();1243- videoFormatTransition.reset();1244- activeVideoFormatEpoch = 0;1245- droppedFrames = 0;1246- transportDroppedFrames = 0;1247- transportObjectDroppedFrames = null;1248- transportLateObjectDroppedFrames = null;1249- frontendDroppedFrames = 0;1250- frontendQueueDroppedFrames = null;1251- frontendResyncDroppedFrames = null;1252- frontendQueueStats = null;1253- frontendResyncStats = null;1254- transportIntervalStats = null;1255- frontendIpcSendDurationStats = null;1256- rustTimingWindow = null;1257- networkDiagnostics = null;1258- receivedFrames = 0;1259- decoderInputFrames = 0;1260- decoderOutputFrames = 0;1261- presentedFrames = 0;1262- presentationDroppedFrames = 0;1263- lastTransportFps = 0;1264- lastFrontendSendFps = 0;1265- frontendDeliveryRate.reset();1266- decoderInputRate.reset();1267- decoderOutputRate.reset();1268- presentationRate.reset();1269- frontendDeliveryCadence.reset();1270- decoderInputCadence.reset();1271- decoderOutputCadence.reset();1272- presentationCadence.reset();1273- decodeLatency.reset();1274- clientPresentationLatency.reset();1275- drawLatency.reset();1276- streamPathMode = 'unknown';1277- streamTransportMode = 'unknown';1278- streamRttMs = null;1279- lastMediaSequence = null;1280- adaptiveDecision = null;1281-}1282-1283-function enqueueVideoChunk(chunk, receivedAt, codecLabel, mediaPtsMicros = null) {1284- if (!videoDecoder) return false;1285- const enqueuedAt = performance.now();1286- while (decodeTimings.size >= MAX_DECODE_TIMINGS) {1287- decodeTimings.delete(decodeTimings.keys().next().value);1288- }1289- decodeTimings.set(chunk.timestamp, { receivedAt, enqueuedAt, mediaPtsMicros });1290- try {1291- videoDecoder.decode(chunk);1292- decoderInputFrames++;1293- decoderInputRate.record(enqueuedAt);1294- decoderInputCadence.record(enqueuedAt);1295- return true;1296- } catch (error) {1297- decodeTimings.delete(chunk.timestamp);1298- droppedFrames++;1299- console.warn(`${codecLabel} decode failed:`, error);1300- return false;1301- }1302-}1303-1304-function initWebCodecsDecoder(width, height, desc, codecStr) {1305- teardownDecoderPipeline();1306- console.log('WebCodecs configure:', codecStr, 'w:', width, 'h:', height, 'desc:', desc.byteLength, 'bytes');1307- const decoder = new VideoDecoder({1308- output: (frame) => {1309- if (videoDecoder !== decoder) {1310- frame.close();1311- return;615+controlRuntime = createControlRuntime({616+ getConnection: () => ({617+ connected: connectionState.connected,618+ disconnecting: connectionState.disconnecting,619+ capabilities: connectionState.inputCapabilities,620+ }),621+ inputRuntime,622+ invokeCursorGrab: (grab) => invoke('set_client_cursor_grab', { grab }),623+ pointerLock: {624+ target: canvas,625+ getOwner: () => document.pointerLockElement,626+ request: () => {627+ if (typeof canvas.requestPointerLock !== 'function') {628+ throw new Error('browser Pointer Lock is unavailable');1312629 }1313- const now = performance.now();1314- decoderOutputFrames++;1315- decoderOutputRate.record(now);1316- decoderOutputCadence.record(now);1317- const timing = decodeTimings.get(frame.timestamp) ?? null;1318- decodeTimings.delete(frame.timestamp);1319- if (timing) decodeLatency.record(now - timing.enqueuedAt, now);1320- framePresenter.enqueue(frame, timing);630+ return canvas.requestPointerLock();1321631 },1322- error: (e) => {1323- if (videoDecoder !== decoder) return;1324- console.error('VideoDecoder error:', e);1325- enterDecoderRecovery(DECODER_RECOVERY_REASONS.DECODER_ERROR);1326- }1327- });1328- videoDecoder = decoder;1329- try {1330- decoder.configure(buildVideoDecoderConfig({1331- codec: codecStr,1332- width,1333- height,1334- description: desc,1335- }));1336- decoderConfigured = true;1337- return true;1338- } catch (error) {1339- console.warn('WebCodecs configure failed:', error);1340- enterDecoderRecovery(DECODER_RECOVERY_REASONS.DECODER_ERROR);1341- return false;1342- }1343-}632+ exit: () => {633+ if (typeof document.exitPointerLock === 'function') document.exitPointerLock();634+ },635+ eventTarget: document,636+ },637+ publishController: () => controllerRuntime?.publishCurrentState(),638+ resetControllerEscape: () => controllerRuntime?.resetEscape(),639+ onChange: () => updateControlUI(),640+ onReleaseFailure: () => setStatus('err', 'cursor release failed · quit app'),641+});642+videoPipeline = createVideoPipelineSession({643+ hasWebCodecs,644+ canvas,645+ context: ctx,646+ requestKeyframe: (reason) => streamRuntime?.requestKeyframe(reason),647+ resetAudioSync: audioPipeline.resetSyncTelemetry,648+ sampleAudioSkew: audioPipeline.sampleSkew,649+ onFormatChanged: () => {650+ windowRuntime.sizeSurfaceToIncomingStream();651+ if (connectionState.connected) void windowRuntime.fitWindowToIncomingStream();652+ },653+});654+streamRuntime = createStreamRuntime({655+ invokeCommand: invoke,656+ videoPipeline,657+ isConnected: () => connectionState.connected,658+ disconnect,659+});1344660 1345661 function formatCadence(summary) {1346662 if (!summary.count) return '—';
@@ -1355,30 +671,40 @@ function formatRustDurationSummary(summary) {
1355671 1356672 function updateStreamStats() {1357673 const now = performance.now();1358- const frontendDeliveryFps = frontendDeliveryRate.rate(now);1359- const decodeFps = decoderInputRate.rate(now);1360- const decoderOutputFps = decoderOutputRate.rate(now);1361- const presentFps = presentationRate.rate(now);1362- const decodePercentiles = decodeLatency.summary(now);1363- const presentPercentiles = clientPresentationLatency.summary(now);1364- const drawPercentiles = drawLatency.summary(now);1365- const deliveryCadence = frontendDeliveryCadence.summary(now);1366- const decoderInputIntervals = decoderInputCadence.summary(now);1367- const decoderOutputIntervals = decoderOutputCadence.summary(now);1368- const presentationIntervals = presentationCadence.summary(now);1369- const avSkewPercentiles = avSkew.summary(now);674+ const {675+ video,676+ transportDroppedFrames,677+ transportObjectDroppedFrames,678+ transportLateObjectDroppedFrames,679+ frontendDroppedFrames,680+ frontendQueueDroppedFrames,681+ frontendResyncDroppedFrames,682+ frontendQueueStats,683+ frontendResyncStats,684+ transportIntervalStats,685+ frontendIpcSendDurationStats,686+ rustTimingWindow,687+ networkDiagnostics,688+ lastTransportFps,689+ lastFrontendSendFps,690+ streamPathMode,691+ streamRttMs,692+ adaptiveFeedbackAvailable,693+ adaptiveFeedbackError,694+ adaptiveDecision,695+ } = streamRuntime.snapshot(now);1370696 const networkPresentation = networkDiagnosticsPresentation(networkDiagnostics);1371- document.getElementById('stream-received').textContent = receivedFrames;1372- document.getElementById('stream-decoder-input').textContent = decoderInputFrames;1373- document.getElementById('stream-decoder-output').textContent = decoderOutputFrames;1374- document.getElementById('stream-presented').textContent = presentedFrames;1375- document.getElementById('stream-decode-queue').textContent = videoDecoder?.decodeQueueSize ?? 0;1376- document.getElementById('stream-present-queue').textContent = framePresenter.depth;697+ document.getElementById('stream-received').textContent = video.receivedFrames;698+ document.getElementById('stream-decoder-input').textContent = video.decoderInputFrames;699+ document.getElementById('stream-decoder-output').textContent = video.decoderOutputFrames;700+ document.getElementById('stream-presented').textContent = video.presentedFrames;701+ document.getElementById('stream-decode-queue').textContent = video.decoderQueueDepth;702+ document.getElementById('stream-present-queue').textContent = video.presenterQueueDepth;1377703 const discardTelemetry = formatVideoDiscardTelemetry({1378704 transportDroppedFrames,1379705 frontendDroppedFrames,1380- decoderDroppedFrames: droppedFrames,1381- presenterOverwrittenFrames: presentationDroppedFrames,706+ decoderDroppedFrames: video.droppedFrames,707+ presenterOverwrittenFrames: video.presentationDroppedFrames,1382708 });1383709 document.getElementById('stream-dropped').textContent = discardTelemetry.total;1384710 document.getElementById('stream-transport-dropped').textContent = discardTelemetry.transport;
@@ -1407,28 +733,28 @@ function updateStreamStats() {
1407733 document.getElementById('stream-present-dropped').textContent = discardTelemetry.presenterOverwrite;1408734 document.getElementById('stream-transport-fps').textContent = lastTransportFps.toFixed(1);1409735 document.getElementById('stream-ipc-send-fps').textContent = lastFrontendSendFps.toFixed(1);1410- document.getElementById('stream-frontend-fps').textContent = frontendDeliveryFps.toFixed(1);1411- document.getElementById('stream-decode-fps').textContent = decodeFps.toFixed(1);1412- document.getElementById('stream-decoder-output-fps').textContent = decoderOutputFps.toFixed(1);1413- document.getElementById('stream-present-fps').textContent = presentFps.toFixed(1);1414- document.getElementById('stream-av-skew').textContent = avSkewPercentiles.count1415- ? `${formatSignedMilliseconds(avSkewPercentiles.p50)} / ${formatSignedMilliseconds(avSkewPercentiles.p95)} / ${avSkewPercentiles.maxAbsolute.toFixed(1)} ms`736+ document.getElementById('stream-frontend-fps').textContent = video.frontendDeliveryFps.toFixed(1);737+ document.getElementById('stream-decode-fps').textContent = video.decodeFps.toFixed(1);738+ document.getElementById('stream-decoder-output-fps').textContent = video.decoderOutputFps.toFixed(1);739+ document.getElementById('stream-present-fps').textContent = video.presentFps.toFixed(1);740+ document.getElementById('stream-av-skew').textContent = video.avSkewPercentiles.count741+ ? `${formatSignedMilliseconds(video.avSkewPercentiles.p50)} / ${formatSignedMilliseconds(video.avSkewPercentiles.p95)} / ${video.avSkewPercentiles.maxAbsolute.toFixed(1)} ms`1416742 : '—';1417- document.getElementById('stream-decode-latency').textContent = decodePercentiles.count1418- ? `${decodePercentiles.p50.toFixed(1)} / ${decodePercentiles.p95.toFixed(1)} ms`743+ document.getElementById('stream-decode-latency').textContent = video.decodePercentiles.count744+ ? `${video.decodePercentiles.p50.toFixed(1)} / ${video.decodePercentiles.p95.toFixed(1)} ms`1419745 : '—';1420- document.getElementById('stream-present-latency').textContent = presentPercentiles.count1421- ? `${presentPercentiles.p50.toFixed(1)} / ${presentPercentiles.p95.toFixed(1)} ms`746+ document.getElementById('stream-present-latency').textContent = video.presentPercentiles.count747+ ? `${video.presentPercentiles.p50.toFixed(1)} / ${video.presentPercentiles.p95.toFixed(1)} ms`1422748 : '—';1423- document.getElementById('stream-draw-latency').textContent = drawPercentiles.count1424- ? `${drawPercentiles.p50.toFixed(2)} / ${drawPercentiles.p95.toFixed(2)} / ${drawPercentiles.p99.toFixed(2)} / ${drawPercentiles.max.toFixed(2)} ms`749+ document.getElementById('stream-draw-latency').textContent = video.drawPercentiles.count750+ ? `${video.drawPercentiles.p50.toFixed(2)} / ${video.drawPercentiles.p95.toFixed(2)} / ${video.drawPercentiles.p99.toFixed(2)} / ${video.drawPercentiles.max.toFixed(2)} ms`1425751 : '—';1426- document.getElementById('stream-delivery-cadence').textContent = formatCadence(deliveryCadence);1427- document.getElementById('stream-decoder-input-cadence').textContent = formatCadence(decoderInputIntervals);1428- document.getElementById('stream-decoder-output-cadence').textContent = formatCadence(decoderOutputIntervals);1429- document.getElementById('stream-present-cadence').textContent = formatCadence(presentationIntervals);1430- document.getElementById('stream-codec').textContent = activeCodec;1431- document.getElementById('stream-transport').textContent = streamTransportMode;752+ document.getElementById('stream-delivery-cadence').textContent = formatCadence(video.deliveryCadence);753+ document.getElementById('stream-decoder-input-cadence').textContent = formatCadence(video.decoderInputIntervals);754+ document.getElementById('stream-decoder-output-cadence').textContent = formatCadence(video.decoderOutputIntervals);755+ document.getElementById('stream-present-cadence').textContent = formatCadence(video.presentationIntervals);756+ document.getElementById('stream-codec').textContent = video.activeCodec;757+ document.getElementById('stream-transport').textContent = connectionState.mediaTransport;1432758 document.getElementById('stream-path').textContent = streamPathMode;1433759 document.getElementById('stream-rtt').textContent = Number.isFinite(streamRttMs)1434760 ? `${streamRttMs.toFixed(1)} ms`
@@ -1446,859 +772,119 @@ function updateStreamStats() {
1446772 adaptiveFeedbackAvailable,1447773 );1448774 1449- adaptiveFeedbackPublisher.publish({1450- lastSequence: lastMediaSequence,1451- frontendQueueDepth: frontendQueueStats?.depth ?? 0,1452- frontendQueueCapacity: frontendQueueStats?.capacity ?? 4,1453- decoderQueueDepth: videoDecoder?.decodeQueueSize ?? 0,1454- decoderQueueCapacity: MAX_DECODE_QUEUE_SIZE,1455- presenterQueueDepth: framePresenter.depth,1456- presenterQueueCapacity: 2,1457- transportDroppedTotal: transportDroppedFrames,1458- frontendDroppedTotal: frontendDroppedFrames,1459- decoderDroppedTotal: droppedFrames,1460- presenterDroppedTotal: presentationDroppedFrames,1461- // Portal has no clock-synchronized capture-to-delivery measurement yet.1462- // IPC send duration is a different boundary and must not be mislabeled.1463- transportDeliveryP95Ms: null,1464- decodeLatencyP95Ms: decodePercentiles.p95,1465- presentationLatencyP95Ms: presentPercentiles.p95,1466- resyncActive: decoderRecovery.recovering || frontendResyncStats?.active === true,1467- });775+ streamRuntime.publishAdaptiveFeedback(video);1468776 }1469777 1470778 setInterval(() => {1471- if (connected) updateStreamStats();779+ if (connectionState.connected) updateStreamStats();1472780 }, 250);1473781 1474-function clientChromeHeight() {1475- const topbar = document.querySelector('.topbar');1476- const bottombar = document.querySelector('.bottombar');1477- return Math.max(0, (topbar?.offsetHeight ?? 0) + (bottombar?.offsetHeight ?? 0));1478-}1479-1480-function sizeCanvasToIncomingStream() {1481- if (frameWidth < 1 || frameHeight < 1) return;1482- const main = canvas.parentElement;1483- if (!main) return;1484- const bounds = main.getBoundingClientRect();1485- if (bounds.width <= 0 || bounds.height <= 0) return;1486- const surface = fitStreamSurface({1487- frameWidth,1488- frameHeight,1489- availableWidth: bounds.width,1490- availableHeight: bounds.height,1491- });1492- canvas.style.width = `${surface.width}px`;1493- canvas.style.height = `${surface.height}px`;1494-}1495-1496-async function applyClientWindowGeometry(geometry, unmaximize) {1497- try {1498- const applied = await invoke('set_client_window_size', {1499- logicalWidth: geometry.width,1500- logicalHeight: geometry.height,1501- unmaximize,1502- });1503- if (applied) lastObservedWindowSize = { ...geometry };1504- return applied;1505- } catch (error) {1506- console.warn('could not apply stream window geometry:', error);1507- return false;1508- }1509-}1510-1511-async function fitWindowToIncomingStream() {1512- if (!connected || frameWidth < 1 || frameHeight < 1) return false;1513- const aspect = streamAspectKey(frameWidth, frameHeight);1514- if (fittedStreamAspect === aspect || pendingStreamFit?.aspect === aspect) return false;1515- const request = { aspect, generation: activeFrameGeneration, epoch: activeVideoFormatEpoch };1516- pendingStreamFit = request;1517- const geometry = fitInitialStreamWindow({1518- frameWidth,1519- frameHeight,1520- chromeHeight: clientChromeHeight(),1521- availableWidth: window.screen.availWidth,1522- availableHeight: window.screen.availHeight,1523- });1524- const applied = await applyClientWindowGeometry(geometry, true);1525- const currentAspect = frameWidth > 0 && frameHeight > 01526- ? streamAspectKey(frameWidth, frameHeight)1527- : null;1528- const current = pendingStreamFit === request1529- && connected1530- && activeFrameGeneration === request.generation1531- && activeVideoFormatEpoch === request.epoch1532- && currentAspect === request.aspect;1533- if (pendingStreamFit === request) pendingStreamFit = null;1534- if (applied && current) fittedStreamAspect = request.aspect;1535- return applied && current;1536-}1537-1538-function scheduleStreamWindowAspectCorrection() {1539- const observed = { width: window.innerWidth, height: window.innerHeight };1540- const previous = lastObservedWindowSize ?? observed;1541- lastObservedWindowSize = observed;1542- if (streamWindowResizeTimer !== null) clearTimeout(streamWindowResizeTimer);1543- streamWindowResizeTimer = setTimeout(() => {1544- streamWindowResizeTimer = null;1545- if (!connected || frameWidth < 1 || frameHeight < 1) return;1546- const current = { width: window.innerWidth, height: window.innerHeight };1547- let geometry;1548- try {1549- geometry = constrainStreamWindowResize({1550- frameWidth,1551- frameHeight,1552- chromeHeight: clientChromeHeight(),1553- width: current.width,1554- height: current.height,1555- previousWidth: previous.width,1556- previousHeight: previous.height,1557- });1558- } catch (error) {1559- console.warn('could not constrain stream window geometry:', error);1560- return;1561- }1562- if (geometry !== null) void applyClientWindowGeometry(geometry, false);1563- }, 80);1564-}1565-1566-function commitVideoFrame(plan, keyframe, enqueued) {1567- if (!enqueued) {1568- finishVideoChunkEnqueue(keyframe, false);1569- return false;1570- }1571- let committed;1572- try {1573- committed = videoFormatTransition.commit(plan);1574- } catch (error) {1575- console.warn('discarding stale video format transaction:', error);1576- enterDecoderRecovery(DECODER_RECOVERY_REASONS.DECODER_RESET);1577- return false;1578- }1579- if (plan.reconfigure) {1580- activeCodec = committed.format.codec;1581- frameWidth = committed.format.width;1582- frameHeight = committed.format.height;1583- activeVideoFormatEpoch = committed.epoch;1584- if (canvas.width !== frameWidth) canvas.width = frameWidth;1585- if (canvas.height !== frameHeight) canvas.height = frameHeight;1586- sizeCanvasToIncomingStream();1587- if (connected) void fitWindowToIncomingStream();1588- }1589- finishVideoChunkEnqueue(keyframe, true);1590- return true;1591-}1592-1593-function processFramePayload(payload, binaryData = null) {1594- const receivedAt = performance.now();1595- frontendDeliveryRate.record(receivedAt);1596- frontendDeliveryCadence.record(receivedAt);1597- const {1598- width,1599- height,1600- data,1601- codec = 'h264',1602- keyframe,1603- codecConfig = keyframe,1604- sequence = null,1605- pts_micros: ptsMicros,1606- discontinuity = false,1607- } = payload;1608- if (discontinuity) resetAvSyncTelemetry();1609- receivedFrames++;1610-1611- if (hasWebCodecs) {1612- // Binary channels provide an exact view over the IPC ArrayBuffer. The1613- // base64 branch only supports an older in-process sender during migration.1614- const raw = binaryData ?? Uint8Array.from(atob(data), c => c.charCodeAt(0));1615- const decoderBacklogged = videoDecoder?.decodeQueueSize >= MAX_DECODE_QUEUE_SIZE;1616- if (decoderBacklogged) {1617- enterDecoderRecovery(DECODER_RECOVERY_REASONS.FRONTEND_BACKPRESSURE);1618- }1619- const decoderBoundary = discontinuity || !decoderConfigured;1620- let formatPlan;1621- try {1622- formatPlan = videoFormatTransition.plan({1623- sequence,1624- codec,1625- width,1626- height,1627- keyframe,1628- codecConfig,1629- discontinuity: decoderBoundary,1630- });1631- } catch (error) {1632- console.warn('invalid video format transition:', error);1633- droppedFrames++;1634- enterDecoderRecovery(DECODER_RECOVERY_REASONS.DECODER_RESET);1635- return;1636- }1637- if (formatPlan.action === 'drop-stale') {1638- droppedFrames++;1639- return;1640- }1641- if (formatPlan.action === 'recover') {1642- droppedFrames++;1643- enterDecoderRecovery(DECODER_RECOVERY_REASONS.DECODER_RESET);1644- return;1645- }1646- if (formatPlan.reconfigure) {1647- // A delivered discontinuity keyframe is the recovery boundary we were1648- // waiting for, not evidence that another keyframe is missing.1649- if (discontinuity) {1650- enterDecoderRecovery(DECODER_RECOVERY_REASONS.DISCONTINUITY, {1651- requestKeyframe: !keyframe,1652- });1653- } else {1654- enterDecoderRecovery(DECODER_RECOVERY_REASONS.DECODER_RESET, {1655- requestKeyframe: false,1656- });1657- }1658- }1659- if (decoderRecovery.shouldDropFrame({ keyframe })) {1660- if (!decoderRecovery.requestIssued) {1661- enterDecoderRecovery(DECODER_RECOVERY_REASONS.DECODER_RESET);1662- }1663- droppedFrames++;1664- return;1665- }1666- const mediaPtsMicros = exactMediaTimestampMicros(ptsMicros);1667- const chunkTimestamp = mediaPtsMicros !== null1668- ? mediaPtsMicros1669- : performance.now() * 1000;1670-1671- if (codec === 'av1') {1672- // ── AV1 path ──1673- const obus = parseAv1Obus(raw);1674- if (keyframe) {1675- const seqHeader = obus.find(o => o.type === 12);1676- if (seqHeader && formatPlan.reconfigure) {1677- initWebCodecsDecoder(width, height, buildAv1Description(seqHeader.data), av1CodecStr(seqHeader.data));1678- }1679- }1680- if (!decoderConfigured || !videoDecoder) { droppedFrames++; return; }1681- const frameObus = obus.filter(o => o.type !== 2 && o.type !== 12);1682- if (frameObus.length > 0) {1683- let totalLen = 0;1684- for (const o of frameObus) totalLen += o.data.length;1685- const chunkData = new Uint8Array(totalLen);1686- let off = 0;1687- for (const o of frameObus) { chunkData.set(o.data, off); off += o.data.length; }1688- const chunk = new EncodedVideoChunk({1689- type: keyframe ? 'key' : 'delta',1690- timestamp: chunkTimestamp,1691- data: chunkData,1692- });1693- const enqueued = enqueueVideoChunk(chunk, receivedAt, 'av1', mediaPtsMicros);1694- commitVideoFrame(formatPlan, keyframe, enqueued);1695- }1696-1697- } else if (codec === 'h265') {1698- // ── H.265 path ──1699- const nals = parseAnnexBNals(raw);1700- if (keyframe) {1701- let vps = null, sps = null, pps = null;1702- for (const nal of nals) {1703- const t = hevcNalType(nal);1704- if (t === 32) vps = nal;1705- else if (t === 33) sps = nal;1706- else if (t === 34) pps = nal;1707- }1708- if (vps && sps && pps && formatPlan.reconfigure) {1709- initWebCodecsDecoder(width, height, buildHvcDescription(vps, sps, pps), hevcCodecStr(sps));1710- }1711- }1712- if (!decoderConfigured || !videoDecoder) { droppedFrames++; return; }1713- const sliceNals = nals.filter(nal => {1714- const t = hevcNalType(nal);1715- return t !== 32 && t !== 33 && t !== 34 && t !== 35;1716- });1717- if (sliceNals.length > 0) {1718- const chunk = new EncodedVideoChunk({1719- type: keyframe ? 'key' : 'delta',1720- timestamp: chunkTimestamp,1721- data: nalsToLengthPrefixed(sliceNals),1722- });1723- const enqueued = enqueueVideoChunk(chunk, receivedAt, 'hevc', mediaPtsMicros);1724- commitVideoFrame(formatPlan, keyframe, enqueued);1725- }1726-1727- } else {1728- // ── H.264 path (default) ──1729- const nals = parseAnnexBNals(raw);1730- if (keyframe) {1731- let sps = null, pps = null;1732- for (const nal of nals) {1733- const t = h264NalType(nal);1734- if (t === 7) sps = nal;1735- else if (t === 8) pps = nal;1736- }1737- if (sps && pps && formatPlan.reconfigure) {1738- initWebCodecsDecoder(width, height, buildAvcDescription(sps, pps), avcCodecStr(sps));1739- }1740- }1741- if (!decoderConfigured || !videoDecoder) { droppedFrames++; return; }1742- const sliceNals = nals.filter(nal => {1743- const t = h264NalType(nal);1744- return t !== 7 && t !== 8 && t !== 9;1745- });1746- if (sliceNals.length > 0) {1747- const chunk = new EncodedVideoChunk({1748- type: keyframe ? 'key' : 'delta',1749- timestamp: chunkTimestamp,1750- data: nalsToLengthPrefixed(sliceNals),1751- });1752- const enqueued = enqueueVideoChunk(chunk, receivedAt, 'h264', mediaPtsMicros);1753- commitVideoFrame(formatPlan, keyframe, enqueued);1754- }1755- }1756- } else {1757- // JPEG fallback (openh264 decode → JPEG encode in Rust)1758- const dimensionsChanged = frameWidth !== width || frameHeight !== height;1759- frameWidth = width;1760- frameHeight = height;1761- if (canvas.width !== width) canvas.width = width;1762- if (canvas.height !== height) canvas.height = height;1763- if (dimensionsChanged) sizeCanvasToIncomingStream();1764- if (dimensionsChanged && connected) void fitWindowToIncomingStream();1765- const bytes = Uint8Array.from(atob(data), c => c.charCodeAt(0));1766- const blob = new Blob([bytes], { type: 'image/jpeg' });1767- const url = URL.createObjectURL(blob);1768- const img = new Image();1769- img.onload = () => {1770- const drawStartedAt = performance.now();1771- ctx.drawImage(img, 0, 0, canvas.width, canvas.height);1772- URL.revokeObjectURL(url);1773- const now = performance.now();1774- presentedFrames++;1775- presentationRate.record(now);1776- presentationCadence.record(now);1777- drawLatency.record(now - drawStartedAt, now);1778- clientPresentationLatency.record(now - receivedAt, now);1779- };1780- img.src = url;1781- }1782-}1783-1784-function sendFrameAcknowledgment(generation) {1785- void invoke('iroh_client_ack_frame', { generation }).catch((error) => {1786- console.warn('frame acknowledgment failed:', error);1787- });1788-}1789-1790-function acknowledgeFrame(session, generation = null) {1791- const readyGeneration = stageFrameAcknowledgment(session, generation);1792- if (readyGeneration !== null) sendFrameAcknowledgment(readyGeneration);1793-}1794-1795-function activateConnectedFrameSession(session, generation) {1796- if (!isActiveFrameSession(session, activeFrameSession)) {1797- throw new Error('frame session was superseded during connect');1798- }1799- const activation = activateFrameSession(session, generation);1800- activeFrameGeneration = generation;1801- for (const pendingGeneration of activation.acknowledgments) {1802- sendFrameAcknowledgment(pendingGeneration);1803- }1804- const pendingError = session.pendingFrameErrors.splice(0).find(1805- (payload) => isCurrentFrameGeneration(payload?.generation, generation),1806- );1807- if (pendingError) throw new Error(pendingError.error || 'Media connection failed');1808- for (const payload of activation.legacyFrames) {1809- if (isCurrentFrameGeneration(payload?.generation, generation)) {1810- processFramePayload(payload);1811- }1812- }1813-}1814-1815-function failFrameSession(session, error) {1816- if (!isActiveFrameSession(session, activeFrameSession)) return;1817- session.failed = true;1818- session.failureDetail = `Frame delivery failed: ${error}`;1819- console.error(session.failureDetail);1820- if (connected) void disconnect();1821-}1822-1823-function handleBinaryFrameMessage(message, session) {1824- if (!isActiveFrameSession(session, activeFrameSession)) return;1825- let acknowledged = false;1826- try {1827- const frame = parseFrameEnvelope(message);1828- // Parsing establishes the exact envelope and payload bounds. Release the1829- // Rust-side delivery permit before codec parsing/decode enqueue so a slow1830- // invoke round trip cannot turn one scheduling hiccup into a full-GOP1831- // discard. WebCodecs remains independently bounded by decodeQueueSize.1832- acknowledgeFrame(session);1833- acknowledged = true;1834- processFramePayload({1835- width: frame.width,1836- height: frame.height,1837- data: null,1838- codec: frame.codec,1839- keyframe: frame.keyframe,1840- codecConfig: frame.codecConfig,1841- sequence: frame.sequence,1842- pts_micros: frame.ptsMicros,1843- discontinuity: frame.discontinuity,1844- }, frame.data);1845- } catch (error) {1846- failFrameSession(session, error);1847- } finally {1848- if (!acknowledged) {1849- // A malformed envelope disconnects above, but must never deadlock the1850- // bounded sender while teardown reaches Rust.1851- try {1852- acknowledgeFrame(session);1853- } catch (error) {1854- failFrameSession(session, error);1855- }1856- }1857- }1858-}1859-1860-function acknowledgeAudio(generation, deliveryId) {1861- void invoke('iroh_client_ack_audio', { generation, deliveryId }).catch((error) => {1862- console.warn('audio acknowledgment failed:', error);1863- });1864-}1865-1866-function requestNativeAudioStop(session) {1867- const generation = session?.expectedGeneration;1868- if (session !== activeAudioSession1869- || !Number.isSafeInteger(generation)1870- || generation < 01871- || session.stopRequested) return;1872- session.stopRequested = true;1873- void invoke('iroh_client_stop_audio', { expectedGeneration: generation }).catch((error) => {1874- console.warn('audio stop failed:', error);1875- });1876-}1877-1878-function disableAudioForSession(detail, session = activeAudioSession) {1879- if (!session || session !== activeAudioSession) return;1880- resetAvSyncTelemetry(session);1881- session.failed = true;1882- session.failureDetail = detail;1883- audioAvailable = false;1884- if (audioDecoder) {1885- try { audioDecoder.close(); } catch (_) {}1886- audioDecoder = null;1887- }1888- session.messageTracker.clear();1889- try { session.workletNode?.port.postMessage({ type: 'clear' }); } catch (_) {}1890- requestNativeAudioStop(session);1891- setAudioState('error', detail);1892-}1893-1894-function handleBinaryAudioMessage(message, session) {1895- let packet;1896- try {1897- packet = parseAudioEnvelope(message);1898- } catch (error) {1899- console.error('invalid audio packet:', error);1900- disableAudioForSession(`Audio packet failed: ${error}`, session);1901- return;1902- }1903-1904- // Every valid delivery is released using the token embedded in that exact1905- // message. A delayed delivery from an old connection is acknowledged but1906- // cannot enter the current decoder.1907- acknowledgeAudio(packet.generation, packet.deliveryId);1908- if (session !== activeAudioSession1909- || session.failed1910- || !isCurrentAudioDelivery(packet, session.expectedGeneration)) return;1911-1912- try {1913- audioPacketsReceived++;1914- if (!audioDecoder || audioDecoder.state === 'closed') {1915- audioDecoderDropped++;1916- updateAudioUI();1917- return;1918- }1919- const queuedAudioPackets = audioDecoder.decodeQueueSize;1920- if (packet.discontinuity || queuedAudioPackets >= MAX_AUDIO_DECODE_QUEUE_SIZE) {1921- resetAvSyncTelemetry(session, { recoverRing: true });1922- audioDecoderDropped += queuedAudioPackets;1923- configureAudioDecoder(session);1924- }1925- audioDecoder.decode(new EncodedAudioChunk({1926- type: 'key',1927- timestamp: packet.ptsMicros,1928- duration: (packet.frameSamples * 1_000_000) / packet.sampleRate,1929- data: packet.data,1930- }));1931- updateAudioUI();1932- } catch (error) {1933- console.error('undecodable audio packet:', error);1934- disableAudioForSession(`Audio packet failed: ${error}`, session);1935- }1936-}1937-1938782 // The software decoder/JPEG compatibility path intentionally remains an event:1939783 // it is only selected when WebCodecs is unavailable and is not latency-critical.1940784 listen('frame', (event) => {1941- const session = activeFrameSession;1942- if (!isActiveFrameSession(session, activeFrameSession)) return;1943- try {1944- const delivery = stageLegacyFrame(session, event.payload);1945- for (const generation of delivery.acknowledgments) {1946- sendFrameAcknowledgment(generation);1947- }1948- if (delivery.accepted) processFramePayload(event.payload);1949- } catch (error) {1950- failFrameSession(session, error);1951- }785+ streamRuntime.handleLegacyFrame(event.payload);1952786 });1953787 1954788 listen('frame-stats', (event) => {1955- if (!isCurrentFrameGeneration(event.payload?.generation, activeFrameGeneration)) return;1956- const diagnostics = normalizeFrameStatsPayload(event.payload);1957- transportDroppedFrames = diagnostics.transportDroppedFrames ?? transportDroppedFrames;1958- transportObjectDroppedFrames = diagnostics.objectDroppedFrames;1959- transportLateObjectDroppedFrames = diagnostics.lateObjectDroppedFrames;1960- frontendDroppedFrames = diagnostics.frontendDroppedFrames ?? frontendDroppedFrames;1961- frontendQueueDroppedFrames = diagnostics.queueDroppedFrames;1962- frontendResyncDroppedFrames = diagnostics.resyncDroppedFrames;1963- frontendQueueStats = diagnostics.queue;1964- frontendResyncStats = diagnostics.resync;1965- transportIntervalStats = diagnostics.transportIntervals;1966- frontendIpcSendDurationStats = diagnostics.ipcSendDurations;1967- rustTimingWindow = diagnostics.timingWindow;1968- networkDiagnostics = diagnostics.networkDiagnostics;1969- streamPathMode = typeof event.payload.path_mode === 'string'1970- ? event.payload.path_mode1971- : streamPathMode;1972- streamRttMs = Number.isFinite(event.payload.path_rtt_ms)1973- ? event.payload.path_rtt_ms1974- : streamRttMs;1975- lastMediaSequence = Number.isSafeInteger(event.payload.sequence) && event.payload.sequence >= 01976- ? event.payload.sequence : lastMediaSequence;1977- lastTransportFps = Number.isFinite(event.payload.transport_receive_fps)1978- ? event.payload.transport_receive_fps1979- : Number.isFinite(event.payload.transport_fps)1980- ? event.payload.transport_fps1981- : event.payload.fps;1982- lastFrontendSendFps = Number.isFinite(event.payload.frontend_send_fps)1983- ? event.payload.frontend_send_fps1984- : Number.isFinite(event.payload.frontend_fps)1985- ? event.payload.frontend_fps1986- : event.payload.fps;1987- updateStreamStats();789+ if (streamRuntime.handleFrameStats(event.payload)) updateStreamStats();1988790 });1989791 1990792 listen('adaptive-bitrate-decision', (event) => {1991- try {1992- const decision = normalizeAdaptiveDecisionEnvelope(event.payload, activeFrameGeneration);1993- if (decision === null) return;1994- adaptiveDecision = decision;1995- updateStreamStats();1996- } catch (error) {1997- console.warn('invalid adaptive bitrate diagnostic:', error);1998- }793+ if (streamRuntime.handleAdaptiveDecision(event.payload)) updateStreamStats();1999794 });2000795 2001796 listen('adaptive-feedback-state', (event) => {2002- if (!isCurrentFrameGeneration(event.payload?.generation, activeFrameGeneration)) return;2003- if (event.payload?.available !== false) return;2004- adaptiveFeedbackAvailable = false;2005- adaptiveFeedbackError = typeof event.payload.error === 'string'2006- ? event.payload.error : 'feedback stream closed';2007- adaptiveFeedbackPublisher.stop();2008- updateStreamStats();797+ if (streamRuntime.handleAdaptiveFeedbackState(event.payload)) updateStreamStats();2009798 });2010799 2011800 listen('frame-error', (event) => {2012- const session = activeFrameSession;2013- if (!isActiveFrameSession(session, activeFrameSession)) return;2014- if (session.expectedGeneration === null) {2015- if (session.pendingFrameErrors.length < FRAME_CHANNEL_CAPACITY) {2016- session.pendingFrameErrors.push(event.payload);2017- } else {2018- failFrameSession(session, 'pre-connect frame error capacity exceeded');2019- }2020- return;2021- }2022- if (!isCurrentFrameGeneration(event.payload?.generation, session.expectedGeneration)) return;2023- failFrameSession(session, event.payload?.error || 'Media connection failed');801+ streamRuntime.handleFrameError(event.payload);2024802 });2025803 2026804 listen('audio-state', (event) => {2027- const session = activeAudioSession;2028- if (disconnecting || !session || event.payload?.available !== false) return;2029- if (session.expectedGeneration === null) {2030- try {2031- stageAudioTerminalState(session.pendingTerminalStates, event.payload);2032- } catch (error) {2033- console.error('invalid pre-connect audio terminal state:', error);2034- }2035- return;2036- }2037- if (!isCurrentAudioGeneration(event.payload?.generation, session.expectedGeneration)) return;2038- disableAudioForSession(event.payload.error || 'Audio connection ended', session);805+ audioPipeline.handleNativeState(event.payload);2039806 });2040807 2041808 listen('audio-stats', (event) => {2042- if (!isCurrentAudioGeneration(2043- event.payload?.generation,2044- activeAudioSession?.expectedGeneration,2045- )) return;2046- audioTransportDropped = Number.isSafeInteger(event.payload?.sequence_dropped_total)2047- ? event.payload.sequence_dropped_total : audioTransportDropped;2048- audioFrontendDropped = Number.isSafeInteger(event.payload?.frontend_dropped_total)2049- ? event.payload.frontend_dropped_total : audioFrontendDropped;2050- updateAudioUI();809+ audioPipeline.handleNativeStats(event.payload);2051810 });2052811 2053812 // ─── Input ────────────────────────────────────────────────────────────────────2054813 async function toggleControl() {2055- if (!connected || !inputCapabilities.control || controlTransitionInProgress) return;2056814 // Capture synchronous controller provenance before cursor acquisition yields.2057815 // The DOM click dispatcher does not await this async handler.2058- const controllerInitiated = controllerActivationInProgress;2059- if (controlMode) {2060- controlTransitionGeneration += 1;2061- controllerActivationGate.reset();2062- queueNeutralControllerState();2063- releaseHeldInputs();2064- controlMode = false;2065- releasePointerLock();2066- } else {2067- const transitionGeneration = controlTransitionGeneration + 1;2068- controlTransitionGeneration = transitionGeneration;2069- controlTransitionInProgress = true;2070- if (controllerInitiated) controllerActivationGate.arm();2071- const acquired = await requestRelativePointerLock();2072- const ownsTransition = transitionGeneration === controlTransitionGeneration;2073- if (!ownsTransition) {2074- // A cancellation may have released before a late browser request took2075- // ownership. Do not disturb a newer transition, but do not leave an2076- // orphaned lock behind when control is otherwise idle.2077- if (!controlMode2078- && !controlTransitionInProgress2079- && document.pointerLockElement === canvas) {2080- releasePointerLock();2081- }2082- return;2083- }2084- if (!acquired || disconnecting || !connected || !inputCapabilities.control) {2085- exitControlAfterBrowserPointerLockFailure();2086- return;2087- }2088- controlTransitionInProgress = false;2089- controlMode = true;2090- // A used to cross the local control boundary must not also become an2091- // accidental remote A press. Its release forwards the current snapshot,2092- // without requiring noisy analog axes to be exactly zero.2093- if (controllerInitiated) {2094- queueNeutralControllerState();2095- // A may have been released while native cursor acquisition was pending,2096- // when controller publications were still local-only. Re-submit the2097- // latest snapshot through the gate so that release takes effect now;2098- // a still-held A remains suppressed and leaves the queued neutral state.2099- publishCurrentControllerState();2100- } else {2101- publishCurrentControllerState();2102- }2103- }2104- updateControlUI();2105-}2106-2107-function queueNeutralControllerState() {2108- if (!connected || !inputCapabilities.gamepad) return false;2109- return sendInput({ t: 'gp', state: neutralGamepadInputState() });816+ await controlRuntime.toggle({ controllerInitiated: controllerActivationInProgress });2110817 }2111818 2112819 function describeInputCapabilities() {2113820 const accepted = [];2114- if (inputCapabilities.relativePointer) accepted.push('relative pointer');2115- else if (inputCapabilities.absolutePointer) accepted.push('pointer');2116- if (inputCapabilities.keyboard) accepted.push('keyboard');2117- if (inputCapabilities.text) accepted.push('text');2118- if (inputCapabilities.gamepad) accepted.push('gamepad');821+ const capabilities = connectionState.inputCapabilities;822+ if (capabilities.relativePointer) accepted.push('relative pointer');823+ else if (capabilities.absolutePointer) accepted.push('pointer');824+ if (capabilities.keyboard) accepted.push('keyboard');825+ if (capabilities.text) accepted.push('text');826+ if (capabilities.gamepad) accepted.push('gamepad');2119827 return accepted.length > 0 ? accepted.join(' + ') : 'view only';2120828 }2121829 2122830 function pointerInputAvailable() {2123- return inputCapabilities.relativePointer || inputCapabilities.absolutePointer;2124-}2125-2126-async function requestRelativePointerLock() {2127- if (!inputCapabilities.relativePointer) return true;2128- try {2129- const nativeResult = await setNativeCursorGrab(true);2130- // Older commands returned no value, so only an explicit false disables2131- // the browser fallback. Current macOS builds return false because2132- // CoreGraphics owns relative capture and cursor visibility there.2133- browserPointerLockRequired = nativeResult !== false;2134- await requestBrowserPointerLock();2135- return true;2136- } catch (error) {2137- console.warn('relative pointer lock unavailable:', error);2138- return false;2139- }2140-}2141-2142-async function requestBrowserPointerLock() {2143- if (!browserPointerLockRequired) return;2144- try {2145- if (typeof canvas.requestPointerLock !== 'function') {2146- throw new Error('browser Pointer Lock is unavailable');2147- }2148- const request = canvas.requestPointerLock();2149- if (request && typeof request.then === 'function') await request;2150- await waitForBrowserPointerLockOwnership();2151- // Pointer Lock and the native window grab are separate on Linux. Reassert2152- // the native state only after the browser proves canvas ownership.2153- await setNativeCursorGrab(true);2154- } catch (error) {2155- exitControlAfterBrowserPointerLockFailure();2156- throw error;2157- }2158-}2159-2160-function waitForBrowserPointerLockOwnership(timeoutMs = 500) {2161- if (document.pointerLockElement === canvas) return Promise.resolve();2162- return new Promise((resolve, reject) => {2163- let timeoutId;2164- const cleanup = () => {2165- clearTimeout(timeoutId);2166- document.removeEventListener('pointerlockchange', handleChange);2167- document.removeEventListener('pointerlockerror', handleError);2168- };2169- const settle = (callback, value) => {2170- cleanup();2171- callback(value);2172- };2173- const handleChange = () => {2174- if (document.pointerLockElement === canvas) {2175- settle(resolve);2176- } else {2177- settle(reject, new Error('browser Pointer Lock ownership was lost'));2178- }2179- };2180- const handleError = () => {2181- settle(reject, new Error('browser Pointer Lock request was rejected'));2182- };2183- document.addEventListener('pointerlockchange', handleChange);2184- document.addEventListener('pointerlockerror', handleError);2185- timeoutId = setTimeout(() => {2186- settle(reject, new Error('browser Pointer Lock ownership timed out'));2187- }, timeoutMs);2188- });2189-}2190-2191-function exitControlAfterBrowserPointerLockFailure() {2192- if (!browserPointerLockRequired || (!controlMode && !controlTransitionInProgress)) {2193- return false;2194- }2195- controlTransitionGeneration += 1;2196- controlTransitionInProgress = false;2197- controllerActivationGate.reset();2198- controllerEscape.reset();2199- queueNeutralControllerState();2200- releaseHeldInputs();2201- controlMode = false;2202- releasePointerLock();2203- updateControlUI();2204- return true;831+ const capabilities = connectionState.inputCapabilities;832+ return capabilities.relativePointer || capabilities.absolutePointer;2205833 }2206834 2207835 function handleBrowserPointerLockChange() {2208- if (!browserPointerLockLossRequiresControlExit({2209- browserPointerLockRequired,2210- pointerLockElement: document.pointerLockElement,2211- expectedElement: canvas,2212- controlMode,2213- controlTransitionInProgress,2214- })) return;2215- exitControlAfterBrowserPointerLockFailure();836+ controlRuntime.handleBrowserPointerLockChange();2216837 }2217838 2218839 document.addEventListener('pointerlockchange', handleBrowserPointerLockChange);2219840 2220-function setNativeCursorGrab(grab) {2221- const command = nativeCursorCommand.then(2222- () => invoke('set_client_cursor_grab', { grab }),2223- );2224- nativeCursorCommand = command.catch(() => {});2225- return command;2226-}2227-2228-async function releaseNativeCursorGrab(releaseGeneration) {2229- const retryDelaysMs = [0, 16, 50];2230- let lastError = null;2231- for (const delayMs of retryDelaysMs) {2232- if (releaseGeneration !== controlTransitionGeneration || controlMode) return false;2233- if (delayMs > 0) {2234- await new Promise((resolve) => setTimeout(resolve, delayMs));2235- if (releaseGeneration !== controlTransitionGeneration || controlMode) return false;2236- }2237- try {2238- await setNativeCursorGrab(false);2239- return true;2240- } catch (error) {2241- lastError = error;2242- }2243- }2244- console.error('native cursor release failed after bounded retries:', lastError);2245- if (!controlMode && releaseGeneration === controlTransitionGeneration) {2246- setStatus('err', 'cursor release failed · quit app');2247- }2248- return false;2249-}2250-2251841 function currentPointerSurfaceSize() {842+ const { width: frameWidth, height: frameHeight } = videoPipeline.format;2252843 return resolvePointerSurfaceSize(2253- pointerSurfaceDimensions,844+ activePointerSession?.surfaceDimensions ?? null,2254845 frameWidth,2255846 frameHeight,2256- inputCapabilities.relativePointer,847+ connectionState.inputCapabilities.relativePointer,2257848 );2258849 }2259850 2260851 function renderRemotePointer() {852+ const session = activePointerSession;2261853 const surface = currentPointerSurfaceSize();2262- const visible = controlMode2263- && inputCapabilities.relativePointer2264- && inputCapabilities.pointerPositionFeedback2265- && remotePointerPosition !== null2266- && remotePointerVisible854+ const visible = controlRuntime.active855+ && connectionState.inputCapabilities.relativePointer856+ && connectionState.inputCapabilities.pointerPositionFeedback857+ && session?.remotePosition !== null858+ && session?.remoteVisible2267859 && surface !== null;2268860 remotePointer.classList.toggle('visible', visible);2269861 if (!visible) return;2270862 const canvasRect = canvas.getBoundingClientRect();2271863 const mainRect = canvas.parentElement.getBoundingClientRect();2272864 remotePointer.style.left = `${canvasRect.left - mainRect.left2273- + (remotePointerPosition.x / surface.width) * canvasRect.width}px`;865+ + (session.remotePosition.x / surface.width) * canvasRect.width}px`;2274866 remotePointer.style.top = `${canvasRect.top - mainRect.top2275- + (remotePointerPosition.y / surface.height) * canvasRect.height}px`;2276-}2277-2278-function releasePointerLock() {2279- const releaseGeneration = controlTransitionGeneration;2280- void releaseNativeCursorGrab(releaseGeneration);2281- if (document.pointerLockElement !== canvas || typeof document.exitPointerLock !== 'function') return;2282- try { document.exitPointerLock(); } catch (_) {}867+ + (session.remotePosition.y / surface.height) * canvasRect.height}px`;2283868 }2284869 2285870 function updateControlUI() {2286871 const el = document.getElementById('control-toggle');2287- const available = connected && inputCapabilities.control;2288- if (!available) controlMode = false;872+ const capabilities = connectionState.inputCapabilities;873+ const available = connectionState.connected && capabilities.control;874+ const controlling = controlRuntime.setInactiveIfUnavailable(available);2289875 el.textContent = available2290- ? `${controlMode ? 'controlling' : 'take control'} · ${describeInputCapabilities()}${controlMode && inputCapabilities.relativePointer ? ' · Ctrl+Alt+Esc to exit' : controlMode && inputCapabilities.gamepad ? ' · hold Back+Start to exit' : ''}`876+ ? `${controlling ? 'controlling' : 'take control'} · ${describeInputCapabilities()}${controlling && capabilities.relativePointer ? ' · Ctrl+Alt+Esc to exit' : controlling && capabilities.gamepad ? ' · hold Back+Start to exit' : ''}`2291877 : 'view only · input unavailable';2292- el.classList.toggle('active', controlMode);878+ el.classList.toggle('active', controlling);2293879 el.classList.toggle('disabled', !available);2294880 el.setAttribute('aria-disabled', available ? 'false' : 'true');2295881 document.body.classList.toggle(2296882 'native-pointer-control',2297- controlMode && inputCapabilities.relativePointer,883+ controlling && capabilities.relativePointer,2298884 );2299885 canvas.classList.toggle(2300886 'relative-control',2301- controlMode && inputCapabilities.relativePointer,887+ controlling && capabilities.relativePointer,2302888 );2303889 renderRemotePointer();2304890 const streamControl = document.getElementById('stream-control');
@@ -2317,321 +903,112 @@ function scaleCoords(clientX, clientY) {
2317903 }) ?? { x: 0, y: 0 };2318904 }2319905 2320-function mapKey(e) {2321- const key = e.key;2322- const map = {2323- 'ArrowUp': 'Up', 'ArrowDown': 'Down', 'ArrowLeft': 'Left', 'ArrowRight': 'Right',2324- ' ': 'Space', 'Delete': 'Delete', 'Backspace': 'Backspace',2325- 'Enter': 'Enter', 'Tab': 'Tab', 'Escape': 'Escape',2326- 'Shift': 'Shift', 'Control': 'Control', 'Alt': 'Alt', 'Meta': 'Meta',2327- 'Home': 'Home', 'End': 'End', 'PageUp': 'PageUp', 'PageDown': 'PageDown',2328- };2329- const asciiPrintable = key.length === 12330- && key.codePointAt(0) >= 0x202331- && key.codePointAt(0) <= 0x7e;2332- return map[key] || (asciiPrintable ? key : null);2333-}2334-2335-const RELIABLE_INPUT_QUEUE_LIMIT = 128;2336-const IN_FLIGHT_INPUT_RESTORE_RESERVE = 1;2337-const RELIABLE_INPUT_ENQUEUE_LIMIT = RELIABLE_INPUT_QUEUE_LIMIT2338- - IN_FLIGHT_INPUT_RESTORE_RESERVE;2339-const POINTER_MOTION_BARRIER_RESERVE = 2;2340-const RELEASE_INPUT_RESERVE = MAX_HELD_KEYS2341- + MAX_HELD_MOUSE_BUTTONS2342- + POINTER_MOTION_BARRIER_RESERVE;2343-const REGULAR_RELIABLE_INPUT_LIMIT = RELIABLE_INPUT_ENQUEUE_LIMIT - RELEASE_INPUT_RESERVE;2344-const INPUT_RETRY_MS = 8;2345-const reliableInputQueue = [];2346-const heldInputs = new HeldInputState();2347-const pendingPointerMotion = new PointerMotionBuffer();2348-let pendingGamepadInput = null;2349-let inputPumpRunning = false;2350-let inputPumpScheduled = false;2351-2352-function clearPendingInput() {2353- reliableInputQueue.length = 0;2354- heldInputs.clear();2355- controllerActivationGate.reset();2356- pendingPointerMotion.clear();2357- pendingGamepadInput = null;2358-}2359-2360-function inputEventAvailable(event) {2361- if (!inputCapabilities.control) return false;2362- if (event.t === 'mm') return inputCapabilities.absolutePointer;2363- if (event.t === 'mr') return inputCapabilities.relativePointer;2364- if (event.t === 'mp') return inputCapabilities.relativePointer;2365- if (['mc', 'md', 'mu', 'ms'].includes(event.t)) return pointerInputAvailable();2366- if (['kd', 'ku', 'kt'].includes(event.t)) return inputCapabilities.keyboard;2367- if (event.t === 'tx') return inputCapabilities.text;2368- if (event.t === 'gp') return inputCapabilities.gamepad;2369- return false;2370-}2371-2372-function hasPendingInput() {2373- return reliableInputQueue.length > 02374- || pendingPointerMotion.pending2375- || pendingGamepadInput !== null;2376-}2377-2378-function scheduleInputPump() {2379- if (inputPumpRunning || inputPumpScheduled) return;2380- inputPumpScheduled = true;2381- setTimeout(() => {2382- inputPumpScheduled = false;2383- pumpInputQueue();2384- }, 0);2385-}2386-2387-function sendInput(event, { release = false } = {}) {2388- if (!inputEventAvailable(event)) return false;2389- if (event.t === 'gp') {2390- // Controller samples are state, not transitions. A single latest-value slot2391- // bounds latency and guarantees a neutral snapshot can replace stale input.2392- pendingGamepadInput = event;2393- } else if (event.t === 'mm') {2394- // Absolute pointer motion is latest-value data; stale coordinates are not2395- // useful and must not create an invocation backlog.2396- pendingPointerMotion.setAbsolute(event);2397- } else if (event.t === 'mr') {2398- // Relative motion is displacement. Coalesce every sample into one2399- // constant-size total; the pump emits protocol-bounded chunks so2400- // throttling preserves distance instead of dropping motion.2401- if (!pendingPointerMotion.addRelative(event.dx, event.dy)) return false;2402- } else {2403- const queueLimit = release ? RELIABLE_INPUT_ENQUEUE_LIMIT : REGULAR_RELIABLE_INPUT_LIMIT;2404- const pointerTransition = ['mp', 'mc', 'md', 'mu', 'ms'].includes(event.t);2405- const barrierLength = pointerTransition ? pendingPointerMotion.barrierLength : 0;2406- const tail = reliableInputQueue.at(-1);2407- const coalesceScroll = event.t === 'ms' && barrierLength === 0 && tail?.t === 'ms';2408- const requiredSlots = coalesceScroll ? 0 : barrierLength + 1;2409- if (reliableInputQueue.length + requiredSlots > queueLimit) {2410- console.error(release2411- ? 'input release reserve exhausted; closing control session'2412- : 'reliable input queue full; closing control session');2413- // Defer teardown until the caller has removed any transition it could2414- // not enqueue from held-state tracking.2415- queueMicrotask(() => {2416- if (connected) void disconnect();2417- });2418- return false;2419- }2420- if (coalesceScroll) {2421- tail.dx = Math.max(-1000000, Math.min(1000000, tail.dx + event.dx));2422- tail.dy = Math.max(-1000000, Math.min(1000000, tail.dy + event.dy));2423- } else if (pointerTransition) {2424- reliableInputQueue.push(...pendingPointerMotion.takeBarrierBefore(event));2425- } else {2426- reliableInputQueue.push(event);2427- }2428- }2429- scheduleInputPump();2430- return true;2431-}2432-2433-function releaseHeldInputs() {2434- for (const release of heldInputs.releaseEvents()) {2435- // Ordinary reliable events cannot consume this reserved capacity.2436- sendInput(release, { release: true });2437- }2438- heldInputs.clear();2439-}2440-2441-async function waitForInputDrain(timeoutMs) {2442- const deadline = performance.now() + timeoutMs;2443- while ((hasPendingInput() || inputPumpRunning) && performance.now() < deadline) {2444- await new Promise((resolve) => setTimeout(resolve, INPUT_RETRY_MS));2445- }2446-}2447-2448-function takePendingInput() {2449- if (reliableInputQueue.length > 0) {2450- return { event: reliableInputQueue.shift(), reliable: true };2451- }2452- const pointerMotionEvent = pendingPointerMotion.take();2453- if (pointerMotionEvent !== null) {2454- return { event: pointerMotionEvent, reliable: false };2455- }2456- if (pendingGamepadInput !== null) {2457- const event = pendingGamepadInput;2458- pendingGamepadInput = null;2459- return { event, reliable: false };2460- }2461- return null;2462-}2463-2464-function restorePendingInput(item) {2465- if (item.reliable) {2466- if (reliableInputQueue.length >= RELIABLE_INPUT_QUEUE_LIMIT) {2467- throw new Error('reliable input restore reserve exhausted');2468- }2469- reliableInputQueue.unshift(item.event);2470- } else if (item.event.t === 'mm' || item.event.t === 'mr') {2471- // A transition queued during the invoke must remain after the rejected2472- // motion. With no transition, relative displacement merges while stale2473- // absolute state yields to any newer latest position.2474- restoreRejectedPointerMotion(2475- reliableInputQueue,2476- pendingPointerMotion,2477- item.event,2478- RELIABLE_INPUT_QUEUE_LIMIT,2479- );2480- } else if (item.event.t === 'gp') {2481- // Keep a newer controller sample, especially a neutral release state.2482- if (pendingGamepadInput === null) pendingGamepadInput = item.event;2483- }2484-}2485-2486-async function pumpInputQueue() {2487- if (inputPumpRunning) return;2488- inputPumpRunning = true;2489- try {2490- while (hasPendingInput()) {2491- const item = takePendingInput();2492- if (!item) break;2493- let accepted;2494- try {2495- accepted = await invoke('iroh_client_send_input', { event: item.event });2496- } catch (e) {2497- console.warn('input send failed:', e);2498- clearPendingInput();2499- return;2500- }2501- if (!accepted) {2502- try {2503- restorePendingInput(item);2504- } catch (error) {2505- console.error('input restore failed:', error);2506- clearPendingInput();2507- if (connected) void disconnect();2508- return;2509- }2510- await new Promise((resolve) => setTimeout(resolve, INPUT_RETRY_MS));2511- }2512- }2513- } finally {2514- inputPumpRunning = false;2515- if (hasPendingInput()) scheduleInputPump();2516- }2517-}2518-2519906 window.addEventListener('mousemove', (e) => {2520- if (!controlMode || !pointerInputAvailable()) return;2521- if (inputCapabilities.relativePointer) {907+ if (!controlRuntime.active || !pointerInputAvailable()) return;908+ if (connectionState.inputCapabilities.relativePointer) {2522909 const movement = scaleRelativePointerDelta(2523910 e.movementX,2524911 e.movementY,2525912 );2526- sendInput({ t: 'mr', ...movement });913+ inputRuntime.send({ t: 'mr', ...movement });2527914 return;2528915 }2529916 }, { capture: true });2530917 2531918 canvas.addEventListener('mousemove', (e) => {2532- if (!controlMode || !inputCapabilities.absolutePointer || inputCapabilities.relativePointer) return;919+ if (!controlRuntime.active920+ || !connectionState.inputCapabilities.absolutePointer921+ || connectionState.inputCapabilities.relativePointer) return;2533922 const { x, y } = scaleCoords(e.clientX, e.clientY);2534- sendInput({ t: 'mm', x, y });923+ inputRuntime.send({ t: 'mm', x, y });2535924 });2536925 2537926 function handleMouseDown(e) {2538- if (!controlMode || !pointerInputAvailable()) return;2539- if (inputCapabilities.relativePointer) e.stopPropagation();927+ if (!controlRuntime.active || !pointerInputAvailable()) return;928+ if (connectionState.inputCapabilities.relativePointer) e.stopPropagation();2540929 else if (e.target !== canvas) return;2541930 const btn = browserMouseButtonCode(e.button);2542931 if (btn === null) return;2543932 e.preventDefault();2544- if (inputCapabilities.relativePointer && document.pointerLockElement !== canvas) {2545- void requestBrowserPointerLock().catch((error) => {933+ if (connectionState.inputCapabilities.relativePointer && document.pointerLockElement !== canvas) {934+ void controlRuntime.requestBrowserPointerLock().catch((error) => {2546935 console.warn('browser pointer lock retry failed:', error);2547936 });2548937 }2549- if (!heldInputs.trackMouseButton(btn)) return;2550- if (!sendInput({ t: 'md', b: btn })) heldInputs.takeMouseButtonRelease(btn);938+ if (!inputRuntime.trackMouseButton(btn)) return;939+ if (!inputRuntime.send({ t: 'md', b: btn })) inputRuntime.takeMouseButtonRelease(btn);2551940 }2552941 2553942 window.addEventListener('mousedown', handleMouseDown, { capture: true });2554943 2555944 function releaseMouseButton(e) {2556- if (!connected || !pointerInputAvailable()) return;945+ if (!connectionState.connected || !pointerInputAvailable()) return;2557946 const btn = browserMouseButtonCode(e.button);2558947 if (btn === null) return;2559- const release = heldInputs.takeMouseButtonRelease(btn);948+ const release = inputRuntime.takeMouseButtonRelease(btn);2560949 if (!release) return;2561950 e.preventDefault();2562- sendInput(release, { release: true });951+ inputRuntime.send(release, { release: true });2563952 }2564953 2565954 window.addEventListener('mouseup', (e) => {2566- if (controlMode && inputCapabilities.relativePointer) e.stopPropagation();955+ if (controlRuntime.active && connectionState.inputCapabilities.relativePointer) e.stopPropagation();2567956 releaseMouseButton(e);2568957 }, { capture: true });2569958 2570959 window.addEventListener('contextmenu', (e) => {2571- if (!controlMode || !pointerInputAvailable()) return;2572- if (!inputCapabilities.relativePointer && e.target !== canvas) return;960+ if (!controlRuntime.active || !pointerInputAvailable()) return;961+ if (!connectionState.inputCapabilities.relativePointer && e.target !== canvas) return;2573962 e.preventDefault();2574963 }, { capture: true });2575964 2576965 window.addEventListener('wheel', (e) => {2577- if (!controlMode || !pointerInputAvailable()) return;2578- if (!inputCapabilities.relativePointer && e.target !== canvas) return;966+ if (!controlRuntime.active || !pointerInputAvailable()) return;967+ if (!connectionState.inputCapabilities.relativePointer && e.target !== canvas) return;2579968 e.preventDefault();2580- if (inputCapabilities.relativePointer) e.stopPropagation();2581- sendInput({ t: 'ms', dx: Math.round(e.deltaX), dy: Math.round(e.deltaY) });969+ if (connectionState.inputCapabilities.relativePointer) e.stopPropagation();970+ inputRuntime.send({ t: 'ms', dx: Math.round(e.deltaX), dy: Math.round(e.deltaY) });2582971 }, { passive: false, capture: true });2583972 2584973 window.addEventListener('keydown', (e) => {2585- if (!controlMode) return;974+ if (!controlRuntime.active) return;2586975 if (e.key === 'Escape' && e.ctrlKey && e.altKey) {2587976 e.preventDefault();2588- controllerActivationGate.reset();2589- queueNeutralControllerState();2590- releaseHeldInputs();2591- controlMode = false;2592- releasePointerLock();2593- updateControlUI();977+ controlRuntime.exit();2594978 return;2595979 }2596980 const printableText = e.key.length === 1 && !e.ctrlKey && !e.altKey && !e.metaKey;2597- if (printableText && inputCapabilities.text) {981+ if (printableText && connectionState.inputCapabilities.text) {2598982 e.preventDefault();2599- sendInput({ t: 'tx', s: e.key });983+ inputRuntime.send({ t: 'tx', s: e.key });2600984 return;2601985 }2602- if (!inputCapabilities.keyboard) return;986+ if (!connectionState.inputCapabilities.keyboard) return;2603987 const k = mapKey(e);2604988 if (k) {2605989 e.preventDefault();2606990 const keyId = e.code || e.key;2607- const tracking = heldInputs.trackKey(keyId, k);991+ const tracking = inputRuntime.trackKey(keyId, k);2608992 if (tracking === 'repeat') return;2609993 if (tracking === 'full') {2610994 console.error('held key limit reached; refusing additional key transition');2611995 return;2612996 }2613- if (!sendInput({ t: 'kd', k })) heldInputs.takeKeyRelease(keyId);997+ if (!inputRuntime.send({ t: 'kd', k })) inputRuntime.takeKeyRelease(keyId);2614998 }2615999 });26161000 26171001 window.addEventListener('keyup', (e) => {26181002 const keyId = e.code || e.key;2619- const release = heldInputs.takeKeyRelease(keyId);1003+ const release = inputRuntime.takeKeyRelease(keyId);26201004 if (!release) return;26211005 e.preventDefault();2622- sendInput(release, { release: true });1006+ inputRuntime.send(release, { release: true });26231007 });26241008 26251009 window.addEventListener('blur', () => {2626- if (!controlMode && !controlTransitionInProgress) return;2627- controlTransitionGeneration += 1;2628- controlTransitionInProgress = false;2629- controllerActivationGate.reset();2630- queueNeutralControllerState();2631- releaseHeldInputs();2632- controlMode = false;2633- releasePointerLock();2634- updateControlUI();1010+ if (!controlRuntime.active && !controlRuntime.transitioning) return;1011+ controlRuntime.exit();26351012 });26361013 26371014 // ─── Controller PIN entry ─────────────────────────────────────────────────────
@@ -2646,7 +1023,7 @@ function renderPinPad() {
26461023 }26471024 26481025 function openPinPad(targetId, returnFocus = document.activeElement) {2649- if (developmentConnectionMode) return;1026+ if (connectionState.developmentMode) return;26501027 const target = document.getElementById(targetId);26511028 if (!(target instanceof HTMLInputElement) || target.disabled) return;26521029 activePinInput = target;
@@ -2698,29 +1075,6 @@ for (const id of ['enrollment-pin', 'intro-pin', 'pin-input']) {
26981075 }26991076 27001077 // ─── Gamepad polling, UI navigation, and latest-state routing ─────────────────2701-const controllerPublisher = new LatestControllerStatePublisher();2702-const controllerActions = new ControllerActionRepeater();2703-const controllerEscape = new GamepadEscapeHold(1000);2704-let controllerSequence = 0;2705-let selectedControllerIndex = null;2706-let currentControllerState = disconnectedControllerState();2707-let lastControllerSignature = controllerStateSignature(currentControllerState);2708-let externalControllerObserver = null;2709-2710-controllerPublisher.setHandler((state) => {2711- if (externalControllerObserver) {2712- try { externalControllerObserver(state); } catch (error) { console.warn('controller observer failed:', error); }2713- }2714- if (!connected || !controlMode || !inputCapabilities.gamepad) return;2715- const inputState = maskGamepadEscapeChord(toGamepadInputState(state));2716- if (!controllerActivationGate.accepts(inputState)) return;2717- sendInput({ t: 'gp', state: inputState });2718-});2719-2720-function publishCurrentControllerState() {2721- controllerPublisher.publish(currentControllerState);2722-}2723-27241078 function controllerScope() {27251079 const tap = document.getElementById('tap-overlay');27261080 if (!tap.classList.contains('hidden')) return tap;
@@ -2803,79 +1157,43 @@ function updateControllerStatus(state) {
28031157 status.classList.toggle('warning', !standard);28041158 }28051159 2806-function pollControllers(nowMs) {2807- let gamepads = [];2808- try {2809- gamepads = typeof navigator.getGamepads === 'function'2810- ? Array.from(navigator.getGamepads()).filter(Boolean)2811- : [];2812- } catch (error) {2813- console.warn('gamepad poll failed:', error);2814- }2815- const gamepad = selectPreferredController(gamepads, selectedControllerIndex);2816- const nextIndex = gamepad?.index ?? null;2817- if (nextIndex !== selectedControllerIndex) {2818- selectedControllerIndex = nextIndex;2819- controllerActions.reset();2820- controllerEscape.reset();2821- lastControllerSignature = '';2822- }2823-2824- currentControllerState = normalizeGamepad(gamepad, controllerSequence + 1);2825- const signature = controllerStateSignature(currentControllerState);2826- if (signature !== lastControllerSignature) {2827- controllerSequence += 1;2828- currentControllerState = normalizeGamepad(gamepad, controllerSequence);2829- lastControllerSignature = signature;2830- controllerPublisher.publish(currentControllerState);2831- updateControllerStatus(currentControllerState);2832- }2833-2834- const remoteRoute = connected2835- && controlMode2836- && inputCapabilities.gamepad2837- && currentControllerState.connected2838- && currentControllerState.mapping === 'standard';2839- if (remoteRoute) {2840- if (controllerEscape.update(currentControllerState, nowMs)) {2841- controllerActivationGate.reset();2842- queueNeutralControllerState();2843- releaseHeldInputs();2844- controlMode = false;2845- releasePointerLock();2846- updateControlUI();2847- controllerActions.reset();2848- setControllerFocus(document.getElementById('control-toggle'));2849- }2850- } else {2851- controllerEscape.reset();2852- for (const action of controllerActions.update(currentControllerState, nowMs)) {2853- if (action.type === 'navigate') navigateControllerFocus(action.direction);2854- else if (action.type === 'activate') activateControllerFocus();2855- else if (action.type === 'back') controllerBack();2856- }2857- }2858- requestAnimationFrame(pollControllers);2859-}1160+controllerRuntime = createControllerRuntime({1161+ schedulePoll: (callback) => requestAnimationFrame(callback),1162+ isRemoteRoute: () => connectionState.connected1163+ && controlRuntime.active1164+ && connectionState.inputCapabilities.gamepad,1165+ sendRemoteState: (state) => {1166+ if (!controlRuntime.acceptsControllerInput(state)) return;1167+ inputRuntime.send({ t: 'gp', state });1168+ },1169+ onNavigate: navigateControllerFocus,1170+ onActivate: activateControllerFocus,1171+ onBack: controllerBack,1172+ onStatus: updateControllerStatus,1173+ onExit: () => {1174+ controlRuntime.exit();1175+ setControllerFocus(document.getElementById('control-toggle'));1176+ },1177+});28601178 28611179 document.addEventListener('pointerdown', () => {28621180 document.querySelectorAll('.controller-focus').forEach((item) => item.classList.remove('controller-focus'));28631181 }, { capture: true });28641182 28651183 window.addEventListener('resize', () => {2866- sizeCanvasToIncomingStream();1184+ windowRuntime.sizeSurfaceToIncomingStream();28671185 renderRemotePointer();2868- scheduleStreamWindowAspectCorrection();1186+ windowRuntime.scheduleAspectCorrection();28691187 });28701188 28711189 window.addEventListener('focus', () => {2872- if (!controlMode || !inputCapabilities.relativePointer) return;1190+ if (!controlRuntime.active || !connectionState.inputCapabilities.relativePointer) return;28731191 // WebKit rebuilds its native cursor rectangles after the window-level focus28741192 // event. Reassert from the webview on the next frame so the final rectangle28751193 // is the transparent one used during relative control.28761194 requestAnimationFrame(() => {2877- if (!controlMode || !inputCapabilities.relativePointer) return;2878- void setNativeCursorGrab(true).catch((error) => {1195+ if (!controlRuntime.active || !connectionState.inputCapabilities.relativePointer) return;1196+ void controlRuntime.reassertNativeGrab().catch((error) => {28791197 console.warn('native cursor re-hide after focus failed:', error);28801198 });28811199 });
@@ -2908,11 +1226,8 @@ Object.assign(window, {
29081226 openEnrollmentReset, cancelEnrollmentReset, confirmEnrollmentReset,29091227 derivePortalIdentity, chooseInvitationFile, confirmInvitation, cancelInvitation,29101228 sigilController: Object.freeze({2911- getLatestState: () => controllerPublisher.latest,2912- setObserver: (observer) => {2913- if (observer !== null && typeof observer !== 'function') throw new TypeError('observer must be a function or null');2914- externalControllerObserver = observer;2915- },1229+ getLatestState: () => controllerRuntime.latest,1230+ setObserver: controllerRuntime.setObserver,29161231 }),29171232 });29181233
@@ -2934,8 +1249,8 @@ async function initialize() {
29341249 status.className = 'overlay-status err';29351250 status.textContent = 'could not load enrollment';29361251 }2937- if (!developmentConnectionMode) scanFido();2938- requestAnimationFrame(pollControllers);1252+ if (!connectionState.developmentMode) scanFido();1253+ requestAnimationFrame(controllerRuntime.poll);29391254 }29401255 29411256 initialize();
M portal/pointer-feedback.mjs +90 -0

Not shown: this page already shows 6000 lines of diff.

M portal/pointer-feedback.test.mjs +234 -1

Not shown: this page already shows 6000 lines of diff.

A portal/stream-runtime.mjs +361 -0

Not shown: this page already shows 6000 lines of diff.

A portal/stream-runtime.test.mjs +337 -0

Not shown: this page already shows 6000 lines of diff.

A portal/video-pipeline.mjs +518 -0

Not shown: this page already shows 6000 lines of diff.

A portal/video-pipeline.test.mjs +304 -0

Not shown: this page already shows 6000 lines of diff.

A portal/window-runtime.mjs +159 -0

Not shown: this page already shows 6000 lines of diff.

A portal/window-runtime.test.mjs +258 -0

Not shown: this page already shows 6000 lines of diff.