File size: 12,743 Bytes
df84e45
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
"""The computer-use surface: a real browser driving the real editor.

For `surface="gui"` the environment opens the editor for the episode in a
headless Chromium page and the agent acts on it with mouse and keyboard, seeing
only screenshots. Nothing is simulated: a click lands on the same DOM a person
would click, the editor turns it into the same operation an MCP call would
make, and the document is scored like any other.

The browser lives on a private event loop in a background thread. A Playwright
browser is bound to the loop that launched it, and the editor page talks back
to this very server over a WebSocket, so driving it from the server's own loop
would deadlock: the tool would wait on the page, and the page would wait on a
server that is blocked waiting for the tool. Tools therefore `await` futures
from this thread and keep the server loop free.

One browser per process, one context per episode (isolated storage), which is
a few tens of megabytes per session rather than a VM.
"""

from __future__ import annotations

import asyncio
import concurrent.futures
import os
import threading

VIEWPORT = {"width": 1280, "height": 800}

# "ctrl" means the platform's shortcut modifier, so an agent's ctrl+a selects
# all and ctrl+z undoes whether the server runs on Linux or macOS.
_KEY_ALIASES = {
    "ctrl": "ControlOrMeta", "control": "ControlOrMeta", "cmd": "Meta", "command": "Meta", "meta": "Meta",
    "super": "Meta", "win": "Meta", "alt": "Alt", "option": "Alt", "shift": "Shift",
    "enter": "Enter", "return": "Enter", "esc": "Escape", "escape": "Escape",
    "backspace": "Backspace", "delete": "Delete", "del": "Delete", "tab": "Tab",
    "space": " ", "up": "ArrowUp", "down": "ArrowDown", "left": "ArrowLeft",
    "right": "ArrowRight", "home": "Home", "end": "End", "pageup": "PageUp",
    "pagedown": "PageDown", "plus": "+", "minus": "-",
    "arrowup": "ArrowUp", "arrowdown": "ArrowDown", "arrowleft": "ArrowLeft", "arrowright": "ArrowRight",
}


def normalize_keys(combo: str) -> str:
    """'ctrl+shift+z' -> 'Control+Shift+z' (Playwright's spelling)."""
    parts = [p for p in combo.replace(" ", "").split("+") if p] or [combo]
    out = []
    for p in parts:
        low = p.lower()
        if low in _KEY_ALIASES:
            out.append(_KEY_ALIASES[low])
        elif len(p) == 1:
            out.append(p)
        elif low.startswith("f") and low[1:].isdigit():
            out.append(p.upper())
        else:
            out.append(p[:1].upper() + p[1:])
    return "+".join(out)


def self_url() -> str:
    url = os.environ.get("DESIGN_CANVAS_SELF_URL")
    if url:
        return url.rstrip("/")
    return f"http://127.0.0.1:{os.environ.get('PORT', '8000')}"


