diff --git a/docs/source/conf.py b/docs/source/conf.py index 7725b3a15..2eabb96a6 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -77,6 +77,24 @@ } _icons = _VERSION_ICON_MAP.get(_smv_name, _DEFAULT_ICONS) +# Branch-specific CloudXR web client ("CloudXR.js") deployment. docs.yaml +# publishes the prebuilt web client to ``/client//`` where ```` is +# the built ref name with ``/`` replaced by ``-`` (e.g. ``main``, +# ``release-1.3.x``, ``v1.2.3``). Resolving the same slug here lets each +# versioned docs build link to the matching client instead of always ``main``. +_client_slug = (_smv_name or "main").replace("/", "-") +_web_client_url = f"https://nvidia.github.io/IsaacTeleop/client/{_client_slug}/" + +# Shared substitution + link targets injected into every page, so the +# branch-specific web client URL lives in one place. ``|web_client_url|`` +# expands the bare URL (usable in prose and ``parsed-literal`` blocks); the +# named targets back ```...`_`` references in the prose. +rst_epilog = f""" +.. |web_client_url| replace:: {_web_client_url} +.. _`nvidia.github.io/IsaacTeleop/client`: {_web_client_url} +.. _`Isaac Teleop Web Client`: {_web_client_url} +""" + html_theme_options = { "collapse_navigation": True, "use_edit_page_button": True, diff --git a/docs/source/getting_started/build_from_source/webxr.rst b/docs/source/getting_started/build_from_source/webxr.rst index 902f05afa..c649995ca 100644 --- a/docs/source/getting_started/build_from_source/webxr.rst +++ b/docs/source/getting_started/build_from_source/webxr.rst @@ -6,7 +6,7 @@ Build WebXR Client .. tip:: - A prebuilt CloudXR web client is available at https://nvidia.github.io/IsaacTeleop/client/main/. + A prebuilt CloudXR web client is available at |web_client_url|. You only need to build from source if you want to customize the client. The Isaac Teleop WebXR client is built with React, Three.js, and WebXR. It streams VR/AR content diff --git a/docs/source/getting_started/quick_start.rst b/docs/source/getting_started/quick_start.rst index d9c3bed4d..295e9df18 100644 --- a/docs/source/getting_started/quick_start.rst +++ b/docs/source/getting_started/quick_start.rst @@ -348,7 +348,6 @@ More Information .. References -.. _`nvidia.github.io/IsaacTeleop/client`: https://nvidia.github.io/IsaacTeleop/client/main/ .. _`CloudXR documentation`: https://docs.nvidia.com/cloudxr-sdk/latest/index.html .. _`Isaac XR Teleop Sample Client for Apple Vision Pro`: https://github.com/isaac-sim/isaac-xr-teleop-sample-client-apple .. _`Isaac Lab Quick Start`: https://isaac-sim.github.io/IsaacLab/develop/source/setup/quickstart.html diff --git a/docs/source/overview/ecosystem.rst b/docs/source/overview/ecosystem.rst index 5e1ccca2e..22a06fc4e 100644 --- a/docs/source/overview/ecosystem.rst +++ b/docs/source/overview/ecosystem.rst @@ -205,7 +205,6 @@ directly. Each device name in the tables above links to the corresponding manufa .. Other references .. _`Isaac XR Teleop Sample Client`: https://github.com/isaac-sim/isaac-xr-teleop-sample-client-apple -.. _`Isaac Teleop Web Client`: https://nvidia.github.io/IsaacTeleop/client .. _`Manus Gloves Plugin`: https://github.com/NVIDIA/IsaacTeleop/tree/main/src/plugins/manus .. _`Generic 3-axis Pedal Plugin`: https://github.com/NVIDIA/IsaacTeleop/tree/main/src/plugins/generic_3axis_pedal .. _`OAK-D Camera Plugin`: https://github.com/NVIDIA/IsaacTeleop/tree/main/src/plugins/oak diff --git a/docs/source/references/oob_teleop_control.rst b/docs/source/references/oob_teleop_control.rst index fc9b66981..731d01e36 100644 --- a/docs/source/references/oob_teleop_control.rst +++ b/docs/source/references/oob_teleop_control.rst @@ -62,9 +62,9 @@ If the adb automation fails (e.g. headset not paired), you can manually open the client URL on the headset browser with **all three** required query parameters — ``oobEnable``, ``serverIP``, and ``port``: -.. code-block:: text +.. parsed-literal:: - https://nvidia.github.io/IsaacTeleop/client/main/?oobEnable=1&serverIP=&port=48322 + |web_client_url|\ ?oobEnable=1&serverIP=&port=48322 Replace ```` with the streaming host's LAN IP. The ``port`` must match the proxy port (default 48322). @@ -310,9 +310,9 @@ Web client integration The WebXR client connects to the hub when the page URL contains ``oobEnable=1`` plus ``serverIP`` and ``port``: -.. code-block:: text +.. parsed-literal:: - https://nvidia.github.io/IsaacTeleop/client/main/?oobEnable=1&serverIP=10.0.0.1&port=48322 + |web_client_url|\ ?oobEnable=1&serverIP=10.0.0.1&port=48322 The client builds ``wss://{serverIP}:{port}/oob/v1/ws`` and: diff --git a/src/core/cloudxr/python/__main__.py b/src/core/cloudxr/python/__main__.py index 138f45c67..b360f62c3 100644 --- a/src/core/cloudxr/python/__main__.py +++ b/src/core/cloudxr/python/__main__.py @@ -5,7 +5,6 @@ import argparse import os -import re import signal import sys import time @@ -34,6 +33,7 @@ usb_backend_port, usb_turn_port, usb_ui_port, + versioned_web_client_url, ) @@ -84,7 +84,8 @@ def _parse_args() -> argparse.Namespace: f"and HTTPS static WebXR UI on port {usb_ui_port()} " "(override via USB_UI_PORT env). Files live under " "TELEOP_WEB_CLIENT_STATIC_DIR or ~/.cloudxr/static-client; missing " - "index.html / bundle.js are downloaded from nvidia.github.io/IsaacTeleop/client/main/. " + "index.html / bundle.js are downloaded from the matching versioned " + "client under nvidia.github.io/IsaacTeleop/client/. " "The launcher serves them with the same PEM as the WSS proxy. " "Requirements: `coturn`, `adb` on PATH. WebRTC ICE still needs a " "non-loopback interface on the headset (WiFi stays connected)." @@ -182,20 +183,12 @@ def main() -> None: ) print_oob_hub_startup_banner(lan_host=resolve_lan_host_for_oob()) else: - # Print the WebXR client matching this release line, derived from - # the installed version's leading MAJOR.MINOR (present for every - # build type). The client is published per release line on GitHub - # Pages. In OOB modes the banner above already prints a complete, - # mode-correct client URL instead (GitHub Pages /main/ for WiFi, a - # local https URL for USB-local), so this standalone line is + # Print the WebXR client matching the installed version. In OOB + # modes the banner above already prints a complete, mode-correct + # client URL instead (the same versioned GitHub Pages client for + # WiFi, a local https URL for USB-local), so this standalone line is # skipped there to avoid a redundant or misleading second URL. - client_base = "https://nvidia.github.io/IsaacTeleop/client/" - ver_match = re.match(r"(\d+)\.(\d+)", isaacteleop_version) - client_url = ( - f"{client_base}release-{ver_match.group(1)}.{ver_match.group(2)}.x/" - if ver_match - else client_base - ) + client_url = versioned_web_client_url(isaacteleop_version) print(f"WebXR client: \033[36m{client_url}\033[0m") print( f"Activate CloudXR environment in another terminal: \033[1;32msource {env_cfg.env_filepath()}\033[0m" diff --git a/src/core/cloudxr/python/oob_teleop_adb.py b/src/core/cloudxr/python/oob_teleop_adb.py index 87143a298..9b8d8d8b5 100644 --- a/src/core/cloudxr/python/oob_teleop_adb.py +++ b/src/core/cloudxr/python/oob_teleop_adb.py @@ -34,7 +34,7 @@ import time import urllib.request from .oob_teleop_env import ( - DEFAULT_WEB_CLIENT_ORIGIN, + default_web_client_origin, parse_env_port, build_headset_bookmark_url, client_ui_fields_from_env, @@ -486,7 +486,7 @@ def build_teleop_url(*, resolved_port: int, usb_local: bool = False) -> str: **client_ui_fields_from_env(), } ovr = web_client_base_override_from_env() - web_base = ovr if ovr else DEFAULT_WEB_CLIENT_ORIGIN + web_base = ovr if ovr else default_web_client_origin() token = os.environ.get("CONTROL_TOKEN") or None return build_headset_bookmark_url( @@ -1199,7 +1199,7 @@ def clear_headset_browser_cache(*, usb_local: bool) -> int: Returns 0 if the browser isn't running. Never raises. """ from .oob_teleop_env import ( # noqa: PLC0415 - DEFAULT_WEB_CLIENT_ORIGIN, + default_web_client_origin, usb_ui_port, web_client_base_override_from_env, ) @@ -1240,9 +1240,9 @@ def clear_headset_browser_cache(*, usb_local: bool) -> int: origins.append(f"https://127.0.0.1:{ui_port}") else: origin_base = ( - web_client_base_override_from_env() or DEFAULT_WEB_CLIENT_ORIGIN + web_client_base_override_from_env() or default_web_client_origin() ) - # DEFAULT_WEB_CLIENT_ORIGIN has trailing slash + path; reduce to origin. + # The client base has a trailing slash + path; reduce to origin. from urllib.parse import urlparse # noqa: PLC0415 parsed = urlparse(origin_base) diff --git a/src/core/cloudxr/python/oob_teleop_env.py b/src/core/cloudxr/python/oob_teleop_env.py index 3666b558b..064945f0a 100644 --- a/src/core/cloudxr/python/oob_teleop_env.py +++ b/src/core/cloudxr/python/oob_teleop_env.py @@ -15,6 +15,7 @@ import sys import threading import time +from importlib.metadata import PackageNotFoundError, version from pathlib import Path from urllib.error import URLError from urllib.parse import urlencode, urljoin @@ -26,11 +27,50 @@ WSS_PROXY_DEFAULT_PORT = 48322 -DEFAULT_WEB_CLIENT_ORIGIN = "https://nvidia.github.io/IsaacTeleop/client/main/" +# GitHub Pages WebXR client root. The published client lives under a per-ref +# slug (``main``, ``release-1.3.x``, ``v1.2.3``, ...); the docs build emits one +# slug per ref it builds. :func:`default_web_client_origin` resolves the slug +# for the installed version so OOB opens the matching client. +WEB_CLIENT_BASE = "https://nvidia.github.io/IsaacTeleop/client/" + +# Origin used when the installed version can't be resolved (dev trees, tests). +FALLBACK_WEB_CLIENT_ORIGIN = urljoin(WEB_CLIENT_BASE, "main/") + + +def versioned_web_client_url(version: str) -> str: + """GitHub Pages WebXR client URL matching *version*. + + A clean ``MAJOR.MINOR.PATCH`` release (a tag build) maps to the per-tag + client ``client/vMAJOR.MINOR.PATCH/``. Pre-release / dev builds (``1.2.4rc1``, + ``1.3.0.dev5``, ...) and any other version with a leading MAJOR.MINOR map to + the release line ``client/release-MAJOR.MINOR.x/``. Versions with no + parseable MAJOR.MINOR fall back to the generic ``client/`` URL, which the + site redirects to the latest stable tag. The same helper backs the + standalone "WebXR client:" line printed in non-OOB mode, so every path + agrees on which client to open. + """ + v = version.strip() + if re.fullmatch(r"\d+\.\d+\.\d+", v): + return urljoin(WEB_CLIENT_BASE, f"v{v}/") + m = re.match(r"(\d+)\.(\d+)", v) + if m: + return urljoin(WEB_CLIENT_BASE, f"release-{m.group(1)}.{m.group(2)}.x/") + return WEB_CLIENT_BASE + + +def default_web_client_origin() -> str: + """Versioned WebXR client origin for the installed ``isaacteleop`` version. + + Reads the installed distribution version (namespace-independent, so it works + under the test package alias too) and maps it via + :func:`versioned_web_client_url`. Falls back to + :data:`FALLBACK_WEB_CLIENT_ORIGIN` when the version can't be determined. + """ + try: + return versioned_web_client_url(version("isaacteleop")) + except PackageNotFoundError: + return FALLBACK_WEB_CLIENT_ORIGIN -# Published WebXR client files (same origin as ``DEFAULT_WEB_CLIENT_ORIGIN``). -USB_LOCAL_STATIC_INDEX_URL = urljoin(DEFAULT_WEB_CLIENT_ORIGIN, "index.html") -USB_LOCAL_STATIC_BUNDLE_URL = urljoin(DEFAULT_WEB_CLIENT_ORIGIN, "bundle.js") # Upper bound for downloaded client assets (supply-chain / accident guard). _USB_LOCAL_ASSET_MAX_BYTES = 32 * 1024 * 1024 @@ -154,9 +194,10 @@ def require_usb_local_webxr_static_dir() -> Path: f"Cannot create USB-local WebXR static directory {p}: {exc}" ) from exc + client_origin = default_web_client_origin() assets = ( - ("index.html", USB_LOCAL_STATIC_INDEX_URL), - ("bundle.js", USB_LOCAL_STATIC_BUNDLE_URL), + ("index.html", urljoin(client_origin, "index.html")), + ("bundle.js", urljoin(client_origin, "bundle.js")), ) for name, url in assets: dest = p / name @@ -504,7 +545,7 @@ def print_oob_hub_startup_banner( or f"https://localhost:{ui_port}" ) else: - web_base = DEFAULT_WEB_CLIENT_ORIGIN + web_base = default_web_client_origin() stream_cfg: dict = {"serverIP": primary_host, "port": port} if usb_local: diff --git a/src/core/cloudxr_tests/python/test_oob_teleop_env.py b/src/core/cloudxr_tests/python/test_oob_teleop_env.py index 09a402ee2..3ccb8fb47 100644 --- a/src/core/cloudxr_tests/python/test_oob_teleop_env.py +++ b/src/core/cloudxr_tests/python/test_oob_teleop_env.py @@ -11,16 +11,20 @@ import pytest from cloudxr_py_test_ns.oob_teleop_env import ( + FALLBACK_WEB_CLIENT_ORIGIN, TELEOP_WEB_CLIENT_BASE_ENV, TELEOP_WEB_CLIENT_STATIC_DIR_ENV, USB_BACKEND_DEFAULT_PORT, USB_TURN_DEFAULT_PORT, USB_UI_DEFAULT_PORT, + WEB_CLIENT_BASE, WSS_PROXY_DEFAULT_PORT, build_headset_bookmark_url, client_ui_fields_from_env, default_initial_stream_config, + default_web_client_origin, guess_lan_ipv4, + versioned_web_client_url, print_oob_hub_startup_banner, require_usb_local_webxr_static_dir, resolve_lan_host_for_oob, @@ -130,6 +134,56 @@ def test_web_client_base_override_from_env( assert web_client_base_override_from_env() == "https://example.test/app" +def test_versioned_web_client_url_exact_tag() -> None: + # A clean MAJOR.MINOR.PATCH release maps to the per-tag client. + assert ( + versioned_web_client_url("1.2.3") + == "https://nvidia.github.io/IsaacTeleop/client/v1.2.3/" + ) + + +def test_versioned_web_client_url_prerelease_uses_release_line() -> None: + # rc / dev builds fall to the release line, not a per-tag client. + assert ( + versioned_web_client_url("1.3.9rc1") + == "https://nvidia.github.io/IsaacTeleop/client/release-1.3.x/" + ) + assert ( + versioned_web_client_url("1.3.0.dev5") + == "https://nvidia.github.io/IsaacTeleop/client/release-1.3.x/" + ) + + +def test_versioned_web_client_url_unparseable_falls_back_to_base() -> None: + # No leading MAJOR.MINOR -> generic client root (site redirects to stable). + assert versioned_web_client_url("unknown") == WEB_CLIENT_BASE + + +def test_default_web_client_origin_uses_installed_version( + monkeypatch: pytest.MonkeyPatch, +) -> None: + import cloudxr_py_test_ns.oob_teleop_env as mod + + monkeypatch.setattr(mod, "version", lambda _name: "1.4.0", raising=False) + assert ( + default_web_client_origin() + == "https://nvidia.github.io/IsaacTeleop/client/v1.4.0/" + ) + + +def test_default_web_client_origin_falls_back_when_version_missing( + monkeypatch: pytest.MonkeyPatch, +) -> None: + import cloudxr_py_test_ns.oob_teleop_env as mod + from importlib.metadata import PackageNotFoundError + + def _raise(_name: str) -> str: + raise PackageNotFoundError(_name) + + monkeypatch.setattr(mod, "version", _raise, raising=False) + assert default_web_client_origin() == FALLBACK_WEB_CLIENT_ORIGIN + + def test_build_headset_bookmark_url_minimal() -> None: u = build_headset_bookmark_url( web_client_base="https://h.test/",