1import {2 mapCanvasPointToSurface,3 resolvePointerSurfaceSize,4 scaleRelativePointerDelta,5 validatePointerSurfaceDimensions,6 validatePointerPositionFeedback,7 browserMouseButtonCode,8 inputCapabilityLabel,9} from './input-state.mjs';10import { createInputRuntime } from './input-runtime.mjs';11import { createControlRuntime } from './control-runtime.mjs';12import { formatVideoDiscardTelemetry } from './frame-stats.mjs';13import { networkDiagnosticsPresentation } from './network-diagnostics.mjs';14import {15 createPointerFeedbackRuntime,16 newPointerSession,17} from './pointer-feedback.mjs';18import { newConnectionState } from './connection-state.mjs';19import { formatSignedMilliseconds } from './av-sync.mjs';20import { audioButtonPresentation } from './audio-ui.mjs';21import {22 AUDIO_SAMPLE_RATE,23 createAudioPipelineSession,24} from './audio-pipeline.mjs';25import {26 chooseDirectionalIndex,27} from './controller-state.mjs';28import { createControllerRuntime } from './controller-runtime.mjs';29import { createWindowRuntime } from './window-runtime.mjs';30import {31 createVideoPipelineSession,32} from './video-pipeline.mjs';33import { detectAndPublishVideoDeliveryMode } from './video-capability.mjs';34import {35 formatAdaptiveDecision,36} from './adaptive-feedback.mjs';37import { createStreamRuntime } from './stream-runtime.mjs';38import {39 formatInvitationExpiry,40 grantLabel,41 normalizeInvitationSummary,42 shortPeerFingerprint,43} from './enrollment.mjs';44import { keyboardInputForEvent } from './keyboard-map.mjs';4546let enrollmentReady = false;47let pendingInvitationSummary = null;48let currentEnrollmentStatus = null;49let activePointerSession = null;50let controllerActivationInProgress = false;51let controlRuntime = null;52let controllerRuntime = null;5354const invoke = (...args) => window.__TAURI__.core.invoke(...args);55const listen = (...args) => window.__TAURI__.event.listen(...args);56const pointerFeedbackRuntime = createPointerFeedbackRuntime({57 getActiveSession: () => activePointerSession,58 getCapabilities: () => ({59 connected: connectionState.connected,60 pointerPositionFeedback: connectionState.inputCapabilities.pointerPositionFeedback,61 }),62 getSurface: currentPointerSurfaceSize,63 render: renderRemotePointer,64 disconnect,65 logger: console,66});67const inputRuntime = createInputRuntime({68 invokeCommand: invoke,69 getCapabilities: () => connectionState.inputCapabilities,70 isConnected: () => connectionState.connected,71 onFatal: () => { void disconnect(); },72 resetControllerActivation: () => controlRuntime?.resetControllerActivation(),73});74let videoPipeline = null;75let streamRuntime = null;76const audioPipeline = createAudioPipelineSession({77 invokeCommand: invoke,78 onUpdate: updateAudioUI,79 resetAvSync: (...args) => videoPipeline?.resetAvSync(...args),80 isDisconnecting: () => connectionState.disconnecting,81});8283function updatePanelVisibility() {84 const streamSection = document.getElementById('stream-section');85 streamSection.style.display = connectionState.connected ? '' : 'none';86}8788// ─── Status ──────────────────────────────────────────────────────────────────89function setStatus(state, text) {90 const line = document.getElementById('status-line');91 const dot = document.getElementById('status-dot');92 const txt = document.getElementById('status-text');93 line.className = 'status-line ' + state;94 dot.className = 'dot ' + state;95 txt.textContent = text;96}9798function showIntro() {99 document.getElementById('intro').classList.remove('hidden');100 setTimeout(() => {101 const target = connectionState.developmentMode102 ? document.getElementById('intro-connect')103 : document.getElementById('intro-pin');104 target?.focus();105 }, 50);106}107function hideIntro() { document.getElementById('intro').classList.add('hidden'); }108function showTap() {109 document.getElementById('tap-overlay-title').textContent = 'tap key';110 document.getElementById('tap-overlay-desc').textContent = 'Touch the sensor on your key when it blinks.';111 document.getElementById('tap-status').textContent = 'waiting...';112 document.getElementById('tap-overlay').classList.remove('hidden');113}114function hideTap() { document.getElementById('tap-overlay').classList.add('hidden'); }115116function hideEnrollment() { document.getElementById('enrollment-overlay').classList.add('hidden'); }117function showEnrollment() {118 if (!document.getElementById('invitation-overlay').classList.contains('hidden')) return;119 hideIntro();120 document.getElementById('enrollment-overlay').classList.remove('hidden');121 setTimeout(() => document.getElementById('enrollment-pin')?.focus(), 50);122}123124function showInvitationSummary(value) {125 const summary = normalizeInvitationSummary(value);126 pendingInvitationSummary = summary;127 hideEnrollment();128 document.getElementById('invitation-host').textContent = summary.hostFingerprint;129 document.getElementById('invitation-peer').textContent = summary.peerFingerprint;130 document.getElementById('invitation-expiry').textContent = formatInvitationExpiry(summary.expiresAtUnix);131 document.getElementById('invitation-grants').textContent = summary.grants.map(grantLabel).join(' · ');132 document.getElementById('invitation-status').textContent = '';133 document.getElementById('invitation-overlay').classList.remove('hidden');134 setTimeout(() => setControllerFocus(document.getElementById('confirm-invitation')), 0);135}136137async function refreshEnrollment() {138 if (connectionState.developmentMode) {139 enrollmentReady = true;140 hideEnrollment();141 showIntro();142 return true;143 }144 const status = await invoke('portal_enrollment_status');145 currentEnrollmentStatus = status;146 enrollmentReady = status.enrolled === true;147 document.getElementById('reset-enrollment').classList.toggle('hidden', !enrollmentReady);148 document.getElementById('reset-enrollment-intro').classList.toggle('hidden', !enrollmentReady);149 if (status.pending) {150 showInvitationSummary(status.pending);151 return enrollmentReady;152 }153 if (enrollmentReady) {154 hideEnrollment();155 showIntro();156 } else {157 showEnrollment();158 }159 return enrollmentReady;160}161162function openEnrollmentReset() {163 if (!currentEnrollmentStatus?.enrolled || !currentEnrollmentStatus.host_node_id) return;164 togglePanel(false);165 document.getElementById('reset-enrollment-host').textContent =166 shortPeerFingerprint(currentEnrollmentStatus.host_node_id);167 document.getElementById('reset-enrollment-status').textContent = '';168 document.getElementById('reset-enrollment-overlay').classList.remove('hidden');169 setTimeout(() => setControllerFocus(document.getElementById('confirm-reset-enrollment')), 0);170}171172function cancelEnrollmentReset() {173 document.getElementById('reset-enrollment-overlay').classList.add('hidden');174}175176async function confirmEnrollmentReset() {177 const hostNodeId = currentEnrollmentStatus?.host_node_id;178 if (!hostNodeId) return;179 const status = document.getElementById('reset-enrollment-status');180 status.className = 'overlay-status pending';181 status.textContent = 'erasing enrollment';182 try {183 await invoke('portal_reset_enrollment', { expectedHostNodeId: hostNodeId });184 enrollmentReady = false;185 currentEnrollmentStatus = null;186 document.getElementById('reset-enrollment').classList.add('hidden');187 document.getElementById('reset-enrollment-intro').classList.add('hidden');188 document.getElementById('reset-enrollment-overlay').classList.add('hidden');189 showEnrollment();190 } catch (error) {191 status.className = 'overlay-status err';192 status.textContent = String(error);193 }194}195196async function derivePortalIdentity() {197 const pin = document.getElementById('enrollment-pin').value.trim();198 const status = document.getElementById('enrollment-status');199 if (!pin) {200 status.className = 'overlay-status err';201 status.textContent = 'enter pin';202 return;203 }204 status.className = 'overlay-status pending';205 status.textContent = 'tap the security key';206 showTap();207 try {208 const result = await invoke('key_derive_identity', { pin });209 if (result.error || !result.node_id) throw new Error(result.error || 'identity derivation failed');210 document.getElementById('enrollment-peer-id').textContent = result.node_id;211 document.getElementById('enrollment-peer-wrap').classList.remove('hidden');212 status.className = 'overlay-status ok';213 status.textContent = `portal ${shortPeerFingerprint(result.node_id)} ready for an invitation`;214 } catch (error) {215 status.className = 'overlay-status err';216 status.textContent = String(error);217 } finally {218 hideTap();219 }220}221222async function chooseInvitationFile() {223 const status = document.getElementById('enrollment-status');224 try {225 const path = await window.__TAURI__.dialog.open({226 multiple: false,227 directory: false,228 filters: [{ name: 'goq invitation', extensions: ['goq-invite'] }],229 });230 if (!path) return;231 const summary = await invoke('portal_import_invitation_file', { path });232 showInvitationSummary(summary);233 } catch (error) {234 status.className = 'overlay-status err';235 status.textContent = String(error);236 }237}238239async function confirmInvitation() {240 if (!pendingInvitationSummary) return;241 const status = document.getElementById('invitation-status');242 status.className = 'overlay-status pending';243 status.textContent = 'saving enrollment';244 try {245 await invoke('portal_confirm_invitation');246 pendingInvitationSummary = null;247 document.getElementById('invitation-overlay').classList.add('hidden');248 await refreshEnrollment();249 } catch (error) {250 status.className = 'overlay-status err';251 status.textContent = String(error);252 }253}254255async function cancelInvitation() {256 try { await invoke('portal_cancel_invitation'); } catch (error) { console.warn(error); }257 pendingInvitationSummary = null;258 document.getElementById('invitation-overlay').classList.add('hidden');259 showEnrollment();260}261262async function checkDevelopmentConnectionMode() {263 const mode = await invoke('development_connection_mode');264 connectionState.setDevelopmentMode(mode.enabled);265 if (!mode.enabled) return;266267 const badge = document.getElementById('dev-connect-badge');268 badge.classList.remove('hidden');269 badge.title = mode.warning;270 document.getElementById('dev-connect-warning').classList.remove('hidden');271 document.getElementById('intro-desc').textContent = 'development direct-node routing · passkey lookup skipped · not client authorization';272273 for (const id of ['intro-pin', 'pin-input']) {274 const input = document.getElementById(id);275 input.value = '';276 input.disabled = true;277 input.style.display = 'none';278 }279 document.getElementById('controller-pin-intro').style.display = 'none';280 document.getElementById('controller-pin-main').style.display = 'none';281 document.getElementById('pin-label').classList.add('hidden');282}283284// ─── Intro actions ────────────────────────────────────────────────────────────285async function introConnect() {286 if (connectionState.connecting || connectionState.connected) return;287 if (!connectionState.developmentMode && !enrollmentReady) {288 showEnrollment();289 return;290 }291 const pin = document.getElementById('intro-pin').value.trim();292 const status = document.getElementById('intro-status');293 if (!connectionState.developmentMode && !pin) {294 status.className = 'overlay-status err';295 status.textContent = 'enter pin';296 return;297 }298 hideIntro();299 if (!connectionState.developmentMode) showTap();300 document.getElementById('pin-input').value = pin;301 await connectHost();302 if (!connectionState.developmentMode) hideTap();303 if (!connectionState.connected) {304 showIntro();305 status.className = 'overlay-status err';306 status.textContent = 'connection failed';307 }308}309310// ─── Client ───────────────────────────────────────────────────────────────────311function updateAudioUI() {312 const session = audioPipeline.session;313 const toggle = document.getElementById('audio-toggle');314 const status = document.getElementById('stream-audio-state');315 const webviewReceived = document.getElementById('stream-audio-webview-received-packets');316 const transportMissing = document.getElementById('stream-audio-transport-missing-packets');317 const ipcDropped = document.getElementById('stream-audio-ipc-dropped-packets');318 const decoderDropped = document.getElementById('stream-audio-decoder-dropped-packets');319 const pcmHandoffDropped = document.getElementById('stream-audio-pcm-handoff-dropped-blocks');320 const ringOverflow = document.getElementById('stream-audio-ring-overflow-ms');321 const ringRecoveryDiscarded = document.getElementById('stream-audio-ring-recovery-discarded-ms');322 const ringBuffered = document.getElementById('stream-audio-ring-buffer-ms');323 const underruns = document.getElementById('stream-audio-underruns');324 const silence = document.getElementById('stream-audio-silence-ms');325 if (toggle) {326 const presentation = audioButtonPresentation({327 available: session.available,328 muted: session.muted,329 state: session.state,330 detail: session.stateDetail,331 });332 toggle.textContent = presentation.label;333 toggle.classList.toggle('active', session.available && !session.muted);334 toggle.classList.toggle('disabled', !session.available);335 toggle.setAttribute('aria-disabled', session.available ? 'false' : 'true');336 toggle.setAttribute('aria-label', presentation.ariaLabel);337 toggle.title = presentation.title;338 }339 if (status) {340 status.textContent = session.muted && session.available ? 'muted' : session.state;341 status.title = session.stateDetail;342 }343 if (webviewReceived) webviewReceived.textContent = `${session.packetsReceived} packets`;344 if (transportMissing) transportMissing.textContent = `${session.transportDropped} packets`;345 if (ipcDropped) ipcDropped.textContent = `${session.frontendDropped} packets`;346 if (decoderDropped) decoderDropped.textContent = `${session.decoderDropped} packets`;347 if (pcmHandoffDropped) {348 pcmHandoffDropped.textContent = `${session.messageTracker.droppedMessages} blocks`;349 }350 if (ringOverflow) {351 ringOverflow.textContent = `${(session.workletDroppedFrames * 1000 / AUDIO_SAMPLE_RATE).toFixed(1)} ms`;352 }353 if (ringRecoveryDiscarded) {354 ringRecoveryDiscarded.textContent = `${(session.workletRecoveryDiscardedFrames * 1000 / AUDIO_SAMPLE_RATE).toFixed(1)} ms`;355 }356 if (ringBuffered) {357 ringBuffered.textContent = `${(session.bufferedFrames * 1000 / AUDIO_SAMPLE_RATE).toFixed(1)} ms`;358 }359 if (underruns) {360 underruns.textContent = `${session.underflows} events / ${(session.underflowDurationMicros / 1000).toFixed(1)} ms`;361 }362 if (silence) silence.textContent = `${(session.silentDurationMicros / 1000).toFixed(1)} ms`;363}364365async function toggleAudioMute() {366 await audioPipeline.toggleMute();367}368369async function createConnectionAttempt({ createChannel: Channel }) {370 // Reset before opening the channel so early frames cannot race a371 // post-connect reset and disappear from the live diagnostics.372 streamRuntime.prepareConnection();373 if (typeof Channel !== 'function') throw new Error('Tauri binary channels are unavailable');374 const audioSession = audioPipeline.beginAttempt();375 const frameSession = streamRuntime.openFrameSession();376 const pointerSession = newPointerSession();377 activePointerSession = pointerSession;378 const audioAttempt = await audioPipeline.createAttemptChannel(Channel, audioSession);379 const frameChannel = new Channel(380 (message) => streamRuntime.handleBinaryFrame(message, frameSession),381 );382 pointerSession.channel = new Channel(383 (message) => pointerFeedbackRuntime.handleMessage(message, pointerSession),384 );385 return {386 audioSession: audioAttempt.session,387 frameSession,388 pointerSession,389 audioSupport: audioAttempt.support,390 connectionArgs: {391 frameChannel,392 audioChannel: audioAttempt.channel,393 pointerChannel: pointerSession.channel,394 audioSupported: audioAttempt.support.supported,395 },396 };397}398399function validateConnectedAttempt(result, attempt) {400 if (attempt.pointerSession.failed) {401 throw new Error(attempt.pointerSession.failureDetail || 'host returned invalid pointer-position feedback');402 }403 if (attempt.frameSession.failed) {404 throw new Error(attempt.frameSession.failureDetail || 'host returned invalid frame data');405 }406 attempt.pointerSession.surfaceDimensions = result.connected407 ? validatePointerSurfaceDimensions(result.pointer_surface_dimensions)408 : null;409 attempt.pointerFeedbackAvailable = result.connected410 && result.pointer_position_feedback_available === true;411 if (attempt.pointerFeedbackAvailable && attempt.pointerSession.surfaceDimensions === null) {412 throw new Error('host offered pointer feedback without a native pointer surface');413 }414 attempt.initialPointerFeedback = attempt.pointerFeedbackAvailable && attempt.pointerSession.received415 ? validatePointerPositionFeedback(416 attempt.pointerSession.latest,417 attempt.pointerSession.surfaceDimensions,418 )419 : null;420}421422function activateConnectedAttempt(result, attempt) {423 streamRuntime.activateFrameSession(attempt.frameSession, {424 generation: result.media_generation,425 adaptiveFeedbackAvailable: result.adaptive_feedback_available === true,426 adaptiveFeedbackError: result.adaptive_feedback_error,427 });428}429430function closeConnectionAttempt(attempt, { committed = false } = {}) {431 streamRuntime.closeFrameSession(attempt?.frameSession);432 if (committed && attempt?.pointerSession) attempt.pointerSession.closing = true;433}434435async function teardownConnectionAttempt(attempt) {436 const pointerSession = attempt?.pointerSession ?? activePointerSession;437 if (activePointerSession === pointerSession) activePointerSession = null;438 if (pointerSession) pointerSession.channel = null;439 audioPipeline.releaseAttempt(attempt?.audioSession);440 await audioPipeline.teardown();441}442443function updateAcceptedConnectionState({ attempt, state }) {444 const session = attempt.pointerSession;445 if (!state.inputCapabilities.pointerPositionFeedback) {446 session.remotePosition = null;447 session.remoteVisible = false;448 } else if (attempt.initialPointerFeedback !== null) {449 session.remotePosition = attempt.initialPointerFeedback.position;450 session.remoteVisible = attempt.initialPointerFeedback.pointer_visible;451 }452 controlRuntime.resetAcceptedConnection();453}454455async function showConnectedAttempt({ result, attempt }) {456 await audioPipeline.acceptConnectedResult(result, {457 session: attempt.audioSession,458 support: attempt.audioSupport,459 });460 updatePanelVisibility();461 document.getElementById('node-id-text').textContent = result.host_node_id.substring(0, 16) + '...';462 document.getElementById('frame-canvas').style.display = 'block';463 document.getElementById('placeholder').classList.add('hidden');464 document.getElementById('control-bar').classList.add('visible');465 windowRuntime.sizeSurfaceToIncomingStream();466 updateControlUI();467 // Land controller users on the deliberate view-only/control boundary.468 // The connect button is hidden at this point, so retaining its focus469 // would make the next A press fall back to an unrelated top-bar action.470 setTimeout(() => setControllerFocus(document.getElementById('control-toggle')), 0);471 document.getElementById('action-connect').style.display = 'none';472 document.getElementById('action-disconnect').textContent = 'disconnect';473 document.getElementById('action-disconnect').classList.remove('hidden');474 document.getElementById('pin-label').style.display = 'none';475 document.getElementById('pin-input').style.display = 'none';476 document.getElementById('controller-pin-main').style.display = 'none';477}478479async function prepareDisconnect() {480 streamRuntime.prepareDisconnect();481 // Give release commands a short, finite opportunity to cross the bounded482 // Tauri queue before closing it. A broken stream cannot stall disconnect.483 await controlRuntime.prepareDisconnect(250);484}485486async function teardownConnectedResources() {487 const pointerSession = activePointerSession;488 activePointerSession = null;489 if (pointerSession) pointerSession.channel = null;490 await audioPipeline.teardown();491 streamRuntime.teardown();492}493494async function showDisconnectedState() {495 controlRuntime.resetDisconnected();496 updatePanelVisibility();497 setStatus('', 'idle');498 document.getElementById('frame-canvas').style.display = 'none';499 document.getElementById('placeholder').classList.remove('hidden');500 document.getElementById('placeholder').textContent = 'no stream';501 document.getElementById('control-bar').classList.remove('visible');502 document.getElementById('action-connect').style.display = '';503 document.getElementById('action-disconnect').classList.add('hidden');504 if (!connectionState.developmentMode) {505 document.getElementById('pin-label').style.display = '';506 document.getElementById('pin-input').style.display = '';507 document.getElementById('controller-pin-main').style.display = '';508 }509 document.getElementById('intro-status').textContent = '';510}511512const connectionState = newConnectionState({513 invokeCommand: invoke,514 createChannel: window.__TAURI__.core.Channel,515 beforeConnect: audioPipeline.prepareForConnection,516 createAttempt: createConnectionAttempt,517 validateConnectedResult: validateConnectedAttempt,518 activateAttempt: activateConnectedAttempt,519 closeAttempt: closeConnectionAttempt,520 teardownAttempt: teardownConnectionAttempt,521 beforeDisconnect: prepareDisconnect,522 teardownConnection: teardownConnectedResources,523 onStatus: (state, detail, context) => {524 if (state === 'ok') updateAcceptedConnectionState(context);525 setStatus(state, detail);526 },527 onConnected: showConnectedAttempt,528 onDisconnected: showDisconnectedState,529 onFailure: ({ error }) => console.error('connection failed:', error),530 onDisconnectError: (error) => console.error(error),531 onNativeDisconnectError: (error) => console.error('failed to close rejected connection:', error),532});533534async function connectHost() {535 await connectionState.connect({ pin: document.getElementById('pin-input').value });536}537538async function disconnect() {539 await connectionState.disconnect();540}541542// ─── FIDO scan ────────────────────────────────────────────────────────────────543async function scanFido() {544 try {545 const info = await invoke('fido_device_info');546 const el = document.getElementById('fido-status');547 if (!info.found) {548 el.textContent = 'no device';549 el.style.color = 'var(--muted-fg)';550 return;551 }552 el.textContent = info.error ? `found (${info.error})` : 'connected';553 el.style.color = info.error ? 'var(--yellow)' : 'var(--green)';554 document.getElementById('fido-product').textContent = info.product || '—';555 document.getElementById('fido-vidpid').textContent =556 `${info.vid.toString(16).padStart(4,'0')}:${info.pid.toString(16).padStart(4,'0')}`;557 document.getElementById('fido-versions').textContent = info.versions.join(', ') || '—';558 document.getElementById('fido-extensions').textContent = info.extensions.join(', ') || '—';559 document.getElementById('fido-pin').textContent = info.pin_retries ?? '—';560 } catch (e) {561 console.error('scanFido failed:', e);562 document.getElementById('fido-status').textContent = 'error';563 }564}565566// ─── Panel ────────────────────────────────────────────────────────────────────567function togglePanel(force) {568 const panel = document.getElementById('panel');569 const open = typeof force === 'boolean' ? force : !panel.classList.contains('visible');570 panel.classList.toggle('visible', open);571 document.getElementById('panel-overlay').classList.toggle('visible', open);572 if (open) setTimeout(() => document.getElementById('panel-close').focus(), 0);573}574575// ─── WebCodecs detection ──────────────────────────────────────────────────────576// Finish the codec probe and publish the result to Rust before exposing any577// connect handlers. Rust refuses to connect when WebCodecs is unavailable, so578// the pipeline below can assume binary WebCodecs delivery.579await detectAndPublishVideoDeliveryMode({ invokeCommand: invoke });580581// ─── Frame decoding ───────────────────────────────────────────────────────────582const canvas = document.getElementById('frame-canvas');583const remotePointer = document.getElementById('remote-pointer');584const ctx = canvas.getContext('2d');585const windowRuntime = createWindowRuntime({586 getFormat: () => videoPipeline.format,587 getSurfaceBounds: () => {588 const main = canvas.parentElement;589 if (!main) return null;590 const bounds = main.getBoundingClientRect();591 return { width: bounds.width, height: bounds.height };592 },593 setSurfaceSize: ({ width, height }) => {594 canvas.style.width = `${width}px`;595 canvas.style.height = `${height}px`;596 },597});598controlRuntime = createControlRuntime({599 getConnection: () => ({600 connected: connectionState.connected,601 disconnecting: connectionState.disconnecting,602 capabilities: connectionState.inputCapabilities,603 }),604 inputRuntime,605 invokeCursorGrab: (grab) => invoke('set_client_cursor_grab', { grab }),606 pointerLock: {607 target: canvas,608 getOwner: () => document.pointerLockElement,609 request: () => {610 if (typeof canvas.requestPointerLock !== 'function') {611 throw new Error('browser Pointer Lock is unavailable');612 }613 return canvas.requestPointerLock();614 },615 exit: () => {616 if (typeof document.exitPointerLock === 'function') document.exitPointerLock();617 },618 eventTarget: document,619 },620 publishController: () => controllerRuntime?.publishCurrentState(),621 resetControllerEscape: () => controllerRuntime?.resetEscape(),622 onChange: () => updateControlUI(),623 onReleaseFailure: () => setStatus('err', 'cursor release failed · quit app'),624});625videoPipeline = createVideoPipelineSession({626 canvas,627 context: ctx,628 requestKeyframe: (reason) => streamRuntime?.requestKeyframe(reason),629 resetAudioSync: audioPipeline.resetSyncTelemetry,630 sampleAudioSkew: audioPipeline.sampleSkew,631 onFormatChanged: () => {632 windowRuntime.sizeSurfaceToIncomingStream();633 },634});635streamRuntime = createStreamRuntime({636 invokeCommand: invoke,637 videoPipeline,638 isConnected: () => connectionState.connected,639 disconnect,640});641642function formatCadence(summary) {643 if (!summary.count) return '—';644 return `${summary.p50.toFixed(1)} / ${summary.p95.toFixed(1)} / ${summary.p99.toFixed(1)} / ${summary.max.toFixed(1)} ms · >25/>33 ${summary.over25Ms}/${summary.over33Ms}`;645}646647function formatRustDurationSummary(summary) {648 if (!summary) return '—';649 if (!summary.count) return '— · 0 samples';650 return `${summary.p50Ms.toFixed(2)} / ${summary.p95Ms.toFixed(2)} / ${summary.maxMs.toFixed(2)} ms · ${summary.count} samples`;651}652653function updateStreamStats() {654 const now = performance.now();655 const {656 video,657 transportDroppedFrames,658 transportObjectDroppedFrames,659 transportLateObjectDroppedFrames,660 frontendDroppedFrames,661 frontendQueueDroppedFrames,662 frontendResyncDroppedFrames,663 frontendQueueStats,664 frontendResyncStats,665 transportIntervalStats,666 frontendIpcSendDurationStats,667 rustTimingWindow,668 networkDiagnostics,669 lastTransportFps,670 lastFrontendSendFps,671 streamPathMode,672 streamRttMs,673 adaptiveFeedbackAvailable,674 adaptiveFeedbackError,675 adaptiveDecision,676 } = streamRuntime.snapshot(now);677 const networkPresentation = networkDiagnosticsPresentation(networkDiagnostics);678 document.getElementById('stream-received').textContent = video.receivedFrames;679 document.getElementById('stream-decoder-input').textContent = video.decoderInputFrames;680 document.getElementById('stream-decoder-output').textContent = video.decoderOutputFrames;681 document.getElementById('stream-presented').textContent = video.presentedFrames;682 document.getElementById('stream-decode-queue').textContent = video.decoderQueueDepth;683 document.getElementById('stream-present-queue').textContent = video.presenterQueueDepth;684 const discardTelemetry = formatVideoDiscardTelemetry({685 transportDroppedFrames,686 frontendDroppedFrames,687 decoderDroppedFrames: video.droppedFrames,688 presenterOverwrittenFrames: video.presentationDroppedFrames,689 });690 document.getElementById('stream-dropped').textContent = discardTelemetry.total;691 document.getElementById('stream-transport-dropped').textContent = discardTelemetry.transport;692 document.getElementById('stream-transport-object-dropped').textContent =693 transportObjectDroppedFrames === null ? '—' : `${transportObjectDroppedFrames} frames`;694 document.getElementById('stream-transport-late-object-dropped').textContent =695 transportLateObjectDroppedFrames === null696 ? '—' : `${transportLateObjectDroppedFrames} frames`;697 document.getElementById('stream-frontend-dropped').textContent = discardTelemetry.frontend;698 document.getElementById('stream-frontend-queue-dropped').textContent = frontendQueueDroppedFrames === null699 ? '—' : `${frontendQueueDroppedFrames} frames`;700 document.getElementById('stream-frontend-resync-dropped').textContent = frontendResyncDroppedFrames === null701 ? '—' : `${frontendResyncDroppedFrames} frames`;702 document.getElementById('stream-frontend-queue').textContent = frontendQueueStats === null703 ? '—'704 : `${frontendQueueStats.depth} current / ${frontendQueueStats.peak} peak / ${frontendQueueStats.capacity} frames capacity`;705 document.getElementById('stream-frontend-resync').textContent = frontendResyncStats === null706 ? '—'707 : `${frontendResyncStats.episodes} ${frontendResyncStats.episodes === 1 ? 'episode' : 'episodes'} · ${frontendResyncStats.active ? 'active' : 'idle'} · total ${frontendResyncStats.totalMs.toFixed(1)} ms · current ${frontendResyncStats.active ? `${frontendResyncStats.currentMs.toFixed(1)} ms` : '—'} · max ${frontendResyncStats.maxMs.toFixed(1)} ms`;708 document.getElementById('stream-transport-interval').textContent = formatRustDurationSummary(transportIntervalStats);709 document.getElementById('stream-ipc-send-duration').textContent = formatRustDurationSummary(frontendIpcSendDurationStats);710 document.getElementById('stream-rust-timing-window').textContent = rustTimingWindow === null711 ? '—'712 : `${rustTimingWindow.windowMs.toFixed(1)} ms / ${rustTimingWindow.sampleCapacity} samples`;713 document.getElementById('stream-decoder-dropped').textContent = discardTelemetry.decoder;714 document.getElementById('stream-present-dropped').textContent = discardTelemetry.presenterOverwrite;715 document.getElementById('stream-transport-fps').textContent = lastTransportFps.toFixed(1);716 document.getElementById('stream-ipc-send-fps').textContent = lastFrontendSendFps.toFixed(1);717 document.getElementById('stream-frontend-fps').textContent = video.frontendDeliveryFps.toFixed(1);718 document.getElementById('stream-decode-fps').textContent = video.decodeFps.toFixed(1);719 document.getElementById('stream-decoder-output-fps').textContent = video.decoderOutputFps.toFixed(1);720 document.getElementById('stream-present-fps').textContent = video.presentFps.toFixed(1);721 document.getElementById('stream-av-skew').textContent = video.avSkewPercentiles.count722 ? `${formatSignedMilliseconds(video.avSkewPercentiles.p50)} / ${formatSignedMilliseconds(video.avSkewPercentiles.p95)} / ${video.avSkewPercentiles.maxAbsolute.toFixed(1)} ms`723 : '—';724 document.getElementById('stream-decode-latency').textContent = video.decodePercentiles.count725 ? `${video.decodePercentiles.p50.toFixed(1)} / ${video.decodePercentiles.p95.toFixed(1)} ms`726 : '—';727 document.getElementById('stream-present-latency').textContent = video.presentPercentiles.count728 ? `${video.presentPercentiles.p50.toFixed(1)} / ${video.presentPercentiles.p95.toFixed(1)} ms`729 : '—';730 document.getElementById('stream-draw-latency').textContent = video.drawPercentiles.count731 ? `${video.drawPercentiles.p50.toFixed(2)} / ${video.drawPercentiles.p95.toFixed(2)} / ${video.drawPercentiles.p99.toFixed(2)} / ${video.drawPercentiles.max.toFixed(2)} ms`732 : '—';733 document.getElementById('stream-delivery-cadence').textContent = formatCadence(video.deliveryCadence);734 document.getElementById('stream-decoder-input-cadence').textContent = formatCadence(video.decoderInputIntervals);735 document.getElementById('stream-decoder-output-cadence').textContent = formatCadence(video.decoderOutputIntervals);736 document.getElementById('stream-present-cadence').textContent = formatCadence(video.presentationIntervals);737 document.getElementById('stream-codec').textContent = video.activeCodec;738 document.getElementById('stream-transport').textContent = connectionState.mediaTransport;739 document.getElementById('stream-path').textContent = streamPathMode;740 document.getElementById('stream-rtt').textContent = Number.isFinite(streamRttMs)741 ? `${streamRttMs.toFixed(1)} ms`742 : '— ms';743 document.getElementById('stream-network-session').textContent = networkPresentation.session;744 document.getElementById('stream-network-media').textContent = networkPresentation.media;745 document.getElementById('stream-network-input').textContent = networkPresentation.input;746 document.getElementById('stream-network-audio').textContent = networkPresentation.audio;747 document.getElementById('stream-network-input-ack').textContent = networkPresentation.inputAck;748 document.getElementById('stream-adaptive-feedback').textContent = adaptiveFeedbackAvailable749 ? 'authenticated · 1 Hz bounded reports'750 : adaptiveFeedbackError || 'unavailable';751 document.getElementById('stream-adaptive-decision').textContent = formatAdaptiveDecision(752 adaptiveDecision,753 adaptiveFeedbackAvailable,754 );755756 streamRuntime.publishAdaptiveFeedback(video);757}758759setInterval(() => {760 if (connectionState.connected) updateStreamStats();761}, 250);762763listen('frame-stats', (event) => {764 if (streamRuntime.handleFrameStats(event.payload)) updateStreamStats();765});766767listen('adaptive-bitrate-decision', (event) => {768 if (streamRuntime.handleAdaptiveDecision(event.payload)) updateStreamStats();769});770771listen('adaptive-feedback-state', (event) => {772 if (streamRuntime.handleAdaptiveFeedbackState(event.payload)) updateStreamStats();773});774775listen('frame-error', (event) => {776 streamRuntime.handleFrameError(event.payload);777});778779listen('audio-state', (event) => {780 audioPipeline.handleNativeState(event.payload);781});782783listen('audio-stats', (event) => {784 audioPipeline.handleNativeStats(event.payload);785});786787// ─── Input ────────────────────────────────────────────────────────────────────788async function toggleControl() {789 // Capture synchronous controller provenance before cursor acquisition yields.790 // The DOM click dispatcher does not await this async handler.791 await controlRuntime.toggle({ controllerInitiated: controllerActivationInProgress });792}793794function describeInputCapabilities() {795 return inputCapabilityLabel(connectionState.inputCapabilities);796}797798function pointerInputAvailable() {799 const capabilities = connectionState.inputCapabilities;800 return capabilities.relativePointer || capabilities.absolutePointer;801}802803function handleBrowserPointerLockChange() {804 controlRuntime.handleBrowserPointerLockChange();805}806807document.addEventListener('pointerlockchange', handleBrowserPointerLockChange);808809function currentPointerSurfaceSize() {810 const { width: frameWidth, height: frameHeight } = videoPipeline.format;811 return resolvePointerSurfaceSize(812 activePointerSession?.surfaceDimensions ?? null,813 frameWidth,814 frameHeight,815 connectionState.inputCapabilities.relativePointer,816 );817}818819function renderRemotePointer() {820 const session = activePointerSession;821 const surface = currentPointerSurfaceSize();822 const visible = controlRuntime.active823 && connectionState.inputCapabilities.relativePointer824 && connectionState.inputCapabilities.pointerPositionFeedback825 && session?.remotePosition !== null826 && session?.remoteVisible827 && surface !== null;828 remotePointer.classList.toggle('visible', visible);829 if (!visible) return;830 const canvasRect = canvas.getBoundingClientRect();831 const mainRect = canvas.parentElement.getBoundingClientRect();832 remotePointer.style.left = `${canvasRect.left - mainRect.left833 + (session.remotePosition.x / surface.width) * canvasRect.width}px`;834 remotePointer.style.top = `${canvasRect.top - mainRect.top835 + (session.remotePosition.y / surface.height) * canvasRect.height}px`;836}837838function updateControlUI() {839 const el = document.getElementById('control-toggle');840 const capabilities = connectionState.inputCapabilities;841 const available = connectionState.connected && capabilities.control;842 const controlling = controlRuntime.setInactiveIfUnavailable(available);843 el.textContent = available844 ? `${controlling ? 'controlling' : 'take control'} · ${describeInputCapabilities()}${controlling && capabilities.relativePointer ? ' · Ctrl+Alt+Esc to exit' : controlling && capabilities.gamepad ? ' · hold Back+Start to exit' : ''}`845 : 'view only · input unavailable';846 el.classList.toggle('active', controlling);847 el.classList.toggle('disabled', !available);848 el.setAttribute('aria-disabled', available ? 'false' : 'true');849 document.body.classList.toggle(850 'native-pointer-control',851 controlling && capabilities.relativePointer,852 );853 canvas.classList.toggle(854 'relative-control',855 controlling && capabilities.relativePointer,856 );857 renderRemotePointer();858 const streamControl = document.getElementById('stream-control');859 if (streamControl) {860 streamControl.textContent = available ? describeInputCapabilities() : 'view only · unavailable';861 }862}863864function scaleCoords(clientX, clientY) {865 const rect = canvas.getBoundingClientRect();866 return mapCanvasPointToSurface({867 clientX,868 clientY,869 rect,870 surface: currentPointerSurfaceSize(),871 }) ?? { x: 0, y: 0 };872}873874window.addEventListener('mousemove', (e) => {875 if (!controlRuntime.active || !pointerInputAvailable()) return;876 if (connectionState.inputCapabilities.relativePointer) {877 const movement = scaleRelativePointerDelta(878 e.movementX,879 e.movementY,880 );881 inputRuntime.send({ t: 'mr', ...movement });882 return;883 }884}, { capture: true });885886canvas.addEventListener('mousemove', (e) => {887 if (!controlRuntime.active888 || !connectionState.inputCapabilities.absolutePointer889 || connectionState.inputCapabilities.relativePointer) return;890 const { x, y } = scaleCoords(e.clientX, e.clientY);891 inputRuntime.send({ t: 'mm', x, y });892});893894function handleMouseDown(e) {895 if (!controlRuntime.active || !pointerInputAvailable()) return;896 if (connectionState.inputCapabilities.relativePointer) e.stopPropagation();897 else if (e.target !== canvas) return;898 const btn = browserMouseButtonCode(e.button);899 if (btn === null) return;900 e.preventDefault();901 if (connectionState.inputCapabilities.relativePointer && document.pointerLockElement !== canvas) {902 void controlRuntime.requestBrowserPointerLock().catch((error) => {903 console.warn('browser pointer lock retry failed:', error);904 });905 }906 if (!inputRuntime.trackMouseButton(btn)) return;907 if (!inputRuntime.send({ t: 'md', b: btn })) inputRuntime.takeMouseButtonRelease(btn);908}909910window.addEventListener('mousedown', handleMouseDown, { capture: true });911912function releaseMouseButton(e) {913 if (!connectionState.connected || !pointerInputAvailable()) return;914 const btn = browserMouseButtonCode(e.button);915 if (btn === null) return;916 const release = inputRuntime.takeMouseButtonRelease(btn);917 if (!release) return;918 e.preventDefault();919 inputRuntime.send(release, { release: true });920}921922window.addEventListener('mouseup', (e) => {923 if (controlRuntime.active && connectionState.inputCapabilities.relativePointer) e.stopPropagation();924 releaseMouseButton(e);925}, { capture: true });926927function suppressLocalClickDuringRelativeControl(e) {928 if (!controlRuntime.active || !connectionState.inputCapabilities.relativePointer) return;929 // mousedown/mouseup are forwarded to the host above, but WebKit synthesizes930 // a separate click at the native cursor's frozen local position. Prevent931 // that click from reaching the underlying Portal control that acquired the932 // cursor, where it would immediately toggle controlling mode back off.933 e.preventDefault();934 e.stopImmediatePropagation();935}936937window.addEventListener('click', suppressLocalClickDuringRelativeControl, { capture: true });938939window.addEventListener('contextmenu', (e) => {940 if (!controlRuntime.active || !pointerInputAvailable()) return;941 if (!connectionState.inputCapabilities.relativePointer && e.target !== canvas) return;942 e.preventDefault();943}, { capture: true });944945window.addEventListener('wheel', (e) => {946 if (!controlRuntime.active || !pointerInputAvailable()) return;947 if (!connectionState.inputCapabilities.relativePointer && e.target !== canvas) return;948 e.preventDefault();949 if (connectionState.inputCapabilities.relativePointer) e.stopPropagation();950 inputRuntime.send({ t: 'ms', dx: Math.round(e.deltaX), dy: Math.round(e.deltaY) });951}, { passive: false, capture: true });952953window.addEventListener('keydown', (e) => {954 if (!controlRuntime.active) return;955 if (e.key === 'Escape' && e.ctrlKey && e.altKey) {956 e.preventDefault();957 controlRuntime.exit();958 return;959 }960 const input = keyboardInputForEvent(e, connectionState.inputCapabilities);961 if (input === null) return;962 e.preventDefault();963 if (input.type === 'text') {964 inputRuntime.send({ t: 'tx', s: input.text });965 return;966 }967 const keyId = e.code || e.key;968 const tracking = inputRuntime.trackKey(keyId, input.key);969 if (tracking === 'repeat') return;970 if (tracking === 'full') {971 console.error('held key limit reached; refusing additional key transition');972 return;973 }974 if (!inputRuntime.send({ t: 'kd', k: input.key })) inputRuntime.takeKeyRelease(keyId);975});976977window.addEventListener('keyup', (e) => {978 const keyId = e.code || e.key;979 const release = inputRuntime.takeKeyRelease(keyId);980 if (!release) return;981 e.preventDefault();982 inputRuntime.send(release, { release: true });983});984985window.addEventListener('blur', () => {986 if (!controlRuntime.active && !controlRuntime.transitioning) return;987 controlRuntime.exit();988});989990// ─── Controller PIN entry ─────────────────────────────────────────────────────991let activePinInput = null;992let pinPadReturnFocus = null;993994function renderPinPad() {995 const display = document.getElementById('pin-pad-display');996 const length = activePinInput?.value.length || 0;997 display.textContent = length === 0 ? 'empty' : '•'.repeat(length);998 display.setAttribute('aria-label', length === 0 ? 'PIN empty' : `PIN has ${length} digits`);999}10001001function openPinPad(targetId, returnFocus = document.activeElement) {1002 if (connectionState.developmentMode) return;1003 const target = document.getElementById(targetId);1004 if (!(target instanceof HTMLInputElement) || target.disabled) return;1005 activePinInput = target;1006 pinPadReturnFocus = returnFocus;1007 renderPinPad();1008 document.getElementById('pin-pad-overlay').classList.remove('hidden');1009 setTimeout(() => document.querySelector('[data-pin-digit="1"]').focus(), 0);1010}10111012function closePinPad() {1013 document.getElementById('pin-pad-overlay').classList.add('hidden');1014 const returnFocus = pinPadReturnFocus;1015 activePinInput = null;1016 pinPadReturnFocus = null;1017 returnFocus?.focus();1018}10191020function pinPadDigit(digit) {1021 if (!activePinInput || !/^\d$/.test(digit)) return;1022 const limit = Number(activePinInput.maxLength) > 0 ? activePinInput.maxLength : 64;1023 if (activePinInput.value.length >= limit) return;1024 activePinInput.value += digit;1025 activePinInput.dispatchEvent(new Event('input', { bubbles: true }));1026 renderPinPad();1027}10281029function pinPadBackspace() {1030 if (!activePinInput || activePinInput.value.length === 0) return;1031 activePinInput.value = activePinInput.value.slice(0, -1);1032 activePinInput.dispatchEvent(new Event('input', { bubbles: true }));1033 renderPinPad();1034}10351036function pinPadClear() {1037 if (!activePinInput) return;1038 activePinInput.value = '';1039 activePinInput.dispatchEvent(new Event('input', { bubbles: true }));1040 renderPinPad();1041}10421043for (const id of ['enrollment-pin', 'intro-pin', 'pin-input']) {1044 document.getElementById(id).addEventListener('keydown', (event) => {1045 if (event.key !== 'Enter') return;1046 event.preventDefault();1047 if (id === 'enrollment-pin') void derivePortalIdentity();1048 else if (id === 'intro-pin') void introConnect();1049 else void connectHost();1050 });1051}10521053// ─── Gamepad polling, UI navigation, and latest-state routing ─────────────────1054function controllerScope() {1055 const tap = document.getElementById('tap-overlay');1056 if (!tap.classList.contains('hidden')) return tap;1057 const pinPad = document.getElementById('pin-pad-overlay');1058 if (!pinPad.classList.contains('hidden')) return pinPad;1059 const resetEnrollment = document.getElementById('reset-enrollment-overlay');1060 if (!resetEnrollment.classList.contains('hidden')) return resetEnrollment;1061 const invitation = document.getElementById('invitation-overlay');1062 if (!invitation.classList.contains('hidden')) return invitation;1063 const enrollment = document.getElementById('enrollment-overlay');1064 if (!enrollment.classList.contains('hidden')) return enrollment;1065 const intro = document.getElementById('intro');1066 if (!intro.classList.contains('hidden')) return intro;1067 const panel = document.getElementById('panel');1068 if (panel.classList.contains('visible')) return panel;1069 return document;1070}10711072function controllerElements() {1073 return [...controllerScope().querySelectorAll('[data-controller-focus]')].filter((element) => {1074 if (element.disabled || element.getAttribute('aria-disabled') === 'true') return false;1075 if (element.closest('.overlay-screen.hidden') || element.closest('.panel:not(.visible)')) return false;1076 const style = getComputedStyle(element);1077 return style.display !== 'none' && style.visibility !== 'hidden' && element.getClientRects().length > 0;1078 });1079}10801081function setControllerFocus(element) {1082 document.querySelectorAll('.controller-focus').forEach((item) => item.classList.remove('controller-focus'));1083 if (!element) return;1084 element.classList.add('controller-focus');1085 element.focus({ preventScroll: false });1086}10871088function navigateControllerFocus(direction) {1089 const elements = controllerElements();1090 if (elements.length === 0) return;1091 const currentIndex = elements.indexOf(document.activeElement);1092 const rects = elements.map((element) => element.getBoundingClientRect());1093 setControllerFocus(elements[chooseDirectionalIndex(rects, currentIndex, direction)]);1094}10951096function activateControllerFocus() {1097 const elements = controllerElements();1098 let target = elements.includes(document.activeElement) ? document.activeElement : elements[0];1099 if (!target) return;1100 setControllerFocus(target);1101 if (target.dataset.pinTarget) {1102 openPinPad(target.dataset.pinTarget, target);1103 } else {1104 controllerActivationInProgress = true;1105 try {1106 target.click();1107 } finally {1108 controllerActivationInProgress = false;1109 }1110 }1111}11121113function controllerBack() {1114 if (!document.getElementById('pin-pad-overlay').classList.contains('hidden')) {1115 closePinPad();1116 } else if (!document.getElementById('reset-enrollment-overlay').classList.contains('hidden')) {1117 cancelEnrollmentReset();1118 } else if (document.getElementById('panel').classList.contains('visible')) {1119 togglePanel(false);1120 }1121}11221123function updateControllerStatus(state) {1124 const status = document.getElementById('controller-status');1125 if (!state.connected) {1126 status.textContent = 'controller: none';1127 status.classList.remove('connected', 'warning');1128 return;1129 }1130 const standard = state.mapping === 'standard';1131 status.textContent = standard ? 'controller: ready' : 'controller: non-standard';1132 status.classList.toggle('connected', standard);1133 status.classList.toggle('warning', !standard);1134}11351136controllerRuntime = createControllerRuntime({1137 schedulePoll: (callback) => requestAnimationFrame(callback),1138 isRemoteRoute: () => connectionState.connected1139 && controlRuntime.active1140 && connectionState.inputCapabilities.gamepad,1141 sendRemoteState: (state) => {1142 if (!controlRuntime.acceptsControllerInput(state)) return;1143 inputRuntime.send({ t: 'gp', state });1144 },1145 onNavigate: navigateControllerFocus,1146 onActivate: activateControllerFocus,1147 onBack: controllerBack,1148 onStatus: updateControllerStatus,1149 onExit: () => {1150 controlRuntime.exit();1151 setControllerFocus(document.getElementById('control-toggle'));1152 },1153});11541155document.addEventListener('pointerdown', () => {1156 document.querySelectorAll('.controller-focus').forEach((item) => item.classList.remove('controller-focus'));1157}, { capture: true });11581159window.addEventListener('resize', () => {1160 windowRuntime.sizeSurfaceToIncomingStream();1161 renderRemotePointer();1162});11631164window.addEventListener('focus', () => {1165 if (!controlRuntime.active || !connectionState.inputCapabilities.relativePointer) return;1166 // WebKit rebuilds its native cursor rectangles after the window-level focus1167 // event. Reassert from the webview on the next frame so the final rectangle1168 // is the transparent one used during relative control.1169 requestAnimationFrame(() => {1170 if (!controlRuntime.active || !connectionState.inputCapabilities.relativePointer) return;1171 void controlRuntime.reassertNativeGrab().catch((error) => {1172 console.warn('native cursor re-hide after focus failed:', error);1173 });1174 });1175});11761177document.addEventListener('keydown', () => {1178 document.querySelectorAll('.controller-focus').forEach((item) => item.classList.remove('controller-focus'));1179}, { capture: true });11801181listen('fido-done', () => {1182 document.getElementById('tap-overlay-title').textContent = 'connecting';1183 document.getElementById('tap-overlay-desc').textContent = 'Key recognised. Waiting for host to respond.';1184 document.getElementById('tap-status').textContent = 'please wait...';1185});11861187listen('dev-connect-routing', (event) => {1188 console.warn('[development direct-node routing]', event.payload.warning, event.payload.host_node_id);1189});11901191listen('invitation-pending', (event) => {1192 try { showInvitationSummary(event.payload); }1193 catch (error) { console.error('invalid native invitation summary:', error); }1194});11951196// ─── Expose to HTML onclick handlers ─────────────────────────────────────────1197Object.assign(window, {1198 introConnect, connectHost, disconnect,1199 scanFido, togglePanel, toggleControl, toggleAudioMute,1200 openPinPad, closePinPad, pinPadDigit, pinPadBackspace, pinPadClear,1201 openEnrollmentReset, cancelEnrollmentReset, confirmEnrollmentReset,1202 derivePortalIdentity, chooseInvitationFile, confirmInvitation, cancelInvitation,1203 sigilController: Object.freeze({1204 getLatestState: () => controllerRuntime.latest,1205 setObserver: controllerRuntime.setObserver,1206 }),1207});12081209// ─── Init ─────────────────────────────────────────────────────────────────────1210async function initialize() {1211 try {1212 await checkDevelopmentConnectionMode();1213 } catch (e) {1214 console.error('development connection mode check failed:', e);1215 }1216 updatePanelVisibility();1217 updateControlUI();1218 try {1219 await refreshEnrollment();1220 } catch (error) {1221 console.error('enrollment status check failed:', error);1222 showEnrollment();1223 const status = document.getElementById('enrollment-status');1224 status.className = 'overlay-status err';1225 status.textContent = 'could not load enrollment';1226 }1227 if (!connectionState.developmentMode) scanFido();1228 requestAnimationFrame(controllerRuntime.poll);1229}12301231initialize();