Add CloudXR VR streaming support for PICO 4 Ultra (Early Access)

Replaces manual H264/TCP stereo streaming with NVIDIA CloudXR for
higher-quality stereoscopic rendering and lower latency.

Changes:
- teleop_xr_agent.py: add --cloudxr flag (enables Isaac Sim XR mode,
  disables manual StreamingManager)
- deps/cloudxr/: NVIDIA CloudXR.js SDK (Early Access) with Isaac Lab
  teleop React web client
- deps/cloudxr/Dockerfile.wss.proxy: HAProxy WSS proxy for PICO 4 Ultra
  HTTPS mode (routes wss://48322 → ws://49100)
- deps/cloudxr/isaac/webpack.dev.js: disable file watching to avoid
  EMFILE errors with large node_modules
- deps/cloudxr/INSTALL.md: full setup guide

Usage:
  # Start CloudXR Runtime + Isaac Lab
  cd ~/IsaacLab && ./docker/container.py start \
      --files docker-compose.cloudxr-runtime.patch.yaml \
      --env-file .env.cloudxr-runtime

  # Run teleop with CloudXR
  ~/IsaacLab/isaaclab.sh -p teleop_xr_agent.py \
      --task Isaac-MindRobot-2i-DualArm-IK-Abs-v0 --cloudxr

  # Serve web client
  cd deps/cloudxr/isaac && npm run dev-server:https
This commit is contained in:
2026-03-26 14:29:03 +08:00
parent eef7ff838d
commit 623e05f250
133 changed files with 24869 additions and 2 deletions

View File

@@ -0,0 +1,125 @@
interface CapabilityCheck {
name: string;
required: boolean;
check: () => boolean | Promise<boolean>;
message: string;
}
const capabilities: CapabilityCheck[] = [
{
name: 'WebGL2',
required: true,
check: () => {
const canvas = document.createElement('canvas');
const gl = canvas.getContext('webgl2');
return gl !== null;
},
message: 'WebGL2 is required for rendering',
},
{
name: 'WebXR',
required: true,
check: () => {
return 'xr' in navigator;
},
message: 'WebXR is required for VR/AR functionality',
},
{
name: 'RTCPeerConnection',
required: true,
check: () => {
return 'RTCPeerConnection' in window;
},
message: 'RTCPeerConnection is required for WebRTC streaming',
},
{
name: 'requestVideoFrameCallback',
required: true,
check: () => {
const video = document.createElement('video');
return typeof video.requestVideoFrameCallback === 'function';
},
message: 'HTMLVideoElement.requestVideoFrameCallback is required for video frame processing',
},
{
name: 'Canvas.captureStream',
required: true,
check: () => {
const canvas = document.createElement('canvas');
return typeof canvas.captureStream === 'function';
},
message: 'Canvas.captureStream is required for video streaming',
},
{
name: 'AV1 Codec Support',
required: false,
check: async () => {
try {
// Check if MediaCapabilities API is available
if (!navigator.mediaCapabilities) {
return false;
}
// Check MediaCapabilities for AV1 decoding support
const config = {
type: 'webrtc' as MediaDecodingType,
video: {
contentType: 'video/av1',
width: 1920,
height: 1080,
framerate: 60,
bitrate: 15000000, // 15 Mbps
},
};
const result = await navigator.mediaCapabilities.decodingInfo(config);
return result.supported;
} catch (error) {
console.warn('Error checking AV1 support:', error);
return false;
}
},
message: 'AV1 codec support is recommended for optimal streaming quality',
},
];
export async function checkCapabilities(): Promise<{
success: boolean;
failures: string[];
warnings: string[];
}> {
const failures: string[] = [];
const warnings: string[] = [];
const requiredFailures: string[] = [];
for (const capability of capabilities) {
try {
const result = await Promise.resolve(capability.check());
if (!result) {
if (capability.required) {
requiredFailures.push(capability.message);
console.error(`Required capability missing: ${capability.message}`);
} else {
warnings.push(capability.message);
console.warn(`Optional capability missing: ${capability.message}`);
}
failures.push(capability.message);
}
} catch (error) {
if (capability.required) {
requiredFailures.push(capability.message);
console.error(`Error checking required capability ${capability.name}:`, error);
} else {
warnings.push(capability.message);
console.warn(`Error checking optional capability ${capability.name}:`, error);
}
failures.push(capability.message);
}
}
return {
success: requiredFailures.length === 0,
failures,
warnings,
};
}

98
deps/cloudxr/helpers/LoadIWER.ts vendored Normal file
View File

@@ -0,0 +1,98 @@
const IWER_version = '2.1.1';
const IWER_DEVUI_version = '1.1.2';
export interface IWERLoadResult {
supportsImmersive: boolean;
iwerLoaded: boolean;
}
export async function loadIWERIfNeeded(): Promise<IWERLoadResult> {
let supportsImmersive = false;
let iwerLoaded = false;
if ('xr' in navigator) {
try {
const vr = await (navigator.xr as XRSystem).isSessionSupported?.('immersive-vr');
const ar = await (navigator.xr as XRSystem).isSessionSupported?.('immersive-ar');
supportsImmersive = Boolean(vr || ar);
} catch (_) {}
}
if (!supportsImmersive) {
console.info('Immersive mode not supported, loading IWER as fallback.');
// Load IWER first
const script = document.createElement('script');
script.src = `https://unpkg.com/iwer@${IWER_version}/build/iwer.min.js`;
script.async = true;
script.integrity = 'sha384-ZOdYbNlfA4q9jkBGcdmjy2ZYmjxy2uzncU6it3cPOHi12/WF048bamSU0Z5N+V5u';
script.crossOrigin = 'anonymous';
await new Promise<void>(resolve => {
script.onload = async () => {
console.info('IWER loaded as fallback.');
const IWERGlobal = (window as any).IWER || (globalThis as any).IWER;
if (!IWERGlobal) {
console.warn('IWER global not found after script load.');
supportsImmersive = false;
resolve();
return;
}
// Load iwer-devui after IWER
const devUIScript = document.createElement('script');
devUIScript.src = `https://unpkg.com/@iwer/devui@${IWER_DEVUI_version}/build/iwer-devui.min.js`;
devUIScript.async = true;
devUIScript.integrity =
'sha384-CG/gISX6PadiSzc8i2paU7CYLVsnVJaJ0tgoVnAPq/gyiTX6bddG5rwOgMDGlq74';
devUIScript.crossOrigin = 'anonymous';
await new Promise<void>(devUIResolve => {
devUIScript.onload = () => {
console.info('IWER DevUI loaded.');
devUIResolve();
};
devUIScript.onerror = error => {
console.warn('Failed to load IWER DevUI:', error);
devUIResolve();
};
document.head.appendChild(devUIScript);
});
try {
// Create XRDevice with Meta Quest 3 profile
const xrDevice = new IWERGlobal.XRDevice(IWERGlobal.metaQuest3);
// Initialize DevUI with the XR device
const IWER_DevUI = (window as any).IWER_DevUI || (globalThis as any).IWER_DevUI;
if (IWER_DevUI?.DevUI) {
xrDevice.installDevUI(IWER_DevUI.DevUI);
console.info('IWER DevUI initialized with XR device.');
} else {
console.warn('IWER DevUI not found after script load, continuing without DevUI.');
}
// Install the runtime and wait for it to be ready
const maybePromise = xrDevice.installRuntime?.();
if (maybePromise && typeof maybePromise.then === 'function') {
await maybePromise;
}
supportsImmersive = true;
iwerLoaded = true;
} catch (e) {
console.warn('IWER runtime install failed:', e);
supportsImmersive = false;
}
resolve();
};
script.onerror = () => {
console.warn('Failed to load IWER.');
supportsImmersive = false;
resolve();
};
document.head.appendChild(script);
});
}
return { supportsImmersive, iwerLoaded };
}

2685
deps/cloudxr/helpers/WebGLState.ts vendored Normal file

File diff suppressed because it is too large Load Diff

1220
deps/cloudxr/helpers/WebGLStateApply.ts vendored Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,286 @@
import { WebGLStateTracker, WebGLState } from './WebGLState';
import { apply } from './WebGLStateApply';
/**
* BoundWebGLState - A WebGL2 context with automatic state tracking
*
* This is the original WebGL2 context with state-changing functions rebound
* to call the tracker before forwarding to the original implementation.
*/
export class BoundWebGLState {
private gl: WebGL2RenderingContext;
private tracker: WebGLStateTracker;
private originalFunctions: Map<string, Function>;
private savedState?: WebGLState;
private trackingEnabled = true;
constructor(
gl: WebGL2RenderingContext,
tracker: WebGLStateTracker,
originalFunctions: Map<string, Function>
) {
this.gl = gl;
this.tracker = tracker;
this.originalFunctions = originalFunctions;
}
/**
* Get the internal state tracker
*/
private getTracker(): WebGLStateTracker {
return this.tracker;
}
/**
* Save the current tracked WebGL state
* This clones the state so it can be restored later
*/
save(): void {
this.savedState = this.tracker.getState();
}
/**
* Restore the previously saved WebGL state
* This applies the saved state back to the WebGL context
* @throws {Error} If no state has been saved
*/
restore(): void {
if (!this.savedState) {
throw new Error('No state has been saved. Call save() before restore().');
}
// Save the current tracking state and enable tracking during restore
// This ensures the tracker stays synchronized with actual GL state
const wasTrackingEnabled = this.trackingEnabled;
this.trackingEnabled = true;
const currentState = this.tracker.getState();
apply(this.gl, this.savedState, currentState);
// Restore the original tracking state
this.trackingEnabled = wasTrackingEnabled;
}
enableTracking(enable: boolean): void {
this.trackingEnabled = enable;
}
_enabled(): boolean {
return this.trackingEnabled;
}
/**
* Revert all tracked functions back to their original implementations
* This removes state tracking from the WebGL context
*/
revert(): void {
const glAny = this.gl as any;
for (const [name, originalFunction] of this.originalFunctions.entries()) {
glAny[name] = originalFunction;
}
// Clear the map
this.originalFunctions.clear();
// Remove the stored BoundWebGLState from the GL context
delete glAny.__cloudxrBoundState;
}
}
/**
* Bind a WebGL2 context with automatic state tracking
*
* Rebinds state-changing methods on the WebGL context to automatically track
* state changes before forwarding to the original implementation.
*
* @param gl - The WebGL2RenderingContext to wrap
* @returns A BoundWebGLState instance that provides access to the tracker and revert functionality
*
* @example
* ```typescript
* const canvas = document.getElementById('canvas') as HTMLCanvasElement;
* const gl = canvas.getContext('webgl2')!;
* const binding = bindGL(gl);
*
* // Use gl like a normal WebGL context - it's now tracked
* gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
* gl.bindVertexArray(vao);
*
* // Save the current state
* binding.save();
*
* // Make some temporary changes
* gl.bindBuffer(gl.ARRAY_BUFFER, tempBuffer);
* gl.enable(gl.BLEND);
*
* // Restore the saved state
* binding.restore();
*
* // Access tracked state
* const state = binding.getTracker().getState();
* console.log(state.buffers?.arrayBuffer); // The bound buffer
*
* // When done, revert to stop tracking
* binding.revert();
* ```
*/
export function bindGL(gl: WebGL2RenderingContext): BoundWebGLState {
const glAny = gl as any;
// Check if this GL context is already wrapped - prevent double-wrapping
if (glAny.__cloudxrBoundState) {
console.warn(
'WebGL context is already wrapped with state tracking. Returning existing BoundWebGLState.'
);
return glAny.__cloudxrBoundState;
}
// Create the tracker
const tracker = new WebGLStateTracker();
// Store original functions for later reversion
const originalFunctions = new Map<string, Function>();
const wrappedFunctions = new Map<string, Function>();
const state = new BoundWebGLState(gl, tracker, originalFunctions);
// Store the BoundWebGLState on the GL context to prevent double-wrapping
glAny.__cloudxrBoundState = state;
// Helper function to bind a method
const bind = (name: string, trackerMethod: Function) => {
// CRITICAL: Store the original BEFORE we replace it, otherwise we'll store the wrapper
const originalMethod = glAny[name];
if (!originalMethod) {
throw new Error('Original method not found for ' + name);
}
if (originalMethod === wrappedFunctions.get(name)) {
throw new Error('Wrapped function already bound for ' + name);
}
const original = originalMethod.bind(gl);
originalFunctions.set(name, original);
const wrappedFunction = (...args: any[]) => {
if (state._enabled()) {
trackerMethod.apply(tracker, args);
}
return original(...args);
};
wrappedFunctions.set(name, wrappedFunction);
glAny[name] = wrappedFunction;
};
// Buffer bindings
bind('bindBuffer', tracker.bindBuffer);
bind('bindBufferBase', tracker.bindBufferBase);
bind('bindBufferRange', tracker.bindBufferRange);
// Buffer lifecycle tracking (for validation without GPU calls)
const originalCreateBuffer = glAny.createBuffer.bind(gl);
originalFunctions.set('createBuffer', originalCreateBuffer);
glAny.createBuffer = (): WebGLBuffer | null => {
const buffer = originalCreateBuffer();
if (buffer) {
tracker.createBuffer(buffer);
}
return buffer;
};
bind('deleteBuffer', tracker.deleteBuffer);
// VAO and vertex attributes
bind('bindVertexArray', tracker.bindVertexArray);
bind('deleteVertexArray', tracker.deleteVertexArray);
bind('enableVertexAttribArray', tracker.enableVertexAttribArray);
bind('disableVertexAttribArray', tracker.disableVertexAttribArray);
bind('vertexAttribPointer', tracker.vertexAttribPointer);
bind('vertexAttribIPointer', tracker.vertexAttribIPointer);
bind('vertexAttribDivisor', tracker.vertexAttribDivisor);
// Texture bindings
bind('activeTexture', tracker.activeTexture);
bind('bindTexture', tracker.bindTexture);
// Program binding
bind('useProgram', tracker.useProgram);
// Framebuffer bindings
bind('bindFramebuffer', tracker.bindFramebuffer);
bind('framebufferTexture2D', tracker.framebufferTexture2D);
bind('framebufferRenderbuffer', tracker.framebufferRenderbuffer);
bind('framebufferTextureLayer', tracker.framebufferTextureLayer);
bind('drawBuffers', tracker.drawBuffers);
bind('readBuffer', tracker.readBuffer);
// Renderbuffer binding
bind('bindRenderbuffer', tracker.bindRenderbuffer);
// Transform feedback
bind('bindTransformFeedback', tracker.bindTransformFeedback);
bind('beginTransformFeedback', tracker.beginTransformFeedback);
bind('endTransformFeedback', tracker.endTransformFeedback);
bind('pauseTransformFeedback', tracker.pauseTransformFeedback);
bind('resumeTransformFeedback', tracker.resumeTransformFeedback);
// Capabilities (enable/disable)
bind('enable', tracker.enable);
bind('disable', tracker.disable);
// Viewport and scissor
bind('viewport', tracker.viewport);
bind('scissor', tracker.scissor);
// Clear values
bind('clearColor', tracker.clearColor);
bind('clearDepth', tracker.clearDepth);
bind('clearStencil', tracker.clearStencil);
// Blend state
bind('blendColor', tracker.blendColor);
bind('blendEquation', tracker.blendEquation);
bind('blendEquationSeparate', tracker.blendEquationSeparate);
bind('blendFunc', tracker.blendFunc);
bind('blendFuncSeparate', tracker.blendFuncSeparate);
// Depth state
bind('depthFunc', tracker.depthFunc);
bind('depthMask', tracker.depthMask);
bind('depthRange', tracker.depthRange);
// Stencil state
bind('stencilFunc', tracker.stencilFunc);
bind('stencilFuncSeparate', tracker.stencilFuncSeparate);
bind('stencilMask', tracker.stencilMask);
bind('stencilMaskSeparate', tracker.stencilMaskSeparate);
bind('stencilOp', tracker.stencilOp);
bind('stencilOpSeparate', tracker.stencilOpSeparate);
// Color mask
bind('colorMask', tracker.colorMask);
// Culling and face orientation
bind('cullFace', tracker.cullFace);
bind('frontFace', tracker.frontFace);
// Line width
bind('lineWidth', tracker.lineWidth);
// Polygon offset
bind('polygonOffset', tracker.polygonOffset);
// Sample coverage
bind('sampleCoverage', tracker.sampleCoverage);
// Pixel store parameters
bind('pixelStorei', tracker.pixelStorei);
// Sampler binding
bind('bindSampler', tracker.bindSampler);
// Query operations
bind('beginQuery', tracker.beginQuery);
bind('endQuery', tracker.endQuery);
return state;
}

54
deps/cloudxr/helpers/WebGlUtils.ts vendored Normal file
View File

@@ -0,0 +1,54 @@
export function getOrCreateCanvas(
id: string,
resolution?: { width: number; height: number }
): HTMLCanvasElement {
let canvas = document.getElementById(id) as HTMLCanvasElement | null;
if (!canvas) {
canvas = document.createElement('canvas') as HTMLCanvasElement;
canvas.id = id;
// canvas.style.display = "none";
document.body.appendChild(canvas);
}
if (!canvas) {
throw new Error('Failed to create canvas');
}
if (resolution) {
canvas.width = resolution.width;
canvas.height = resolution.height;
}
return canvas;
}
export function logOrThrow(tagString: string, gl: WebGL2RenderingContext) {
const err = gl.getError();
if (err !== gl.NO_ERROR) {
let errorString;
switch (err) {
case gl.INVALID_ENUM:
errorString = 'INVALID_ENUM';
break;
case gl.INVALID_VALUE:
errorString = 'INVALID_VALUE';
break;
case gl.INVALID_OPERATION:
errorString = 'INVALID_OPERATION';
break;
case gl.INVALID_FRAMEBUFFER_OPERATION:
errorString = 'INVALID_FRAMEBUFFER_OPERATION';
break;
case gl.OUT_OF_MEMORY:
errorString = 'OUT_OF_MEMORY';
break;
case gl.CONTEXT_LOST_WEBGL:
errorString = 'CONTEXT_LOST_WEBGL';
break;
default:
errorString = 'UNKNOWN_ERROR';
break;
}
throw new Error('WebGL error: ' + tagString + ': ' + errorString + ' (' + err + ')');
} else {
console.debug('WebGL no-error: ' + tagString);
}
}

View File

@@ -0,0 +1,35 @@
/**
* Override PressureObserver to catch errors from unexpected browser implementations.
*
* Some browsers have buggy PressureObserver implementations that throw errors
* when observe() is called. This wrapper catches and logs those errors instead
* of letting them propagate.
*
* This should be called early in your application, before any code attempts
* to use PressureObserver.
*/
export function overridePressureObserver(): void {
if (typeof window === 'undefined' || !(window as any).PressureObserver) {
return;
}
const OriginalPressureObserver = (window as any).PressureObserver;
(window as any).PressureObserver = class PressureObserver extends OriginalPressureObserver {
observe(source: any) {
try {
const result = super.observe(source);
if (result && typeof result.catch === 'function') {
return result.catch((e: Error) => {
console.warn('PressureObserver.observe() failed:', e.message);
return undefined;
});
}
return result;
} catch (e: any) {
console.warn('PressureObserver.observe() failed:', e.message);
return undefined;
}
}
};
}

293
deps/cloudxr/helpers/utils.ts vendored Normal file
View File

@@ -0,0 +1,293 @@
/**
* Parses URL parameters and returns them as an object
* @param location - Optional location object (defaults to window.location)
* @returns Object with URL parameters as key-value pairs
*/
export function getUrlParams(location: Location = window.location): Record<string, string> {
const params: Record<string, string> = {};
const queryString = location.search.substring(1);
if (queryString) {
const pairs = queryString.split('&');
for (const pair of pairs) {
const [key, value = ''] = pair.split('=');
params[decodeURIComponent(key)] = decodeURIComponent(value);
}
}
return params;
}
/**
* Enables localStorage functionality for form elements
* @param element - The HTML input or select element to enable localStorage for
* @param key - The localStorage key to use for saving/loading the value
*/
export function enableLocalStorage(element: HTMLInputElement | HTMLSelectElement, key: string) {
// Check if localStorage is already enabled for this element and key
const localStorageKey = `__localStorageEnabled_${key}`;
if ((element as any)[localStorageKey]) {
console.warn(`localStorage already enabled for ${key}, skipping`);
return;
}
// Load saved value from localStorage
try {
// Check if the key exists in localStorage, not just if it has values
if (localStorage.hasOwnProperty(key)) {
const savedValue = localStorage.getItem(key);
element.value = savedValue || '';
console.info(`Loaded saved ${key} from localStorage:`, savedValue);
}
} catch (error) {
console.warn(`${key}: Failed to load saved value from localStorage:`, error);
}
// Set up event listener to save value when changed
const changeHandler = () => {
try {
// Always save the value, even if it's empty
localStorage.setItem(key, element.value);
console.info(`Saved ${key} to localStorage:`, JSON.stringify(element.value));
} catch (error) {
console.warn(`${key}: Failed to save to localStorage:`, error);
}
};
element.addEventListener('change', changeHandler);
// Mark this element as having localStorage enabled for this key
(element as any)[localStorageKey] = true;
}
/**
* Strips protocol prefixes (http:// or https://) from a URL string
* @param url - The URL string to clean
* @returns The URL without protocol prefix
*/
function stripProtocol(url: string): string {
return url.replace(/^https?:\/\//, '');
}
/**
* Connection configuration object containing server connection details
*/
export interface ConnectionConfiguration {
/** Final server IP address (may be modified for HTTPS proxy routing) */
serverIP: string;
/** Final port number (fixed to 443 for HTTPS, user-provided for HTTP) */
port: number;
/** Whether the connection will use secure protocol (HTTPS/WSS) */
useSecureConnection: boolean;
}
/**
* CloudXR configuration interface containing all streaming settings
*/
export interface CloudXRConfig {
/** IP address of the CloudXR streaming server */
serverIP: string;
/** Port number for the CloudXR server connection */
port: number;
/** Whether to use secure connection (HTTPS/WSS) or insecure (HTTP/WS) */
useSecureConnection: boolean;
/** Width of each eye in pixels (must be multiple of 16) */
perEyeWidth: number;
/** Height of each eye in pixels (must be multiple of 16) */
perEyeHeight: number;
/** Target frame rate for the XR device in frames per second (FPS) */
deviceFrameRate: number;
/** Maximum streaming bitrate in Megabits per second (Mbps) */
maxStreamingBitrateMbps: number;
/** XR immersive mode: 'ar' for augmented reality, 'vr' for virtual reality */
immersiveMode: 'ar' | 'vr';
/** Application identifier string for the CloudXR session */
app: string;
/** Type of server being connected to */
serverType: string;
/** Optional proxy URL for HTTPS routing (e.g., 'https://proxy.example.com/'); if empty, uses direct WSS connection */
proxyUrl: string;
/** Preferred XR reference space for tracking and positioning */
referenceSpaceType: 'auto' | 'local-floor' | 'local' | 'viewer' | 'unbounded';
/** XR reference space offset along X axis in meters (positive is right) */
xrOffsetX?: number;
/** XR reference space offset along Y axis in meters (positive is up) */
xrOffsetY?: number;
/** XR reference space offset along Z axis in meters (positive is backward) */
xrOffsetZ?: number;
}
/**
* Determines connection configuration based on protocol and user inputs
* Supports both direct WSS connections and proxy routing for HTTPS
*
* @param serverIP - The user-provided server IP address
* @param port - The user-provided port number
* @param proxyUrl - Optional proxy URL for HTTPS routing (if provided, uses proxy routing; otherwise direct connection)
* @param location - Optional location object (defaults to window.location)
* @returns Object containing server IP, port, and security settings
* @throws {Error} When proxy URL format is invalid (must start with https://)
*/
export function getConnectionConfig(
serverIP: string,
port: number,
proxyUrl: string,
location: Location = window.location
): ConnectionConfiguration {
let finalServerIP = '';
let finalPort = port;
let finalUseSecureConnection = false;
// Determine if we should use secure connection based on page protocol
if (location.protocol === 'https:') {
console.info('Running on HTTPS protocol - using secure WebSocket (WSS)');
finalUseSecureConnection = true;
// Check if proxy URL is provided for routing
const trimmedProxyUrl = proxyUrl?.trim();
if (trimmedProxyUrl) {
// Proxy routing mode
console.info('Proxy URL provided - using proxy routing mode');
if (!trimmedProxyUrl.startsWith('https://')) {
throw new Error('Proxy URL must start with https://. Received: ' + trimmedProxyUrl);
}
// Use port 443 for proxy routing (standard HTTPS port)
finalPort = 443;
// Route through proxy: if specific server IP provided, append it to proxy URL
if (serverIP && serverIP !== 'localhost' && serverIP !== '127.0.0.1') {
// Route to proxy with IP
const cleanServerIP = stripProtocol(serverIP);
// Clean proxy URL - strip protocol and trailing slash
const cleanProxyUrl = stripProtocol(trimmedProxyUrl).replace(/\/$/, '');
finalServerIP = `${cleanProxyUrl}/${cleanServerIP}`;
console.info(`Using HTTPS proxy with IP: ${finalServerIP}`);
} else {
// Route to proxy without IP
finalServerIP = stripProtocol(trimmedProxyUrl).replace(/\/$/, '');
console.info(`Using HTTPS proxy without specific IP: ${finalServerIP}`);
}
} else {
// Direct WSS connection mode
console.info('No proxy URL - using direct WSS connection');
// Handle server IP input
if (serverIP && serverIP !== 'localhost' && serverIP !== '127.0.0.1') {
finalServerIP = stripProtocol(serverIP);
console.info('Using user-provided server IP:', finalServerIP);
} else {
finalServerIP = new URL(location.href).hostname;
console.info('Using default server IP from window location:', finalServerIP);
}
// Use user-provided port for direct WSS
if (port && !isNaN(port)) {
finalPort = port;
console.info('Using user-provided port:', finalPort);
}
}
} else {
// HTTP protocol - direct WS connection
console.info('Running on HTTP protocol - using insecure WebSocket (WS)');
finalUseSecureConnection = false;
// Handle server IP input
if (serverIP && serverIP !== 'localhost' && serverIP !== '127.0.0.1') {
finalServerIP = stripProtocol(serverIP);
console.info('Using user-provided server IP:', finalServerIP);
} else {
finalServerIP = new URL(location.href).hostname;
console.info('Using default server IP from window location:', finalServerIP);
}
// Handle port input
if (port && !isNaN(port)) {
finalPort = port;
console.info('Using user-provided port:', finalPort);
}
}
return {
serverIP: finalServerIP,
port: finalPort,
useSecureConnection: finalUseSecureConnection,
} as ConnectionConfiguration;
}
/**
* Sets up certificate acceptance link for self-signed certificates in HTTPS mode
* Shows a link to accept certificates when using direct WSS connection (no proxy)
*
* @param serverIpInput - Input element for server IP address
* @param portInput - Input element for port number
* @param proxyUrlInput - Input element for proxy URL
* @param certAcceptanceLink - Container element for the certificate link
* @param certLink - Anchor element for the certificate URL
* @param location - Optional location object (defaults to window.location)
* @returns Cleanup function to remove event listeners
*/
export function setupCertificateAcceptanceLink(
serverIpInput: HTMLInputElement,
portInput: HTMLInputElement,
proxyUrlInput: HTMLInputElement,
certAcceptanceLink: HTMLElement,
certLink: HTMLAnchorElement,
location: Location = window.location
): () => void {
/**
* Updates the certificate acceptance link based on current configuration
* Shows link only when in HTTPS mode without proxy (direct WSS)
*/
const updateCertLink = () => {
const isHttps = location.protocol === 'https:';
const hasProxy = proxyUrlInput.value.trim().length > 0;
const portValue = parseInt(portInput.value, 10);
const defaultPort = hasProxy ? 443 : 48322;
const port = portValue || defaultPort;
// Show link only in HTTPS mode without proxy
if (isHttps && !hasProxy) {
let serverIp = serverIpInput.value.trim();
if (!serverIp) {
serverIp = new URL(location.href).hostname;
}
const url = `https://${serverIp}:${port}/`;
certAcceptanceLink.style.display = 'block';
certLink.href = url;
certLink.textContent = `Click ${url} to accept cert`;
} else {
certAcceptanceLink.style.display = 'none';
}
};
// Add event listeners to update link when inputs change
serverIpInput.addEventListener('input', updateCertLink);
portInput.addEventListener('input', updateCertLink);
proxyUrlInput.addEventListener('input', updateCertLink);
// Initial update after localStorage values are restored
setTimeout(updateCertLink, 0);
// Return cleanup function to remove event listeners
return () => {
serverIpInput.removeEventListener('input', updateCertLink);
portInput.removeEventListener('input', updateCertLink);
proxyUrlInput.removeEventListener('input', updateCertLink);
};
}