mirror of
https://github.com/KingOfTheNOPs/CDP-Toolkit
synced 2026-06-29 08:59:48 +00:00
853 lines
30 KiB
Python
853 lines
30 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import contextlib
|
|
import json
|
|
import time
|
|
from dataclasses import dataclass
|
|
from typing import Any
|
|
|
|
from aiohttp import WSMsgType, web
|
|
|
|
from .client import CDPClient, TargetSession
|
|
from .log import info
|
|
|
|
|
|
def normalize_url(url: str | None) -> str:
|
|
value = (url or "").strip()
|
|
if not value:
|
|
return "about:blank"
|
|
if "://" not in value and not value.startswith("about:"):
|
|
return "https://" + value
|
|
return value
|
|
|
|
|
|
def normalize_browser_socks(proxy: str | None) -> str | None:
|
|
if proxy and proxy.lower().startswith("socks5h://"):
|
|
return "socks5://" + proxy[len("socks5h://") :]
|
|
return proxy
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class RemoteBrowserOptions:
|
|
cdp_endpoint: str
|
|
listen_host: str
|
|
listen_port: int
|
|
socks: str | None = None
|
|
browser_socks: str | None = None
|
|
start_url: str = "about:blank"
|
|
mode: str = "offscreen"
|
|
width: int = 1440
|
|
height: int = 900
|
|
quality: int = 70
|
|
every_nth_frame: int = 1
|
|
offscreen_left: int = -2400
|
|
offscreen_top: int = -1200
|
|
close_target: bool = True
|
|
import_cookies: bool = True
|
|
|
|
|
|
class RemoteBrowserServer:
|
|
def __init__(self, options: RemoteBrowserOptions):
|
|
self.options = options
|
|
self.cdp: CDPClient | None = None
|
|
self.session: TargetSession | None = None
|
|
self.target_id: str | None = None
|
|
self.browser_context_id: str | None = None
|
|
self.window_id: int | None = None
|
|
self._closed = asyncio.Event()
|
|
self._clients: set[web.WebSocketResponse] = set()
|
|
self._screencast_task: asyncio.Task | None = None
|
|
self._state_task: asyncio.Task | None = None
|
|
self._last_frame: dict[str, Any] | None = None
|
|
self._last_state: dict[str, Any] | None = None
|
|
self._started_at = time.time()
|
|
|
|
async def start(self) -> None:
|
|
if self.options.mode not in {"offscreen", "background"}:
|
|
raise ValueError("--mode must be offscreen or background")
|
|
|
|
self.cdp = await CDPClient.connect(self.options.cdp_endpoint, self.options.socks)
|
|
await self._create_target()
|
|
await self._enable_target()
|
|
self._screencast_task = asyncio.create_task(self._screencast_loop())
|
|
self._state_task = asyncio.create_task(self._state_loop())
|
|
|
|
async def close(self) -> None:
|
|
if self._closed.is_set():
|
|
return
|
|
self._closed.set()
|
|
for task in (self._screencast_task, self._state_task):
|
|
if task:
|
|
task.cancel()
|
|
with contextlib.suppress(asyncio.CancelledError):
|
|
await task
|
|
for ws in list(self._clients):
|
|
with contextlib.suppress(Exception):
|
|
await ws.close()
|
|
self._clients.clear()
|
|
|
|
if self.session:
|
|
with contextlib.suppress(Exception):
|
|
await self.session.send("Page.stopScreencast")
|
|
if self.cdp:
|
|
with contextlib.suppress(Exception):
|
|
await self.cdp.detach(self.session)
|
|
self.session = None
|
|
|
|
if self.cdp:
|
|
if self.target_id and self.options.close_target:
|
|
with contextlib.suppress(Exception):
|
|
await self.cdp.close_target(self.target_id)
|
|
if self.browser_context_id:
|
|
with contextlib.suppress(Exception):
|
|
await self.cdp.dispose_browser_context(self.browser_context_id)
|
|
await self.cdp.close()
|
|
self.cdp = None
|
|
|
|
async def wait_closed(self) -> None:
|
|
await self._closed.wait()
|
|
|
|
async def _create_target(self) -> None:
|
|
assert self.cdp is not None
|
|
effective_browser_socks = normalize_browser_socks(self.options.browser_socks)
|
|
if effective_browser_socks:
|
|
self.browser_context_id = await self.cdp.create_browser_context(
|
|
proxy_server=effective_browser_socks,
|
|
dispose_on_detach=False,
|
|
)
|
|
if self.options.import_cookies:
|
|
cookies = await self.cdp.get_cookies()
|
|
await self.cdp.set_cookies(cookies, browser_context_id=self.browser_context_id)
|
|
info("imported {} cookies into proxied browser context", len(cookies))
|
|
|
|
params: dict[str, Any] = {"url": "about:blank"}
|
|
if self.browser_context_id:
|
|
params["browserContextId"] = self.browser_context_id
|
|
if self.options.mode == "offscreen":
|
|
params.update(
|
|
{
|
|
"newWindow": True,
|
|
"width": self.options.width,
|
|
"height": self.options.height,
|
|
"background": True,
|
|
}
|
|
)
|
|
params["left"] = self.options.offscreen_left
|
|
params["top"] = self.options.offscreen_top
|
|
elif self.options.mode == "background":
|
|
params["background"] = True
|
|
|
|
info("creating remote-browser target mode={} url={}", self.options.mode, self.options.start_url)
|
|
result = await self.cdp.send("Target.createTarget", params)
|
|
self.target_id = result["targetId"]
|
|
self.session = await self.cdp.attach(self.target_id)
|
|
|
|
if self.options.mode == "offscreen":
|
|
with contextlib.suppress(Exception):
|
|
window = await self.cdp.send("Browser.getWindowForTarget", {"targetId": self.target_id})
|
|
self.window_id = int(window["windowId"])
|
|
bounds: dict[str, Any] = {
|
|
"width": self.options.width,
|
|
"height": self.options.height,
|
|
"windowState": "normal",
|
|
}
|
|
bounds["left"] = self.options.offscreen_left
|
|
bounds["top"] = self.options.offscreen_top
|
|
await self.cdp.send("Browser.setWindowBounds", {"windowId": self.window_id, "bounds": bounds})
|
|
|
|
async def _enable_target(self) -> None:
|
|
assert self.session is not None
|
|
await self.session.send("Page.enable")
|
|
await self.session.send("Runtime.enable")
|
|
with contextlib.suppress(Exception):
|
|
await self.session.send("Emulation.setFocusEmulationEnabled", {"enabled": True})
|
|
with contextlib.suppress(Exception):
|
|
await self.session.send(
|
|
"Emulation.setDeviceMetricsOverride",
|
|
{
|
|
"width": self.options.width,
|
|
"height": self.options.height,
|
|
"deviceScaleFactor": 1,
|
|
"mobile": False,
|
|
"screenWidth": self.options.width,
|
|
"screenHeight": self.options.height,
|
|
},
|
|
)
|
|
await self.session.send("Page.navigate", {"url": normalize_url(self.options.start_url)})
|
|
await self.session.send(
|
|
"Page.startScreencast",
|
|
{
|
|
"format": "jpeg",
|
|
"quality": self.options.quality,
|
|
"maxWidth": self.options.width,
|
|
"maxHeight": self.options.height,
|
|
"everyNthFrame": self.options.every_nth_frame,
|
|
},
|
|
)
|
|
|
|
async def _screencast_loop(self) -> None:
|
|
assert self.cdp is not None
|
|
assert self.session is not None
|
|
while not self._closed.is_set():
|
|
try:
|
|
msg = await self.cdp.connection.wait_for_event("Page.screencastFrame", timeout=30)
|
|
except asyncio.TimeoutError:
|
|
info("waiting for screencast frames from target {}", self.target_id)
|
|
continue
|
|
if msg.get("sessionId") != self.session.session_id:
|
|
continue
|
|
params = msg.get("params", {})
|
|
metadata = params.get("metadata", {})
|
|
frame = {
|
|
"type": "frame",
|
|
"data": params.get("data", ""),
|
|
"metadata": metadata,
|
|
"frameSessionId": params.get("sessionId"),
|
|
"targetId": self.target_id,
|
|
"ts": time.time(),
|
|
}
|
|
self._last_frame = frame
|
|
await self.session.send("Page.screencastFrameAck", {"sessionId": params.get("sessionId")})
|
|
await self._broadcast(frame)
|
|
|
|
async def _state_loop(self) -> None:
|
|
while not self._closed.is_set():
|
|
try:
|
|
state = await self.current_state()
|
|
self._last_state = state
|
|
await self._broadcast({"type": "state", "state": state})
|
|
except Exception as exc:
|
|
info("state poll failed: {}", exc)
|
|
await asyncio.sleep(1)
|
|
|
|
async def current_state(self) -> dict[str, Any]:
|
|
assert self.session is not None
|
|
result = await self.session.evaluate(
|
|
"""
|
|
(() => ({
|
|
url: location.href,
|
|
title: document.title,
|
|
readyState: document.readyState,
|
|
width: window.innerWidth,
|
|
height: window.innerHeight,
|
|
scrollX: window.scrollX,
|
|
scrollY: window.scrollY
|
|
}))()
|
|
"""
|
|
)
|
|
value = result.get("result", {}).get("value") or {}
|
|
return {
|
|
"url": value.get("url", ""),
|
|
"title": value.get("title", ""),
|
|
"readyState": value.get("readyState", ""),
|
|
"width": int(value.get("width") or self.options.width),
|
|
"height": int(value.get("height") or self.options.height),
|
|
"scrollX": value.get("scrollX", 0),
|
|
"scrollY": value.get("scrollY", 0),
|
|
"mode": self.options.mode,
|
|
"targetId": self.target_id,
|
|
"browserContextId": self.browser_context_id,
|
|
"uptimeSeconds": round(time.time() - self._started_at, 1),
|
|
}
|
|
|
|
async def handle_ws(self, request: web.Request) -> web.WebSocketResponse:
|
|
ws = web.WebSocketResponse(max_msg_size=4 * 1024 * 1024, heartbeat=25)
|
|
await ws.prepare(request)
|
|
self._clients.add(ws)
|
|
if self._last_state:
|
|
await ws.send_str(json.dumps({"type": "state", "state": self._last_state}, separators=(",", ":")))
|
|
if self._last_frame:
|
|
await ws.send_str(json.dumps(self._last_frame, separators=(",", ":")))
|
|
try:
|
|
async for msg in ws:
|
|
if msg.type == WSMsgType.TEXT:
|
|
await self.handle_client_message(ws, json.loads(msg.data))
|
|
elif msg.type in {WSMsgType.ERROR, WSMsgType.CLOSE}:
|
|
break
|
|
finally:
|
|
self._clients.discard(ws)
|
|
return ws
|
|
|
|
async def handle_client_message(self, ws: web.WebSocketResponse, payload: dict[str, Any]) -> None:
|
|
assert self.session is not None
|
|
kind = payload.get("type")
|
|
if kind == "navigate":
|
|
await self.session.send("Page.navigate", {"url": normalize_url(payload.get("url"))})
|
|
elif kind == "reload":
|
|
await self.session.send("Page.reload", {"ignoreCache": bool(payload.get("ignoreCache", False))})
|
|
elif kind == "history":
|
|
await self._go_history(int(payload.get("delta", 0)))
|
|
elif kind == "mouse":
|
|
await self._dispatch_mouse(payload)
|
|
elif kind == "scroll":
|
|
await self._dom_scroll(float(payload.get("deltaX", 0)), float(payload.get("deltaY", 0)))
|
|
elif kind == "key":
|
|
await self._dispatch_key(payload)
|
|
elif kind == "text":
|
|
await self.session.send("Input.insertText", {"text": str(payload.get("text", ""))})
|
|
elif kind == "copy":
|
|
result = await self._copy_text()
|
|
await ws.send_str(json.dumps({"type": "clipboard", **result}, separators=(",", ":")))
|
|
elif kind == "close":
|
|
await self.close()
|
|
|
|
async def _go_history(self, delta: int) -> None:
|
|
if delta == 0:
|
|
return
|
|
assert self.session is not None
|
|
history = await self.session.send("Page.getNavigationHistory")
|
|
entries = history.get("entries", [])
|
|
target_index = int(history.get("currentIndex", 0)) + delta
|
|
if 0 <= target_index < len(entries):
|
|
await self.session.send("Page.navigateToHistoryEntry", {"entryId": entries[target_index]["id"]})
|
|
|
|
async def _dispatch_mouse(self, payload: dict[str, Any]) -> None:
|
|
assert self.session is not None
|
|
params: dict[str, Any] = {
|
|
"type": str(payload.get("event", "mouseMoved")),
|
|
"x": float(payload.get("x", 0)),
|
|
"y": float(payload.get("y", 0)),
|
|
"button": str(payload.get("button", "none")),
|
|
"modifiers": int(payload.get("modifiers", 0)),
|
|
}
|
|
if "clickCount" in payload:
|
|
params["clickCount"] = int(payload.get("clickCount") or 1)
|
|
if params["type"] == "mouseWheel":
|
|
params["deltaX"] = float(payload.get("deltaX", 0))
|
|
params["deltaY"] = float(payload.get("deltaY", 0))
|
|
await self.session.send("Input.dispatchMouseEvent", params)
|
|
|
|
async def _dom_scroll(self, delta_x: float, delta_y: float) -> None:
|
|
assert self.session is not None
|
|
await self.session.evaluate(
|
|
f"""
|
|
(() => {{
|
|
const deltaX = {delta_x};
|
|
const deltaY = {delta_y};
|
|
const x = Math.max(1, Math.floor(window.innerWidth / 2));
|
|
const y = Math.max(1, Math.floor(window.innerHeight / 2));
|
|
const canScroll = (node) => {{
|
|
if (!node) return false;
|
|
if (node === document.body || node === document.documentElement) {{
|
|
return document.documentElement.scrollHeight > window.innerHeight ||
|
|
document.documentElement.scrollWidth > window.innerWidth;
|
|
}}
|
|
const style = window.getComputedStyle(node);
|
|
const yScrollable = ['auto', 'scroll', 'overlay'].includes(style.overflowY) &&
|
|
node.scrollHeight > node.clientHeight;
|
|
const xScrollable = ['auto', 'scroll', 'overlay'].includes(style.overflowX) &&
|
|
node.scrollWidth > node.clientWidth;
|
|
return yScrollable || xScrollable;
|
|
}};
|
|
let target = document.elementFromPoint(x, y);
|
|
while (target && target !== document.body && target !== document.documentElement && !canScroll(target)) {{
|
|
target = target.parentElement;
|
|
}}
|
|
if (!target || target === document.body || target === document.documentElement || !canScroll(target)) {{
|
|
window.scrollBy(deltaX, deltaY);
|
|
return {{ ok: true, target: 'window', scrollX: window.scrollX, scrollY: window.scrollY }};
|
|
}}
|
|
target.scrollLeft += deltaX;
|
|
target.scrollTop += deltaY;
|
|
return {{
|
|
ok: true,
|
|
target: target.tagName || 'element',
|
|
scrollLeft: target.scrollLeft,
|
|
scrollTop: target.scrollTop,
|
|
windowScrollX: window.scrollX,
|
|
windowScrollY: window.scrollY
|
|
}};
|
|
}})()
|
|
"""
|
|
)
|
|
|
|
async def _dispatch_key(self, payload: dict[str, Any]) -> None:
|
|
assert self.session is not None
|
|
params: dict[str, Any] = {
|
|
"type": str(payload.get("event", "keyDown")),
|
|
"key": str(payload.get("key", "")),
|
|
"code": str(payload.get("code", "")),
|
|
"windowsVirtualKeyCode": int(payload.get("windowsVirtualKeyCode", 0)),
|
|
"nativeVirtualKeyCode": int(payload.get("nativeVirtualKeyCode", 0)),
|
|
"modifiers": int(payload.get("modifiers", 0)),
|
|
}
|
|
text = str(payload.get("text", ""))
|
|
if text:
|
|
params["text"] = text
|
|
params["unmodifiedText"] = str(payload.get("unmodifiedText") or text)
|
|
await self.session.send("Input.dispatchKeyEvent", params)
|
|
|
|
async def _copy_text(self) -> dict[str, Any]:
|
|
assert self.session is not None
|
|
result = await self.session.evaluate(
|
|
"""
|
|
(() => {
|
|
const selection = (window.getSelection && window.getSelection().toString()) || '';
|
|
if (selection) return { ok: true, text: selection, source: 'selection' };
|
|
const active = document.activeElement;
|
|
if (active && typeof active.value === 'string') {
|
|
const start = typeof active.selectionStart === 'number' ? active.selectionStart : 0;
|
|
const end = typeof active.selectionEnd === 'number' ? active.selectionEnd : active.value.length;
|
|
const text = end > start ? active.value.slice(start, end) : active.value;
|
|
return { ok: true, text, source: 'active-value' };
|
|
}
|
|
if (active && active.isContentEditable) {
|
|
return { ok: true, text: active.innerText || active.textContent || '', source: 'contenteditable' };
|
|
}
|
|
return { ok: false, text: '', source: '', reason: 'no selected or focused text' };
|
|
})()
|
|
"""
|
|
)
|
|
return result.get("result", {}).get("value") or {"ok": False, "text": "", "reason": "copy failed"}
|
|
|
|
async def _broadcast(self, payload: dict[str, Any]) -> None:
|
|
if not self._clients:
|
|
return
|
|
text = json.dumps(payload, separators=(",", ":"))
|
|
stale: list[web.WebSocketResponse] = []
|
|
for ws in self._clients:
|
|
try:
|
|
await ws.send_str(text)
|
|
except Exception:
|
|
stale.append(ws)
|
|
for ws in stale:
|
|
self._clients.discard(ws)
|
|
|
|
|
|
VIEWER_HTML = """<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>CDP Browser</title>
|
|
<style>
|
|
:root {
|
|
color-scheme: dark;
|
|
--bg: #111316;
|
|
--bar: #191c20;
|
|
--bar2: #20242a;
|
|
--line: #353b44;
|
|
--text: #e8edf2;
|
|
--muted: #aeb8c4;
|
|
--accent: #72d28b;
|
|
--warn: #f5b85b;
|
|
--danger: #ef6f6c;
|
|
}
|
|
* { box-sizing: border-box; }
|
|
body {
|
|
margin: 0;
|
|
min-height: 100vh;
|
|
overflow: hidden;
|
|
background: var(--bg);
|
|
color: var(--text);
|
|
font-family: Segoe UI, Arial, sans-serif;
|
|
}
|
|
.shell {
|
|
display: grid;
|
|
grid-template-rows: auto 1fr;
|
|
height: 100vh;
|
|
}
|
|
.toolbar {
|
|
display: grid;
|
|
gap: 8px;
|
|
padding: 8px;
|
|
background: var(--bar);
|
|
border-bottom: 1px solid var(--line);
|
|
}
|
|
.row {
|
|
display: grid;
|
|
grid-template-columns: auto 1fr auto;
|
|
gap: 8px;
|
|
align-items: center;
|
|
}
|
|
.nav {
|
|
display: flex;
|
|
gap: 6px;
|
|
align-items: center;
|
|
}
|
|
.scroll-row {
|
|
display: flex;
|
|
gap: 6px;
|
|
flex-wrap: wrap;
|
|
align-items: center;
|
|
}
|
|
.scroll-row button {
|
|
min-width: 104px;
|
|
}
|
|
button, input {
|
|
height: 34px;
|
|
border: 1px solid var(--line);
|
|
border-radius: 6px;
|
|
background: var(--bar2);
|
|
color: var(--text);
|
|
font: inherit;
|
|
}
|
|
button {
|
|
min-width: 40px;
|
|
padding: 0 10px;
|
|
cursor: pointer;
|
|
}
|
|
button:hover, button:focus {
|
|
border-color: var(--accent);
|
|
outline: none;
|
|
}
|
|
button.danger:hover, button.danger:focus {
|
|
border-color: var(--danger);
|
|
}
|
|
input {
|
|
width: 100%;
|
|
padding: 0 10px;
|
|
}
|
|
.status {
|
|
display: flex;
|
|
gap: 12px;
|
|
min-width: 0;
|
|
color: var(--muted);
|
|
font-size: 12px;
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
}
|
|
.dot {
|
|
width: 8px;
|
|
height: 8px;
|
|
border-radius: 50%;
|
|
background: var(--warn);
|
|
display: inline-block;
|
|
}
|
|
.dot.ready { background: var(--accent); }
|
|
.viewport {
|
|
min-height: 0;
|
|
display: grid;
|
|
place-items: center;
|
|
background: #070809;
|
|
overflow: hidden;
|
|
}
|
|
canvas {
|
|
display: block;
|
|
max-width: 100%;
|
|
max-height: 100%;
|
|
outline: none;
|
|
background: #000;
|
|
image-rendering: auto;
|
|
}
|
|
canvas:focus {
|
|
box-shadow: inset 0 0 0 2px var(--accent);
|
|
}
|
|
@media (max-width: 720px) {
|
|
.row {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
.nav {
|
|
flex-wrap: wrap;
|
|
}
|
|
button {
|
|
min-width: 54px;
|
|
}
|
|
.scroll-row button {
|
|
flex: 1 1 120px;
|
|
}
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="shell">
|
|
<div class="toolbar">
|
|
<form class="row" id="nav-form">
|
|
<div class="nav">
|
|
<button type="button" id="back" title="Back">Back</button>
|
|
<button type="button" id="forward" title="Forward">Forward</button>
|
|
<button type="button" id="reload" title="Reload">Reload</button>
|
|
</div>
|
|
<input id="address" autocomplete="off" spellcheck="false" value="about:blank">
|
|
<div class="nav">
|
|
<button type="submit" title="Navigate">Go</button>
|
|
<button type="button" id="paste" title="Paste local clipboard">Paste</button>
|
|
<button type="button" id="copy" title="Copy remote selection">Copy</button>
|
|
<button type="button" class="danger" id="close" title="Close remote target">Close</button>
|
|
</div>
|
|
</form>
|
|
<div class="scroll-row">
|
|
<button type="button" data-scroll-y="-240" title="Scroll up">Scroll Up</button>
|
|
<button type="button" data-scroll-y="-720" title="Page up">Page Up</button>
|
|
<button type="button" data-scroll-y="720" title="Page down">Page Down</button>
|
|
<button type="button" data-scroll-y="240" title="Scroll down">Scroll Down</button>
|
|
</div>
|
|
<div class="status">
|
|
<span><span class="dot" id="dot"></span> <span id="conn">connecting</span></span>
|
|
<span id="page-state"></span>
|
|
<span id="target"></span>
|
|
</div>
|
|
</div>
|
|
<div class="viewport">
|
|
<canvas id="screen" tabindex="0"></canvas>
|
|
</div>
|
|
</div>
|
|
<script>
|
|
const canvas = document.getElementById('screen');
|
|
const ctx = canvas.getContext('2d');
|
|
const address = document.getElementById('address');
|
|
const conn = document.getElementById('conn');
|
|
const dot = document.getElementById('dot');
|
|
const pageState = document.getElementById('page-state');
|
|
const target = document.getElementById('target');
|
|
let ws = null;
|
|
let meta = { deviceWidth: 1440, deviceHeight: 900 };
|
|
let remoteState = {};
|
|
let lastMove = 0;
|
|
|
|
function connect() {
|
|
const scheme = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
ws = new WebSocket(`${scheme}//${location.host}/ws`);
|
|
ws.onopen = () => {
|
|
conn.textContent = 'connected';
|
|
dot.classList.add('ready');
|
|
};
|
|
ws.onclose = () => {
|
|
conn.textContent = 'disconnected';
|
|
dot.classList.remove('ready');
|
|
setTimeout(connect, 1000);
|
|
};
|
|
ws.onmessage = (event) => {
|
|
const message = JSON.parse(event.data);
|
|
if (message.type === 'frame') drawFrame(message);
|
|
if (message.type === 'state') updateState(message.state || {});
|
|
if (message.type === 'clipboard') copyRemote(message);
|
|
};
|
|
}
|
|
|
|
function send(payload) {
|
|
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
|
ws.send(JSON.stringify(payload));
|
|
}
|
|
|
|
function drawFrame(message) {
|
|
meta = message.metadata || meta;
|
|
const width = Math.max(1, Math.round(meta.deviceWidth || remoteState.width || 1440));
|
|
const height = Math.max(1, Math.round(meta.deviceHeight || remoteState.height || 900));
|
|
if (canvas.width !== width || canvas.height !== height) {
|
|
canvas.width = width;
|
|
canvas.height = height;
|
|
}
|
|
const image = new Image();
|
|
image.onload = () => ctx.drawImage(image, 0, 0, canvas.width, canvas.height);
|
|
image.src = `data:image/jpeg;base64,${message.data}`;
|
|
}
|
|
|
|
function updateState(state) {
|
|
remoteState = state;
|
|
if (document.activeElement !== address && state.url) address.value = state.url;
|
|
document.title = state.title ? `${state.title} - CDP Browser` : 'CDP Browser';
|
|
pageState.textContent = `${state.readyState || 'loading'} ${state.width || ''}x${state.height || ''}`;
|
|
target.textContent = state.targetId ? `${state.mode || ''} ${String(state.targetId).slice(0, 12)}` : '';
|
|
}
|
|
|
|
function modifiers(event) {
|
|
let value = 0;
|
|
if (event.altKey) value |= 1;
|
|
if (event.ctrlKey) value |= 2;
|
|
if (event.metaKey) value |= 4;
|
|
if (event.shiftKey) value |= 8;
|
|
return value;
|
|
}
|
|
|
|
function buttonName(button) {
|
|
if (button === 0) return 'left';
|
|
if (button === 1) return 'middle';
|
|
if (button === 2) return 'right';
|
|
return 'none';
|
|
}
|
|
|
|
function pointer(event) {
|
|
const rect = canvas.getBoundingClientRect();
|
|
const width = canvas.width || meta.deviceWidth || 1440;
|
|
const height = canvas.height || meta.deviceHeight || 900;
|
|
return {
|
|
x: Math.max(0, Math.min(width, (event.clientX - rect.left) * width / rect.width)),
|
|
y: Math.max(0, Math.min(height, (event.clientY - rect.top) * height / rect.height)),
|
|
};
|
|
}
|
|
|
|
function centerPoint() {
|
|
const width = canvas.width || meta.deviceWidth || remoteState.width || 1440;
|
|
const height = canvas.height || meta.deviceHeight || remoteState.height || 900;
|
|
return { x: width / 2, y: height / 2 };
|
|
}
|
|
|
|
function sendScroll(deltaY) {
|
|
send({
|
|
type: 'scroll',
|
|
deltaX: 0,
|
|
deltaY,
|
|
});
|
|
canvas.focus();
|
|
}
|
|
|
|
function mouse(event, type) {
|
|
const pos = pointer(event);
|
|
send({
|
|
type: 'mouse',
|
|
event: type,
|
|
x: pos.x,
|
|
y: pos.y,
|
|
button: type === 'mouseMoved' ? 'none' : buttonName(event.button),
|
|
clickCount: event.detail || 1,
|
|
modifiers: modifiers(event),
|
|
});
|
|
}
|
|
|
|
document.getElementById('nav-form').addEventListener('submit', (event) => {
|
|
event.preventDefault();
|
|
send({ type: 'navigate', url: address.value });
|
|
canvas.focus();
|
|
});
|
|
document.getElementById('back').addEventListener('click', () => send({ type: 'history', delta: -1 }));
|
|
document.getElementById('forward').addEventListener('click', () => send({ type: 'history', delta: 1 }));
|
|
document.getElementById('reload').addEventListener('click', () => send({ type: 'reload' }));
|
|
document.getElementById('close').addEventListener('click', () => send({ type: 'close' }));
|
|
document.getElementById('copy').addEventListener('click', () => send({ type: 'copy' }));
|
|
document.querySelectorAll('[data-scroll-y]').forEach((button) => {
|
|
button.addEventListener('click', () => sendScroll(Number(button.dataset.scrollY || '0')));
|
|
});
|
|
document.getElementById('paste').addEventListener('click', async () => {
|
|
let text = '';
|
|
if (navigator.clipboard && navigator.clipboard.readText) {
|
|
text = await navigator.clipboard.readText();
|
|
} else {
|
|
text = window.prompt('Text to paste') || '';
|
|
}
|
|
send({ type: 'text', text });
|
|
canvas.focus();
|
|
});
|
|
|
|
async function copyRemote(message) {
|
|
if (!message.ok) {
|
|
pageState.textContent = message.reason || 'copy failed';
|
|
return;
|
|
}
|
|
if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
await navigator.clipboard.writeText(message.text || '');
|
|
pageState.textContent = `copied ${String(message.text || '').length} chars from ${message.source || 'remote'}`;
|
|
}
|
|
}
|
|
|
|
canvas.addEventListener('contextmenu', (event) => event.preventDefault());
|
|
canvas.addEventListener('mousedown', (event) => {
|
|
canvas.focus();
|
|
mouse(event, 'mousePressed');
|
|
event.preventDefault();
|
|
});
|
|
canvas.addEventListener('mouseup', (event) => {
|
|
mouse(event, 'mouseReleased');
|
|
event.preventDefault();
|
|
});
|
|
canvas.addEventListener('mousemove', (event) => {
|
|
const now = performance.now();
|
|
if (now - lastMove < 25) return;
|
|
lastMove = now;
|
|
mouse(event, 'mouseMoved');
|
|
});
|
|
canvas.addEventListener('wheel', (event) => {
|
|
const pos = pointer(event);
|
|
send({
|
|
type: 'mouse',
|
|
event: 'mouseWheel',
|
|
x: pos.x,
|
|
y: pos.y,
|
|
button: 'none',
|
|
deltaX: event.deltaX,
|
|
deltaY: event.deltaY,
|
|
modifiers: modifiers(event),
|
|
});
|
|
event.preventDefault();
|
|
}, { passive: false });
|
|
canvas.addEventListener('keydown', (event) => {
|
|
const printable = event.key.length === 1 && !event.ctrlKey && !event.metaKey && !event.altKey;
|
|
send({
|
|
type: 'key',
|
|
event: 'keyDown',
|
|
key: event.key,
|
|
code: event.code,
|
|
text: printable ? event.key : '',
|
|
unmodifiedText: printable ? event.key : '',
|
|
windowsVirtualKeyCode: event.keyCode || 0,
|
|
nativeVirtualKeyCode: event.keyCode || 0,
|
|
modifiers: modifiers(event),
|
|
});
|
|
if (!event.metaKey) event.preventDefault();
|
|
});
|
|
canvas.addEventListener('keyup', (event) => {
|
|
send({
|
|
type: 'key',
|
|
event: 'keyUp',
|
|
key: event.key,
|
|
code: event.code,
|
|
windowsVirtualKeyCode: event.keyCode || 0,
|
|
nativeVirtualKeyCode: event.keyCode || 0,
|
|
modifiers: modifiers(event),
|
|
});
|
|
if (!event.metaKey) event.preventDefault();
|
|
});
|
|
canvas.addEventListener('paste', (event) => {
|
|
const text = event.clipboardData ? event.clipboardData.getData('text') : '';
|
|
if (text) send({ type: 'text', text });
|
|
event.preventDefault();
|
|
});
|
|
|
|
connect();
|
|
</script>
|
|
</body>
|
|
</html>
|
|
"""
|
|
|
|
|
|
async def health(request: web.Request) -> web.Response:
|
|
server: RemoteBrowserServer = request.app["remote_browser"]
|
|
return web.json_response(
|
|
{
|
|
"ok": not server._closed.is_set(),
|
|
"targetId": server.target_id,
|
|
"browserContextId": server.browser_context_id,
|
|
"lastFrame": bool(server._last_frame),
|
|
"state": server._last_state or {},
|
|
}
|
|
)
|
|
|
|
|
|
async def index(_: web.Request) -> web.Response:
|
|
return web.Response(text=VIEWER_HTML, content_type="text/html")
|
|
|
|
|
|
async def websocket(request: web.Request) -> web.WebSocketResponse:
|
|
server: RemoteBrowserServer = request.app["remote_browser"]
|
|
return await server.handle_ws(request)
|
|
|
|
|
|
async def serve_remote_browser(options: RemoteBrowserOptions) -> None:
|
|
server = RemoteBrowserServer(options)
|
|
await server.start()
|
|
app = web.Application()
|
|
app["remote_browser"] = server
|
|
app.add_routes(
|
|
[
|
|
web.get("/", index),
|
|
web.get("/healthz", health),
|
|
web.get("/ws", websocket),
|
|
]
|
|
)
|
|
runner = web.AppRunner(app, access_log=None)
|
|
await runner.setup()
|
|
site = web.TCPSite(runner, options.listen_host, options.listen_port)
|
|
await site.start()
|
|
print(f"CDP browser listening on http://{options.listen_host}:{options.listen_port}")
|
|
print(f"Controlling {options.cdp_endpoint} target {str(server.target_id or '')[:12]} in {options.mode} mode")
|
|
print("Press Ctrl+C here or Close in the viewer to stop.")
|
|
try:
|
|
await server.wait_closed()
|
|
except asyncio.CancelledError:
|
|
pass
|
|
finally:
|
|
await runner.cleanup()
|
|
await server.close()
|