Files
MichaelScottsman 5af91fc1bd Glass leaves with its page; the sidebar keeps native glass in VR Settings; Friends clears the search row; a paintbrush on the bar tunes the glass
Route changes
- Plates fade with their content: the reporter reads a running opacity fade on a plate or its ancestors
  (Home -> Friends: the route's 150 ms exit) and reports exitAt/exitMs; glassd fades the plate's coverage
  linearly on that clock, optics unchanged (cap exitAt); the daemon keeps a leaving plate for exactly
  that fade. A plate whose element left the DOM (Home -> Library stays in one /library route: no fade,
  Home unmounts when Library has rendered) goes in 90 ms instead of the 350 ms dematerialize ramp that
  left Home's discs over the Library grid.
- The window cover arrives with the route: the shell sets the new route's glass mode in the history
  change itself, the reporter reports the mode flip at once (tickNow) with the time it happened, and
  glassd springs a kept surface's new cover from then (appearAt now also for main gaining its cover).
- A disc popped at the switch belongs to the page that leaves: it never flips the new page to the crops
  layout and goes with the page's fade (lgs_sg.js routeOut).

Sidebar in VR Settings
- The frame menu is a laser-only surface keyed on Steam's gamepad-nav flag, which goes stale on
  SteamVR's own pages (the laser works SteamVR's page): the native glass went on and off with every
  input Steam saw, its CSS glass in between. On a SteamVR page (the frame's VR Settings action active)
  the rule does not apply.

Friends
- The page starts at y 108 (C1a's pad + 40, as Settings' list): the sidebar platter no longer sits
  under the search capsule.

Glass tuner (flag glassTune, on)
- A paintbrush circle after the system capsule (its own glass piece and native cover) opens a bar popup
  through Steam's own bar popup button: colour (8 swatches) and tint strength, Glass intensity (the
  dial), Refraction, Frost, Highlights, Reset to default.
- Daemon action glass.tune (kind ui, its own 40 ms rate, outside the global window): glassd.json at once,
  ~/.config/glass-shell/tune.json 400 ms after the last change (kept across reboots by request; the
  defaults delete it; RT-6 allows exactly this file), the dial file mirrored for the SteamVR pages.
- glassd: tune {refract, frost, light, hue, hueK} applied in drawGlass, eased (40 ms) like the dial now
  is; the glass body's neutral moves toward the hue (uHue), per-piece tints stay on top (cap tune).
- The CSS glass follows live: a style per Steam window sets --lgs-dial / --lgs-tune-hue; the material
  tints mix the colour in.
2026-10-09 14:06:10 -04:00

626 lines
29 KiB
Python

