cat / goq
clone

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

fix/drop-hero-notarization-note
goq / portal / main.js
main.js 59.5K
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 { createSessionState } from './session-state.mjs';13import { createFocusRuntime } from './focus-runtime.mjs';14import {15 focusOverlayStillCurrent,16 handoffProposalForSelf,17 preemptionCandidateForSelf,18} from './focus-overlay.mjs';19import { renderRoster, rosterPresentation } from './roster-ui.mjs';20import { formatVideoDiscardTelemetry } from './frame-stats.mjs';21import { networkDiagnosticsPresentation } from './network-diagnostics.mjs';22import {23 createPointerFeedbackRuntime,24 newPointerSession,25} from './pointer-feedback.mjs';26import { newConnectionState } from './connection-state.mjs';27import { formatSignedMilliseconds } from './av-sync.mjs';28import { audioButtonPresentation } from './audio-ui.mjs';29import {30 AUDIO_SAMPLE_RATE,31 createAudioPipelineSession,32} from './audio-pipeline.mjs';33import {34 chooseDirectionalIndex,35} from './controller-state.mjs';36import { createControllerRuntime } from './controller-runtime.mjs';37import { createWindowRuntime } from './window-runtime.mjs';38import {39 createVideoPipelineSession,40} from './video-pipeline.mjs';41import { detectAndPublishVideoDeliveryMode } from './video-capability.mjs';42import {43 formatAdaptiveDecision,44} from './adaptive-feedback.mjs';45import { createStreamRuntime } from './stream-runtime.mjs';46import {47 formatInvitationExpiry,48 grantLabel,49 normalizeInvitationSummary,50 shortPeerFingerprint,51} from './enrollment.mjs';52import { keyboardInputForEvent } from './keyboard-map.mjs';5354let enrollmentReady = false;55let pendingInvitationSummary = null;56let currentEnrollmentStatus = null;57let activePointerSession = null;58let controllerActivationInProgress = false;59let controlRuntime = null;60let controllerRuntime = null;61let sessionStateRuntime = null;62let focusRuntime = null;63let activeFocusOverlay = null;6465const invoke = (...args) => window.__TAURI__.core.invoke(...args);66const listen = (...args) => window.__TAURI__.event.listen(...args);67const pointerFeedbackRuntime = createPointerFeedbackRuntime({68 getActiveSession: () => activePointerSession,69 getCapabilities: () => ({70 connected: connectionState.connected,71 pointerPositionFeedback: connectionState.inputCapabilities.pointerPositionFeedback,72 }),73 getSurface: currentPointerSurfaceSize,74 render: renderRemotePointer,75 disconnect,76 logger: console,77});78const inputRuntime = createInputRuntime({79 invokeCommand: invoke,80 getCapabilities: () => connectionState.inputCapabilities,81 isConnected: () => connectionState.connected,82 onFatal: () => { void disconnect(); },83 resetControllerActivation: () => controlRuntime?.resetControllerActivation(),84});85let videoPipeline = null;86let streamRuntime = null;87const audioPipeline = createAudioPipelineSession({88 invokeCommand: invoke,89 onUpdate: updateAudioUI,90 resetAvSync: (...args) => videoPipeline?.resetAvSync(...args),91 isDisconnecting: () => connectionState.disconnecting,92});9394function updatePanelVisibility() {95 const streamSection = document.getElementById('stream-section');96 streamSection.style.display = connectionState.connected ? '' : 'none';97}9899// ─── Status ──────────────────────────────────────────────────────────────────100function setStatus(state, text) {101 const line = document.getElementById('status-line');102 const dot = document.getElementById('status-dot');103 const txt = document.getElementById('status-text');104 line.className = 'status-line ' + state;105 dot.className = 'dot ' + state;106 txt.textContent = text;107}108109function showIntro() {110 document.getElementById('intro').classList.remove('hidden');111 setTimeout(() => {112 const target = connectionState.developmentMode113 ? document.getElementById('intro-connect')114 : document.getElementById('intro-pin');115 target?.focus();116 }, 50);117}118function hideIntro() { document.getElementById('intro').classList.add('hidden'); }119function showTap() {120 document.getElementById('tap-overlay-title').textContent = 'tap key';121 document.getElementById('tap-overlay-desc').textContent = 'Touch the sensor on your key when it blinks.';122 document.getElementById('tap-status').textContent = 'waiting...';123 document.getElementById('tap-overlay').classList.remove('hidden');124}125function hideTap() { document.getElementById('tap-overlay').classList.add('hidden'); }126127function hideEnrollment() { document.getElementById('enrollment-overlay').classList.add('hidden'); }128function showEnrollment() {129 if (!document.getElementById('invitation-overlay').classList.contains('hidden')) return;130 hideIntro();131 document.getElementById('enrollment-overlay').classList.remove('hidden');132 setTimeout(() => document.getElementById('enrollment-pin')?.focus(), 50);133}134135function showInvitationSummary(value) {136 const summary = normalizeInvitationSummary(value);137 pendingInvitationSummary = summary;138 hideEnrollment();139 document.getElementById('invitation-host').textContent = summary.hostFingerprint;140 document.getElementById('invitation-peer').textContent = summary.peerFingerprint;141 document.getElementById('invitation-expiry').textContent = formatInvitationExpiry(summary.expiresAtUnix);142 const access = summary.viewOnly ? 'view only' : 'input-capable';143 document.getElementById('invitation-grants').textContent = `${access} · ${summary.grants.map(grantLabel).join(' · ')}`;144 document.getElementById('invitation-status').textContent = '';145 document.getElementById('invitation-overlay').classList.remove('hidden');146 setTimeout(() => setControllerFocus(document.getElementById('confirm-invitation')), 0);147}148149async function refreshEnrollment() {150 if (connectionState.developmentMode) {151 enrollmentReady = true;152 hideEnrollment();153 showIntro();154 return true;155 }156 const status = await invoke('portal_enrollment_status');157 currentEnrollmentStatus = status;158 enrollmentReady = status.enrolled === true;159 document.getElementById('reset-enrollment').classList.toggle('hidden', !enrollmentReady);160 document.getElementById('reset-enrollment-intro').classList.toggle('hidden', !enrollmentReady);161 if (status.pending) {162 showInvitationSummary(status.pending);163 return enrollmentReady;164 }165 if (enrollmentReady) {166 hideEnrollment();167 showIntro();168 } else {169 showEnrollment();170 }171 return enrollmentReady;172}173174function openEnrollmentReset() {175 if (!currentEnrollmentStatus?.enrolled || !currentEnrollmentStatus.host_node_id) return;176 togglePanel(false);177 document.getElementById('reset-enrollment-host').textContent =178 shortPeerFingerprint(currentEnrollmentStatus.host_node_id);179 document.getElementById('reset-enrollment-status').textContent = '';180 document.getElementById('reset-enrollment-overlay').classList.remove('hidden');181 setTimeout(() => setControllerFocus(document.getElementById('confirm-reset-enrollment')), 0);182}183184function cancelEnrollmentReset() {185 document.getElementById('reset-enrollment-overlay').classList.add('hidden');186}187188async function confirmEnrollmentReset() {189 const hostNodeId = currentEnrollmentStatus?.host_node_id;190 if (!hostNodeId) return;191 const status = document.getElementById('reset-enrollment-status');192 status.className = 'overlay-status pending';193 status.textContent = 'erasing enrollment';194 try {195 await invoke('portal_reset_enrollment', { expectedHostNodeId: hostNodeId });196 enrollmentReady = false;197 currentEnrollmentStatus = null;198 document.getElementById('reset-enrollment').classList.add('hidden');199 document.getElementById('reset-enrollment-intro').classList.add('hidden');200 document.getElementById('reset-enrollment-overlay').classList.add('hidden');201 showEnrollment();202 } catch (error) {203 status.className = 'overlay-status err';204 status.textContent = String(error);205 }206}207208async function derivePortalIdentity() {209 const pin = document.getElementById('enrollment-pin').value.trim();210 const status = document.getElementById('enrollment-status');211 if (!pin) {212 status.className = 'overlay-status err';213 status.textContent = 'enter pin';214 return;215 }216 status.className = 'overlay-status pending';217 status.textContent = 'tap the security key';218 showTap();219 try {220 const result = await invoke('key_derive_identity', { pin });221 if (result.error || !result.node_id) throw new Error(result.error || 'identity derivation failed');222 document.getElementById('enrollment-peer-id').textContent = result.node_id;223 document.getElementById('enrollment-peer-wrap').classList.remove('hidden');224 status.className = 'overlay-status ok';225 status.textContent = `portal ${shortPeerFingerprint(result.node_id)} ready for an invitation`;226 } catch (error) {227 status.className = 'overlay-status err';228 status.textContent = String(error);229 } finally {230 hideTap();231 }232}233234async function chooseInvitationFile() {235 const status = document.getElementById('enrollment-status');236 try {237 const path = await window.__TAURI__.dialog.open({238 multiple: false,239 directory: false,240 filters: [{ name: 'goq invitation', extensions: ['goq-invite'] }],241 });242 if (!path) return;243 const summary = await invoke('portal_import_invitation_file', { path });244 showInvitationSummary(summary);245 } catch (error) {246 status.className = 'overlay-status err';247 status.textContent = String(error);248 }249}250251async function confirmInvitation() {252 if (!pendingInvitationSummary) return;253 const status = document.getElementById('invitation-status');254 status.className = 'overlay-status pending';255 status.textContent = 'saving enrollment';256 try {257 await invoke('portal_confirm_invitation');258 pendingInvitationSummary = null;259 document.getElementById('invitation-overlay').classList.add('hidden');260 await refreshEnrollment();261 } catch (error) {262 status.className = 'overlay-status err';263 status.textContent = String(error);264 }265}266267async function cancelInvitation() {268 try { await invoke('portal_cancel_invitation'); } catch (error) { console.warn(error); }269 pendingInvitationSummary = null;270 document.getElementById('invitation-overlay').classList.add('hidden');271 showEnrollment();272}273274async function checkDevelopmentConnectionMode() {275 const mode = await invoke('development_connection_mode');276 connectionState.setDevelopmentMode(mode.enabled);277 if (!mode.enabled) return;278279 const badge = document.getElementById('dev-connect-badge');280 badge.classList.remove('hidden');281 badge.title = mode.warning;282 document.getElementById('dev-connect-warning').classList.remove('hidden');283 document.getElementById('intro-desc').textContent = 'development direct-node routing · passkey lookup skipped · not client authorization';284285 for (const id of ['intro-pin', 'pin-input']) {286 const input = document.getElementById(id);287 input.value = '';288 input.disabled = true;289 input.style.display = 'none';290 }291 document.getElementById('controller-pin-intro').style.display = 'none';292 document.getElementById('controller-pin-main').style.display = 'none';293 document.getElementById('pin-label').classList.add('hidden');294}295296// ─── Intro actions ────────────────────────────────────────────────────────────297async function introConnect() {298 if (connectionState.connecting || connectionState.connected) return;299 if (!connectionState.developmentMode && !enrollmentReady) {300 showEnrollment();301 return;302 }303 const pin = document.getElementById('intro-pin').value.trim();304 const status = document.getElementById('intro-status');305 if (!connectionState.developmentMode && !pin) {306 status.className = 'overlay-status err';307 status.textContent = 'enter pin';308 return;309 }310 hideIntro();311 if (!connectionState.developmentMode) showTap();312 document.getElementById('pin-input').value = pin;313 await connectHost();314 if (!connectionState.developmentMode) hideTap();315 if (!connectionState.connected) {316 showIntro();317 status.className = 'overlay-status err';318 status.textContent = 'connection failed';319 }320}321322// ─── Client ───────────────────────────────────────────────────────────────────323function updateAudioUI() {324 const session = audioPipeline.session;325 const toggle = document.getElementById('audio-toggle');326 const status = document.getElementById('stream-audio-state');327 const webviewReceived = document.getElementById('stream-audio-webview-received-packets');328 const transportMissing = document.getElementById('stream-audio-transport-missing-packets');329 const ipcDropped = document.getElementById('stream-audio-ipc-dropped-packets');330 const decoderDropped = document.getElementById('stream-audio-decoder-dropped-packets');331 const pcmHandoffDropped = document.getElementById('stream-audio-pcm-handoff-dropped-blocks');332 const ringOverflow = document.getElementById('stream-audio-ring-overflow-ms');333 const ringRecoveryDiscarded = document.getElementById('stream-audio-ring-recovery-discarded-ms');334 const ringBuffered = document.getElementById('stream-audio-ring-buffer-ms');335 const underruns = document.getElementById('stream-audio-underruns');336 const silence = document.getElementById('stream-audio-silence-ms');337 if (toggle) {338 const presentation = audioButtonPresentation({339 available: session.available,340 muted: session.muted,341 state: session.state,342 detail: session.stateDetail,343 });344 toggle.textContent = presentation.label;345 toggle.classList.toggle('active', session.available && !session.muted);346 toggle.classList.toggle('disabled', !session.available);347 toggle.setAttribute('aria-disabled', session.available ? 'false' : 'true');348 toggle.setAttribute('aria-label', presentation.ariaLabel);349 toggle.title = presentation.title;350 }351 if (status) {352 status.textContent = session.muted && session.available ? 'muted' : session.state;353 status.title = session.stateDetail;354 }355 if (webviewReceived) webviewReceived.textContent = `${session.packetsReceived} packets`;356 if (transportMissing) transportMissing.textContent = `${session.transportDropped} packets`;357 if (ipcDropped) ipcDropped.textContent = `${session.frontendDropped} packets`;358 if (decoderDropped) decoderDropped.textContent = `${session.decoderDropped} packets`;359 if (pcmHandoffDropped) {360 pcmHandoffDropped.textContent = `${session.messageTracker.droppedMessages} blocks`;361 }362 if (ringOverflow) {363 ringOverflow.textContent = `${(session.workletDroppedFrames * 1000 / AUDIO_SAMPLE_RATE).toFixed(1)} ms`;364 }365 if (ringRecoveryDiscarded) {366 ringRecoveryDiscarded.textContent = `${(session.workletRecoveryDiscardedFrames * 1000 / AUDIO_SAMPLE_RATE).toFixed(1)} ms`;367 }368 if (ringBuffered) {369 ringBuffered.textContent = `${(session.bufferedFrames * 1000 / AUDIO_SAMPLE_RATE).toFixed(1)} ms`;370 }371 if (underruns) {372 underruns.textContent = `${session.underflows} events / ${(session.underflowDurationMicros / 1000).toFixed(1)} ms`;373 }374 if (silence) silence.textContent = `${(session.silentDurationMicros / 1000).toFixed(1)} ms`;375}376377async function toggleAudioMute() {378 await audioPipeline.toggleMute();379}380381async function createConnectionAttempt({ createChannel: Channel }) {382 // Reset before opening the channel so early frames cannot race a383 // post-connect reset and disappear from the live diagnostics.384 streamRuntime.prepareConnection();385 if (typeof Channel !== 'function') throw new Error('Tauri binary channels are unavailable');386 const audioSession = audioPipeline.beginAttempt();387 const frameSession = streamRuntime.openFrameSession();388 const pointerSession = newPointerSession();389 activePointerSession = pointerSession;390 const audioAttempt = await audioPipeline.createAttemptChannel(Channel, audioSession);391 const frameChannel = new Channel(392 (message) => streamRuntime.handleBinaryFrame(message, frameSession),393 );394 pointerSession.channel = new Channel(395 (message) => pointerFeedbackRuntime.handleMessage(message, pointerSession),396 );397 return {398 audioSession: audioAttempt.session,399 frameSession,400 pointerSession,401 audioSupport: audioAttempt.support,402 connectionArgs: {403 frameChannel,404 audioChannel: audioAttempt.channel,405 pointerChannel: pointerSession.channel,406 audioSupported: audioAttempt.support.supported,407 },408 };409}410411function validateConnectedAttempt(result, attempt) {412 if (attempt.pointerSession.failed) {413 throw new Error(attempt.pointerSession.failureDetail || 'host returned invalid pointer-position feedback');414 }415 if (attempt.frameSession.failed) {416 throw new Error(attempt.frameSession.failureDetail || 'host returned invalid frame data');417 }418 attempt.pointerSession.surfaceDimensions = result.connected419 ? validatePointerSurfaceDimensions(result.pointer_surface_dimensions)420 : null;421 attempt.pointerFeedbackAvailable = result.connected422 && result.pointer_position_feedback_available === true;423 if (attempt.pointerFeedbackAvailable && attempt.pointerSession.surfaceDimensions === null) {424 throw new Error('host offered pointer feedback without a native pointer surface');425 }426 attempt.initialPointerFeedback = attempt.pointerFeedbackAvailable && attempt.pointerSession.received427 ? validatePointerPositionFeedback(428 attempt.pointerSession.latest,429 attempt.pointerSession.surfaceDimensions,430 )431 : null;432}433434function activateConnectedAttempt(result, attempt) {435 streamRuntime.activateFrameSession(attempt.frameSession, {436 generation: result.media_generation,437 adaptiveFeedbackAvailable: result.adaptive_feedback_available === true,438 adaptiveFeedbackError: result.adaptive_feedback_error,439 });440}441442function closeConnectionAttempt(attempt, { committed = false } = {}) {443 streamRuntime.closeFrameSession(attempt?.frameSession);444 if (committed && attempt?.pointerSession) attempt.pointerSession.closing = true;445}446447async function teardownConnectionAttempt(attempt) {448 const pointerSession = attempt?.pointerSession ?? activePointerSession;449 if (activePointerSession === pointerSession) activePointerSession = null;450 if (pointerSession) pointerSession.channel = null;451 audioPipeline.releaseAttempt(attempt?.audioSession);452 await audioPipeline.teardown();453}454455function updateAcceptedConnectionState({ result, attempt, state }) {456 const session = attempt.pointerSession;457 if (!state.inputCapabilities.pointerPositionFeedback) {458 session.remotePosition = null;459 session.remoteVisible = false;460 } else if (attempt.initialPointerFeedback !== null) {461 session.remotePosition = attempt.initialPointerFeedback.position;462 session.remoteVisible = attempt.initialPointerFeedback.pointer_visible;463 }464 sessionStateRuntime.acceptConnection({465 nativeGeneration: result.media_generation,466 controlProtocol: connectionState.controlProtocol,467 initialSnapshot: result.session_snapshot ?? null,468 eligible: state.inputCapabilities.control,469 });470 focusRuntime.reset();471 controlRuntime.resetAcceptedConnection();472}473474async function showConnectedAttempt({ result, attempt }) {475 await audioPipeline.acceptConnectedResult(result, {476 session: attempt.audioSession,477 support: attempt.audioSupport,478 });479 updatePanelVisibility();480 document.getElementById('node-id-text').textContent = result.host_node_id.substring(0, 16) + '...';481 document.getElementById('frame-canvas').style.display = 'block';482 document.getElementById('placeholder').classList.add('hidden');483 document.getElementById('control-bar').classList.add('visible');484 windowRuntime.sizeSurfaceToIncomingStream();485 updateControlUI();486 // Land controller users on the deliberate view-only/control boundary.487 // The connect button is hidden at this point, so retaining its focus488 // would make the next A press fall back to an unrelated top-bar action.489 setTimeout(() => setControllerFocus(document.getElementById('control-toggle')), 0);490 document.getElementById('action-connect').style.display = 'none';491 document.getElementById('action-disconnect').textContent = 'disconnect';492 document.getElementById('action-disconnect').classList.remove('hidden');493 document.getElementById('pin-label').style.display = 'none';494 document.getElementById('pin-input').style.display = 'none';495 document.getElementById('controller-pin-main').style.display = 'none';496}497498async function prepareDisconnect() {499 streamRuntime.prepareDisconnect();500 // Give release commands a short, finite opportunity to cross the bounded501 // Tauri queue before closing it. A broken stream cannot stall disconnect.502 await controlRuntime.prepareDisconnect(250);503}504505async function teardownConnectedResources() {506 const pointerSession = activePointerSession;507 activePointerSession = null;508 if (pointerSession) pointerSession.channel = null;509 await audioPipeline.teardown();510 streamRuntime.teardown();511}512513async function showDisconnectedState() {514 focusRuntime.reset();515 sessionStateRuntime.reset();516 controlRuntime.resetDisconnected();517 updatePanelVisibility();518 setStatus('', 'idle');519 document.getElementById('frame-canvas').style.display = 'none';520 document.getElementById('placeholder').classList.remove('hidden');521 document.getElementById('placeholder').textContent = 'no stream';522 document.getElementById('control-bar').classList.remove('visible');523 document.getElementById('action-connect').style.display = '';524 document.getElementById('action-disconnect').classList.add('hidden');525 if (!connectionState.developmentMode) {526 document.getElementById('pin-label').style.display = '';527 document.getElementById('pin-input').style.display = '';528 document.getElementById('controller-pin-main').style.display = '';529 }530 document.getElementById('intro-status').textContent = '';531}532533const connectionState = newConnectionState({534 invokeCommand: invoke,535 createChannel: window.__TAURI__.core.Channel,536 beforeConnect: audioPipeline.prepareForConnection,537 createAttempt: createConnectionAttempt,538 validateConnectedResult: validateConnectedAttempt,539 activateAttempt: activateConnectedAttempt,540 closeAttempt: closeConnectionAttempt,541 teardownAttempt: teardownConnectionAttempt,542 beforeDisconnect: prepareDisconnect,543 teardownConnection: teardownConnectedResources,544 onStatus: (state, detail, context) => {545 if (state === 'ok') updateAcceptedConnectionState(context);546 setStatus(state, detail);547 },548 onConnected: showConnectedAttempt,549 onDisconnected: showDisconnectedState,550 onFailure: ({ error }) => console.error('connection failed:', error),551 onDisconnectError: (error) => console.error(error),552 onNativeDisconnectError: (error) => console.error('failed to close rejected connection:', error),553});554555async function connectHost() {556 await connectionState.connect({ pin: document.getElementById('pin-input').value });557}558559async function disconnect() {560 await connectionState.disconnect();561}562563// ─── FIDO scan ────────────────────────────────────────────────────────────────564async function scanFido() {565 try {566 const info = await invoke('fido_device_info');567 const el = document.getElementById('fido-status');568 if (!info.found) {569 el.textContent = 'no device';570 el.style.color = 'var(--muted-fg)';571 return;572 }573 el.textContent = info.error ? `found (${info.error})` : 'connected';574 el.style.color = info.error ? 'var(--yellow)' : 'var(--green)';575 document.getElementById('fido-product').textContent = info.product || '—';576 document.getElementById('fido-vidpid').textContent =577 `${info.vid.toString(16).padStart(4,'0')}:${info.pid.toString(16).padStart(4,'0')}`;578 document.getElementById('fido-versions').textContent = info.versions.join(', ') || '—';579 document.getElementById('fido-extensions').textContent = info.extensions.join(', ') || '—';580 document.getElementById('fido-pin').textContent = info.pin_retries ?? '—';581 } catch (e) {582 console.error('scanFido failed:', e);583 document.getElementById('fido-status').textContent = 'error';584 }585}586587// ─── Panel ────────────────────────────────────────────────────────────────────588function togglePanel(force) {589 const panel = document.getElementById('panel');590 const open = typeof force === 'boolean' ? force : !panel.classList.contains('visible');591 panel.classList.toggle('visible', open);592 document.getElementById('panel-overlay').classList.toggle('visible', open);593 if (open) setTimeout(() => document.getElementById('panel-close').focus(), 0);594}595596function closeFocusOverlay() {597 activeFocusOverlay = null;598 document.getElementById('focus-handoff-overlay').classList.add('hidden');599 document.getElementById('focus-preempt-overlay').classList.add('hidden');600}601602function renderFocusOverlay() {603 const session = sessionStateRuntime?.snapshot();604 if (!session) return;605 const handoff = handoffProposalForSelf(session);606 if (handoff) {607 activeFocusOverlay = handoff;608 document.getElementById('focus-preempt-overlay').classList.add('hidden');609 document.getElementById('focus-handoff-requester').textContent = handoff.requester;610 document.getElementById('focus-handoff-overlay').classList.remove('hidden');611 setTimeout(() => setControllerFocus(document.getElementById('deny-focus-handoff')), 0);612 return;613 }614 if (activeFocusOverlay && !focusOverlayStillCurrent(activeFocusOverlay, session)) {615 closeFocusOverlay();616 }617}618619function openFocusPreempt() {620 const candidate = preemptionCandidateForSelf(sessionStateRuntime.snapshot());621 if (!candidate) return false;622 activeFocusOverlay = candidate;623 document.getElementById('focus-preempt-holder').textContent = candidate.holder;624 document.getElementById('focus-preempt-overlay').classList.remove('hidden');625 setTimeout(() => setControllerFocus(document.getElementById('cancel-focus-preempt')), 0);626 return true;627}628629function cancelFocusPreempt() {630 if (activeFocusOverlay?.type !== 'preempt') return;631 closeFocusOverlay();632 setControllerFocus(document.getElementById('control-toggle'));633}634635async function approveFocusHandoff() {636 if (activeFocusOverlay?.type !== 'handoff'637 || !focusOverlayStillCurrent(activeFocusOverlay, sessionStateRuntime.snapshot())) return false;638 controlRuntime.exit({ releaseHostFocus: false, resetEscape: true });639 await inputRuntime.drain(250);640 closeFocusOverlay();641 return focusRuntime.approve();642}643644async function denyFocusHandoff() {645 if (activeFocusOverlay?.type !== 'handoff'646 || !focusOverlayStillCurrent(activeFocusOverlay, sessionStateRuntime.snapshot())) return false;647 closeFocusOverlay();648 return focusRuntime.deny();649}650651async function confirmFocusPreempt() {652 if (activeFocusOverlay?.type !== 'preempt'653 || !focusOverlayStillCurrent(activeFocusOverlay, sessionStateRuntime.snapshot())) return false;654 closeFocusOverlay();655 return focusRuntime.preempt({ controllerInitiated: controllerActivationInProgress });656}657658// ─── WebCodecs detection ──────────────────────────────────────────────────────659// Finish the codec probe and publish the result to Rust before exposing any660// connect handlers. Rust refuses to connect when WebCodecs is unavailable, so661// the pipeline below can assume binary WebCodecs delivery.662await detectAndPublishVideoDeliveryMode({ invokeCommand: invoke });663664// ─── Frame decoding ───────────────────────────────────────────────────────────665const canvas = document.getElementById('frame-canvas');666const remotePointer = document.getElementById('remote-pointer');667const ctx = canvas.getContext('2d');668const windowRuntime = createWindowRuntime({669 getFormat: () => videoPipeline.format,670 getSurfaceBounds: () => {671 const main = canvas.parentElement;672 if (!main) return null;673 const bounds = main.getBoundingClientRect();674 return { width: bounds.width, height: bounds.height };675 },676 setSurfaceSize: ({ width, height }) => {677 canvas.style.width = `${width}px`;678 canvas.style.height = `${height}px`;679 },680});681controlRuntime = createControlRuntime({682 getConnection: () => ({683 connected: connectionState.connected,684 disconnecting: connectionState.disconnecting,685 capabilities: {686 ...connectionState.inputCapabilities,687 control: connectionState.inputCapabilities.control688 && (connectionState.controlProtocol !== 'control-v2' || sessionStateRuntime?.focused === true),689 },690 }),691 inputRuntime,692 invokeCursorGrab: (grab) => invoke('set_client_cursor_grab', { grab }),693 pointerLock: {694 target: canvas,695 getOwner: () => document.pointerLockElement,696 request: () => {697 if (typeof canvas.requestPointerLock !== 'function') {698 throw new Error('browser Pointer Lock is unavailable');699 }700 return canvas.requestPointerLock();701 },702 exit: () => {703 if (typeof document.exitPointerLock === 'function') document.exitPointerLock();704 },705 eventTarget: document,706 },707 publishController: () => controllerRuntime?.publishCurrentState(),708 resetControllerEscape: () => controllerRuntime?.resetEscape(),709 onChange: () => {710 sessionStateRuntime?.setLocallyActive(controlRuntime.active);711 updateControlUI();712 },713 onExit: () => {714 if (connectionState.controlProtocol === 'control-v2') {715 return focusRuntime?.release().catch((error) => {716 console.error('focus release failed:', error);717 return false;718 });719 }720 return false;721 },722 onReleaseFailure: () => setStatus('err', 'cursor release failed · quit app'),723});724sessionStateRuntime = createSessionState({725 onFocusLoss: () => controlRuntime.exit({ releaseHostFocus: false, resetEscape: true }),726 onChange: () => updateSessionUI(),727});728focusRuntime = createFocusRuntime({729 invokeCommand: invoke,730 getSession: () => sessionStateRuntime.snapshot(),731 startFocusedInput: async () => {732 const capabilities = await invoke('iroh_client_start_focused_input');733 connectionState.updateInputCapabilities(capabilities);734 },735 activateLocalControl: ({ controllerInitiated }) => controlRuntime.toggle({ controllerInitiated }),736 onChange: () => updateControlUI(),737});738videoPipeline = createVideoPipelineSession({739 canvas,740 context: ctx,741 requestKeyframe: (reason) => streamRuntime?.requestKeyframe(reason),742 resetAudioSync: audioPipeline.resetSyncTelemetry,743 sampleAudioSkew: audioPipeline.sampleSkew,744 onFormatChanged: () => {745 windowRuntime.sizeSurfaceToIncomingStream();746 },747});748streamRuntime = createStreamRuntime({749 invokeCommand: invoke,750 videoPipeline,751 isConnected: () => connectionState.connected,752 disconnect,753});754755function formatCadence(summary) {756 if (!summary.count) return '—';757 return `${summary.p50.toFixed(1)} / ${summary.p95.toFixed(1)} / ${summary.p99.toFixed(1)} / ${summary.max.toFixed(1)} ms · >25/>33 ${summary.over25Ms}/${summary.over33Ms}`;758}759760function formatRustDurationSummary(summary) {761 if (!summary) return '—';762 if (!summary.count) return '— · 0 samples';763 return `${summary.p50Ms.toFixed(2)} / ${summary.p95Ms.toFixed(2)} / ${summary.maxMs.toFixed(2)} ms · ${summary.count} samples`;764}765766function updateStreamStats() {767 const now = performance.now();768 const {769 video,770 transportDroppedFrames,771 transportObjectDroppedFrames,772 transportLateObjectDroppedFrames,773 frontendDroppedFrames,774 frontendQueueDroppedFrames,775 frontendResyncDroppedFrames,776 frontendQueueStats,777 frontendResyncStats,778 transportIntervalStats,779 frontendIpcSendDurationStats,780 rustTimingWindow,781 networkDiagnostics,782 lastTransportFps,783 lastFrontendSendFps,784 streamPathMode,785 streamRttMs,786 adaptiveFeedbackAvailable,787 adaptiveFeedbackError,788 adaptiveDecision,789 } = streamRuntime.snapshot(now);790 const networkPresentation = networkDiagnosticsPresentation(networkDiagnostics);791 document.getElementById('stream-received').textContent = video.receivedFrames;792 document.getElementById('stream-decoder-input').textContent = video.decoderInputFrames;793 document.getElementById('stream-decoder-output').textContent = video.decoderOutputFrames;794 document.getElementById('stream-presented').textContent = video.presentedFrames;795 document.getElementById('stream-decode-queue').textContent = video.decoderQueueDepth;796 document.getElementById('stream-present-queue').textContent = video.presenterQueueDepth;797 const discardTelemetry = formatVideoDiscardTelemetry({798 transportDroppedFrames,799 frontendDroppedFrames,800 decoderDroppedFrames: video.droppedFrames,801 presenterOverwrittenFrames: video.presentationDroppedFrames,802 });803 document.getElementById('stream-dropped').textContent = discardTelemetry.total;804 document.getElementById('stream-transport-dropped').textContent = discardTelemetry.transport;805 document.getElementById('stream-transport-object-dropped').textContent =806 transportObjectDroppedFrames === null ? '—' : `${transportObjectDroppedFrames} frames`;807 document.getElementById('stream-transport-late-object-dropped').textContent =808 transportLateObjectDroppedFrames === null809 ? '—' : `${transportLateObjectDroppedFrames} frames`;810 document.getElementById('stream-frontend-dropped').textContent = discardTelemetry.frontend;811 document.getElementById('stream-frontend-queue-dropped').textContent = frontendQueueDroppedFrames === null812 ? '—' : `${frontendQueueDroppedFrames} frames`;813 document.getElementById('stream-frontend-resync-dropped').textContent = frontendResyncDroppedFrames === null814 ? '—' : `${frontendResyncDroppedFrames} frames`;815 document.getElementById('stream-frontend-queue').textContent = frontendQueueStats === null816 ? '—'817 : `${frontendQueueStats.depth} current / ${frontendQueueStats.peak} peak / ${frontendQueueStats.capacity} frames capacity`;818 document.getElementById('stream-frontend-resync').textContent = frontendResyncStats === null819 ? '—'820 : `${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`;821 document.getElementById('stream-transport-interval').textContent = formatRustDurationSummary(transportIntervalStats);822 document.getElementById('stream-ipc-send-duration').textContent = formatRustDurationSummary(frontendIpcSendDurationStats);823 document.getElementById('stream-rust-timing-window').textContent = rustTimingWindow === null824 ? '—'825 : `${rustTimingWindow.windowMs.toFixed(1)} ms / ${rustTimingWindow.sampleCapacity} samples`;826 document.getElementById('stream-decoder-dropped').textContent = discardTelemetry.decoder;827 document.getElementById('stream-present-dropped').textContent = discardTelemetry.presenterOverwrite;828 document.getElementById('stream-transport-fps').textContent = lastTransportFps.toFixed(1);829 document.getElementById('stream-ipc-send-fps').textContent = lastFrontendSendFps.toFixed(1);830 document.getElementById('stream-frontend-fps').textContent = video.frontendDeliveryFps.toFixed(1);831 document.getElementById('stream-decode-fps').textContent = video.decodeFps.toFixed(1);832 document.getElementById('stream-decoder-output-fps').textContent = video.decoderOutputFps.toFixed(1);833 document.getElementById('stream-present-fps').textContent = video.presentFps.toFixed(1);834 document.getElementById('stream-av-skew').textContent = video.avSkewPercentiles.count835 ? `${formatSignedMilliseconds(video.avSkewPercentiles.p50)} / ${formatSignedMilliseconds(video.avSkewPercentiles.p95)} / ${video.avSkewPercentiles.maxAbsolute.toFixed(1)} ms`836 : '—';837 document.getElementById('stream-decode-latency').textContent = video.decodePercentiles.count838 ? `${video.decodePercentiles.p50.toFixed(1)} / ${video.decodePercentiles.p95.toFixed(1)} ms`839 : '—';840 document.getElementById('stream-present-latency').textContent = video.presentPercentiles.count841 ? `${video.presentPercentiles.p50.toFixed(1)} / ${video.presentPercentiles.p95.toFixed(1)} ms`842 : '—';843 document.getElementById('stream-draw-latency').textContent = video.drawPercentiles.count844 ? `${video.drawPercentiles.p50.toFixed(2)} / ${video.drawPercentiles.p95.toFixed(2)} / ${video.drawPercentiles.p99.toFixed(2)} / ${video.drawPercentiles.max.toFixed(2)} ms`845 : '—';846 document.getElementById('stream-delivery-cadence').textContent = formatCadence(video.deliveryCadence);847 document.getElementById('stream-decoder-input-cadence').textContent = formatCadence(video.decoderInputIntervals);848 document.getElementById('stream-decoder-output-cadence').textContent = formatCadence(video.decoderOutputIntervals);849 document.getElementById('stream-present-cadence').textContent = formatCadence(video.presentationIntervals);850 document.getElementById('stream-codec').textContent = video.activeCodec;851 document.getElementById('stream-transport').textContent = connectionState.mediaTransport;852 document.getElementById('stream-path').textContent = streamPathMode;853 document.getElementById('stream-rtt').textContent = Number.isFinite(streamRttMs)854 ? `${streamRttMs.toFixed(1)} ms`855 : '— ms';856 document.getElementById('stream-network-session').textContent = networkPresentation.session;857 document.getElementById('stream-network-media').textContent = networkPresentation.media;858 document.getElementById('stream-network-input').textContent = networkPresentation.input;859 document.getElementById('stream-network-audio').textContent = networkPresentation.audio;860 document.getElementById('stream-network-input-ack').textContent = networkPresentation.inputAck;861 document.getElementById('stream-adaptive-feedback').textContent = adaptiveFeedbackAvailable862 ? `authenticated · 1 Hz bounded reports · ${networkPresentation.adaptive}`863 : adaptiveFeedbackError || 'unavailable';864 document.getElementById('stream-adaptive-decision').textContent = formatAdaptiveDecision(865 adaptiveDecision,866 adaptiveFeedbackAvailable,867 );868869 streamRuntime.publishAdaptiveFeedback(video);870}871872setInterval(() => {873 if (connectionState.connected) updateStreamStats();874}, 250);875876listen('frame-stats', (event) => {877 if (streamRuntime.handleFrameStats(event.payload)) updateStreamStats();878});879880listen('adaptive-bitrate-decision', (event) => {881 if (streamRuntime.handleAdaptiveDecision(event.payload)) updateStreamStats();882});883884listen('adaptive-feedback-state', (event) => {885 if (streamRuntime.handleAdaptiveFeedbackState(event.payload)) updateStreamStats();886});887888listen('frame-error', (event) => {889 streamRuntime.handleFrameError(event.payload);890});891892listen('audio-state', (event) => {893 audioPipeline.handleNativeState(event.payload);894});895896listen('audio-stats', (event) => {897 audioPipeline.handleNativeStats(event.payload);898});899900// ─── Input ────────────────────────────────────────────────────────────────────901async function toggleControl() {902 // Capture synchronous controller provenance before cursor acquisition yields.903 // The DOM click dispatcher does not await this async handler.904 if (connectionState.controlProtocol === 'control-v2' && !sessionStateRuntime.focused) {905 if (preemptionCandidateForSelf(sessionStateRuntime.snapshot())) {906 openFocusPreempt();907 return;908 }909 await focusRuntime.request({ controllerInitiated: controllerActivationInProgress });910 updateControlUI();911 return;912 }913 await controlRuntime.toggle({ controllerInitiated: controllerActivationInProgress });914}915916function describeInputCapabilities() {917 return inputCapabilityLabel(connectionState.inputCapabilities);918}919920function pointerInputAvailable() {921 const capabilities = connectionState.inputCapabilities;922 return capabilities.relativePointer || capabilities.absolutePointer;923}924925function handleBrowserPointerLockChange() {926 controlRuntime.handleBrowserPointerLockChange();927}928929document.addEventListener('pointerlockchange', handleBrowserPointerLockChange);930931function currentPointerSurfaceSize() {932 const { width: frameWidth, height: frameHeight } = videoPipeline.format;933 return resolvePointerSurfaceSize(934 activePointerSession?.surfaceDimensions ?? null,935 frameWidth,936 frameHeight,937 connectionState.inputCapabilities.relativePointer,938 );939}940941function renderRemotePointer() {942 const session = activePointerSession;943 const surface = currentPointerSurfaceSize();944 const visible = controlRuntime.active945 && connectionState.inputCapabilities.relativePointer946 && connectionState.inputCapabilities.pointerPositionFeedback947 && session?.remotePosition !== null948 && session?.remoteVisible949 && surface !== null;950 remotePointer.classList.toggle('visible', visible);951 if (!visible) return;952 const canvasRect = canvas.getBoundingClientRect();953 const mainRect = canvas.parentElement.getBoundingClientRect();954 remotePointer.style.left = `${canvasRect.left - mainRect.left955 + (session.remotePosition.x / surface.width) * canvasRect.width}px`;956 remotePointer.style.top = `${canvasRect.top - mainRect.top957 + (session.remotePosition.y / surface.height) * canvasRect.height}px`;958}959960function updateControlUI() {961 const el = document.getElementById('control-toggle');962 const capabilities = connectionState.inputCapabilities;963 const available = connectionState.connected964 && capabilities.control965 && (connectionState.controlProtocol !== 'control-v2' || sessionStateRuntime.eligible);966 const controlling = controlRuntime.setInactiveIfUnavailable(available);967 const v2 = connectionState.controlProtocol === 'control-v2';968 const occupiedByAnother = v2969 && sessionStateRuntime.snapshot().snapshot?.focus?.state === 'held'970 && !sessionStateRuntime.focused;971 const focusPending = focusRuntime?.snapshot().pending === true;972 const ownerCanPreempt = preemptionCandidateForSelf(sessionStateRuntime.snapshot()) !== null;973 const controlAction = focusPending974 ? 'control request pending'975 : v2 && !sessionStateRuntime.focused976 ? ownerCanPreempt ? 'spectating · take control' : occupiedByAnother ? 'spectating · request control' : 'request control'977 : controlling ? 'release control' : 'activate control';978 el.textContent = available979 ? `${controlAction} · ${describeInputCapabilities()}${controlling && capabilities.relativePointer ? ' · Ctrl+Alt+Esc to exit' : controlling && capabilities.gamepad ? ' · hold Back+Start to exit' : ''}`980 : 'view only · input unavailable';981 el.classList.toggle('active', controlling);982 el.classList.toggle('disabled', !available);983 el.classList.toggle('hidden', v2 && !sessionStateRuntime.eligible);984 el.setAttribute('aria-disabled', available ? 'false' : 'true');985 document.body.classList.toggle(986 'native-pointer-control',987 controlling && capabilities.relativePointer,988 );989 canvas.classList.toggle(990 'relative-control',991 controlling && capabilities.relativePointer,992 );993 renderRemotePointer();994 const streamControl = document.getElementById('stream-control');995 if (streamControl) {996 streamControl.textContent = available ? describeInputCapabilities() : 'view only · unavailable';997 }998}9991000function updateSessionUI() {1001 updateControlUI();1002 renderFocusOverlay();1003 const viewerStatus = document.getElementById('viewer-status');1004 const rosterSection = document.getElementById('roster-section');1005 const rosterSummary = document.getElementById('roster-summary');1006 const rosterList = document.getElementById('roster-list');1007 const session = sessionStateRuntime.snapshot();1008 const visible = session.mode === 'control-v2' && session.snapshot !== null;1009 viewerStatus.classList.toggle('hidden', !visible);1010 rosterSection.classList.toggle('hidden', !visible);1011 if (!visible) {1012 viewerStatus.textContent = 'viewers: unavailable';1013 rosterSummary.textContent = 'Roster unavailable';1014 rosterList.replaceChildren();1015 return;1016 }1017 const presentation = rosterPresentation(session.snapshot);1018 viewerStatus.textContent = presentation.shell;1019 viewerStatus.setAttribute('aria-label', presentation.shell);1020 rosterSummary.textContent = `${presentation.count} connected · ${presentation.focus}`;1021 renderRoster(rosterList, session.snapshot);1022}10231024function scaleCoords(clientX, clientY) {1025 const rect = canvas.getBoundingClientRect();1026 return mapCanvasPointToSurface({1027 clientX,1028 clientY,1029 rect,1030 surface: currentPointerSurfaceSize(),1031 }) ?? { x: 0, y: 0 };1032}10331034window.addEventListener('mousemove', (e) => {1035 if (!controlRuntime.active || !pointerInputAvailable()) return;1036 if (connectionState.inputCapabilities.relativePointer) {1037 const movement = scaleRelativePointerDelta(1038 e.movementX,1039 e.movementY,1040 );1041 inputRuntime.send({ t: 'mr', ...movement });1042 return;1043 }1044}, { capture: true });10451046canvas.addEventListener('mousemove', (e) => {1047 if (!controlRuntime.active1048 || !connectionState.inputCapabilities.absolutePointer1049 || connectionState.inputCapabilities.relativePointer) return;1050 const { x, y } = scaleCoords(e.clientX, e.clientY);1051 inputRuntime.send({ t: 'mm', x, y });1052});10531054function handleMouseDown(e) {1055 if (!controlRuntime.active || !pointerInputAvailable()) return;1056 if (connectionState.inputCapabilities.relativePointer) e.stopPropagation();1057 else if (e.target !== canvas) return;1058 const btn = browserMouseButtonCode(e.button);1059 if (btn === null) return;1060 e.preventDefault();1061 if (connectionState.inputCapabilities.relativePointer && document.pointerLockElement !== canvas) {1062 void controlRuntime.requestBrowserPointerLock().catch((error) => {1063 console.warn('browser pointer lock retry failed:', error);1064 });1065 }1066 if (!inputRuntime.trackMouseButton(btn)) return;1067 if (!inputRuntime.send({ t: 'md', b: btn })) inputRuntime.takeMouseButtonRelease(btn);1068}10691070window.addEventListener('mousedown', handleMouseDown, { capture: true });10711072function releaseMouseButton(e) {1073 if (!connectionState.connected || !pointerInputAvailable()) return;1074 const btn = browserMouseButtonCode(e.button);1075 if (btn === null) return;1076 const release = inputRuntime.takeMouseButtonRelease(btn);1077 if (!release) return;1078 e.preventDefault();1079 inputRuntime.send(release, { release: true });1080}10811082window.addEventListener('mouseup', (e) => {1083 if (controlRuntime.active && connectionState.inputCapabilities.relativePointer) e.stopPropagation();1084 releaseMouseButton(e);1085}, { capture: true });10861087function suppressLocalClickDuringRelativeControl(e) {1088 if (!controlRuntime.active || !connectionState.inputCapabilities.relativePointer) return;1089 // mousedown/mouseup are forwarded to the host above, but WebKit synthesizes1090 // a separate click at the native cursor's frozen local position. Prevent1091 // that click from reaching the underlying Portal control that acquired the1092 // cursor, where it would immediately toggle controlling mode back off.1093 e.preventDefault();1094 e.stopImmediatePropagation();1095}10961097window.addEventListener('click', suppressLocalClickDuringRelativeControl, { capture: true });10981099window.addEventListener('contextmenu', (e) => {1100 if (!controlRuntime.active || !pointerInputAvailable()) return;1101 if (!connectionState.inputCapabilities.relativePointer && e.target !== canvas) return;1102 e.preventDefault();1103}, { capture: true });11041105window.addEventListener('wheel', (e) => {1106 if (!controlRuntime.active || !pointerInputAvailable()) return;1107 if (!connectionState.inputCapabilities.relativePointer && e.target !== canvas) return;1108 e.preventDefault();1109 if (connectionState.inputCapabilities.relativePointer) e.stopPropagation();1110 inputRuntime.send({ t: 'ms', dx: Math.round(e.deltaX), dy: Math.round(e.deltaY) });1111}, { passive: false, capture: true });11121113window.addEventListener('keydown', (e) => {1114 if (!controlRuntime.active) return;1115 if (e.key === 'Escape' && e.ctrlKey && e.altKey) {1116 e.preventDefault();1117 controlRuntime.exit();1118 return;1119 }1120 const input = keyboardInputForEvent(e, connectionState.inputCapabilities);1121 if (input === null) return;1122 e.preventDefault();1123 if (input.type === 'text') {1124 inputRuntime.send({ t: 'tx', s: input.text });1125 return;1126 }1127 const keyId = e.code || e.key;1128 const tracking = inputRuntime.trackKey(keyId, input.key);1129 if (tracking === 'repeat') return;1130 if (tracking === 'full') {1131 console.error('held key limit reached; refusing additional key transition');1132 return;1133 }1134 if (!inputRuntime.send({ t: 'kd', k: input.key })) inputRuntime.takeKeyRelease(keyId);1135});11361137window.addEventListener('keyup', (e) => {1138 const keyId = e.code || e.key;1139 const release = inputRuntime.takeKeyRelease(keyId);1140 if (!release) return;1141 e.preventDefault();1142 inputRuntime.send(release, { release: true });1143});11441145window.addEventListener('blur', () => {1146 if (!controlRuntime.active && !controlRuntime.transitioning) return;1147 controlRuntime.exit();1148});11491150document.addEventListener('visibilitychange', () => {1151 if (!document.hidden || (!controlRuntime.active && !controlRuntime.transitioning)) return;1152 controlRuntime.exit();1153});11541155// ─── Controller PIN entry ─────────────────────────────────────────────────────1156let activePinInput = null;1157let pinPadReturnFocus = null;11581159function renderPinPad() {1160 const display = document.getElementById('pin-pad-display');1161 const length = activePinInput?.value.length || 0;1162 display.textContent = length === 0 ? 'empty' : '•'.repeat(length);1163 display.setAttribute('aria-label', length === 0 ? 'PIN empty' : `PIN has ${length} digits`);1164}11651166function openPinPad(targetId, returnFocus = document.activeElement) {1167 if (connectionState.developmentMode) return;1168 const target = document.getElementById(targetId);1169 if (!(target instanceof HTMLInputElement) || target.disabled) return;1170 activePinInput = target;1171 pinPadReturnFocus = returnFocus;1172 renderPinPad();1173 document.getElementById('pin-pad-overlay').classList.remove('hidden');1174 setTimeout(() => document.querySelector('[data-pin-digit="1"]').focus(), 0);1175}11761177function closePinPad() {1178 document.getElementById('pin-pad-overlay').classList.add('hidden');1179 const returnFocus = pinPadReturnFocus;1180 activePinInput = null;1181 pinPadReturnFocus = null;1182 returnFocus?.focus();1183}11841185function pinPadDigit(digit) {1186 if (!activePinInput || !/^\d$/.test(digit)) return;1187 const limit = Number(activePinInput.maxLength) > 0 ? activePinInput.maxLength : 64;1188 if (activePinInput.value.length >= limit) return;1189 activePinInput.value += digit;1190 activePinInput.dispatchEvent(new Event('input', { bubbles: true }));1191 renderPinPad();1192}11931194function pinPadBackspace() {1195 if (!activePinInput || activePinInput.value.length === 0) return;1196 activePinInput.value = activePinInput.value.slice(0, -1);1197 activePinInput.dispatchEvent(new Event('input', { bubbles: true }));1198 renderPinPad();1199}12001201function pinPadClear() {1202 if (!activePinInput) return;1203 activePinInput.value = '';1204 activePinInput.dispatchEvent(new Event('input', { bubbles: true }));1205 renderPinPad();1206}12071208for (const id of ['enrollment-pin', 'intro-pin', 'pin-input']) {1209 document.getElementById(id).addEventListener('keydown', (event) => {1210 if (event.key !== 'Enter') return;1211 event.preventDefault();1212 if (id === 'enrollment-pin') void derivePortalIdentity();1213 else if (id === 'intro-pin') void introConnect();1214 else void connectHost();1215 });1216}12171218// ─── Gamepad polling, UI navigation, and latest-state routing ─────────────────1219function controllerScope() {1220 const tap = document.getElementById('tap-overlay');1221 if (!tap.classList.contains('hidden')) return tap;1222 const handoff = document.getElementById('focus-handoff-overlay');1223 if (!handoff.classList.contains('hidden')) return handoff;1224 const preempt = document.getElementById('focus-preempt-overlay');1225 if (!preempt.classList.contains('hidden')) return preempt;1226 const pinPad = document.getElementById('pin-pad-overlay');1227 if (!pinPad.classList.contains('hidden')) return pinPad;1228 const resetEnrollment = document.getElementById('reset-enrollment-overlay');1229 if (!resetEnrollment.classList.contains('hidden')) return resetEnrollment;1230 const invitation = document.getElementById('invitation-overlay');1231 if (!invitation.classList.contains('hidden')) return invitation;1232 const enrollment = document.getElementById('enrollment-overlay');1233 if (!enrollment.classList.contains('hidden')) return enrollment;1234 const intro = document.getElementById('intro');1235 if (!intro.classList.contains('hidden')) return intro;1236 const panel = document.getElementById('panel');1237 if (panel.classList.contains('visible')) return panel;1238 return document;1239}12401241function controllerElements() {1242 return [...controllerScope().querySelectorAll('[data-controller-focus]')].filter((element) => {1243 if (element.disabled || element.getAttribute('aria-disabled') === 'true') return false;1244 if (element.closest('.overlay-screen.hidden') || element.closest('.panel:not(.visible)')) return false;1245 const style = getComputedStyle(element);1246 return style.display !== 'none' && style.visibility !== 'hidden' && element.getClientRects().length > 0;1247 });1248}12491250function setControllerFocus(element) {1251 document.querySelectorAll('.controller-focus').forEach((item) => item.classList.remove('controller-focus'));1252 if (!element) return;1253 element.classList.add('controller-focus');1254 element.focus({ preventScroll: false });1255}12561257function navigateControllerFocus(direction) {1258 const elements = controllerElements();1259 if (elements.length === 0) return;1260 const currentIndex = elements.indexOf(document.activeElement);1261 const rects = elements.map((element) => element.getBoundingClientRect());1262 setControllerFocus(elements[chooseDirectionalIndex(rects, currentIndex, direction)]);1263}12641265function activateControllerFocus() {1266 const elements = controllerElements();1267 let target = elements.includes(document.activeElement) ? document.activeElement : elements[0];1268 if (!target) return;1269 setControllerFocus(target);1270 if (target.dataset.pinTarget) {1271 openPinPad(target.dataset.pinTarget, target);1272 } else {1273 controllerActivationInProgress = true;1274 try {1275 target.click();1276 } finally {1277 controllerActivationInProgress = false;1278 }1279 }1280}12811282function controllerBack() {1283 if (!document.getElementById('focus-handoff-overlay').classList.contains('hidden')) {1284 void denyFocusHandoff();1285 } else if (!document.getElementById('focus-preempt-overlay').classList.contains('hidden')) {1286 cancelFocusPreempt();1287 } else if (!document.getElementById('pin-pad-overlay').classList.contains('hidden')) {1288 closePinPad();1289 } else if (!document.getElementById('reset-enrollment-overlay').classList.contains('hidden')) {1290 cancelEnrollmentReset();1291 } else if (document.getElementById('panel').classList.contains('visible')) {1292 togglePanel(false);1293 }1294}12951296function updateControllerStatus(state) {1297 const status = document.getElementById('controller-status');1298 if (!state.connected) {1299 status.textContent = 'controller: none';1300 status.classList.remove('connected', 'warning');1301 return;1302 }1303 const standard = state.mapping === 'standard';1304 status.textContent = standard ? 'controller: ready' : 'controller: non-standard';1305 status.classList.toggle('connected', standard);1306 status.classList.toggle('warning', !standard);1307}13081309controllerRuntime = createControllerRuntime({1310 schedulePoll: (callback) => requestAnimationFrame(callback),1311 isRemoteRoute: () => connectionState.connected1312 && controlRuntime.active1313 && connectionState.inputCapabilities.gamepad,1314 sendRemoteState: (state) => {1315 if (!controlRuntime.acceptsControllerInput(state)) return;1316 inputRuntime.send({ t: 'gp', state });1317 },1318 onNavigate: navigateControllerFocus,1319 onActivate: activateControllerFocus,1320 onBack: controllerBack,1321 onStatus: updateControllerStatus,1322 onExit: () => {1323 controlRuntime.exit();1324 setControllerFocus(document.getElementById('control-toggle'));1325 },1326});13271328document.addEventListener('pointerdown', () => {1329 document.querySelectorAll('.controller-focus').forEach((item) => item.classList.remove('controller-focus'));1330}, { capture: true });13311332window.addEventListener('resize', () => {1333 windowRuntime.sizeSurfaceToIncomingStream();1334 renderRemotePointer();1335});13361337window.addEventListener('focus', () => {1338 if (!controlRuntime.active || !connectionState.inputCapabilities.relativePointer) return;1339 // WebKit rebuilds its native cursor rectangles after the window-level focus1340 // event. Reassert from the webview on the next frame so the final rectangle1341 // is the transparent one used during relative control.1342 requestAnimationFrame(() => {1343 if (!controlRuntime.active || !connectionState.inputCapabilities.relativePointer) return;1344 void controlRuntime.reassertNativeGrab().catch((error) => {1345 console.warn('native cursor re-hide after focus failed:', error);1346 });1347 });1348});13491350document.addEventListener('keydown', () => {1351 document.querySelectorAll('.controller-focus').forEach((item) => item.classList.remove('controller-focus'));1352}, { capture: true });13531354listen('fido-done', () => {1355 document.getElementById('tap-overlay-title').textContent = 'connecting';1356 document.getElementById('tap-overlay-desc').textContent = 'Key recognised. Waiting for host to respond.';1357 document.getElementById('tap-status').textContent = 'please wait...';1358});13591360listen('dev-connect-routing', (event) => {1361 console.warn('[development direct-node routing]', event.payload.warning, event.payload.host_node_id);1362});13631364listen('session-state', (event) => {1365 try {1366 if (sessionStateRuntime.applyNativeSnapshot(event.payload)) {1367 void focusRuntime.observeSession().catch((error) => {1368 console.error('focused input activation failed:', error);1369 controlRuntime.exit();1370 });1371 }1372 } catch (error) {1373 console.error('invalid session-state payload:', error);1374 void disconnect();1375 }1376});13771378listen('focus-command-result', (event) => {1379 focusRuntime.observeResult(event.payload);1380});13811382listen('invitation-pending', (event) => {1383 try { showInvitationSummary(event.payload); }1384 catch (error) { console.error('invalid native invitation summary:', error); }1385});13861387// ─── Expose to HTML onclick handlers ─────────────────────────────────────────1388Object.assign(window, {1389 introConnect, connectHost, disconnect,1390 scanFido, togglePanel, toggleControl, toggleAudioMute,1391 openPinPad, closePinPad, pinPadDigit, pinPadBackspace, pinPadClear,1392 openEnrollmentReset, cancelEnrollmentReset, confirmEnrollmentReset,1393 derivePortalIdentity, chooseInvitationFile, confirmInvitation, cancelInvitation,1394 approveFocusHandoff, denyFocusHandoff, cancelFocusPreempt, confirmFocusPreempt,1395 sigilController: Object.freeze({1396 getLatestState: () => controllerRuntime.latest,1397 setObserver: controllerRuntime.setObserver,1398 }),1399});14001401// ─── Init ─────────────────────────────────────────────────────────────────────1402async function initialize() {1403 try {1404 await checkDevelopmentConnectionMode();1405 } catch (e) {1406 console.error('development connection mode check failed:', e);1407 }1408 updatePanelVisibility();1409 updateControlUI();1410 try {1411 await refreshEnrollment();1412 } catch (error) {1413 console.error('enrollment status check failed:', error);1414 showEnrollment();1415 const status = document.getElementById('enrollment-status');1416 status.className = 'overlay-status err';1417 status.textContent = 'could not load enrollment';1418 }1419 if (!connectionState.developmentMode) scanFido();1420 requestAnimationFrame(controllerRuntime.poll);1421}14221423initialize();