Files
MichaelScottsman 8b422d1cd6 AssPod in + > Launch Program: the installer adds it once VLC is there, and it opens the assPod with the theme on or off
lgs pod: with the theme on, the bar's asspod.open; with it off, only the assPod's runtime modules
in Steam (rt.podOnly, no liveness watch on the CSS core) and lgs-shell in pod mode (no theme, the
assPod's page script only), which exits once the assPod is away with nothing playing. lgs on
replaces a pod unit with a full one.
2026-10-10 14:37:53 -04:00

4551 lines
233 KiB
Python

#!/usr/bin/env python3
"""Glass Shell native layer daemon: the transient user unit "lgs-shell".
While the theme is on, this keeps SteamVR's own pages themed (what the old
"lgs-vr" watcher did). With the native layer opted in (lgs on --native), it
also joins three things (docs/NATIVE.md):
Steam (devtools 8080, SharedJSContext)
device/lgs_layers.js reports the elements that pop out, through the CDP
binding "lgsLayers"; html.lgs-native is set on a window, and popped
elements are acknowledged (data-lgs-pop), only once the compositor
shows their glass
glassd (native/glassd/glassd, a child process)
renders the room-refracting window glass and the Liquid Glass slabs
into dmabuf overlays "glassd.<surface>"; it reads /dev/shm/lgs/glassd.json
and answers in /dev/shm/lgs/glassd-out.json
SteamVR systemui (devtools 8090)
device/lgs_sg.js builds the scene-graph nodes that layer glassd's glass
and crops of Steam's own textures in stereo depth (window.__LGS_SG)
Native mode is decided once when the daemon starts (native = auto | on | off;
auto reads /tmp/lgs/flags.json, then device/defaults.json, and is CSS only
unless one of them says native: on): a glassd binary that appears later is
never picked up, and one that disappears ends native mode for the session.
Without it (the default, and what the "+ > Launch Program" toggle runs) the
daemon is CSS only: SteamVR page theming, no reporter in Steam, no glassd, no
camera, and in systemui only what a flagged transform override or a window
dim needs (lgs_sg.js, contracts/sg.md §4-5).
In both modes it also (docs/phase2/contracts/daemon.md):
- publishes geometry (S, r, Hm, the frame menu's panel) and the shown frame
page from systemui to Steam's runtime bridge (__LGS_RT.bridge);
- relays Steam's "lgsAction" binding calls to device/shell_ext plugins,
strictly validated and rate-limited;
- injects SteamVR page scripts device/vr/<page>.<name>.js and removes them
again.
Pod mode (--pod, what `lgs pod` starts while the theme is off; + > Launch
Program > AssPod): no theme anywhere. SteamVR's pages are not themed, only
the assPod's page script goes in, Steam runs only the assPod's runtime
modules (rt.podOnly, loaded by lgs pod), and a theme that is off is not a
reason to go dormant. It exits once the assPod has been away with nothing
playing for POD_IDLE_S, at once after a Steam restart, and on lgs on (which
starts a full unit in its place) or lgs off.
Nothing persists: the unit is transient (systemd-run --collect) and writes no
bytecode (python3 -B); lgs off stops it at once. A theme off without lgs off
(a lab --stock step) makes it dormant: native layer, glassd, reporter and scene
graph off, SteamVR page theming kept; it resumes when the theme is back and
exits after shellThemeGraceS (600 s), at once after a Steam restart, or after
120 s without Steam (contracts/daemon.md §1). On exit it removes every node,
class, binding global and file it made. If it dies without cleaning up,
lgs-native clears itself within 3 s and the scene-graph nodes within 12 s
(heartbeat watchdogs).
lgs_shell.py start [--native|--css|--auto|--pod] [--glassd PATH|none]
[--glassd-args "ARGS"] [--feed] [--stay]
[--test-report PATH] [--assume-caps CAPS]
start the unit; lgs on does this.
--native opts in to the native layer
(from this command glassd gets
--no-feed unless --feed or
--glassd-args is given); --css is CSS
only; neither (or --auto) keeps a
running unit and resolves "auto" for
a new one. --stay (lab tests and
native-session only): dormant up to
STAY_MAX_S while the theme is off,
and native only for STAY_MAX_S.
--test-report (lab) replaces the
reporter with a report JSON file.
lgs_shell.py stop stop it (clean teardown)
lgs_shell.py status unit + live daemon status (JSON)
lgs_shell.py selftest NAME lab checks: actions (offline), dm1,
dm2, dm3, dm4, dm5, dm6, grace,
plates (contract §11)
lgs_shell.py daemon [--native] [--glassd PATH|none] ...
the unit's process (foreground)
"""
import sys
sys.dont_write_bytecode = True # no __pycache__ next to the code (hard rule 7); the unit also runs -B
import asyncio # noqa: E402
import collections # noqa: E402
import hashlib # noqa: E402
import json # noqa: E402
import os # noqa: E402
import shutil # noqa: E402
import signal # noqa: E402
import subprocess # noqa: E402
import time # noqa: E402
import traceback # noqa: E402
import urllib.request # noqa: E402
import weakref # noqa: E402
HERE = os.path.dirname(os.path.realpath(__file__))
sys.path.insert(0, HERE)
import lgs # noqa: E402
import lgs_vr # noqa: E402
import shell_ext # noqa: E402
UNIT = "lgs-shell"
SHM = "/dev/shm/lgs"
GLASSD_JSON = os.path.join(SHM, "glassd.json")
GLASSD_OUT = os.path.join(SHM, "glassd-out.json")
STATUS = os.path.join(SHM, "shell.json")
SG_FLAGS = "/tmp/lgs/sg-flags.json" # optional lgs_sg.js panel flags (wearer tests; transient)
FLAGS_FILE = "/tmp/lgs/flags.json" # session flags (RAM; P10 writes it for a locked step)
DEFAULTS_FILE = os.path.join(HERE, "defaults.json") # V1's shipped defaults (PLAN §1.17)
GLASSD_BIN = os.path.join(lgs.ROOT, "native", "glassd", "glassd")
LAYERS_JS = os.path.join(HERE, "lgs_layers.js")
LAYERS_CFG = os.path.join(lgs.THEME_DIR, "layers.json") # Phase 1 configuration (retired by P6)
LAYERS_DIR = os.path.join(lgs.THEME_DIR, "layers") # Phase 2 fragments, name order
SG_JS = os.path.join(HERE, "lgs_sg.js")
SG_RULES_DIR = os.path.join(lgs.THEME_DIR, "sg") # transform overrides (contracts/sg.md §4)
MOTION_JS = os.path.join(HERE, "shared", "motion.js") # P5's springs, prepended to lgs_sg.js
VR_SCRIPT_DIRS = (os.path.join(HERE, "vr"), "/tmp/lgs/vr-scripts") # page scripts; the second for tests
VR_PAUSE = lgs_vr.PAUSE
VR_PAUSE_MAX_S = 300 # a lab --theme off pause on vr: pages older than this is stale (REQ C6b->P8)
BINDING = "lgsLayers"
ACTION_BINDING = "lgsAction" # Steam -> daemon actions (shell_ext)
REPORTER = "__LGS_LAYERS" # global installed by lgs_layers.js
STEAM_MAIN_KEY = "valve.steam.gamepadui.main"
DAEMON_BEAT_S = 2.0 # bridge 'daemon' heartbeat; its ttlMs is 3x this
GEOM_POLL_S = 1.0 # systemui geometry and page poll
GEOM_DUMP_S = 15.0 # DumpLaserOverlays (a ~100 ms compositor round trip) as a safety net this often;
GEOM_DUMP_AFTER = (1.0, 3.0, 6.0) # ... and these many s after the frame menu's node changed (the dump lags it)
ACTION_TICK_S = 1.0 # plugin scan and tick
RAMP_IN_MS = {"window": 735, "thick": 735} # GM §5.2: covers and thick ride sheet-in (settles ~0.73 s)
RAMP_OUT_MS = {"window": 514, "thick": 514} # sheet-out (~0.51 s)
RAMP_IN_DEFAULT_MS, RAMP_OUT_DEFAULT_MS, RAMP_REDUCED_MS = 250, 350, 180
PAGE_OUT_MS = 150 # --lgs-d-page-out: a leaving route's fade; plates removed with it fade on it
TUNE_SAVE_S = 0.4 # the wearer's tune is saved (and the dial file mirrored) this long after the last change
# glassd.json v3 fields copied from the report, and the glassd cap each needs
# (contracts/glassd.md; contracts/daemon.md §7)
SURF_V3 = {"plates": "plates", "coverDz": "coverDz", "masks": "masks", "scaleFrom": "scaleFrom"}
SURF_V2 = ("quad", "phase", "appear", "phaseMs")
SLAB_V3 = {"tint": "tint", "hole": "holes", "ox": "offset", "oy": "offset"}
SLAB_V2 = ("phase", "appear", "phaseMs")
PLATE_KEYS = ("id", "x", "y", "w", "h", "r", "material", "phase", "appear", "phaseMs", "appearAt", "tint", "fill",
"occluder", "shadow", "exitAt", "exitMs")
TOP_V3 = {"unitM": "unitM", "masks": "masks", "roomDim": "roomDim"}
COVER_DZ, BASE_DZ = 0.001, 0.002 # metres toward the viewer (NATIVE.md)
SG_MAX_PUSH_HZ = 15 # lgs_sg.js scheduler pushes per second, at most
SPEC_MIN_INTERVAL = 1 / SG_MAX_PUSH_HZ
GLASSD_JSON_MIN_INTERVAL = 0.25 # position-only changes to glassd.json
WATCHDOG_MS = 12000 # lgs_sg.js clears its nodes without a heartbeat this long
NATIVE_TTL_MS = 3000 # lgs-native clears itself without a heartbeat this long (< WATCHDOG_MS)
NATIVE_BEAT_S = 1.0 # lgs-native / ack heartbeat
SHOWN_MS = 350 # a pushed scene-graph change is on screen after this (fact 4: ~0.3 s)
SG_BEAT_FRESH_S = 2.5 # lgs-native needs a systemui heartbeat this recent
NATIVE_GRACE_S = 1.5 # a window keeps lgs-native this long after glassd last drew glass for it
MORPH_TOKENS = ("interactive", "hover-in", "fade", "snappy", "morph-open", "morph-close", "sheet-in", "sheet-out",
"page", "depth") # native/shared/motion_tokens.h: the springs a cover morph may ride
STILL_S = 0.15 # a layer pops once its rect has been still this long
STILL_PX = 2.0 # ... within this many texture px
OVERLAP_CLIP_PX = 2.5 # overlaps this thin are trimmed off, wider ones skip the layer
SLAB_SIZE_TOL_PX = 2.5 # glassd slab vs element x backdropScale
THEME_OFF_POLLS = 3 # Steam theme seen off this many polls (1 s) -> dormant (or exit)
THEME_OFF_GRACE_S = 600 # ... dormant this long before exiting (flag shellThemeGraceS; 0 = exit)
STAY_MAX_S = 2400 # --stay: dormant at most this long, and native at most this long after start
POD_SCRIPTS = ("asspod",) # --pod: the only SteamVR page scripts it installs
POD_IDLE_S = 30.0 # --pod: exit once the assPod is away and nothing plays this long
POD_FIRST_OPEN_S = 90.0 # --pod: ... or when it was never taken out this long after start
POD_RT_TEARDOWN_JS = ("(async () => { const r = window.__LGS_RT; if (!r || !r.podOnly || typeof r.teardown !== 'function')"
" return 'absent'; await r.teardown('pod daemon exit'); return 'removed'; })()")
# (glass.py's native-session client gives up after 2400 s; a unit older than
# that has lost its client, e.g. a usage-limit cut-off)
STEAM_GONE_S = 120 # Steam's devtools unreachable this long -> exit (also with --stay)
STEAM_SLOW_MAX = 3 # evaluations timing out in a row before the Steam socket is dropped
GLASSD_MAX_CRASHES = 5 # within GLASSD_CRASH_WINDOW s -> give up (CSS only)
GLASSD_CRASH_WINDOW = 120
GLASSD_TEMP_EXIT = 75 # EX_TEMPFAIL from glassd: SteamVR gone, lock held; not a crash
GLASSD_STALE_S = 6.0 # glassd rewrites glassd-out.json every 2 s ("updated")
GLASSD_FIRST_OUT_S = 20.0 # time for glassd's first glassd-out.json
STALL_S = 1.5 # the daemon's event loop did not run this long: it was stopped (SIGSTOP) or
# starved; its own silence is not glassd's or Steam's (REQ P7->P8, SG-6)
MAIN_QUAD_W = 0.98 # main window's nominal world width (m), NATIVE.md fact 4
MAIN_QUAD_TOL = 0.12 # glassd's quadW off by more than this: main stays CSS only
BUG_LIMIT, BUG_WINDOW = 5, 120 # unexpected exceptions in one loop before it is fatal
SPEC_LOG_MIN_S = 1.0 # "systemui: spec" log lines at most this often
START_LOCK = "/tmp/lgs/shell-start.lock" # serializes start() (two `lgs on` at once; RAM)
TEST_EXT_DIR = "/tmp/lgs/shell-ext-test" # lab: RAM plugins (kinds echo / read only), selftest dm4
def _net_errors():
errs = (ConnectionError, OSError, RuntimeError, asyncio.TimeoutError, TimeoutError, ValueError, EOFError)
try:
import aiohttp
errs += (aiohttp.ClientError,) # WSServerHandshakeError, ServerDisconnectedError, ...
except ImportError:
pass
return errs
NET_ERRORS = _net_errors() # a lost or refused devtools connection: retry
def log(msg):
print(time.strftime("%H:%M:%S ") + msg, flush=True)
lgs.log("shell: " + msg)
def write_json_atomic(path, obj):
os.makedirs(os.path.dirname(path), exist_ok=True)
tmp = f"{path}.{os.getpid()}.tmp"
with open(tmp, "w", encoding="utf-8") as f:
json.dump(obj, f, separators=(",", ":"))
os.replace(tmp, path)
def read_json(path):
try:
with open(path, encoding="utf-8") as f:
return json.load(f)
except (OSError, ValueError):
return None
def remove_glassd_files():
for p in (GLASSD_JSON, GLASSD_OUT):
try:
os.remove(p)
except OSError:
pass
def read_dial():
return lgs.read_dial()
def _mtime(path):
try:
return os.stat(path).st_mtime_ns
except OSError:
return None
def http_json(url, timeout=3):
with urllib.request.urlopen(url, timeout=timeout) as r:
return json.load(r)
# ------------------------------------------------------------------ flags and the native decision
def flat_flags(d):
"""A flags file is flat ({"native": "on"}) or nested ({"flags": {...}})."""
if not isinstance(d, dict):
return {}
if isinstance(d.get("flags"), dict):
out = {k: v for k, v in d.items() if k != "flags"}
out.update(d["flags"])
return out
return dict(d)
class FlagReader:
"""defaults.json < /tmp/lgs/flags.json, re-read when either file changes."""
def __init__(self, paths=(DEFAULTS_FILE, FLAGS_FILE)):
self.paths = paths
self.cache = {}
self.merged = {}
self.sig = None
def read(self):
sig = []
for p in self.paths:
try:
sig.append(os.stat(p).st_mtime_ns)
except OSError:
sig.append(None)
sig = tuple(sig)
if sig != self.sig:
self.sig = sig
m = {}
for p, s in zip(self.paths, sig):
if s is not None:
m.update(flat_flags(read_json(p)))
self.merged = m
return self.merged
def native_word(v):
if v is True or v in ("on", "true", "1", 1):
return "on"
if v is False or v in ("off", "false", "0", 0):
return "off"
return "auto"
def resolve_native(requested):
"""(native: bool, source, reason) for start(native=requested). 'auto' reads
/tmp/lgs/flags.json, then device/defaults.json; 'auto' in a file means
"not decided" and falls through; the built-in end of the chain is CSS only."""
req = native_word(requested) if requested is not None else "auto"
if req != "auto":
return req == "on", "cli", None if req == "on" else "CSS only requested (lgs on --css)"
for src, path in (("flags", FLAGS_FILE), ("defaults", DEFAULTS_FILE)):
v = native_word(flat_flags(read_json(path)).get("native"))
if v != "auto":
return v == "on", src, None if v == "on" else f"native=auto: native off in {path}"
return False, "builtin", "native=auto: the native gate has not passed (device/defaults.json has no native: on)"
def css_reason_for(req, src):
if req == "off":
return "CSS only requested (lgs on --css)"
if req == "auto":
if src == "builtin":
return "native=auto: the native gate has not passed (device/defaults.json has no native: on)"
return f"native=auto: native off in {FLAGS_FILE if src == 'flags' else DEFAULTS_FILE}"
return "native layer not requested; lgs on --native opts in"
def json_files(folder):
"""*.json in name order, skipping _wip/ and dot files."""
try:
names = sorted(n for n in os.listdir(folder) if n.endswith(".json") and not n.startswith((".", "_")))
except OSError:
return []
return [os.path.join(folder, n) for n in names]
def layers_config():
"""The reporter's configuration: theme/layers/*.json as [{file, ...}] in name
order (contracts/reporter.md §3.1), else Phase 1's theme/layers.json object.
Returns (config, errors, version)."""
errors, frags = [], []
for p in json_files(LAYERS_DIR):
d = read_json(p)
if isinstance(d, dict):
frags.append(dict(d, file=os.path.basename(p)))
else:
errors.append(f"{os.path.basename(p)}: not a JSON object")
if frags:
cfg = frags
else:
cfg = read_json(LAYERS_CFG) if os.path.exists(LAYERS_CFG) else None
if os.path.exists(LAYERS_CFG) and cfg is None:
errors.append("theme/layers.json: not valid JSON")
ver = hashlib.sha1(json.dumps(cfg, sort_keys=True).encode()).hexdigest()[:10] if cfg is not None else None
return cfg, errors, ver
def sg_rules(flags):
"""Active transform overrides from theme/sg/*.json (contracts/sg.md §4.1):
merged in name order, `supersedes` drops earlier ids, a rule with a `flag`
is kept only while that flag is true. Returns (rules, errors)."""
rules, errors, order = {}, [], []
for p in json_files(SG_RULES_DIR):
d = read_json(p)
if not isinstance(d, dict) or not isinstance(d.get("rules", []), list):
errors.append(f"{os.path.basename(p)}: not a fragment")
continue
for rid in d.get("supersedes") or []:
if rid in rules:
del rules[rid]
order.remove(rid)
for r in d.get("rules") or []:
if not isinstance(r, dict) or not isinstance(r.get("id"), str):
errors.append(f"{os.path.basename(p)}: rule without id")
continue
if r["id"] in rules:
errors.append(f"{os.path.basename(p)}: duplicate rule {r['id']}")
continue
rules[r["id"]] = dict(r, fragment=os.path.basename(p))
order.append(r["id"])
active = [rules[i] for i in order if not rules[i].get("flag") or flags.get(rules[i]["flag"]) is True]
return active, errors
def vr_scripts():
"""SteamVR page scripts: {name: {page, path, version, flag, src}} from
device/vr/<page>.<name>.js and the test folder (the test folder wins)."""
out = {}
for folder in VR_SCRIPT_DIRS:
try:
names = sorted(os.listdir(folder))
except OSError:
continue
for n in names:
if not n.endswith(".js") or n.startswith((".", "_")) or n.count(".") < 2:
continue
page, name = n[:-3].split(".", 1)
p = os.path.join(folder, n)
try:
with open(p, encoding="utf-8") as f:
src = f.read()
except OSError:
continue
flag = None
for line in src.splitlines()[:5]:
rest = line.split("@lgs-flag", 1)[1].split() if "@lgs-flag" in line else []
if rest:
flag = rest[0]
out[name] = {"page": page, "path": p, "flag": flag, "src": src.strip().rstrip(";"),
"version": hashlib.sha1(src.encode()).hexdigest()[:10]}
return out
def _pdeathsig():
"""preexec for glassd: SIGTERM it when the daemon dies (a hand-run daemon
killed outside systemd must not leave the camera-reading child behind)."""
try:
import ctypes
ctypes.CDLL("libc.so.6", use_errno=True).prctl(1, int(signal.SIGTERM)) # PR_SET_PDEATHSIG
except Exception: # noqa: BLE001 - best effort
pass
# ------------------------------------------------------------------ CDP
class CDP:
"""A persistent devtools websocket: concurrent calls plus event handlers."""
live = weakref.WeakSet() # open sockets, closed at the daemon's exit (no aiohttp warnings)
def __init__(self, name, ws_url):
self.name = name
self.ws_url = ws_url
self.n = 0
self.pending = {}
self.handlers = {}
self.closed = False
self.ws = None
self.http = None
self.reader = None
async def open(self):
import aiohttp
self.http = aiohttp.ClientSession()
CDP.live.add(self)
try:
self.ws = await self.http.ws_connect(self.ws_url, max_msg_size=0)
except BaseException:
await self.http.close()
raise
self.reader = asyncio.create_task(self._read())
return self
async def _read(self):
import aiohttp
try:
async for msg in self.ws:
if msg.type != aiohttp.WSMsgType.TEXT:
if msg.type in (aiohttp.WSMsgType.CLOSE, aiohttp.WSMsgType.CLOSED, aiohttp.WSMsgType.ERROR):
break
continue
d = json.loads(msg.data)
if "id" in d:
fut = self.pending.pop(d["id"], None)
if fut and not fut.done():
fut.set_result(d)
else:
cb = self.handlers.get(d.get("method"))
if cb:
try:
cb(d.get("params") or {})
except Exception as e: # noqa: BLE001 - a handler bug must not kill the socket
log(f"{self.name}: handler {d.get('method')}: {e!r}")
except Exception: # noqa: BLE001 - socket torn down
pass
finally:
self.closed = True
for fut in self.pending.values():
if not fut.done():
fut.set_exception(ConnectionError(f"{self.name}: devtools socket closed"))
self.pending.clear()
async def send(self, method, params=None, timeout=20):
if self.closed:
raise ConnectionError(f"{self.name}: devtools socket closed")
self.n += 1
i = self.n
fut = asyncio.get_running_loop().create_future()
self.pending[i] = fut
try:
await self.ws.send_json({"id": i, "method": method, "params": params or {}})
d = await asyncio.wait_for(fut, timeout)
finally:
self.pending.pop(i, None)
if "error" in d:
raise RuntimeError(f"{method}: {d['error']}")
return d["result"]
async def eval(self, expr, timeout=20, by_value=True):
r = await self.send("Runtime.evaluate", {
"expression": expr, "returnByValue": by_value, "awaitPromise": True}, timeout)
if "exceptionDetails" in r:
ex = r["exceptionDetails"]
raise RuntimeError("JS: " + str(ex.get("exception", {}).get("description") or ex.get("text")))
return r["result"].get("value") if by_value else r["result"]
async def close(self):
self.closed = True
CDP.live.discard(self)
try:
if self.ws is not None:
await self.ws.close()
except Exception: # noqa: BLE001
pass
try:
if self.http is not None:
await self.http.close()
except Exception: # noqa: BLE001
pass
if self.reader is not None:
self.reader.cancel()
async def eval_once(ws_url, expr, timeout=20, name="page"):
"""One evaluation on a fresh socket that is always closed again."""
c = CDP(name, ws_url)
try:
await c.open()
return await c.eval(expr, timeout)
finally:
await c.close()
# ------------------------------------------------------------------ Steam-side helpers
# Sets html.lgs-native on the Steam windows whose glassd cover the compositor
# shows, and acknowledges the popped layers it shows to the reporter
# (__LGS_LAYERS.ack: only those get data-lgs-pop). Only while the theme
# (__LGS) is on. Without a heartbeat (set()) for ttl ms it removes the class
# again, so a dead daemon never leaves windows unpainted.
# `all`: native mode is live (glassd and the scene graph healthy): every Steam
# window is native, pooled popups included, before they first show, so their
# glass is glassd's from the first frame (lgs_core.js also sets the class on a
# window it injects into while h.live()).
NATIVE_JS = r"""
(function (keys, ttl, acks, plates, covers, all) {
const W = window;
let h = W.__LGS_NATIVE;
if (!h) {
const ALIAS = { 'valve.steam.gamepadui.main': /^VR_uid/, 'valve.steam.gamepadui.keyboard': /^VRKeyboard_uid/,
'valve.steam.gamepadui.notifications': /^VRNotificationToasts_uid/ };
const matches = (name, key) => (ALIAS[key] ? ALIAS[key].test(name) : (name.startsWith(key + '.') || name.startsWith(key + '_')));
h = W.__LGS_NATIVE = { keys: [], acks: {}, plates: {}, covers: [], all: false, last: 0, ttl, timer: 0, applied: [], tagged: null };
h.live = () => (h.all || h.keys.length > 0) && Date.now() - h.last < h.ttl
&& !!(W.__LGS && W.__LGS.state && W.__LGS.state.enabled);
h.sweep = () => {
const live = h.live();
const applied = [];
let pops = [];
try { pops = [...g_PopupManager.m_mapPopups.values()]; } catch (_) { /* not ready */ }
for (const p of pops) {
let doc = null;
try { doc = p.window && p.window.document; } catch (_) { continue; }
if (!doc || !doc.documentElement) continue;
const on = live && (h.all || h.keys.some((k) => matches(p.m_strName, k)));
const cl = doc.documentElement.classList;
if (on !== cl.contains('lgs-native')) cl.toggle('lgs-native', on);
if (on) applied.push(p.m_strName.replace(/_uid\d+$/, ''));
}
h.applied = applied;
if (!live && h.timer) { clearInterval(h.timer); h.timer = 0; }
return applied;
};
// v3 reporters (status().v >= 3) take {key: {cover, pops, plates}}; older
// ones {key: [pop ids]} (contracts/reporter.md §7)
h.ack = () => {
try {
const L = W.__LGS_LAYERS;
if (!L || typeof L.ack !== 'function') return;
let v3 = false;
try { v3 = typeof L.status === 'function' && (L.status().v | 0) >= 3; } catch (_) { /* old */ }
const map = {};
if (h.keys.length) {
for (const k of h.keys) {
map[k] = v3 ? { cover: h.covers.includes(k), pops: h.acks[k] || [], plates: h.plates[k] || [] }
: (h.acks[k] || []);
}
}
h.tagged = L.ack(map);
} catch (_) { /* reporter restarting */ }
};
h.set = (ks, ac, pl, cv, al) => {
h.keys = ks || []; h.acks = ac || {}; h.plates = pl || {}; h.covers = cv || h.keys; h.all = !!al; h.last = Date.now();
if (!h.timer && (h.all || h.keys.length)) h.timer = setInterval(h.sweep, 1000);
const r = h.sweep(); h.ack(); return r;
};
h.clear = () => { h.keys = []; h.acks = {}; h.plates = {}; h.covers = []; h.all = false; const r = h.sweep(); h.ack(); if (h.timer) clearInterval(h.timer); delete W.__LGS_NATIVE; return r; };
}
h.ttl = ttl;
const applied = h.set(keys, acks, plates, covers, all);
return JSON.stringify({ applied, tagged: h.tagged });
})
"""
# __LGS_RT.bridge (P1's runtime): set() each [name, value]; 'none' without a runtime
BRIDGE_SET_JS = r"""
(function (pairs) {
const R = window.__LGS_RT, b = R && R.bridge;
if (!b || typeof b.set !== 'function') return 'none';
let n = 0;
for (const [k, v] of pairs) { try { b.set(k, v); n++; } catch (_) { /* a listener threw */ } }
return 'ok:' + n;
})
"""
# Names the runtime does not hold (it was reinstalled): null without a runtime
BRIDGE_MISSING_JS = r"""
(function (names) {
const R = window.__LGS_RT, b = R && R.bridge;
if (!b || typeof b.set !== 'function') return null;
if (typeof b.get !== 'function') return names;
return names.filter((n) => { try { return b.get(n) === undefined; } catch (_) { return true; } });
})
"""
# Our CDP binding globals out of SharedJSContext: Runtime.removeBinding does not
# delete the function a closed session installed (G-REMOVE, review R1 M1). Only
# native-code functions (bindings); a test's own JS stand-in is left alone.
# Returns the names deleted, joined ('' when none).
DELETE_BINDINGS_JS = r"""
(function (names) {
const out = [];
for (const n of names) {
const f = window[n];
if (typeof f === 'function' && /\[native code\]/.test(Function.prototype.toString.call(f))) {
try { delete window[n]; out.push(n); } catch (_) { /* non-configurable */ }
}
}
return out.join(',');
})
"""
# Steam tick: theme on, Reduce Motion
STEAM_TICK_JS = ("JSON.stringify([!!(window.__LGS && window.__LGS.state && window.__LGS.state.enabled), "
"!!(window.matchMedia && matchMedia('(prefers-reduced-motion: reduce)').matches)])")
# systemui, read-only: geometry (the SP §1.2 snippet), the Steam frame's shown
# page (CC15) and, with dump, the frame menu's laser panel (WN §3.3.2)
GEOM_JS = r"""
(async function (wantDump) {
const out = { geom: null, page: null, err: null };
try {
const FS = window.FrameStore, DS = window.DashboardStore;
if (!FS || !DS || !FS.frames || !FS.frames.length) { out.err = 'no FrameStore'; return JSON.stringify(out); }
const MAIN = 'valve.steam.gamepadui.main';
// Steam's page by its summon key (page ids are handed out at run time: page 3
// once, page 4 now), not /page:3/ or the frame's active page, which is SteamVR
// Settings or the binding UI while those show (REQ P7->P8, sg.md §4.2)
let f = null, sp = null;
for (const x of FS.frames) {
const ps = x.pages ? [...(typeof x.pages.values === 'function' ? x.pages.values() : x.pages)] : [];
const p = ps.find((q) => q && q.m_sSummonOverlayKey === MAIN);
if (p) { f = x; sp = p; break; }
}
if (!f) { f = FS.frames[0]; sp = f.activePage; }
const z = sp.size, S = DS.dashboardScale, H0 = f.size.mainPanelHeightOverride || 1.5;
const r = z.latestMeasuredPanelLocalHeight / H0, Hw = z.latestMeasuredPanelWorldHeight;
const g = { S, H0, r, Hm: Hw, unitM: S * r, mmPerCssPx: 1500 * Hw / 1080, dashDist: DS.dashboardDistance, frameMenu: null };
const sf = FS.frames.find((x) => x.m_mapPages && [...x.m_mapPages.values()].some((p) => p.m_sSummonOverlayKey === MAIN)) || f;
const pages = {};
if (sf.m_mapPages) for (const [id, p] of sf.m_mapPages) pages[id] = p.m_sSummonOverlayKey;
const ap = sf.activePage;
out.page = { frameID: sf.m_unFrameID, activePageID: sf.m_unActivePageID, summonKey: ap ? ap.m_sSummonOverlayKey : null,
steam: !!ap && ap.m_sSummonOverlayKey === MAIN, pages };
const el = document.querySelector('[id^="PooledPopup-valve.steam.gamepadui.frame.menu"]');
if (el && typeof el.buildNode === 'function') {
const p = el.buildNode({}, el)[1].properties;
g.frameMenu = { key: p.key, mpp: p['meters-per-pixel'], uv: [p.uv_min[0], p.uv_min[1], p.uv_max[0], p.uv_max[1]],
visible: p.visibility === 0 || p.visibility === undefined };
// what can change the laser panel's size: a change re-dumps (not published)
out.fmSig = JSON.stringify(['key', 'uv_min', 'uv_max', 'meters-per-pixel', 'visibility', 'scale-index',
'frame-resize-scale-factor', 'origin', 'curvature'].map((k) => p[k]));
if (wantDump && window.OverlayStore && OverlayStore.DumpLaserOverlays) {
const d = await OverlayStore.DumpLaserOverlays();
const ov = d && d.overlays || {};
const k = Object.keys(ov).find((n) => n.startsWith(p.key + ' ') || n.startsWith(p.key + '_'));
const sp = k && ov[k].scene_graph_panel;
if (sp) { g.frameMenu.fWidth = sp.fWidth; g.frameMenu.fHeight = sp.fHeight; g.frameMenu.dumped = true; }
}
}
out.geom = g;
} catch (e) { out.err = String(e && e.message || e).slice(0, 200); }
return JSON.stringify(out);
})
"""
# systemui: our scene graph out (overrides restored), and P5's springs that
# the daemon prepended (P5's REQ: no global left behind)
SG_DESTROY_JS = r"""
(() => {
let r = 'absent';
if (window.__LGS_SG) { try { window.__LGS_SG.destroy(); r = 'cleared'; } catch (e) { r = 'error: ' + e; } }
const M = globalThis.__LGS_MOTION;
if (M) { try { if (typeof M.remove === 'function') M.remove(); } catch (_) { /* best effort */ } delete globalThis.__LGS_MOTION; }
return r;
})()
"""
# SteamVR page scripts (contracts/daemon.md §6). VRX_INSTALL_JS(name, version,
# ctx) wraps the script's function expression.
VRX_PROBE_JS = r"""
(function (want) {
const R = window.__LGS_VRX || {};
const out = {};
for (const [n, v] of Object.entries(want)) out[n] = R[n] ? (R[n].version === v ? 'ok' : 'stale') : 'need';
for (const n of Object.keys(R)) if (!(n in want)) out[n] = 'extra';
return JSON.stringify(out);
})
"""
VRX_REMOVE_JS = r"""
(function (names) {
const R = window.__LGS_VRX;
if (!R) return 0;
let n = 0;
for (const k of Object.keys(R)) {
if (names && !names.includes(k)) continue;
try { if (R[k].api && typeof R[k].api.remove === 'function') R[k].api.remove(); } catch (_) { /* its bug */ }
delete R[k]; n++;
}
if (!Object.keys(R).length) delete window.__LGS_VRX;
return n;
})
"""
def vrx_install_js(name, version, ctx, src):
return (f"(() => {{ const R = window.__LGS_VRX || (window.__LGS_VRX = {{}}); const N = {json.dumps(name)};\n"
f"if (R[N] && R[N].version === {json.dumps(version)}) return 'ok';\n"
"if (R[N]) { try { R[N].api && R[N].api.remove && R[N].api.remove(); } catch (_) {} delete R[N]; }\n"
f"let api;\ntry {{ api = ({src}\n)({json.dumps(ctx)}); }}\n"
"catch (e) { if (!Object.keys(R).length) delete window.__LGS_VRX; return 'error: ' + String(e && e.message || e).slice(0, 160); }\n"
f"R[N] = {{ version: {json.dumps(version)}, api: api || {{}}, at: Date.now() }};\nreturn 'installed'; }})()")
MAIN_SIZE_JS = r"""
(() => { try {
const w = SteamUIStore.WindowStore.VRGamepadUIMainWindowInstance.BrowserWindow;
return [Math.round(w.innerWidth * w.devicePixelRatio), Math.round(w.innerHeight * w.devicePixelRatio)];
} catch (e) { return null; } })()
"""
REPORTER_STOP_JS = f"""
(() => {{ const r = window.{REPORTER}; if (!r) return 'absent';
for (const m of ['stop', 'disable', 'destroy']) if (typeof r[m] === 'function') {{ r[m](); return m; }}
return 'no stop method'; }})()
"""
# The reporter's heartbeat (it stops itself when pings stop), sent only to our
# own instance. 'ok' | 'foreign' (running, but another binding: someone's
# test) | 'waiting' (for the theme's token index) | 'stopped' | 'absent' | 'error'
REPORTER_PING_JS = f"""
(() => {{ const r = window.{REPORTER}; if (!r || typeof r.status !== 'function') return 'absent';
try {{ const s = r.status();
if (!s.running) return s.waiting ? 'waiting' : 'stopped';
if (s.binding !== {json.dumps(BINDING)}) return 'foreign';
if (typeof r.ping === 'function') r.ping();
return 'ok';
}} catch (e) {{ return 'error'; }} }})()
"""
REPORTER_RESEND_JS = f"""
(() => {{ const r = window.{REPORTER}; if (!r) return 'absent';
for (const m of ['resend', 'flush', 'report', 'refresh']) if (typeof r[m] === 'function') {{ try {{ r[m](true); }} catch (_) {{}} return m; }}
return 'none'; }})()
"""
def file_version(path):
try:
with open(path, "rb") as f:
return hashlib.sha1(f.read()).hexdigest()[:10]
except OSError:
return None
def material_for(surface):
if surface.get("material"):
return surface["material"]
return "window" if surface.get("name") == "main" else "panel"
# ------------------------------------------------------------------ report -> glassd.json v3 checks
def _num(v, lo=-1e7, hi=1e7):
"""A finite number within [lo, hi], else None (bools are not numbers)."""
if isinstance(v, bool) or not isinstance(v, (int, float, str)):
return None
try:
f = float(v)
except ValueError:
return None
return f if lo <= f <= hi and f == f else None
def _nums(v, n, lo=-1e7, hi=1e7):
"""A list of exactly n finite numbers (floats), else None."""
if not isinstance(v, (list, tuple)) or len(v) != n:
return None
out = [_num(x, lo, hi) for x in v]
return None if None in out else out
def _round(f):
return round(f, 4) if f != int(f) else int(f)
def clean_color(c):
"""A CSS colour string or [r, g, b(, a)] in 0..1 (contracts/glassd.md units)."""
if isinstance(c, str) and 0 < len(c) <= 64:
return c
if isinstance(c, list) and len(c) in (3, 4):
vals = [_num(v, 0, 1) for v in c]
if all(v is not None for v in vals):
return [_round(v) for v in vals]
return None
def clean_rect(d, keys=("x", "y", "w", "h", "r"), optional=("r",)):
out = {}
for k in keys:
if k not in d and k in optional:
continue
v = _num(d.get(k))
if v is None:
return None
out[k] = _round(round(v, 5 if k == "dz" else 2))
return out
def clean_phase_fields(src, dst):
if "phase" in src and _num(src["phase"], 0, 1) is not None:
dst["phase"] = _round(_num(src["phase"], 0, 1))
if src.get("appear") in ("materialize", "dematerialize"):
dst["appear"] = src["appear"]
if "phaseMs" in src and _num(src["phaseMs"], 0, 10000) is not None:
dst["phaseMs"] = int(_num(src["phaseMs"], 0, 10000))
# the epoch ms the materialize started at (the page's own open animation): glassd ramps from then
if "appearAt" in src and _num(src["appearAt"], 0, 1e15) is not None:
dst["appearAt"] = int(_num(src["appearAt"], 0, 1e15))
def clean_plate(p, i):
if not isinstance(p, dict):
return None
out = clean_rect(p)
if out is None or out["w"] < 2 or out["h"] < 2:
return None
pid = p.get("id")
out["id"] = str(pid)[:64] if pid is not None else f"p{i}"
if isinstance(p.get("material"), str) and len(p["material"]) <= 16:
out["material"] = p["material"]
clean_phase_fields(p, out)
for k in ("tint", "fill"):
if k in p and clean_color(p[k]) is not None:
out[k] = clean_color(p[k])
if isinstance(p.get("occluder"), bool):
out["occluder"] = p["occluder"]
if "shadow" in p and _num(p["shadow"], 0, 1) is not None:
out["shadow"] = _round(_num(p["shadow"], 0, 1))
# its content fading out (a route leaving): from that epoch ms over that long (contracts/glassd.md §1.3)
if _num(p.get("exitAt"), 1, 1e15) is not None:
ms = _num(p.get("exitMs"), 0, 2000)
out["exitAt"] = int(_num(p["exitAt"], 1, 1e15))
out["exitMs"] = int(ms) if ms is not None else PAGE_OUT_MS
return out
def clean_edges(e):
"""hole.edges (contracts/glassd.md §1.4, cap holeEdges): {top, right, bottom,
left}, each a colour or a list of 1-8 colours; unparsable colours and empty
edges are dropped. None when nothing is left."""
if not isinstance(e, dict):
return None
out = {}
for k in ("top", "right", "bottom", "left"):
v = e.get(k)
if isinstance(v, list) and v and not (len(v) in (3, 4) and all(_num(x) is not None for x in v)):
cs = [c for c in (clean_color(x) for x in v[:8]) if c is not None]
if cs:
out[k] = cs
elif v is not None and clean_color(v) is not None:
out[k] = clean_color(v)
return out or None
def clean_hole(h, clip=None, edges=False):
if h is True:
return {"clip": [_round(round(v, 2)) for v in clip]} if clip else True
if not isinstance(h, dict):
return None
out = {}
for k, lo, hi in (("shadow", 0, 1), ("y", -500, 500), ("blur", 0, 500)):
if k in h and _num(h[k], lo, hi) is not None:
out[k] = _round(_num(h[k], lo, hi))
if "fill" in h and clean_color(h["fill"]) is not None:
out["fill"] = clean_color(h["fill"])
if edges and clean_edges(h.get("edges")) is not None: # REQ P9->P8, only with glassd's holeEdges cap
out["edges"] = clean_edges(h["edges"])
c = h.get("clip")
if isinstance(c, list) and len(c) == 4 and all(_num(v) is not None for v in c):
out["clip"] = [_round(round(_num(v), 2)) for v in c]
elif clip:
out["clip"] = [_round(round(v, 2)) for v in clip]
return out or True
def clean_masks(ms, world=False):
"""World quads {O, U, V} (top level) or surface rects {x, y, w, h, dz?}, ≤ 8."""
out = []
for m in (ms if isinstance(ms, list) else [])[:8]:
if not isinstance(m, dict):
continue
if world:
q = {}
for k in ("O", "U", "V"):
v = m.get(k)
if isinstance(v, list) and len(v) == 3 and all(_num(x) is not None for x in v):
q[k] = [_round(round(_num(x), 5)) for x in v]
if len(q) == 3:
out.append(q)
else:
r = clean_rect(m, ("x", "y", "w", "h", "dz"), optional=("dz",))
if r is not None:
out.append(r)
return out
def clean_quad(q):
m = clean_masks([q], world=True)
return m[0] if m else None
# ------------------------------------------------------------------ the daemon
class Shell:
def __init__(self, glassd_path, stay=False, glassd_args=None, native=False, native_req=None, native_src=None,
test_report=None, assume_caps=None, pod=False):
self.pod = bool(pod) # pod mode: the assPod alone, no theme (lgs pod)
if self.pod:
native = False
self.glassd_path = glassd_path
self.glassd_args = list(glassd_args or [])
self.stay = stay # lab aid: go dormant instead of exiting on theme off
self.native_requested = bool(native)
self.native_req = native_req or ("on" if native else "off") # what start() was asked: auto | on | off
self.native_src = native_src or "cli" # where auto was decided
self.test_report = test_report # lab: a report file replaces the reporter
self.assume_caps = set(assume_caps or []) # lab: act as if glassd announced these caps
self.test_report_mtime = None
# Read once: a binary built mid-session is never picked up.
if not self.native_requested:
self.native_enabled, self.css_reason = False, css_reason_for(self.native_req, self.native_src)
elif not self.glassd_path:
self.native_enabled, self.css_reason = False, "glassd disabled"
elif not os.access(self.glassd_path, os.X_OK) and not ensure_glassd(self.glassd_path):
self.native_enabled, self.css_reason = False, "no glassd binary at start; python glass.py native-build"
else:
self.native_enabled, self.css_reason = True, None
self.stopping = False
self.stop_task = None
self.tasks = []
self.exit_code = 0
self.torn = False
self.since = time.time()
self.errors = collections.deque(maxlen=12)
self.bugs = {} # loop -> [times of unexpected exceptions]
self.changed = asyncio.Event() # spec inputs changed
self.wake_at = None # a layer becomes still enough to pop
# Steam side
self.steam = None
self.steam_ok = False
self.theme_on = None
self.theme_off_polls = 0
self.dormant_since = None # theme off, waiting up to the grace for it to come back
self.steam_target_id = None # SharedJSContext's target id: a new one = Steam restarted
self.steam_restarted = False # ... and the theme has not been seen on since
self.steam_lost_at = None # Steam's devtools unreachable since
self.slow_evals = 0 # Steam evaluations that timed out (connection kept)
self.loop_beat = time.monotonic() # stall_loop's last tick
self.stall_at = -1e9 # monotonic time the last stall of our own loop ended
self.stalls, self.last_stall_s = 0, None
self.report = None
self.report_at = 0.0
self.reports = 0
self.dash_hidden = False
self.layer_still = {} # (surface, id) -> (rect, still since)
self.reporter_state = "not injected"
self.reporter_injected_at = 0.0
self.reporter_version = None
self.fallback = None # synthesized report when no reporter
self.native_applied = []
self.native_tagged = None
self.acked = {}
self.native_force = False
# systemui side
self.sysui = None
self.sysui_ok = False
self.sg_version = file_version(SG_JS)
self.sg_status = None
self.sg_injected_at = 0.0
self.sg_nosched = 0
self.sg_reset = False
self.sysui_ctx = None
self.sg_beat = None
self.sg_beat_at = 0.0
self.spec_sent = None
self.spec_sent_obj = None
self.spec_sent_at = 0.0
self.spec_seq = 0
self.spec_summary = None
self.spec_logged_at = 0.0
self.spec_log_sig = None
self.spec_log_skipped = 0
self.flags_mtime = None
self.flags = None
self.resized_noted = None
# glassd
self.gproc = None
self.g_pid = None
self.g_started = 0.0
self.g_crashes = []
self.g_restarts = 0
self.g_temp_exits = 0
self.g_last_exit = None
self.g_given_up = False
self.g_kill_reason = None # 'dormant' while we stop glassd on purpose
self.g_frames = False
self.gout = None
self.gout_mtime = None
self.gout_seen = 0.0
self.g_stale = False
self.g_files_removed = False
self.gseq = 0
self.gjson_struct = None
self.gjson_full = None
self.gjson_written_at = 0.0
self.dial_mtime = None
# the wearer's glass tune (lgs.TUNE, kept across reboots): glassd.json's dial and tune, the bridge
# value 'tune'; saved TUNE_SAVE_S after the last change (a slider streams changes)
self.tune = lgs.read_tune()
self.tune_mtime = _mtime(lgs.TUNE)
self.dial_v = lgs.read_dial()
self.tune_save = None # the pending save (asyncio TimerHandle)
self.tune_mirrored = (self.dial_v, self.tune["hue"], self.tune["hueK"])
# SteamVR page theming and page scripts
self.vr_version = None
self.vr_pages = {}
self.vrx_status = {} # page -> {script name: state}
self.vrx_failed = {} # (page, name) -> version that threw
self.vrx_sig, self.vrx_scripts = None, {} # page script files: (folder, name, mtime) list, parsed
self.vr_pause_since = None # a lab --theme off pause seen since (vr_paused)
# flags (defaults.json < /tmp/lgs/flags.json)
self.flagreader = FlagReader()
self.rt_flags = {}
self.profile = "default"
self.reduce_motion = False
# bridge to Steam's runtime (__LGS_RT.bridge)
self.bridge_values = {"tune": dict(self.tune)} # name -> value
self.bridge_sent = {} # name -> (json without 'at', time)
self.bridge_runtime = None # True / False once checked
self.bridge_beat_at = 0.0
# actions
self.registry = shell_ext.Registry(self, test_dir=TEST_EXT_DIR) # + RAM test plugins (echo / read)
self.registry.add_builtin("sgwindow", {
"args": {"dim": {"type": "num", "min": 0, "max": 1, "optional": True},
"recede": {"type": "num", "min": 0, "max": 0.3, "optional": True},
"motion": {"type": "str", "max": 24, "re": r"^[a-z][a-z-]{0,23}$", "optional": True},
"ttlMs": {"type": "int", "min": 500, "max": 60000, "optional": True},
"reset": {"type": "bool", "optional": True}},
"sources": ["main", "bar", "barpopup", "ccpopup"], "rate": 0.25, "kind": "ui"},
self.action_sgwindow)
# the wearer's glass tune (the bar's paintbrush panel, rt 33-tune.js): every field optional, a partial
# update merged; a slider streams calls, so its own rate (25 a second) is its only limit
self.registry.add_builtin("glass.tune", {
"args": {"dial": {"type": "num", "min": 0, "max": 1, "optional": True},
"hueK": {"type": "num", "min": 0, "max": 1, "optional": True},
"refract": {"type": "num", "min": 0, "max": 2, "optional": True},
"frost": {"type": "num", "min": 0, "max": 2, "optional": True},
"light": {"type": "num", "min": 0, "max": 2, "optional": True},
"hue": {"type": "str", "max": 7, "re": r"^(#[0-9a-fA-F]{6})?$", "optional": True},
"reset": {"type": "bool", "optional": True}},
"sources": ["bar", "barpopup", "main"], "rate": 0.04, "kind": "ui", "global": False},
self.action_glass_tune)
# echo restricted to the main window: the wrong-source probe of DM-4
self.registry.add_builtin("echo.main", {"args": {"text": {"type": "str", "max": 200, "optional": True}},
"sources": ["main"], "rate": 0.2, "kind": "echo"},
shell_ext.BUILTIN["echo"][1])
self.trusted_ctx = None # SharedJSContext's default execution context id
self.ctx_probe = None # (nonce, future) while probing
self.ctx_probe_lock = asyncio.Lock()
self.replies = collections.deque(maxlen=10)
self.action_tasks = set()
# geometry and page (systemui, read-only)
self.geom = None
self.page = None
self.geom_err = None
self.geom_at = 0.0
self.geom_dump_at = 0.0 # last DumpLaserOverlays read (status geomDumpAt; DM-3 waits for one)
self.geom_ok = False
self.vr_conns = {} # SteamVR page target id -> CDP (kept open; m4)
self.vr_targets = {} # SteamVR page title -> devtools target (the page loop's last list)
self.vr_http = None # aiohttp session for 8090's /json/list (keep-alive)
# glassd.json v3 and the materialize policy
self.items_first = {} # (surface, kind, id) -> time first written
self.native_seen = {} # overlay key -> when glassd last drew glass for it (NATIVE_GRACE_S)
self.vr_panels = {} # SteamVR panels: name -> {w, h, material, glass: {x, y, w, h, r}} (lgs_sg.js beat)
self.vr_shown = set() # ... the ones on the page now
self.items_last = {} # (surface, kind, id) -> last item written (for its fade-out)
self.fading = {} # (surface, kind, id) -> (item with phase 0, until)
self.cover_last = {} # surface -> had a cover in the last glassd.json
self.sg_crops = {} # Steam overlay key -> [u0, u1] its scene-graph panel shows
self.plate_acks = {}
self.cover_keys = []
self.pop_clips = {} # (surface, id) -> clip rect trimmed by build_spec (hole.clip)
# transform overrides and the window state (lgs_sg.js v2)
self.sg_rules = []
self.sg_rule_errors = []
self.sg_rules_sig = None
self.sg_rules_sent = None
self.window_req = None # {dim, recede, motion, until} from the sgwindow action
self.layers_version = None
# ---------------------------------------------------------------- state
def mode(self):
if self.pod:
return "pod"
if self.dormant_since is not None: # theme off without lgs off: waiting for it (contract §1)
return "dormant"
if not self.native_enabled:
return f"css-only ({self.css_reason})"
if self.native_ok():
return "native"
return "starting"
def native_possible(self):
"""Native mode opted in, with a glassd binary at start, not given up and
not removed since. Otherwise the daemon only themes SteamVR pages (CSS
only) and injects neither the reporter nor lgs_sg.js."""
return self.native_enabled
def disable_native(self, reason):
if self.native_enabled:
self.native_enabled, self.css_reason = False, reason
self.note(f"native layer off for this session: {reason}")
self.changed.set()
def glassd_running(self):
return bool(self.gproc and self.gproc.returncode is None)
def glassd_ready(self):
"""glassd draws: frames out, not exiting, healthy, output not stale."""
g = self.gout
return bool(g and self.g_frames and not g.get("exiting") and g.get("healthy", True) is not False
and not self.g_stale)
def native_ok(self):
return bool(self.native_possible() and self.glassd_running() and self.glassd_ready()
and self.steam_ok and self.theme_on)
def native_why(self):
"""Why native_ok() is false (logs and status), or None."""
for ok, why in ((self.native_possible(), "native off"), (self.glassd_running(), "glassd not running"),
(self.glassd_ready(), "glassd not ready"), (self.steam_ok, "steam not connected"),
(self.theme_on, "theme off")):
if not ok:
return why
return None
def current_report(self):
if self.report is not None:
return self.report
return self.fallback
def page_away(self, overlay_key):
"""True for Steam's main window while the dashboard frame shows another
page (SteamVR Settings, the binding UI): Steam's window is not on screen
then, so its cover, plates, base and pops must not be either (REQ C6b->P8:
glassd's main cover stayed drawn over SteamVR's settings panel). Unknown
(systemui unreachable, steam None) is not away."""
p = self.page
return overlay_key == STEAM_MAIN_KEY and isinstance(p, dict) and p.get("steam") is False
def note(self, msg):
self.errors.append(time.strftime("%H:%M:%S ") + msg)
log(msg)
def bug(self, where, e):
"""An unexpected exception in a loop: log it, back off; fatal (re-raised)
after BUG_LIMIT within BUG_WINDOW s. Returns the delay before a retry."""
now = time.time()
times = [t for t in self.bugs.get(where, []) if now - t < BUG_WINDOW] + [now]
self.bugs[where] = times
tb = traceback.format_exc().strip().splitlines()
self.note(f"{where}: unexpected {e!r} ({len(times)}/{BUG_LIMIT} in {BUG_WINDOW} s) at {' | '.join(tb[-3:])[:300]}")
if len(times) >= BUG_LIMIT:
raise e
return min(30, 2 ** len(times))
def caps(self):
"""glassd's v3 features (glassd-out.json caps); empty for a v2 binary."""
c = (self.gout or {}).get("caps")
caps = {x for x in c if isinstance(x, str)} if isinstance(c, list) else set()
return caps | self.assume_caps # lab only (--assume-caps): test the writer against an older glassd
def refresh_flags(self):
"""Re-read the flags; returns True when the depth profile changed."""
self.rt_flags = self.flagreader.read()
prof = "wearer" if self.rt_flags.get("interactivePops") is True else "default"
changed = prof != self.profile
if changed:
log(f"flags: profile {self.profile} -> {prof}")
self.profile = prof
rules, errs = sg_rules(self.rt_flags)
sig = json.dumps(rules, sort_keys=True)
if sig != self.sg_rules_sig:
self.sg_rules_sig, self.sg_rules, self.sg_rule_errors = sig, rules, errs
log(f"systemui: transform overrides {[r.get('id') for r in rules] or 'none'}"
+ (f" (errors {errs})" if errs else ""))
self.changed.set()
if self.window_req and self.window_req.get("until", 0) < time.time():
log("systemui: window state request expired; restoring")
self.window_req = None
self.changed.set()
return changed
def window_state(self):
"""The window dim / recede to request (contracts/sg.md §5): the report's
`window` in native mode wins over the sgwindow action."""
rep = self.current_report() if self.native_possible() else None
w = (rep or {}).get("window") if isinstance(rep, dict) else None
if isinstance(w, dict):
return {k: w[k] for k in ("dim", "recede", "motion") if k in w}
if self.window_req:
return {k: self.window_req[k] for k in ("dim", "recede", "motion") if k in self.window_req}
return None
def theme_grace_s(self):
"""How long to stay dormant while the Steam theme is off (flag
shellThemeGraceS, 0..3600 s; 0 = exit after THEME_OFF_POLLS as in Phase 1).
A --stay unit (lab, native-session) waits STAY_MAX_S whatever the flag."""
if self.stay:
return STAY_MAX_S
v = _num(self.rt_flags.get("shellThemeGraceS"), 0, 3600)
return THEME_OFF_GRACE_S if v is None else v
def check_stay_limit(self):
"""A --stay unit is native for at most STAY_MAX_S: after that its
native-session client is gone for sure (it gives up after 2400 s), so
the native layer ends and the unit follows the normal rules (contract §1)."""
if self.stay and time.time() - self.since >= STAY_MAX_S:
self.stay = False
self.note(f"--stay limit: running {STAY_MAX_S} s; its client is gone. Native layer off, normal "
"theme-off rules from now on")
self.disable_native(f"--stay limit ({STAY_MAX_S} s) reached")
def sg_wanted(self):
"""lgs_sg.js belongs in systemui: native mode, or (CSS only) an active
transform override or a window state (P7's REQ, contracts/sg.md §1)."""
if self.dormant_since is not None: # theme off: Steam's own panels until it is back
return False
return self.native_possible() or bool(self.sg_rules) or self.window_state() is not None
def daemon_value(self):
return {"v": 1, "at": int(time.time() * 1000), "ttlMs": int(DAEMON_BEAT_S * 3000), "pid": os.getpid(),
"mode": self.mode(), "native": bool(self.native_ok()), "profile": self.profile,
"steamvr": bool(self.geom_ok), "actions": sorted(self.registry.types),
"dryRun": bool(self.rt_flags.get("actionsDryRun"))}
# ---------------------------------------------------------------- plugin host (shell_ext.Ctx)
async def ext_steam_eval(self, expr, timeout=10):
s = self.steam
if self.steam_ok and s is not None and not s.closed:
return await s.eval(expr, timeout)
t = await self.steam_target()
return await eval_once(t["webSocketDebuggerUrl"], expr, timeout, "steam-ext")
async def ext_vr_eval(self, page, expr, timeout=10):
"""Over the page's kept socket (the page loop's target list, else a fresh one): plugins that
poll a page several times a second (asspod) cost one message per call, not a new socket."""
t = self.vr_targets.get(page)
if t is None:
loop = asyncio.get_running_loop()
t = next((x for x in await loop.run_in_executor(None, lgs_vr.targets) if (x.get("title") or "") == page), None)
if t is None:
raise ConnectionError(f"no SteamVR page {page!r}")
try:
return await self.vr_page_eval(t, expr, timeout)
except RuntimeError as e:
if str(e).startswith("JS: "):
raise
self.vr_targets.pop(page, None)
raise
except Exception:
self.vr_targets.pop(page, None)
raise
def ext_vr_pages(self):
return sorted(self.vr_pages)
def ext_flags(self):
return self.rt_flags
def ext_geom(self):
return self.geom
def ext_page(self):
return self.page
def ext_log(self, msg):
log(msg)
async def ext_actions_logger(self, atype, args):
"""True when P1's action logger (rt.test.actions) is on: the call is
recorded there and not run (the never-list in lab steps)."""
js = ("(function (t, a) { const R = window.__LGS_RT, A = R && R.test && R.test.actions;"
" if (!A || typeof A.enabled !== 'function' || !A.enabled()) return false;"
" try { A.record({ fn: 'daemon.' + t, arg: a }); } catch (_) { /* old runtime */ } return true; })"
f"({json.dumps(atype)}, {json.dumps(args)})")
return bool(await self.ext_steam_eval(js, 5))
async def action_sgwindow(self, ctx, atype, args, call):
"""Built-in 'sgwindow': Steam's window dim / recede for a while (TTL),
also in CSS-only mode (contracts/sg.md §5; contract §5)."""
if args.get("reset"):
self.window_req = None
else:
w = {k: args[k] for k in ("dim", "recede", "motion") if k in args}
if not w:
raise ValueError("nothing to set")
self.window_req = dict(w, until=time.time() + args.get("ttlMs", 10000) / 1000, src=call.get("src"))
self.changed.set()
return {"window": self.window_state()}
async def action_glass_tune(self, ctx, atype, args, call):
"""Built-in 'glass.tune': the wearer's glass tune from the bar's paintbrush panel (dial, colour,
refraction, frost, highlights; reset). glassd.json and the bridge follow at once (glassd eases
to it); tune.json, kept across reboots, TUNE_SAVE_S after the last change."""
if args.get("reset"):
t = dict(lgs.TUNE_DEFAULT)
else:
t = dict(self.tune)
for k in ("dial", "hueK", "refract", "frost", "light"):
if k in args:
t[k] = args[k]
if "hue" in args:
t["hue"] = args["hue"] or None
self.set_tune(lgs.clean_tune(t), save=True)
return {"tune": self.tune}
def stereo_on(self):
"""Per-eye glass unless the flag glassStereo is false."""
return (self.rt_flags or {}).get("glassStereo") is not False
def set_tune(self, t, save):
"""A new tune (the panel's, or tune.json changed by `lgs dial`): glassd.json, the bridge, and
(save) tune.json plus the dial file a moment later."""
self.tune = t
self.dial_v = t["dial"] if save or os.path.exists(lgs.TUNE) else lgs.read_dial()
self.bridge_values["tune"] = dict(t)
self.write_glassd_json()
self.changed.set()
if save:
if self.tune_save is not None:
self.tune_save.cancel()
self.tune_save = asyncio.get_running_loop().call_later(TUNE_SAVE_S, self.save_tune)
def save_tune(self):
"""tune.json (removed at the defaults) and, when the dial or the colour changed, the dial file:
the theme bundle carries both, and lgs_vr re-themes the SteamVR pages on the dial file."""
self.tune_save = None
try:
lgs.write_tune(self.tune)
mirror = (self.tune["dial"], self.tune["hue"], self.tune["hueK"])
if mirror != self.tune_mirrored:
with open(lgs.DIAL, "w", encoding="utf-8") as f:
f.write(f"{self.tune['dial']:g}\n")
self.tune_mirrored = mirror
except OSError as e:
self.note(f"tune: not saved ({e})")
self.tune_mtime = _mtime(lgs.TUNE)
self.dial_mtime = _mtime(lgs.DIAL)
def status(self):
g = self.gout or {}
sg = self.sg_status or {}
b = self.sg_beat or {}
rep = self.current_report() or {}
return {
"pid": os.getpid(), "since": time.strftime("%H:%M:%S", time.localtime(self.since)),
"updated": time.time(), "mode": self.mode(), "pod": self.pod,
"native": {"requested": self.native_req, "resolved": "on" if self.native_requested else "off",
"source": self.native_src, "enabled": self.native_enabled, "reason": self.css_reason},
"flags": self.rt_flags, "profile": self.profile, "reduceMotion": self.reduce_motion,
"testReport": self.test_report,
"bridge": {"runtime": self.bridge_runtime,
"sent": {k: round(time.time() - t, 1) for k, (_, t) in self.bridge_sent.items()}},
"geom": self.geom, "page": self.page, "geomError": self.geom_err,
"mainAway": self.page_away(STEAM_MAIN_KEY),
"geomDumpAt": round(self.geom_dump_at, 2) if self.geom_dump_at else None,
"actions": dict(self.registry.status(), replies=list(self.replies), trustedCtx=self.trusted_ctx),
"vrScripts": self.vrx_status,
"sgRules": {"active": [r.get("id") for r in self.sg_rules], "errors": self.sg_rule_errors,
"window": self.window_req},
"glassd": {"path": self.glassd_path, "args": self.glassd_args, "pid": self.g_pid,
"running": self.glassd_running(), "ready": self.glassd_ready(),
"healthy": g.get("healthy"), "stale": self.g_stale, "feed": g.get("feed"),
"restarts": self.g_restarts, "tempExits": self.g_temp_exits, "lastExit": self.g_last_exit,
"givenUp": self.g_given_up, "frames": self.g_frames, "seq": g.get("seq"), "fps": g.get("fps"),
"gpu_ms": g.get("gpu_ms"), "surfaces": sorted((g.get("surfaces") or {}).keys()),
"configSeq": self.gseq, "version": g.get("version"), "caps": sorted(self.caps()),
"fading": len(self.fading)},
"dormant": ({"since": round(self.dormant_since, 1), "graceS": self.theme_grace_s(),
"stay": self.stay} if self.dormant_since is not None else None),
"steam": {"connected": self.steam_ok, "themeOn": self.theme_on, "reporter": self.reporter_state,
"restarted": self.steam_restarted, "slowEvals": self.slow_evals,
"reports": self.reports, "reportSeq": (self.report or {}).get("seq"),
"reportAgeS": round(time.time() - self.report_at, 1) if self.report_at else None,
"dashboardHidden": self.dash_hidden,
"source": "reporter" if self.report is not None else ("fallback" if self.fallback else None),
"surfaces": [s.get("name") for s in rep.get("surfaces", [])],
"lgsNative": self.native_applied, "acked": self.acked, "plateAcks": self.plate_acks,
"tagged": self.native_tagged, "layersVersion": self.layers_version},
"systemui": {"connected": self.sysui_ok, "sgVersion": self.sg_version, "spec": self.spec_summary,
"beatAgeS": round(time.time() - self.sg_beat_at, 1) if self.sg_beat_at else None,
"items": b.get("items", sg.get("items")), "panels": sg.get("panels"), "nodes": sg.get("nodes"),
"attached": b.get("attached", sg.get("attached")), "parents": sg.get("parents"),
"relayouts": sg.get("relayouts"), "pushes": sg.get("pushes"),
"pushesLastSec": sg.get("pushesLastSec"), "reattaches": sg.get("reattaches"),
"scheduler": sg.get("scheduler"), "expired": b.get("expired", sg.get("expired")),
"expiries": sg.get("expiries"), "rebuilds": sg.get("rebuilds"),
"flags": self.flags, "sgErrors": sg.get("errors")},
"steamvrPages": {"paused": os.path.exists(VR_PAUSE), "version": self.vr_version, "pages": self.vr_pages,
"pausedS": round(time.time() - self.vr_pause_since, 1) if self.vr_pause_since else None},
"stalls": {"count": self.stalls, "lastS": self.last_stall_s,
"agoS": round(time.monotonic() - self.stall_at, 1) if self.stalls else None},
"errors": list(self.errors),
}
# ---------------------------------------------------------------- glassd.json
def ramp_ms(self, material, out, kind="slab"):
if self.reduce_motion:
return RAMP_REDUCED_MS
if material == "dim" and kind == "slab": # a dim slab rides sheet-in / sheet-out (glassd §1.4)
material = "thick"
if out:
return RAMP_OUT_MS.get(material, RAMP_OUT_DEFAULT_MS)
return RAMP_IN_MS.get(material, RAMP_IN_DEFAULT_MS)
def glassd_config(self):
"""glassd.json from the report: Phase 1 fields, the v3 fields glassd's
caps announce, and the materialize policy (contracts/daemon.md §7)."""
now = time.time()
caps = self.caps()
rep = self.current_report() or {"surfaces": []}
surfaces, by_name, current, shown, gained = [], {}, {}, set(), set()
for s in rep.get("surfaces", []):
if not isinstance(s, dict) or not s.get("name") or not s.get("overlayKey"):
continue
name = str(s["name"])[:64]
# every number type-checked (contract §7): a bad field drops its item, never the daemon
texW, texH, radius = _num(s.get("texW") or 0, 0, 16384), _num(s.get("texH") or 0, 0, 16384), \
_num(s.get("radius") or 0, 0, 8192)
if texW is None or texH is None or radius is None or not isinstance(s["overlayKey"], str):
continue
# Steam's page away (SteamVR Settings or the binding UI in its frame): glassd keeps drawing
# main's glass, ready for its return, and lgs_sg.js takes main's nodes off the scene graph
# itself in the same update the page goes in, and back in the one it returns in (it reads
# SteamVR's active page every frame); this 1 s poll only follows it for status
slabs = []
for L in s.get("layers") if isinstance(s.get("layers"), list) else []:
if not isinstance(L, dict) or L.get("id") is None:
continue
w, h = _num(L.get("w"), 0, 16384), _num(L.get("h"), 0, 16384)
r, x, y = _num(L.get("r") or 0, 0, 8192), _num(L.get("x") or 0), _num(L.get("y") or 0)
dz = _num(L.get("dz") or 0, -1, 1)
if None in (w, h, r, x, y, dz) or w < 2 or h < 2:
continue
mat = L.get("material")[:16] if isinstance(L.get("material"), str) else "liquid"
if mat == "none" and "none" not in caps:
continue # an older glassd would draw glass over art: the element stays flat
if mat == "dim" and "dimSlab" not in caps:
continue # ... or glass where a dark room-dim cell was asked for
sl = {"id": str(L["id"])[:64], "w": round(w), "h": round(h), "r": round(r),
"material": mat or "liquid",
# for glassd's slab world point:
"x": round(x), "y": round(y), "dz": dz}
clean_phase_fields(L, sl)
if "tint" in caps and clean_color(L.get("tint")) is not None:
sl["tint"] = clean_color(L["tint"])
if mat == "dim" and clean_color(L.get("fill")) is not None:
sl["fill"] = clean_color(L["fill"])
if "holes" in caps and L.get("hole") not in (None, False):
hole = clean_hole(L["hole"], self.pop_clips.get((name, sl["id"])), edges="holeEdges" in caps)
if hole is not None:
sl["hole"] = hole
if "offset" in caps:
for k in ("ox", "oy"):
if _num(L.get(k)) is not None:
sl[k] = _round(round(_num(L[k]), 2))
slabs.append(sl)
mat_s = material_for(s)
surf = {"name": name, "overlayKey": s["overlayKey"][:128],
"texW": round(texW), "texH": round(texH), "radius": round(radius),
"material": mat_s[:16] if isinstance(mat_s, str) else "panel",
"visible": bool(s.get("visible", True)), "slabs": slabs}
# a hidden pooled popup whose content is ready: glassd draws its glass ahead (contracts/glassd.md)
if s.get("armed") is True and not surf["visible"]:
surf["armed"] = True
# the cover's rounded shapes (bar segments, a popup's card); glassd
# covers the whole texture with `radius` when absent
if isinstance(s.get("shapes"), list):
shapes = []
for sh in s["shapes"]:
if not isinstance(sh, dict):
continue
vals = {k: _num(sh.get(k) or 0) for k in ("x", "y", "w", "h", "r")}
if None not in vals.values():
shapes.append({k: round(v) for k, v in vals.items()})
surf["shapes"] = shapes
clean_phase_fields(s, surf) # the cover's own phase: passed through, never set by P8
# a morphing cover (the reporter's cover.morph): glassd moves the shapes it shows toward these
# on the named spring from `at` (epoch ms), with the page's own motion
m = s.get("morph")
if "morph" in caps and isinstance(m, dict) and m.get("token") in MORPH_TOKENS \
and _num(m.get("at"), 0, 1e15) is not None:
surf["morph"] = {"token": m["token"], "at": int(_num(m["at"], 0, 1e15))}
q = clean_quad(s.get("quad")) if s.get("quad") is not None else None
if q:
surf["quad"] = q
plates = [p for p in (clean_plate(p, i) for i, p in enumerate(s.get("plates") or [])
if isinstance(s.get("plates"), list)) if p]
if "dim" not in caps: # a dim plate is a flat dark tone: never glass instead
plates = [p for p in plates if p.get("material") != "dim"]
if "exitAt" not in caps:
for p in plates:
p.pop("exitAt", None)
p.pop("exitMs", None)
if plates and "plates" in caps:
surf["plates"] = plates[:32]
elif plates and isinstance(surf.get("shapes"), list):
# a v2 glassd: plates become cover shapes (≤ 8), as P6 suggests
surf["shapes"] = (surf["shapes"] + [{k: round(p[k]) for k in ("x", "y", "w", "h")}
| {"r": round(p.get("r", min(p["w"], p["h"]) / 2))}
for p in plates])[:8]
if "coverDz" in caps and _num(s.get("coverDz"), -1, 1) is not None:
surf["coverDz"] = _round(round(_num(s["coverDz"], -1, 1), 5))
if "masks" in caps and s.get("masks") is not None:
surf["masks"] = clean_masks(s.get("masks"))
if "scaleFrom" in caps and s.get("scaleFrom") in ("main", "overlay"):
surf["scaleFrom"] = s["scaleFrom"]
# per-eye glass where the scene graph shows the surface whole (Steam's window; the bar, the frame
# menu and the popups show crops of theirs, which a stereo panel cannot split per eye)
# stereo where the scene graph says which part of the texture its panel shows (glassd builds
# the eye pair around that crop; the panel spans the pair): Steam's window, the bar, the frame
# menu, the footer, popups once shown
# not the frame menu: it opens by animating its width and Steam widens its crop along with it, which
# reaches glassd a beat (~1 s) late, so a pair built around the old crop cut the opening glass off
crop = (self.sg_crops or {}).get(s["overlayKey"]) if "stereo" in caps and self.stereo_on() and not name.startswith("frame.menu") else None
if isinstance(crop, list) and len(crop) == 2 and None not in (_num(crop[0], 0, 1), _num(crop[1], 0, 1)) and crop[1] - crop[0] > 0.01:
surf["stereo"] = True
surf["crop"] = [round(float(crop[0]), 3), round(float(crop[1]), 3)]
# materialize: a new slab or plate id appears with materialize (GM §5.4)
for kind, items in (("slab", surf["slabs"]), ("plate", surf.get("plates") or [])):
for it in items:
key = (name, kind, it["id"])
if key not in self.items_first:
self.items_first[key] = now
if "appear" not in it and "phase" not in it:
it["appear"] = "materialize"
current[key] = it
if surf["visible"]:
shown.add(name)
# a cover is "shapes" non-empty, or (absent) the whole texture of a window-material surface
cover = bool(surf["shapes"]) if isinstance(surf.get("shapes"), list) else surf["material"] == "window"
if cover and self.cover_last.get(name) is False:
gained.add(name)
self.cover_last[name] = cover
surfaces.append(surf)
by_name[name] = surf
# ... and one that leaves dematerializes: phase 0 for its out-ramp, then gone. A plate whose content
# was fading out (exitAt) stays for exactly that fade instead, glassd fading it with the page; on a
# surface that just gained its cover (Home -> a window route) a plate removed before any fade was
# seen fades on the page's leave (PAGE_OUT_MS) from now
for key, it in self.items_last.items():
if key in current or key in self.fading:
continue
if key[0] not in shown: # its surface closed or hid: nothing to fade on
continue
if key[1] == "plate" and "exitAt" in caps:
if "exitAt" not in it and key[0] in gained:
it = it | {"exitAt": int(now * 1000), "exitMs": PAGE_OUT_MS}
if "exitAt" in it:
until = (it["exitAt"] + it["exitMs"]) / 1000
if until > now:
self.fading[key] = ({k: v for k, v in it.items() if k != "appear"}, min(until, now + 2.0))
continue
until = now + self.ramp_ms(it.get("material"), True, key[1]) / 1000
self.fading[key] = ({k: v for k, v in it.items() if k != "appear"} | {"phase": 0}, until)
for key, (it, until) in list(self.fading.items()):
surf = by_name.get(key[0])
if key in current or until <= now or surf is None or not surf["visible"]:
del self.fading[key]
continue
surf.setdefault("slabs" if key[1] == "slab" else "plates", []).append(dict(it))
self.items_last = current
for key in [k for k in self.items_first if k not in current and k not in self.fading]:
del self.items_first[key]
# SteamVR Settings' window glass (a SteamVR panel, not a Steam window): placed by main's overlay
# (the page takes main's place in the frame), drawn ahead ("armed") while the page is away, so the
# glass is there in the update the page mounts in (lgs_sg.js adds the cover node then)
for vn, vp in self.vr_panels.items() if "armed" in caps else []:
g = vp.get("glass") or {}
vals = [_num(vp.get("w"), 2, 8192), _num(vp.get("h"), 2, 8192)] + \
[_num(g.get(k), 0, 8192) for k in ("x", "y", "w", "h", "r")]
if None in vals:
continue
w, h, gx, gy, gw, gh, gr = (round(v) for v in vals)
on = vn in self.vr_shown
surfaces.append({"name": "vr." + vn, "overlayKey": STEAM_MAIN_KEY, "texW": w, "texH": h,
"radius": gr, "material": vp["material"], "visible": on, "armed": not on, "slabs": [],
**({"stereo": True} if "stereo" in caps and self.stereo_on() else {}),
"shapes": [{"x": gx, "y": gy, "w": gw, "h": gh, "r": gr}]})
cfg = {"dial": self.dial_v, "reduceMotion": bool(self.reduce_motion), "surfaces": surfaces}
# per-eye glass (glassd renders each eye's view side by side; the scene graph shows each eye its half):
# on unless the flag glassStereo is false (contracts/glassd.md §1.1)
if "stereo" in caps:
cfg["stereo"] = self.stereo_on()
if "tune" in caps: # the wearer's tune (glassd eases to it; contracts/glassd.md §1.1)
t = self.tune
cfg["tune"] = {"refract": t["refract"], "frost": t["frost"], "light": t["light"]}
if t["hue"]:
cfg["tune"].update(hue=t["hue"], hueK=t["hueK"])
if "unitM" in caps and self.geom and _num(self.geom.get("unitM"), 0.01, 10) is not None:
cfg["unitM"] = self.geom["unitM"]
if "masks" in caps and rep.get("masks") is not None:
cfg["masks"] = clean_masks(rep.get("masks"), world=True)
if "roomDim" in caps and _num(rep.get("roomDim"), 0, 0.9) is not None:
cfg["roomDim"] = _round(_num(rep["roomDim"], 0, 0.9))
return cfg
def write_glassd_json(self, force=False):
if not self.native_possible() or (self.dormant_since is not None and not self.glassd_running()):
return False # dormant: glassd is stopped and its files are gone until the theme is back
cfg = self.glassd_config()
full = json.dumps(cfg, sort_keys=True)
moving = ("x", "y", "dz", "ox", "oy")
struct = json.dumps(dict(cfg, surfaces=[
dict(s, slabs=[{k: v for k, v in sl.items() if k not in moving} for sl in s["slabs"]],
plates=[{k: v for k, v in p.items() if k not in moving} for p in s.get("plates") or []])
for s in cfg["surfaces"]]), sort_keys=True)
if not force and full == self.gjson_full:
return False
now = time.time()
# Positions alone change while scrolling: write those at most 4x a second.
if not force and struct == self.gjson_struct and now - self.gjson_written_at < GLASSD_JSON_MIN_INTERVAL:
return False
self.gseq += 1
write_json_atomic(GLASSD_JSON, dict(cfg, seq=self.gseq))
self.gjson_full, self.gjson_struct, self.gjson_written_at = full, struct, now
return True
# ---------------------------------------------------------------- spec
def track_layers(self, rep, now):
"""Since when each reported layer's rect has been still (within STILL_PX)."""
seen = {}
for s in rep.get("surfaces") or []:
if not isinstance(s, dict):
continue
for L in s.get("layers") if isinstance(s.get("layers"), list) else []:
try:
k = (str(s.get("name")), str(L["id"]))
r = (float(L["x"]), float(L["y"]), float(L["w"]), float(L["h"]))
except (KeyError, TypeError, ValueError):
continue
prev = self.layer_still.get(k)
if prev is not None and max(abs(a - b) for a, b in zip(prev[0], r)) <= STILL_PX:
seen[k] = prev
else:
seen[k] = (r, now)
self.layer_still = seen
def read_flags(self):
"""Optional panel flags for lgs_sg.js (spec.flags), from /tmp/lgs/sg-flags.json."""
try:
m = os.stat(SG_FLAGS).st_mtime_ns
except OSError:
m = None
if m != self.flags_mtime:
self.flags_mtime = m
f = read_json(SG_FLAGS) if m is not None else None
self.flags = f if isinstance(f, dict) and f else None
if m is not None:
log(f"systemui: panel flags {self.flags}")
return self.flags
def main_resized(self, g):
"""glassd's world width of the main quad, if it reports one (quadW), far
from nominal: the user resized the window. Until crops are verified to
follow a resize, main then stays CSS only."""
q = _num(g.get("quadW"), 0.01, 100)
if q is None:
return False
bad = abs(q / MAIN_QUAD_W - 1) > MAIN_QUAD_TOL
if bad != bool(self.resized_noted):
self.resized_noted = bad
if bad:
self.note(f"main window is {q:.2f} m wide (nominal {MAIN_QUAD_W}): resized; main stays CSS only")
else:
log(f"main window back to {q:.2f} m wide: native again")
return bad
def spec_top(self):
"""The spec's top-level v2 fields (contracts/sg.md §3.1)."""
top = {"profile": self.profile, "reduceMotion": bool(self.reduce_motion),
"depthMotion": "none" if self.rt_flags.get("sgDepthAnim") is False else "depth"}
if self.geom and _num(self.geom.get("unitM"), 0.01, 10) is not None:
top["unitM"] = self.geom["unitM"]
w = self.window_state()
if w is not None:
top["window"] = w
return top
def build_spec(self):
self.wake_at = None
if not self.native_ok():
return dict({"M": None, "surfaces": []}, **self.spec_top())
rep = self.current_report() or {"surfaces": []}
clips = {}
gsurf = (self.gout or {}).get("surfaces")
gsurf = gsurf if isinstance(gsurf, dict) else {}
now = time.time()
out = []
wake = None
for s in rep.get("surfaces") if isinstance(rep.get("surfaces"), list) else []:
if not isinstance(s, dict) or not isinstance(s.get("name"), str) or not isinstance(s.get("overlayKey"), str):
continue
g = gsurf.get(s["name"])
if not isinstance(g, dict):
continue
if not s.get("visible", True):
# a hidden pooled popup: its nodes wait in lgs_sg.js (standby) and are built the moment
# Steam puts its panel back on the page, in the same scene-graph update as Steam's own
if s.get("pooled") is True:
sb = self.standby_entry(s, g)
if sb:
out.append(sb)
continue
if s.get("name") == "main" and self.main_resized(g):
continue
texW, texH = _num(s.get("texW"), 1, 16384), _num(s.get("texH"), 1, 16384)
scale = _num(g.get("backdropScale") or 0.75, 0.05, 4)
gW, gH = _num(g.get("texW"), 1, 16384), _num(g.get("texH"), 1, 16384)
backdrop = _nums(g.get("backdrop"), 4)
if None in (texW, texH, scale, gW, gH) or backdrop is None:
continue
slabs = g.get("slabs") if isinstance(g.get("slabs"), dict) else {}
popped, taken, dims = [], [], []
for L in s.get("layers") if isinstance(s.get("layers"), list) else []:
if not isinstance(L, dict) or L.get("id") is None:
continue
lid = str(L["id"])[:64]
x, y, w, h = (_num(L.get(k)) for k in ("x", "y", "w", "h"))
if None in (x, y, w, h):
continue
uv = _nums(slabs.get(lid), 4)
if uv is None:
continue # no slab yet: the element stays flat in the base (with its CSS glass)
if L.get("material") == "dim":
# a room-dim cell (glassd §1.4): no element to crop; P7 places
# the cell (behind the window, scaled up). Older lgs_sg.js ignore it.
dz = _num(L.get("dz") or 0, -1, 1)
if dz is not None:
dims.append({"id": lid, "x": round(x, 2), "y": round(y, 2), "w": round(w, 2),
"h": round(h, 2), "dz": dz, "slab": uv})
continue
# glassd draws a slab at the element's size x backdropScale; if
# the element was resized since, wait for the new slab.
if abs((uv[2] - uv[0]) * gW - w * scale) > SLAB_SIZE_TOL_PX or \
abs((uv[3] - uv[1]) * gH - h * scale) > SLAB_SIZE_TOL_PX:
continue
# pop only once the rect has been still: a moving element
# (scrolling, a focus change) stays flat instead of being
# cropped at a stale rect for a scene-graph round trip
still = self.layer_still.get((str(s.get("name")), lid))
if still is None:
continue
if now - still[1] < STILL_S:
t = still[1] + STILL_S
wake = t if wake is None else min(wake, t)
continue
# an overlap of a few px with an already popped element (the
# reporter allows 2 px) is trimmed off; a wider one skips it
rect = [x, y, x + w, y + h]
clipped, conflict = False, False
for t in taken:
ix = min(rect[2], t[2]) - max(rect[0], t[0])
iy = min(rect[3], t[3]) - max(rect[1], t[1])
if ix <= 0 or iy <= 0:
continue
if min(ix, iy) > OVERLAP_CLIP_PX:
conflict = True
break
clipped = True
if ix <= iy:
if t[0] <= rect[0] < t[2]:
rect[0] = t[2]
else:
rect[2] = t[0]
else:
if t[1] <= rect[1] < t[3]:
rect[1] = t[3]
else:
rect[3] = t[1]
if conflict or rect[2] - rect[0] < 2 or rect[3] - rect[1] < 2:
continue
dz = _num(L.get("dz") or 0.012, -1, 1)
if dz is None:
continue
taken.append(tuple(rect))
p = {"id": lid, "x": round(x, 2), "y": round(y, 2), "w": round(w, 2), "h": round(h, 2),
"dz": dz, "slab": uv}
if clipped:
p["clip"] = [round(v, 2) for v in rect]
clips[(str(s.get("name")), lid)] = p["clip"]
# depth-channel fields (contracts/sg.md §3.3); interactive only in the wearer profile
if L.get("interactive") is True and self.profile == "wearer":
p["interactive"] = True
if L.get("from") == "cut" or _num(L.get("from"), -1, 1) is not None:
p["from"] = L["from"] if L.get("from") == "cut" else _num(L["from"], -1, 1)
if isinstance(L.get("motion"), str) and len(L["motion"]) <= 24:
p["motion"] = L["motion"]
if isinstance(L.get("sink"), bool):
p["sink"] = L["sink"]
popped.append(p)
sp = {"steamKey": s["overlayKey"], "texW": texW, "texH": texH, "visible": True,
"glassd": {"key": g.get("key") or "glassd." + s["name"], "backdrop": backdrop, "scale": scale,
"stereo": g.get("stereo") is True, "crop": g.get("crop")},
"coverDz": COVER_DZ, "baseDz": BASE_DZ, "popped": popped}
if _num(s.get("coverDz"), -1, 1) is not None:
sp["coverDz"] = _num(s["coverDz"], -1, 1)
if _num(s.get("dim"), 0, 1) is not None:
sp["dim"] = _num(s["dim"], 0, 1)
# mosaic bands only without a cover (contracts/reporter.md §2.3): with a cover Steam's real
# panel is hidden behind it, so a base limited to bands would hide everything outside them
# (REQ C2a->P8 #17; P6's reporter no longer sends both, this guards older copies)
shp = s.get("shapes")
has_cover = (isinstance(shp, list) and len(shp) > 0) or (shp is None and material_for(s) == "window")
if isinstance(s.get("mosaic"), list) and not has_cover:
sp["mosaic"] = [r for r in (clean_rect(m, ("x", "y", "w", "h"), ()) for m in s["mosaic"]
if isinstance(m, dict)) if r][:16]
# slabs glassd is fading out (contracts/daemon.md §7): no crop, their cell
outs = []
for (sname, kind, sid), (it, until) in self.fading.items():
if sname != s.get("name") or kind != "slab":
continue
uv = _nums(slabs.get(sid), 4)
if uv is not None:
outs.append({"id": sid, "x": it.get("x"), "y": it.get("y"), "w": it.get("w"), "h": it.get("h"),
"dz": it.get("dz"), "slab": uv, "until": int(until * 1000)})
if outs:
sp["slabsOut"] = outs
if dims:
sp["dimSlabs"] = dims
out.append(sp)
self.wake_at = wake
self.pop_clips = clips
spec = dict({"M": None, "surfaces": out}, **self.spec_top())
vr = []
for vn in self.vr_panels:
gv = gsurf.get("vr." + vn)
if isinstance(gv, dict) and _nums(gv.get("backdrop"), 4) is not None:
vr.append({"name": vn, "glassd": {"key": gv.get("key") or "glassd.vr." + vn,
"backdrop": _nums(gv.get("backdrop"), 4),
"scale": _num(gv.get("backdropScale") or 0.75, 0.05, 4),
"stereo": gv.get("stereo") is True}})
if vr:
spec["vr"] = vr
flags = self.read_flags()
if flags and out:
spec["flags"] = flags
return spec
def standby_entry(self, s, g):
texW, texH = _num(s.get("texW"), 1, 16384), _num(s.get("texH"), 1, 16384)
scale = _num(g.get("backdropScale") or 0.75, 0.05, 4)
backdrop = _nums(g.get("backdrop"), 4)
if None in (texW, texH, scale) or backdrop is None:
return None
sp = {"steamKey": s["overlayKey"], "texW": texW, "texH": texH, "visible": False, "standby": True,
"glassd": {"key": g.get("key") or "glassd." + s["name"], "backdrop": backdrop, "scale": scale,
"stereo": g.get("stereo") is True, "crop": g.get("crop")},
"coverDz": COVER_DZ, "baseDz": BASE_DZ, "popped": []}
if _num(s.get("coverDz"), -1, 1) is not None:
sp["coverDz"] = _num(s["coverDz"], -1, 1)
return sp
def native_live(self):
"""Native mode is live: glassd produces frames and the scene graph follows our spec (the same
preconditions native_state needs). Then every Steam window is lgs-native."""
if not (self.native_ok() and self.sysui_ok and self.sg_beat and self.spec_sent_obj):
return False
b = self.sg_beat
return not (time.time() - self.sg_beat_at > SG_BEAT_FRESH_S or b.get("expired") or not b.get("attached")
or not b.get("push"))
def native_state(self):
"""(overlay keys that get lgs-native, {key: popped ids shown}, {key: plate
ids shown}, keys whose cover glass is shown), from the latest systemui
heartbeat, the spec it reflects and glassd's output (contract §8)."""
if not (self.native_ok() and self.sysui_ok and self.sg_beat and self.spec_sent_obj):
return [], {}, {}, []
b = self.sg_beat
if time.time() - self.sg_beat_at > SG_BEAT_FRESH_S or b.get("expired") or not b.get("attached") \
or not b.get("push"):
return [], {}, {}, []
gsurf = (self.gout or {}).get("surfaces")
gsurf = gsurf if isinstance(gsurf, dict) else {}
names = {s["overlayKey"]: s["name"] for s in (self.current_report() or {}).get("surfaces", [])
if isinstance(s, dict) and isinstance(s.get("overlayKey"), str) and isinstance(s.get("name"), str)}
spec = {s["steamKey"]: {p["id"] for p in s["popped"]} for s in self.spec_sent_obj.get("surfaces", [])}
now = time.time()
now_ms = now * 1000
keys, acks, plates, covers = [], {}, {}, []
surf = b.get("surf") if isinstance(b.get("surf"), dict) else {}
for key, sm in surf.items():
if key not in spec or not isinstance(sm, dict):
continue
name = names.get(key)
g = gsurf.get(name) if isinstance(gsurf.get(name), dict) else {}
at = _num(sm.get("coverAt") or 0, 0, 1e15) or 0 # epoch ms
if not at or now_ms - at < SHOWN_MS:
continue
# plates: drawn by glassd (listed in its output), in the cover's
# region (pushed), and their materialize ramp has had time to end
pl = []
for pid in g.get("plates") if isinstance(g.get("plates"), list) else []:
first = self.items_first.get((name, "plate", str(pid)))
if first is None or (name, "plate", str(pid)) in self.fading:
continue
item = self.items_last.get((name, "plate", str(pid))) or {}
if now - max(first, at / 1000) >= (self.ramp_ms(item.get("material"), False, "plate") + SHOWN_MS) / 1000:
pl.append(str(pid))
has_cover = g.get("cover") != 0
if not has_cover and not pl:
# glassd draws no glass for it right now: between two glass modes (main: window ->
# windowless, its plates still materializing) the window stays native a moment, so its
# CSS glass never flashes back in between
if now - self.native_seen.get(key, 0) > NATIVE_GRACE_S:
continue
else:
self.native_seen[key] = now
keys.append(key)
if has_cover:
covers.append(key)
if pl:
plates[key] = sorted(pl)
pops = sm.get("pops") if isinstance(sm.get("pops"), dict) else {}
acks[key] = sorted(i for i, t in pops.items()
if _num(t, 0, 1e15) and now_ms - _num(t, 0, 1e15) >= SHOWN_MS and i in spec[key])
return sorted(keys), acks, plates, sorted(covers)
# ---------------------------------------------------------------- Steam
def on_binding(self, params):
if params.get("name") == ACTION_BINDING:
payload = params.get("payload")
probe = self.ctx_probe
if probe and isinstance(payload, str) and payload == json.dumps({"probe": probe[0]}):
if not probe[1].done():
probe[1].set_result(params.get("executionContextId"))
return
t = asyncio.get_running_loop().create_task(self.handle_action(payload, params.get("executionContextId")))
self.action_tasks.add(t)
t.add_done_callback(self.action_tasks.discard)
return
if params.get("name") != BINDING or self.test_report:
return
self.take_report(params.get("payload"))
def take_report(self, payload):
try:
rep = json.loads(payload or "null")
except ValueError as e:
self.note(f"bad layer report: {e}")
return
if not isinstance(rep, dict) or not isinstance(rep.get("surfaces"), list):
return
now = time.time()
self.report_at = now
self.reports += 1
# its last report (every surface invisible) when it stops: re-injected
# on the next steam tick
self.reporter_state = "stopped (reporter)" if rep.get("stopped") else "reporting"
hidden = rep.get("dash") is False
if hidden != self.dash_hidden:
log("steam: dashboard " + ("hidden: glass layout kept as is" if hidden else "shown"))
self.dash_hidden = hidden
# While the dashboard is hidden every surface reports invisible: keep
# the last layout (nodes, covers, glassd's atlas) so reopening shows the
# glass at once instead of rebuilding it.
if hidden and self.report is not None:
return
self.track_layers(rep, now)
self.report = rep
self.changed.set()
def reporter_key(self):
"""What the injected reporter depends on: its file, the layer fragments
and the depth profile. A change re-injects it."""
self.layers_version = layers_config()[2]
return f"{file_version(LAYERS_JS)}/{self.layers_version}/{self.profile}"
def read_test_report(self):
"""Lab: the report comes from a file (--test-report), re-read on change."""
try:
m = os.stat(self.test_report).st_mtime_ns
except OSError:
m = None
if m == self.test_report_mtime:
return
self.test_report_mtime = m
if m is None:
if self.report is not None:
self.report = None
self.changed.set()
self.reporter_state = "test report missing"
return
try:
with open(self.test_report, encoding="utf-8") as f:
payload = f.read()
except OSError:
return
self.reporter_state = "test report"
self.take_report(payload)
async def steam_target(self):
loop = asyncio.get_running_loop()
ts = await loop.run_in_executor(None, lambda: http_json(lgs.CDP + "/json/list", 5))
for t in ts:
if t.get("title") == "SharedJSContext":
return t
raise ConnectionError("no SharedJSContext target")
# ---------------------------------------------------------------- actions (contract §5)
async def probe_context(self):
"""Learn SharedJSContext's default execution context id: our own
evaluation (which runs there) calls the binding with a nonce."""
async with self.ctx_probe_lock:
s = self.steam
if not (self.steam_ok and s is not None and not s.closed):
return None
nonce = os.urandom(8).hex()
fut = asyncio.get_running_loop().create_future()
self.ctx_probe = (nonce, fut)
try:
await s.eval(f"(window.{ACTION_BINDING}({json.dumps(json.dumps({'probe': nonce}))}), 0)", 5)
self.trusted_ctx = await asyncio.wait_for(fut, 3)
except (asyncio.TimeoutError, *NET_ERRORS) as e:
self.note(f"actions: context probe failed: {e!r}")
finally:
self.ctx_probe = None
return self.trusted_ctx
async def handle_action(self, payload, ctx_id):
try:
if self.trusted_ctx is None or ctx_id != self.trusted_ctx:
await self.probe_context() # first call, or the page's context changed
reply = await self.registry.call(payload, from_default_context=ctx_id == self.trusted_ctx)
if reply is None:
return
self.replies.append({k: reply.get(k) for k in ("id", "ok", "error", "dry")} | {"at": time.strftime("%H:%M:%S")})
await self.bridge_send([["reply", reply]])
except asyncio.CancelledError:
raise
except Exception as e: # noqa: BLE001 - an action must never take the daemon down
self.note(f"actions: {e!r}")
async def actions_loop(self):
"""Plugins: load new or changed files, unload removed ones, tick them."""
while not self.stopping:
try:
ch = await self.registry.scan()
if ch:
log(f"actions: plugins {ch}; types {sorted(self.registry.types)}")
await self.registry.tick()
except asyncio.CancelledError:
raise
except Exception as e: # noqa: BLE001
await asyncio.sleep(self.bug("actions", e))
await asyncio.sleep(ACTION_TICK_S)
# ---------------------------------------------------------------- bridge (contract §3)
async def bridge_send(self, pairs):
s = self.steam
if not pairs or not (self.steam_ok and s is not None and not s.closed):
return False
r = await s.eval(f"({BRIDGE_SET_JS})({json.dumps(pairs)})", 5)
ok = isinstance(r, str) and r.startswith("ok")
if ok != self.bridge_runtime:
log(f"steam: runtime bridge {'present' if ok else 'absent'}")
self.bridge_runtime = ok
now = time.time()
if ok:
for name, value in pairs:
if name != "reply":
self.bridge_sent[name] = (json.dumps({k: v for k, v in value.items() if k != "at"}
if isinstance(value, dict) else value, sort_keys=True), now)
return ok
async def bridge_tick(self):
"""Send what changed, what the runtime lost (reinstalled), and the
daemon heartbeat every DAEMON_BEAT_S."""
s = self.steam
if not (self.steam_ok and s is not None and not s.closed):
return
now = time.time()
self.bridge_values["daemon"] = self.daemon_value()
if self.geom is not None:
self.bridge_values["geom"] = self.geom
if self.page is not None:
self.bridge_values["page"] = self.page
names = sorted(self.bridge_values)
missing = await s.eval(f"({BRIDGE_MISSING_JS})({json.dumps(names)})", 5)
if missing is None:
if self.bridge_runtime is not False:
log("steam: runtime bridge absent (no __LGS_RT.bridge); values kept for when it appears")
self.bridge_runtime = False
self.bridge_sent = {}
return
pairs = []
for name in names:
v = self.bridge_values[name]
js = json.dumps({k: x for k, x in v.items() if k != "at"} if isinstance(v, dict) else v, sort_keys=True)
prev = self.bridge_sent.get(name)
due = name in (missing or []) or prev is None or prev[0] != js or \
(name == "daemon" and now - prev[1] >= DAEMON_BEAT_S - 0.05)
if due:
pairs.append([name, v])
if pairs:
await self.bridge_send(pairs)
async def inject_reporter(self):
if not os.path.exists(LAYERS_JS):
if self.reporter_state != "missing":
log("lgs_layers.js not installed: main window glass only, nothing pops out")
self.reporter_state = "missing"
return False
with open(LAYERS_JS, encoding="utf-8") as f:
src = f.read()
cfg, errs, _ = layers_config()
for e in errs:
self.note(f"layers: {e}")
opts = {"binding": BINDING, "layers": cfg, "version": file_version(LAYERS_JS), "ackMode": True,
"profile": self.profile}
if self.geom and _num(self.geom.get("S"), 0.01, 10) is not None and _num(self.geom.get("r"), 0.05, 10):
opts["geom"] = {"S": self.geom["S"], "r": self.geom["r"]}
self.reporter_injected_at = time.time()
self.reporter_version = self.reporter_key()
try:
await self.steam.eval(f"(window.__LGS_LAYERS_OPTS = {json.dumps(opts)}, 0)")
r = await self.steam.eval(src, timeout=30, by_value=False)
if r.get("type") == "function" and r.get("objectId"): # a factory: call it with the options
await self.steam.send("Runtime.callFunctionOn", {
"objectId": r["objectId"], "functionDeclaration": "function (o) { return this(o); }",
"arguments": [{"value": opts}], "returnByValue": True, "awaitPromise": True})
if r.get("objectId"):
await self.steam.send("Runtime.releaseObject", {"objectId": r["objectId"]})
except RuntimeError as e: # their bug: report it, retry only when the file changes
self.reporter_state = f"error: {str(e)[:160]}"
self.note(f"reporter: {e}")
return False
present = await self.steam.eval(f"typeof window.{REPORTER}")
self.reporter_state = "injected" if present != "undefined" else "injected (no global)"
self.native_force = True # a fresh reporter: send the acknowledgements again
resend = await self.steam.eval(REPORTER_RESEND_JS)
log(f"reporter injected ({self.reporter_state}, resend: {resend})")
return True
async def steam_loop(self):
while not self.stopping:
delay = 2
try:
try:
t = await self.steam_target()
except NET_ERRORS:
# Steam gone (quit, restarting): nothing of ours is left in it;
# wait a while for it, then exit (never outlive a Steam restart)
self.steam_lost_at = self.steam_lost_at or time.time()
if time.time() - self.steam_lost_at >= STEAM_GONE_S: # --stay too (review R1 M3)
log(f"steam: unreachable for {STEAM_GONE_S} s; exiting")
self.request_stop("steam gone", strip_vr=True)
return
raise
self.steam_lost_at = None
if self.steam_target_id and t.get("id") != self.steam_target_id:
# a new SharedJSContext: Steam (or its UI) restarted. Until
# the theme is seen on again, theme off means exit at once.
self.steam_restarted = True
log("steam: SharedJSContext is a new target (Steam restarted or its UI reloaded)")
self.steam_target_id = t.get("id")
self.steam = await CDP("steam", t["webSocketDebuggerUrl"]).open()
self.steam.handlers["Runtime.bindingCalled"] = self.on_binding
# lgsLayers only when a reporter can be injected (native mode);
# a leftover one from an older unit is removed (G-REMOVE, review R1 M1)
if self.native_possible():
await self.steam.send("Runtime.addBinding", {"name": BINDING})
else:
r = await self.steam.eval(f"({DELETE_BINDINGS_JS})({json.dumps([BINDING])})", 10)
if r:
log(f"steam: removed a leftover binding global {r}")
await self.steam.send("Runtime.addBinding", {"name": ACTION_BINDING})
self.steam_ok = True
self.trusted_ctx, self.bridge_sent = None, {}
log("steam: connected")
await self.probe_context()
self.refresh_flags()
if self.native_possible() and not self.test_report:
await self.inject_reporter()
tick, slow = 0, 0
while not self.stopping and not self.steam.closed:
try:
r = await self.steam.eval(STEAM_TICK_JS, 10)
on, rm = json.loads(r) if isinstance(r, str) else (False, False)
if bool(rm) != self.reduce_motion:
self.reduce_motion = bool(rm)
log(f"steam: Reduce Motion {'on' if rm else 'off'}")
self.changed.set()
if self.refresh_flags() and self.native_possible() and self.reporter_version \
and not self.test_report:
await self.inject_reporter() # the depth profile changed
self.theme_on = bool(on)
self.theme_off_polls = 0 if on else self.theme_off_polls + 1
if on:
self.steam_restarted = False
if self.dormant_since is not None:
log(f"steam: theme is back after {time.time() - self.dormant_since:.0f} s; resuming")
self.dormant_since = None
self.native_force, self.bridge_sent = True, {}
self.changed.set()
self.check_stay_limit()
if self.theme_off_polls >= THEME_OFF_POLLS and not self.pod: # pod: no theme, by design
# A theme off without `lgs off` (which stops us first) is a
# test toggling it (lab --stock, P1's selftests) or a Steam
# restart: stay dormant for the grace, exit on a restart.
# --stay only lengthens the grace (STAY_MAX_S): a Steam
# restart ends a --stay unit too (review R1 M3).
grace = self.theme_grace_s()
if self.steam_restarted or grace <= 0 or (
self.dormant_since is not None and time.time() - self.dormant_since >= grace):
log("steam: theme is off" + (" after a Steam restart" if self.steam_restarted else
f" (dormant {grace:.0f} s)" if grace > 0 else "")
+ "; tearing down")
self.request_stop("steam theme off", strip_vr=True)
return
if self.dormant_since is None:
self.dormant_since = time.time()
log(f"steam: theme is off; dormant for up to {grace:.0f} s"
+ (" (--stay)" if self.stay else "")
+ " (native layer, glassd and reporter off; lgs off stops the daemon at once)")
self.changed.set()
# reporter installed later, changed on disk, or lost (page reload);
# stopped again when native mode ended (CSS only) or while dormant
if (tick % 3 == 0 or self.dormant_since is not None) and self.reporter_version and (
not self.native_possible() or self.dormant_since is not None):
r = await self.steam.eval(REPORTER_STOP_JS, 10)
log(f"steam: {'dormant' if self.native_possible() else 'native layer off'}; "
f"reporter stopped ({r})")
self.reporter_version = None
self.reporter_state = "stopped (dormant)" if self.native_possible() else "stopped (css only)"
self.report, self.fallback = None, None
elif self.dormant_since is not None:
pass
elif self.native_possible() and self.test_report:
self.read_test_report()
elif self.native_possible() and os.path.exists(LAYERS_JS):
if tick % 3 == 0 and self.reporter_key() != self.reporter_version:
await self.inject_reporter()
elif self.reporter_state == "stopped (reporter)":
log("steam: reporter stopped; re-injecting")
await self.inject_reporter()
elif self.reporter_state in ("injected", "reporting", "foreign"):
rs = await self.steam.eval(REPORTER_PING_JS, 10) # every tick: its heartbeat
if rs in ("absent", "stopped", "error") and tick % 3 == 0:
# page reloaded, it stopped itself (no pings or
# heartbeat while we stalled), or someone stopped it
log(f"steam: reporter {rs}; re-injecting")
await self.inject_reporter()
elif rs == "foreign" and self.reporter_state != "foreign":
self.note("steam: another lgs_layers instance (other binding) is running; "
"not replacing it; no layer reports meanwhile")
self.reporter_state = "foreign"
elif rs == "ok" and self.reporter_state == "foreign":
self.reporter_state = "injected"
# no reporter (or it never reported): glass for the main window only
if not self.native_possible() or self.test_report or self.dormant_since is not None:
pass
elif self.report is None and (self.reporter_state == "missing"
or time.time() - self.reporter_injected_at > 10):
size = await self.steam.eval(MAIN_SIZE_JS, 10)
fb = None
if size:
fb = {"seq": 0, "surfaces": [{"name": "main", "overlayKey": "valve.steam.gamepadui.main",
"visible": True, "texW": size[0], "texH": size[1],
"radius": 48, "layers": []}]}
if fb != self.fallback:
self.fallback = fb
self.changed.set()
elif self.report is not None:
self.fallback = None
self.write_glassd_json()
await self.bridge_tick()
slow = 0
except (asyncio.TimeoutError, TimeoutError):
# Steam's JS thread busy (another agent's audit, a runtime
# reload): one late answer is not a lost connection. Closing
# the socket here took the native layer down for 2-4 s.
slow += 1
if self.steam.closed or slow >= STEAM_SLOW_MAX:
raise
if slow == 1:
self.slow_evals += 1
log("steam: an evaluation timed out (Steam busy?); connection kept")
tick += 1
await asyncio.sleep(1.0)
except asyncio.CancelledError:
raise
except NET_ERRORS as e:
if not self.stopping:
self.note(f"steam: {e!r}")
if self.steam_ok:
delay = 0.5 # a connection that worked: try again at once
except Exception as e: # noqa: BLE001 - see bug()
if not self.stopping:
delay = self.bug("steam", e)
finally:
self.steam_ok = False
self.changed.set()
if self.steam:
await self.steam.close()
if not self.stopping:
await asyncio.sleep(delay)
async def native_loop(self):
"""html.lgs-native per window and the popped-layer acknowledgements:
sent when they change (within 0.1 s) and as a 1 s heartbeat while any
window is native. Cleared at once when systemui or glassd drops out."""
sent, sent_at = None, 0.0
while not self.stopping:
# 10 Hz only while there is a native layer to follow (CSS only: 1 Hz; review R1 m4)
await asyncio.sleep(0.1 if self.native_possible() or self.native_applied or self.native_force else 1.0)
s = self.steam
if not self.steam_ok or s is None or s.closed:
sent = None
continue
try:
keys, acks, plates, covers = self.native_state()
live = self.native_live()
payload = (keys, acks, plates, covers, live)
now = time.time()
if not self.native_force:
if payload == sent and (not (keys or live) or now - sent_at < NATIVE_BEAT_S):
continue
if sent is None and not keys and not live and not self.native_applied:
sent = payload # nothing native and nothing applied: no call needed
continue
self.native_force = False
r = await s.eval(f"({NATIVE_JS})({json.dumps(keys)}, {NATIVE_TTL_MS}, {json.dumps(acks)}, "
f"{json.dumps(plates)}, {json.dumps(covers)}, {json.dumps(live)})", 5)
res = json.loads(r) if isinstance(r, str) else {}
sent, sent_at = payload, now
applied = res.get("applied") or []
if applied != self.native_applied:
log(f"steam: lgs-native on {applied or 'no windows'}")
self.native_applied = applied
self.native_tagged = res.get("tagged")
if (acks, plates) != (self.acked, self.plate_acks):
self.bridge_values["acks"] = {"pops": acks, "plates": plates}
self.acked, self.plate_acks, self.cover_keys = acks, plates, covers
except asyncio.CancelledError:
raise
except NET_ERRORS as e:
if not self.stopping:
self.note(f"steam: lgs-native: {e!r}")
sent = None
# right after a stall of ours the late answer was ours: again at once (SG-6)
await asyncio.sleep(0 if self.stalled_recently() else 1)
except Exception as e: # noqa: BLE001 - see bug()
if not self.stopping:
sent = None
await asyncio.sleep(self.bug("lgs-native", e))
# ---------------------------------------------------------------- systemui
async def sysui_target(self):
loop = asyncio.get_running_loop()
ts = await loop.run_in_executor(None, lambda: http_json(lgs_vr.VR_CDP + "/json/list", 3))
for t in ts:
if t.get("type") == "page" and t.get("title") == "systemui":
return t
for t in ts:
if t.get("type") == "page" and "systemui" in (t.get("url") or ""):
return t
raise ConnectionError("no systemui page on 8090")
def on_sysui_context(self, params, cleared=False):
"""systemui's page context went away (reload) while the devtools socket
stays open: our nodes are gone with it. Drop lgs-native at once and
re-inject (sysui_loop)."""
ctx = (params or {}).get("executionContextId")
if cleared or (ctx is not None and ctx == self.sysui_ctx):
self.sysui_ctx = None
self.sg_beat, self.sg_beat_at = None, 0.0
self.sg_reset = True
self.changed.set()
def on_sysui_context_created(self, params):
c = (params or {}).get("context") or {}
if (c.get("auxData") or {}).get("isDefault"):
self.sysui_ctx = c.get("id")
@staticmethod
def sg_source_version():
"""lgs_sg.js and, when present, P5's motion.js (prepended): a change to
either re-injects."""
v = file_version(SG_JS)
m = file_version(MOTION_JS)
return f"{v}+{m}" if m else v
async def send_overrides(self, force=False):
"""theme/sg rules, filtered by flags (refresh_flags), to lgs_sg.js v2."""
if not force and self.sg_rules_sent == self.sg_rules_sig:
return
payload = json.dumps({"seq": self.spec_seq, "rules": self.sg_rules})
r = await self.sysui.eval(f"(window.__LGS_SG && typeof window.__LGS_SG.overrides === 'function') ? "
f"JSON.stringify(window.__LGS_SG.overrides({payload})) : 'unsupported'", 10)
self.sg_rules_sent = self.sg_rules_sig
if self.sg_rules or r != "unsupported":
log(f"systemui: overrides {[x.get('id') for x in self.sg_rules]} -> {str(r)[:200]}")
async def inject_sg(self, force=False):
with open(SG_JS, encoding="utf-8") as f:
src = f.read().strip().rstrip(";")
motion = None
if os.path.exists(MOTION_JS):
try:
with open(MOTION_JS, encoding="utf-8") as f:
motion = f.read()
except OSError:
motion = None
self.sg_version = self.sg_source_version()
opts = {"version": self.sg_version, "watchdogMs": WATCHDOG_MS, "maxPushHz": SG_MAX_PUSH_HZ}
if force:
opts["force"] = True
if motion:
# the depth channel: P5's springs in the same scope (contracts/sg.md §1)
opts["motion"] = True
expr = f"(() => {{\n{motion}\n;return ({src})({json.dumps(opts)});\n}})()"
else:
expr = f"({src})({json.dumps(opts)})"
r = await self.sysui.eval(expr, 20)
self.sg_rules_sent = None # a fresh instance: send the overrides again
st = json.loads(r) if isinstance(r, str) else r
self.sg_status = st
self.sg_injected_at = time.time()
self.sg_beat, self.sg_beat_at = None, 0.0
sch = (st or {}).get("scheduler") or {}
log(f"systemui: lgs_sg {self.sg_version} installed (scheduler module {sch.get('module')}, "
f"retire {sch.get('retire')}{', ' + sch['error'] if sch.get('error') else ''})")
self.spec_sent = None
def set_beat(self, b):
if isinstance(b, dict) and "surf" in b:
self.sg_beat, self.sg_beat_at = b, time.time()
# the part of each Steam texture its panel shows: kept (a surface hidden for a moment keeps its
# crop), a change rewrites glassd.json (the eye pair is built around it)
c = b.get("crops")
if isinstance(c, dict):
crops = dict(self.sg_crops or {})
for k, v in c.items():
if isinstance(k, str) and isinstance(v, list) and len(v) == 2:
crops[k] = [round(float(x), 3) for x in v if isinstance(x, (int, float))][:2]
if crops != self.sg_crops:
self.sg_crops = crops
self.changed.set()
# SteamVR Settings' panel (lgs_sg.js measures it while the page shows): its size and glass rect
# are kept, so glassd's "vrsettings" glass stays drawn ahead for the next time the page shows
v = b.get("vr")
shown = set()
for it in v if isinstance(v, list) else []:
if isinstance(it, dict) and isinstance(it.get("name"), str) and isinstance(it.get("glass"), dict) \
and it.get("material") in ("window", "panel", "thick", "liquid"):
name = it["name"][:24]
shown.add(name)
if self.vr_panels.get(name) != it:
self.vr_panels[name] = it
self.changed.set()
if shown != self.vr_shown:
self.vr_shown = shown
self.changed.set()
async def send_spec(self, spec, js):
self.spec_seq += 1
payload = json.dumps(dict(spec, seq=self.spec_seq), separators=(",", ":"))
r = await self.sysui.eval(f"window.__LGS_SG ? JSON.stringify(window.__LGS_SG.update({payload})) : null", 10)
if r is None:
await self.inject_sg()
r = await self.sysui.eval(f"JSON.stringify(window.__LGS_SG.update({payload}))", 10)
self.spec_sent, self.spec_sent_obj, self.spec_sent_at = js, spec, time.time()
res = json.loads(r) if r else {}
self.set_beat(res)
self.spec_summary = {"seq": self.spec_seq, "surfaces": len(spec["surfaces"]),
"popped": sum(len(s["popped"]) for s in spec["surfaces"]),
"items": res.get("items"), "counts": res.get("counts")}
# Log structural changes only, at most once a second (scrolling moves
# popped elements many times a second).
sig = (self.spec_summary["surfaces"], self.spec_summary["popped"], json.dumps(res.get("counts"), sort_keys=True))
if res.get("changed") and sig != self.spec_log_sig:
now = time.time()
if now - self.spec_logged_at >= SPEC_LOG_MIN_S:
more = f" (+{self.spec_log_skipped} more)" if self.spec_log_skipped else ""
why = "" if spec["surfaces"] else f" [{self.native_why() or 'no surface ready'}]"
log(f"systemui: spec -> {self.spec_summary}{more}{why}")
self.spec_logged_at, self.spec_log_sig, self.spec_log_skipped = now, sig, 0
else:
self.spec_log_skipped += 1
return res
async def sysui_loop(self):
while not self.stopping:
delay = 2
try:
if not self.sg_wanted(): # CSS only and no override or window state: leave systemui alone
await asyncio.sleep(1)
continue
ok = await asyncio.get_running_loop().run_in_executor(None, lgs_vr.available)
if not ok:
await asyncio.sleep(3)
continue
t = await self.sysui_target()
self.sysui = await CDP("systemui", t["webSocketDebuggerUrl"]).open()
self.sysui_ctx, self.sg_reset = None, False
self.sysui.handlers["Runtime.executionContextCreated"] = self.on_sysui_context_created
self.sysui.handlers["Runtime.executionContextDestroyed"] = self.on_sysui_context
self.sysui.handlers["Runtime.executionContextsCleared"] = lambda p: self.on_sysui_context(p, True)
await self.sysui.send("Runtime.enable")
await self.inject_sg()
self.sysui_ok = True
log("systemui: connected")
next_beat, next_status, it_at, slow = 0.0, 0.0, time.monotonic(), 0
while not self.stopping and not self.sysui.closed:
try:
now = time.time()
# wake for a layer that becomes still and for a fade-out that ends
# (its phase-0 item leaves glassd.json and slabsOut on time)
fade_end = min((u for _, u in self.fading.values()), default=now + 0.5) + 0.01
until = min(next_beat, now + 0.5, self.wake_at or now + 0.5, fade_end)
try:
await asyncio.wait_for(self.changed.wait(), max(0.02, until - now))
except asyncio.TimeoutError:
pass
self.changed.clear()
if self.stopping:
break
gap, it_at = time.monotonic() - it_at, time.monotonic()
if gap > STALL_S and self.sg_wanted():
# we were stopped (SIGSTOP / SIGCONT) or starved: the heartbeat
# first, so lgs_sg.js rebuilds its last spec at once (REQ P7->P8, SG-6)
r = await self.sysui.eval("window.__LGS_SG ? JSON.stringify(window.__LGS_SG.ping()) : null", 10)
if r:
self.set_beat(json.loads(r))
log(f"systemui: heartbeat sent first after a {gap:.1f} s stall of ours")
next_beat = 0.0
if not self.sg_wanted(): # glassd gave up, its binary was removed, overrides off
await self.sysui.eval(SG_DESTROY_JS, 10)
log("systemui: nothing left to show (native off, no overrides); scene graph removed")
break
if self.sg_reset: # page reloaded under us
self.sg_reset = False
log("systemui: page context reset (reload?); re-injecting")
await asyncio.sleep(0.5) # let the new page come up
await self.inject_sg()
next_beat = 0.0
if self.sg_source_version() != self.sg_version:
await self.inject_sg()
next_beat = 0.0
await self.send_overrides()
self.write_glassd_json()
spec = self.build_spec()
js = json.dumps(spec, separators=(",", ":"))
if js != self.spec_sent:
wait = self.spec_sent_at + SPEC_MIN_INTERVAL - time.time()
if wait > 0:
await asyncio.sleep(wait)
spec = self.build_spec()
js = json.dumps(spec, separators=(",", ":"))
res = await self.send_spec(spec, js)
# look again right after lgs_sg.js pushed, to learn when
pin = res.get("pushIn")
next_beat = min(next_beat, time.time() + (pin if isinstance(pin, (int, float)) else 50) / 1000 + 0.04)
now = time.time()
if now >= next_beat:
r = await self.sysui.eval("window.__LGS_SG ? JSON.stringify(window.__LGS_SG.ping()) : null", 10)
if r is None:
log("systemui: __LGS_SG gone (page reloaded?); re-injecting")
await self.inject_sg()
next_beat = 0.0
continue
b = json.loads(r)
self.set_beat(b)
if b.get("push"):
self.sg_nosched = 0
elif now - self.sg_injected_at > min(60, 3 * 2 ** self.sg_nosched):
# installed before the page's bundle was ready (or the
# module moved): no scheduler, so nothing reaches the
# compositor and nothing is native. Retry, backing off.
self.sg_nosched += 1
self.note(f"systemui: scene-graph scheduler not found; re-injecting (try {self.sg_nosched})")
await self.inject_sg(force=True)
next_beat = time.time() + 1
continue
if b.get("rebuilt") or b.get("expired") or (not b.get("items") and spec["surfaces"]):
if b.get("rebuilt") or b.get("expired"):
self.note(f"systemui: scene-graph watchdog had fired (rebuilt {b.get('rebuilt')}); "
"sending the spec again")
self.spec_sent = None
self.changed.set()
# poll fast only while a push is pending shortly after a change
fast = b.get("pending") and now - self.spec_sent_at < 3
next_beat = now + (0.1 if fast else NATIVE_BEAT_S)
if now >= next_status:
next_status = now + 2
r = await self.sysui.eval("window.__LGS_SG ? JSON.stringify(window.__LGS_SG.status()) : null", 10)
if r:
self.sg_status = json.loads(r)
slow = 0
except (asyncio.TimeoutError, TimeoutError):
# an answer that was due while we were stopped: not a lost page.
# Keep the socket and ping at once (REQ P7->P8, SG-6); twice in a row
# without a stall drops it as before
slow += 1
if self.sysui.closed or (slow >= 2 and not self.stalled_recently()):
raise
log("systemui: an evaluation timed out" + (" across a stall of ours" if self.stalled_recently()
else " (systemui busy?)") + "; connection kept")
next_beat, it_at = 0.0, time.monotonic()
except asyncio.CancelledError:
raise
except NET_ERRORS as e:
if not self.stopping:
self.note(f"systemui: {e!r}")
except Exception as e: # noqa: BLE001 - see bug()
if not self.stopping:
delay = self.bug("systemui", e)
finally:
self.sysui_ok = False
self.sg_beat, self.sg_beat_at = None, 0.0
if self.sysui:
await self.sysui.close()
if not self.stopping:
await asyncio.sleep(delay)
# ---------------------------------------------------------------- glassd
async def glassd_out_loop(self):
last_check = 0.0
while not self.stopping:
await asyncio.sleep(0.1 if self.native_possible() or self.glassd_running() else 1.0) # CSS only: 1 Hz
now = time.time()
if now - last_check >= 1.0:
last_check = now
try:
await self.supervise_glassd(now)
except asyncio.CancelledError:
raise
except Exception as e: # noqa: BLE001 - see bug()
await asyncio.sleep(self.bug("glassd supervisor", e))
try:
m = os.stat(GLASSD_OUT).st_mtime_ns
except OSError:
if self.gout is not None:
self.gout = None
self.changed.set()
continue
if m == self.gout_mtime:
continue
self.gout_mtime = m
try:
with open(GLASSD_OUT, encoding="utf-8") as f:
d = json.load(f)
except (OSError, ValueError):
continue # mid-write; next poll
if not isinstance(d, dict) or not self.glassd_running():
continue
self.gout_seen = now
if self.g_stale:
self.g_stale = False
log("glassd: output fresh again")
was_ready = self.glassd_ready()
self.gout = d
if not self.g_frames and ((_num(d.get("fps") or 0) or 0) > 0 or (_num(d.get("frames") or 0, 0, 1e15) or 0) > 0):
self.g_frames = True
log(f"glassd: producing frames (fps {d.get('fps')}, gpu {d.get('gpu_ms')} ms); native mode")
if was_ready != self.glassd_ready():
log(f"glassd: {'ready' if self.glassd_ready() else 'not ready'} (healthy {d.get('healthy')}, "
f"exiting {d.get('exiting', False)})")
self.changed.set()
def stalled_recently(self, within=3.0):
"""True while our own event loop is (or just was) stalled: SIGSTOP /
SIGCONT (NAT freeze), a starved CPU. Read before stall_loop has run
again, it still sees the gap (loop_beat is old)."""
now = time.monotonic()
return now - self.loop_beat > STALL_S or now - self.stall_at < within
async def stall_loop(self):
"""Notices that the daemon itself did not run (REQ P7->P8, SG-6): the
silence of glassd and the late Steam / systemui answers during that
time are ours, so glassd is not restarted as hung, and systemui gets
its heartbeat first (sysui_loop)."""
self.loop_beat = time.monotonic()
while not self.stopping:
await asyncio.sleep(0.25)
now = time.monotonic()
gap = now - self.loop_beat
self.loop_beat = now
if gap > STALL_S:
self.stall_at, self.stalls, self.last_stall_s = now, self.stalls + 1, round(gap, 1)
self.gout_seen = time.time() # glassd gets a fresh window to show it is alive
log(f"daemon: our event loop stalled {gap:.1f} s (stopped or starved); not held against "
"glassd or Steam")
self.changed.set()
async def supervise_glassd(self, now):
"""Once a second: binary removed -> CSS only; dormant -> glassd stopped
(glassd_loop starts it again when the theme is back); output stale ->
restart (not right after a stall of our own)."""
if not self.native_possible():
if self.glassd_running():
await self.kill_glassd()
if self.gseq and not self.g_files_removed: # native mode ended mid-session
self.g_files_removed = True
remove_glassd_files()
# only the binary was missing (an install swapping the folder under us, a toggle run before the
# install finished): native comes back as soon as a glassd is in place again
if self.native_requested and self.glassd_path and str(self.css_reason or "").startswith(
("glassd binary removed", "no glassd binary at start")) and ensure_glassd(self.glassd_path):
log("glassd: binary in place again; native layer back on")
self.native_enabled, self.css_reason = True, None
self.g_files_removed = False
self.g_given_up = False
self.tasks.append(asyncio.create_task(self.glassd_loop()))
self.changed.set()
return
if not os.access(self.glassd_path, os.X_OK) and not ensure_glassd(self.glassd_path):
self.disable_native("glassd binary removed")
await self.kill_glassd()
return
if self.dormant_since is not None:
# theme off without lgs off: nothing to draw, so no camera reader and
# no render loop while we wait (review R1 M2); not counted as a crash
if self.glassd_running():
await self.kill_glassd("dormant")
remove_glassd_files()
self.gjson_full = self.gjson_struct = None
return
if not self.glassd_running():
return
if self.stalled_recently():
# we were stopped (SIGSTOP) or starved, not glassd: its output time
# restarts now (it rewrites glassd-out.json within 2 s if alive)
self.gout_seen = max(self.gout_seen, time.time())
return
# The real glassd rewrites glassd-out.json every 2 s ("updated"); the
# stand-in (fakeglassd) only on changes, so staleness is not checked for it.
if self.gout and "updated" in self.gout and now - self.gout_seen > GLASSD_STALE_S:
if not self.g_stale:
self.g_stale = True
self.changed.set()
self.note(f"glassd: no output for {now - self.gout_seen:.0f} s (hung?); restarting it")
await self.kill_glassd()
elif not self.gout and now - self.g_started > GLASSD_FIRST_OUT_S:
self.note(f"glassd: no glassd-out.json after {GLASSD_FIRST_OUT_S:.0f} s; restarting it")
await self.kill_glassd()
async def pipe_glassd(self, proc):
while True:
try:
line = await proc.stdout.readline()
except ValueError: # a line over the stream limit (the reader dropped it)
print("glassd: [over-long output line dropped]", flush=True)
continue
if not line:
return
print("glassd: " + line.decode(errors="replace").rstrip(), flush=True)
async def glassd_loop(self):
if not self.native_possible():
log(f"CSS only + SteamVR page theming ({self.css_reason})")
return
temp_streak = 0
while not self.stopping and self.native_possible():
if not os.access(self.glassd_path, os.X_OK) and not ensure_glassd(self.glassd_path):
self.disable_native("glassd binary removed")
return
# glassd needs SteamVR and something to cover (and the theme on: not dormant)
if not (self.sysui_ok and self.steam_ok and self.theme_on and self.dormant_since is None
and self.current_report()):
await asyncio.sleep(1)
continue
for p in (GLASSD_OUT,):
try:
os.remove(p)
except OSError:
pass
self.gout, self.gout_mtime, self.g_frames, self.g_stale = None, None, False, False
self.write_glassd_json(force=True)
args = [self.glassd_path] + self.glassd_args + os.environ.get("LGS_GLASSD_ARGS", "").split()
try:
self.gproc = await asyncio.create_subprocess_exec(
*args, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT,
stdin=asyncio.subprocess.DEVNULL, start_new_session=False, limit=1 << 20,
preexec_fn=_pdeathsig)
except OSError as e:
self.note(f"glassd: cannot start: {e}")
self.g_given_up = True
self.disable_native(f"glassd cannot start: {e}")
return
self.g_pid = self.gproc.pid
self.g_started = time.time()
self.gout_seen = self.g_started
log(f"glassd: started pid {self.g_pid} ({' '.join(args)})")
piper = asyncio.create_task(self.pipe_glassd(self.gproc))
rc = await self.gproc.wait()
await asyncio.gather(piper, return_exceptions=True)
self.g_last_exit = rc
self.g_frames = False
self.gout = None
self.changed.set()
if self.stopping or not self.native_possible():
return
if self.g_kill_reason == "dormant": # we stopped it: wait for the theme (top of the loop)
self.g_kill_reason = None
temp_streak = 0
continue
self.g_kill_reason = None
self.g_restarts += 1
if rc == GLASSD_TEMP_EXIT: # SteamVR went away, or another glassd holds the lock
self.g_temp_exits += 1
temp_streak += 1
if temp_streak == 1 or temp_streak % 10 == 0:
self.note(f"glassd: exited {rc} (temporary; SteamVR gone or lock held), "
f"retrying ({temp_streak} in a row)")
await asyncio.sleep(min(30, 3 * temp_streak))
continue
temp_streak = 0
now = time.time()
self.g_crashes = [t for t in self.g_crashes if now - t < GLASSD_CRASH_WINDOW] + [now]
self.note(f"glassd: exited with {rc} after {now - self.g_started:.1f} s")
if len(self.g_crashes) >= GLASSD_MAX_CRASHES:
self.g_given_up = True
self.disable_native(f"glassd gave up: {GLASSD_MAX_CRASHES} exits in {GLASSD_CRASH_WINDOW} s")
return
await asyncio.sleep(min(30, 2 ** len(self.g_crashes)))
async def kill_glassd(self, reason=None):
"""SIGTERM glassd (SIGKILL after 4 s). reason 'dormant' = on purpose,
not a crash (glassd_loop does not count it nor back off)."""
p = self.gproc
if not p or p.returncode is not None:
return
self.g_kill_reason = reason
if reason:
log(f"glassd: stopping it ({reason})")
try:
p.terminate()
await asyncio.wait_for(p.wait(), 4)
except asyncio.TimeoutError:
p.kill()
await p.wait()
except ProcessLookupError:
pass
log(f"glassd: stopped ({p.returncode})")
# ---------------------------------------------------------------- geometry and page (contract §4)
@staticmethod
def round_geom(g, prev):
def rn(v, nd):
f = _num(v)
return None if f is None else round(f, nd)
out = {"v": 1, "S": rn(g.get("S"), 4), "H0": rn(g.get("H0"), 4), "r": rn(g.get("r"), 4),
"Hm": rn(g.get("Hm"), 4), "unitM": rn(g.get("unitM"), 4), "mmPerCssPx": rn(g.get("mmPerCssPx"), 4),
"dashDist": rn(g.get("dashDist"), 3), "frameMenu": None}
fm = g.get("frameMenu")
if isinstance(fm, dict) and fm.get("key"):
m = {"key": fm["key"], "mpp": rn(fm.get("mpp"), 7), "visible": bool(fm.get("visible")),
"uv": [rn(v, 4) for v in fm.get("uv") or []]}
pf = (prev or {}).get("frameMenu") or {}
if fm.get("dumped"):
m["fWidth"], m["fHeight"] = rn(fm.get("fWidth"), 4), rn(fm.get("fHeight"), 4)
elif pf.get("key") == m["key"]:
m["fWidth"], m["fHeight"] = pf.get("fWidth"), pf.get("fHeight")
if m.get("fHeight") and m["mpp"]:
m["clipTexH"] = round(m["fHeight"] / m["mpp"], 1)
m["clipCssH"] = round(m["clipTexH"] / 1.5, 1)
if out["S"] and m["clipCssH"]:
m["mpMm"] = round(1000 * m["fHeight"] * out["S"] / m["clipCssH"], 3)
out["frameMenu"] = m
return out
async def geom_loop(self):
"""Read-only geometry and page from systemui, in both modes, published
on the bridge when they change (polled 1/s). DumpLaserOverlays (the
frame menu's panel size) runs 1, 3 and 6 s after the frame menu's node
or another value changed (the dump lags the node), and every
GEOM_DUMP_S as a safety net."""
loop = asyncio.get_running_loop()
conn = None
last_dump, dumps_at, fm_sig = 0.0, [0.0], None
while not self.stopping:
delay = 3
try:
if not await loop.run_in_executor(None, lgs_vr.available):
await asyncio.sleep(3)
continue
t = await self.sysui_target()
conn = await CDP("systemui-geom", t["webSocketDebuggerUrl"]).open()
self.geom_ok = True
dumps_at = [0.0] # a fresh connection: dump at once
while not self.stopping and not conn.closed:
now = time.time()
want = now - last_dump >= GEOM_DUMP_S or any(a <= now for a in dumps_at)
r = await conn.eval(f"({GEOM_JS})({'true' if want else 'false'})", 10)
d = json.loads(r) if isinstance(r, str) else {}
if want:
last_dump = now
dumps_at = [a for a in dumps_at if a > now]
if isinstance(d.get("geom"), dict) and ((d["geom"].get("frameMenu") or {}).get("dumped")
or not d["geom"].get("frameMenu")):
self.geom_dump_at = now
if d.get("fmSig") != fm_sig:
if fm_sig is not None:
dumps_at = [now + a for a in GEOM_DUMP_AFTER] # the panel may have changed size
fm_sig = d.get("fmSig")
self.geom_err = d.get("err")
if isinstance(d.get("geom"), dict):
g = self.round_geom(d["geom"], self.geom)
old = {k: v for k, v in (self.geom or {}).items() if k != "at"}
if g != old:
if self.geom is not None and not want and not dumps_at:
dumps_at = [now + a for a in GEOM_DUMP_AFTER] # something moved: the panel too
if (self.geom or {}).get("unitM") != g.get("unitM"):
self.changed.set() # glassd's unitM and the spec follow
self.geom = dict(g, at=int(now * 1000))
self.bridge_values["geom"] = self.geom
if isinstance(d.get("page"), dict):
p = dict(d["page"], v=1)
p["pages"] = {str(k): v for k, v in (p.get("pages") or {}).items()}
if p != {k: v for k, v in (self.page or {}).items() if k != "at"}:
if self.page is not None:
log(f"systemui: frame page {self.page.get('summonKey')} -> {p.get('summonKey')}")
if (self.page or {}).get("steam") is not p.get("steam"):
self.changed.set() # Steam's window shown / hidden: glassd.json and the spec follow
self.page = dict(p, at=int(now * 1000))
self.bridge_values["page"] = self.page
await asyncio.sleep(GEOM_POLL_S)
except asyncio.CancelledError:
raise
except NET_ERRORS as e:
if not self.stopping:
self.note(f"geometry: {e!r}")
except Exception as e: # noqa: BLE001 - see bug()
if not self.stopping:
delay = self.bug("geometry", e)
finally:
self.geom_ok = False
if self.page is not None:
if self.page.get("steam") is False:
self.changed.set() # unknown again: Steam's window is no longer held hidden
self.page = {"v": 1, "at": int(time.time() * 1000), "frameID": None, "activePageID": None,
"summonKey": None, "steam": None, "pages": {}}
self.bridge_values["page"] = self.page
if conn:
await conn.close()
conn = None
if not self.stopping:
await asyncio.sleep(delay)
# ---------------------------------------------------------------- SteamVR page scripts (contract §6)
def vr_paused(self):
"""A lab `--theme off` on a vr: page (lgs_vr.stop) pauses page theming and the page scripts. The
lab's `--theme on` (lgs_vr.apply_once) ends the pause; one older than VR_PAUSE_MAX_S is stale (a step
that turned the theme off and never on again, which left the pause for good): it is removed and
theming and the page scripts resume (REQ C6b->P8: a flagged page script was never installed)."""
try:
age = time.time() - os.stat(VR_PAUSE).st_mtime
except OSError:
if self.vr_pause_since is not None:
log(f"vr: page theming resumed (paused {time.time() - self.vr_pause_since:.0f} s)")
self.vr_pause_since = None
return False
if age > VR_PAUSE_MAX_S:
try:
os.remove(VR_PAUSE)
except OSError:
pass
log(f"vr: a page-theming pause {age:.0f} s old ended (a lab --theme off without its --theme on); "
"theming and page scripts resume")
self.vr_pause_since = None
return False
if self.vr_pause_since is None:
self.vr_pause_since = time.time() - age
log("vr: page theming paused (lab --theme off on a vr: page): page scripts removed with the theme")
return True
async def vr_scripts_for(self, t, scripts):
"""Install, refresh or remove the page scripts of one SteamVR page."""
name = t.get("title") or t.get("url")
# the flags files themselves (a stat, cached by mtime), not the Steam loop's copy: a lab step's
# --flags reaches the page within one poll even while Steam's loop is slow or reconnecting
flags = self.flagreader.read()
st = self.vrx_status.setdefault(name, {})
mine = {n: s for n, s in scripts.items() if s["page"] == name}
want = {n: s for n, s in mine.items() if not s["flag"] or flags.get(s["flag"]) is True}
if not want and not any(v in ("installed", "ok") for v in st.values()):
for n, s in mine.items():
st[n] = "flag-off"
for n in [n for n in st if n not in mine]:
del st[n]
return
r = await self.vr_page_eval(t, f"({VRX_PROBE_JS})({json.dumps({n: s['version'] for n, s in want.items()})})", 10)
probe = json.loads(r) if isinstance(r, str) else {}
extra = [n for n, v in probe.items() if v == "extra"]
if extra:
await self.vr_page_eval(t, f"({VRX_REMOVE_JS})({json.dumps(extra)})", 10)
log(f"vr: {name}: page scripts removed {extra}")
for n in extra:
st[n] = "flag-off" if n in mine else "removed"
for n in [n for n in st if n not in mine and n not in extra]:
del st[n]
for n, s in mine.items():
if n not in want:
st[n] = "flag-off"
for n, s in want.items():
if probe.get(n) == "ok":
st[n] = "ok"
continue
if self.vrx_failed.get((name, n)) == s["version"]:
continue
ctx = {"name": n, "page": name, "version": s["version"], "flags": dict(flags)}
res = await self.vr_page_eval(t, vrx_install_js(n, s["version"], ctx, s["src"]), 15)
st[n] = res if isinstance(res, str) else str(res)
if st[n].startswith("error"):
self.vrx_failed[(name, n)] = s["version"]
self.note(f"vr: {name}: page script {n} failed: {st[n][:160]}")
else:
self.vrx_failed.pop((name, n), None)
log(f"vr: {name}: page script {n} {st[n]} ({s['version']})")
async def vr_scripts_remove_all(self):
loop = asyncio.get_running_loop()
out = {}
try:
if not await loop.run_in_executor(None, lgs_vr.available):
return out
for t in await loop.run_in_executor(None, lgs_vr.targets):
try:
out[t.get("title") or t.get("url")] = await eval_once(
t["webSocketDebuggerUrl"], f"({VRX_REMOVE_JS})(null)", 10)
except Exception: # noqa: BLE001 - page closing
pass
except Exception as e: # noqa: BLE001
log(f"vr: removing page scripts: {e!r}")
self.vrx_status = {}
return out
# ---------------------------------------------------------------- SteamVR page theming
async def vr_list(self):
"""SteamVR's page targets (8090 /json/list) over one kept-alive HTTP
session (no new TCP connection per poll; review R1 m4), or None while
SteamVR's devtools are unreachable."""
import aiohttp
if self.vr_http is None or self.vr_http.closed:
self.vr_http = aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=3))
try:
async with self.vr_http.get(lgs_vr.VR_CDP + "/json/list") as r:
ts = await r.json(content_type=None)
except NET_ERRORS:
return None
return [t for t in ts if isinstance(t, dict) and t.get("type") == "page"
and t.get("webSocketDebuggerUrl")] if isinstance(ts, list) else None
async def vr_page_eval(self, t, expr, timeout=10):
"""Evaluate in a SteamVR page over its kept socket (opened on first
use; dropped when it fails, times out or the page goes). A JS error
keeps the socket."""
tid = t.get("id") or t["webSocketDebuggerUrl"]
c = self.vr_conns.get(tid)
if c is None or c.closed:
c = await CDP("vr:" + str(t.get("title") or tid), t["webSocketDebuggerUrl"]).open()
self.vr_conns[tid] = c
try:
return await c.eval(expr, timeout)
except RuntimeError as e:
if c.closed or not str(e).startswith("JS: "):
self.vr_conns.pop(tid, None)
await c.close()
raise
except BaseException:
self.vr_conns.pop(tid, None)
await c.close()
raise
def vr_scripts_cached(self):
"""vr_scripts(), re-read only when a file in its folders changed."""
sig = []
for folder in VR_SCRIPT_DIRS:
try:
for n in sorted(os.listdir(folder)):
if n.endswith(".js"):
sig.append((folder, n, os.stat(os.path.join(folder, n)).st_mtime_ns))
except OSError:
continue
if sig != self.vrx_sig:
self.vrx_sig, self.vrx_scripts = sig, vr_scripts()
return self.vrx_scripts
async def vr_conns_close(self, keep=()):
for tid in [k for k in self.vr_conns if k not in keep]:
await self.vr_conns.pop(tid).close()
async def vr_theme_loop(self):
sig, full, probe = None, None, None
while not self.stopping:
await asyncio.sleep(lgs_vr.POLL)
if self.vr_paused(): # lab "--theme off" on a vr: page (page scripts stripped with it)
self.vrx_status, self.vr_pages, self.vr_targets = {}, {}, {}
await self.vr_conns_close()
continue
try:
ts = await self.vr_list()
if ts is None:
self.vr_targets = {}
await self.vr_conns_close()
continue
if self.pod: # no page theming: the assPod's page script only
self.vr_targets = {(t.get("title") or t.get("url")): t for t in ts}
scripts = {n: v for n, v in self.vr_scripts_cached().items() if n in POD_SCRIPTS}
await self.vr_conns_close(keep={t.get("id") or t["webSocketDebuggerUrl"] for t in ts})
pages = {}
for t in ts:
name = t.get("title") or t.get("url")
try:
await self.vr_scripts_for(t, scripts)
pages[name] = "pod"
except Exception: # noqa: BLE001 - page reloading or closing
pages[name] = "error"
self.vr_pages = pages
for gone in [n for n in self.vrx_status if n not in pages]:
del self.vrx_status[gone]
continue
s = lgs_vr.signature()
if s != sig:
sig = s
p = lgs_vr.payload()
full = lgs_vr.core_call(p)
probe = (f"(window.__LGS_VR && window.__LGS_VR.version === {json.dumps(p['version'])})"
" ? (window.__LGS_VR.apply() ? 'ok' : 'retry') : 'need'")
self.vr_version = p["version"]
log(f"vr: theme {p['version']} ({len(p['css'])} bytes)")
self.vr_targets = {(t.get("title") or t.get("url")): t for t in ts}
pages = {}
scripts = self.vr_scripts_cached()
await self.vr_conns_close(keep={t.get("id") or t["webSocketDebuggerUrl"] for t in ts})
for t in ts:
name = t.get("title") or t.get("url")
try:
r = await self.vr_page_eval(t, probe, 10)
if r == "need":
r = await self.vr_page_eval(t, full, 30)
log(f"vr: {name}: {r}")
pages[name] = r
if r in ("ok", "applied"): # page scripts go in after the page's theme
await self.vr_scripts_for(t, scripts)
except Exception: # noqa: BLE001 - page reloading or closing
pages[name] = "error"
self.vr_pages = pages
for gone in [n for n in self.vrx_status if n not in pages]:
del self.vrx_status[gone]
except asyncio.CancelledError:
raise
except Exception as e: # noqa: BLE001 - best effort, like lgs-vr
self.note(f"vr: {e!r}")
# ---------------------------------------------------------------- pod mode
async def pod_loop(self):
"""--pod: end once the assPod is away and nothing plays (POD_IDLE_S), when it was never taken out
(POD_FIRST_OPEN_S), or at once after a Steam restart (its pod runtime went with the old page)."""
opened, idle_since = False, None
while not self.stopping:
await asyncio.sleep(1.0)
if self.steam_restarted:
log("pod: Steam restarted; exiting")
self.request_stop("pod: steam restarted", strip_vr=True)
return
p = self.registry.plugins.get("asspod") or {}
st = p["ctx"].state if p.get("ctx") is not None else {}
busy = bool(st.get("open") or st.get("playing") or st.get("starting"))
opened = opened or bool(st.get("open"))
now = time.time()
if busy:
idle_since = None
continue
if not opened:
if now - self.since >= POD_FIRST_OPEN_S:
log(f"pod: the assPod was not taken out within {POD_FIRST_OPEN_S:.0f} s; exiting")
self.request_stop("pod: never opened", strip_vr=True)
return
continue
idle_since = idle_since or now
if now - idle_since >= POD_IDLE_S:
log(f"pod: the assPod has been away with nothing playing for {POD_IDLE_S:.0f} s; exiting")
self.request_stop("pod: idle", strip_vr=True)
return
# ---------------------------------------------------------------- status
async def status_loop(self):
while not self.stopping:
# tune.json written by someone else (`lgs dial`): taken over; our own saves set tune_mtime
tm = _mtime(lgs.TUNE)
if tm != self.tune_mtime and self.tune_save is None:
self.tune_mtime = tm
self.set_tune(lgs.read_tune(), save=False)
m = _mtime(lgs.DIAL)
if m != self.dial_mtime:
self.dial_mtime = m
self.dial_v = lgs.read_dial()
self.write_glassd_json()
try:
write_json_atomic(STATUS, self.status())
except OSError:
pass
await asyncio.sleep(2)
# ---------------------------------------------------------------- lifecycle
def request_stop(self, reason, strip_vr=False, code=0):
"""Start the shutdown once (signal handlers, theme off, fatal errors)."""
if self.stop_task is None:
self.stop_task = asyncio.get_running_loop().create_task(self.stop(reason, strip_vr, code))
return self.stop_task
async def stop(self, reason, strip_vr=False, code=0):
# Cancel the workers first, so a late spec push cannot re-create the
# nodes that teardown removes; teardown uses fresh devtools sessions.
self.stopping = True
self.exit_code = code
log(f"stopping: {reason}")
try: # tells "lgs on" (start()) to wait for us instead of calling us running
st = self.status()
st.update(mode="stopping", stopping=reason)
write_json_atomic(STATUS, st)
except OSError:
pass
self.changed.set()
for t in self.tasks:
t.cancel()
await asyncio.gather(*self.tasks, return_exceptions=True)
await self.teardown(strip_vr)
async def teardown(self, strip_vr=False):
if self.torn:
return
self.torn = True
loop = asyncio.get_running_loop()
try: # the kept SteamVR page sockets and 8090 HTTP session (teardown uses fresh ones)
await self.vr_conns_close()
if self.vr_http is not None:
await self.vr_http.close()
except Exception as e: # noqa: BLE001
log(f"teardown: closing page sockets: {e!r}")
# 0. plugins undo what they did (e.g. a deep link switched SteamVR's
# frame page), then the SteamVR page scripts come out
try:
await asyncio.wait_for(self.registry.stop(), 15)
log("teardown: action plugins stopped")
except Exception as e: # noqa: BLE001
log(f"teardown: plugins: {e!r}")
for t in list(self.action_tasks):
t.cancel()
r0 = await self.vr_scripts_remove_all()
if any(r0.values()):
log(f"teardown: page scripts removed {r0}")
# 1. scene graph: back to Steam's own panels (also in CSS-only mode:
# transform overrides restore with destroy())
try:
if await loop.run_in_executor(None, lgs_vr.available):
t = await self.sysui_target()
r = await eval_once(t["webSocketDebuggerUrl"],
SG_DESTROY_JS, 10)
log(f"teardown: scene graph {r}")
except Exception as e: # noqa: BLE001
log(f"teardown: scene graph: {e!r}")
# 2. Steam: lgs-native off, reporter off, binding removed
try:
t = await self.steam_target()
s = await CDP("steam-teardown", t["webSocketDebuggerUrl"]).open()
try:
r = await s.eval("window.__LGS_NATIVE ? JSON.stringify(window.__LGS_NATIVE.clear()) : 'absent'", 10)
rr = await s.eval(REPORTER_STOP_JS, 10)
for b in (BINDING, ACTION_BINDING):
try:
await s.send("Runtime.removeBinding", {"name": b})
except RuntimeError:
pass
# the bridge learns the daemon is gone at once (not after its TTL)
gone = dict(self.daemon_value(), mode="stopped", native=False, actions=[], steamvr=False, ttlMs=0)
await s.eval(f"({BRIDGE_SET_JS})({json.dumps([['daemon', gone]])})", 5)
# the inert binding functions (both: removeBinding leaves them):
# gone, so callers see no daemon and nothing of ours stays (G-REMOVE)
rb = await s.eval(f"({DELETE_BINDINGS_JS})({json.dumps([BINDING, ACTION_BINDING])})", 5)
if self.pod: # the assPod's own runtime (lgs pod) goes with us; a full one stays
rp = await s.eval(POD_RT_TEARDOWN_JS, 30)
log(f"teardown: pod runtime {rp}")
finally:
await s.close()
log(f"teardown: lgs-native {r}, reporter {rr}, binding globals deleted [{rb}]")
except Exception as e: # noqa: BLE001
log(f"teardown: steam: {e!r}")
# 3. glassd
await self.kill_glassd()
remove_glassd_files()
# 4. SteamVR pages (only when the Steam theme went away)
if strip_vr:
try:
await loop.run_in_executor(None, lgs_vr.strip)
log("teardown: SteamVR pages stripped")
except Exception as e: # noqa: BLE001
log(f"teardown: strip: {e!r}")
try:
st = self.status()
st.update(mode="stopped", stopped=time.time())
write_json_atomic(STATUS, st)
except OSError:
pass
async def run(self):
loop = asyncio.get_running_loop()
for sig in (signal.SIGTERM, signal.SIGINT, signal.SIGHUP):
loop.add_signal_handler(sig, lambda s=sig: self.request_stop(signal.Signals(s).name))
os.makedirs(SHM, exist_ok=True)
log(f"lgs-shell starting (pid {os.getpid()}, " + ("pod mode: the assPod alone, no theme" if self.pod else
(f"native, glassd {self.glassd_path} {' '.join(self.glassd_args)}".rstrip() if self.native_enabled
else f"css only: {self.css_reason}")) + ")")
self.refresh_flags()
self.tasks = [loop.create_task(c) for c in (
self.stall_loop(), self.steam_loop(), self.native_loop(), self.sysui_loop(), self.glassd_loop(),
self.glassd_out_loop(),
self.vr_theme_loop(), self.status_loop(), self.geom_loop(), self.actions_loop())]
if self.pod:
self.tasks.append(loop.create_task(self.pod_loop()))
pending = set(self.tasks)
while self.stop_task is None and pending:
done, pending = await asyncio.wait(pending, return_when=asyncio.FIRST_COMPLETED)
if self.stop_task is not None:
break
for t in done:
if not t.cancelled() and t.exception() is not None: # a bug, not a lost connection
log(f"fatal: {t.exception()!r}")
self.request_stop(f"fatal {t.exception()!r}", code=1)
break
# a loop that ends normally (CSS only, glassd given up) is fine
if self.stop_task is None:
self.request_stop("all loops ended")
await self.stop_task
for c in list(CDP.live): # sockets a cancelled task left open
await c.close()
await asyncio.sleep(0)
log("lgs-shell exited")
return self.exit_code
# ------------------------------------------------------------------ unit control
def user_env():
return lgs_vr.user_env()
def unit_info():
"""(ActiveState, MainPID) of the unit."""
r = subprocess.run(["systemctl", "--user", "show", "-p", "ActiveState", "-p", "MainPID", UNIT],
capture_output=True, text=True, env=user_env())
d = dict(line.split("=", 1) for line in r.stdout.splitlines() if "=" in line)
try:
pid = int(d.get("MainPID") or 0)
except ValueError:
pid = 0
return d.get("ActiveState") or "inactive", pid
def unit_active():
return unit_info()[0] in ("active", "activating", "reloading")
def daemon_argv(pid):
try:
with open(f"/proc/{pid}/cmdline", "rb") as f:
return [a.decode(errors="replace") for a in f.read().split(b"\0") if a]
except OSError:
return []
def daemon_stopping(pid):
st = read_json(STATUS) or {}
return bool(pid) and st.get("pid") == pid and st.get("mode") in ("stopping", "stopped")
def settle(timeout=20.0):
"""Wait while the unit is stopping (deactivating, or its daemon tearing
itself down after the theme went off). Returns (state, pid)."""
end = time.time() + timeout
while True:
state, pid = unit_info()
if not (state == "deactivating" or (state in ("active", "activating") and daemon_stopping(pid))):
return state, pid
if time.time() > end:
subprocess.run(["systemctl", "--user", "stop", UNIT], capture_output=True, env=user_env())
return unit_info()
time.sleep(0.25)
def stay_arg(argv):
return "--stay" in argv
def native_arg(argv):
"""--native: opt in to the native layer; --css: CSS only; neither: None."""
return True if "--native" in argv else (False if "--css" in argv else None)
def glassd_args_arg(argv):
"""--glassd-args "ARGS": extra glassd arguments (tests: --no-feed, --key-prefix)."""
if "--glassd-args" in argv:
return argv[argv.index("--glassd-args") + 1].split()
return None
def glassd_arg(argv):
if "--glassd" in argv:
v = argv[argv.index("--glassd") + 1]
return None if v == "none" else os.path.abspath(v)
return GLASSD_BIN
NATIVE_MODES = ("auto", "on", "off") # start(native=...) takes these (P1 checks for it)
def core_args(argv):
"""A daemon's arguments without the bookkeeping ones (--req, --src)."""
out, skip = [], False
for a in argv:
if skip:
skip = False
continue
if a in ("--req", "--src"):
skip = True
continue
out.append(a)
return out
GLASSD_PREBUILT = os.path.join(lgs.ROOT, "native", "glassd", "prebuilt", "glassd")
def ensure_glassd(path=GLASSD_BIN):
"""The shipped glassd (native/glassd/prebuilt/glassd) in place when no build is there yet, so native
glass works out of the box and a Liquid Glass toggle that runs before a build finishes is never stuck
CSS-only. True when an executable glassd is in place."""
if os.access(path, os.X_OK):
return True
if path != GLASSD_BIN or not os.access(GLASSD_PREBUILT, os.R_OK):
return False
try:
tmp = path + ".tmp"
shutil.copyfile(GLASSD_PREBUILT, tmp)
os.chmod(tmp, 0o755)
os.replace(tmp, path)
log("glassd: no build here; the shipped binary is in place")
return True
except OSError as e:
log(f"glassd: could not put the shipped binary in place: {e}")
return False
def start(glassd=GLASSD_BIN, stay=False, glassd_args=None, native=None, test_report=None, assume_caps=None, pod=False):
"""Start the unit. Also resumes SteamVR page theming that a lab "--theme
off" on a vr: page paused.
native: 'on' / True runs the native layer, 'off' / False CSS only; both
restart a unit that runs otherwise. None or 'auto' keeps a running unit as
it is, whatever its mode (a plain `lgs on` never ends another agent's
native session); a new unit resolves auto (resolve_native: flags.json,
then defaults.json, else CSS only). A unit that is stopping is waited for
and then started fresh. stay=True (lab testing only) keeps it running,
dormant, while the Steam theme is off. test_report (lab) replaces the
reporter with a report JSON file. pod=True (lgs pod, theme off) runs pod
mode: a running pod unit is kept, any other unit is replaced by one; a
running pod unit is replaced by a full one for any other request.
Serialized by START_LOCK: two `lgs on` at once (one lock-free) made the
loser's systemd-run fail with "already loaded" although a unit ran (review
R1 m5); if the lock cannot be had in 60 s, it goes on and treats that
error as running."""
if glassd and glassd != "none":
ensure_glassd(glassd)
try:
lk = FileLock(START_LOCK, 60).__enter__()
except (OSError, TimeoutError):
lk = None
try:
return _start(glassd, stay, glassd_args, native, test_report, assume_caps, pod)
finally:
if lk is not None:
lk.__exit__()
def _start(glassd, stay, glassd_args, native, test_report, assume_caps, pod=False):
if pod:
native = "off"
req = "auto" if native is None else native_word(native)
try:
os.remove(VR_PAUSE)
except OSError:
pass
# the pre-native watcher, if an older lgs started it
subprocess.run(["systemctl", "--user", "stop", lgs_vr.UNIT], capture_output=True, env=user_env())
def daemon_args(nat):
return ["--glassd", (glassd if nat and glassd else "none")] + (["--native"] if nat else []) + \
(["--stay"] if stay else []) + (["--glassd-args", " ".join(glassd_args)] if nat and glassd_args else []) + \
(["--test-report", os.path.abspath(test_report)] if test_report else []) + \
(["--assume-caps", ",".join(assume_caps)] if assume_caps else []) + \
(["--pod"] if pod else [])
state, pid = settle()
if state in ("active", "activating", "reloading"):
argv = daemon_argv(pid)
cur = "--native" in argv
if pod and "--pod" in argv:
return "running (pod)"
was_pod = "--pod" in argv
running = core_args(argv[argv.index("daemon") + 1:]) if "daemon" in argv else None
# an explicit request restarts a unit that runs differently
st = read_json(STATUS) or {}
stay_over = req == "on" and st.get("pid") == pid and \
str((st.get("native") or {}).get("reason") or "").startswith("--stay limit")
if (req == "auto" or running == daemon_args(req == "on")) and not stay_over and not was_pod and not pod:
if cur and st.get("pid") == pid and not (st.get("native") or {}).get("enabled", True):
return f"running (native requested; {st.get('mode')})"
return "running (native)" if cur else "running (css only)"
stop() # (a --stay unit past its limit is restarted for a new explicit native request)
settle()
nat, src, reason = resolve_native(req)
subprocess.run(["systemctl", "--user", "reset-failed", UNIT], capture_output=True, env=user_env())
cmd = ["systemd-run", "--user", "--unit", UNIT, "--collect", "--quiet",
"-p", "KillMode=mixed", "-p", "TimeoutStopSec=15", "-E", "PYTHONDONTWRITEBYTECODE=1",
"--description=Glass Shell: native glass layer + SteamVR page theming (transient)",
"/usr/bin/python3", "-B", "-u", os.path.realpath(__file__), "daemon"] + daemon_args(nat) + \
["--req", req, "--src", src]
r = subprocess.run(cmd, capture_output=True, text=True, env=user_env())
if r.returncode != 0:
if "already loaded" in r.stderr or "already exists" in r.stderr:
# another start won a race (one without the start lock): a unit runs
state, pid = settle()
if state in ("active", "activating", "reloading"):
cur = "--native" in daemon_argv(pid)
return "running (native, started concurrently)" if cur else "running (css only, started concurrently)"
return f"failed: {r.stderr.strip()}"
if pod:
return "started (pod)"
if nat and not (glassd and os.access(glassd, os.X_OK)):
return "started (css only: native requested but no glassd binary)"
return "started (native)" if nat else "started (css only)"
def stop():
"""Stop the unit; its SIGTERM handler removes every node, class and file."""
was = unit_active()
subprocess.run(["systemctl", "--user", "stop", UNIT], capture_output=True, env=user_env())
subprocess.run(["systemctl", "--user", "stop", lgs_vr.UNIT], capture_output=True, env=user_env())
return "stopped" if was else "not running"
def status():
state, pid = unit_info()
out = {"unit": "active" if state in ("active", "activating", "reloading") else state}
st = read_json(STATUS)
if isinstance(st, dict) and (out["unit"] == "active" or st.get("mode") == "stopped"):
age = time.time() - st.get("updated", 0)
out.update(st)
out["statusAgeS"] = round(age, 1)
return out
def brief(st):
"""One-level summary for lgs status."""
if st.get("unit") != "active":
return {"unit": st.get("unit")}
g, s, u = st.get("glassd", {}), st.get("steam", {}), st.get("systemui", {})
a, geo = st.get("actions") or {}, st.get("geom") or {}
return {"unit": "active", "mode": st.get("mode"), "native": st.get("native"), "dormant": st.get("dormant"),
"glassd": {k: g.get(k) for k in ("running", "ready", "pid", "restarts", "fps", "gpu_ms", "surfaces",
"caps")},
"layers": {"source": s.get("source"), "reporter": s.get("reporter"), "surfaces": s.get("surfaces")},
"lgsNative": s.get("lgsNative"),
"sceneGraph": {"items": u.get("items"), "panels": u.get("panels"), "attached": u.get("attached"),
"overrides": (st.get("sgRules") or {}).get("active")},
"steamvrPages": len((st.get("steamvrPages") or {}).get("pages") or {}),
"vrScripts": st.get("vrScripts"),
"bridge": (st.get("bridge") or {}).get("runtime"),
"geom": {k: geo.get(k) for k in ("S", "r", "Hm", "unitM")} if geo else None,
"page": (st.get("page") or {}).get("summonKey"),
"actions": {"types": a.get("types"), "calls": a.get("calls"), "failed": a.get("failed")},
"profile": st.get("profile"),
"errors": (st.get("errors") or [])[-3:]}
# ------------------------------------------------------------------ lab self-tests (contract §11)
TEST_SCRIPT_JS = r"""// lgs_shell.py selftest dm5: a test page script (RAM only; the test removes it)
(function (ctx) {
const el = document.documentElement;
el.dataset.lgsEcho = ctx.version;
return { remove() { delete el.dataset.lgsEcho; }, status() { return { echo: el.dataset.lgsEcho || null }; } };
})
"""
TEST_SCRIPT = "/tmp/lgs/vr-scripts/systemui.p8echo.js"
# the SP §1.2 snippet, with Steam's page found by its summon key (REQ P7->P8: page ids change at run time)
SP12_SNIPPET = ("(()=>{let f=null,p=null; for (const x of FrameStore.frames) { for (const q of x.pages.values()) "
"if (q.m_sSummonOverlayKey==='valve.steam.gamepadui.main') { f=x; p=q; } } "
"const z=p.size, S=DashboardStore.dashboardScale, "
"H0=f.size.mainPanelHeightOverride||1.5; const r=z.latestMeasuredPanelLocalHeight/H0, "
"Hw=z.latestMeasuredPanelWorldHeight; return JSON.stringify({S,H0,r,Hw,unitToM:S*r,"
"mmPerCssPx:1500*Hw/1080,dashDist:DashboardStore.dashboardDistance});})()")
FAKEGLASSD = os.path.join(lgs.ROOT, "native", "spike", "fakeglassd")
class FileLock:
"""flock with a timeout (the lab's lock files; order native -> lab -> lab-vr)."""
def __init__(self, path, timeout):
self.path, self.timeout, self.f = path, timeout, None
def __enter__(self):
import fcntl
os.makedirs(os.path.dirname(self.path), exist_ok=True)
self.f = open(self.path, "a")
end = time.time() + self.timeout
while True:
try:
fcntl.flock(self.f, fcntl.LOCK_EX | fcntl.LOCK_NB)
return self
except OSError:
if time.time() > end:
self.f.close()
raise TimeoutError(f"lock busy: {self.path}")
time.sleep(0.5)
def __exit__(self, *exc):
import fcntl
fcntl.flock(self.f, fcntl.LOCK_UN)
self.f.close()
def _wait(pred, timeout, step=0.25):
end = time.time() + timeout
while True:
v = pred()
if v or time.time() > end:
return v
time.sleep(step)
def _vr_js(page, expr, timeout=10):
for t in lgs_vr.targets():
if t.get("title") == page:
return asyncio.run(eval_once(t["webSocketDebuggerUrl"], expr, timeout))
raise RuntimeError(f"no SteamVR page {page!r}")
def _steam_js(expr, timeout=10):
return lgs.run_js("SharedJSContext", expr, timeout)
def _close(a, b, tol):
try:
return abs(float(a) - float(b)) <= tol
except (TypeError, ValueError):
return False
def _journal(since):
r = subprocess.run(["journalctl", "--user", "-u", UNIT, "--no-pager", "-o", "cat", "--since", since],
capture_output=True, text=True, env=user_env())
return r.stdout.splitlines()
TEST_REPORT = "/tmp/lgs/p8-test-report.json"
TEST_DUMP = "/tmp/lgs/p8-dump"
MAIN_NATIVE_JS = ("(() => { try { for (const p of g_PopupManager.m_mapPopups.values()) if (/^VR_uid/.test(p.m_strName)) "
"return p.window.document.documentElement.classList.contains('lgs-native'); } catch (e) {} return null; })()")
def _main_native():
try:
return _steam_js(MAIN_NATIVE_JS, 5)
except Exception: # noqa: BLE001
return None
def _bindings():
"""typeof the daemon's binding globals in SharedJSContext."""
try:
return json.loads(_steam_js(f"JSON.stringify({{{BINDING}: typeof window.{BINDING}, "
f"{ACTION_BINDING}: typeof window.{ACTION_BINDING}}})", 5))
except Exception as e: # noqa: BLE001
return {"error": repr(e)}
def _alive(pid):
try:
os.kill(int(pid), 0)
return True
except (OSError, TypeError, ValueError):
return False
def _sg_items():
try:
r = _vr_js("systemui", "window.__LGS_SG ? JSON.stringify((s => ({items: s.items, expired: s.expired}))"
"(__LGS_SG.status())) : null", 5)
return json.loads(r) if r else None
except Exception: # noqa: BLE001
return None
def _main_size():
try:
r = _steam_js(MAIN_SIZE_JS, 5)
if isinstance(r, list) and len(r) == 2:
return r
except Exception: # noqa: BLE001
pass
return [1920, 1080]
def _native_start(report, caps=None, gargs=None, glassd=None):
write_json_atomic(TEST_REPORT, report)
print("start native:", start(glassd=glassd or FAKEGLASSD, stay=True, glassd_args=gargs or [], native="on",
test_report=TEST_REPORT, assume_caps=caps), flush=True)
st = _wait(lambda: (lambda s: s if s.get("mode") == "native" else None)(status()), 45, 0.5)
return st or status()
def _report_window(W, H, layers=True):
return {"seq": 1, "dash": True, "surfaces": [
{"name": "main", "overlayKey": STEAM_MAIN_KEY, "visible": True, "texW": W, "texH": H, "radius": 81,
"material": "window", "shapes": [{"x": 0, "y": 0, "w": W, "h": round(H * 656 / 720), "r": 81}],
"layers": [{"id": "p8-cap", "x": round(W * 0.42), "y": round(H * 0.05), "w": 300, "h": 90, "r": 45,
"dz": 0.0271, "material": "liquid"}] if layers else []}]}
def _journal_times(lines, needle):
out = []
for x in lines:
if needle in x:
try:
h, m, s = x[:8].split(":")
out.append(int(h) * 3600 + int(m) * 60 + int(s))
except ValueError:
pass
return out
def _selftest_dm1(check, args):
"""NAT regressions with fakeglassd: freeze (SIGSTOP 15 s), teardown order,
restart backoff and give-up."""
import signal as sg
W, H = _main_size()
st = _native_start(_report_window(W, H))
check("native mode with fakeglassd", st.get("mode") == "native", st.get("mode"))
check("lgs-native on main", _wait(lambda: _main_native() is True, 20, 0.5))
s0 = _sg_items()
check("scene graph built", bool(s0 and s0.get("items")), s0)
# (a) freeze
pid = status().get("pid")
t_off = t_nodes = None
os.kill(pid, sg.SIGSTOP)
t0 = time.time()
try:
while time.time() - t0 < 15:
if t_off is None and _main_native() is False:
t_off = round(time.time() - t0, 1)
if t_nodes is None:
s = _sg_items()
if s is None or not s.get("items") or s.get("expired"):
t_nodes = round(time.time() - t0, 1)
time.sleep(0.25)
finally:
os.kill(pid, sg.SIGCONT)
t1 = time.time()
t_nb = t_sb = None
while time.time() - t1 < 15 and (t_nb is None or t_sb is None):
si = _sg_items() or {}
if t_sb is None and (si.get("items") or 0) > 0 and si.get("expired") is False: # rebuilt, not just kept
t_sb = round(time.time() - t1, 1)
if t_nb is None and _main_native() is True:
t_nb = round(time.time() - t1, 1)
time.sleep(0.1)
back = t_nb is not None and t_sb is not None
t_back = max(t_nb, t_sb) if back else None
print(f"NOTE after SIGCONT: nodes back {t_sb} s, lgs-native back {t_nb} s", flush=True)
check("freeze: lgs-native off within 4.5 s", t_off is not None and t_off <= 4.5, t_off)
check("freeze: nodes off between 11 and 14 s", t_nodes is not None and 11 <= t_nodes <= 14, t_nodes)
check("freeze: both back within 3 s of SIGCONT", bool(back) and t_back <= 3, t_back)
check("freeze: lgs-native back within 1.5 s of SIGCONT (NAT: about 1 s)", t_nb is not None and t_nb <= 1.5, t_nb)
# (b) teardown order
since = time.strftime("%Y-%m-%d %H:%M:%S")
time.sleep(1)
print("stop:", stop(), flush=True)
lines = _journal(since)
marks = ["stopping:", "teardown: action plugins stopped", "teardown: scene graph", "teardown: lgs-native",
"glassd: stopped", "lgs-shell exited"]
pos = [next((i for i, x in enumerate(lines) if m in x), -1) for m in marks]
check("teardown order: plugins, scene graph, lgs-native + reporter, glassd, exit",
all(p >= 0 for p in pos) and pos == sorted(pos), list(zip(marks, pos)))
check("after stop: no lgs-native, no scene graph, no glassd files",
_main_native() is False and not (_sg_items() or {}).get("items")
and not os.path.exists(GLASSD_JSON) and not os.path.exists(GLASSD_OUT),
f"{_main_native()} {_sg_items()} {os.path.exists(GLASSD_JSON)}")
gl = _bindings()
check("after stop: no lgsLayers / lgsAction global in Steam (G-REMOVE)", gl == {"lgsLayers": "undefined",
"lgsAction": "undefined"}, gl)
# (c) restart backoff and give-up (5 crashes in 120 s)
st = _native_start(_report_window(W, H))
check("native again", st.get("mode") == "native", st.get("mode"))
since = time.strftime("%Y-%m-%d %H:%M:%S")
time.sleep(1)
for i in range(5):
g = (status().get("glassd") or {})
gp = g.get("pid")
if not g.get("running") or not gp:
gone = _wait(lambda: (status().get("glassd") or {}).get("running"), 40, 0.5)
gp = (status().get("glassd") or {}).get("pid")
if not gone:
break
try:
os.kill(gp, sg.SIGKILL)
except OSError:
pass
if i < 4:
_wait(lambda: (lambda g2: g2.get("running") and g2.get("pid") != gp)(status().get("glassd") or {}), 40, 0.5)
gave_up = _wait(lambda: (status().get("mode") or "").startswith("css-only (glassd gave up"), 30, 0.5)
lines = _journal(since)
ex = _journal_times(lines, "glassd: exited with")
stt = _journal_times(lines, "glassd: started pid")
delays = [b - a for a, b in zip(ex, stt[1:] if stt and stt[0] < (ex[0] if ex else 0) else stt)][:4]
check("restart backoff 2, 4, 8, 16 s (±1.5)", len(delays) == 4 and all(abs(d - w) <= 1.5 for d, w in
zip(delays, (2, 4, 8, 16))), delays)
check("gives up after 5 crashes in 120 s: CSS only", bool(gave_up), status().get("mode"))
check("after give-up: lgs-native off, nodes gone, glassd files gone",
_wait(lambda: _main_native() is False and not (_sg_items() or {}).get("items")
and not os.path.exists(GLASSD_JSON), 10, 0.5),
f"{_main_native()} {_sg_items()} {os.path.exists(GLASSD_JSON)}")
def _selftest_freeze(check, args):
"""A freeze of the daemon itself with the real glassd (--no-feed: no camera),
which rewrites glassd-out.json every 2 s (REQ P7->P8, SG-6): after SIGCONT
glassd is not taken for hung (same pid, no restart), systemui gets its
heartbeat first, nodes and lgs-native are back within 1 s."""
import signal as sg
W, H = _main_size()
st = _native_start(_report_window(W, H), glassd=GLASSD_BIN, gargs=["--no-feed"])
check("native mode with the real glassd (--no-feed)", st.get("mode") == "native", st.get("mode"))
check("lgs-native on main", _wait(lambda: _main_native() is True, 20, 0.5))
g0 = (status().get("glassd") or {})
check("glassd writes 'updated' (so its silence is watched)", "updated" in (read_json(GLASSD_OUT) or {}),
sorted((read_json(GLASSD_OUT) or {}).keys())[:12])
since = time.strftime("%Y-%m-%d %H:%M:%S")
pid = status().get("pid")
os.kill(pid, sg.SIGSTOP)
try:
nodes_off = _wait(lambda: (lambda s: s is None or not s.get("items") or s.get("expired"))(_sg_items()), 15, 0.25)
time.sleep(max(0, 15 - 12.5))
finally:
os.kill(pid, sg.SIGCONT)
t1 = time.time()
t_nb = t_sb = None
while time.time() - t1 < 10 and (t_nb is None or t_sb is None):
si = _sg_items() or {}
if t_sb is None and (si.get("items") or 0) > 0 and si.get("expired") is False:
t_sb = round(time.time() - t1, 2)
if t_nb is None and _main_native() is True:
t_nb = round(time.time() - t1, 2)
time.sleep(0.05)
check("freeze: nodes gone (watchdog) while we were stopped", bool(nodes_off))
check("after SIGCONT: nodes back within 1 s", t_sb is not None and t_sb <= 1.0, t_sb)
check("after SIGCONT: lgs-native back within 1.5 s", t_nb is not None and t_nb <= 1.5, t_nb)
time.sleep(8) # past glassd's stale limit (6 s)
g1 = (status().get("glassd") or {})
lines = _journal(since)
check("glassd not taken for hung: same pid, no restart", g1.get("pid") == g0.get("pid")
and g1.get("restarts") == g0.get("restarts") and not any("hung?" in x for x in lines),
(g0.get("pid"), g1.get("pid"), [x for x in lines if "hung" in x][:2]))
check("the stall was noticed and systemui got its heartbeat first",
any("event loop stalled" in x for x in lines) and any("heartbeat sent first" in x for x in lines),
[x for x in lines if "stall" in x][:3])
st = status()
check("still native 8 s later", st.get("mode") == "native" and _main_native() is True, st.get("mode"))
def _selftest_dm2(check, args):
"""glassd.json v3: plates, holes, tints, masks, coverDz, unitM, materialize
and fades, with fakeglassd (and --assume-caps when its caps lack them)."""
W, H = _main_size()
rep = {"seq": 1, "v": 3, "dash": True, "profile": "default", "roomDim": 0.3,
"masks": [{"O": [-0.6, 1.4, -1.2], "U": [0.2, 0, 0], "V": [0, -0.5, 0]}],
"surfaces": [{
"name": "main", "overlayKey": STEAM_MAIN_KEY, "visible": True, "texW": W, "texH": H, "radius": 81,
"material": "window", "mode": "windowless", "shapes": [], "coverDz": 0.001,
"masks": [{"x": -300, "y": 0, "w": 200, "h": H, "dz": 0.03}],
"plates": [{"id": "p8-disc", "x": 300, "y": 200, "w": 180, "h": 180, "r": 90, "material": "liquid"},
{"id": "p8-card", "x": 600, "y": 420, "w": 600, "h": 300, "r": 54, "material": "thick",
"fill": "rgba(0, 0, 0, 0.14)"},
{"id": "p8-dim", "x": 1300, "y": 200, "w": 300, "h": 200, "r": 40, "material": "dim"}],
"layers": [{"id": "p8-play", "x": 320, "y": 820, "w": 420, "h": 120, "r": 60, "dz": 0.0407,
"material": "liquid", "tint": "rgb(48 199 89 / 0.55)",
"hole": {"fill": "rgb(18 20 26 / 0.9)",
"edges": {"top": "rgb(80 52 37)", "left": ["rgb(70 50 40)", "nope" * 20,
[0.3, 0.2, 0.15]], "right": []}}},
{"id": "p8-info", "x": 780, "y": 830, "w": 100, "h": 100, "r": 50, "dz": 0.0271,
"material": "none", "hole": True, "tint": [0.1, 0.45, 1.0, 0.5]},
{"id": "p8-roomdim", "x": 1700, "y": 900, "w": 64, "h": 64, "r": 0, "dz": -0.05,
"material": "dim", "fill": [0, 0, 0, 0.4]}]},
# the keyboard surface (K-G6 platter behind the keys; K-G2 scale), hidden: glassd.json only
{"name": "keyboard", "overlayKey": "valve.steam.gamepadui.keyboard", "visible": False,
"texW": 1500, "texH": 540, "radius": 48, "material": "panel",
"shapes": [{"x": 0, "y": 0, "w": 1500, "h": 540, "r": 48}], "coverDz": -0.027, "scaleFrom": "overlay",
"layers": []}]}
caps = ["plates", "holes", "tint", "masks", "coverDz", "none", "offset", "unitM", "dim", "scaleFrom", "roomDim",
"dimSlab", "holeEdges"]
os.makedirs(TEST_DUMP, exist_ok=True) # fakeglassd writes into it, it does not create it
st = _native_start(rep, caps=caps, gargs=["--dump", TEST_DUMP])
check("native mode with fakeglassd", st.get("mode") == "native", st.get("mode"))
out = read_json(GLASSD_OUT) or {}
print(f"NOTE glassd-out: version {out.get('version')} caps {out.get('caps')} "
f"(assumed for the writer: {caps})", flush=True)
g = _wait(lambda: (lambda d: d if d and any(s.get("plates") for s in d.get("surfaces", [])) else None)(
read_json(GLASSD_JSON)), 10, 0.25) or {}
m = next((s for s in g.get("surfaces", []) if s.get("name") == "main"), {})
check("plates carried, in order", [p.get("id") for p in m.get("plates") or []] == ["p8-disc", "p8-card", "p8-dim"],
m.get("plates"))
check("new plates and slabs materialize", all(x.get("appear") == "materialize"
for x in (m.get("plates") or []) + (m.get("slabs") or [])))
sl = {s.get("id"): s for s in m.get("slabs") or []}
check("slab tint (CSS string) and hole fill carried",
(sl.get("p8-play") or {}).get("tint") == "rgb(48 199 89 / 0.55)"
and ((sl.get("p8-play") or {}).get("hole") or {}).get("fill") == "rgb(18 20 26 / 0.9)", sl.get("p8-play"))
check("hole.edges carried and cleaned (REQ P9->P8: bad sample and empty edge dropped)",
((sl.get("p8-play") or {}).get("hole") or {}).get("edges") == {
"top": "rgb(80 52 37)", "left": ["rgb(70 50 40)", [0.3, 0.2, 0.15]]}, (sl.get("p8-play") or {}).get("hole"))
check("material none slab with hole: true and a colour list tint",
(sl.get("p8-info") or {}).get("material") == "none" and (sl.get("p8-info") or {}).get("hole") is True
and (sl.get("p8-info") or {}).get("tint") == [0.1, 0.45, 1, 0.5], sl.get("p8-info"))
check("surface masks and coverDz", m.get("masks") == [{"x": -300, "y": 0, "w": 200, "h": H, "dz": 0.03}]
and m.get("coverDz") == 0.001, (m.get("masks"), m.get("coverDz")))
check("top-level world masks, unitM = live geometry, reduceMotion, roomDim",
g.get("masks") == [{"O": [-0.6, 1.4, -1.2], "U": [0.2, 0, 0], "V": [0, -0.5, 0]}]
and g.get("unitM") == (status().get("geom") or {}).get("unitM") and g.get("reduceMotion") is False
and g.get("roomDim") == 0.3, {k: g.get(k) for k in ("masks", "unitM", "reduceMotion", "roomDim")})
check("dim slab (room-dim cell) with its fill", (sl.get("p8-roomdim") or {}).get("material") == "dim"
and (sl.get("p8-roomdim") or {}).get("fill") == [0, 0, 0, 0.4], sl.get("p8-roomdim"))
kb = next((s for s in g.get("surfaces", []) if s.get("name") == "keyboard"), {})
check("keyboard surface: coverDz -0.027, scaleFrom overlay, cover shape, hidden",
kb.get("coverDz") == -0.027 and kb.get("scaleFrom") == "overlay" and kb.get("visible") is False
and len(kb.get("shapes") or []) == 1, kb)
out = read_json(GLASSD_OUT) or {}
om = (out.get("surfaces") or {}).get("main") or {}
check("fakeglassd drew the plates (glassd-out plates)", om.get("plates") == ["p8-disc", "p8-card", "p8-dim"]
and (om.get("counts") or {}).get("holes") == 2, {k: om.get(k) for k in ("plates", "counts")})
sp = _wait(lambda: (lambda s: s if s and s.get("items") else None)(_sg_items()), 10, 0.5)
check("scene graph built from the v3 spec", bool(sp), sp)
import shutil
try: # the first scene's textures (procedural: fakeglassd paints no camera image)
for fn in os.listdir(TEST_DUMP):
if fn.endswith(".png") and not fn.startswith("first-"):
shutil.copy(os.path.join(TEST_DUMP, fn), os.path.join(TEST_DUMP, "first-" + fn))
except OSError:
pass
# fades: the disc plate and the play slab leave
time.sleep(1.0)
rep2 = json.loads(json.dumps(rep))
rep2["seq"] = 2
s2 = rep2["surfaces"][0]
s2["plates"] = [p for p in s2["plates"] if p["id"] != "p8-disc"]
s2["layers"] = [x for x in s2["layers"] if x["id"] != "p8-play"]
t0 = time.time()
write_json_atomic(TEST_REPORT, rep2)
def item(d, kind, iid):
mm = next((s for s in (d or {}).get("surfaces", []) if s.get("name") == "main"), {})
return next((x for x in mm.get(kind) or [] if x.get("id") == iid), None)
faded = _wait(lambda: (lambda d: d if (item(d, "plates", "p8-disc") or {}).get("phase") == 0
and (item(d, "slabs", "p8-play") or {}).get("phase") == 0 else None)(read_json(GLASSD_JSON)),
6, 0.02) # the test report is re-read on the 1 s Steam tick; a busy Steam slows it
t_faded = time.time()
check("leaving plate and slab written with phase 0", bool(faded),
f"{round(t_faded - t0, 2)} s after the report changed (--test-report is re-read once a second)"
if faded else str(read_json(GLASSD_JSON))[:300])
gone = _wait(lambda: (lambda d: d if item(d, "plates", "p8-disc") is None and item(d, "slabs", "p8-play") is None
and item(d, "plates", "p8-card") else None)(read_json(GLASSD_JSON)), 4, 0.02)
t_gone = round(time.time() - t_faded, 2)
check("then removed after the 350 ms out-ramp (≤ 0.35 + 0.3 s)", bool(gone) and 0.3 <= t_gone <= 0.65, t_gone)
try:
pngs = sorted(os.listdir(TEST_DUMP))
except OSError:
pngs = []
print(f"NOTE fakeglassd dumps (procedural, no camera): {pngs}"
+ ("" if "--keep-dump" in args else " (deleted after the test; --keep-dump keeps them)"), flush=True)
check("fakeglassd --dump wrote textures", bool(pngs), pngs)
FM_DUMP_JS = ("(async()=>{const d=await OverlayStore.DumpLaserOverlays();const o=d.overlays||{};"
"const k=Object.keys(o).find(n=>/frame\\.menu/.test(n));const p=k&&o[k].scene_graph_panel;"
"return JSON.stringify(p?{fWidth:p.fWidth,fHeight:p.fHeight}:null)})()")
def _selftest_dm3(check, args):
"""Geometry against the SP §1.2 snippet, and the frame menu's panel against
a DumpLaserOverlays of our own: atomic (review R1 m3), i.e. the daemon's own
dump came after ours and the panel did not change between our two dumps.
Other agents restart the daemon (native-session, lgs on): a run during
which the daemon's pid changed is repeated (up to 3 times)."""
for run in range(3):
res = []
pid0 = _wait(lambda: (lambda s: s.get("pid") if s.get("geom") and s.get("geomDumpAt") else None)(status()),
20, 0.5)
_selftest_dm3_once(lambda n, ok, info="": res.append((n, ok, info)))
if pid0 and status().get("pid") == pid0:
break
print(f"NOTE the daemon was restarted during the run (pid {pid0} -> {status().get('pid')}); again", flush=True)
for r in res:
check(*r)
def _selftest_dm3_once(check):
st = status()
g = st.get("geom") or {}
ref = json.loads(_vr_js("systemui", SP12_SNIPPET))
for k, rk, tol in (("S", "S", 6e-5), ("H0", "H0", 6e-5), ("r", "r", 6e-5), ("Hm", "Hw", 6e-5),
("unitM", "unitToM", 6e-5), ("mmPerCssPx", "mmPerCssPx", 6e-5), ("dashDist", "dashDist", 6e-4)):
check(f"geom.{k} = snippet {rk}", _close(g.get(k), ref.get(rk), tol), f"{g.get(k)} vs {ref.get(rk)}")
got = None
for attempt in range(3):
d0 = json.loads(_vr_js("systemui", FM_DUMP_JS, 15))
t0 = time.time()
s1 = _wait(lambda: (lambda x: x if (x.get("geomDumpAt") or 0) > t0 else None)(status()), GEOM_DUMP_S + 6, 0.5)
d1 = json.loads(_vr_js("systemui", FM_DUMP_JS, 15))
if s1 and d0 == d1:
got = (s1, d1, round(s1["geomDumpAt"] - t0, 1), attempt + 1)
break
print(f"NOTE frame menu panel changed during the step ({d0} -> {d1}) or no daemon dump ({bool(s1)}); again",
flush=True)
if got is None:
check("frame menu stable over one daemon dump (3 tries)", False, "panel kept changing or no daemon dump")
elif got[1] is None:
check("frame menu present in DumpLaserOverlays", False, "no frame menu panel")
else:
s1, dump, lag, tries = got
fm = (s1.get("geom") or {}).get("frameMenu") or {}
print(f"NOTE daemon dump {lag} s after ours (try {tries}); our dumps before and after it equal", flush=True)
check("frameMenu.fHeight = DumpLaserOverlays (same step)", _close(fm.get("fHeight"), dump.get("fHeight"), 6e-5),
f"{fm.get('fHeight')} vs {dump.get('fHeight')}")
check("frameMenu.fWidth = DumpLaserOverlays (same step)", _close(fm.get("fWidth"), dump.get("fWidth"), 6e-5),
f"{fm.get('fWidth')} vs {dump.get('fWidth')}")
check("frameMenu.clipCssH = fHeight / mpp / 1.5",
_close(fm.get("clipCssH"), float(dump["fHeight"]) / float(fm.get("mpp") or 1) / 1.5, 0.11),
f"{fm.get('clipCssH')} mpMm {fm.get('mpMm')}")
st = s1
steam = json.loads(_vr_js("systemui", "JSON.stringify((()=>{const sf=FrameStore.frames.find(x=>x.m_mapPages&&"
"[...x.m_mapPages.values()].some(p=>p.m_sSummonOverlayKey==="
"'valve.steam.gamepadui.main'));return sf&&sf.activePage&&"
"sf.activePage.m_sSummonOverlayKey})())"))
page = st.get("page") or {}
check("page.summonKey = FrameStore", page.get("summonKey") == steam, f"{page.get('summonKey')} vs {steam}")
check("page.steam", page.get("steam") == (steam == STEAM_MAIN_KEY))
g = st.get("geom") or {}
rt = _steam_js("(() => { const R = window.__LGS_RT, b = R && R.bridge; if (!b || !b.get) return null;"
" return JSON.stringify({geom: b.get('geom'), page: b.get('page'), daemon: b.get('daemon')}); })()")
if rt is None:
print("NOTE bridge: no __LGS_RT.bridge in Steam (runtime off); bridge delivery not checked", flush=True)
else:
b = json.loads(rt)
check("bridge geom = published geom", (b.get("geom") or {}).get("r") == g.get("r")
and (b.get("geom") or {}).get("S") == g.get("S")
and ((b.get("geom") or {}).get("frameMenu") or {}).get("fWidth") == (g.get("frameMenu") or {}).get("fWidth"),
str(b.get("geom"))[:160])
check("bridge page = published page", (b.get("page") or {}).get("summonKey") == page.get("summonKey"))
d = b.get("daemon") or {}
check("bridge daemon heartbeat fresh", d and time.time() * 1000 - d.get("at", 0) < d.get("ttlMs", 0),
str(d)[:160])
P8_FIX_JS = r"""
(function (on) {
for (const p of g_PopupManager.m_mapPopups.values()) {
if (!/^VR_uid/.test(p.m_strName)) continue;
const d = p.window.document;
let f = d.getElementById('lgs-p8-fix');
if (f) f.remove();
if (!on) return 'removed';
f = d.createElement('div');
f.id = 'lgs-p8-fix';
f.setAttribute('data-lgs-plate', 'liquid');
f.setAttribute('data-lgs-plate-id', 'p8-live');
f.style.cssText = 'position:fixed;left:220px;top:180px;width:160px;height:160px;border-radius:80px;' +
'pointer-events:none;z-index:2147483647;background:rgba(255,255,255,0.08)';
d.body.appendChild(f);
return 'added';
}
return 'no main window';
})
"""
P8_FIX_STATE_JS = ("(() => { for (const p of g_PopupManager.m_mapPopups.values()) if (/^VR_uid/.test(p.m_strName)) {"
" const f = p.window.document.getElementById('lgs-p8-fix'); return JSON.stringify({fix: !!f,"
" ack: !!(f && f.hasAttribute('data-lgs-plate-ack'))}); } return null; })()")
def _selftest_plates(check, args):
"""The real reporter (P6 v3) with fakeglassd: options passed, a plate from a
fixture element goes to glassd.json, is acked (data-lgs-plate-ack), fades
out when the element goes; motion.js is prepended to lgs_sg.js."""
print("start native (reporter, fakeglassd):",
start(glassd=FAKEGLASSD, stay=True, glassd_args=[], native="on"), flush=True)
st = _wait(lambda: (lambda s: s if s.get("mode") == "native" and (s.get("steam") or {}).get("reports") else None)(
status()), 45, 0.5) or status()
check("native with the real reporter reporting", st.get("mode") == "native"
and (st.get("steam") or {}).get("source") == "reporter", (st.get("mode"), (st.get("steam") or {}).get("reporter")))
# the reporter deletes __LGS_LAYERS_OPTS once read: its status() shows what it got
ro = _steam_js("(() => { const L = window.__LGS_LAYERS, s = L && L.status ? L.status() : {};"
" return JSON.stringify({v: s.v, fragments: s.fragments, profile: s.profile, geom: s.geom,"
" ackMode: s.ackMode}); })()")
ro = json.loads(ro) if ro else {}
frags = sorted(os.path.basename(f) for f in json_files(LAYERS_DIR))
got = ro.get("fragments") or [] # a fragment whose flag is off may be left out
check("reporter v3 got theme/layers fragments, profile, geom (= ours) and ackMode", (ro.get("v") or 0) >= 3
and got and set(got) <= set(frags) and frags[0] in got and ro.get("profile") in ("default", "wearer")
and (ro.get("geom") or {}).get("src") in ("opts", "bridge") # both are ours; P6 prefers the live bridge
and _close((ro.get("geom") or {}).get("S"), (status().get("geom") or {}).get("S"), 1e-4)
and ro.get("ackMode") is True, (ro, frags))
mot = _vr_js("systemui", "typeof globalThis.__LGS_MOTION")
check("motion.js prepended to lgs_sg.js (__LGS_MOTION in vr:systemui)", mot == "object", mot)
try:
check("plate fixture added to the main window", _steam_js(f"({P8_FIX_JS})(true)") == "added")
def plate(d):
m = next((s for s in (d or {}).get("surfaces", []) if s.get("name") == "main"), {})
return next((p for p in m.get("plates") or [] if p.get("id") == "p8-live"), None)
t0 = time.time()
p = _wait(lambda: plate(read_json(GLASSD_JSON)), 6, 0.1)
check("its plate reaches glassd.json (materialize)", p and p.get("appear") == "materialize"
and p.get("w", 0) > 200, p)
drawn = _wait(lambda: "p8-live" in (((read_json(GLASSD_OUT) or {}).get("surfaces") or {}).get("main") or {})
.get("plates", []), 5, 0.1)
check("fakeglassd draws it (glassd-out plates)", bool(drawn))
ack = _wait(lambda: (json.loads(_steam_js(P8_FIX_STATE_JS) or "{}") or {}).get("ack"), 6, 0.2)
t_ack = round(time.time() - t0, 2)
check("plate acked: data-lgs-plate-ack on the element (after its 250 ms ramp + 350 ms)", bool(ack)
and t_ack >= 0.6, t_ack)
pa = _wait(lambda: (lambda m: m if any("p8-live" in v for v in m.values()) else None)(
(status().get("steam") or {}).get("plateAcks") or {}), 5, 0.5)
check("status lists the plate ack", bool(pa), pa)
_steam_js(f"({P8_FIX_JS})(false)")
faded = _wait(lambda: (plate(read_json(GLASSD_JSON)) or {}).get("phase") == 0, 4, 0.05)
check("element removed: its plate fades (phase 0)", bool(faded))
gone = _wait(lambda: plate(read_json(GLASSD_JSON)) is None, 3, 0.05)
check("then leaves glassd.json", bool(gone))
finally:
_steam_js(f"({P8_FIX_JS})(false)")
TEST_PLUGIN_PY = '''# lgs_shell.py selftest dm4: a RAM test plugin (kind read; the test removes it)
ACTIONS = {"p8.probe": {"args": {"n": {"type": "int", "min": 0, "max": 99}}, "sources": ["main"], "kind": "read",
"rate": 0}}
async def run(ctx, type, args):
return {"probe": args["n"] + 1, "plugin": ctx.name}
'''
TEST_PLUGIN_NAV_PY = '''# lgs_shell.py selftest dm4: a test plugin that asks for kind nav (must be refused from the RAM folder)
ACTIONS = {"p8.go": {"args": {}, "kind": "nav"}}
async def run(ctx, type, args):
return 1
'''
def _selftest_dm4(check, args):
"""Actions through the binding: echo, unknown type, wrong source, bad args,
rate, all logged; then a plugin file loaded live from the RAM test folder
(TEST_EXT_DIR), called, refused when it asks for kind nav, unloaded when
its file goes (review R1 m8)."""
if _steam_js("typeof window.lgsAction") != "function":
check("lgsAction binding present", False, "typeof window.lgsAction != function")
return
n = os.urandom(3).hex()
def call(cid, obj):
return _steam_js(f"(window.lgsAction(JSON.stringify({json.dumps(dict(obj, id=cid))})), 0)")
call(f"t-echo-{n}", {"type": "echo", "args": {"text": "hi"}, "src": "main"})
time.sleep(0.3)
call(f"t-unknown-{n}", {"type": "nope.nope", "args": {}, "src": "main"})
call(f"t-src-{n}", {"type": "echo.main", "args": {}, "src": "bar"})
call(f"t-args-{n}", {"type": "echo", "args": {"text": "x" * 300}, "src": "main"})
time.sleep(0.3)
_steam_js(f"(window.lgsAction(JSON.stringify({json.dumps({'id': 't-rate1-' + n, 'type': 'echo', 'args': {}, 'src': 'main'})})),"
f" window.lgsAction(JSON.stringify({json.dumps({'id': 't-rate2-' + n, 'type': 'echo', 'args': {}, 'src': 'main'})})), 0)")
time.sleep(2.6)
st = status()
reps = {r.get("id"): r for r in (st.get("actions") or {}).get("replies") or []}
rej = {r.get("id"): r for r in (st.get("actions") or {}).get("rejected") or []}
exp = {f"t-echo-{n}": None, f"t-unknown-{n}": "unknown-type", f"t-src-{n}": "bad-source",
f"t-args-{n}": "bad-args", f"t-rate1-{n}": None, f"t-rate2-{n}": "rate"}
for cid, err in exp.items():
r = reps.get(cid) or {}
if err is None:
check(f"{cid.rsplit('-', 1)[0]} answered ok", r.get("ok") is True, str(r))
else:
check(f"{cid.rsplit('-', 1)[0]} rejected {err} and logged",
r.get("ok") is False and r.get("error") == err and (rej.get(cid) or {}).get("error") == err,
f"{r} / {rej.get(cid)}")
bridge_js = ("(() => { const R = window.__LGS_RT, b = R && R.bridge; return b && b.get ?"
" JSON.stringify(b.get('reply')) : null; })()")
last = _steam_js(bridge_js)
if last is None:
print("NOTE bridge: no __LGS_RT.bridge in Steam (runtime off); replies checked in shell.json only",
flush=True)
else:
lr = json.loads(last) or {}
check("bridge carries the last reply", lr.get("id") == f"t-rate2-{n}" and lr.get("error") == "rate",
str(lr)[:160])
lines = [x for x in _journal("-2min") if n in x]
check("every call logged in the journal", len(lines) >= 6, f"{len(lines)} lines")
# a plugin file, live: the running daemon scans TEST_EXT_DIR (RAM) once a second
plug, nav = os.path.join(TEST_EXT_DIR, "p8probe.py"), os.path.join(TEST_EXT_DIR, "p8nav.py")
os.makedirs(TEST_EXT_DIR, exist_ok=True)
try:
with open(plug, "w", encoding="utf-8") as f:
f.write(TEST_PLUGIN_PY)
with open(nav, "w", encoding="utf-8") as f:
f.write(TEST_PLUGIN_NAV_PY)
a = _wait(lambda: (lambda x: x if "p8.probe" in (x.get("types") or []) and "test:p8nav" in (x.get("failed") or {})
else None)(status().get("actions") or {}), 6, 0.5) or status().get("actions") or {}
check("plugin file loaded live by the running daemon (p8.probe)", "p8.probe" in (a.get("types") or []),
a.get("types"))
check("a RAM test plugin asking for kind nav is refused", "test:p8nav" in (a.get("failed") or {})
and "p8.go" not in (a.get("types") or []), (a.get("failed") or {}).get("test:p8nav"))
call(f"t-plug-{n}", {"type": "p8.probe", "args": {"n": 41}, "src": "main"})
r = _wait(lambda: {x.get("id"): x for x in (status().get("actions") or {}).get("replies") or []}
.get(f"t-plug-{n}"), 5, 0.5) or {}
check("plugin call answered ok", r.get("ok") is True, r)
last = _steam_js(bridge_js)
if last is not None:
lr = json.loads(last) or {}
check("plugin result on the bridge: {probe: 42, plugin: test:p8probe}", lr.get("id") == f"t-plug-{n}"
and (lr.get("result") or {}).get("probe") == 42
and (lr.get("result") or {}).get("plugin") == "test:p8probe", str(lr)[:160])
finally:
for p in (plug, nav):
try:
os.remove(p)
except OSError:
pass
a = _wait(lambda: (lambda x: x if "p8.probe" not in (x.get("types") or []) and "test:p8nav" not in
(x.get("failed") or {}) else None)(status().get("actions") or {}), 6, 0.5)
check("plugin unloaded when its file goes", bool(a), (status().get("actions") or {}).get("types"))
def _selftest_dm5(check, args):
"""A SteamVR page script: injected only into its page, listed, removed when
its file goes; with --stop also removed on daemon stop (then restarted)."""
os.makedirs(os.path.dirname(TEST_SCRIPT), exist_ok=True)
try:
with open(TEST_SCRIPT, "w", encoding="utf-8") as f:
f.write(TEST_SCRIPT_JS)
ver = hashlib.sha1(TEST_SCRIPT_JS.encode()).hexdigest()[:10]
probe = ("JSON.stringify({vrx: !!(window.__LGS_VRX && window.__LGS_VRX.p8echo), "
"attr: document.documentElement.dataset.lgsEcho || null})")
got = _wait(lambda: (lambda d: d if d["vrx"] else None)(json.loads(_vr_js("systemui", probe))), 8)
check("injected into vr:systemui", got and got.get("attr") == ver, str(got))
st = _wait(lambda: (lambda s: s if s.get("p8echo") in ("installed", "ok") else None)(
(status().get("vrScripts") or {}).get("systemui") or {}), 5) or {}
check("status lists it", st.get("p8echo") in ("installed", "ok"), str(st))
others = [t.get("title") for t in lgs_vr.targets() if t.get("title") != "systemui"]
leak = [p for p in others if _vr_js(p, "!!(window.__LGS_VRX && window.__LGS_VRX.p8echo)")]
check("only in matching pages", not leak, str(leak))
os.remove(TEST_SCRIPT)
gone = _wait(lambda: (lambda d: d if not d["vrx"] and not d["attr"] else None)(
json.loads(_vr_js("systemui", probe))), 8)
check("removed when its file goes", bool(gone), str(gone))
if "--stop" in args:
with open(TEST_SCRIPT, "w", encoding="utf-8") as f:
f.write(TEST_SCRIPT_JS)
got = _wait(lambda: (lambda d: d if d["vrx"] else None)(json.loads(_vr_js("systemui", probe))), 8)
check("re-injected", bool(got), str(got))
print("stop:", stop(), flush=True)
after = json.loads(_vr_js("systemui", probe))
check("removed on daemon stop", not after["vrx"] and not after["attr"], str(after))
gl = _bindings()
check("after stop: no lgsLayers / lgsAction global in Steam (G-REMOVE)",
gl == {BINDING: "undefined", ACTION_BINDING: "undefined"}, gl)
os.remove(TEST_SCRIPT)
print("start:", start(native="auto"), flush=True)
finally:
try:
os.remove(TEST_SCRIPT)
except OSError:
pass
def _selftest_grace(check, args, nat):
"""Theme off without lgs off: dormant (status mode 'dormant'), same pid;
native (nat): glassd stopped while dormant and its files gone, not counted
as a crash; theme back: resumed in the same process (native: glassd again,
lgs-native back)."""
if nat:
W, H = _main_size()
write_json_atomic(TEST_REPORT, _report_window(W, H))
print("start native (no --stay):", start(glassd=FAKEGLASSD, stay=False, glassd_args=[], native="on",
test_report=TEST_REPORT), flush=True)
st = _wait(lambda: (lambda s: s if s.get("mode") == "native" else None)(status()), 45, 0.5) or status()
check("native with fakeglassd before", st.get("mode") == "native", st.get("mode"))
check("native unit: lgsLayers binding present", _bindings().get(BINDING) == "function", _bindings())
st0 = status()
pid0 = st0.get("pid")
g0 = st0.get("glassd") or {}
check("unit active before", st0.get("unit") == "active" and not st0.get("dormant"), st0.get("mode"))
was_on = lgs.is_on()
try:
if was_on:
lgs.op("off", quiet=True)
d = _wait(lambda: (lambda s: s if s.get("dormant") else None)(status()), 12, 0.5) or {}
check("theme off: dormant, same pid, status mode 'dormant'", d.get("pid") == pid0
and (d.get("dormant") or {}).get("graceS") and d.get("mode") == "dormant",
f"{d.get('pid')} vs {pid0} {d.get('dormant')} {d.get('mode')}")
if nat:
gone = _wait(lambda: not _alive(g0.get("pid")), 4, 0.25)
check("dormant: glassd process stopped within 4 s (review R1 M2)", bool(gone) and g0.get("pid"),
f"pid {g0.get('pid')} alive {_alive(g0.get('pid'))}")
check("dormant: no glassd.json / glassd-out.json", not os.path.exists(GLASSD_JSON)
and not os.path.exists(GLASSD_OUT), (os.path.exists(GLASSD_JSON), os.path.exists(GLASSD_OUT)))
check("dormant: lgs-native off on main", _wait(lambda: _main_native() is False, 4, 0.5), _main_native())
time.sleep(3)
s2 = status()
check("still running 8 s after the theme went off", s2.get("unit") == "active" and s2.get("pid") == pid0,
f"{s2.get('unit')} {s2.get('pid')}")
check("dormant: no scene graph in systemui", not (_sg_items() or {}).get("items"), _sg_items())
if nat:
check("dormant: glassd still stopped 3 s later",
not (s2.get("glassd") or {}).get("running") and not _alive(g0.get("pid")),
(s2.get("glassd") or {}).get("pid"))
finally:
if was_on:
lgs.op("on", quiet=True)
r = _wait(lambda: (lambda s: s if s.get("unit") == "active" and not s.get("dormant") else None)(status()),
10, 0.5) or {}
check("theme back: resumed (not dormant, same pid)", r.get("pid") == pid0, f"{r.get('pid')} {r.get('dormant')}")
if nat:
n = _wait(lambda: (lambda s: s if s.get("mode") == "native" else None)(status()), 30, 0.5) or status()
g1 = n.get("glassd") or {}
check("theme back: native again with a new glassd", n.get("mode") == "native" and g1.get("pid")
and g1.get("pid") != g0.get("pid"), (n.get("mode"), g0.get("pid"), g1.get("pid")))
check("the dormancy stop was not counted as a crash (restarts unchanged)",
g1.get("restarts") == g0.get("restarts") and not g1.get("givenUp"), (g0.get("restarts"), g1.get("restarts")))
check("lgs-native back on main", _wait(lambda: _main_native() is True, 20, 0.5), _main_native())
back = _wait(lambda: _steam_js("typeof window.lgsAction") == "function", 5)
check("action binding present after resume", bool(back))
rt = _wait(lambda: _steam_js("(() => { const R = window.__LGS_RT, b = R && R.bridge; const d = b && b.get && "
"b.get('daemon'); return d ? Date.now() - d.at < d.ttlMs : null; })()") is True, 8)
if _steam_js("!!(window.__LGS_RT && window.__LGS_RT.bridge)"):
check("bridge daemon heartbeat fresh after resume", bool(rt))
pages = (status().get("steamvrPages") or {}).get("pages") or {}
check("SteamVR pages themed", pages and all(v in ("ok", "applied") for v in pages.values()), pages)
def selftest(args):
"""Lab checks against the running unit. Prints PASS/FAIL lines and a JSON
summary; exit 0 when every check passed."""
name = args[0] if args else ""
results = []
def check(n, ok, info=""):
results.append({"check": n, "pass": bool(ok), "info": info if not ok or info else ""})
print(("PASS " if ok else "FAIL ") + n + (f" [{info}]" if info != "" else ""), flush=True)
if name == "actions":
import shutil
import tempfile
tmp = tempfile.mkdtemp(prefix="lgs-p8-", dir="/tmp/lgs" if os.path.isdir("/tmp/lgs") else None)
try:
p, f, res = shell_ext.selftest(tmp)
finally:
shutil.rmtree(tmp, ignore_errors=True)
for n, ok, info in res:
check(n, ok, "" if ok else info)
elif name == "dm6":
nat, src, reason = resolve_native("auto")
check("auto resolves to CSS only without a gate pass", (not nat) if src == "builtin" else True,
f"{nat} {src} {reason}")
if "--live" in args:
with FileLock("/tmp/lgs/native.lock", 1800), FileLock("/tmp/lgs/lab.lock", 600), FileLock("/tmp/lgs/lab-vr.lock", 240):
print("stop:", stop(), flush=True)
print("start(auto):", start(native="auto"), flush=True)
st = _wait(lambda: (status() if status().get("unit") == "active" and
(status().get("mode") or "").startswith("css-only") else None), 20)
st = st or status()
nat_st = st.get("native") or {}
check("status.mode css-only with the reason", (st.get("mode") or "").startswith("css-only (native=auto")
and nat_st.get("requested") == "auto", f"{st.get('mode')} / {nat_st}")
check("a running unit is kept by start(auto)", start(native="auto").startswith("running"))
elif name == "dm3":
with FileLock("/tmp/lgs/lab-vr.lock", 240): # it reads systemui and runs DumpLaserOverlays there
_selftest_dm3(check, args)
elif name == "dm4":
with FileLock("/tmp/lgs/lab.lock", 600): # it calls lgsAction in Steam's SharedJSContext
_selftest_dm4(check, args)
elif name == "dm5":
# --stop restarts the daemon: native.lock and lab.lock as well (order native -> lab -> lab-vr)
import contextlib
stopping = "--stop" in args
with (FileLock("/tmp/lgs/native.lock", 1800) if stopping else contextlib.nullcontext()), \
(FileLock("/tmp/lgs/lab.lock", 600) if stopping else contextlib.nullcontext()), \
FileLock("/tmp/lgs/lab-vr.lock", 240):
_selftest_dm5(check, args)
elif name == "grace":
# theme off without `lgs off` (lab --stock, P1's selftests): dormant, not gone.
# --native: the same with a native unit (fakeglassd, test report): glassd stopped while dormant
import contextlib
nat = "--native" in args
if nat and not os.access(FAKEGLASSD, os.X_OK):
print(f"BLOCKED: no fakeglassd at {FAKEGLASSD} (python glass.py native-build fake)")
return 3
with (FileLock("/tmp/lgs/native.lock", 1800) if nat else contextlib.nullcontext()), \
FileLock("/tmp/lgs/lab.lock", 600), FileLock("/tmp/lgs/lab-vr.lock", 240):
try:
_selftest_grace(check, args, nat)
finally:
if nat:
print("back to css:", start(native="off"), flush=True)
back = _wait(lambda: (status().get("mode") or "").startswith("css-only"), 30)
check("back to CSS only", back, status().get("mode"))
try:
os.remove(TEST_REPORT)
except OSError:
pass
elif name in ("dm1", "dm2", "plates", "freeze"):
need = GLASSD_BIN if name == "freeze" else FAKEGLASSD
if not os.access(need, os.X_OK):
print(f"BLOCKED: no {os.path.basename(need)} at {need} (python glass.py native-build)")
return 3
# native.lock for the native session; lab-vr.lock too, since systemui's
# scene graph changes (and is frozen) under other agents' vr: steps
import contextlib
# plates edits the Steam main window (a fixture): lab.lock as well
with FileLock("/tmp/lgs/native.lock", 1800), \
(FileLock("/tmp/lgs/lab.lock", 600) if name == "plates" else contextlib.nullcontext()), \
FileLock("/tmp/lgs/lab-vr.lock", 240):
try:
{"dm1": _selftest_dm1, "dm2": _selftest_dm2, "plates": _selftest_plates,
"freeze": _selftest_freeze}[name](check, args)
finally:
# always back to CSS only (lgs on --css), then wait for it
print("back to css:", start(native="off"), flush=True)
back = _wait(lambda: (status().get("mode") or "").startswith("css-only"), 30)
check("back to CSS only", back, status().get("mode"))
time.sleep(2)
left = {"reporter": _steam_js("typeof window.__LGS_LAYERS"),
"motion": _vr_js("systemui", "typeof globalThis.__LGS_MOTION"),
"sg": _vr_js("systemui", "typeof window.__LGS_SG"), "glassd.json": os.path.exists(GLASSD_JSON)}
check("nothing native left (reporter, __LGS_MOTION, __LGS_SG, glassd.json)",
left["motion"] == "undefined" and left["glassd.json"] is False
and (left["sg"] == "undefined" or not (_sg_items() or {}).get("items")), left)
gl = _bindings()
check("CSS-only unit: no lgsLayers global, lgsAction present", gl.get(BINDING) == "undefined"
and gl.get(ACTION_BINDING) == "function", gl)
for p in (TEST_REPORT,):
try:
os.remove(p)
except OSError:
pass
if "--keep-dump" not in args:
import shutil
shutil.rmtree(TEST_DUMP, ignore_errors=True)
else:
print("selftest NAME: actions | dm1 | dm2 [--keep-dump] | dm3 | dm4 | dm5 [--stop] | dm6 [--live] | "
"grace [--native] | plates | freeze")
return 2
ok = all(r["pass"] for r in results)
print(json.dumps({"selftest": name, "passed": sum(r["pass"] for r in results), "failed":
sum(not r["pass"] for r in results), "at": time.strftime("%Y-%m-%d %H:%M:%S")}))
return 0 if ok else 1
def opt_arg(argv, name):
if name in argv and argv.index(name) + 1 < len(argv):
return argv[argv.index(name) + 1]
return None
def main(argv):
cmd = argv[1] if len(argv) > 1 else "status"
if cmd == "daemon":
try:
return asyncio.run(Shell(glassd_arg(argv), stay_arg(argv), glassd_args_arg(argv),
native=bool(native_arg(argv)), native_req=opt_arg(argv, "--req"),
native_src=opt_arg(argv, "--src"),
test_report=opt_arg(argv, "--test-report"),
assume_caps=[c for c in (opt_arg(argv, "--assume-caps") or "").split(",") if c],
pod="--pod" in argv).run())
except KeyboardInterrupt:
return 0
if cmd == "start":
gargs = glassd_args_arg(argv)
glassd = glassd_arg(argv)
nat = native_arg(argv)
# From the command line (lab and tests) the real glassd does not read
# the camera unless asked to: --feed, or explicit --glassd-args.
if gargs is None and "--feed" not in argv and glassd and os.path.basename(glassd) == "glassd":
gargs = ["--no-feed"]
print(start(glassd, stay_arg(argv), gargs, None if nat is None or "--auto" in argv else nat,
test_report=opt_arg(argv, "--test-report"),
assume_caps=[c for c in (opt_arg(argv, "--assume-caps") or "").split(",") if c],
pod="--pod" in argv))
elif cmd == "stop":
print(stop())
elif cmd == "status":
print(json.dumps(status(), indent=1))
elif cmd == "selftest":
return selftest(argv[2:])
else:
print(__doc__)
return 2
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv))