makepad/apps/wm/tools/frametrace/ftrig.py
Admin d3ca8bb506 wm and hosted apps: the desktop portal, clipboard, file picker and paced children
A hosted child is ticked at its owner's frames, one child frame per frame
shown; hosted apps launch with `cargo run` from a checkout, open at the size
they ask for, open more windows of their own, resize live and die with the
WM. The WM is the desktop's portal backend (file chooser, light/dark), with
a makepad-drawn file and folder picker; the Wayland clipboard and primary
selection meet the makepad clipboard. The AI hub is the WM's own child and
its panel shows running models. A covered window gets no frames; tiling and
the warp shelves exclude each other; phone WM Settings fixes; dock order
places a foreign app next to ours.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-14 07:24:22 +02:00

211 lines
9 KiB
Python

"""Frame-exact WM rig: an owned headless Weston, the WM under --remote
--virtual-clock with MAKEPAD_WM_FRAMETRACE, driven one frame at a time.
from ftrig import Rig
rig = Rig(root, "tag", apps="notes,clock").up()
rig.frames(30) # 30 frames, real time given to the clients between them
rig.key("ArrowRight", logo=1) # SUPER+Right
rig.frames(40)
rig.down() # quits, reaps only the processes it started
# rig.trace is the JSONL path; analyze.py reads it
Hidden only (MAKEPAD_HIDE_WINDOWS=1, SANDBOX_MUTE=1, a private Weston
socket). Kills only the PIDs it recorded (Weston, the WM, the WM's
children by pid + start time). Needs a release build with frametrace.
"""
import json, os, signal, subprocess as sp, tempfile, time
HOME = os.path.expanduser("~")
def _birth(pid):
try:
return open(f"/proc/{pid}/stat").read().rsplit(")", 1)[1].split()[19]
except OSError:
return None
class Rig:
def __init__(self, root, tag, apps="", env=None, size="1280x800", scale="1.3", dpi=2, real=0.012, out=None, files=None, virtual=True):
self.root, self.tag, self.apps = root, tag, apps
self.env = env or {}
# MAKEPAD_HOME-relative files written before the WM starts (settings).
self.files = files or {}
self.size, self.scale, self.dpi, self.real = size, scale, dpi, real
# virtual: --virtual-clock, one frame per `frames` step; else the WM
# runs in real time and `frames(n)` waits n/60 s (pacing is real).
self.virtual = virtual
self.out = out or os.path.join(HOME, "lanes", "wm-review-runs", tag)
os.makedirs(self.out, exist_ok=True)
self.trace = os.path.join(self.out, "frames.jsonl")
self.log = os.path.join(self.out, "wm.log")
self.R = os.path.join(root, "target/release/makepad-remote")
self.owned = {} # pid -> birth
self.kids = {}
self.marks = [] # (frame line count at the mark, label)
# ---- process lifetime -------------------------------------------------
def up(self):
if os.path.exists(self.trace):
os.unlink(self.trace) # one literal path, ours
xdg = f"/run/user/{os.getuid()}"
self.sock = f"wayland-ftrig-{''.join(c for c in self.tag if c.isalnum())}-{os.getpid()}"
e = dict(os.environ, XDG_RUNTIME_DIR=xdg)
e = {k: v for k, v in e.items() if not k.startswith(("MAKEPAD_", "RELAX_", "RAPID_"))}
w, h = (int(v) for v in self.size.split("x"))
self.wp = sp.Popen(["nice", "-n", "10", "weston", "--backend=headless", "--renderer=gl", f"--socket={self.sock}",
f"--width={w + 200}", f"--height={h + 200}", "--idle-time=0"],
stdout=open(os.path.join(self.out, "weston.log"), "w"), stderr=sp.STDOUT, env=e, start_new_session=True)
self.owned[self.wp.pid] = _birth(self.wp.pid)
for _ in range(100):
if "weston-desktop-shell" in open(os.path.join(self.out, "weston.log"), errors="replace").read():
break
time.sleep(0.1)
self.state = tempfile.mkdtemp(prefix=f"ftrig-{self.tag}-")
for rel, text in self.files.items():
path = os.path.join(self.state, rel)
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "w") as f:
f.write(text)
e.update(WAYLAND_DISPLAY=self.sock, SANDBOX_MUTE="1", MAKEPAD_HIDE_WINDOWS="1", MAKEPAD_WM_BACKDROP="warp-grid",
MAKEPAD_WM_NO_WARM="1", MAKEPAD_WM_ROOT=self.root, MAKEPAD_HOME=self.state,
XDG_CONFIG_HOME=self.state + "/c", XDG_DATA_HOME=self.state + "/d", XDG_CACHE_HOME=self.state + "/k",
MAKEPAD_WM_FRAMETRACE=self.trace, CARGO_NET_OFFLINE="true", MAKEPAD_CEF_OFFLINE="1")
if self.apps:
e["MAKEPAD_WM_TEST_APP"] = self.apps
e.update(self.env)
args = [os.path.join(self.root, "target/release/makepad-app-wm"), "--remote", f"--window={self.size}@{self.dpi}",
f"-scale={self.scale}"] + (["--virtual-clock"] if self.virtual else [])
self.ap = sp.Popen(["nice", "-n", "10"] + args, cwd=self.root, stdout=open(self.log, "w"), stderr=sp.STDOUT,
env=e, start_new_session=True)
self.owned[self.ap.pid] = _birth(self.ap.pid)
for _ in range(300):
if "socket=" in open(self.log, errors="replace").read():
break
if self.ap.poll() is not None:
raise RuntimeError("wm exited at start; see " + self.log)
time.sleep(0.1)
return self
def _track(self):
rows = sp.run(["ps", "-e", "-o", "pid=,ppid="], capture_output=True, text=True).stdout.split("\n")
par = {}
for r in rows:
if r.strip():
c, p = map(int, r.split())
par.setdefault(p, []).append(c)
todo = list(par.get(self.ap.pid, []))
while todo:
c = todo.pop()
b = _birth(c)
if b:
self.kids[c] = b
todo.extend(par.get(c, []))
def down(self):
self._track()
try:
self.r("quit")
self.ap.wait(timeout=10)
except Exception:
pass
for pid, b in list(self.owned.items()) + list(self.kids.items()):
if _birth(pid) == b:
try:
os.kill(pid, signal.SIGTERM)
except ProcessLookupError:
pass
end = time.time() + 6
while time.time() < end and any(_birth(p) == b for p, b in list(self.owned.items()) + list(self.kids.items())):
time.sleep(0.1)
for pid, b in list(self.owned.items()) + list(self.kids.items()):
if _birth(pid) == b:
try:
os.kill(pid, signal.SIGKILL)
except ProcessLookupError:
pass
# ---- remote -----------------------------------------------------------
def r(self, *a):
p = sp.run([self.R, "app", str(self.ap.pid), *[str(x) for x in a]], capture_output=True, text=True, timeout=90)
return p.stdout.strip()
def frames(self, n=1, real=None):
"""n frames of 1/60 s, giving the clients `real` seconds between frames."""
real = self.real if real is None else real
if not self.virtual:
time.sleep(n / 60.0)
self._track()
return
for _ in range(n):
self.r("step", "frames=1")
if real:
time.sleep(real)
self._track()
def settle(self, max_frames=600, real=0.03):
for _ in range(max_frames // 10):
self.frames(10, real)
s = self.r("settled")
try:
if json.loads(s).get("settled"):
return True
except ValueError:
pass
return False
def mark(self, label):
"""Label the next frame line in the trace (analyze.py prints it)."""
n = sum(1 for _ in open(self.trace)) if os.path.exists(self.trace) else 0
self.marks.append((n + 1, label))
with open(os.path.join(self.out, "marks.json"), "w") as f:
json.dump(self.marks, f)
def key(self, code, **mods):
m = [f"{k}=1" for k, v in mods.items() if v]
self.r("key", f"c={code}", "k=down", *m)
self.r("key", f"c={code}", "k=up", *m)
def mouse(self, k, x, y, **extra):
return self.r("mouse", f"k={k}", f"x={x:.1f}", f"y={y:.1f}", *[f"{a}={b}" for a, b in extra.items()])
def drag(self, path, frames_per_step=1, dwell_end=0, release=True, real=None):
"""Press at path[0], move through the rest one step per frame batch, optionally release."""
x, y = path[0]
self.mouse("move", x, y)
self.frames(2, real)
self.mouse("down", x, y)
self.frames(1, real)
for (x, y) in path[1:]:
self.mouse("move", x, y)
self.frames(frames_per_step, real)
if dwell_end:
self.frames(dwell_end, real)
if release:
self.mouse("up", x, y)
self.frames(1, real)
def grab(self, path):
return self.r("grab", f"out={path}")
def launch(self, app):
return self.r("call", "os.launch", json.dumps({"app": app}))
# ---- trace ------------------------------------------------------------
def last(self):
"""The newest complete frame record (the WM may be writing the next line as it is read:
a line without its newline is not finished yet)."""
last = None
with open(self.trace) as f:
for line in f:
if line.endswith("\n"):
last = line
return json.loads(last) if last else None
def window(self, client=None, app_index=None):
"""Live (not captured) records of the newest frame, by client."""
fr = self.last()
live = [w for w in fr["w"] if w["cap"] is None]
if client is not None:
return next((w for w in live if w["c"] == client), None)
return live