Skip to content

Commit 6448028

Browse files
authored
WebXR client localhost bugfix and self-signed cert acceptance feedback (#379)
1 parent d033855 commit 6448028

4 files changed

Lines changed: 302 additions & 35 deletions

File tree

deps/cloudxr/webxr_client/helpers/utils.ts

Lines changed: 208 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
367382
export 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
}

deps/cloudxr/webxr_client/src/App.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -212,11 +212,11 @@ function App() {
212212
} else if (iwerWasLoaded) {
213213
// Include IWER status in the final success message
214214
cloudXR2DUI.showStatus(
215-
'CloudXR.js SDK is supported. Ready to connect!\nUsing IWER (Immersive Web Emulator Runtime) - Emulating Meta Quest 3.',
215+
'CloudXR.js SDK is supported.\nUsing IWER (Immersive Web Emulator Runtime) - Emulating Meta Quest 3.',
216216
'info'
217217
);
218218
} else {
219-
cloudXR2DUI.showStatus('CloudXR.js SDK is supported. Ready to connect!', 'success');
219+
cloudXR2DUI.showStatus('CloudXR.js SDK is supported.', 'success');
220220
}
221221

222222
setCapabilitiesValid(true);

deps/cloudxr/webxr_client/src/CloudXR2DUI.tsx

Lines changed: 47 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,8 @@ import {
4848
getResolutionFromInputs,
4949
setSelectValueIfAvailable,
5050
setupCertificateAcceptanceLink,
51+
type CertLinkController,
52+
type CertStatusInfo,
5153
} from '@helpers/utils';
5254
import {
5355
getGridValidationError,
@@ -160,8 +162,9 @@ export class CloudXR2DUI {
160162
event: string;
161163
handler: EventListener;
162164
}> = [];
163-
/** Cleanup function for certificate acceptance link */
164-
private certLinkCleanup: (() => void) | null = null;
165+
/** Certificate link controller (cleanup + verification helpers) */
166+
private certLinkController: CertLinkController | null = null;
167+
private certStatus: CertStatusInfo = { accepted: true, required: false, verified: true };
165168

166169
/**
167170
* Creates a new CloudXR2DUI instance
@@ -279,7 +282,7 @@ export class CloudXR2DUI {
279282
// Default port: HTTP → 49100, HTTPS without proxy → 48322, HTTPS with proxy → 443
280283
const defaultPort = useSecure ? 48322 : 49100;
281284
return {
282-
serverIP: '127.0.0.1',
285+
serverIP: (typeof window !== 'undefined' && window.location.hostname) || '127.0.0.1',
283286
port: defaultPort,
284287
useSecureConnection: useSecure,
285288
perEyeWidth: 2048,
@@ -441,12 +444,16 @@ export class CloudXR2DUI {
441444
});
442445

443446
// Set up certificate acceptance link and store cleanup function
444-
this.certLinkCleanup = setupCertificateAcceptanceLink(
447+
this.certLinkController = setupCertificateAcceptanceLink(
445448
this.serverIpInput,
446449
this.portInput,
447450
this.proxyUrlInput,
448451
this.certAcceptanceLink,
449-
this.certLink
452+
this.certLink,
453+
(status: CertStatusInfo) => {
454+
this.certStatus = status;
455+
this.updateConnectButtonState();
456+
}
450457
);
451458
}
452459

@@ -515,7 +522,14 @@ export class CloudXR2DUI {
515522
reprojectionGridCols,
516523
reprojectionGridRows
517524
);
518-
const combinedConnectMessage = [connectMessage, gridConnectMessage].filter(Boolean).join(' ');
525+
const certPending =
526+
this.certStatus.required && this.certStatus.verified === true && !this.certStatus.accepted;
527+
const certMessage = certPending
528+
? 'Accept the certificate using the link below before connecting.'
529+
: '';
530+
const combinedConnectMessage = [connectMessage, gridConnectMessage, certMessage]
531+
.filter(Boolean)
532+
.join('\n');
519533
if (combinedConnectMessage) {
520534
this.validationMessageText.textContent = combinedConnectMessage;
521535
this.validationMessageBox.className = 'validation-message-box show';
@@ -525,7 +539,7 @@ export class CloudXR2DUI {
525539
}
526540
// Only update button when idle (don't override "CONNECT (starting...)" or "CONNECT (XR session active)")
527541
if (this.startButton && this.startButton.innerHTML === 'CONNECT') {
528-
const shouldEnable = !resolutionError && !gridError;
542+
const shouldEnable = !resolutionError && !gridError && !certPending;
529543
this.setStartButtonState(!shouldEnable, 'CONNECT');
530544
}
531545
}
@@ -747,6 +761,29 @@ export class CloudXR2DUI {
747761

748762
// Create new handler
749763
this.handleConnectClick = async () => {
764+
this.updateConnectButtonState();
765+
if (this.certStatus.required && !this.certStatus.accepted && this.certLinkController) {
766+
this.setStartButtonState(true, 'CONNECT (waiting for certificate check...)');
767+
let certWaitTimeoutId: ReturnType<typeof setTimeout> | null = null;
768+
try {
769+
await Promise.race([
770+
this.certLinkController.verifyNow(),
771+
new Promise<void>(resolve => {
772+
certWaitTimeoutId = setTimeout(resolve, 500);
773+
}),
774+
]);
775+
} finally {
776+
if (certWaitTimeoutId !== null) {
777+
clearTimeout(certWaitTimeoutId);
778+
}
779+
}
780+
this.setStartButtonState(false, 'CONNECT');
781+
this.updateConnectButtonState();
782+
}
783+
if (this.startButton?.disabled) {
784+
this.updateConnectButtonState();
785+
return;
786+
}
750787
const cfg = this.getConfiguration();
751788
const resolutionError = getResolutionValidationError(cfg.perEyeWidth, cfg.perEyeHeight);
752789
const gridError = getGridValidationError(
@@ -821,9 +858,9 @@ export class CloudXR2DUI {
821858
}
822859

823860
// Clean up certificate acceptance link listeners
824-
if (this.certLinkCleanup) {
825-
this.certLinkCleanup();
826-
this.certLinkCleanup = null;
861+
if (this.certLinkController) {
862+
this.certLinkController();
863+
this.certLinkController = null;
827864
}
828865
}
829866
}

0 commit comments

Comments
 (0)