@@ -362,21 +362,116 @@ export function isLocalServer(hostname: string): boolean {
362362 * @param certAcceptanceLink - Container element for the certificate link
363363 * @param certLink - Anchor element for the certificate URL
364364 * @param location - Optional location object (defaults to window.location)
365- * @returns Cleanup function to remove event listeners
365+ * @returns Certificate link controller with cleanup and verification helpers
366366 */
367+ export interface CertStatusInfo {
368+ accepted : boolean ;
369+ required : boolean ;
370+ verified : boolean ;
371+ }
372+
373+ export interface CertLinkController {
374+ /** Removes listeners created by setupCertificateAcceptanceLink(). */
375+ ( ) : void ;
376+ /** Forces a cert check for the current effective URL and updates status. */
377+ verifyNow : ( ) => Promise < CertStatusInfo > ;
378+ /** Waits for an in-flight cert check started elsewhere (if any). */
379+ waitForPendingVerification : ( ) => Promise < CertStatusInfo > ;
380+ }
381+
367382export function setupCertificateAcceptanceLink (
368383 serverIpInput : HTMLInputElement ,
369384 portInput : HTMLInputElement ,
370385 proxyUrlInput : HTMLInputElement ,
371386 certAcceptanceLink : HTMLElement ,
372387 certLink : HTMLAnchorElement ,
373- location : Location = window . location
374- ) : ( ) => void {
388+ onStatusChange ?: ( status : CertStatusInfo ) => void ,
389+ location : Location = window . location ,
390+ fetchFn : typeof fetch = globalThis . fetch
391+ ) : CertLinkController {
392+ let abortController : AbortController | null = null ;
393+ let accepted = false ;
394+ let certRequired = false ;
395+ let verified = false ;
396+ let activeCertUrl : string | null = null ;
397+ let pendingVerification : Promise < CertStatusInfo > | null = null ;
398+ let pendingVerificationUrl : string | null = null ;
399+
400+ function notifyStatus ( ) : void {
401+ onStatusChange ?.( { accepted, required : certRequired , verified } ) ;
402+ }
403+
404+ function markAccepted ( url : string ) : void {
405+ if ( url !== activeCertUrl ) {
406+ return ;
407+ }
408+ if ( ! accepted ) {
409+ console . warn ( '[CloudXR] Certificate accepted for %s' , url ) ;
410+ }
411+ accepted = true ;
412+ verified = true ;
413+ certAcceptanceLink . classList . remove ( 'cert-unverified' ) ;
414+ certAcceptanceLink . classList . add ( 'cert-accepted' ) ;
415+ certLink . textContent = `Certificate accepted (${ url } )` ;
416+ notifyStatus ( ) ;
417+ }
418+
419+ function markUnverified ( url : string ) : void {
420+ if ( url !== activeCertUrl ) {
421+ return ;
422+ }
423+ accepted = false ;
424+ verified = false ;
425+ certAcceptanceLink . classList . remove ( 'cert-accepted' ) ;
426+ certAcceptanceLink . classList . add ( 'cert-unverified' ) ;
427+ certLink . textContent = `Click ${ url } to accept cert` ;
428+ notifyStatus ( ) ;
429+ }
430+
431+ function markPending ( url : string ) : void {
432+ if ( url !== activeCertUrl ) {
433+ return ;
434+ }
435+ accepted = false ;
436+ verified = true ;
437+ certAcceptanceLink . classList . remove ( 'cert-unverified' ) ;
438+ certAcceptanceLink . classList . remove ( 'cert-accepted' ) ;
439+ certLink . textContent = `Click ${ url } to accept cert` ;
440+ notifyStatus ( ) ;
441+ }
442+
443+ async function checkCert ( url : string ) : Promise < void > {
444+ if ( url !== activeCertUrl ) {
445+ return ;
446+ }
447+ // Skip polling while an XR session is active to avoid unnecessary network requests
448+ if ( document . body . classList . contains ( 'xr-mode' ) ) {
449+ return ;
450+ }
451+ if ( abortController ) {
452+ abortController . abort ( ) ;
453+ }
454+ abortController = new AbortController ( ) ;
455+ try {
456+ await fetchFn ( url , { signal : abortController . signal , mode : 'no-cors' } ) ;
457+ markAccepted ( url ) ;
458+ } catch ( err ) {
459+ if ( err instanceof DOMException && err . name === 'AbortError' ) {
460+ return ;
461+ }
462+ markPending ( url ) ;
463+ console . warn (
464+ '[CloudXR] Certificate not yet accepted — cert polling errors for %s are expected.' ,
465+ url
466+ ) ;
467+ }
468+ }
469+
375470 /**
376471 * Updates the certificate acceptance link based on current configuration
377472 * Shows link only when in HTTPS mode without proxy (direct WSS)
378473 */
379- const updateCertLink = ( ) => {
474+ const updateCertLink = ( runCertCheck : boolean ) => {
380475 const isHttps = location . protocol === 'https:' ;
381476 const hasProxy = proxyUrlInput . value . trim ( ) . length > 0 ;
382477 const portValue = parseInt ( portInput . value , 10 ) ;
@@ -388,29 +483,125 @@ export function setupCertificateAcceptanceLink(
388483
389484 // Only show when we have a reasonable cert URL: either the user filled in
390485 // a server IP, or the page itself is on a local/dev host.
391- if ( isHttps && ! hasProxy && ( serverIpPopulated || isLocalServer ( location . hostname ) ) ) {
486+ certRequired = isHttps && ! hasProxy && ( serverIpPopulated || isLocalServer ( location . hostname ) ) ;
487+ if ( certRequired ) {
392488 const effectiveIp = serverIpPopulated ? serverIp : new URL ( location . href ) . hostname ;
393489 const url = `https://${ effectiveIp } :${ port } /` ;
490+ activeCertUrl = url ;
394491 certAcceptanceLink . style . display = 'block' ;
395492 certLink . href = url ;
396- certLink . textContent = `Click ${ url } to accept cert` ;
493+ // Keep blue "unverified" until a probe result is known.
494+ markUnverified ( url ) ;
495+ if ( runCertCheck ) {
496+ void checkCert ( url ) ;
497+ }
397498 } else {
499+ activeCertUrl = null ;
500+ accepted = false ;
501+ verified = false ;
502+ if ( abortController ) abortController . abort ( ) ;
503+ certAcceptanceLink . classList . remove ( 'cert-unverified' ) ;
504+ certAcceptanceLink . classList . remove ( 'cert-accepted' ) ;
398505 certAcceptanceLink . style . display = 'none' ;
506+ notifyStatus ( ) ;
507+ }
508+ } ;
509+
510+ const onFocus = ( ) => {
511+ if ( certRequired && activeCertUrl ) {
512+ void startVerification ( ) ;
399513 }
400514 } ;
401515
402- // Add event listeners to update link when inputs change
403- serverIpInput . addEventListener ( 'input' , updateCertLink ) ;
404- portInput . addEventListener ( 'input' , updateCertLink ) ;
405- proxyUrlInput . addEventListener ( 'input' , updateCertLink ) ;
516+ const onInput = ( ) => {
517+ updateCertLink ( false ) ;
518+ } ;
519+ const onCommittedChange = ( ) => {
520+ void startVerification ( ) ;
521+ } ;
522+ const onProxyCommittedChange = ( ) => {
523+ updateCertLink ( false ) ;
524+ if ( certRequired && activeCertUrl ) {
525+ void startVerification ( ) ;
526+ }
527+ } ;
406528
407- // Initial update after localStorage values are restored
408- setTimeout ( updateCertLink , 0 ) ;
529+ // Typing updates displayed URL/state; committed IP/port changes trigger probes.
530+ serverIpInput . addEventListener ( 'input' , onInput ) ;
531+ portInput . addEventListener ( 'input' , onInput ) ;
532+ proxyUrlInput . addEventListener ( 'input' , onInput ) ;
533+ serverIpInput . addEventListener ( 'change' , onCommittedChange ) ;
534+ serverIpInput . addEventListener ( 'blur' , onCommittedChange ) ;
535+ portInput . addEventListener ( 'change' , onCommittedChange ) ;
536+ portInput . addEventListener ( 'blur' , onCommittedChange ) ;
537+ proxyUrlInput . addEventListener ( 'change' , onProxyCommittedChange ) ;
538+ proxyUrlInput . addEventListener ( 'blur' , onProxyCommittedChange ) ;
539+ window . addEventListener ( 'focus' , onFocus ) ;
540+
541+ // Run initial cert state after localStorage restoration.
542+ void startVerification ( ) ;
543+
544+ async function verifyNow ( ) : Promise < CertStatusInfo > {
545+ updateCertLink ( false ) ;
546+ if ( certRequired && activeCertUrl ) {
547+ await checkCert ( activeCertUrl ) ;
548+ } else {
549+ notifyStatus ( ) ;
550+ }
551+ return { accepted, required : certRequired , verified } ;
552+ }
553+
554+ function startVerification ( ) : Promise < CertStatusInfo > {
555+ updateCertLink ( false ) ;
556+ const currentUrl = certRequired ? activeCertUrl : null ;
557+ if ( pendingVerification && pendingVerificationUrl === currentUrl ) {
558+ return pendingVerification ;
559+ }
560+ const run = ( async ( ) => {
561+ if ( currentUrl ) {
562+ await checkCert ( currentUrl ) ;
563+ } else {
564+ notifyStatus ( ) ;
565+ }
566+ return { accepted, required : certRequired , verified } ;
567+ } ) ( ) ;
568+ pendingVerification = run ;
569+ pendingVerificationUrl = currentUrl ;
570+ return run . finally ( ( ) => {
571+ if ( pendingVerification === run ) {
572+ pendingVerification = null ;
573+ pendingVerificationUrl = null ;
574+ }
575+ } ) ;
576+ }
577+
578+ function waitForPendingVerification ( ) : Promise < CertStatusInfo > {
579+ if ( pendingVerification ) {
580+ return pendingVerification ;
581+ }
582+ if ( certRequired && ! verified ) {
583+ return startVerification ( ) ;
584+ }
585+ return Promise . resolve ( { accepted, required : certRequired , verified } ) ;
586+ }
409587
410- // Return cleanup function to remove event listeners
411- return ( ) => {
412- serverIpInput . removeEventListener ( 'input' , updateCertLink ) ;
413- portInput . removeEventListener ( 'input' , updateCertLink ) ;
414- proxyUrlInput . removeEventListener ( 'input' , updateCertLink ) ;
588+ // Return callable controller with cleanup and verification helpers.
589+ const cleanup = ( ) => {
590+ serverIpInput . removeEventListener ( 'input' , onInput ) ;
591+ portInput . removeEventListener ( 'input' , onInput ) ;
592+ proxyUrlInput . removeEventListener ( 'input' , onInput ) ;
593+ serverIpInput . removeEventListener ( 'change' , onCommittedChange ) ;
594+ serverIpInput . removeEventListener ( 'blur' , onCommittedChange ) ;
595+ portInput . removeEventListener ( 'change' , onCommittedChange ) ;
596+ portInput . removeEventListener ( 'blur' , onCommittedChange ) ;
597+ proxyUrlInput . removeEventListener ( 'change' , onProxyCommittedChange ) ;
598+ proxyUrlInput . removeEventListener ( 'blur' , onProxyCommittedChange ) ;
599+ window . removeEventListener ( 'focus' , onFocus ) ;
600+ if ( abortController ) abortController . abort ( ) ;
415601 } ;
602+ const controller = Object . assign ( cleanup , {
603+ verifyNow,
604+ waitForPendingVerification,
605+ } ) as CertLinkController ;
606+ return controller ;
416607}
0 commit comments