Files
Frametop--frametop/pointer/dashpatch/ft-dashpatch.py
T
DeeJanuzandClaude Opus 5.5 6d0c542bb8 pointer: a dashboard patch lets the treadmill move SteamVR's windows
SteamVR's dashboard starts a floating window's move only for a press from
the head or a hand: UndockedOverlay.startFloatingWindowMove reads the
pressing device from the mouse event's CapsLock/NumLock bits and logs "mouse
event carried no input path" for anything else. So the 3D mouse as a
treadmill clicked and resized SteamVR's windows but couldn't move them, the
regression since 2026-10-09.

pointer/dashpatch wraps the grab bar's handler in the dashboard page: a
press with no device runs it as the right hand, with that pose answered by
/user/treadmill, and the move then follows /user/treadmill.
frametop-dashpatch.service puts it in over the dashboard's debugger, again
whenever SteamVR restarts. The installer turns the debugger on at
127.0.0.1:8089 (not 8087, which SteamOS forwards to the network on 8088);
the uninstaller turns it off if Frametop turned it on.

Headset test 2026-10-13: window moves with the treadmill role work.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 21:23:04 -06:00

207 lines
8.5 KiB
Python
Executable File

#!/usr/bin/env python3
"""Let the 3D mouse's treadmill device move SteamVR's floating windows.
SteamVR's dashboard (vrwebhelper's systemui page) starts a floating window's move only for a
press from the head or a hand: UndockedOverlay.startFloatingWindowMove reads the pressing
device from the mouse event's CapsLock/NumLock bits and gives up on a treadmill. So the 3D
mouse, a treadmill since 2026-10-09 (docs/design.md, "Treadmill role"), clicked and resized
those windows but couldn't move them. treadmill-move.js wraps the grab bar's handler so a press
with no device moves the window with /user/treadmill. This puts it into the dashboard over the
Chrome DevTools Protocol, again whenever the dashboard's page is new (SteamVR restarted).
The dashboard's debugger is off by default. SteamVR turns it on with VRWebHelper.DebuggerEnabled
and listens on VRWebHelper.DebuggerPort, on 127.0.0.1 only. Not 8087: SteamOS's
steamvr-web-debug-portforward.service forwards 0.0.0.0:8088 to 127.0.0.1:8087, which would put
the dashboard's debugger on the network. Any local program can use the port, as with Steam's
own on 8080 (steam/steamui.py).
pointer/dashpatch/ft-dashpatch.py on # turn the debugger on (from SteamVR's next start)
pointer/dashpatch/ft-dashpatch.py off # turn it off again, if this turned it on
pointer/dashpatch/ft-dashpatch.py status # the settings, and whether the patch is in
pointer/dashpatch/ft-dashpatch.py run # keep the patch in (frametop-dashpatch.service)
With SteamVR running the settings go through vrcmd, since SteamVR keeps its settings in memory
and writes over the file; without it, they go into SteamVR's settings file.
"""
import json
import os
import re
import subprocess
import sys
import time
import urllib.request
from pathlib import Path
HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(HERE.parent.parent / "steam"))
from steamui import _WebSocket # noqa: E402 (Frametop's small CDP client)
HOME = Path.home()
SECTION = "VRWebHelper"
PORT = 8089
PATCH = HERE / "treadmill-move.js"
STEAMVR_BIN = "/opt/steamvr/bin/linuxarm64"
STEAMVR_SETTINGS = (HOME / ".config/openvr/config/steamvr.vrsettings",
HOME / ".steam/steam/config/steamvr.vrsettings")
# There when this turned the debugger on, so `off` leaves a debugger someone else turned on alone.
MARK = HOME / ".local/state/frametop/pointer/dashboard-debugger"
# A terminal in the desktop has the desktop's own config folder; vrcmd needs SteamVR's.
ENV = dict(os.environ, XDG_CONFIG_HOME=str(HOME / ".config"), LD_LIBRARY_PATH=STEAMVR_BIN)
def steamvr_up():
return subprocess.run(["pgrep", "-x", "vrserver"], capture_output=True).returncode == 0
def vrcmd(*args):
return subprocess.run([f"{STEAMVR_BIN}/vrcmd", *args], capture_output=True, text=True, timeout=15, env=ENV).stdout
def settings_file():
return next((p for p in STEAMVR_SETTINGS if p.exists()), STEAMVR_SETTINGS[0])
def get():
"""(DebuggerEnabled, DebuggerPort): a bool or None, an int or None."""
if steamvr_up():
on = re.search(rf"^{SECTION}\.DebuggerEnabled=(\w+)", vrcmd("--settings-bool", f"{SECTION}.DebuggerEnabled"), re.M)
port = re.search(rf"^{SECTION}\.DebuggerPort=(-?\d+)", vrcmd("--settings-int", f"{SECTION}.DebuggerPort"), re.M)
return (on.group(1) in ("true", "1") if on else None), (int(port.group(1)) if port else None)
try:
s = json.loads(settings_file().read_text()).get(SECTION, {})
except (OSError, ValueError, AttributeError):
return None, None
on, port = s.get("DebuggerEnabled"), s.get("DebuggerPort")
return (on if isinstance(on, bool) else None), (port if isinstance(port, int) else None)
def put(on):
if steamvr_up():
# 1 or 0: vrcmd reads "true" as 0.
vrcmd("--set-settings-bool", f"{SECTION}.DebuggerEnabled", "1" if on else "0")
if on:
vrcmd("--set-settings-int", f"{SECTION}.DebuggerPort", str(PORT))
got_on, got_port = get()
return got_on is on and (not on or got_port == PORT)
path = settings_file()
try:
d = json.loads(path.read_text())
except FileNotFoundError:
d = {}
except (OSError, ValueError) as e:
print(f"can't read {path}: {e}", file=sys.stderr)
return False
s = d.setdefault(SECTION, {})
s["DebuggerEnabled"] = on
if on:
s["DebuggerPort"] = PORT
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(".frametop-tmp")
tmp.write_text(json.dumps(d, indent=3) + "\n")
tmp.replace(path)
return True
def dashboard():
"""The systemui page's debugger URL, or None (SteamVR not up, or its debugger off)."""
try:
with urllib.request.urlopen(f"http://127.0.0.1:{PORT}/json", timeout=3) as r:
targets = json.load(r)
except (OSError, ValueError):
return None
return next((t.get("webSocketDebuggerUrl") for t in targets if t.get("title") == "systemui"), None)
def evaluate(url, expression):
ws = _WebSocket(url)
try:
ws.send(json.dumps({"id": 1, "method": "Runtime.evaluate",
"params": {"expression": expression, "returnByValue": True}}))
while True:
reply = json.loads(ws.recv())
if reply.get("id") == 1:
break
finally:
ws.close()
result = reply.get("result", {})
if "exceptionDetails" in result:
d = result["exceptionDetails"]
return "error: " + (d.get("exception", {}).get("description") or d.get("text", "?"))
return result.get("result", {}).get("value")
STATE = "JSON.stringify(window.__ftTreadmillMove || null)"
def run():
"""Puts the patch in each new dashboard page, and checks it's still in every 30 s (the page
can reload without a new URL)."""
patch = PATCH.read_text()
version = int(re.search(r"const VERSION = (\d+);", patch).group(1))
last_url, last_check = None, 0.0
while True:
url = dashboard()
now = time.monotonic()
if url and (url != last_url or now - last_check > 30):
try:
state = json.loads(evaluate(url, STATE) or "null")
if not isinstance(state, dict) or state.get("version", 0) < version:
print(f"SteamVR's dashboard: {evaluate(url, patch)}", flush=True)
last_url, last_check = url, now
except (OSError, ValueError) as e:
print(f"SteamVR's dashboard: can't reach it yet ({e})", flush=True)
last_url = None
time.sleep(3)
def main():
what = sys.argv[1] if len(sys.argv) == 2 else ""
if what == "on":
on, port = get()
if on and port == PORT:
print(f"SteamVR's dashboard debugger: on already, on port {PORT}")
return 0
if not put(True):
print("couldn't turn on SteamVR's dashboard debugger: the 3D mouse can't move SteamVR's windows. "
"Try again with: pointer/dashpatch/ft-dashpatch.py on", file=sys.stderr)
return 1
if not on:
MARK.parent.mkdir(parents=True, exist_ok=True)
MARK.write_text(f"{SECTION}.DebuggerEnabled was off; Frametop turned it on (port {PORT})\n")
print(f"SteamVR's dashboard debugger: on, on 127.0.0.1:{PORT}, once SteamVR restarts "
"(so the 3D mouse can move SteamVR's windows)")
return 0
if what == "off":
if not MARK.exists():
print("SteamVR's dashboard debugger: left as it is (Frametop didn't turn it on)")
return 0
if not put(False):
print("couldn't turn off SteamVR's dashboard debugger. Try again with: "
"pointer/dashpatch/ft-dashpatch.py off", file=sys.stderr)
return 1
MARK.unlink()
print("SteamVR's dashboard debugger: off again, once SteamVR restarts")
return 0
if what == "status":
on, port = get()
print(f"{SECTION}.DebuggerEnabled: {'on' if on else 'off'}" + (" (set by Frametop)" if MARK.exists() else "")
+ f", DebuggerPort: {port if port is not None else 'unset'}")
url = dashboard()
if not url:
print(f"dashboard: not reachable on 127.0.0.1:{PORT}")
else:
try:
print(f"dashboard patch: {evaluate(url, STATE)}")
except (OSError, ValueError) as e:
print(f"dashboard patch: unknown ({e})")
return 0
if what == "run":
run()
return 0
print("usage: pointer/dashpatch/ft-dashpatch.py on|off|status|run", file=sys.stderr)
return 2
if __name__ == "__main__":
sys.exit(main())