modcdp 0.0.2__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,5 @@
1
+ node_modules/
2
+ dist/
3
+ client/go/modcdp
4
+ client/go/modcdp-go
5
+ __pycache__/
modcdp-0.0.2/PKG-INFO ADDED
@@ -0,0 +1,12 @@
1
+ Metadata-Version: 2.4
2
+ Name: modcdp
3
+ Version: 0.0.2
4
+ Summary: Python client for ModCDP.
5
+ Project-URL: Repository, https://github.com/pirate/ModCDP
6
+ Requires-Python: >=3.10
7
+ Requires-Dist: websocket-client>=1.8.0
8
+ Description-Content-Type: text/markdown
9
+
10
+ # ModCDP Python Client
11
+
12
+ Python client for ModCDP.
modcdp-0.0.2/README.md ADDED
@@ -0,0 +1,3 @@
1
+ # ModCDP Python Client
2
+
3
+ Python client for ModCDP.
modcdp-0.0.2/demo.py ADDED
@@ -0,0 +1,379 @@
1
+ """Python demo for ModCDPClient. Mirrors client/js/demo.js.
2
+
3
+ Modes (mirror the JS / Go demos):
4
+ --live Use the running Google Chrome enabled via chrome://inspect.
5
+ --direct *.* -> direct_cdp on the client.
6
+ --loopback *.* -> service_worker on the client; *.* -> loopback_cdp on
7
+ the server. Default.
8
+ --debugger *.* -> service_worker on the client; *.* -> chrome_debugger
9
+ on the server.
10
+ """
11
+
12
+ import json
13
+ import os
14
+ import subprocess
15
+ import sys
16
+ import threading
17
+ import time
18
+ import urllib.request
19
+ from pathlib import Path
20
+
21
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
22
+ from modcdp import ModCDPClient
23
+ from modcdp.types import JsonValue, ProtocolPayload
24
+
25
+ ROOT = Path(__file__).resolve().parent.parent.parent
26
+ EXTENSION_PATH = ROOT / "dist" / "extension"
27
+ LIVE_DEVTOOLS_ACTIVE_PORTS = [
28
+ Path.home() / "Library" / "Application Support" / "Google" / "Chrome" / "DevToolsActivePort",
29
+ Path.home() / "Library" / "Application Support" / "Google" / "Chrome Beta" / "DevToolsActivePort",
30
+ ] if sys.platform == "darwin" else [
31
+ Path.home() / ".config" / "google-chrome" / "DevToolsActivePort",
32
+ Path.home() / ".config" / "chromium" / "DevToolsActivePort",
33
+ ]
34
+ CHROME = os.environ.get("CHROME_PATH") or (
35
+ "/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary"
36
+ if sys.platform == "darwin"
37
+ else "/opt/pw-browsers/chromium-1194/chrome-linux/chrome"
38
+ )
39
+
40
+
41
+ def expect_object(value: JsonValue, label: str) -> ProtocolPayload:
42
+ if not isinstance(value, dict):
43
+ raise RuntimeError(f"{label} returned non-object value: {value!r}")
44
+ return value
45
+
46
+
47
+ def server_routes_for(mode: str) -> ProtocolPayload:
48
+ route = "loopback_cdp" if mode == "loopback" else "chrome_debugger" if mode == "debugger" else "auto"
49
+ routes: ProtocolPayload = {
50
+ "Mod.*": "service_worker",
51
+ "Custom.*": "service_worker",
52
+ "*.*": route,
53
+ }
54
+ return routes
55
+
56
+
57
+ def client_options_for(mode, cdp_url, launch_options=None):
58
+ direct_normal_event_routes = {
59
+ "Target.setDiscoverTargets": "direct_cdp",
60
+ "Target.createTarget": "direct_cdp",
61
+ "Target.activateTarget": "direct_cdp",
62
+ }
63
+ if mode == "direct":
64
+ return {
65
+ "cdp_url": cdp_url,
66
+ "extension_path": str(EXTENSION_PATH),
67
+ "launch_options": launch_options or {},
68
+ "routes": {"Mod.*": "service_worker", "Custom.*": "service_worker", "*.*": "direct_cdp", **direct_normal_event_routes},
69
+ }
70
+ server = {
71
+ "routes": server_routes_for(mode),
72
+ }
73
+ if cdp_url and mode == "loopback":
74
+ server["loopback_cdp_url"] = cdp_url
75
+ return {
76
+ "cdp_url": cdp_url,
77
+ "extension_path": str(EXTENSION_PATH),
78
+ "launch_options": launch_options or {},
79
+ "routes": {"Mod.*": "service_worker", "Custom.*": "service_worker", "*.*": "service_worker", **direct_normal_event_routes},
80
+ "server": server,
81
+ }
82
+
83
+
84
+ def wait_for_live_cdp_url():
85
+ started_at = time.time()
86
+ opener = ["open", "chrome://inspect/#remote-debugging"] if sys.platform == "darwin" else ["xdg-open", "chrome://inspect/#remote-debugging"]
87
+ subprocess.Popen(opener, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
88
+ print("opened chrome://inspect/#remote-debugging")
89
+ print("waiting for Chrome to expose DevToolsActivePort; click Allow when Chrome asks.")
90
+ while True:
91
+ for path in LIVE_DEVTOOLS_ACTIVE_PORTS:
92
+ try:
93
+ if path.stat().st_mtime < started_at - 1:
94
+ continue
95
+ lines = [line.strip() for line in path.read_text().splitlines() if line.strip()]
96
+ if len(lines) >= 2:
97
+ return f"ws://127.0.0.1:{lines[0]}{lines[1]}"
98
+ except Exception:
99
+ pass
100
+ time.sleep(0.25)
101
+
102
+
103
+ def main():
104
+ flags = {a[2:] for a in sys.argv[1:] if a.startswith("--")}
105
+ live = "live" in flags
106
+ mode = "debugger" if "debugger" in flags else "direct" if "direct" in flags else "loopback" if "loopback" in flags else "direct" if live else "loopback"
107
+ print(f"== mode: {'live/' if live else ''}{mode} ==")
108
+
109
+ cdp = None
110
+ try:
111
+ if live:
112
+ cdp_url = wait_for_live_cdp_url()
113
+ launch_options = {}
114
+ else:
115
+ cdp_url = None
116
+ launch_options = {
117
+ "executable_path": CHROME,
118
+ "headless": sys.platform.startswith("linux"),
119
+ "sandbox": not sys.platform.startswith("linux"),
120
+ "extra_args": [f"--load-extension={EXTENSION_PATH}"],
121
+ }
122
+
123
+ cdp = ModCDPClient(**client_options_for(mode, cdp_url, launch_options))
124
+ foreground_events = []
125
+ target_created_events = []
126
+ events_lock = threading.Lock()
127
+
128
+ def on_target_created(payload, *_):
129
+ print(f"Target.targetCreated -> {payload.get('targetInfo', {}).get('targetId')}")
130
+ with events_lock:
131
+ target_created_events.append(payload)
132
+
133
+ def on_foreground_changed(payload, *_):
134
+ print(f"Custom.foregroundTargetChanged -> {payload}")
135
+ with events_lock:
136
+ foreground_events.append(payload)
137
+
138
+ cdp.on("Target.targetCreated", on_target_created)
139
+
140
+ cdp.connect()
141
+ print(f"upstream cdp: {cdp.cdp_url}")
142
+ print(f"connected; ext {cdp.extension_id} session {cdp.ext_session_id}")
143
+ print(f"ping latency -> {cdp.latency}")
144
+
145
+ configure_params: ProtocolPayload = {"routes": server_routes_for(mode)}
146
+ if mode == "loopback":
147
+ if cdp.cdp_url is None:
148
+ raise RuntimeError("loopback mode requires a resolved cdp_url after connect")
149
+ configure_params["loopback_cdp_url"] = cdp.cdp_url
150
+ configure_result = expect_object(cdp.send("Mod.configure", configure_params), "Mod.configure")
151
+ if expect_object(configure_result.get("routes"), "Mod.configure.routes").get("*.*") != server_routes_for(mode)["*.*"]:
152
+ raise RuntimeError(f"unexpected Mod.configure result {configure_result}")
153
+ print(f"Mod.configure -> {configure_result.get('routes')}")
154
+
155
+ pong_events = []
156
+ pong_lock = threading.Lock()
157
+ ping_sent_at = int(time.time() * 1000)
158
+
159
+ def on_pong(payload, *_):
160
+ with pong_lock:
161
+ pong_events.append(payload)
162
+
163
+ cdp.on("Mod.pong", on_pong)
164
+ ping_result = expect_object(cdp.send("Mod.ping", {"sentAt": ping_sent_at}), "Mod.ping")
165
+ deadline = time.monotonic() + 3.0
166
+ while True:
167
+ with pong_lock:
168
+ pong = next((event for event in pong_events if event.get("sentAt") == ping_sent_at), None)
169
+ if pong or time.monotonic() >= deadline:
170
+ break
171
+ time.sleep(0.02)
172
+ if ping_result.get("ok") is not True or not pong or pong.get("from") != "extension-service-worker":
173
+ raise RuntimeError(f"unexpected Mod.ping/Mod.pong result ping={ping_result} pong={pong}")
174
+ print(f"Mod.ping/pong -> {ping_result} {pong}")
175
+
176
+ if mode == "debugger":
177
+ try:
178
+ version = expect_object(cdp.send("Browser.getVersion"), "Browser.getVersion")
179
+ if not isinstance(version.get("protocolVersion"), str) or not isinstance(version.get("product"), str):
180
+ raise RuntimeError(f"unexpected Browser.getVersion result {version}")
181
+ print(f"Browser.getVersion -> {version}")
182
+ except Exception as e:
183
+ print(f"Browser.getVersion -> (debugger route rejected: {str(e).splitlines()[0]} )")
184
+ runtime_eval = expect_object(cdp.send("Runtime.evaluate", {"expression": "(() => 42)()", "returnByValue": True}), "Runtime.evaluate")
185
+ result = expect_object(runtime_eval.get("result"), "Runtime.evaluate.result")
186
+ if result.get("value") != 42:
187
+ raise RuntimeError(f"unexpected Runtime.evaluate result {runtime_eval}")
188
+ print(f"Runtime.evaluate -> {runtime_eval}")
189
+ else:
190
+ version = expect_object(cdp.send("Browser.getVersion"), "Browser.getVersion")
191
+ if not isinstance(version.get("protocolVersion"), str) or not isinstance(version.get("product"), str):
192
+ raise RuntimeError(f"unexpected Browser.getVersion result {version}")
193
+ print(f"Browser.getVersion -> {version}")
194
+
195
+ modcdp_eval = expect_object(cdp.send("Mod.evaluate", {"expression": "({ extensionId: chrome.runtime.id })"}), "Mod.evaluate")
196
+ if modcdp_eval.get("extensionId") != cdp.extension_id:
197
+ raise RuntimeError(f"unexpected Mod.evaluate result {modcdp_eval}")
198
+ print(f"Mod.evaluate -> {modcdp_eval}")
199
+
200
+ echo_registration = expect_object(cdp.send("Mod.addCustomCommand", {
201
+ "name": "Custom.echo",
202
+ "expression": "async (params, method) => ({ echoed: params.value, method })",
203
+ }), "Mod.addCustomCommand Custom.echo")
204
+ if echo_registration.get("registered") is not True or echo_registration.get("name") != "Custom.echo":
205
+ raise RuntimeError(f"unexpected Custom.echo registration {echo_registration}")
206
+ echo_result = expect_object(cdp.send("Custom.echo", {"value": "custom-command-ok"}), "Custom.echo")
207
+ if echo_result.get("echoed") != "custom-command-ok" or echo_result.get("method") != "Custom.echo":
208
+ raise RuntimeError(f"unexpected Custom.echo result {echo_result}")
209
+ print(f"Custom.echo -> {echo_result}")
210
+
211
+ tab_command_registration = expect_object(cdp.send("Mod.addCustomCommand", {
212
+ "name": "Custom.TabIdFromTargetId",
213
+ "expression": '''async ({ targetId }) => {
214
+ const targets = await chrome.debugger.getTargets();
215
+ const target = targets.find(target => target.id === targetId);
216
+ return { tabId: target?.tabId ?? null };
217
+ }''',
218
+ }), "Mod.addCustomCommand Custom.TabIdFromTargetId")
219
+ if tab_command_registration.get("registered") is not True:
220
+ raise RuntimeError(f"unexpected TabIdFromTargetId registration {tab_command_registration}")
221
+ target_command_registration = expect_object(cdp.send("Mod.addCustomCommand", {
222
+ "name": "Custom.targetIdFromTabId",
223
+ "expression": '''async ({ tabId }) => {
224
+ const targets = await chrome.debugger.getTargets();
225
+ const target = targets.find(target => target.type === "page" && target.tabId === tabId);
226
+ return { targetId: target?.id ?? null };
227
+ }''',
228
+ }), "Mod.addCustomCommand Custom.targetIdFromTabId")
229
+ if target_command_registration.get("registered") is not True:
230
+ raise RuntimeError(f"unexpected targetIdFromTabId registration {target_command_registration}")
231
+ middleware_registered = False
232
+ for phase in ("response", "event"):
233
+ middleware_registration = expect_object(cdp.send("Mod.addMiddleware", {
234
+ "name": "*",
235
+ "phase": phase,
236
+ "expression": '''async (payload, next) => {
237
+ const seen = new WeakSet();
238
+ const visit = async value => {
239
+ if (!value || typeof value !== "object" || seen.has(value)) return;
240
+ seen.add(value);
241
+ if (!Array.isArray(value) && typeof value.targetId === "string" && value.tabId == null) {
242
+ const { tabId } = await cdp.send("Custom.TabIdFromTargetId", { targetId: value.targetId });
243
+ if (tabId != null) value.tabId = tabId;
244
+ }
245
+ for (const child of Array.isArray(value) ? value : Object.values(value)) await visit(child);
246
+ };
247
+ await visit(payload);
248
+ return next(payload);
249
+ }''',
250
+ }), f"Mod.addMiddleware {phase}")
251
+ if middleware_registration.get("registered") is not True or middleware_registration.get("phase") != phase:
252
+ raise RuntimeError(f"unexpected {phase} middleware registration {middleware_registration}")
253
+ middleware_registered = True
254
+ if not middleware_registered:
255
+ raise RuntimeError("middleware registration loop did not run")
256
+
257
+ demo_events = []
258
+ demo_lock = threading.Lock()
259
+
260
+ def on_demo_event(payload, *_):
261
+ with demo_lock:
262
+ demo_events.append(payload)
263
+
264
+ demo_event_registration = expect_object(cdp.send("Mod.addCustomEvent", {"name": "Custom.demoEvent"}), "Mod.addCustomEvent Custom.demoEvent")
265
+ if demo_event_registration.get("registered") is not True or demo_event_registration.get("name") != "Custom.demoEvent":
266
+ raise RuntimeError(f"unexpected Custom.demoEvent registration {demo_event_registration}")
267
+ cdp.on("Custom.demoEvent", on_demo_event)
268
+ emit_result = expect_object(cdp.send("Mod.evaluate", {"expression": '''async () => await ModCDP.emit("Custom.demoEvent", { value: "custom-event-ok" })'''}), "Custom.demoEvent emit")
269
+ if emit_result.get("emitted") is not True:
270
+ raise RuntimeError(f"unexpected Custom.demoEvent emit result {emit_result}")
271
+ deadline = time.monotonic() + 3.0
272
+ while True:
273
+ with demo_lock:
274
+ demo_event = next((event for event in demo_events if event.get("value") == "custom-event-ok"), None)
275
+ if demo_event or time.monotonic() >= deadline:
276
+ break
277
+ time.sleep(0.02)
278
+ if not demo_event:
279
+ raise RuntimeError("expected Custom.demoEvent")
280
+ print(f"Custom.demoEvent -> {demo_event}")
281
+
282
+ foreground_event_registration = expect_object(cdp.send("Mod.addCustomEvent", {"name": "Custom.foregroundTargetChanged"}), "Mod.addCustomEvent Custom.foregroundTargetChanged")
283
+ if foreground_event_registration.get("registered") is not True:
284
+ raise RuntimeError(f"unexpected foreground event registration {foreground_event_registration}")
285
+ cdp.on("Custom.foregroundTargetChanged", on_foreground_changed)
286
+ cdp.send("Mod.evaluate", {"expression": '''chrome.tabs.onActivated.addListener(async ({ tabId }) => {
287
+ const targets = await chrome.debugger.getTargets();
288
+ const target = targets.find(target => target.type === "page" && target.tabId === tabId);
289
+ const tab = await chrome.tabs.get(tabId).catch(() => null);
290
+ await cdp.emit("Custom.foregroundTargetChanged", { tabId, targetId: target?.id ?? null, url: target?.url ?? tab?.url ?? null });
291
+ })'''})
292
+
293
+ cdp.send("Target.setDiscoverTargets", {"discover": True})
294
+ created_target = expect_object(cdp.send("Target.createTarget", {"url": "https://example.com", "background": True}), "Target.createTarget")
295
+ created_target_id = created_target.get("targetId")
296
+ if not created_target_id:
297
+ raise RuntimeError(f"Target.createTarget returned no targetId: {created_target}")
298
+ deadline = time.monotonic() + 3.0
299
+ while True:
300
+ with events_lock:
301
+ matched_target_event = next((event for event in target_created_events if event.get("targetInfo", {}).get("targetId") == created_target_id), None)
302
+ if matched_target_event or time.monotonic() >= deadline:
303
+ break
304
+ time.sleep(0.02)
305
+ if not matched_target_event:
306
+ raise RuntimeError(f"expected Target.targetCreated for {created_target_id}")
307
+ print(f"normal event matched -> {created_target_id}")
308
+
309
+ tab_from_target = expect_object(cdp.send("Custom.TabIdFromTargetId", {"targetId": created_target_id}), "Custom.TabIdFromTargetId")
310
+ if not isinstance(tab_from_target.get("tabId"), int | float):
311
+ raise RuntimeError(f"unexpected Custom.TabIdFromTargetId result {tab_from_target}")
312
+ print(f"Custom.TabIdFromTargetId -> {tab_from_target}")
313
+
314
+ cdp.send("Target.activateTarget", {"targetId": created_target_id})
315
+ deadline = time.monotonic() + 3.0
316
+ while True:
317
+ with events_lock:
318
+ foreground = next((event for event in foreground_events if event.get("targetId") == created_target_id), None)
319
+ if foreground or time.monotonic() >= deadline:
320
+ break
321
+ time.sleep(0.02)
322
+ if not foreground:
323
+ raise RuntimeError(f"expected Custom.foregroundTargetChanged for {created_target_id}")
324
+ if tab_from_target.get("tabId") != foreground.get("tabId"):
325
+ raise RuntimeError(f"unexpected Custom.foregroundTargetChanged result {foreground}")
326
+
327
+ target_from_tab = expect_object(cdp.send("Custom.targetIdFromTabId", {"tabId": foreground["tabId"]}), "Custom.targetIdFromTabId")
328
+ if target_from_tab.get("targetId") != created_target_id or target_from_tab.get("tabId") != foreground.get("tabId"):
329
+ raise RuntimeError(f"unexpected Custom.targetIdFromTabId/middleware result {target_from_tab}")
330
+ print(f"Custom.targetIdFromTabId -> {target_from_tab}")
331
+
332
+ print(f"\nSUCCESS ({mode}): normal command, normal event, custom commands, custom event, and middleware all passed")
333
+
334
+ # TTY-only: drop into a REPL where you can send live commands and
335
+ # watch events as they print. Skip when run non-interactively so the
336
+ # demo stays CI-friendly.
337
+ if sys.stdin.isatty():
338
+ cdp.on("Mod.pong", lambda e: print(f"\n[event] Mod.pong {e}"))
339
+ run_repl(cdp, mode)
340
+
341
+ return 0
342
+ finally:
343
+ if cdp is not None:
344
+ try: cdp.close()
345
+ except Exception: pass
346
+
347
+
348
+ def run_repl(cdp, mode):
349
+ import re
350
+ print(f"\nBrowser remains running. Mode: {mode}.")
351
+ print("Enter commands as Domain.method({...JSON params...}). Examples:")
352
+ print(' Browser.getVersion({})')
353
+ print(' Mod.evaluate({"expression": "chrome.tabs.query({active: true})"})')
354
+ print(' Custom.TabIdFromTargetId({"targetId": "..."})')
355
+ print("Type exit or quit to disconnect (browser keeps running).")
356
+ cmd_re = re.compile(r"^([A-Za-z_]\w*\.[A-Za-z_]\w*)(?:\((.*)\))?$")
357
+ while True:
358
+ try:
359
+ line = input("ModCDP> ").strip()
360
+ except (EOFError, KeyboardInterrupt):
361
+ print()
362
+ break
363
+ if not line: continue
364
+ if line in ("exit", "quit"): break
365
+ try:
366
+ m = cmd_re.match(line)
367
+ if not m:
368
+ raise ValueError("format: Domain.method({...JSON...})")
369
+ method = m.group(1)
370
+ raw = (m.group(2) or "").strip()
371
+ params = json.loads(raw) if raw else {}
372
+ result = cdp.send(method, params)
373
+ print(json.dumps(result, indent=2))
374
+ except Exception as e:
375
+ print(f"error: {e}")
376
+
377
+
378
+ if __name__ == "__main__":
379
+ sys.exit(main())