diff --git a/docs/img/panel-switcher.png b/docs/img/panel-switcher.png new file mode 100644 index 0000000..ce02452 Binary files /dev/null and b/docs/img/panel-switcher.png differ diff --git a/docs/mac-in-headset.md b/docs/mac-in-headset.md index 590a831..ed1b90a 100644 --- a/docs/mac-in-headset.md +++ b/docs/mac-in-headset.md @@ -468,3 +468,18 @@ versus on, medians of the runs, ms): window to the front first. - Ctrl stays Ctrl. On the Mac, copy is ⌘C, so use Meta+C on a keyboard paired with the Frame. + +## Switching panels and workspace limits + +**Tools → Panel switcher** lists open SteamVR panels, including Mac viewers. +Use **Show** to request focus or **Open in headset** for Frame Control's own +switcher panel. It uses SteamVR/gamescope and Chromium, with no third-party +overlay app. [Device checks and limits](panels.md#frame-controls-panel-switcher) +include the difference between a panel surviving a scene launch and staying +visible over it. + +Saved spatial layouts are blocked on this build: the public OpenVR transform +setter denies access to gamescope-owned panels. Reconnecting an existing viewer +is supported; restoring its room position after a reboot is not. We do not +save short-lived Mac window IDs or viewer access keys as if they were a durable +workspace. See [the feasibility evidence](panels.md#saved-spatial-layouts-blocked-on-the-current-panel-route). diff --git a/docs/panels.md b/docs/panels.md index b08720b..d9c47f7 100644 --- a/docs/panels.md +++ b/docs/panels.md @@ -123,3 +123,140 @@ a limit on the number of floating panels. keyboard) or virtual desktops arrange windows within the 1280×800 rectangle. - **Windows-only overlay tools** (Desktop+, OVR Toolkit, OVRdrop) do this for a PC's desktop in SteamVR. They don't run on the Frame's standalone Linux. + +## Frame Control's panel switcher + +**Verified 2026-09-28**, SteamOS 0.4.1, BUILD_ID `20260925.6191901`, +SteamVR 2.18.1: **Tools → Panel switcher** lists SteamVR's open main panels, +including panels that are currently hidden. **Show** asks SteamVR to bring one +forward. **Open in headset** opens the same switcher as its own panel; choose +it again from Steam's dashboard after switching away. Refresh updates the list. +This is a list, not thumbnail Exposé. + +![Frame Control's switcher rendered on the Frame](img/panel-switcher.png) + +This is our own Python/HTML implementation (`ui/frame_panels.py`), using the +Frame's shipped `vrcmd` OpenVR client and gamescope. The headset page uses +Chromium (Chromium XR when present, then system Chromium, then the existing +Chromium Flatpak). No XSOverlay, OVR Toolkit, WayVR or other overlay application +is needed. This dependency boundary also applies to future layout and panel +persistence work: platform APIs and bundled libraries are fine; another app +must not implement the feature for us. + +The companion runs the helper over SSH. Opening it in the headset installs a +copy under `~/.local/share/frame-control/panels/` and starts a loopback HTTP +server and an isolated Chromium profile. There is no startup service or global +setting change. Close the switcher to stop its server and browser. Other +Chromium profiles, Steam and SteamVR are left alone. If the window or runtime +closes, use **Open in headset** again. + +The page carries a random, per-process access key in its URL fragment, removes +it from the address bar, keeps it in tab session storage for page reloads, and +sends it in a header. Panel lists and actions need +that key; Host and Origin checks reject other sites. The key permits only +listing panels, requesting focus and closing this switcher. Like Mac viewer +launch tickets, it is initially readable by another process running as the +same Frame user. Panel titles are rendered as text, never HTML. The companion +retains its existing request guards. No Mac capture credentials cross this API. + +**Verified:** the real headset page rendered its panel list (image above), its +HTTP focus request changed `GAMESCOPE_FOCUSED_APP` to `2000999030`, a request +without the key returned HTTP 403, and Close stopped the helper and its browser. +Opening an already running switcher requests its focus rather than creating a +second one. The companion uses the same list/focus helper. **Unverified:** laser +selection while wearing the headset, physical placement, and non-XR Chromium. +The API reports that focus was *requested*: another action can take focus before +we observe the result. Closed panels are rejected after re-enumeration. + +## Saved spatial layouts: blocked on the current panel route + +**Verified 2026-09-28**, same build, using a temporary xterm panel with +`STEAM_GAME=2000999031` and `FnTable:IVROverlay_028` from +`/opt/steamvr/bin/linuxarm64/libopenvr_api.so`: + +| OpenVR call | Result | +|---|---| +| `FindOverlay("valve.steam.desktopgame.2000999031")` | Success | +| `GetOverlayWidthInMeters` | Success, 2.67 m | +| `SetOverlayWidthInMeters` (same width) | Success | +| `GetOverlayTransformType` | Success, type 5 (`VROverlayTransform_DashboardTab`) | +| `GetOverlayTransformAbsolute` | 18 (`WrongTransformType`) | +| `SetOverlayTransformAbsolute` (identity rotation, 1.2 m up, 1.5 m forward) | 12 (`PermissionDenied`); type remained 5 | + +The public interface names type 5 **DashboardTab**; SteamVR's dashboard code +places these panels through its scene graph. It owns the frame/docking +transforms. A successful width setter does not grant permission to restore the +position. `vrcmd --dock-overlay world ` dispatched a docking request but +the dashboard logged `Failed to get SGTransform in setInitialTransformForLocation. +Invalid transform ID`. This does not establish working world placement. + +**Inferred:** saving X11 pixel rectangles or Mac window IDs would not restore +this spatial arrangement. Mac window IDs also change when an application +reopens; viewer tickets and reconnect keys must not go into a layout file. +The base Mac stream reconnects after a network break, but that is different +from recreating windows and their room positions after a reboot. + +There is consequently no Save/Restore control yet. A durable layout needs a +working transform restore path, stable source identity, and a fresh capture +permission/ticket flow. The tested gamescope-owned overlay route denies that +transform operation. A future Frame Control-owned overlay renderer, or a +supported platform API for dashboard frame transforms, needs its own device +proof before building layout UI. This is a blocker for the current approach, +not a claim that all possible implementations are impossible. Reboot recovery +was not tested: the shared headset was not rebooted. + +## Panels during an immersive session + +**Verified 2026-09-28**, same build: our Chromium switcher panel remained in +OpenVR's overlay list before, during and after the Frame's shipped `helloxr -g +Vulkan` sample. During the test `vrcmd --stats` identified +`system.generated.openxr.helloxr.helloxr`, with 242 frame submissions. The test +ended only its own sample process; no SteamVR, Steam, power or global settings +were changed. The switcher was still selectable afterwards. + +This proves survival of that panel across an OpenXR scene session, **not** that +it stayed visibly composited over the scene: OpenVR reported it `not_visible` +before, during and after. **Verified:** calling `ShowOverlay` on our +*gamescope-owned* switcher overlay returns 12 (`PermissionDenied`). A helper +cannot force that panel visible using the public overlay call. Use the +switcher/dashboard to request access to it; we do not fight the runtime with a +repeated force-focus loop. + +**Verified in a second controlled run:** a live H.264 test-pattern stream from +this checkout's Mac helper, through its own SSH tunnel and a temporary Chromium +profile, survived the same OpenXR sample (257 scene-frame submissions). Its +panel `2000999032` changed from `visible` before launch to `not_visible` during +and after the scene. The Mac helper still reported the same `test` stream; +captured frames increased from 35 to 232, with 29.5 decoded/drawn fps afterwards. +The test did not capture personal Mac windows or inject Mac input. The sample, +viewer, temporary profile, tunnel and Mac helper were cleaned up. This proves +stream survival, and also shows why it must not be advertised as always visible. + +**Unverified:** persistent visible placement while playing a Steam-launched VR +game, Plasma desktop and real Mac-window behavior during that launch, and worn +headset input. Other threads were launching games and changing the runtime on +the shared device, so those transitions were not treated as controlled evidence. +A runtime/X-server restart can destroy the viewer windows; a network reconnect +cannot recreate them. No “always visible during games” guarantee is shipped. + +## Keyboard passthrough feasibility + +**Verified 2026-09-28**, same build, using `FnTable:IVRTrackedCamera_006`: +`HasCamera(0)` returned success and true. `GetCameraFrameSize` returned 100 +(`OperationFailed`), with zero dimensions, for all three public frame types +(distorted, undistorted and maximum-undistorted), including after acquiring the +video service. Acquisition returned success and a handle; release returned 101 +(`InvalidHandle`). The probe shut down its OpenVR client afterwards. No camera +frames were captured and no camera settings were changed. + +**Documented:** the public OpenVR camera interface provides camera frame sizes, +intrinsics, projections and streaming handles; these are prerequisites for a +spatially aligned camera cutout. See Valve's +[OpenVR C API](https://github.com/ValveSoftware/openvr/blob/master/headers/openvr_capi.h). + +**Inferred:** camera presence alone does not establish access to camera pixels. +The failed frame-size path blocks a keyboard cutout in our current panel +implementation. We have not established a keyboard detector or a calibrated +camera-to-panel mapping. Built-in full-room passthrough is not proof of a +public, selectively masked camera stream. No keyboard cutout is offered, and +no third-party camera/overlay app is substituted for it. diff --git a/docs/testing.md b/docs/testing.md index b06577d..a36b7b7 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -148,3 +148,20 @@ For example, on 2026-09-27 the smoke test found that Steam's `create-shortcut` refuses ids with a hyphen (`missing/invalid arguments`), which the fake had accepted. The fake now refuses them the same way, and Frame Control makes ids Steam accepts. + +## Panel switcher + +`tests/test_panels.py` supplies fake-Frame `vrcmd --overlays` output, checks +main-panel filtering (including hidden panels), revalidates closed panels before +focus, and drives the headset helper's real loopback HTTP server to test access +keys, Host/Origin guards, malformed requests, offline errors and Close. It runs +in the normal unit suite without OpenVR or a headset. The fixture format comes +from SteamVR 2.18.1, BUILD_ID `20260925.6191901`; it does not simulate rendering. + +On the Frame, run `python3 -` over SSH with `ui/frame_panels.py` on stdin to +list panels. `--focus ` rechecks the list and requests focus. In Frame +Control, **Tools → Panel switcher → Open in headset** exercises installation, +Chromium rendering and the same helper through HTTP. Close the switcher after +testing. [The recorded device checks](panels.md#frame-controls-panel-switcher) +cover actual focus, HTTP guards and an OpenXR sample transition, and separately +identify the unverified Steam-game, spatial layout, reboot and laser behaviors. diff --git a/tests/test_panels.py b/tests/test_panels.py new file mode 100644 index 0000000..6cfb241 --- /dev/null +++ b/tests/test_panels.py @@ -0,0 +1,102 @@ +"""Fake-Frame panel responses and the headset page's real HTTP guards.""" +import http.client +import json +from pathlib import Path +import sys +import threading +import unittest +from unittest.mock import patch + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent / 'ui')) +import frame_panels as panels + +# Shape verified with vrcmd on SteamVR 2.18.1 / BUILD_ID 20260925.6191901. +OVERLAYS = """---- OVERLAYS ---- +'valve.steam.desktopgame.12' -- 'Alex's ', 1920x1080 visible VROverlayType_Dashboard_Main +'valve.steam.desktopgame.12.thumb' -- 'Thumb', not_visible VROverlayType_Dashboard_Thumbnail +'valve.steam.desktopgame.12.layer1' -- 'Layer', visible VROverlayType_Subview +'system.pointer' -- 'Pointer', visible VROverlayType_Basic +'valve.steam.desktopgame.13' -- 'Other', not_visible VROverlayType_Dashboard_Main +""" + + +class Panels(unittest.TestCase): + def test_enumerates_only_main_panels_including_hidden(self): + rows = panels.parse_overlays(OVERLAYS) + self.assertEqual(len(rows), 2) + self.assertEqual(rows[0]['title'], "Alex's ") + self.assertTrue(rows[0]['visible']) + self.assertFalse(rows[1]['visible']) + + def test_unavailable_runtime_is_not_an_empty_workspace(self): + with self.assertRaises(panels.PanelError): + panels.parse_overlays('SteamVR not running') + self.assertEqual(panels.parse_overlays('---- OVERLAYS ----'), []) + + @patch.object(panels, 'run', return_value=OVERLAYS) + def test_focus_revalidates_and_dispatches_without_shell(self, run): + key = 'valve.steam.desktopgame.12' + self.assertEqual(panels.focus(key)['requested'], key) + self.assertEqual(run.call_args.args[0], [panels.VRCMD, '--showdashboard', key]) + + @patch.object(panels, 'run', return_value=OVERLAYS) + def test_closed_or_injected_panel_never_dispatches(self, run): + for key in (None, 12, 'x;touch /tmp/bad', '../other', 'missing'): + with self.assertRaises(panels.PanelError): + panels.focus(key) + self.assertEqual(run.call_count, 1) # only valid-looking 'missing' enumerates + + +class HeadsetHTTP(unittest.TestCase): + def setUp(self): + self.server = panels.HTTPServer(('127.0.0.1', 0), panels.Handler) + self.server.key = 'test-key' + self.server.closing = False + self.thread = threading.Thread(target=self.server.serve_forever, daemon=True) + self.thread.start() + + def tearDown(self): + self.server.shutdown() + self.thread.join() + self.server.server_close() + + def request(self, path, body=None, headers=None): + c = http.client.HTTPConnection('127.0.0.1', self.server.server_port, timeout=5) + try: + c.request('POST' if body is not None else 'GET', path, body=body, headers=headers or {}) + r = c.getresponse() + return r.status, r.read().decode() + finally: + c.close() + + def test_page_is_public_but_contains_no_key_or_private_titles(self): + status, page = self.request('/') + self.assertEqual(status, 200) + self.assertNotIn('test-key', page) + self.assertIn('textContent=p.title', page) # titles never become HTML + self.assertEqual(self.request('/panels')[0], 403) + + @patch.object(panels, 'state', return_value={'panels': []}) + def test_auth_host_and_origin_checks(self, state): + auth = {'X-Panel-Key': 'test-key'} + self.assertEqual(self.request('/panels', headers=auth)[0], 200) + for extra in ({'Host': 'evil.test'}, {'Origin': 'https://evil.test'}, {'X-Panel-Key': 'wrong'}): + self.assertEqual(self.request('/panels', headers={**auth, **extra})[0], 403) + self.assertEqual(state.call_count, 1) + + @patch.object(panels, 'focus', return_value={'requested': 'panel'}) + def test_post_validation_and_close(self, focus): + auth = {'X-Panel-Key': 'test-key'} + for body in ('[]', '{broken', '0', '"text"', 'x' * 1025): + self.assertEqual(self.request('/focus', body, auth)[0], 400) + self.assertEqual(focus.call_count, 0) + self.assertEqual(self.request('/focus', '{"key":"panel"}', auth)[0], 200) + self.assertEqual(focus.call_args.args, ('panel',)) + self.assertEqual(self.request('/close', '{}', auth)[0], 200) + self.assertTrue(self.server.closing) + + @patch.object(panels, 'state', side_effect=panels.PanelError('offline')) + def test_offline_is_an_error_not_a_successful_empty_list(self, state): + status, body = self.request('/panels', headers={'X-Panel-Key': 'test-key'}) + self.assertEqual(status, 502) + self.assertEqual(json.loads(body), {'error': 'offline'}) diff --git a/ui/frame_panels.py b/ui/frame_panels.py new file mode 100644 index 0000000..4bf753c --- /dev/null +++ b/ui/frame_panels.py @@ -0,0 +1,261 @@ +"""Panel switcher. Runs on the Frame, either piped over SSH or installed with +--open for its loopback-only headset page. Uses Valve's shipped vrcmd and +Chromium, not an overlay app. Spatial layout limitations: docs/panels.md. +""" +import argparse +import hmac +import json +import os +from pathlib import Path +import re +import secrets +import signal +import subprocess +import sys +import time +from http.server import BaseHTTPRequestHandler, HTTPServer + +VRCMD = '/opt/steamvr/bin/linuxarm64/vrcmd' +PANEL_ID = 2000999030 +PANEL_KEY = 'valve.steam.desktopgame.' + str(PANEL_ID) +KEY = re.compile(r'[A-Za-z0-9_.:-]{1,200}\Z') + + +class PanelError(Exception): + pass + + +def run(args): + try: + p = subprocess.run(args, capture_output=True, text=True, timeout=10, + env={**os.environ, 'DISPLAY': ':0', 'LC_ALL': 'C.UTF-8'}) + except (OSError, subprocess.TimeoutExpired) as e: + raise PanelError('The panel service did not answer: ' + str(e)) + if p.returncode: + raise PanelError((p.stderr or p.stdout).strip()[-400:] or 'Panel command failed') + return p.stdout + + +def parse_overlays(text): + """Only main dashboard panels, never their thumbnails, layers or cursors. + vrcmd output verified on SteamVR 2.18.1, BUILD_ID 20260925.6191901. + """ + if '---- OVERLAYS ----' not in text: + raise PanelError('SteamVR did not return its panel list. Is the headset awake?') + panels = [] + for line in text.splitlines(): + m = re.fullmatch(r"'([^']+)' -- '(.*)', (.*?) VROverlayType_Dashboard_Main\s*", line) + if m and KEY.fullmatch(m[1]): + panels.append({'key': m[1], 'title': 'Panel switcher' if m[1] == PANEL_KEY else m[2] or m[1], + 'visible': 'not_visible' not in m[3]}) + return panels + + +def state(): + return {'panels': parse_overlays(run([VRCMD, '--overlays']))} + + +def focus(key): + if not isinstance(key, str) or not KEY.fullmatch(key): + raise PanelError('Choose an open panel.') + if key not in {p['key'] for p in state()['panels']}: + raise PanelError('That panel has closed. Refresh the list.') + run([VRCMD, '--showdashboard', key]) + # vrcmd acknowledges dispatch, not final focus (a game or the user can + # switch again). Do not report a focus success without observing it. + return {'requested': key, 'message': 'Asked SteamVR to show the panel.'} + + +PAGE = ''' +Panel switcher [fc-panels] + +

