mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-25 13:01:47 +01:00
Merge pull request #198 from saphid/fleet/se9k61wn4f-headset-ui
Add removable Frame Control panel for SteamVR
This commit is contained in:
10 files changed
+583
-5
No files matched your search
@@ -298,7 +298,7 @@ Frame's software fits together, all checked against a real headset and labelled
|
||||
| [PC in the headset](docs/pc-in-headset.md) | Experimental, Windows only, off until you turn it on: a whole PC screen as a panel in the Frame. Tested in a Windows VM; individual windows need a WGC-enabled build |
|
||||
| [Mac in the headset](docs/mac-in-headset.md) | Mac windows and screens as panels in the Frame, with laser and keyboard input |
|
||||
| [VR mods and custom songs](docs/mods.md) | UEVR for Unreal games from Frame Control (install, play in VR, remove), the per-game table, Beat Saber blockers |
|
||||
| [Decky Loader on Frame](docs/decky-frame.md) · [Frame Control extensions](docs/extensions.md) | Experimental Decky support, removal, and the local extension API |
|
||||
| [Decky Loader on Frame](docs/decky-frame.md) · [Frame Control in VR](docs/headset-app.md) · [Frame Control extensions](docs/extensions.md) | Experimental Decky support, a removable headset panel, and the local extension API |
|
||||
| [SSH](docs/ssh.md) · [Streaming](docs/streaming.md) · [Files](docs/file-transfer.md) · [Panels](docs/panels.md) · [Tailscale](docs/tailscale.md) | Topic notes |
|
||||
| [Frame Control for iPhone](docs/iphone.md) | The iPhone and iPad app, how it runs the server on the Frame, pairing |
|
||||
| [Recovery and OS images](docs/recovery-and-images.md) | Where to download the Frame's OS, what's inside, testing without the headset |
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
# Frame Control in the headset
|
||||
|
||||
**Experimental, Frame OS 0.5.6.** Tools → Frame Control in VR installs a local
|
||||
panel in SteamVR's dashboard. It is a way to reach Frame Control while wearing
|
||||
the headset, independent of Decky's Quick Access Menu. The panel starts only
|
||||
after you opt in; its server binds to `127.0.0.1` and it uses a normal user
|
||||
service. No `sudo`, root filesystem change, or Steam client patch is needed.
|
||||
|
||||
The companion copies its bundled UI over the existing trusted SSH connection.
|
||||
On the Frame, `frame-control-headset.service` runs a loopback server and one
|
||||
Chromium app window on gamescope. The window receives a dedicated `STEAM_GAME`
|
||||
id, so SteamVR registers `valve.steam.desktopgame.2001947813` as a dashboard
|
||||
panel. **Show in SteamVR** requests that dashboard tab; the wearer can select
|
||||
it again from SteamVR's dashboard. Closing the panel stops its browser and
|
||||
server. The service is not enabled at boot.
|
||||
|
||||
The page shows battery, free home storage and SteamVR status. Its per-run UI
|
||||
key is passed in the URL fragment, removed from the address bar after load,
|
||||
and sent in the `X-Frame-UI` header. The key is absent from the HTTP request
|
||||
line and server log. The usual host/origin and anti-framing checks still apply.
|
||||
|
||||
## Real Frame test, 2026-10-25
|
||||
|
||||
On Frame OS 0.5.6, build `20261022.6153909`, the opt-in install started the
|
||||
user service, served `/headset`, read live `/api/status` from the browser, and
|
||||
registered a mapped Chromium window with SteamVR under the dedicated overlay
|
||||
key. The server rejected a request with the wrong UI key. A read-only status
|
||||
probe reported the user service running and the overlay registered.
|
||||
|
||||
The unworn headset was in standby (`activity_level` 3). SteamVR reported the
|
||||
new overlay as `not_visible` even after `ShowDashboard`. An OpenVR stereo
|
||||
screenshot was a uniform dark frame. The documented temporary wake attempt
|
||||
(`power.turnOffScreensTimeout` 5 → 3600, `--handlewakeup`, `ShowDashboard`)
|
||||
still left the overlay `not_visible`; the setting was restored to 5 and the
|
||||
dashboard hidden. The X window's `xwd` image was black too. These observations
|
||||
prove panel creation and page/API execution, **not wearer-visible pixels or
|
||||
laser/controller input**. A worn-headset check is still required before
|
||||
describing the panel as visually verified.
|
||||
|
||||
The test then removed the managed service and app bundle. Its unit became
|
||||
inactive, port 47813 closed, the SteamVR overlay disappeared, and the Frame's
|
||||
battery was 100%. Steam and SteamVR were left running. The browser profile and
|
||||
extensions folder were deliberately kept.
|
||||
|
||||
## Remove
|
||||
|
||||
Use **Tools → Frame Control in VR → Remove**. If the companion is unavailable,
|
||||
run:
|
||||
|
||||
```sh
|
||||
ssh frame 'systemctl --user stop frame-control-headset.service; rm -f ~/.config/systemd/user/frame-control-headset.service; systemctl --user daemon-reload; rm -rf ~/.local/share/frame-control/headset/bundle; rm -f ~/.local/share/frame-control/headset/receipt.json ~/.local/share/frame-control/headset/server.log'
|
||||
```
|
||||
|
||||
That command removes only Frame Control's managed panel code. The Chromium
|
||||
profile remains in `~/.local/share/frame-control/headset/profile` so local
|
||||
extension data is not discarded. Inspect and delete that folder separately if
|
||||
you no longer want it. Third-party extensions are in
|
||||
`~/.local/share/frame-control/extensions` and are never removed by this tool.
|
||||
@@ -0,0 +1,65 @@
|
||||
"""Headset panel packaging, ownership checks and window targeting."""
|
||||
import sandbox # noqa: F401
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import secrets
|
||||
import subprocess
|
||||
import sys
|
||||
import tarfile
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'ui'))
|
||||
import frame_headset
|
||||
import frame_headset_runtime
|
||||
|
||||
|
||||
class HeadsetBundle(unittest.TestCase):
|
||||
def test_bundle_is_deterministic_and_contains_the_local_ui(self):
|
||||
first = frame_headset.bundle()
|
||||
self.assertEqual(first, frame_headset.bundle())
|
||||
with tarfile.open(fileobj=io.BytesIO(first), mode='r:gz') as archive:
|
||||
names = set(archive.getnames())
|
||||
self.assertIn('ui/headset.html', names)
|
||||
self.assertIn('ui/frame_headset_runtime.py', names)
|
||||
self.assertIn('ui/server.py', names)
|
||||
self.assertFalse(any('\\' in name for name in names))
|
||||
self.assertFalse(any('__pycache__' in name for name in names))
|
||||
|
||||
def test_incompatible_device_is_rejected_before_upload(self):
|
||||
state = {'arch': 'x86_64', 'variant': 'vr'}
|
||||
calls = []
|
||||
def ssh(*args, **kwargs):
|
||||
calls.append(args)
|
||||
return json.dumps(state)
|
||||
with patch.object(frame_headset, 'bundle') as bundle:
|
||||
with self.assertRaisesRegex(ValueError, 'Steam Frame'):
|
||||
frame_headset.change(ssh, 'install')
|
||||
bundle.assert_not_called()
|
||||
self.assertEqual(len(calls), 1)
|
||||
|
||||
@unittest.skipIf(os.name == 'nt', 'remote script runs on Linux')
|
||||
def test_unowned_install_is_never_removed(self):
|
||||
with tempfile.TemporaryDirectory() as home:
|
||||
token = secrets.token_hex(12)
|
||||
result = subprocess.run([sys.executable, '-', 'remove', token, 'none'],
|
||||
input=frame_headset.APPLY, text=True, capture_output=True,
|
||||
env={**os.environ, 'HOME': home}, timeout=5)
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn('No Frame Control managed', result.stderr)
|
||||
|
||||
|
||||
class HeadsetWindow(unittest.TestCase):
|
||||
def test_uses_only_its_marker_title(self):
|
||||
tree = ' 0x101 "Other panel"\n 0xabc "Frame Control VR [fc-headset]"\n'
|
||||
with patch.object(frame_headset_runtime, 'command', return_value=tree):
|
||||
self.assertEqual(frame_headset_runtime.panel_window(), '0xabc')
|
||||
with patch.object(frame_headset_runtime, 'command', return_value=' 0x101 "Other panel"'):
|
||||
self.assertIsNone(frame_headset_runtime.panel_window())
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -9,7 +9,7 @@ import subprocess
|
||||
import unittest
|
||||
|
||||
ROOT = pathlib.Path(__file__).resolve().parents[1]
|
||||
STUBS = ['$', 'toggleLive', 'scrollToY', 'scroller', 'loadMacView', 'loadSteamPipewire', 'loadPanels', 'loadPanelSwitcher', 'loadExtensions', 'loadDecky', 'loadBackups', 'loadStorage', 'loadPower', 'loadTitles', 'showAndroidTab',
|
||||
STUBS = ['$', 'toggleLive', 'scrollToY', 'scroller', 'loadMacView', 'loadSteamPipewire', 'loadPanels', 'loadPanelSwitcher', 'loadExtensions', 'loadDecky', 'loadHeadsetPanel', 'loadBackups', 'loadStorage', 'loadPower', 'loadTitles', 'showAndroidTab',
|
||||
'loadAndroid', 'loadDisplays', 'setGameSource', 'requestAnimationFrame']
|
||||
|
||||
RUN = r'''
|
||||
|
||||
@@ -126,6 +126,14 @@ class ServerGuards(unittest.TestCase):
|
||||
self.assertEqual(code, 400)
|
||||
self.assertIn('experimental', body['error'])
|
||||
|
||||
def test_headset_page_and_install_acknowledgement(self):
|
||||
code, _, body = self.request('GET', '/headset')
|
||||
self.assertEqual(code, 200)
|
||||
self.assertIn(b'Frame Control VR [fc-headset]', body)
|
||||
code, body = self.post('/api/headset', {'action': 'install'})
|
||||
self.assertEqual(code, 400)
|
||||
self.assertIn('opt-in', body['error'])
|
||||
|
||||
def test_foreign_host_rejected(self):
|
||||
# DNS rebinding: a hostile name pointed at 127.0.0.1.
|
||||
for path in ("/", "/api/status"):
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
"""Install and control the removable SteamVR Frame Control panel on the Frame.
|
||||
|
||||
The companion sends a deterministic app bundle over SSH. The Frame owns a user
|
||||
service and a browser profile; nothing is installed under /etc or as root.
|
||||
"""
|
||||
import gzip
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
from pathlib import Path
|
||||
import secrets
|
||||
import tarfile
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
ARCHIVE_DIRS = ('ui', 'apk-catalog', 'frame/android', 'frame/devkit-utils', 'frame/openxr-compat', 'scripts')
|
||||
MAX_ARCHIVE = 32 * 1024 * 1024
|
||||
|
||||
|
||||
PROBE = r'''
|
||||
import json, os, pathlib, platform, subprocess
|
||||
home = pathlib.Path.home()
|
||||
base = home / '.local/share/frame-control/headset'
|
||||
unit = home / '.config/systemd/user/frame-control-headset.service'
|
||||
try: receipt = json.loads((base/'receipt.json').read_text())
|
||||
except (OSError, ValueError): receipt = {}
|
||||
try: state = json.loads((base/'state.json').read_text())
|
||||
except (OSError, ValueError): state = {}
|
||||
release = {}
|
||||
for line in pathlib.Path('/etc/os-release').read_text().splitlines():
|
||||
if '=' in line:
|
||||
k, v = line.split('=', 1); release[k] = v.strip('"')
|
||||
active = subprocess.run(['systemctl','--user','is-active','frame-control-headset.service'],
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL).returncode == 0
|
||||
overlay = 'valve.steam.desktopgame.2001947813'
|
||||
try:
|
||||
p = subprocess.run(['/opt/steamvr/bin/linuxarm64/vrcmd','--overlays'],
|
||||
capture_output=True, text=True, timeout=5)
|
||||
registered = p.returncode == 0 and ("'"+overlay+"'") in p.stdout
|
||||
except (OSError, subprocess.TimeoutExpired): registered = False
|
||||
print(json.dumps({'arch':platform.machine(),'variant':release.get('VARIANT_ID'),
|
||||
'os':release.get('VERSION_ID'),'build':release.get('BUILD_ID'),
|
||||
'managed':unit.is_file() and receipt.get('managedBy') == 'Frame Control',
|
||||
'version':receipt.get('version'),'running':active,'overlayRegistered':registered,
|
||||
'window':state.get('window') if active else None,
|
||||
'extensionsFolder':str(home/'.local/share/frame-control/extensions')}))
|
||||
'''
|
||||
|
||||
|
||||
APPLY = r'''
|
||||
import hashlib, io, json, os, pathlib, platform, shutil, subprocess, sys, tarfile, time
|
||||
action, token, digest = sys.argv[1:]
|
||||
home = pathlib.Path.home()
|
||||
base = home / '.local/share/frame-control/headset'
|
||||
bundle = base / 'bundle'
|
||||
receipt = base / 'receipt.json'
|
||||
unit = home / '.config/systemd/user/frame-control-headset.service'
|
||||
upload = pathlib.Path('/tmp/frame-control-headset-' + token)
|
||||
stage = base / ('stage-' + token)
|
||||
old = base / ('old-' + token)
|
||||
overlay = 'valve.steam.desktopgame.2001947813'
|
||||
def systemd(*args):
|
||||
p = subprocess.run(['systemctl','--user',*args], capture_output=True, text=True, timeout=55)
|
||||
if p.returncode: raise RuntimeError((p.stderr or p.stdout).strip()[-400:] or 'user service failed')
|
||||
def managed():
|
||||
try: return unit.is_file() and json.loads(receipt.read_text()).get('managedBy') == 'Frame Control'
|
||||
except (OSError, ValueError): return False
|
||||
def show():
|
||||
deadline = time.monotonic() + 45
|
||||
while time.monotonic() < deadline:
|
||||
if (base/'state.json').is_file():
|
||||
p = subprocess.run(['/opt/steamvr/bin/linuxarm64/vrcmd','--showdashboard',overlay],
|
||||
capture_output=True, text=True, timeout=10)
|
||||
if p.returncode: raise RuntimeError((p.stderr or p.stdout).strip()[-400:] or 'dashboard rejected panel')
|
||||
return {'message':'Asked SteamVR to show Frame Control. Select its panel in the dashboard if it is hidden.',
|
||||
'overlay':overlay}
|
||||
time.sleep(.25)
|
||||
raise RuntimeError('The headset panel did not register within 45 seconds; check the user service log')
|
||||
try:
|
||||
if action == 'remove':
|
||||
if not managed(): raise RuntimeError('No Frame Control managed headset panel to remove')
|
||||
systemd('stop','frame-control-headset.service')
|
||||
unit.unlink()
|
||||
systemd('daemon-reload')
|
||||
shutil.rmtree(bundle)
|
||||
receipt.unlink()
|
||||
(base/'server.log').unlink(missing_ok=True)
|
||||
print(json.dumps({'message':'Removed the Frame Control headset service and app bundle. Extensions and browser profile were kept.'}))
|
||||
elif action == 'close':
|
||||
if not managed(): raise RuntimeError('The headset panel is not installed by Frame Control')
|
||||
systemd('stop','frame-control-headset.service')
|
||||
print(json.dumps({'message':'Closed the Frame Control headset panel.'}))
|
||||
elif action == 'open':
|
||||
if not managed(): raise RuntimeError('Install the headset panel first')
|
||||
systemd('start','frame-control-headset.service')
|
||||
print(json.dumps(show()))
|
||||
else:
|
||||
if action not in ('install','update'): raise RuntimeError('Invalid headset panel action')
|
||||
if platform.machine() != 'aarch64': raise RuntimeError('This headset panel needs the Frame ARM64 build')
|
||||
if action == 'install' and (unit.exists() or receipt.exists() or bundle.exists()):
|
||||
raise RuntimeError('Headset panel files already exist; no existing installation was changed')
|
||||
if action == 'update' and not managed(): raise RuntimeError('No Frame Control managed panel to update')
|
||||
data = upload.read_bytes()
|
||||
if not 1024 < len(data) <= 32*1024*1024 or hashlib.sha256(data).hexdigest() != digest:
|
||||
raise RuntimeError('Headset bundle size or digest did not match')
|
||||
base.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||
stage.mkdir(mode=0o700)
|
||||
with tarfile.open(fileobj=io.BytesIO(data), mode='r:gz') as archive:
|
||||
members = archive.getmembers()
|
||||
if not members or any(not m.isfile() or not m.name.startswith(('ui/','apk-catalog/','frame/android/',
|
||||
'frame/devkit-utils/','frame/openxr-compat/','scripts/')) or '..' in pathlib.PurePosixPath(m.name).parts
|
||||
or m.size > 8*1024*1024 for m in members):
|
||||
raise RuntimeError('Invalid headset bundle member')
|
||||
archive.extractall(stage, members=members, filter='data')
|
||||
if not (stage/'ui/server.py').is_file() or not (stage/'ui/frame_headset_runtime.py').is_file():
|
||||
raise RuntimeError('Incomplete headset bundle')
|
||||
unit.parent.mkdir(parents=True, exist_ok=True)
|
||||
previous_unit = unit.read_bytes() if unit.exists() else None
|
||||
previous_receipt = receipt.read_bytes() if receipt.exists() else None
|
||||
if action == 'update':
|
||||
systemd('stop','frame-control-headset.service')
|
||||
bundle.rename(old)
|
||||
try:
|
||||
stage.rename(bundle)
|
||||
unit.write_text('[Unit]\nDescription=Frame Control headset panel\nAfter=gamescope-session.target steamvr.service\n'
|
||||
'[Service]\nType=simple\nExecStart=/usr/bin/python3 -I -u '+str(bundle/'ui/frame_headset_runtime.py')+'\n'
|
||||
'Restart=no\nKillMode=control-group\nTimeoutStopSec=12\n')
|
||||
receipt.write_text(json.dumps({'managedBy':'Frame Control','version':digest[:16]})+'\n')
|
||||
systemd('daemon-reload')
|
||||
systemd('start','frame-control-headset.service')
|
||||
result = show()
|
||||
except Exception:
|
||||
subprocess.run(['systemctl','--user','stop','frame-control-headset.service'],
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
if bundle.exists(): shutil.rmtree(bundle)
|
||||
if old.exists(): old.rename(bundle)
|
||||
if previous_unit is None: unit.unlink(missing_ok=True)
|
||||
else: unit.write_bytes(previous_unit)
|
||||
if previous_receipt is None: receipt.unlink(missing_ok=True)
|
||||
else: receipt.write_bytes(previous_receipt)
|
||||
subprocess.run(['systemctl','--user','daemon-reload'],
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
if previous_unit: subprocess.run(['systemctl','--user','start','frame-control-headset.service'],
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
raise
|
||||
if old.exists(): shutil.rmtree(old)
|
||||
print(json.dumps({'message':'Installed Frame Control on the headset. '+result['message'],
|
||||
'version':digest[:16],'overlay':overlay}))
|
||||
finally:
|
||||
upload.unlink(missing_ok=True)
|
||||
if stage.exists(): shutil.rmtree(stage)
|
||||
'''
|
||||
|
||||
|
||||
def bundle():
|
||||
"""Pack app code shipped with the desktop app; never include user data."""
|
||||
raw = io.BytesIO()
|
||||
with tarfile.open(fileobj=raw, mode='w') as archive:
|
||||
for dirname in ARCHIVE_DIRS:
|
||||
folder = ROOT / dirname
|
||||
if not folder.is_dir():
|
||||
continue
|
||||
for path in sorted(folder.rglob('*')):
|
||||
if not path.is_file() or path.is_symlink() or '__pycache__' in path.parts:
|
||||
continue
|
||||
if path.suffix not in ('.py', '.html', '.js', '.css', '.json', '.sh', '.gz', '.zst', '.so', '.txt', '.md') and path.name not in ('ssh',):
|
||||
continue
|
||||
data = path.read_bytes()
|
||||
info = tarfile.TarInfo(path.relative_to(ROOT).as_posix())
|
||||
info.size, info.mtime, info.uid, info.gid = len(data), 0, 0, 0
|
||||
info.mode = 0o755 if path.stat().st_mode & 0o111 else 0o644
|
||||
archive.addfile(info, io.BytesIO(data))
|
||||
with io.BytesIO() as out:
|
||||
with gzip.GzipFile(fileobj=out, mode='wb', mtime=0) as gz:
|
||||
gz.write(raw.getvalue())
|
||||
data = out.getvalue()
|
||||
if len(data) > MAX_ARCHIVE:
|
||||
raise ValueError('Headset app bundle is too large')
|
||||
return data
|
||||
|
||||
|
||||
def status(ssh):
|
||||
return json.loads(ssh('python3 -', stdin=PROBE, timeout=15))
|
||||
|
||||
|
||||
def change(ssh, action):
|
||||
if action not in ('install', 'update', 'open', 'close', 'remove'):
|
||||
raise ValueError('Invalid headset panel action')
|
||||
current = status(ssh)
|
||||
if action in ('install', 'update', 'open') and (current['arch'] != 'aarch64' or current['variant'] != 'vr'):
|
||||
raise ValueError('This feature needs a Steam Frame running Frame OS')
|
||||
data = bundle() if action in ('install', 'update') else None
|
||||
digest = hashlib.sha256(data or b'').hexdigest()
|
||||
token = secrets.token_hex(12)
|
||||
remote = '/tmp/frame-control-headset-' + token
|
||||
try:
|
||||
if data is not None:
|
||||
ssh('umask 077; cat > ' + remote, stdin=data, text=False, timeout=120)
|
||||
return json.loads(ssh(f'python3 - {action} {token} {digest}', stdin=APPLY, timeout=100))
|
||||
finally:
|
||||
if data is not None:
|
||||
try: ssh('rm -f ' + remote, timeout=10)
|
||||
except Exception: pass
|
||||
@@ -0,0 +1,149 @@
|
||||
"""Run Frame Control's opt-in headset panel as one bounded user service.
|
||||
|
||||
This process owns only its loopback server and Chromium profile. Steam, SteamVR,
|
||||
gamescope, other browser profiles, and the user's extensions are never stopped.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
|
||||
BASE = Path.home() / '.local/share/frame-control/headset'
|
||||
PORT = 47813
|
||||
APPID = 2001947813
|
||||
OVERLAY = f'valve.steam.desktopgame.{APPID}'
|
||||
VRCMD = '/opt/steamvr/bin/linuxarm64/vrcmd'
|
||||
TITLE = '[fc-headset]'
|
||||
|
||||
|
||||
class RuntimeError_(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def browser_command():
|
||||
chrome = Path.home() / 'chromium-xr/chrome'
|
||||
if chrome.is_file() and os.access(chrome, os.X_OK):
|
||||
return [str(chrome)]
|
||||
if Path('/usr/bin/chromium').is_file():
|
||||
return ['/usr/bin/chromium']
|
||||
if subprocess.run(['flatpak', 'info', 'org.chromium.Chromium'],
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=10).returncode == 0:
|
||||
return ['flatpak', 'run', 'org.chromium.Chromium']
|
||||
raise RuntimeError_('Chromium is needed on the Frame to show the headset panel')
|
||||
|
||||
|
||||
def command(*args):
|
||||
result = subprocess.run(args, capture_output=True, text=True, timeout=5,
|
||||
env={**os.environ, 'DISPLAY': ':0', 'LC_ALL': 'C.UTF-8'})
|
||||
if result.returncode:
|
||||
raise RuntimeError_((result.stderr or result.stdout).strip()[-400:] or f'{args[0]} failed')
|
||||
return result.stdout
|
||||
|
||||
|
||||
def panel_window():
|
||||
for line in command('xwininfo', '-root', '-children').splitlines():
|
||||
match = re.match(r'\s*(0x[0-9a-fA-F]+) .*\[fc-headset\]', line)
|
||||
if match:
|
||||
return match[1]
|
||||
return None
|
||||
|
||||
|
||||
def wait_for_server(proc):
|
||||
deadline = time.monotonic() + 15
|
||||
while time.monotonic() < deadline and proc.poll() is None:
|
||||
try:
|
||||
with urllib.request.urlopen(f'http://127.0.0.1:{PORT}/headset', timeout=.5) as response:
|
||||
if response.status == 200 and TITLE.encode() in response.read(200_000):
|
||||
return
|
||||
except (OSError, urllib.error.URLError):
|
||||
pass
|
||||
time.sleep(.2)
|
||||
raise RuntimeError_('The headset loopback server did not start')
|
||||
|
||||
|
||||
def terminate_owned(proc):
|
||||
if proc and proc.poll() is None:
|
||||
os.killpg(proc.pid, signal.SIGTERM)
|
||||
try:
|
||||
proc.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
os.killpg(proc.pid, signal.SIGKILL)
|
||||
proc.wait()
|
||||
|
||||
|
||||
def serve():
|
||||
bundle = BASE / 'bundle/ui'
|
||||
key = os.urandom(24).hex()
|
||||
BASE.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||
state_file = BASE / 'state.json'
|
||||
env = {**os.environ, 'FRAME_LOCAL': '1', 'FRAME_DEVICE': 'headset',
|
||||
'FRAME_HEADSET_RUNTIME': '1', 'FRAME_UI_KEY': key}
|
||||
env.pop('WAYLAND_DISPLAY', None)
|
||||
env['DISPLAY'] = ':0'
|
||||
env['LD_PRELOAD'] = '' # Steam's overlay preload crashes Chromium's zygote.
|
||||
server = browser = None
|
||||
stopping = False
|
||||
|
||||
def stop(*_):
|
||||
nonlocal stopping
|
||||
stopping = True
|
||||
|
||||
signal.signal(signal.SIGTERM, stop)
|
||||
signal.signal(signal.SIGINT, stop)
|
||||
try:
|
||||
command('xprop', '-root', 'GAMESCOPE_FOCUSABLE_WINDOWS')
|
||||
with (BASE / 'server.log').open('ab') as log:
|
||||
server = subprocess.Popen([sys.executable, '-I', '-u', '-B', str(bundle / 'server.py'),
|
||||
'--port', str(PORT)], env=env, stdin=subprocess.DEVNULL,
|
||||
stdout=log, stderr=subprocess.STDOUT, start_new_session=True)
|
||||
wait_for_server(server)
|
||||
browser = subprocess.Popen([*browser_command(), '--ozone-platform=x11',
|
||||
'--user-data-dir=' + str(BASE / 'profile'),
|
||||
'--no-first-run', '--no-default-browser-check',
|
||||
'--password-store=basic', '--disable-session-crashed-bubble',
|
||||
'--noerrdialogs', '--window-size=1200,800',
|
||||
'--app=http://127.0.0.1:%d/headset#%s' % (PORT, key)],
|
||||
env=env, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL, start_new_session=True)
|
||||
deadline = time.monotonic() + 30
|
||||
win = None
|
||||
while time.monotonic() < deadline and browser.poll() is None and not stopping:
|
||||
win = panel_window()
|
||||
if win:
|
||||
break
|
||||
time.sleep(.25)
|
||||
if not win:
|
||||
raise RuntimeError_('The Frame Control browser window did not appear')
|
||||
command('xprop', '-id', win, '-f', 'STEAM_GAME', '32c', '-set', 'STEAM_GAME', str(APPID))
|
||||
deadline = time.monotonic() + 8
|
||||
while time.monotonic() < deadline:
|
||||
if OVERLAY in command(VRCMD, '--overlays'):
|
||||
break
|
||||
time.sleep(.25)
|
||||
else:
|
||||
raise RuntimeError_('SteamVR did not register the Frame Control panel')
|
||||
state_file.write_text(json.dumps({'port': PORT, 'window': win, 'overlay': OVERLAY,
|
||||
'started': time.time()}) + '\n')
|
||||
state_file.chmod(0o600)
|
||||
print('Frame Control headset panel registered as ' + OVERLAY, flush=True)
|
||||
while not stopping and server.poll() is None and browser.poll() is None:
|
||||
time.sleep(.5)
|
||||
finally:
|
||||
state_file.unlink(missing_ok=True)
|
||||
terminate_owned(browser)
|
||||
terminate_owned(server)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
try:
|
||||
serve()
|
||||
except (OSError, RuntimeError_, subprocess.SubprocessError) as exc:
|
||||
print(str(exc), file=sys.stderr)
|
||||
sys.exit(1)
|
||||
@@ -0,0 +1,38 @@
|
||||
<!doctype html><html lang="en"><head><meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Frame Control VR [fc-headset]</title>
|
||||
<style>
|
||||
:root{color-scheme:dark;font:24px/1.4 system-ui,sans-serif;background:#0d1925;color:#f3f7fa}
|
||||
*{box-sizing:border-box}body{margin:0;padding:34px;max-width:1280px}h1{font-size:40px;margin:0 0 6px}
|
||||
p{margin:0 0 22px;color:#b9c9d6}.cards{display:grid;grid-template-columns:repeat(3,1fr);gap:18px}
|
||||
.card{background:#1b3042;border:1px solid #40576c;border-radius:14px;padding:24px;min-height:158px}
|
||||
.label{display:block;color:#b9c9d6;font-size:20px}.value{display:block;font-size:38px;font-weight:700;margin-top:14px}
|
||||
button{font:inherit;background:#23618c;color:white;border:1px solid #72baf0;border-radius:12px;padding:15px 24px;cursor:pointer}
|
||||
button:focus-visible{outline:5px solid #f8d370;outline-offset:3px}#message{min-height:1.5em;margin-top:18px}
|
||||
@media(max-width:720px){.cards{grid-template-columns:1fr}body{padding:20px}}
|
||||
</style></head><body>
|
||||
<h1>Frame Control</h1><p>Your Frame, from inside SteamVR. Open this panel from the dashboard at any time.</p>
|
||||
<div class="cards">
|
||||
<div class="card"><span class="label">Battery</span><span class="value" id="battery">…</span></div>
|
||||
<div class="card"><span class="label">Home storage free</span><span class="value" id="storage">…</span></div>
|
||||
<div class="card"><span class="label">SteamVR</span><span class="value" id="steamvr">…</span></div>
|
||||
</div>
|
||||
<p id="message" role="status" aria-live="polite"></p>
|
||||
<button id="refresh">Refresh status</button>
|
||||
<script>
|
||||
const key=location.hash.slice(1)||sessionStorage.getItem('frameHeadsetKey')||'';
|
||||
if(key)sessionStorage.setItem('frameHeadsetKey',key);
|
||||
history.replaceState(null,'',location.pathname);
|
||||
const byId=id=>document.getElementById(id);
|
||||
async function api(path,body){const response=await fetch(path,{method:body?'POST':'GET',
|
||||
headers:{'X-Frame-UI':key,'Content-Type':'application/json'},body:body?JSON.stringify(body):undefined});
|
||||
const data=await response.json();if(!response.ok)throw Error(data.error||'Frame request failed');return data;}
|
||||
async function refresh(){try{const status=await api('/api/status');
|
||||
byId('battery').textContent=status.battery?.percent==null?'Unavailable':`${status.battery.percent}%`;
|
||||
const free=status.disk?.home?.free;
|
||||
byId('storage').textContent=Number.isFinite(free)?`${(free/1e9).toFixed(1)} GB`:'Unavailable';
|
||||
byId('steamvr').textContent=status.services?.steamvr?'Running':'Unavailable';
|
||||
byId('message').textContent=`Frame OS ${status.os?.version||'unknown'} · Updated ${new Date().toLocaleTimeString()}`;
|
||||
}catch(error){byId('message').textContent=error.message;}}
|
||||
byId('refresh').onclick=refresh;refresh();
|
||||
</script></body></html>
|
||||
+40
-3
@@ -1436,7 +1436,7 @@
|
||||
|
||||
<div class="page" data-page="tools">
|
||||
<div class="workspace-heading"><div><a href="#tools" id="toolBack" class="workspace-link" hidden>← All tools</a><h1 id="toolTitle">Tools</h1><p id="toolSubtitle">Choose a task for your Frame.</p></div></div>
|
||||
<div id="toolDirectory"><section class="tool-group"><h2>Content & files</h2><div class="quick-routes"><a href="#transfer"><span>Send to Frame</span><small>Add files, APKs or your own games</small></a><a href="#media"><span>Media player</span><small>Play your own videos and media</small></a><a href="#linuxApps"><span>Linux apps</span><small>Install and manage desktop apps</small></a></div></section><section class="tool-group"><h2>Play & control</h2><div class="quick-routes"><a href="#capture"><span>Capture & Live</span><small>See and control the headset view</small></a><a href="#pad" title="Type on the headset (T)"><span>Type on the headset</span><small>Keyboard, trackpad and clipboard</small></a><a href="#panels"><span>Panel switcher</span><small>Bring an open panel into view</small></a><a href="#vrTools"><span>VR tools</span><small>Performance HUD and VR utilities</small></a><a href="#comfort"><span>Family & comfort</span><small>Session reminders and breaks</small></a><a href="#macview" id="macToolLink" hidden><span>Mac in the headset</span><small>Share Mac windows and displays</small></a><a href="#steamPipewire" id="steamPipewireLink" hidden><span>Steam desktop sharing</span><small>Linux: launch Steam or re-pick a screen</small></a><a href="#macview" id="pcToolLink" hidden><span>PC in the headset</span><small>Experimental: share this PC's screen</small></a></div></section><section class="tool-group" id="wirelessToolGroup" hidden><h2>This PC</h2><div class="quick-routes"><a href="#wireless"><span>Wireless Adapter doctor</span><small>Read-only Windows adapter and interference checks</small></a></div></section><section class="tool-group"><h2>Your Frame</h2><div class="quick-routes"><a href="#powerReport"><span>Power & heat report</span><small>Read-only observation and offline downloads</small></a><a href="#storage"><span>Game storage</span><small>Free space and Steam install sizes</small></a><a href="#backup" id="backupToolLink"><span>Backup & recovery</span><small>Save games and settings, restore after a reset</small></a></div><a class="workspace-link" href="#settings">Connection, display & preferences →</a></section><section class="tool-group"><h2>Modding & extensions</h2><div class="quick-routes"><a href="#extensions"><span>Extensions</span><small>Add local tools to Frame Control</small></a><a href="#decky"><span>Decky Loader</span><small>Experimental Steam plugin loader</small></a></div></section><section class="tool-group"><h2>Assistant</h2><a class="workspace-link" href="/assistant">Open assistant & AI agents →</a><p class="sub">Use your own model endpoint or review a proposed MCP action.</p></section></div>
|
||||
<div id="toolDirectory"><section class="tool-group"><h2>Content & files</h2><div class="quick-routes"><a href="#transfer"><span>Send to Frame</span><small>Add files, APKs or your own games</small></a><a href="#media"><span>Media player</span><small>Play your own videos and media</small></a><a href="#linuxApps"><span>Linux apps</span><small>Install and manage desktop apps</small></a></div></section><section class="tool-group"><h2>Play & control</h2><div class="quick-routes"><a href="#capture"><span>Capture & Live</span><small>See and control the headset view</small></a><a href="#pad" title="Type on the headset (T)"><span>Type on the headset</span><small>Keyboard, trackpad and clipboard</small></a><a href="#panels"><span>Panel switcher</span><small>Bring an open panel into view</small></a><a href="#vrTools"><span>VR tools</span><small>Performance HUD and VR utilities</small></a><a href="#comfort"><span>Family & comfort</span><small>Session reminders and breaks</small></a><a href="#macview" id="macToolLink" hidden><span>Mac in the headset</span><small>Share Mac windows and displays</small></a><a href="#steamPipewire" id="steamPipewireLink" hidden><span>Steam desktop sharing</span><small>Linux: launch Steam or re-pick a screen</small></a><a href="#macview" id="pcToolLink" hidden><span>PC in the headset</span><small>Experimental: share this PC's screen</small></a></div></section><section class="tool-group" id="wirelessToolGroup" hidden><h2>This PC</h2><div class="quick-routes"><a href="#wireless"><span>Wireless Adapter doctor</span><small>Read-only Windows adapter and interference checks</small></a></div></section><section class="tool-group"><h2>Your Frame</h2><div class="quick-routes"><a href="#powerReport"><span>Power & heat report</span><small>Read-only observation and offline downloads</small></a><a href="#storage"><span>Game storage</span><small>Free space and Steam install sizes</small></a><a href="#backup" id="backupToolLink"><span>Backup & recovery</span><small>Save games and settings, restore after a reset</small></a></div><a class="workspace-link" href="#settings">Connection, display & preferences →</a></section><section class="tool-group"><h2>Modding & extensions</h2><div class="quick-routes"><a href="#extensions"><span>Extensions</span><small>Add local tools to Frame Control</small></a><a href="#decky"><span>Decky Loader</span><small>Experimental Steam plugin loader</small></a><a href="#headsetPanel"><span>Frame Control in VR</span><small>Open tools inside the headset</small></a></div></section><section class="tool-group"><h2>Assistant</h2><a class="workspace-link" href="/assistant">Open assistant & AI agents →</a><p class="sub">Use your own model endpoint or review a proposed MCP action.</p></section></div>
|
||||
<div class="grid-3" id="toolWorkspace">
|
||||
<section class="panel" id="extensions">
|
||||
<div class="shelf-head"><h2>Extensions</h2><span class="spacer"></span><button class="small" id="extensionsRefresh">Refresh</button></div>
|
||||
@@ -1457,6 +1457,14 @@
|
||||
<div class="row" style="margin-top:10px"><button class="small action" id="deckyInstall" disabled>Install</button><button class="small" id="deckyUpdate" disabled>Update</button><button class="small danger" id="deckyRemove" disabled>Remove managed Decky</button></div>
|
||||
<div class="hint">Install and update download Decky's official ARM64 release and verify its published SHA-256. Frame Control uses a removable user service and does not change the root filesystem. Remove keeps plugin files.</div>
|
||||
</section>
|
||||
<section class="panel" id="headsetPanel">
|
||||
<div class="shelf-head"><h2>Frame Control in VR</h2><span class="spacer"></span><button class="small" id="headsetRefresh">Refresh</button></div>
|
||||
<p class="sub">Open a Frame Control panel in SteamVR's dashboard. The panel runs on the Frame through a loopback server and an opt-in user service.</p>
|
||||
<div id="headsetState" class="sub">Checking…</div>
|
||||
<label class="popt"><input type="checkbox" id="headsetAck"> I want to install this local headset panel. Closing or removing it leaves SteamVR and my extensions running.</label>
|
||||
<div class="row" style="margin-top:10px"><button class="small action" id="headsetInstall" disabled>Install & open</button><button class="small" id="headsetUpdate" disabled>Update</button><button class="small" id="headsetOpen" disabled>Show in SteamVR</button><button class="small" id="headsetClose" disabled>Close</button><button class="small danger" id="headsetRemove" disabled>Remove</button></div>
|
||||
<p class="hint">Open SteamVR's dashboard to find Frame Control. Remove stops its user service and removes its app bundle; your extension folders and browser profile stay on the Frame.</p>
|
||||
</section>
|
||||
<section class="panel" id="wireless" hidden>
|
||||
<h2>Wireless Adapter doctor</h2>
|
||||
<p class="sub">Read-only snapshot of this Windows PC. Works without a headset connection. Nothing on the Frame is read or changed.</p>
|
||||
@@ -2194,6 +2202,34 @@ for (const action of ['Install', 'Update', 'Remove']) {
|
||||
loadDecky();
|
||||
};
|
||||
}
|
||||
let headsetInfo = null;
|
||||
async function loadHeadsetPanel() {
|
||||
try {
|
||||
const d = headsetInfo = await api('/api/headset');
|
||||
$('headsetState').textContent = `${d.os || 'Unknown Frame OS'} · ${d.arch} · ${d.managed ? `installed ${d.version || ''}` : 'not installed'} · ${d.running ? d.overlayRegistered ? 'SteamVR panel registered' : 'service starting' : 'stopped'}`;
|
||||
} catch (error) { headsetInfo = null; $('headsetState').textContent = error.message; }
|
||||
headsetButtons();
|
||||
}
|
||||
function headsetButtons() {
|
||||
const d = headsetInfo, ready = !!d && d.arch === 'aarch64' && d.variant === 'vr';
|
||||
$('headsetInstall').disabled = !ready || !!d.managed || !$('headsetAck').checked;
|
||||
$('headsetUpdate').disabled = !ready || !d.managed || !$('headsetAck').checked;
|
||||
$('headsetOpen').disabled = !ready || !d.managed;
|
||||
$('headsetClose').disabled = !d?.running;
|
||||
$('headsetRemove').disabled = !d?.managed || !$('headsetAck').checked;
|
||||
}
|
||||
$('headsetAck').onchange = headsetButtons;
|
||||
$('headsetRefresh').onclick = loadHeadsetPanel;
|
||||
for (const action of ['Install', 'Update', 'Open', 'Close', 'Remove']) {
|
||||
$(`headset${action}`).onclick = async () => {
|
||||
if (!headsetInfo) return;
|
||||
const verb = action.toLowerCase();
|
||||
if (verb === 'remove' && !confirm('Remove the Frame Control headset user service and app bundle? Your extensions and browser profile will stay.')) return;
|
||||
await runJob(`${action} Frame Control in VR`, 'headset-panel',
|
||||
() => api('/api/headset', {action:verb, acknowledged:$('headsetAck').checked}));
|
||||
loadHeadsetPanel();
|
||||
};
|
||||
}
|
||||
window.addEventListener('message', event => {
|
||||
if (!activeExtension || event.source !== $('extensionFrame').contentWindow) return;
|
||||
const request = event.data;
|
||||
@@ -6178,10 +6214,10 @@ const PAGES = ["apps", "games", "tools", "settings"];
|
||||
const SECONDARY_PAGES = ["home", "capture", "devices", "control"];
|
||||
const SECTION_PAGE = {library:"games", discover:"apps", android:"apps", view:"capture", shots:"capture", device:"home",
|
||||
steamPipewire:"tools", vrTools:"tools", comfort:"tools", assistant:"tools", pad:"tools", sideloaded:"games", getgames:"games", steamStore:"games", androidLibrary:"apps", appStore:"apps", linuxApps:"apps",
|
||||
display:"settings", androidSettings:"settings", transfer:"tools", media:"tools", artworkSettings:"settings", autoSync:"settings", power:"settings", macview:"tools", privacy:"settings", panels:"tools", storage:"tools", wireless:"tools", powerReport:"tools", backup:"tools", extensions:"tools", decky:"tools"};
|
||||
display:"settings", androidSettings:"settings", transfer:"tools", media:"tools", artworkSettings:"settings", autoSync:"settings", power:"settings", macview:"tools", privacy:"settings", panels:"tools", storage:"tools", wireless:"tools", powerReport:"tools", backup:"tools", extensions:"tools", decky:"tools", headsetPanel:"tools"};
|
||||
const TOOL_TITLES = {steamPipewire:"Steam desktop sharing", transfer:"Send to Frame", media:"Media player", artworkSettings:"Library artwork", autoSync:"Copy to this computer",
|
||||
pad:"Type on the headset", panels:"Panel switcher", vrTools:"VR tools", comfort:"Family & comfort", androidSettings:"App display & reports",
|
||||
power:"Remote & power", macview:"Mac in the headset", privacy:"Privacy & updates", storage:"Game storage", wireless:"Wireless Adapter doctor", powerReport:"Power & heat report", backup:"Backup & recovery", extensions:"Extensions", decky:"Decky Loader"};
|
||||
power:"Remote & power", macview:"Mac in the headset", privacy:"Privacy & updates", storage:"Game storage", wireless:"Wireless Adapter doctor", powerReport:"Power & heat report", backup:"Backup & recovery", extensions:"Extensions", decky:"Decky Loader", headsetPanel:"Frame Control in VR"};
|
||||
const workspace = {source:"all", scope:"installed", apps:"installed", scroll:new Map(), key:null, hash:null, y:0};
|
||||
let page = "apps";
|
||||
// Reuse the existing live lists and form handlers in their new destinations.
|
||||
@@ -6290,6 +6326,7 @@ function showPage(e) {
|
||||
if (tool === 'panels') loadPanelSwitcher();
|
||||
if (tool === 'extensions') loadExtensions();
|
||||
if (tool === 'decky') loadDecky();
|
||||
if (tool === 'headsetPanel') loadHeadsetPanel();
|
||||
if (tool === 'backup') loadBackups();
|
||||
if (tool === 'storage') loadStorage();
|
||||
if (tool === 'powerReport') loadPower();
|
||||
|
||||
@@ -80,6 +80,7 @@ import frame_wired_doctor # noqa: E402
|
||||
import frame_compat_db # noqa: E402
|
||||
import frame_extensions # noqa: E402
|
||||
import frame_decky # noqa: E402
|
||||
import frame_headset # noqa: E402
|
||||
|
||||
frame_host.trust_bundled_cas()
|
||||
|
||||
@@ -559,6 +560,17 @@ def decky(body):
|
||||
return start_job("Decky Loader " + action, lambda: frame_decky.change(ssh, action))
|
||||
|
||||
|
||||
def headset_action(body):
|
||||
if LOCAL:
|
||||
raise Failure("Manage the headset panel from Frame Control on your computer", 400)
|
||||
action = body.get("action")
|
||||
if action not in ("install", "update", "open", "close", "remove"):
|
||||
raise Failure("Choose install, update, open, close, or remove", 400)
|
||||
if action in ("install", "update", "remove") and body.get("acknowledged") is not True:
|
||||
raise Failure("Confirm the opt-in headset installation or removal", 400)
|
||||
return start_job("Headset panel " + action, lambda: frame_headset.change(ssh, action))
|
||||
|
||||
|
||||
COMFORT_FILES = ("frame_comfort.py", "frame_status.py", "frame_steam.py")
|
||||
COMFORT_MISSING = {"comfort": "missing"} # what COMFORT_RUN prints when the cached copy isn't complete
|
||||
# Runs frame_comfort.py from ~/.cache/frame-control/comfort/<version>, first writing any of
|
||||
@@ -3332,6 +3344,7 @@ POST = {
|
||||
"/api/power": power_action,
|
||||
"/api/android/backup": android_backup, "/api/backup": backup_action,
|
||||
"/api/decky": decky,
|
||||
"/api/headset": headset_action,
|
||||
"/api/vr": vr,
|
||||
"/api/comfort": comfort, "/api/media": media, "/api/agent/call": agent_call,
|
||||
"/api/agent/approval": agent_approval, "/api/assistant/chat": assistant_chat,
|
||||
@@ -3515,6 +3528,9 @@ class Handler(BaseHTTPRequestHandler):
|
||||
try:
|
||||
if path in ("/", "/index.html"):
|
||||
self.send_bytes((HERE / "index.html").read_bytes(), "text/html; charset=utf-8")
|
||||
elif path == "/headset":
|
||||
# Dedicated, large-control UI for the opt-in SteamVR panel.
|
||||
self.send_bytes((HERE / "headset.html").read_bytes(), "text/html; charset=utf-8")
|
||||
elif path == "/store":
|
||||
# The Android store as a headset panel (scripts/store-on-frame.sh).
|
||||
self.send_bytes((HERE / "store.html").read_bytes(), "text/html; charset=utf-8")
|
||||
@@ -3534,6 +3550,10 @@ class Handler(BaseHTTPRequestHandler):
|
||||
self.send_json(frame_extensions.list_extensions())
|
||||
elif path == "/api/decky":
|
||||
self.send_json(frame_decky.status(ssh))
|
||||
elif path == "/api/headset":
|
||||
if LOCAL:
|
||||
raise Failure("Manage this panel from Frame Control on your computer", 400)
|
||||
self.send_json(frame_headset.status(ssh))
|
||||
elif path.startswith("/extension/"):
|
||||
parts = path.split("/")
|
||||
if len(parts) != 4:
|
||||
|
||||
Reference in new issue
Block a user