mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-06 02:00:19 +02:00
Merge pull request #41 from saphid/panel-workspaces
Add panel switchers and document workspace feasibility
This commit is contained in:
8 files changed
+621
-3
No files matched your search
Binary file not shown.
|
After Width: | Height: | Size: 74 KiB |
@@ -468,3 +468,18 @@ versus on, medians of the runs, ms):
|
|||||||
window to the front first.
|
window to the front first.
|
||||||
- Ctrl stays Ctrl. On the Mac, copy is ⌘C, so use Meta+C on a keyboard paired
|
- Ctrl stays Ctrl. On the Mac, copy is ⌘C, so use Meta+C on a keyboard paired
|
||||||
with the Frame.
|
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).
|
||||||
+152
@@ -124,6 +124,158 @@ a limit on the number of floating panels.
|
|||||||
- **Windows-only overlay tools** (Desktop+, OVR Toolkit, OVRdrop) do this for a
|
- **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.
|
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é.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
### Shared-device recheck, 2026-09-29
|
||||||
|
|
||||||
|
**Verified:** the follow-up's atomic `mkdir /tmp/frame-test.lock` attempts
|
||||||
|
failed because another thread held the lock. The existing lock was left alone;
|
||||||
|
no applications were installed, launched or stopped in this follow-up. The last
|
||||||
|
read-only battery check showed 62%, charging. The 180 Python and 8 website tests
|
||||||
|
passed again locally.
|
||||||
|
|
||||||
|
**Unverified in this follow-up:** the prepared browser-button test (Refresh,
|
||||||
|
selection, reload and Close) and repeated OpenXR transition could not run under
|
||||||
|
the shared lock. The device results elsewhere in this page are the earlier
|
||||||
|
2026-09-28 observations, not results from this blocked recheck. In particular,
|
||||||
|
HTTP focus is not evidence of worn-headset laser input. Follow the
|
||||||
|
[shared-device test procedure](testing.md#headset-smoke-test) for the next run.
|
||||||
|
|
||||||
|
## 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 <key>` 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.
|
||||||
|
|
||||||
## Frame Control's media theatre
|
## Frame Control's media theatre
|
||||||
|
|
||||||
[The owned media player](vr-video.md) can show its video or stereo image on a
|
[The owned media player](vr-video.md) can show its video or stereo image on a
|
||||||
|
|||||||
@@ -104,6 +104,20 @@ switch while SSH is down:
|
|||||||
|
|
||||||
## Headset smoke test
|
## Headset smoke test
|
||||||
|
|
||||||
|
**Documented shared-device procedure:** before a test installs, launches or
|
||||||
|
stops an application, acquire `ssh frame 'mkdir /tmp/frame-test.lock'`. If it
|
||||||
|
fails, leave that lock alone and continue offline work. Only the thread that
|
||||||
|
acquired it releases it with `ssh frame 'rmdir /tmp/frame-test.lock'`, after
|
||||||
|
cleanup. Keep each device session to a few minutes.
|
||||||
|
|
||||||
|
Check battery capacity and charging state under `/sys/class/power_supply`
|
||||||
|
before and after; keep capacity above 20%. Stop only processes started by the
|
||||||
|
test, remove temporary installs and profiles, and restore the prior dashboard
|
||||||
|
state. Leave Steam and SteamVR running. Do not reboot or change global settings.
|
||||||
|
Record the build, actual interaction results, cleanup and any unworn-headset
|
||||||
|
limits alongside screenshots or logs. These are caller responsibilities; the
|
||||||
|
smoke script below does not acquire this shared lock itself.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
scripts/frame-smoke.sh # needs `ssh frame` to work without a password
|
scripts/frame-smoke.sh # needs `ssh frame` to work without a password
|
||||||
scripts/frame-smoke.sh --pair # also pairs a throwaway key: approve it in the headset
|
scripts/frame-smoke.sh --pair # also pairs a throwaway key: approve it in the headset
|
||||||
@@ -179,3 +193,20 @@ late/suspended sessions, cancellation, failed actions, duplicate alerts, reboot
|
|||||||
invalidation, per-zone thermal trips and shared on-headset state. The server
|
invalidation, per-zone thermal trips and shared on-headset state. The server
|
||||||
guards reject invalid session settings before SSH. See
|
guards reject invalid session settings before SSH. See
|
||||||
[real-device evidence and limits](family-comfort.md#verification).
|
[real-device evidence and limits](family-comfort.md#verification).
|
||||||
|
|
||||||
|
## 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 <key>` 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.
|
||||||
@@ -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 <notes>', 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 <notes>")
|
||||||
|
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'})
|
||||||
@@ -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 = '''<!doctype html><html lang="en"><meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1"><title>Panel switcher [fc-panels]</title>
|
||||||
|
<style>body{background:#101b27;color:#eee;font:24px system-ui;margin:36px;max-width:1000px}
|
||||||
|
h1{font-size:36px}button{font:inherit;padding:16px 24px;border:1px solid #546574;border-radius:10px;
|
||||||
|
background:#23384b;color:white;cursor:pointer}button:focus-visible{outline:4px solid #66c0f4}
|
||||||
|
#panels{display:grid;gap:14px;margin:24px 0}#panels button{text-align:left}p{color:#bac8d5}</style>
|
||||||
|
<h1>Panel switcher</h1><p>Choose a panel to show it. Open this panel again from Steam's dashboard.</p>
|
||||||
|
<button id="refresh">Refresh</button> <button id="close">Close switcher</button>
|
||||||
|
<p id="status" role="status"></p><div id="panels"></div>
|
||||||
|
<script>
|
||||||
|
const token=location.hash.slice(1)||sessionStorage.getItem('panelKey')||'';
|
||||||
|
if(token)sessionStorage.setItem('panelKey',token);history.replaceState(null,'',location.pathname);
|
||||||
|
async function api(path,body){const r=await fetch(path,{method:body?'POST':'GET',
|
||||||
|
headers:{'X-Panel-Key':token,'Content-Type':'application/json'},body:body?JSON.stringify(body):undefined});
|
||||||
|
const s=await r.json();if(!r.ok)throw Error(s.error||'Panel request failed');return s;}
|
||||||
|
const status=document.getElementById('status');
|
||||||
|
async function refresh(){try{const s=await api('/panels');const list=document.getElementById('panels');list.replaceChildren();
|
||||||
|
for(const p of s.panels){const b=document.createElement('button');b.textContent=p.title;
|
||||||
|
b.onclick=async()=>{b.disabled=true;try{const r=await api('/focus',{key:p.key});status.textContent=r.message;}
|
||||||
|
catch(e){status.textContent=e.message;}finally{b.disabled=false;}};list.append(b);}
|
||||||
|
status.textContent=s.panels.length?'':'No open panels.';}catch(e){status.textContent=e.message;}}
|
||||||
|
document.getElementById('refresh').onclick=refresh;
|
||||||
|
document.getElementById('close').onclick=async()=>{try{await api('/close',{});window.close();}catch(e){status.textContent=e.message;}};
|
||||||
|
refresh();
|
||||||
|
</script></html>'''
|
||||||
|
|
||||||
|
|
||||||
|
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())
|
||||||
+32
-2
@@ -842,6 +842,15 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="panel" id="panels">
|
||||||
|
<div class="shelf-head"><h2>Panel switcher</h2><span class="spacer"></span>
|
||||||
|
<button class="small" id="panelsRefresh">Refresh</button>
|
||||||
|
<button class="small action" id="panelsOpen">Open in headset</button></div>
|
||||||
|
<div class="list" id="panelList"><div class="sub">Refresh to see open panels.</div></div>
|
||||||
|
<div class="hint">Choose a panel to show it in SteamVR. The headset switcher stays available from Steam's dashboard.
|
||||||
|
Saved spatial layouts are not available yet.</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="panel" id="macview" hidden>
|
<section class="panel" id="macview" hidden>
|
||||||
<div class="shelf-head"><h2>Mac in the headset</h2><span class="count" id="mvCount"></span><span class="spacer"></span>
|
<div class="shelf-head"><h2>Mac in the headset</h2><span class="count" id="mvCount"></span><span class="spacer"></span>
|
||||||
<select id="mvQuality" title="Picture quality and bandwidth">
|
<select id="mvQuality" title="Picture quality and bandwidth">
|
||||||
@@ -3034,6 +3043,27 @@ $("shotsRefresh").onclick = loadShots;
|
|||||||
$("shotsSaveNew").onclick = e => saveShots(shots.list.filter(s => !s.saved), e.currentTarget);
|
$("shotsSaveNew").onclick = e => saveShots(shots.list.filter(s => !s.saved), e.currentTarget);
|
||||||
$("shotsFolder").onclick = e => act($("shotsFolder").textContent, () => api("/api/open", { what: "shots" }), e.currentTarget);
|
$("shotsFolder").onclick = e => act($("shotsFolder").textContent, () => api("/api/open", { what: "shots" }), e.currentTarget);
|
||||||
|
|
||||||
|
// ---- Panel switcher: our UI over SteamVR's panel API ----
|
||||||
|
let panelSeq = 0;
|
||||||
|
async function loadPanels() {
|
||||||
|
const seq = ++panelSeq;
|
||||||
|
try {
|
||||||
|
const s = await api("/api/panels", {action: "list"});
|
||||||
|
if (seq !== panelSeq) return;
|
||||||
|
$("panelList").innerHTML = s.panels.length ? s.panels.map(p =>
|
||||||
|
`<div class="item"><div class="grow"><div class="t">${esc(p.title)}</div></div>
|
||||||
|
<button class="small" data-panel="${esc(p.key)}">Show</button></div>`).join("")
|
||||||
|
: '<div class="sub">No open panels.</div>';
|
||||||
|
} catch (e) { if (seq === panelSeq) failed($("panelList"), e); }
|
||||||
|
}
|
||||||
|
$("panelsRefresh").onclick = loadPanels;
|
||||||
|
$("panelsOpen").onclick = e => act("Open panel switcher in headset",
|
||||||
|
() => api("/api/panels", {action: "open"}), e.currentTarget);
|
||||||
|
$("panelList").onclick = e => {
|
||||||
|
const b = e.target.closest("[data-panel]"); if (!b) return;
|
||||||
|
act("Show panel", () => api("/api/panels", {action: "focus", key: b.dataset.panel}), b);
|
||||||
|
};
|
||||||
|
|
||||||
// ---- Mac in the headset: windows and displays as panels (ui/frame_macview.py) ----
|
// ---- Mac in the headset: windows and displays as panels (ui/frame_macview.py) ----
|
||||||
let mvSeq = 0, mvTimer = 0;
|
let mvSeq = 0, mvTimer = 0;
|
||||||
// streaming: the src it's shown as (a window may be "separate:<id>"), or "".
|
// streaming: the src it's shown as (a window may be "separate:<id>"), or "".
|
||||||
@@ -3112,7 +3142,7 @@ $("macview").onclick = async e => {
|
|||||||
// ---- pages: #home, #games, #android, #tools (older section links still work) ----
|
// ---- pages: #home, #games, #android, #tools (older section links still work) ----
|
||||||
const PAGES = ["home", "games", "android", "tools"];
|
const PAGES = ["home", "games", "android", "tools"];
|
||||||
const SECTION_PAGE = { view: "home", device: "home", shots: "home", library: "games", sideloaded: "games", getgames: "games",
|
const SECTION_PAGE = { view: "home", device: "home", shots: "home", library: "games", sideloaded: "games", getgames: "games",
|
||||||
display: "android", transfer: "tools", apps: "tools", power: "tools", macview: "tools", privacy: "tools" };
|
display: "android", transfer: "tools", apps: "tools", power: "tools", macview: "tools", privacy: "tools", panels: "tools" };
|
||||||
let page = "home";
|
let page = "home";
|
||||||
function showPage() {
|
function showPage() {
|
||||||
const id = location.hash.slice(1);
|
const id = location.hash.slice(1);
|
||||||
@@ -3124,7 +3154,7 @@ function showPage() {
|
|||||||
document.title = page === "home" ? "Frame Control" : `${page[0].toUpperCase() + page.slice(1)} · Frame Control`;
|
document.title = page === "home" ? "Frame Control" : `${page[0].toUpperCase() + page.slice(1)} · Frame Control`;
|
||||||
const section = !PAGES.includes(id) && id && $(id);
|
const section = !PAGES.includes(id) && id && $(id);
|
||||||
if (section) section.scrollIntoView(); else window.scrollTo(0, 0);
|
if (section) section.scrollIntoView(); else window.scrollTo(0, 0);
|
||||||
if (page === "tools") loadMacView();
|
if (page === "tools") { loadMacView(); loadPanels(); }
|
||||||
}
|
}
|
||||||
window.addEventListener("hashchange", showPage);
|
window.addEventListener("hashchange", showPage);
|
||||||
// While a text field has focus, phones hide the bottom tab bar (see body.typing in the CSS).
|
// While a text field has focus, phones hide the bottom tab bar (see body.typing in the CSS).
|
||||||
|
|||||||
+28
-1
@@ -48,6 +48,7 @@ import frame_comfort # noqa: E402
|
|||||||
import frame_host # noqa: E402
|
import frame_host # noqa: E402
|
||||||
import frame_macview # noqa: E402
|
import frame_macview # noqa: E402
|
||||||
import frame_media # noqa: E402
|
import frame_media # noqa: E402
|
||||||
|
import frame_panels # noqa: E402
|
||||||
import frame_report # noqa: E402
|
import frame_report # noqa: E402
|
||||||
import frame_store # noqa: E402
|
import frame_store # noqa: E402
|
||||||
import frame_telemetry # noqa: E402
|
import frame_telemetry # noqa: E402
|
||||||
@@ -1764,6 +1765,32 @@ def _sweep_one(prefix, d):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# ---- Panel switcher (same helper on the companion and in the headset) ----
|
||||||
|
|
||||||
|
def panels_action(body):
|
||||||
|
action = body.get("action", "list")
|
||||||
|
script = (HERE / "frame_panels.py").read_text()
|
||||||
|
if action == "open":
|
||||||
|
# Installed in the user account so the page can outlive this SSH call.
|
||||||
|
remote = ('umask 077; mkdir -p ~/.local/share/frame-control/panels && '
|
||||||
|
'tmp=$(mktemp ~/.local/share/frame-control/panels/install.XXXXXX) && '
|
||||||
|
'cat > "$tmp" && mv "$tmp" ~/.local/share/frame-control/panels/switcher.py && '
|
||||||
|
'python3 ~/.local/share/frame-control/panels/switcher.py --open')
|
||||||
|
elif action == "list":
|
||||||
|
remote = "python3 -"
|
||||||
|
elif action == "focus":
|
||||||
|
key = body.get("key")
|
||||||
|
if not isinstance(key, str) or not frame_panels.KEY.fullmatch(key):
|
||||||
|
raise Failure("Choose an open panel.", 400)
|
||||||
|
remote = "python3 - --focus " + shlex.quote(key)
|
||||||
|
else:
|
||||||
|
raise Failure("Unknown panel action", 400)
|
||||||
|
result = json.loads(ssh(remote, stdin=script, timeout=45))
|
||||||
|
if "error" in result:
|
||||||
|
raise Failure(result["error"], 502)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
# ---- Our Frame-side media player -----------------------------------------
|
# ---- Our Frame-side media player -----------------------------------------
|
||||||
|
|
||||||
_MEDIA_LOCK = threading.Lock()
|
_MEDIA_LOCK = threading.Lock()
|
||||||
@@ -1906,7 +1933,7 @@ POST = {"/api/comfort": comfort, "/api/media": media, "/api/agent/call": agent_c
|
|||||||
"/api/webinstall/check": webinstall_check, "/api/webinstall/start": webinstall_start,
|
"/api/webinstall/check": webinstall_check, "/api/webinstall/start": webinstall_start,
|
||||||
"/api/webinstall/cancel": webinstall_cancel,
|
"/api/webinstall/cancel": webinstall_cancel,
|
||||||
"/api/telemetry": frame_telemetry.update_settings, "/api/telemetry/event": frame_telemetry.page_event,
|
"/api/telemetry": frame_telemetry.update_settings, "/api/telemetry/event": frame_telemetry.page_event,
|
||||||
"/api/report/preview": report_preview, "/api/report": report_send, "/api/macview": macview_action}
|
"/api/report/preview": report_preview, "/api/report": report_send, "/api/macview": macview_action, "/api/panels": panels_action}
|
||||||
|
|
||||||
|
|
||||||
# ---- HTTP ------------------------------------------------------------------
|
# ---- HTTP ------------------------------------------------------------------
|
||||||
|
|||||||
Reference in new issue
Block a user