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.
@@ -0,0 +1,4 @@
1
+ __pycache__/
2
+ *.egg-info/
3
+ build/
4
+ dist/
@@ -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,5 @@
1
+ from .bridge import resolve_cdp_ws_url, serve
2
+ from .viewer import VIEWER_HTML
3
+
4
+ __all__ = ["serve", "resolve_cdp_ws_url", "VIEWER_HTML"]
5
+ __version__ = "0.1.0"
@@ -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>"""