hitl-cdp-browser-provider 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- hitl_cdp_browser_provider-0.1.0/.gitignore +4 -0
- hitl_cdp_browser_provider-0.1.0/PKG-INFO +100 -0
- hitl_cdp_browser_provider-0.1.0/README.md +88 -0
- hitl_cdp_browser_provider-0.1.0/pyproject.toml +25 -0
- hitl_cdp_browser_provider-0.1.0/src/hitl_cdp_browser_provider/__init__.py +5 -0
- hitl_cdp_browser_provider-0.1.0/src/hitl_cdp_browser_provider/bridge.py +236 -0
- hitl_cdp_browser_provider-0.1.0/src/hitl_cdp_browser_provider/cli.py +48 -0
- hitl_cdp_browser_provider-0.1.0/src/hitl_cdp_browser_provider/viewer.py +100 -0
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: hitl-cdp-browser-provider
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Bridge any CDP-capable browser session (agent-browser, Playwright, Puppeteer, plain Chrome) into an HTTP+WebSocket endpoint for human-in-the-loop remote viewing/control, e.g. HITL Broker's Browser Gateway.
|
|
5
|
+
Project-URL: Homepage, https://github.com/aqiu9/HITL-Broker
|
|
6
|
+
Project-URL: Repository, https://github.com/aqiu9/HITL-Broker
|
|
7
|
+
License: MIT
|
|
8
|
+
Keywords: agent-browser,browser-automation,cdp,chrome-devtools-protocol,hitl,human-in-the-loop,playwright,puppeteer
|
|
9
|
+
Requires-Python: >=3.9
|
|
10
|
+
Requires-Dist: websockets<14,>=13
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
|
|
13
|
+
# hitl-cdp-browser-provider
|
|
14
|
+
|
|
15
|
+
Bridges **any** Chrome DevTools Protocol (CDP) browser session into a plain
|
|
16
|
+
HTTP page + WebSocket endpoint — the shape [HITL Broker](https://github.com/aqiu9/HITL-Broker)'s
|
|
17
|
+
Browser Gateway (and similar systems) expect a task's `internal_url` to
|
|
18
|
+
serve.
|
|
19
|
+
|
|
20
|
+
Works with anything that exposes a CDP remote-debugging endpoint:
|
|
21
|
+
[agent-browser](https://github.com/keithamus/agent-browser), Playwright,
|
|
22
|
+
Puppeteer, or a plain `chrome --remote-debugging-port=...`. It talks CDP's
|
|
23
|
+
own standard primitives (`Page.startScreencast`, `Input.dispatchMouseEvent`,
|
|
24
|
+
`Input.dispatchKeyEvent`) — not any tool-specific protocol — so one bridge
|
|
25
|
+
covers all of them. Functionally identical to the
|
|
26
|
+
[Node package of the same name](https://github.com/aqiu9/HITL-Broker/tree/main/packages/node/hitl-cdp-browser-provider);
|
|
27
|
+
pick whichever fits your automation stack (e.g. Playwright-Python/Selenium-Python
|
|
28
|
+
users won't need Node at all).
|
|
29
|
+
|
|
30
|
+
## Install
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
pip install hitl-cdp-browser-provider
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Use
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
# Point it at a CDP HTTP base -- it auto-picks the first "page" target
|
|
40
|
+
hitl-cdp-browser-provider --cdp-http http://127.0.0.1:9222 --bind 0.0.0.0:18234
|
|
41
|
+
|
|
42
|
+
# Or an exact page WebSocket URL if you already have one
|
|
43
|
+
hitl-cdp-browser-provider --cdp-url ws://127.0.0.1:9222/devtools/page/ABC123 --bind 0.0.0.0:18234
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Then point your HITL Source's `browser_endpoint` (or any equivalent
|
|
47
|
+
"internal_url for browser tasks" field) at `<bind-host>:<bind-port>`.
|
|
48
|
+
|
|
49
|
+
### Finding a CDP endpoint
|
|
50
|
+
|
|
51
|
+
- **agent-browser**: `agent-browser get cdp-url --session <name>` gives a
|
|
52
|
+
`ws://127.0.0.1:<port>/devtools/browser/<id>` browser-level URL — pass its
|
|
53
|
+
`http://127.0.0.1:<port>` base as `--cdp-http` (screencast needs a page
|
|
54
|
+
target, which `--cdp-http` auto-discovers via `/json/list`).
|
|
55
|
+
- **Chrome/Chromium directly**: launch with `--remote-debugging-port=9222`,
|
|
56
|
+
use `--cdp-http http://127.0.0.1:9222`.
|
|
57
|
+
- **Playwright (Python)**: launch Chromium with
|
|
58
|
+
`args=["--remote-debugging-port=9222"]`, then point `--cdp-http` at that
|
|
59
|
+
port. (`browser.new_cdp_session(page)` also works if you'd rather wire
|
|
60
|
+
the CDP session up yourself instead of using this CLI.)
|
|
61
|
+
- **Selenium (Python)**: Chrome launched with `--remote-debugging-port`
|
|
62
|
+
works the same way; Selenium 4's own `driver.execute_cdp_cmd` isn't
|
|
63
|
+
needed here since this bridge connects directly.
|
|
64
|
+
|
|
65
|
+
## Library use
|
|
66
|
+
|
|
67
|
+
```python
|
|
68
|
+
import asyncio
|
|
69
|
+
from hitl_cdp_browser_provider import serve
|
|
70
|
+
|
|
71
|
+
asyncio.run(serve({
|
|
72
|
+
"cdp_http": "http://127.0.0.1:9222",
|
|
73
|
+
"cdp_url": None,
|
|
74
|
+
"bind": "0.0.0.0:18234",
|
|
75
|
+
"quality": 60,
|
|
76
|
+
"max_width": 1280,
|
|
77
|
+
"max_height": 800,
|
|
78
|
+
}))
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## Protocol
|
|
82
|
+
|
|
83
|
+
The viewer page renders `{"t":"frame","data":"<base64 jpeg>"}` messages onto
|
|
84
|
+
a `<canvas>` and sends back `{"t":"mouse",...}` / `{"t":"key",...}` messages,
|
|
85
|
+
which this bridge translates 1:1 into `Input.dispatchMouseEvent` /
|
|
86
|
+
`Input.dispatchKeyEvent` CDP calls.
|
|
87
|
+
|
|
88
|
+
## Not CDP? Use noVNC instead
|
|
89
|
+
|
|
90
|
+
If your browser session isn't driven by anything CDP-capable (or you want a
|
|
91
|
+
screen-level rather than browser-API-level handoff — e.g. an arbitrary GUI
|
|
92
|
+
app in a container, not just a browser), see the
|
|
93
|
+
[noVNC recipe](https://github.com/aqiu9/HITL-Broker/blob/main/docs/browser-provider-novnc.md)
|
|
94
|
+
in the HITL-Broker repo instead — noVNC + websockify already implements the
|
|
95
|
+
same "HTTP page + WS" contract with zero custom code, at the cost of needing
|
|
96
|
+
an X server (Xvfb) + VNC server in the container.
|
|
97
|
+
|
|
98
|
+
## License
|
|
99
|
+
|
|
100
|
+
MIT
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# hitl-cdp-browser-provider
|
|
2
|
+
|
|
3
|
+
Bridges **any** Chrome DevTools Protocol (CDP) browser session into a plain
|
|
4
|
+
HTTP page + WebSocket endpoint — the shape [HITL Broker](https://github.com/aqiu9/HITL-Broker)'s
|
|
5
|
+
Browser Gateway (and similar systems) expect a task's `internal_url` to
|
|
6
|
+
serve.
|
|
7
|
+
|
|
8
|
+
Works with anything that exposes a CDP remote-debugging endpoint:
|
|
9
|
+
[agent-browser](https://github.com/keithamus/agent-browser), Playwright,
|
|
10
|
+
Puppeteer, or a plain `chrome --remote-debugging-port=...`. It talks CDP's
|
|
11
|
+
own standard primitives (`Page.startScreencast`, `Input.dispatchMouseEvent`,
|
|
12
|
+
`Input.dispatchKeyEvent`) — not any tool-specific protocol — so one bridge
|
|
13
|
+
covers all of them. Functionally identical to the
|
|
14
|
+
[Node package of the same name](https://github.com/aqiu9/HITL-Broker/tree/main/packages/node/hitl-cdp-browser-provider);
|
|
15
|
+
pick whichever fits your automation stack (e.g. Playwright-Python/Selenium-Python
|
|
16
|
+
users won't need Node at all).
|
|
17
|
+
|
|
18
|
+
## Install
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
pip install hitl-cdp-browser-provider
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Use
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
# Point it at a CDP HTTP base -- it auto-picks the first "page" target
|
|
28
|
+
hitl-cdp-browser-provider --cdp-http http://127.0.0.1:9222 --bind 0.0.0.0:18234
|
|
29
|
+
|
|
30
|
+
# Or an exact page WebSocket URL if you already have one
|
|
31
|
+
hitl-cdp-browser-provider --cdp-url ws://127.0.0.1:9222/devtools/page/ABC123 --bind 0.0.0.0:18234
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Then point your HITL Source's `browser_endpoint` (or any equivalent
|
|
35
|
+
"internal_url for browser tasks" field) at `<bind-host>:<bind-port>`.
|
|
36
|
+
|
|
37
|
+
### Finding a CDP endpoint
|
|
38
|
+
|
|
39
|
+
- **agent-browser**: `agent-browser get cdp-url --session <name>` gives a
|
|
40
|
+
`ws://127.0.0.1:<port>/devtools/browser/<id>` browser-level URL — pass its
|
|
41
|
+
`http://127.0.0.1:<port>` base as `--cdp-http` (screencast needs a page
|
|
42
|
+
target, which `--cdp-http` auto-discovers via `/json/list`).
|
|
43
|
+
- **Chrome/Chromium directly**: launch with `--remote-debugging-port=9222`,
|
|
44
|
+
use `--cdp-http http://127.0.0.1:9222`.
|
|
45
|
+
- **Playwright (Python)**: launch Chromium with
|
|
46
|
+
`args=["--remote-debugging-port=9222"]`, then point `--cdp-http` at that
|
|
47
|
+
port. (`browser.new_cdp_session(page)` also works if you'd rather wire
|
|
48
|
+
the CDP session up yourself instead of using this CLI.)
|
|
49
|
+
- **Selenium (Python)**: Chrome launched with `--remote-debugging-port`
|
|
50
|
+
works the same way; Selenium 4's own `driver.execute_cdp_cmd` isn't
|
|
51
|
+
needed here since this bridge connects directly.
|
|
52
|
+
|
|
53
|
+
## Library use
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
import asyncio
|
|
57
|
+
from hitl_cdp_browser_provider import serve
|
|
58
|
+
|
|
59
|
+
asyncio.run(serve({
|
|
60
|
+
"cdp_http": "http://127.0.0.1:9222",
|
|
61
|
+
"cdp_url": None,
|
|
62
|
+
"bind": "0.0.0.0:18234",
|
|
63
|
+
"quality": 60,
|
|
64
|
+
"max_width": 1280,
|
|
65
|
+
"max_height": 800,
|
|
66
|
+
}))
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## Protocol
|
|
70
|
+
|
|
71
|
+
The viewer page renders `{"t":"frame","data":"<base64 jpeg>"}` messages onto
|
|
72
|
+
a `<canvas>` and sends back `{"t":"mouse",...}` / `{"t":"key",...}` messages,
|
|
73
|
+
which this bridge translates 1:1 into `Input.dispatchMouseEvent` /
|
|
74
|
+
`Input.dispatchKeyEvent` CDP calls.
|
|
75
|
+
|
|
76
|
+
## Not CDP? Use noVNC instead
|
|
77
|
+
|
|
78
|
+
If your browser session isn't driven by anything CDP-capable (or you want a
|
|
79
|
+
screen-level rather than browser-API-level handoff — e.g. an arbitrary GUI
|
|
80
|
+
app in a container, not just a browser), see the
|
|
81
|
+
[noVNC recipe](https://github.com/aqiu9/HITL-Broker/blob/main/docs/browser-provider-novnc.md)
|
|
82
|
+
in the HITL-Broker repo instead — noVNC + websockify already implements the
|
|
83
|
+
same "HTTP page + WS" contract with zero custom code, at the cost of needing
|
|
84
|
+
an X server (Xvfb) + VNC server in the container.
|
|
85
|
+
|
|
86
|
+
## License
|
|
87
|
+
|
|
88
|
+
MIT
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "hitl-cdp-browser-provider"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Bridge any CDP-capable browser session (agent-browser, Playwright, Puppeteer, plain Chrome) into an HTTP+WebSocket endpoint for human-in-the-loop remote viewing/control, e.g. HITL Broker's Browser Gateway."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
keywords = ["hitl", "cdp", "chrome-devtools-protocol", "browser-automation", "human-in-the-loop", "playwright", "puppeteer", "agent-browser"]
|
|
13
|
+
dependencies = [
|
|
14
|
+
"websockets>=13,<14",
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
[project.scripts]
|
|
18
|
+
hitl-cdp-browser-provider = "hitl_cdp_browser_provider.cli:main"
|
|
19
|
+
|
|
20
|
+
[project.urls]
|
|
21
|
+
Homepage = "https://github.com/aqiu9/HITL-Broker"
|
|
22
|
+
Repository = "https://github.com/aqiu9/HITL-Broker"
|
|
23
|
+
|
|
24
|
+
[tool.hatch.build.targets.wheel]
|
|
25
|
+
packages = ["src/hitl_cdp_browser_provider"]
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
"""Bridge a Chrome DevTools Protocol (CDP) browser session into a plain
|
|
2
|
+
HTTP page + WebSocket endpoint.
|
|
3
|
+
|
|
4
|
+
Talks CDP's own standard primitives (Page.startScreencast,
|
|
5
|
+
Input.dispatchMouseEvent, Input.dispatchKeyEvent) -- not any
|
|
6
|
+
automation-tool-specific protocol -- so this works against agent-browser,
|
|
7
|
+
Playwright, Puppeteer, or a plain `chrome --remote-debugging-port=...`.
|
|
8
|
+
Functionally identical to the Node implementation
|
|
9
|
+
(`hitl-cdp-browser-provider` npm package); pick whichever language fits
|
|
10
|
+
your automation stack.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import asyncio
|
|
16
|
+
import json
|
|
17
|
+
import urllib.error
|
|
18
|
+
import urllib.request
|
|
19
|
+
from typing import Any, Optional
|
|
20
|
+
|
|
21
|
+
import websockets
|
|
22
|
+
|
|
23
|
+
from .viewer import VIEWER_HTML
|
|
24
|
+
|
|
25
|
+
_MOUSE_EVENT_TYPE_MAP = {
|
|
26
|
+
"down": "mousePressed",
|
|
27
|
+
"up": "mouseReleased",
|
|
28
|
+
"move": "mouseMoved",
|
|
29
|
+
"wheel": "mouseWheel",
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
async def resolve_cdp_ws_url(cdp_url: Optional[str], cdp_http: Optional[str]) -> str:
|
|
34
|
+
"""Resolve a concrete CDP page WebSocket URL.
|
|
35
|
+
|
|
36
|
+
Accepts either an exact ws://.../devtools/page/<id> URL, or an http(s)
|
|
37
|
+
CDP base (e.g. http://127.0.0.1:9222) from which the first "page"
|
|
38
|
+
target is picked via the standard GET /json/list endpoint.
|
|
39
|
+
"""
|
|
40
|
+
if cdp_url:
|
|
41
|
+
return cdp_url
|
|
42
|
+
assert cdp_http is not None
|
|
43
|
+
base = cdp_http.rstrip("/")
|
|
44
|
+
try:
|
|
45
|
+
with urllib.request.urlopen(f"{base}/json/list", timeout=5) as resp:
|
|
46
|
+
targets = json.loads(resp.read())
|
|
47
|
+
except urllib.error.URLError as exc:
|
|
48
|
+
raise RuntimeError(f"GET {base}/json/list failed: {exc}") from exc
|
|
49
|
+
page = next((t for t in targets if t.get("type") == "page"), None)
|
|
50
|
+
if page is None and targets:
|
|
51
|
+
page = targets[0]
|
|
52
|
+
if page is None or not page.get("webSocketDebuggerUrl"):
|
|
53
|
+
raise RuntimeError(f"No CDP page target found at {base}/json/list")
|
|
54
|
+
return page["webSocketDebuggerUrl"]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class CdpSession:
|
|
58
|
+
"""Thin JSON-RPC wrapper around a CDP WebSocket connection."""
|
|
59
|
+
|
|
60
|
+
def __init__(self, ws: Any, on_event) -> None:
|
|
61
|
+
self._ws = ws
|
|
62
|
+
self._on_event = on_event
|
|
63
|
+
self._next_id = 1
|
|
64
|
+
self._pending: dict[int, asyncio.Future] = {}
|
|
65
|
+
self._reader_task = asyncio.ensure_future(self._reader())
|
|
66
|
+
|
|
67
|
+
@classmethod
|
|
68
|
+
async def connect(cls, cdp_ws_url: str, on_event) -> "CdpSession":
|
|
69
|
+
ws = await websockets.connect(cdp_ws_url, max_size=None)
|
|
70
|
+
return cls(ws, on_event)
|
|
71
|
+
|
|
72
|
+
async def send(self, method: str, params: Optional[dict] = None) -> Any:
|
|
73
|
+
msg_id = self._next_id
|
|
74
|
+
self._next_id += 1
|
|
75
|
+
fut: asyncio.Future = asyncio.get_event_loop().create_future()
|
|
76
|
+
self._pending[msg_id] = fut
|
|
77
|
+
await self._ws.send(json.dumps({"id": msg_id, "method": method, "params": params or {}}))
|
|
78
|
+
return await fut
|
|
79
|
+
|
|
80
|
+
async def _reader(self) -> None:
|
|
81
|
+
try:
|
|
82
|
+
async for raw in self._ws:
|
|
83
|
+
msg = json.loads(raw)
|
|
84
|
+
if "id" in msg and msg["id"] in self._pending:
|
|
85
|
+
fut = self._pending.pop(msg["id"])
|
|
86
|
+
if fut.done():
|
|
87
|
+
continue
|
|
88
|
+
if "error" in msg:
|
|
89
|
+
fut.set_exception(RuntimeError(msg["error"].get("message", "CDP error")))
|
|
90
|
+
else:
|
|
91
|
+
fut.set_result(msg.get("result"))
|
|
92
|
+
elif "method" in msg:
|
|
93
|
+
self._on_event(msg["method"], msg.get("params") or {})
|
|
94
|
+
except websockets.ConnectionClosed:
|
|
95
|
+
pass
|
|
96
|
+
finally:
|
|
97
|
+
for fut in self._pending.values():
|
|
98
|
+
if not fut.done():
|
|
99
|
+
fut.set_exception(RuntimeError("CDP connection closed"))
|
|
100
|
+
self._pending.clear()
|
|
101
|
+
|
|
102
|
+
async def close(self) -> None:
|
|
103
|
+
self._reader_task.cancel()
|
|
104
|
+
try:
|
|
105
|
+
await self._ws.close()
|
|
106
|
+
except Exception:
|
|
107
|
+
pass
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
async def _handle_viewer(viewer_ws: Any, cdp_ws_url: str, opts: dict) -> None:
|
|
111
|
+
def on_event(method: str, params: dict) -> None:
|
|
112
|
+
if method != "Page.screencastFrame":
|
|
113
|
+
return
|
|
114
|
+
asyncio.ensure_future(_on_frame(params))
|
|
115
|
+
|
|
116
|
+
async def _on_frame(params: dict) -> None:
|
|
117
|
+
data = params.get("data")
|
|
118
|
+
session_id = params.get("sessionId")
|
|
119
|
+
try:
|
|
120
|
+
await viewer_ws.send(json.dumps({"t": "frame", "data": data}))
|
|
121
|
+
except Exception:
|
|
122
|
+
pass
|
|
123
|
+
try:
|
|
124
|
+
await cdp.send("Page.screencastFrameAck", {"sessionId": session_id})
|
|
125
|
+
except Exception:
|
|
126
|
+
pass
|
|
127
|
+
|
|
128
|
+
cdp = await CdpSession.connect(cdp_ws_url, on_event)
|
|
129
|
+
|
|
130
|
+
try:
|
|
131
|
+
await cdp.send("Page.enable")
|
|
132
|
+
# See the Node implementation's comment: Chrome only treats
|
|
133
|
+
# Input.dispatchMouseEvent as a trusted user gesture (able to
|
|
134
|
+
# trigger anchor navigation, focus changes, etc.) when the target
|
|
135
|
+
# tab/window is actually foregrounded at the OS level. A headed
|
|
136
|
+
# Chrome left in the background otherwise accepts every dispatch
|
|
137
|
+
# call with no error but silently drops the gesture.
|
|
138
|
+
try:
|
|
139
|
+
await cdp.send("Page.bringToFront")
|
|
140
|
+
except Exception:
|
|
141
|
+
pass
|
|
142
|
+
await cdp.send(
|
|
143
|
+
"Page.startScreencast",
|
|
144
|
+
{
|
|
145
|
+
"format": "jpeg",
|
|
146
|
+
"quality": opts["quality"],
|
|
147
|
+
"maxWidth": opts["max_width"],
|
|
148
|
+
"maxHeight": opts["max_height"],
|
|
149
|
+
"everyNthFrame": 1,
|
|
150
|
+
},
|
|
151
|
+
)
|
|
152
|
+
await viewer_ws.send(json.dumps({"t": "status", "state": "connected"}))
|
|
153
|
+
except Exception as exc:
|
|
154
|
+
try:
|
|
155
|
+
await viewer_ws.send(json.dumps({"t": "status", "state": "error", "message": str(exc)}))
|
|
156
|
+
except Exception:
|
|
157
|
+
pass
|
|
158
|
+
await cdp.close()
|
|
159
|
+
return
|
|
160
|
+
|
|
161
|
+
try:
|
|
162
|
+
async for raw in viewer_ws:
|
|
163
|
+
try:
|
|
164
|
+
msg = json.loads(raw)
|
|
165
|
+
except Exception:
|
|
166
|
+
continue
|
|
167
|
+
try:
|
|
168
|
+
if msg.get("t") == "mouse":
|
|
169
|
+
cdp_type = _MOUSE_EVENT_TYPE_MAP.get(msg.get("event"))
|
|
170
|
+
if not cdp_type:
|
|
171
|
+
continue
|
|
172
|
+
await cdp.send(
|
|
173
|
+
"Input.dispatchMouseEvent",
|
|
174
|
+
{
|
|
175
|
+
"type": cdp_type,
|
|
176
|
+
"x": msg.get("x", 0),
|
|
177
|
+
"y": msg.get("y", 0),
|
|
178
|
+
"button": msg.get("button", "none"),
|
|
179
|
+
"clickCount": 1 if msg.get("event") in ("down", "up") else 0,
|
|
180
|
+
"deltaX": msg.get("deltaX", 0),
|
|
181
|
+
"deltaY": msg.get("deltaY", 0),
|
|
182
|
+
},
|
|
183
|
+
)
|
|
184
|
+
elif msg.get("t") == "key":
|
|
185
|
+
cdp_type = "keyDown" if msg.get("event") == "down" else "keyUp"
|
|
186
|
+
await cdp.send(
|
|
187
|
+
"Input.dispatchKeyEvent",
|
|
188
|
+
{
|
|
189
|
+
"type": cdp_type,
|
|
190
|
+
"key": msg.get("key"),
|
|
191
|
+
"code": msg.get("code"),
|
|
192
|
+
"text": msg.get("text") or None,
|
|
193
|
+
"unmodifiedText": msg.get("text") or None,
|
|
194
|
+
},
|
|
195
|
+
)
|
|
196
|
+
except Exception:
|
|
197
|
+
pass # best-effort input forwarding
|
|
198
|
+
except websockets.ConnectionClosed:
|
|
199
|
+
pass
|
|
200
|
+
finally:
|
|
201
|
+
try:
|
|
202
|
+
await cdp.send("Page.stopScreencast")
|
|
203
|
+
except Exception:
|
|
204
|
+
pass
|
|
205
|
+
await cdp.close()
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _make_process_request(html_bytes: bytes):
|
|
209
|
+
async def process_request(path: str, request_headers):
|
|
210
|
+
upgrade = request_headers.get("Upgrade", "")
|
|
211
|
+
if upgrade.lower() != "websocket":
|
|
212
|
+
return (200, [("Content-Type", "text/html; charset=utf-8")], html_bytes)
|
|
213
|
+
return None
|
|
214
|
+
|
|
215
|
+
return process_request
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
async def serve(opts: dict) -> None:
|
|
219
|
+
cdp_ws_url = await resolve_cdp_ws_url(opts.get("cdp_url"), opts.get("cdp_http"))
|
|
220
|
+
host, port_str = opts["bind"].split(":")
|
|
221
|
+
port = int(port_str)
|
|
222
|
+
html_bytes = VIEWER_HTML.encode("utf-8")
|
|
223
|
+
|
|
224
|
+
async def handler(websocket, *_path_arg):
|
|
225
|
+
# older/newer websockets versions differ on whether the handler
|
|
226
|
+
# receives a second positional `path` argument; accept both.
|
|
227
|
+
await _handle_viewer(websocket, cdp_ws_url, opts)
|
|
228
|
+
|
|
229
|
+
async with websockets.serve(
|
|
230
|
+
handler,
|
|
231
|
+
host,
|
|
232
|
+
port,
|
|
233
|
+
process_request=_make_process_request(html_bytes),
|
|
234
|
+
):
|
|
235
|
+
print(f"hitl-cdp-browser-provider listening on http://{host}:{port} -> {cdp_ws_url}", flush=True)
|
|
236
|
+
await asyncio.Future()
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import asyncio
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
from .bridge import serve
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def main() -> None:
|
|
9
|
+
parser = argparse.ArgumentParser(
|
|
10
|
+
prog="hitl-cdp-browser-provider",
|
|
11
|
+
description=(
|
|
12
|
+
"Bridge ANY Chrome DevTools Protocol (CDP) browser session -- agent-browser, "
|
|
13
|
+
"Playwright, Puppeteer, or a plain 'chrome --remote-debugging-port=...' -- into "
|
|
14
|
+
"a plain HTTP page + WebSocket endpoint (the shape HITL Broker's Browser Gateway, "
|
|
15
|
+
"or any similar system, expects)."
|
|
16
|
+
),
|
|
17
|
+
)
|
|
18
|
+
group = parser.add_mutually_exclusive_group(required=True)
|
|
19
|
+
group.add_argument("--cdp-url", help="Exact CDP page WebSocket URL")
|
|
20
|
+
group.add_argument(
|
|
21
|
+
"--cdp-http",
|
|
22
|
+
help="CDP HTTP base (e.g. http://127.0.0.1:9222); auto-picks the first 'page' target via /json/list",
|
|
23
|
+
)
|
|
24
|
+
parser.add_argument("--bind", default="127.0.0.1:18234", help="host:port to listen on (default 127.0.0.1:18234)")
|
|
25
|
+
parser.add_argument("--quality", type=int, default=60, help="JPEG quality for the screencast (default 60)")
|
|
26
|
+
parser.add_argument("--max-width", type=int, default=1280, help="Screencast max width in px (default 1280)")
|
|
27
|
+
parser.add_argument("--max-height", type=int, default=800, help="Screencast max height in px (default 800)")
|
|
28
|
+
args = parser.parse_args()
|
|
29
|
+
|
|
30
|
+
opts = {
|
|
31
|
+
"cdp_url": args.cdp_url,
|
|
32
|
+
"cdp_http": args.cdp_http,
|
|
33
|
+
"bind": args.bind,
|
|
34
|
+
"quality": args.quality,
|
|
35
|
+
"max_width": args.max_width,
|
|
36
|
+
"max_height": args.max_height,
|
|
37
|
+
}
|
|
38
|
+
try:
|
|
39
|
+
asyncio.run(serve(opts))
|
|
40
|
+
except KeyboardInterrupt:
|
|
41
|
+
pass
|
|
42
|
+
except Exception as exc: # pragma: no cover - top-level CLI error path
|
|
43
|
+
print(f"Failed to start bridge: {exc}", file=sys.stderr)
|
|
44
|
+
sys.exit(1)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
if __name__ == "__main__":
|
|
48
|
+
main()
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Self-contained viewer page served on any GET request.
|
|
2
|
+
|
|
3
|
+
Renders CDP screencast frames on a <canvas> and forwards mouse/keyboard
|
|
4
|
+
events back over the same-origin WebSocket. Identical wire protocol to the
|
|
5
|
+
Node implementation (`hitl-cdp-browser-provider` npm package) -- both talk
|
|
6
|
+
the same {"t": "frame"/"mouse"/"key", ...} JSON shape, so either bridge
|
|
7
|
+
works with either viewer.
|
|
8
|
+
|
|
9
|
+
No absolute-path assets (inline CSS/JS only) so this still works when
|
|
10
|
+
served behind an arbitrary path prefix, e.g. HITL Broker's
|
|
11
|
+
/browser/{token}/... proxy.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
VIEWER_HTML = """<!doctype html>
|
|
15
|
+
<html>
|
|
16
|
+
<head>
|
|
17
|
+
<meta charset="utf-8">
|
|
18
|
+
<title>HITL Browser Session</title>
|
|
19
|
+
<style>
|
|
20
|
+
html,body{margin:0;height:100%;background:#111;overflow:hidden}
|
|
21
|
+
#c{display:block;width:100%;height:100%;object-fit:contain;cursor:crosshair;outline:none}
|
|
22
|
+
#status{position:fixed;top:8px;left:8px;color:#0f0;font:12px monospace;background:#000a;padding:4px 8px;border-radius:4px;pointer-events:none}
|
|
23
|
+
</style>
|
|
24
|
+
</head>
|
|
25
|
+
<body>
|
|
26
|
+
<div id="status">connecting…</div>
|
|
27
|
+
<canvas id="c" tabindex="0"></canvas>
|
|
28
|
+
<script>
|
|
29
|
+
(function () {
|
|
30
|
+
var canvas = document.getElementById('c');
|
|
31
|
+
var ctx = canvas.getContext('2d');
|
|
32
|
+
var status = document.getElementById('status');
|
|
33
|
+
var img = new Image();
|
|
34
|
+
var naturalW = 0, naturalH = 0;
|
|
35
|
+
var proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
36
|
+
var ws = new WebSocket(proto + '//' + location.host + location.pathname);
|
|
37
|
+
|
|
38
|
+
img.onload = function () {
|
|
39
|
+
naturalW = img.naturalWidth;
|
|
40
|
+
naturalH = img.naturalHeight;
|
|
41
|
+
if (canvas.width !== naturalW || canvas.height !== naturalH) {
|
|
42
|
+
canvas.width = naturalW;
|
|
43
|
+
canvas.height = naturalH;
|
|
44
|
+
}
|
|
45
|
+
ctx.drawImage(img, 0, 0);
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
ws.onopen = function () { status.textContent = 'connected'; };
|
|
49
|
+
ws.onclose = function () { status.textContent = 'disconnected'; };
|
|
50
|
+
ws.onerror = function () { status.textContent = 'error'; };
|
|
51
|
+
ws.onmessage = function (ev) {
|
|
52
|
+
var msg;
|
|
53
|
+
try { msg = JSON.parse(ev.data); } catch (e) { return; }
|
|
54
|
+
if (msg.t === 'frame') {
|
|
55
|
+
img.src = 'data:image/jpeg;base64,' + msg.data;
|
|
56
|
+
} else if (msg.t === 'status') {
|
|
57
|
+
status.textContent = msg.message || msg.state;
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
function toBrowserCoords(clientX, clientY) {
|
|
62
|
+
var rect = canvas.getBoundingClientRect();
|
|
63
|
+
var scaleX = naturalW ? naturalW / rect.width : 1;
|
|
64
|
+
var scaleY = naturalH ? naturalH / rect.height : 1;
|
|
65
|
+
return { x: (clientX - rect.left) * scaleX, y: (clientY - rect.top) * scaleY };
|
|
66
|
+
}
|
|
67
|
+
function send(o) { if (ws.readyState === 1) ws.send(JSON.stringify(o)); }
|
|
68
|
+
function btnName(b) { return b === 2 ? 'right' : b === 1 ? 'middle' : 'left'; }
|
|
69
|
+
|
|
70
|
+
canvas.addEventListener('mousedown', function (e) {
|
|
71
|
+
var p = toBrowserCoords(e.clientX, e.clientY);
|
|
72
|
+
send({ t: 'mouse', event: 'down', x: p.x, y: p.y, button: btnName(e.button) });
|
|
73
|
+
canvas.focus();
|
|
74
|
+
e.preventDefault();
|
|
75
|
+
});
|
|
76
|
+
canvas.addEventListener('mouseup', function (e) {
|
|
77
|
+
var p = toBrowserCoords(e.clientX, e.clientY);
|
|
78
|
+
send({ t: 'mouse', event: 'up', x: p.x, y: p.y, button: btnName(e.button) });
|
|
79
|
+
});
|
|
80
|
+
canvas.addEventListener('mousemove', function (e) {
|
|
81
|
+
var p = toBrowserCoords(e.clientX, e.clientY);
|
|
82
|
+
send({ t: 'mouse', event: 'move', x: p.x, y: p.y, button: 'none' });
|
|
83
|
+
});
|
|
84
|
+
canvas.addEventListener('wheel', function (e) {
|
|
85
|
+
var p = toBrowserCoords(e.clientX, e.clientY);
|
|
86
|
+
send({ t: 'mouse', event: 'wheel', x: p.x, y: p.y, deltaX: e.deltaX, deltaY: e.deltaY });
|
|
87
|
+
e.preventDefault();
|
|
88
|
+
}, { passive: false });
|
|
89
|
+
canvas.addEventListener('keydown', function (e) {
|
|
90
|
+
send({ t: 'key', event: 'down', key: e.key, code: e.code, text: e.key.length === 1 ? e.key : '' });
|
|
91
|
+
e.preventDefault();
|
|
92
|
+
});
|
|
93
|
+
canvas.addEventListener('keyup', function (e) {
|
|
94
|
+
send({ t: 'key', event: 'up', key: e.key, code: e.code });
|
|
95
|
+
e.preventDefault();
|
|
96
|
+
});
|
|
97
|
+
})();
|
|
98
|
+
</script>
|
|
99
|
+
</body>
|
|
100
|
+
</html>"""
|