class GuiDriver:
    _instance: GuiDriver | None = None
    _instance_lock = threading.Lock()

    @classmethod
    def get(cls) -> GuiDriver:
        with cls._instance_lock:
            if cls._instance is None:
                cls._instance = cls()
            return cls._instance

    def __init__(self):
        self._loop = asyncio.new_event_loop()
        self._thread = threading.Thread(target=self._loop.run_forever, name="gui-driver",
                                        daemon=True)
        self._thread.start()
        self._pw = None
        self._browser = None
        self._pages: dict[str, tuple] = {}  # episode_id -> (context, page)
        self._remote: dict[str, object] = {}  # episode_id -> remote screen (desktop agent), instead of a page
        self._start_lock = None

    # ------------------------------------------------------------ plumbing

    def submit(self, coro) -> concurrent.futures.Future:
        return asyncio.run_coroutine_threadsafe(coro, self._loop)

    async def call(self, coro, timeout: float = 30.0):
        """Await `coro` on the driver loop from any other loop."""
        fut = self.submit(coro)
        return await asyncio.wait_for(asyncio.wrap_future(fut), timeout)

    def call_sync(self, coro, timeout: float = 30.0):
        return self.submit(coro).result(timeout)

    async def _browser_ready(self):
        if self._start_lock is None:
            self._start_lock = asyncio.Lock()
        async with self._start_lock:
            if self._browser is None or not self._browser.is_connected():
                try:
                    from playwright.async_api import async_playwright
                except ImportError as exc:  # pragma: no cover - depends on extras
                    raise RuntimeError(
                        "the gui surface needs Playwright: pip install 'openenv-design-canvas[gui]'"
                        " && playwright install chromium"
                    ) from exc
                self._pw = await async_playwright().start()
                self._browser = await self._pw.chromium.launch(
                    args=["--disable-dev-shm-usage", "--font-render-hinting=none",
                          "--disable-lcd-text"]
                )
        return self._browser

    # ------------------------------------------------------------ pages

    async def _open(self, episode_id: str, url: str, cookies=None, ready=None, headers=None):
        await self._close(episode_id)
        browser = await self._browser_ready()
        context = await browser.new_context(viewport=VIEWPORT, device_scale_factor=1, extra_http_headers=headers)
        if headers:
            # the headers carry a sandbox's access token: the page may reach that app and nothing else
            origin = "/".join(url.split("/")[:3])

            async def only_app(route):
                await (route.continue_() if route.request.url.startswith(origin) else route.abort("blockedbyclient"))

            await context.route("**/*", only_app)
        if cookies:
            await context.add_cookies(cookies)
        page = await context.new_page()
        await page.goto(url, wait_until="load")
        if ready:
            await page.wait_for_selector(ready, timeout=120000)
            await page.wait_for_timeout(2500)  # a real app keeps loading fonts and its renderer after first paint
        else:
            await page.wait_for_function("window.designEditor && window.designEditor.ready === true",
                                         timeout=20000, polling=100)
        await self._settle(page)
        self._pages[episode_id] = (context, page)
        return True

    async def _close(self, episode_id: str):
        entry = self._pages.pop(episode_id, None)
        if entry:
            try:
                await entry[0].close()
            except Exception:
                pass

    def _page(self, episode_id: str):
        entry = self._pages.get(episode_id)
        if entry is None:
            raise RuntimeError("no editor page for this episode; call reset(surface='gui')")
        return entry[1]

    async def _settle(self, page, ms: int = 60):
        # Let the editor finish pending operations and repaint before the next
        # screenshot, so the agent never acts on a half-updated frame.
        try:
            await page.evaluate(
                "() => (window.designEditor && window.designEditor.whenIdle)"
                " ? window.designEditor.whenIdle(2000) : null"
            )
        except Exception:
            pass
        await page.wait_for_timeout(ms)

    # ------------------------------------------------------------ actions

    def attach_remote(self, episode_id: str, client) -> None:
        """Drive this episode through a remote screen (a desktop agent: realapps.desktop.client.DesktopClient)."""
        self._remote[episode_id] = client

    def detach_remote(self, episode_id: str) -> None:
        self._remote.pop(episode_id, None)

    def open(self, episode_id: str, url: str, cookies: list[dict] | None = None, ready: str | None = None,
             headers: dict[str, str] | None = None):
        """Open the editor for an episode. A real app (realapps) passes its session cookies and a CSS selector
        that appears once its editor has loaded, instead of the built-in editor's ready flag, and, when it runs in
        a sandbox, the headers that admit the browser (requests to any other origin are then blocked)."""
        return self.submit(self._open(episode_id, url, cookies, ready, headers))

    def close(self, episode_id: str):
        return self.submit(self._close(episode_id))

    async def screenshot(self, episode_id: str) -> bytes:
        if (r := self._remote.get(episode_id)) is not None:
            return await asyncio.to_thread(r.screenshot)
        async def run():
            page = self._page(episode_id)
            await self._settle(page, 30)
            return await page.screenshot(type="png")

        return await self.call(run())

    async def click(self, episode_id, x, y, button="left", clicks=1, modifiers=None):
        if (r := self._remote.get(episode_id)) is not None:
            return await asyncio.to_thread(r.input, type={1: "click", 2: "double", 3: "triple"}.get(clicks, "click") if button != "right" else "right", x=x, y=y, modifiers=[normalize_keys(m) for m in (modifiers or [])])
        async def run():
            page = self._page(episode_id)
            mods = [normalize_keys(m) for m in (modifiers or [])]
            for m in mods:
                await page.keyboard.down(m)
            try:
                await page.mouse.click(x, y, button=button, click_count=clicks, delay=20)
            finally:
                for m in reversed(mods):
                    await page.keyboard.up(m)
            await self._settle(page)

        return await self.call(run())

    async def move(self, episode_id, x, y):
        if (r := self._remote.get(episode_id)) is not None:
            return await asyncio.to_thread(r.input, type="move", x=x, y=y)
        async def run():
            page = self._page(episode_id)
            await page.mouse.move(x, y, steps=4)
            await self._settle(page, 30)

        return await self.call(run())

    async def drag(self, episode_id, sx, sy, ex, ey, modifiers=None):
        if (r := self._remote.get(episode_id)) is not None:
            return await asyncio.to_thread(r.input, type="drag", **{"from": [sx, sy], "to": [ex, ey]})
        async def run():
            page = self._page(episode_id)
            mods = [normalize_keys(m) for m in (modifiers or [])]
            await page.mouse.move(sx, sy)
            for m in mods:
                await page.keyboard.down(m)
            await page.mouse.down()
            steps = max(6, int(((ex - sx) ** 2 + (ey - sy) ** 2) ** 0.5 / 25))
            await page.mouse.move(ex, ey, steps=steps)
            await page.mouse.up()
            for m in reversed(mods):
                await page.keyboard.up(m)
            await self._settle(page, 90)

        return await self.call(run())

    async def scroll(self, episode_id, x, y, dx, dy, modifiers=None):
        if (r := self._remote.get(episode_id)) is not None:
            return await asyncio.to_thread(r.input, type="scroll", x=x, y=y, dx=round(dx / 100), dy=round(dy / 100))
        async def run():
            page = self._page(episode_id)
            await page.mouse.move(x, y)
            mods = [normalize_keys(m) for m in (modifiers or [])]
            for m in mods:
                await page.keyboard.down(m)
            await page.mouse.wheel(dx, dy)
            for m in reversed(mods):
                await page.keyboard.up(m)
            await self._settle(page, 80)

        return await self.call(run())

    async def type_text(self, episode_id, text: str):
        if (r := self._remote.get(episode_id)) is not None:
            return await asyncio.to_thread(r.input, type="type", text=text)
        async def run():
            page = self._page(episode_id)
            await page.keyboard.type(text, delay=8)
            await self._settle(page)

        return await self.call(run(), timeout=60)

    async def key(self, episode_id, combo: str):
        if (r := self._remote.get(episode_id)) is not None:
            return await asyncio.to_thread(r.input, type="key", keys=[normalize_keys(p) for p in combo.split()])
        async def run():
            page = self._page(episode_id)
            for part in combo.split():
                await page.keyboard.press(normalize_keys(part))
            await self._settle(page)

        return await self.call(run())

    async def wait(self, episode_id, seconds: float):
        if episode_id in self._remote:
            return await asyncio.sleep(max(0.0, min(seconds, 10.0)))
        async def run():
            page = self._page(episode_id)
            await page.wait_for_timeout(int(max(0.0, min(seconds, 10.0)) * 1000))

        return await self.call(run(), timeout=15)