"""Glass Shell daemon plugins: the action registry behind the `lgsAction` binding.
Steam-side code calls window.lgsAction(JSON.stringify({id, type, args, src}))
in SharedJSContext. lgs_shell.py hands every call to Registry.call(), which
validates it strictly (size, JSON, id, type, source, argument schema, flag,
rate) and runs the plugin that registered the type. The reply goes back to
Steam through __LGS_RT.bridge 'reply' (docs/phase2/contracts/daemon.md §5).
Plugins are device/shell_ext/<name>.py files owned by the packages that write
them. Each defines ACTIONS = {type: spec} and `async def run(ctx, type, args)`;
optionally `async def tick(ctx)` (once a second) and `async def stop(ctx)`
(teardown: undo everything). A plugin that fails to import is listed as
failed; the others keep working. Files are re-imported when they change.
This module has no side effects on import and needs nothing outside the
standard library, so it can be unit tested offline (Registry.selftest()).
"""
import asyncio
import collections
import importlib.util
import json
import math
import os
import re
import time
import traceback
HERE = os.path.dirname(os.path.realpath(__file__))
MAX_PAYLOAD = 4096
ID_RE = re.compile(r"^[A-Za-z0-9_.:-]{1,64}$")
TYPE_RE = re.compile(r"^[a-z][a-z0-9_]{0,31}(\.[a-z][a-z0-9_]{0,31})?$")
# Steam window kinds a call may name as its source (contract §5)
SOURCES = ("main", "bar", "barpopup", "frame.menu", "floatingfooter", "keyboard", "notifications",
"tooltip", "volumelevel", "ccpopup")
KINDS = ("echo", "read", "ui", "nav", "launch")
DRY_KINDS = ("nav", "launch") # never run while actionsDryRun or the runtime's action logger is on
DEFAULT_RATE = 1.0 # s between accepted calls of one type
GLOBAL_LIMIT, GLOBAL_WINDOW = 20, 10.0 # accepted calls in any window
DEFAULT_TIMEOUT = 10.0
ARG_TYPES = ("str", "int", "num", "bool", "enum")
SPEC_KEYS = {"args", "sources", "rate", "kind", "flag", "timeout", "global"}
ARG_KEYS = {"type", "optional", "max", "re", "min", "values"}
STR_MAX = 256
TEST_PREFIX = "test:" # plugins from the registry's test_dir (RAM, lab only)
TEST_KINDS = ("echo", "read") # ... which may never navigate, launch or change the UI
class ActionError(Exception):
def __init__(self, code, detail=None):
super().__init__(code)
self.code = code
self.detail = detail
# ------------------------------------------------------------------ specs and validation
def check_spec(atype, spec):
"""A plugin's ACTIONS entry, normalised; ValueError when it is malformed
(the plugin is then refused as a whole: a typo must not open a hole)."""
if not isinstance(atype, str) or not TYPE_RE.match(atype):
raise ValueError(f"bad action type name {atype!r}")
if not isinstance(spec, dict):
raise ValueError(f"{atype}: spec is not a dict")
extra = set(spec) - SPEC_KEYS
if extra:
raise ValueError(f"{atype}: unknown spec keys {sorted(extra)}")
args = spec.get("args", {})
if not isinstance(args, dict):
raise ValueError(f"{atype}: args is not a dict")
norm_args = {}
for k, a in args.items():
if not isinstance(k, str) or not re.match(r"^[A-Za-z_][A-Za-z0-9_]{0,31}$", k):
raise ValueError(f"{atype}: bad arg name {k!r}")
if not isinstance(a, dict) or a.get("type") not in ARG_TYPES:
raise ValueError(f"{atype}.{k}: type must be one of {ARG_TYPES}")
if set(a) - ARG_KEYS:
raise ValueError(f"{atype}.{k}: unknown keys {sorted(set(a) - ARG_KEYS)}")
b = dict(a)
if b["type"] == "str":
b["max"] = int(b.get("max", STR_MAX))
if "re" in b:
b["re_c"] = re.compile(b["re"])
if b["type"] == "enum":
vals = b.get("values")
if not isinstance(vals, (list, tuple)) or not vals:
raise ValueError(f"{atype}.{k}: enum needs values")
b["values"] = list(vals)
norm_args[k] = b
sources = spec.get("sources", ["main"])
if sources == "*":
sources = list(SOURCES)
if not isinstance(sources, (list, tuple)) or not sources or any(s not in SOURCES for s in sources):
raise ValueError(f"{atype}: sources must be a non-empty subset of {SOURCES}")
kind = spec.get("kind", "nav")
if kind not in KINDS:
raise ValueError(f"{atype}: kind must be one of {KINDS}")
rate = float(spec.get("rate", DEFAULT_RATE))
timeout = float(spec.get("timeout", DEFAULT_TIMEOUT))
if not (0 <= rate <= 3600) or not (0 < timeout <= 120):
raise ValueError(f"{atype}: rate or timeout out of range")
flag = spec.get("flag")
if flag is not None and not isinstance(flag, str):
raise ValueError(f"{atype}: flag must be a string")
# global: False = the type's own rate is its only limit (a slider dragged: up to 1/rate calls a second,
# which the global window would cut off after GLOBAL_LIMIT). Only for cheap, side-effect-light kinds.
glob = spec.get("global", True)
if not isinstance(glob, bool) or (not glob and (kind != "ui" or rate < 0.02)):
raise ValueError(f"{atype}: global False needs kind ui and a rate of at least 0.02 s")
return {"args": norm_args, "sources": list(sources), "kind": kind, "rate": rate,
"timeout": timeout, "flag": flag, "global": glob}
def validate_args(schema, args):
"""Exactly the schema: no unknown keys, required keys present, types,
ranges and patterns. Returns a clean dict; raises ActionError('bad-args')."""
if args is None:
args = {}
if not isinstance(args, dict):
raise ActionError("bad-args", "args is not an object")
extra = set(args) - set(schema)
if extra:
raise ActionError("bad-args", f"unknown args {sorted(extra)[:5]}")
out = {}
for k, a in schema.items():
if k not in args:
if a.get("optional"):
continue
raise ActionError("bad-args", f"missing {k}")
v = args[k]
t = a["type"]
if t == "str":
if not isinstance(v, str) or len(v) > a["max"]:
raise ActionError("bad-args", f"{k}: string of at most {a['max']} chars expected")
if "re_c" in a and not a["re_c"].fullmatch(v):
raise ActionError("bad-args", f"{k}: does not match {a['re']}")
elif t in ("int", "num"):
if isinstance(v, bool) or not isinstance(v, (int, float)) or \
(t == "int" and not (isinstance(v, int) or (isinstance(v, float) and v.is_integer()))):
raise ActionError("bad-args", f"{k}: {t} expected")
if isinstance(v, float) and not math.isfinite(v):
raise ActionError("bad-args", f"{k}: not finite")
if t == "int":
v = int(v)
if "min" in a and v < a["min"] or "max" in a and v > a["max"]:
raise ActionError("bad-args", f"{k}: out of range")
elif t == "bool":
if not isinstance(v, bool):
raise ActionError("bad-args", f"{k}: bool expected")
elif t == "enum":
if isinstance(v, bool) or v not in a["values"]:
raise ActionError("bad-args", f"{k}: one of {a['values']} expected")
out[k] = v
return out
# ------------------------------------------------------------------ per-plugin context
class Ctx:
"""What a plugin may use. `host` is the daemon (lgs_shell.Shell) or a test
double with the same methods."""
def __init__(self, host, name):
self._host = host
self.name = name
self.state = {}
async def steam_eval(self, expr, timeout=10):
return await self._host.ext_steam_eval(expr, timeout)
async def vr_eval(self, page, expr, timeout=10):
return await self._host.ext_vr_eval(page, expr, timeout)
def vr_pages(self):
return self._host.ext_vr_pages()
@property
def flags(self):
return dict(self._host.ext_flags())
@property
def dry(self):
return bool(self._host.ext_flags().get("actionsDryRun"))
@property
def geom(self):
return self._host.ext_geom()
@property
def page(self):
return self._host.ext_page()
def log(self, msg):
self._host.ext_log(f"ext {self.name}: {msg}")
# ------------------------------------------------------------------ built-in actions
async def _echo(ctx, atype, args, call):
return {"echo": args.get("text", ""), "src": call.get("src"), "at": time.time()}
BUILTIN = {
"echo": ({"args": {"text": {"type": "str", "max": 200, "optional": True}},
"sources": "*", "rate": 0.2, "kind": "echo"}, _echo),
}
# ------------------------------------------------------------------ the registry
class Registry:
def __init__(self, host, plugin_dir=HERE, builtin=True, clock=time.monotonic, test_dir=None):
self.host = host
self.dir = plugin_dir
self.test_dir = test_dir # lab: RAM plugins "test:<name>", kinds echo / read only
self.clock = clock
self.plugins = {} # name -> {"mod", "mtime", "ctx", "types"}
self.failed = {} # name -> error
self.types = {} # type -> (plugin name, spec, fn)
self.last_ok = {} # type -> clock of the last accepted call
self.recent = collections.deque() # clocks of accepted calls (global limit)
self.inflight = set()
self.calls = 0
self.rejected = collections.deque(maxlen=10)
self.log_ring = collections.deque(maxlen=20)
self.scan_at = 0.0
if builtin:
ctx = Ctx(host, "builtin")
types = {}
for t, (spec, fn) in BUILTIN.items():
s = check_spec(t, spec)
self.types[t] = ("builtin", s, (lambda f: (lambda c, ty, a, call: f(c, ty, a, call)))(fn))
types[t] = s
self.plugins["builtin"] = {"mod": None, "mtime": None, "ctx": ctx, "types": sorted(types)}
def add_builtin(self, atype, spec, fn):
"""A daemon-provided action: fn(ctx, type, args, call) -> result."""
s = check_spec(atype, spec)
if atype in self.types:
raise ValueError(f"type {atype!r} already registered")
self.types[atype] = ("builtin", s, fn)
self.plugins["builtin"]["types"] = sorted(set(self.plugins["builtin"]["types"]) | {atype})
# ---------------------------------------------------------------- loading
def files(self):
out = {}
for folder, prefix in ((self.dir, ""), (self.test_dir, TEST_PREFIX)):
if not folder:
continue
try:
names = sorted(os.listdir(folder))
except OSError:
continue
for n in names:
if n.endswith(".py") and not n.startswith(("_", ".")):
p = os.path.join(folder, n)
try:
out[prefix + n[:-3]] = (p, os.stat(p).st_mtime_ns)
except OSError:
pass
return out
async def scan(self, force=False):
"""Load new or changed plugins, unload removed ones. Cheap when nothing
changed; the daemon calls it once a second. Returns what changed."""
now = self.clock()
if not force and now - self.scan_at < 1.0:
return []
self.scan_at = now
changed = []
files = self.files()
for name in [n for n in self.plugins if n != "builtin" and n not in files]:
await self._unload(name)
changed.append(f"-{name}")
for name in [n for n in self.failed if n not in files]:
del self.failed[name]
for name, (path, mtime) in files.items():
cur = self.plugins.get(name)
if cur and cur["mtime"] == mtime:
continue
if name in self.failed and self.failed[name][0] == mtime:
continue
if cur:
await self._unload(name)
err = self._load(name, path, mtime)
changed.append(f"+{name}" if not err else f"!{name}")
return changed
def _load(self, name, path, mtime):
try:
spec = importlib.util.spec_from_file_location(f"lgs_shell_ext_{name.replace(':', '_')}", path)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
actions = getattr(mod, "ACTIONS", None)
run = getattr(mod, "run", None)
if not isinstance(actions, dict) or not actions:
raise ValueError("no ACTIONS dict")
if not callable(run) or not asyncio.iscoroutinefunction(run):
raise ValueError("run must be an async function run(ctx, type, args)")
specs = {}
for t, s in actions.items():
if t in self.types:
raise ValueError(f"type {t!r} already registered by {self.types[t][0]}")
specs[t] = check_spec(t, s)
if name.startswith(TEST_PREFIX) and specs[t]["kind"] not in TEST_KINDS:
raise ValueError(f"{t}: a test plugin may only have kinds {TEST_KINDS}")
except Exception as e: # noqa: BLE001 - a broken plugin must not stop the daemon
msg = f"{type(e).__name__}: {e}"[:300]
self.failed[name] = (mtime, msg)
self._log(f"plugin {name} failed to load: {msg}")
return msg
ctx = Ctx(self.host, name)
for t, s in specs.items():
self.types[t] = (name, s, (lambda f: (lambda c, ty, a, call: f(c, ty, a)))(run))
self.plugins[name] = {"mod": mod, "mtime": mtime, "ctx": ctx, "types": sorted(specs)}
self.failed.pop(name, None)
self._log(f"plugin {name} loaded: {', '.join(sorted(specs))}")
return None
async def _unload(self, name):
p = self.plugins.pop(name, None)
if not p:
return
for t in p["types"]:
self.types.pop(t, None)
await self._call_hook(name, p, "stop")
self._log(f"plugin {name} unloaded")
async def _call_hook(self, name, p, hook):
fn = getattr(p.get("mod"), hook, None) if p.get("mod") else None
if fn is None or not asyncio.iscoroutinefunction(fn):
return
try:
await asyncio.wait_for(fn(p["ctx"]), 10)
except asyncio.CancelledError:
raise
except Exception as e: # noqa: BLE001
self._log(f"plugin {name} {hook}: {e!r}")
async def tick(self):
for name, p in list(self.plugins.items()):
await self._call_hook(name, p, "tick")
async def stop(self):
for name in [n for n in self.plugins if n != "builtin"]:
await self._unload(name)
# ---------------------------------------------------------------- calls
def _log(self, msg):
self.log_ring.append(time.strftime("%H:%M:%S ") + msg)
try:
self.host.ext_log(msg)
except Exception: # noqa: BLE001
pass
def _reject(self, cid, code, detail, call):
rec = {"id": cid, "type": (call or {}).get("type") if isinstance(call, dict) else None,
"src": (call or {}).get("src") if isinstance(call, dict) else None,
"error": code, "detail": detail, "at": time.strftime("%H:%M:%S")}
self.rejected.append(rec)
self._log(f"action {cid} {rec['type']} from {rec['src']}: rejected {code}" + (f" ({detail})" if detail else ""))
return {"id": cid, "ok": False, "error": code, **({"detail": detail} if detail else {})}
async def call(self, payload, from_default_context=True):
"""One binding call. Returns the reply dict, or None when no id could be
read (nothing can be answered then; it is still logged)."""
self.calls += 1
call = None
cid = None
try:
if not isinstance(payload, str) or len(payload.encode("utf-8", "replace")) > MAX_PAYLOAD:
raise ActionError("too-big")
try:
call = json.loads(payload)
except ValueError:
raise ActionError("bad-json") from None
if not isinstance(call, dict):
raise ActionError("bad-json", "not an object")
cid = call.get("id")
if not isinstance(cid, str) or not ID_RE.match(cid):
cid = None
raise ActionError("bad-id")
extra = set(call) - {"id", "type", "args", "src"}
if extra:
raise ActionError("bad-json", f"unknown keys {sorted(extra)[:5]}")
atype = call.get("type")
if not isinstance(atype, str) or atype not in self.types:
raise ActionError("unknown-type", str(atype)[:40] if atype is not None else None)
pname, spec, fn = self.types[atype]
src = call.get("src")
if not from_default_context:
raise ActionError("bad-source", "not SharedJSContext's default context")
if not isinstance(src, str) or src not in spec["sources"]:
raise ActionError("bad-source", f"{str(src)[:40]} not in {spec['sources']}")
args = validate_args(spec["args"], call.get("args"))
flags = self.host.ext_flags()
if spec["flag"] and not flags.get(spec["flag"]):
raise ActionError("flag-off", spec["flag"])
now = self.clock()
while self.recent and now - self.recent[0] > GLOBAL_WINDOW:
self.recent.popleft()
if atype in self.inflight:
raise ActionError("busy")
last = self.last_ok.get(atype)
if last is not None and now - last < spec["rate"]:
raise ActionError("rate", f"{spec['rate']} s between calls")
if spec["global"] and len(self.recent) >= GLOBAL_LIMIT:
raise ActionError("rate", f"{GLOBAL_LIMIT} calls in {GLOBAL_WINDOW:g} s")
self.last_ok[atype] = now
if spec["global"]:
self.recent.append(now)
except ActionError as e:
return self._reject(cid, e.code, e.detail, call) if cid else (self._reject(None, e.code, e.detail, call) and None)
if spec["kind"] in DRY_KINDS:
why = "actionsDryRun" if flags.get("actionsDryRun") else None
if why is None:
try: # the runtime's action logger (P1 rt.test.actions): on in every lab step
if await self.host.ext_actions_logger(atype, args):
why = "runtime action logger"
except Exception as e: # noqa: BLE001 - cannot tell: do not run
why = f"logger check failed: {e!r}"[:80]
if why:
self._log(f"action {cid} {atype} from {src}: dry run ({why}), args {json.dumps(args)[:200]}")
return {"id": cid, "ok": True, "dry": True, "result": None}
ctx = self.plugins.get(pname, {}).get("ctx") or Ctx(self.host, pname)
self.inflight.add(atype)
try:
result = await asyncio.wait_for(fn(ctx, atype, args, call), spec["timeout"])
json.dumps(result) # must be serialisable
except asyncio.TimeoutError:
return self._reject(cid, "timeout", f"{spec['timeout']:g} s", call)
except asyncio.CancelledError:
raise
except Exception as e: # noqa: BLE001 - a plugin bug is answered, never fatal
tb = traceback.format_exc().strip().splitlines()[-1]
return self._reject(cid, "plugin-error", f"{e!r}"[:200] or tb[:200], call)
finally:
self.inflight.discard(atype)
if spec["global"]: # a slider's stream of calls is not logged one by one
self._log(f"action {cid} {atype} from {src}: ok")
return {"id": cid, "ok": True, "result": result}
def status(self):
return {"types": sorted(self.types), "plugins": {n: p["types"] for n, p in self.plugins.items()},
"failed": {n: e for n, (_, e) in self.failed.items()}, "calls": self.calls,
"rejected": list(self.rejected), "dryRun": bool(self.host.ext_flags().get("actionsDryRun"))}
# ------------------------------------------------------------------ offline self-test
class _TestHost:
def __init__(self):
self.flags = {}
self.lines = []
async def ext_steam_eval(self, expr, timeout=10):
return None
async def ext_vr_eval(self, page, expr, timeout=10):
return None
def ext_vr_pages(self):
return []
def ext_flags(self):
return self.flags
def ext_geom(self):
return None
def ext_page(self):
return None
def ext_log(self, msg):
self.lines.append(msg)
async def ext_actions_logger(self, atype, args):
return bool(self.flags.get("_logger"))
def selftest(tmpdir):
"""Offline checks of the validator, the rate limits and plugin loading.
Returns (passed, failed, details)."""
results = []
def check(name, cond, info=""):
results.append((name, bool(cond), info))
clock = [100.0]
host = _TestHost()
# a test plugin with every arg type, a flagged action and a broken twin
os.makedirs(tmpdir, exist_ok=True)
with open(os.path.join(tmpdir, "probe.py"), "w", encoding="utf-8") as f:
f.write(
"ACTIONS = {\n"
" 'probe.nav': {'args': {'app': {'type': 'str', 're': r'^steam\\.app\\.\\d{1,10}$'},\n"
" 'n': {'type': 'int', 'min': 0, 'max': 9, 'optional': True},\n"
" 'mode': {'type': 'enum', 'values': ['a', 'b'], 'optional': True},\n"
" 'on': {'type': 'bool', 'optional': True}},\n"
" 'sources': ['main'], 'kind': 'nav', 'rate': 1.0},\n"
" 'probe.flagged': {'args': {}, 'kind': 'read', 'flag': 'wp.test', 'rate': 0},\n"
" 'probe.boom': {'args': {}, 'kind': 'read', 'rate': 0},\n"
" 'probe.slow': {'args': {}, 'kind': 'read', 'rate': 0, 'timeout': 0.05},\n"
"}\n"
"import asyncio\n"
"async def run(ctx, type, args):\n"
" if type == 'probe.boom':\n"
" raise RuntimeError('boom')\n"
" if type == 'probe.slow':\n"
" await asyncio.sleep(1)\n"
" return {'type': type, 'args': args}\n")
with open(os.path.join(tmpdir, "broken.py"), "w", encoding="utf-8") as f:
f.write("ACTIONS = {'Bad Name': {}}\nasync def run(ctx, t, a):\n return 1\n")
with open(os.path.join(tmpdir, "dupe.py"), "w", encoding="utf-8") as f:
f.write("ACTIONS = {'echo': {'args': {}}}\nasync def run(ctx, t, a):\n return 1\n")
reg = Registry(host, plugin_dir=tmpdir, clock=lambda: clock[0])
async def go():
await reg.scan(force=True)
check("plugin loaded", "probe" in reg.plugins and "probe.nav" in reg.types)
check("broken plugin refused", "broken" in reg.failed and "Bad Name" not in reg.types)
check("duplicate type refused", "dupe" in reg.failed and reg.types["echo"][0] == "builtin")
def mk(**kw):
return json.dumps(kw)
r = await reg.call(mk(id="e1", type="echo", args={"text": "hi"}, src="bar"))
check("echo", r and r["ok"] and r["result"]["echo"] == "hi" and r["result"]["src"] == "bar", r)
r = await reg.call(mk(id="u1", type="nope", args={}, src="main"))
check("unknown type", r and r["error"] == "unknown-type", r)
r = await reg.call(mk(id="s1", type="probe.nav", args={"app": "steam.app.620980"}, src="bar"))
check("wrong source", r and r["error"] == "bad-source", r)
r = await reg.call(mk(id="s2", type="probe.nav", args={"app": "steam.app.620980"}, src="main"),
from_default_context=False)
check("wrong context", r and r["error"] == "bad-source", r)
r = await reg.call(mk(id="s3", type="probe.nav", args={"app": "steam.app.620980"}))
check("missing source", r and r["error"] == "bad-source", r)
for bad, why in ((
{"app": "steam.app.62x"}, "pattern"), ({"app": "steam.app.1", "x": 1}, "unknown key"),
({}, "missing"), ({"app": 5}, "type"), ({"app": "steam.app.1", "n": 10}, "range"),
({"app": "steam.app.1", "n": True}, "bool as int"), ({"app": "steam.app.1", "mode": "c"}, "enum"),
({"app": "steam.app.1", "on": 1}, "int as bool"), ({"app": "steam.app.1", "n": 1.5}, "float as int")):
r = await reg.call(mk(id="a1", type="probe.nav", args=bad, src="main"))
check(f"bad args: {why}", r and r["error"] == "bad-args", r)
r = await reg.call(mk(id="n1", type="probe.nav", args={"app": "steam.app.620980", "n": 3}, src="main"))
check("valid call runs", r and r["ok"] and r["result"]["args"] == {"app": "steam.app.620980", "n": 3}, r)
r = await reg.call(mk(id="n2", type="probe.nav", args={"app": "steam.app.620980"}, src="main"))
check("rate limit", r and r["error"] == "rate", r)
clock[0] += 1.01
host.flags["actionsDryRun"] = True
r = await reg.call(mk(id="n3", type="probe.nav", args={"app": "steam.app.620980"}, src="main"))
check("dry run: nav not executed", r and r["ok"] and r.get("dry") and r["result"] is None, r)
host.flags.pop("actionsDryRun")
clock[0] += 1.01
host.flags["_logger"] = True
r = await reg.call(mk(id="n4", type="probe.nav", args={"app": "steam.app.620980"}, src="main"))
check("dry run: runtime action logger on", r and r["ok"] and r.get("dry"), r)
host.flags.pop("_logger")
r = await reg.call(mk(id="f1", type="probe.flagged", args={}, src="main"))
check("flag off refused", r and r["error"] == "flag-off", r)
host.flags["wp.test"] = True
r = await reg.call(mk(id="f2", type="probe.flagged", args={}, src="main"))
check("flag on runs", r and r["ok"], r)
r = await reg.call(mk(id="b1", type="probe.boom", args={}, src="main"))
check("plugin error answered", r and r["error"] == "plugin-error", r)
r = await reg.call(mk(id="t1", type="probe.slow", args={}, src="main"))
check("timeout answered", r and r["error"] == "timeout", r)
r = await reg.call("x" * (MAX_PAYLOAD + 1))
check("too big: no reply", r is None)
r = await reg.call("{not json")
check("bad json: no reply", r is None)
r = await reg.call(mk(id="bad id!", type="echo", args={}, src="main"))
check("bad id: no reply", r is None)
r = await reg.call(mk(id="k1", type="echo", args={}, src="main", extra=1))
check("unknown top-level key", r and r["error"] == "bad-json", r)
clock[0] += 20
n_ok = 0
for i in range(25):
clock[0] += 0.21
r = await reg.call(mk(id=f"g{i}", type="echo", args={}, src="main"))
n_ok += bool(r and r["ok"])
check("global limit 20 per 10 s", n_ok == 20, n_ok)
check("rejections logged", len(reg.rejected) == 10 and reg.rejected[-1]["error"] == "rate")
# reload on change and unload on removal
with open(os.path.join(tmpdir, "probe.py"), "a", encoding="utf-8") as f:
f.write("\n# changed\n")
st = os.stat(os.path.join(tmpdir, "probe.py"))
os.utime(os.path.join(tmpdir, "probe.py"), ns=(st.st_atime_ns, st.st_mtime_ns + 10 ** 9))
ch = await reg.scan(force=True)
check("reload on change", "+probe" in ch and "probe.nav" in reg.types, ch)
os.remove(os.path.join(tmpdir, "probe.py"))
ch = await reg.scan(force=True)
check("unload on removal", "-probe" in ch and "probe.nav" not in reg.types, ch)
# the RAM test folder (lab): echo / read plugins only, named test:<file>
tdir = os.path.join(tmpdir, "ramtest")
os.makedirs(tdir, exist_ok=True)
with open(os.path.join(tdir, "ping.py"), "w", encoding="utf-8") as f:
f.write("ACTIONS = {'t.ping': {'args': {}, 'kind': 'read', 'rate': 0}}\n"
"async def run(ctx, t, a):\n return 'pong'\n")
with open(os.path.join(tdir, "go.py"), "w", encoding="utf-8") as f:
f.write("ACTIONS = {'t.go': {'args': {}, 'kind': 'nav', 'rate': 0}}\nasync def run(ctx, t, a):\n return 1\n")
reg.test_dir = tdir
ch = await reg.scan(force=True)
check("test folder: read plugin loaded as test:ping", "test:ping" in reg.plugins and "t.ping" in reg.types, ch)
check("test folder: a nav plugin is refused", "test:go" in reg.failed and "t.go" not in reg.types,
reg.failed.get("test:go"))
clock[0] += 20
r = await reg.call(mk(id="tp1", type="t.ping", args={}, src="main"))
check("test folder plugin answers", r and r["ok"] and r["result"] == "pong", r)
for n in ("ping.py", "go.py"):
os.remove(os.path.join(tdir, n))
ch = await reg.scan(force=True)
check("test folder plugin unloaded with its file", "t.ping" not in reg.types and "test:go" not in reg.failed, ch)
os.rmdir(tdir)
await reg.stop()
check("stop keeps builtin", list(reg.plugins) == ["builtin"])
asyncio.run(go())
for n in ("broken.py", "dupe.py"):
try:
os.remove(os.path.join(tmpdir, n))
except OSError:
pass
passed = sum(1 for _, ok, _ in results if ok)
return passed, len(results) - passed, results