Files
DroidDeck/tools/linuxfs/overlay/usr/local/bin/droiddeck-agent
MaxsTechReview eadebc46d5 Performance: idle front end, proot stops, launch-time passes and log collection (#529)
* Front end: draw the capsule wall without clipping it every frame

The tilted wall clipped each capsule to a rounded rect and recomposed its columns on every drift
step, and a rotated rounded clip is rasterised in software on the render threads: the idle main
page held about 1.7 cores. The art is now loaded at the capsule's size with its corners already
rounded, each column draws its capsules itself in one layer moved by the drift, and the drift
advances at most every 15 ms. The idle page takes about a third of a core.

* libfakeinput: let calls on descriptors it does not serve skip its lock

Every read, write, ioctl, close and poll of every process in a Proton session went through the
controller lock and the three map lookups, Wine's wineserver pipes and the game's files alike.
A bitmap of the descriptors the maps hold, set before one is handed out and cleared when it is
closed, lets everything else go straight to the kernel.

* Pad motion: ask the display for its rotation four times a second

The rotation was looked up for every sensor reading, every 2 ms, and cost more than the rest of
the reading; it is now refreshed every 250 ms, and the readings no longer allocate pairs.

* Session: keep the library passes out of a game's launch

The redist seeder stamped each prefix with the checksum of the whole set of names, which differs
between the session's pass and the game launch's (the game's own names are added), so every
launch re-appended the markers to every prefix in the library while the game loaded. Each
prefix's marker now lists the names it was given and only the missing ones are added.

The beat's seed and compatibility passes, and the Lossless Scaling ownership probe (which loads
steamclient.so), also wait while a wineserver runs: a launch rewrites the manifest and made them
due at exactly that moment. The game's own launch seeds its prefix regardless.

* Agent: keep the focus watcher's X connection open while nothing changes

The connect timeout stayed on the socket, so with no property change the read timed out, and the
watcher dropped the connection, reconnected and took a fresh snapshot every few seconds.

* Game environment: start Cyberpunk 2077 without REDlauncher

The prelauncher opens REDlauncher, a Qt WebEngine window that hands over to the game half a
minute later and then stays up beside it, well over a core busy for as long as the game runs; its
own --launcher-skip started the game twice. The game's executable is started directly instead.

* netif: answer Wine's network driver from a reading of the link

Mortal Kombat X has Wine's network driver enumerate every interface, address and route table
hundreds of times a second. Each enumeration read /etc/droiddeck-net several times, asked the
kernel for its interface dump again (a netlink socket bound under proot, refused each time) and
looked the real functions up with dlsym. The link file is now read at most once a second, a
refused dump is not asked for again within that second, and the real functions are looked up
once.

* Fast path: create memfds without a stop in proot

proot looks at a memfd's name only to refuse the few that are then executed or locked; every other
memfd_create is now issued from the trampoline. The session's /proc/net and status copies made
one per read, a stop each. Without memfd in the kernel the call still goes to proot's ashmem.

* Logs: run each redaction rule only on a line that could match it

A session's end copies some 70 MB of the Steam client's logs and puts every line through all
twenty-odd rules, most of a minute of a core on the phone after every session. Each rule now runs
only on a line holding what it cannot match without: its keyword, the five separators of a MAC,
the four dashes of a GUID, a long enough hex or token run, the learned account or address text.
Lines with non-ASCII text still meet every case-insensitive rule. Over 895,000 lines of real
client logs, with the device's accounts and addresses learned, the output is identical and the
pass takes a twentieth of the time.
2026-10-10 19:00:33 -04:00

691 lines
27 KiB
Python
Executable File

#!/usr/bin/python3
"""The guest side of the agent bridge (docs/agent-control.md).
Started by droiddeck-session inside the session, so whatever it runs has the session's own
environment: DISPLAY, the games' Xwayland, XDG_RUNTIME_DIR, and the preloads every guest process
gets - which is what lets it reach the Steam client's DevTools port, open to guest processes only.
The app talks to it through files under $BL_LAUNCH_DIR/agent (the session root, the same path on
both sides):
req/<id>.json a request the app wrote (tmp + rename)
resp/<id>.json the answer, written the same way
hello.json this daemon is up: pid, version, what it can do
focus.json gamescope's focus and every server's top-level windows, rewritten on change
cdp-port the client's DevTools port, written by droiddeck-session
Requests: ping, exec, focus, cdp, quit. Each answer is {"ok": true, ...} or
{"ok": false, "error": {"code", "message"}}.
"""
import base64
import json
import math
import os
import selectors
import signal
import socket
import struct
import subprocess
import sys
import threading
import time
import urllib.request
import urllib.parse
from pathlib import Path
VERSION = 1
OUTPUT_CAP = 256 * 1024
REQUEST_CAP = 256 * 1024
RESPONSE_CAP = 1024 * 1024
FOCUS_PROPS = (
"GAMESCOPE_FOCUSED_APP", "GAMESCOPE_FOCUSED_WINDOW", "GAMESCOPE_FOCUSABLE_APPS",
"GAMESCOPE_FOCUSABLE_WINDOWS", "GAMESCOPECTRL_BASELAYER_APPID",
)
WINDOW_PROPS = (
"STEAM_GAME", "WM_STATE", "_NET_WM_PID", "WM_CLASS", "_NET_WM_WINDOW_OPACITY", "STEAM_OVERLAY",
"GAMESCOPE_EXTERNAL_OVERLAY", "STEAM_INPUT_FOCUS", "_NET_WM_STATE",
)
MAP_STATE = ("unmapped", "unviewable", "viewable")
WM_STATES = {0: "withdrawn", 1: "normal", 3: "iconic"}
def log(message):
print("== agent: " + message, flush=True)
def write_json(path, value):
tmp = path.with_name(path.name + ".tmp")
with os.fdopen(os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC | os.O_NOFOLLOW, 0o600), "w") as stream:
os.fchmod(stream.fileno(), 0o600)
json.dump(value, stream, separators=(",", ":"))
os.replace(tmp, path)
class AgentError(Exception):
def __init__(self, code, message):
super().__init__(message)
self.code = code
# --- X11: just enough of the protocol for root properties, the window tree and events ---------
class X:
"""One connection to an Xwayland server. The rootfs has xprop but no python X library."""
def __init__(self, display):
num = display.lstrip(":").split(".")[0]
last = None
for address in ("\0/tmp/.X11-unix/X" + num, "/tmp/.X11-unix/X" + num):
try:
self.s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
self.s.settimeout(3)
self.s.connect(address)
break
except OSError as e:
last = e
self.s.close()
else:
raise last
self.s.sendall(struct.pack("<BxHHHHxx", 0x6C, 11, 0, 0, 0))
head = self._read(8)
if head[0] != 1:
raise OSError("X connection refused on " + display)
setup = self._read(struct.unpack_from("<H", head, 6)[0] * 4)
vendor = struct.unpack_from("<H", setup, 16)[0]
formats = setup[21]
self.root = struct.unpack_from("<I", setup, 32 + (vendor + 3) // 4 * 4 + formats * 8)[0]
self.events = []
self._atoms = {}
self._names = {}
def close(self):
try:
self.s.close()
except OSError:
pass
def _read(self, n):
buf = b""
while len(buf) < n:
chunk = self.s.recv(n - len(buf))
if not chunk:
raise OSError("X connection closed")
buf += chunk
return buf
def request(self, data):
"""Send one request and return its reply, or None for an X error (a window that went)."""
self.s.sendall(data)
while True:
head = self._read(32)
if head[0] == 0:
return None
if head[0] == 1:
return head + self._read(struct.unpack_from("<I", head, 4)[0] * 4)
self.events.append(head)
def next_event(self):
if self.events:
return self.events.pop(0)
while True:
head = self._read(32)
if head[0] > 1:
return head
def atom(self, name):
if name not in self._atoms:
n = name.encode()
pad = (4 - len(n) % 4) % 4
r = self.request(struct.pack("<BBHHxx", 16, 0, 2 + (len(n) + pad) // 4, len(n)) + n + b"\0" * pad)
self._atoms[name] = struct.unpack_from("<I", r, 8)[0] if r else 0
return self._atoms[name]
def atom_name(self, atom):
if atom not in self._names:
r = self.request(struct.pack("<BxHI", 17, 2, atom))
self._names[atom] = r[32:32 + struct.unpack_from("<H", r, 8)[0]].decode(errors="replace") if r else str(atom)
return self._names[atom]
def select(self, window, mask):
self.s.sendall(struct.pack("<BxHIII", 2, 4, window, 0x800, mask))
def children(self, window):
r = self.request(struct.pack("<BxHI", 15, 2, window))
if not r:
return []
n = struct.unpack_from("<H", r, 16)[0]
return list(struct.unpack_from("<%dI" % n, r, 32))
def attributes(self, window):
r = self.request(struct.pack("<BxHI", 3, 2, window))
return (r[26], r[27], struct.unpack_from("<H", r, 12)[0]) if r else None
def geometry(self, window):
r = self.request(struct.pack("<BxHI", 14, 2, window))
return struct.unpack_from("<hhHH", r, 12) if r else None
def prop(self, window, name, length=1024):
atom = self.atom(name)
if not atom:
return None
r = self.request(struct.pack("<BBHIIIII", 20, 0, 6, window, atom, 0, 0, length))
if not r or r[1] == 0:
return None
fmt, n = r[1], struct.unpack_from("<I", r, 16)[0]
data = r[32:32 + n * fmt // 8]
if fmt == 32:
return list(struct.unpack("<%dI" % n, data))
return data
def windows(x):
"""Top-level windows that are mapped or carry an app id: what gamescope picks focus from."""
out = []
for win in x.children(x.root):
attrs = x.attributes(win)
if not attrs:
continue
props = {name: x.prop(win, name) for name in WINDOW_PROPS}
if attrs[0] != 2 and not props["STEAM_GAME"]:
continue
geo = x.geometry(win) or (0, 0, 0, 0)
cls = props["WM_CLASS"]
state = props["WM_STATE"]
entry = {
"window": win,
"map": MAP_STATE[attrs[0]] if attrs[0] < 3 else attrs[0],
"overrideRedirect": bool(attrs[1]),
"inputOnly": attrs[2] == 2,
"geometry": [geo[0], geo[1], geo[2], geo[3]],
"appId": props["STEAM_GAME"][0] if props["STEAM_GAME"] else None,
"wmState": WM_STATES.get(state[0], state[0]) if state else None,
"pid": props["_NET_WM_PID"][0] if props["_NET_WM_PID"] else None,
"class": cls.split(b"\0")[-2].decode(errors="replace") if cls and b"\0" in cls else None,
}
if props["_NET_WM_WINDOW_OPACITY"]:
entry["opacity"] = props["_NET_WM_WINDOW_OPACITY"][0]
for key, name in (("overlay", "STEAM_OVERLAY"), ("externalOverlay", "GAMESCOPE_EXTERNAL_OVERLAY"),
("inputFocus", "STEAM_INPUT_FOCUS")):
if props[name]:
entry[key] = props[name][0]
if props["_NET_WM_STATE"]:
entry["netState"] = [x.atom_name(a).replace("_NET_WM_STATE_", "") for a in props["_NET_WM_STATE"]]
out.append(entry)
return out
def displays():
seen = []
for d in (os.environ.get("DISPLAY"), os.environ.get("STEAM_GAME_DISPLAY_0")):
if d and d not in seen:
seen.append(d)
return seen
def focus_snapshot():
"""gamescope's focus as Steam sees it (root properties on the client's server) and every
server's windows. GAMESCOPE_FOCUSABLE_WINDOWS is the list the client orders the base layer
from: a game missing there stays behind the client however healthy its window looks."""
shown = displays()
if not shown:
raise AgentError("NO_DISPLAY", "This session has no X display")
snap = {"t": int(time.time() * 1000), "displays": {}}
for i, d in enumerate(shown):
x = X(d)
try:
if i == 0:
values = {name: x.prop(x.root, name) for name in FOCUS_PROPS}
trip = values["GAMESCOPE_FOCUSABLE_WINDOWS"] or []
snap["focusedApp"] = (values["GAMESCOPE_FOCUSED_APP"] or [None])[0]
snap["focusedWindow"] = (values["GAMESCOPE_FOCUSED_WINDOW"] or [None])[0]
snap["focusableApps"] = values["GAMESCOPE_FOCUSABLE_APPS"] or []
snap["focusableWindows"] = [
{"window": trip[k], "appId": trip[k + 1], "pid": trip[k + 2]} for k in range(0, len(trip) - 2, 3)
]
snap["baselayerAppIds"] = values["GAMESCOPECTRL_BASELAYER_APPID"] or []
snap["displays"][d] = windows(x)
finally:
x.close()
return snap
class FocusWatcher(threading.Thread):
"""Rewrites focus.json when gamescope's focus properties change, and every few seconds when
the windows do (a window can change without any root property moving)."""
def __init__(self, path):
super().__init__(daemon=True, name="focus")
self.path = path
self.changed = threading.Event()
self.last = None
self.seq = 0
def run(self):
threading.Thread(target=self.listen, daemon=True, name="focus-events").start()
while True:
self.changed.wait(timeout=5)
self.changed.clear()
time.sleep(0.2) # let a burst of property changes settle
try:
snap = focus_snapshot()
except (OSError, AgentError) as e:
log("focus snapshot failed: %s" % e)
time.sleep(2)
continue
body = {k: v for k, v in snap.items() if k != "t"}
if body == self.last:
continue
self.last = body
self.seq += 1
snap["seq"] = self.seq
write_json(self.path, snap)
def listen(self):
while True:
try:
x = X(os.environ["DISPLAY"])
wanted = {x.atom(name) for name in FOCUS_PROPS}
x.select(x.root, 0x400000) # PropertyChangeMask
# Waits for events as long as there are none: the connect timeout left in place
# here dropped and remade the connection, and took a snapshot, every few seconds.
x.s.settimeout(None)
self.changed.set()
while True:
event = x.next_event()
if event[0] & 0x7F == 28 and struct.unpack_from("<I", event, 8)[0] in wanted:
self.changed.set()
except OSError as e:
log("focus events: %s; reconnecting" % e)
time.sleep(2)
# --- Steam client DevTools (CDP) -----------------------------------------------------------------
class WebSocket:
"""A minimal RFC 6455 client: text frames out, text frames in. Enough for CDP."""
def __init__(self, url, timeout):
target = urllib.parse.urlsplit(url)
if (target.scheme != "ws" or target.hostname not in ("127.0.0.1", "::1", "localhost")
or target.username is not None or target.password is not None or target.fragment):
raise AgentError("INVALID_TARGET", "DevTools targets must use a loopback WebSocket")
hostport = target.netloc
path = target.path or "/"
if target.query:
path += "?" + target.query
host = "127.0.0.1" if target.hostname == "localhost" else target.hostname
self.s = socket.create_connection((host, target.port or 80), timeout=timeout)
try:
self._handshake(path, hostport)
except Exception:
self.s.close()
raise
def _handshake(self, path, hostport):
key = base64.b64encode(os.urandom(16)).decode()
self.s.sendall((
"GET %s HTTP/1.1\r\nHost: %s\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n"
"Sec-WebSocket-Key: %s\r\nSec-WebSocket-Version: 13\r\n\r\n" % (path, hostport, key)
).encode())
head = b""
while b"\r\n\r\n" not in head:
chunk = self.s.recv(4096)
if not chunk:
raise OSError("DevTools closed the connection during the handshake")
head += chunk
if len(head) > 16 * 1024:
raise AgentError("RESPONSE_TOO_LARGE", "The DevTools handshake exceeds 16 KiB")
status = head.split(b"\r\n", 1)[0]
if b" 101 " not in status:
raise OSError("DevTools refused the WebSocket: " + status.decode(errors="replace"))
self.buffer = head.split(b"\r\n\r\n", 1)[1]
def _read(self, n):
while len(self.buffer) < n:
chunk = self.s.recv(65536)
if not chunk:
raise OSError("DevTools closed the connection")
self.buffer += chunk
data, self.buffer = self.buffer[:n], self.buffer[n:]
return data
def send(self, text):
payload = text.encode()
header = bytearray([0x81])
n = len(payload)
if n < 126:
header.append(0x80 | n)
elif n < 65536:
header.append(0x80 | 126)
header += struct.pack(">H", n)
else:
header.append(0x80 | 127)
header += struct.pack(">Q", n)
mask = os.urandom(4)
header += mask
self.s.sendall(bytes(header) + bytes(b ^ mask[i % 4] for i, b in enumerate(payload)))
def recv(self):
message = b""
while True:
b0, b1 = self._read(2)
n = b1 & 0x7F
if n == 126:
n = struct.unpack(">H", self._read(2))[0]
elif n == 127:
n = struct.unpack(">Q", self._read(8))[0]
if n > RESPONSE_CAP or len(message) + n > RESPONSE_CAP:
raise AgentError("RESPONSE_TOO_LARGE", "The DevTools response exceeds 1 MiB")
mask = self._read(4) if b1 & 0x80 else None
data = self._read(n)
if mask:
data = bytes(b ^ mask[i % 4] for i, b in enumerate(data))
op = b0 & 0x0F
if op == 8:
raise OSError("DevTools closed the WebSocket")
if op == 9: # ping
continue
if op in (0, 1, 2):
message += data
if b0 & 0x80:
return message.decode(errors="replace")
def close(self):
try:
self.s.close()
except OSError:
pass
def cdp_port(agent_dir):
try:
port = (agent_dir / "cdp-port").read_text().strip()
except OSError:
port = os.environ.get("BL_CDP_PORT", "")
if not port.isdigit():
raise AgentError("NO_DEVTOOLS", "The Steam client's DevTools port is not known (no Steam client in this session yet)")
return int(port)
def cdp_targets(agent_dir, timeout=5):
url = "http://127.0.0.1:%d/json" % cdp_port(agent_dir)
try:
with urllib.request.urlopen(url, timeout=timeout) as r:
data = r.read(RESPONSE_CAP + 1)
if len(data) > RESPONSE_CAP:
raise AgentError("RESPONSE_TOO_LARGE", "The DevTools response exceeds 1 MiB")
return json.loads(data.decode())
except OSError as e:
raise AgentError("DEVTOOLS_UNREACHABLE", "Could not reach the client's DevTools at %s: %s" % (url, e))
def cdp_eval(agent_dir, expression, target=None, await_promise=True, timeout=15):
targets = [t for t in cdp_targets(agent_dir) if t.get("webSocketDebuggerUrl")]
want = target or "SharedJSContext"
match = [t for t in targets if want in (t.get("title") or "") or want in (t.get("url") or "")]
if not match:
names = ", ".join(sorted({t.get("title") or t.get("url") or "?" for t in targets}))
raise AgentError("NO_TARGET", "No DevTools target matches '%s' (have: %s)" % (want, names))
ws = WebSocket(match[0]["webSocketDebuggerUrl"], timeout)
try:
ws.send(json.dumps({"id": 1, "method": "Runtime.evaluate", "params": {
"expression": expression, "returnByValue": True, "awaitPromise": await_promise,
"userGesture": True,
}}))
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
message = json.loads(ws.recv())
if message.get("id") != 1:
continue
if "error" in message:
raise AgentError("CDP_ERROR", json.dumps(message["error"]))
result = message.get("result", {})
if result.get("exceptionDetails"):
details = result["exceptionDetails"]
text = (details.get("exception") or {}).get("description") or details.get("text") or "exception"
raise AgentError("JS_EXCEPTION", text)
value = result.get("result", {})
return {"target": match[0].get("title"), "type": value.get("type"),
"value": value.get("value"), "description": value.get("description")}
raise AgentError("TIMEOUT", "No answer from DevTools in %ss" % timeout)
finally:
ws.close()
# --- Game processes ------------------------------------------------------------------------------
def game_pids(app_id):
"""Our processes started for this Steam app (Steam sets SteamAppId / SteamGameId in their
environment, and Proton's wine processes inherit it)."""
wanted = [("SteamAppId=%s" % app_id).encode(), ("SteamGameId=%s" % app_id).encode()]
me = os.getpid()
pids = []
for entry in os.listdir("/proc"):
if not entry.isdigit() or int(entry) == me:
continue
try:
with open("/proc/%s/environ" % entry, "rb") as f:
env = f.read().split(b"\0")
except OSError:
continue
if any(w in env for w in wanted):
pids.append(int(entry))
return pids
def quit_game(agent_dir, app_id, timeout):
if not str(app_id).isdigit():
raise AgentError("INVALID_APP_ID", "appId must be a decimal Steam app id")
steps = []
if game_pids(app_id):
try:
cdp_eval(agent_dir, "SteamClient.Apps.TerminateApp(%s, false)" % json.dumps(str(app_id)), timeout=5)
steps.append("client")
except AgentError as e:
steps.append("client unavailable: %s" % e)
deadline = time.monotonic() + timeout
while game_pids(app_id) and time.monotonic() < deadline:
time.sleep(0.5)
left = game_pids(app_id)
if left:
steps.append("SIGTERM %s" % left)
for pid in left:
try:
os.kill(pid, signal.SIGTERM)
except OSError:
pass
end = time.monotonic() + 5
while game_pids(app_id) and time.monotonic() < end:
time.sleep(0.5)
left = game_pids(app_id)
if left:
steps.append("SIGKILL %s" % left)
for pid in left:
try:
os.kill(pid, signal.SIGKILL)
except OSError:
pass
time.sleep(1)
return {"appId": int(app_id), "steps": steps, "remaining": game_pids(app_id)}
# --- Requests ------------------------------------------------------------------------------------
def run_exec(req):
argv = req.get("argv")
if not isinstance(argv, list) or not argv or not all(isinstance(a, str) and "\0" not in a for a in argv):
raise AgentError("INVALID_REQUEST", "exec needs argv, a non-empty list of strings")
timeout = request_timeout(req, 30)
env = dict(os.environ)
extra_env = req.get("env") if req.get("env") is not None else {}
if not isinstance(extra_env, dict) or any("\0" in str(k) or "=" in str(k) or "\0" in str(v) for k, v in extra_env.items()):
raise AgentError("INVALID_REQUEST", "env must be an object with valid environment entries")
env.update({str(k): str(v) for k, v in extra_env.items()})
stdin = req.get("stdin")
if stdin is not None and not isinstance(stdin, str):
raise AgentError("INVALID_REQUEST", "stdin must be a string")
started = time.monotonic()
try:
proc = subprocess.Popen(
argv, cwd=req.get("cwd") or None, env=env, start_new_session=True,
stdin=subprocess.PIPE if stdin is not None else subprocess.DEVNULL,
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
)
except OSError as e:
raise AgentError("EXEC_FAILED", "%s: %s" % (argv[0], e))
captured = {"stdout": bytearray(), "stderr": bytearray()}
truncated = {"stdout": False, "stderr": False}
pending = memoryview(stdin.encode()) if stdin is not None else memoryview(b"")
deadline = started + timeout
timed_out = False
try:
with selectors.DefaultSelector() as selector:
for stream, name in ((proc.stdout, "stdout"), (proc.stderr, "stderr")):
os.set_blocking(stream.fileno(), False)
selector.register(stream, selectors.EVENT_READ, name)
if proc.stdin is not None:
os.set_blocking(proc.stdin.fileno(), False)
if pending:
selector.register(proc.stdin, selectors.EVENT_WRITE, "stdin")
else:
proc.stdin.close()
while selector.get_map() or proc.poll() is None:
remaining = deadline - time.monotonic()
if remaining <= 0:
timed_out = True
break
for key, _ in selector.select(min(0.1, remaining)):
stream, name = key.fileobj, key.data
if name == "stdin":
try:
pending = pending[os.write(stream.fileno(), pending[:65536]):]
except BrokenPipeError:
pending = pending[len(pending):]
if not pending:
selector.unregister(stream)
stream.close()
else:
data = os.read(stream.fileno(), 65536)
if not data:
selector.unregister(stream)
stream.close()
continue
available = OUTPUT_CAP - len(captured[name])
captured[name].extend(data[:available])
truncated[name] |= len(data) > available
if timed_out:
try:
os.killpg(proc.pid, signal.SIGKILL)
except ProcessLookupError:
pass
proc.wait(timeout=5)
finally:
if proc.poll() is None:
try:
os.killpg(proc.pid, signal.SIGKILL)
except ProcessLookupError:
pass
proc.wait(timeout=5)
for stream in (proc.stdin, proc.stdout, proc.stderr):
if stream is not None:
stream.close()
def text(name):
return {"text": captured[name].decode(errors="replace"), "truncated": truncated[name]}
return {"exitCode": proc.returncode, "timedOut": timed_out,
"seconds": round(time.monotonic() - started, 3), "stdout": text("stdout"), "stderr": text("stderr")}
def request_timeout(req, default):
try:
timeout = float(req.get("timeout", default))
except (TypeError, ValueError):
raise AgentError("INVALID_REQUEST", "timeout must be a positive number no greater than 600 seconds")
if not math.isfinite(timeout) or not 0 < timeout <= 600:
raise AgentError("INVALID_REQUEST", "timeout must be a positive number no greater than 600 seconds")
return timeout
def handle(agent_dir, req):
kind = req.get("kind")
if kind == "ping":
return {"pid": os.getpid(), "version": VERSION, "displays": displays()}
if kind == "exec":
return run_exec(req)
if kind == "focus":
return focus_snapshot()
if kind == "cdp":
if req.get("op") == "targets":
return {"targets": [{k: t.get(k) for k in ("title", "url", "type")} for t in cdp_targets(agent_dir)]}
expression = req.get("expression")
if not isinstance(expression, str) or not expression:
raise AgentError("INVALID_REQUEST", "cdp eval needs an expression")
return cdp_eval(agent_dir, expression, req.get("target"), bool(req.get("awaitPromise", True)),
request_timeout(req, 15))
if kind == "quit":
return quit_game(agent_dir, req.get("appId"), request_timeout(req, 15))
raise AgentError("UNKNOWN_REQUEST", "Unknown agent request '%s'" % kind)
def answer(agent_dir, path):
rid = path.stem
try:
with path.open("rb") as stream:
data = stream.read(REQUEST_CAP + 1)
if len(data) > REQUEST_CAP:
raise ValueError("The request exceeds 256 KiB")
req = json.loads(data)
if not isinstance(req, dict):
raise ValueError("The request must be a JSON object")
except (OSError, ValueError) as e:
req = None
result = {"ok": False, "error": {"code": "INVALID_REQUEST", "message": str(e)}}
finally:
try:
path.unlink()
except OSError:
pass
if req is not None:
try:
result = dict(handle(agent_dir, req), ok=True)
except AgentError as e:
result = {"ok": False, "error": {"code": e.code, "message": str(e)}}
except Exception as e: # an answer of some kind always goes back
result = {"ok": False, "error": {"code": "AGENT_FAILED", "message": "%s: %s" % (type(e).__name__, e)}}
write_json(agent_dir / "resp" / (rid + ".json"), result)
def serve(agent_dir, focus=True, poll=0.1):
(agent_dir / "req").mkdir(parents=True, exist_ok=True)
(agent_dir / "resp").mkdir(parents=True, exist_ok=True)
can_focus = focus and bool(os.environ.get("DISPLAY"))
if can_focus:
FocusWatcher(agent_dir / "focus.json").start()
write_json(agent_dir / "hello.json", {"pid": os.getpid(), "version": VERSION, "started": int(time.time() * 1000),
"displays": displays(), "focus": can_focus})
log("serving %s (focus %s)" % (agent_dir, "on" if can_focus else "off"))
taken = set()
while True:
for path in sorted((agent_dir / "req").glob("*.json")):
if path.name in taken:
continue
taken.add(path.name)
threading.Thread(target=lambda p=path: (answer(agent_dir, p), taken.discard(p.name)),
daemon=True, name="req").start()
time.sleep(poll)
def main(argv):
base = os.environ.get("BL_LAUNCH_DIR")
if not base:
print("BL_LAUNCH_DIR is not set", file=sys.stderr)
return 2
serve(Path(base) / "agent", focus="--no-focus" not in argv)
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))