mirror of
https://github.com/DeeJanuz/frametop.git
synced 2026-10-14 15:00:53 +02:00
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>
207 lines
8.5 KiB
Python
Executable File
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())
|