Panel switcher

Choose a panel to show it. Open this panel again from Steam's dashboard.

+ +

+''' + + +class Handler(BaseHTTPRequestHandler): + def setup(self): + super().setup() + self.connection.settimeout(5) + + def log_message(self, *args): + pass # never log the page's access key + + def reply(self, code, value, html=False): + data = value.encode() if html else json.dumps(value).encode() + self.send_response(code) + self.send_header('Content-Type', 'text/html; charset=utf-8' if html else 'application/json') + self.send_header('Content-Length', str(len(data))) + self.send_header('Cache-Control', 'no-store') + self.send_header('X-Content-Type-Options', 'nosniff') + self.send_header('Referrer-Policy', 'no-referrer') + self.send_header('Content-Security-Policy', "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; connect-src 'self'; frame-ancestors 'none'") + self.end_headers() + self.wfile.write(data) + + def allowed(self): + host = '127.0.0.1:' + str(self.server.server_port) + origin = self.headers.get('Origin') + return (self.headers.get('Host') == host and + (origin is None or origin == 'http://' + host) and + hmac.compare_digest(self.headers.get('X-Panel-Key', '').encode(), self.server.key.encode())) + + def do_GET(self): + if self.path == '/': + return self.reply(200, PAGE, html=True) # no data or access key in the page + if not self.allowed(): + return self.reply(403, {'error': 'Open the switcher from Frame Control.'}) + try: + if self.path == '/panels': + return self.reply(200, state()) + self.reply(404, {'error': 'Not found'}) + except PanelError as e: + self.reply(502, {'error': str(e)}) + + def do_POST(self): + if not self.allowed(): + return self.reply(403, {'error': 'Forbidden'}) + try: + size = int(self.headers.get('Content-Length', '0')) + if not 0 < size <= 1024: + raise ValueError('Invalid request size') + body = json.loads(self.rfile.read(size)) + if not isinstance(body, dict): + raise ValueError('Expected an object') + if self.path == '/focus': + return self.reply(200, focus(body.get('key'))) + if self.path == '/close': + self.server.closing = True + return self.reply(200, {'closed': True}) + self.reply(404, {'error': 'Not found'}) + except (ValueError, PanelError) as e: + self.reply(400, {'error': str(e)}) + + +def serve(): + """Own only our Chromium profile and process group. No changes to Steam, + SteamVR, other Chromium sessions, or global power settings. + """ + import fcntl # only on the Frame; module/tests also import on Windows + folder = Path.home() / '.local/share/frame-control/panels' + folder.mkdir(parents=True, exist_ok=True, mode=0o700) + with (folder / 'lock').open('w') as lock: + try: + fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) + except BlockingIOError: + print(json.dumps(focus(PANEL_KEY)), flush=True) + return + chrome = Path.home() / 'chromium-xr/chrome' + if chrome.is_file(): + command = [str(chrome)] + profile = folder / 'chromium' + elif Path('/usr/bin/chromium').is_file(): + command = ['/usr/bin/chromium'] + profile = folder / 'chromium' + elif subprocess.run(['flatpak', 'info', 'org.chromium.Chromium'], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=10).returncode == 0: + command = ['flatpak', 'run', 'org.chromium.Chromium'] + profile = Path.home() / '.var/app/org.chromium.Chromium/data/frame-panel-switcher' + else: + raise PanelError('The headset switcher needs Chromium. The companion switcher still works.') + server = HTTPServer(('127.0.0.1', 0), Handler) + server.key = secrets.token_urlsafe(32) + server.closing = False + server.timeout = .5 + url = 'http://127.0.0.1:%d/#%s' % (server.server_port, server.key) + env = {**os.environ, 'DISPLAY': ':0'} + env.pop('WAYLAND_DISPLAY', None) + browser = subprocess.Popen([*command, '--ozone-platform=x11', + '--user-data-dir=' + str(profile), + '--no-first-run', '--no-default-browser-check', + '--password-store=basic', '--window-size=1200,800', '--app=' + url], + env=env, start_new_session=True, stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + def stop(signum, frame): + server.closing = True + signal.signal(signal.SIGTERM, stop) + win = None + try: + deadline = time.monotonic() + 30 + while not win and time.monotonic() < deadline and browser.poll() is None: + server.handle_request() # Chromium must fetch the page before it has a title + for line in run(['xwininfo', '-root', '-children']).splitlines(): + m = re.match(r'\s*(0x[0-9a-fA-F]+) .*\[fc-panels\]', line) + if m: + win = m[1] + break + if not win: + raise PanelError('The switcher window did not appear within 30 seconds.') + run(['xprop', '-id', win, '-f', 'STEAM_GAME', '32c', '-set', 'STEAM_GAME', str(PANEL_ID)]) + print(json.dumps({'message': 'Opened the panel switcher in the headset.'}), flush=True) + # Caller reads exactly one line, then disconnects; no more stdout. + while not server.closing and browser.poll() is None: + server.handle_request() + # Closing the last app window need not exit Chromium. + if win not in run(['xwininfo', '-root', '-children']): + break + finally: + server.server_close() + if browser.poll() is None: + os.killpg(browser.pid, signal.SIGTERM) + try: + browser.wait(timeout=5) + except subprocess.TimeoutExpired: + os.killpg(browser.pid, signal.SIGKILL) + browser.wait() + + +def open_switcher(): + # This command runs from an installed path, never from the SSH stdin copy. + proc = subprocess.Popen([sys.executable, str(Path(__file__).resolve()), '--serve'], + stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, + text=True, start_new_session=True) + line = proc.stdout.readline() + proc.stdout.close() + if not line: + raise PanelError('The headset switcher could not start.') + result = json.loads(line) + if 'error' in result: + raise PanelError(result['error']) + return result + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--focus') + parser.add_argument('--open', action='store_true') + parser.add_argument('--serve', action='store_true') + args = parser.parse_args() + try: + if args.serve: + serve() + else: + print(json.dumps(open_switcher() if args.open else focus(args.focus) if args.focus else state())) + except (PanelError, OSError) as e: + print(json.dumps({'error': str(e)}), flush=True) + return 1 + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/ui/index.html b/ui/index.html index d9491e4..cc87cf5 100644 --- a/ui/index.html +++ b/ui/index.html @@ -650,6 +650,15 @@ +
+

Panel switcher

+ +
+
Refresh to see open panels.
+
Choose a panel to show it in SteamVR. The headset switcher stays available from Steam's dashboard. + Saved spatial layouts are not available yet.
+
+