cocos-web-inspector-mcp 2.2.1 → 2.4.0

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.
@@ -13,11 +13,26 @@ export declare function consoleMessages(page: Page, options: {
13
13
  textContains?: string | undefined;
14
14
  limit?: number | undefined;
15
15
  }): Promise<unknown>;
16
- export declare function networkRequests(page: Page, options: {
16
+ export declare function networkRequests(page: Page, options: RequestFilter): Promise<unknown>;
17
+ type RequestRow = {
18
+ url: string;
19
+ resourceType: string;
20
+ status: number | null;
21
+ failure?: string;
22
+ };
23
+ type RequestFilter = {
17
24
  urlContains?: string | undefined;
18
25
  resourceType?: string | undefined;
19
26
  failedOnly?: boolean | undefined;
20
27
  limit?: number | undefined;
21
- }): Promise<unknown>;
28
+ };
29
+ /** Filters request rows and keeps the newest `limit`; shared by the browser and native network tools. */
30
+ export declare function selectRequests<T extends RequestRow>(rows: T[], options: RequestFilter, note: string): {
31
+ requests: T[];
32
+ matched: number;
33
+ truncated: boolean;
34
+ note: string;
35
+ };
22
36
  export declare function networkRequest(page: Page, id: number, includeBody: boolean): Promise<unknown>;
23
37
  export declare function storage(page: Page, area: 'local' | 'session' | 'cookies', keyContains?: string, limit?: number): Promise<unknown>;
38
+ export {};
@@ -84,7 +84,6 @@ const idOf = (request) => {
84
84
  return id;
85
85
  };
86
86
  export async function networkRequests(page, options) {
87
- const limit = options.limit ?? 50;
88
87
  const rows = await Promise.all((await page.requests()).map(async (request) => {
89
88
  const response = await request.response().catch(() => null);
90
89
  const failure = request.failure()?.errorText;
@@ -99,10 +98,15 @@ export async function networkRequests(page, options) {
99
98
  ...(timing.responseEnd > 0 ? { durationMs: Math.round(timing.responseEnd) } : {}),
100
99
  };
101
100
  }));
101
+ return selectRequests(rows, options, 'Requests since this server attached; Playwright drops old entries to bound memory.');
102
+ }
103
+ /** Filters request rows and keeps the newest `limit`; shared by the browser and native network tools. */
104
+ export function selectRequests(rows, options, note) {
105
+ const limit = options.limit ?? 50;
102
106
  const matched = rows.filter(row => (!options.urlContains || row.url.includes(options.urlContains))
103
107
  && (!options.resourceType || row.resourceType === options.resourceType)
104
108
  && (!options.failedOnly || row.failure || (row.status ?? 0) >= 400));
105
- return { requests: matched.slice(-limit), matched: matched.length, truncated: matched.length > limit, note: 'Requests since this server attached; Playwright drops old entries to bound memory.' };
109
+ return { requests: matched.slice(-limit), matched: matched.length, truncated: matched.length > limit, note };
106
110
  }
107
111
  export async function networkRequest(page, id, includeBody) {
108
112
  const request = (await page.requests()).find(candidate => requestIds.get(candidate) === id);
@@ -132,6 +136,9 @@ export async function networkRequest(page, id, includeBody) {
132
136
  }
133
137
  export async function storage(page, area, keyContains, limit = 100) {
134
138
  if (area === 'cookies') {
139
+ // A native (JSB) runtime has no browser context or cookie jar.
140
+ if (typeof page.context !== 'function')
141
+ return { area, available: false, reason: 'UNSUPPORTED_PUBLIC_API', entries: [], matched: 0, truncated: false };
135
142
  // Cookie values are always withheld; names and attributes are enough to debug expiry and scope.
136
143
  const cookies = (await page.context().cookies(page.url())).filter(cookie => !keyContains || cookie.name.includes(keyContains));
137
144
  return {
@@ -141,8 +148,11 @@ export async function storage(page, area, keyContains, limit = 100) {
141
148
  truncated: cookies.length > limit,
142
149
  };
143
150
  }
151
+ // Native (JSB) builds have localStorage only, backed by a SQLite file; there is no sessionStorage or cookie jar.
144
152
  const entries = await page.evaluate(store => {
145
- const target = store === 'local' ? localStorage : sessionStorage;
153
+ const target = store === 'local' ? localStorage : globalThis.sessionStorage;
154
+ if (!target)
155
+ return null;
146
156
  const out = [];
147
157
  for (let index = 0; index < target.length && index < 5_000; index++) {
148
158
  const key = target.key(index);
@@ -151,6 +161,8 @@ export async function storage(page, area, keyContains, limit = 100) {
151
161
  }
152
162
  return out;
153
163
  }, area);
164
+ if (!entries)
165
+ return { area, available: false, reason: 'UNSUPPORTED_PUBLIC_API', entries: [], matched: 0, truncated: false };
154
166
  const matched = entries.filter(([key]) => !keyContains || key.includes(keyContains));
155
167
  return {
156
168
  area,
@@ -82,8 +82,10 @@ export class BrowserConnection {
82
82
  if (pageUrl) {
83
83
  const wanted = validateLocalUrl(pageUrl, true).href;
84
84
  const selected = pages.filter(candidate => new URL(candidate.url()).href === wanted);
85
- if (selected.length === 0)
86
- throw new InspectorError('PAGE_NOT_FOUND', `Local page not found: ${sanitizeUrl(pageUrl)}`);
85
+ if (selected.length === 0) {
86
+ const open = pages.slice(0, 10).map(candidate => sanitizeUrl(candidate.url())).join(', ');
87
+ throw new InspectorError('PAGE_NOT_FOUND', `Local page not found: ${sanitizeUrl(pageUrl)} (pageUrl must equal the full open page URL including query and fragment; open pages, shown without them: ${open || 'none'})`);
88
+ }
87
89
  if (selected.length > 1)
88
90
  throw new InspectorError('MULTIPLE_PAGES', `Multiple localhost pages match pageUrl: ${sanitizeUrl(pageUrl)}; close duplicate tabs or use a separate Chromium per project`);
89
91
  return selected[0];
@@ -139,7 +141,8 @@ export class BrowserConnection {
139
141
  const hint = /\b404\b/.test(message)
140
142
  ? '; port is likely held by Chrome built-in remote debugging (chrome://inspect/#remote-debugging), which has no /json/version: turn it off or use another port, check with lsof -nP -iTCP:<port> -sTCP:LISTEN'
141
143
  : '';
142
- throw new InspectorError('CDP_UNAVAILABLE', `Unable to connect to Chromium CDP: ${message}${hint}`);
144
+ const launch = /ECONNREFUSED/.test(message) ? '; no Chromium is listening there: start one with --remote-debugging-port=<port> --user-data-dir=<dir>, or point the server at the right port with --cdp-endpoint / COCOS_CDP_ENDPOINT' : '';
145
+ throw new InspectorError('CDP_UNAVAILABLE', `Unable to connect to Chromium CDP at ${sanitizeUrl(this.endpoint)}: ${message}${hint}${launch}`);
143
146
  });
144
147
  this.#connecting = connecting;
145
148
  try {
package/dist/src/cli.js CHANGED
@@ -5,6 +5,7 @@ import { join } from 'node:path';
5
5
  import { chromium } from 'playwright-core';
6
6
  import { BrowserConnection, InspectorError, sanitizeUrl, validateLocalUrl } from './browser.js';
7
7
  import { devicePresetNames, emulateDevice, inspectCocosPage, networkProfileNames } from './bridge.js';
8
+ import { forwardInspector, NativeConnection } from './native.js';
8
9
  const write = (line) => process.stdout.write(`${line}\n`);
9
10
  function flags(args, names, booleans = []) {
10
11
  const values = {};
@@ -124,10 +125,39 @@ async function launch(args) {
124
125
  await exited;
125
126
  process.exit(0);
126
127
  }
128
+ // Native debug builds: find the inspector port in logcat, forward it over adb, and check the Cocos runtime through it.
129
+ async function nativeDoctor() {
130
+ const fail = (code, message, fix) => {
131
+ write(`FAIL ${code}: ${message}\n Fix: ${fix}`);
132
+ process.exitCode = 1;
133
+ };
134
+ let port;
135
+ try {
136
+ port = await forwardInspector();
137
+ }
138
+ catch (error) {
139
+ return fail('CDP_UNAVAILABLE', error instanceof Error ? error.message : 'adb failed', 'install Android platform-tools, check adb devices, and set ANDROID_SERIAL when several devices are attached');
140
+ }
141
+ if (port === undefined)
142
+ return fail('CDP_UNAVAILABLE', 'no "Debugger listening" line in the device log', 'start a Cocos native build made with Debug checked; release builds have no inspector');
143
+ write(`ok debug build listens on device port ${port}; forwarded to 127.0.0.1:${port}`);
144
+ const endpoint = `http://127.0.0.1:${port}`;
145
+ const info = await (await new NativeConnection(endpoint).page()).evaluate(inspectCocosPage).catch(error => error);
146
+ if (info instanceof Error)
147
+ return fail('CDP_UNAVAILABLE', info.message, 'close Chrome DevTools attached to the game; the inspector takes one session at a time');
148
+ if (!info.cocos?.detected)
149
+ return fail('COCOS_NOT_FOUND', 'no Cocos Creator 3.x runtime in the app', 'wait for the game to finish loading');
150
+ if (!info.cocos.sceneName)
151
+ return fail('SCENE_NOT_READY', `Cocos ${info.cocos.version} has no active scene yet`, 'wait for the first scene to load');
152
+ write(`ok Cocos ${info.cocos.version}, scene "${info.cocos.sceneName}"`);
153
+ write(`\nAdd it to Claude Code:\n claude mcp add cocos-native -- npx -y cocos-web-inspector-mcp --native-endpoint ${endpoint}`);
154
+ }
127
155
  async function doctor(args) {
128
- const { values, positional } = flags(args, ['--cdp-endpoint']);
156
+ const { values, positional } = flags(args, ['--cdp-endpoint'], ['--native']);
129
157
  if (positional.length)
130
- throw new Error('Usage: cocos-web-inspector-mcp doctor [--cdp-endpoint <url>]');
158
+ throw new Error('Usage: cocos-web-inspector-mcp doctor [--cdp-endpoint <url> | --native]');
159
+ if (values['--native'])
160
+ return nativeDoctor();
131
161
  const endpoint = values['--cdp-endpoint'] ?? process.env.COCOS_CDP_ENDPOINT ?? 'http://127.0.0.1:9222';
132
162
  let failed = false;
133
163
  const pass = (message) => write(`ok ${message}`);
@@ -1,4 +1,6 @@
1
1
  import type { Page } from 'playwright-core';
2
+ import { type Clip } from './bridge.js';
3
+ import { selectRequests } from './browser-data.js';
2
4
  type Evaluate = (webSocketUrl: string, expression: string, timeout: number) => Promise<unknown>;
3
5
  export declare class NativeConnection {
4
6
  #private;
@@ -11,6 +13,16 @@ export declare class NativeConnection {
11
13
  }
12
14
  export declare function nativeClick(page: Page, uuid: string): Promise<unknown>;
13
15
  export declare function nativeDrag(page: Page, uuid: string, dx: number, dy: number, durationMs?: number): Promise<unknown>;
16
+ export declare function nativeCaptureNode(page: Page, uuid: string, maxBytes?: number): Promise<unknown>;
17
+ /** Crops a raw screencap to the clip and encodes an RGB PNG, sampling every n-th pixel until it fits the response. */
18
+ export declare function cropPng(raw: Buffer, clip: Clip, maxBytes?: number): unknown;
19
+ /**
20
+ * The inspector port from the engine's own "Debugger listening" logcat lines: the latest one, or `expected` only when
21
+ * some line names it. Any app can log under the Cocos tag, so reconnects pass the port they already used.
22
+ */
23
+ export declare function inspectorPort(logcat: string, expected?: number): number | undefined;
24
+ /** Forwards the device's inspector port to the same loopback port; returns it, or undefined when no debug build has logged one. */
25
+ export declare function forwardInspector(expected?: number): Promise<number | undefined>;
14
26
  /** Parses `logcat -v epoch` lines of the Cocos tag; console.log and console.debug share one level there and map to log. */
15
27
  export declare function parseCocosLogcat(output: string): Array<{
16
28
  type: string;
@@ -22,5 +34,8 @@ export declare function nativeConsoleMessages(page: Page, options: {
22
34
  textContains?: string | undefined;
23
35
  limit?: number | undefined;
24
36
  }): Promise<unknown>;
37
+ export declare function nativePressKey(_page: Page, key: string): Promise<unknown>;
25
38
  export declare function nativeTypeText(page: Page, uuid: string, text: string, submit: boolean): Promise<unknown>;
39
+ export declare function nativeNetworkRequests(page: Page, options: Parameters<typeof selectRequests>[1]): Promise<unknown>;
40
+ export declare function nativeNetworkRequest(page: Page, id: number, includeBody: boolean): Promise<unknown>;
26
41
  export {};
@@ -1,8 +1,9 @@
1
1
  import { execFile } from 'node:child_process';
2
2
  import { promisify } from 'node:util';
3
- import { nodeCenter, runBridge } from './bridge.js';
3
+ import { crc32, deflateSync, gunzipSync } from 'node:zlib';
4
+ import { fitsImage, fitsResponse, nodeCenter, runBridge, visibleClip } from './bridge.js';
4
5
  import { InspectorError, validateLocalUrl } from './browser.js';
5
- import { redactText } from './browser-data.js';
6
+ import { redactBody, redactHeaders, redactText, redactUrl, selectRequests } from './browser-data.js';
6
7
  // Cocos native debug builds (V8) run a Node-style inspector: Runtime and Debugger domains only, no pages, one session at a time.
7
8
  // ponytail: one WebSocket per call so Chrome DevTools can attach between calls; keep a socket open if per-call latency matters.
8
9
  export class NativeConnection {
@@ -27,14 +28,21 @@ export class NativeConnection {
27
28
  return this.#page;
28
29
  }
29
30
  async close() { }
31
+ async #targets() {
32
+ const response = await fetch(new URL('/json/list', this.#http), { signal: AbortSignal.timeout(10_000) });
33
+ return await response.json();
34
+ }
30
35
  async #evaluate(fn, arg) {
31
36
  let targets;
32
37
  try {
33
- const response = await fetch(new URL('/json/list', this.#http), { signal: AbortSignal.timeout(10_000) });
34
- targets = await response.json();
38
+ targets = await this.#targets();
35
39
  }
36
40
  catch (error) {
37
- throw new InspectorError('CDP_UNAVAILABLE', `Unable to reach the native inspector at ${this.#http.host}: ${error instanceof Error ? error.message : 'unknown error'}; run the debug build, find its port in logcat "Debugger listening", then adb forward tcp:<port> tcp:<port>`);
41
+ // An adb server restart or a USB drop takes the forward with it; restore it once, only to the device port this endpoint names.
42
+ const restored = await forwardInspector(Number(this.#http.port)).catch(() => undefined);
43
+ targets = restored === undefined ? [] : await this.#targets().catch(() => []);
44
+ if (!targets.length)
45
+ throw new InspectorError('CDP_UNAVAILABLE', `Unable to reach the native inspector at ${this.#http.host}: ${error instanceof Error ? error.message : 'unknown error'}; check that the debug build is running and adb sees the device (run doctor --native)`);
38
46
  }
39
47
  if (!Array.isArray(targets) || targets.length === 0)
40
48
  throw new InspectorError('NO_LOCAL_PAGE', 'Native inspector lists no target');
@@ -79,15 +87,16 @@ async function evaluateOverWebSocket(webSocketUrl, expression, timeout) {
79
87
  // Real input and logs go through adb on the device the inspector is forwarded from. adb picks the device itself;
80
88
  // with several attached, set ANDROID_SERIAL. Arguments are numbers or a validated package name, never free text.
81
89
  const run = promisify(execFile);
82
- async function adb(args) {
90
+ async function adbBuffer(args) {
83
91
  try {
84
- return (await run('adb', args, { timeout: 15_000, maxBuffer: 16 * 1024 * 1024 })).stdout;
92
+ return (await run('adb', args, { encoding: 'buffer', timeout: 15_000, maxBuffer: 16 * 1024 * 1024 })).stdout;
85
93
  }
86
94
  catch (error) {
87
95
  const message = error instanceof Error ? error.message.split('\n')[0] : 'unknown error';
88
96
  throw new InspectorError('CDP_UNAVAILABLE', `adb ${args[0]} failed: ${message.slice(0, 300)}; check adb devices, and set ANDROID_SERIAL when several are attached`);
89
97
  }
90
98
  }
99
+ const adb = async (args) => (await adbBuffer(args)).toString();
91
100
  // ponytail: viewport coordinates equal screen pixels only for a full-screen game (the Cocos default); add the window offset for windowed apps.
92
101
  const pixel = (value) => String(Math.round(value));
93
102
  const settle = () => new Promise(resolve => setTimeout(resolve, 300));
@@ -110,6 +119,65 @@ export async function nativeDrag(page, uuid, dx, dy, durationMs = 300) {
110
119
  await settle();
111
120
  return { dragged: true, target: { nodeUuid: uuid }, from, to, input: 'adb', runtimeOnly: true };
112
121
  }
122
+ // Raw `screencap` output: width, height, and pixel format as uint32 LE, a colorspace word on Android 12+, then RGBA rows
123
+ // in the current display rotation. The device gzips it (18 MB raw takes ~20 s over Wi-Fi adb, ~1.5 s gzipped), and cropping
124
+ // raw pixels needs only zlib here, where decoding `screencap -p` would need a PNG decoder.
125
+ export async function nativeCaptureNode(page, uuid, maxBytes) {
126
+ const clip = await visibleClip(page, uuid);
127
+ if ('reason' in clip)
128
+ return { captured: false, reason: clip.reason };
129
+ const raw = gunzipSync(await adbBuffer(['exec-out', 'screencap | gzip -1']), { maxOutputLength: 256 * 1024 * 1024 });
130
+ return cropPng(raw, clip, maxBytes);
131
+ }
132
+ /** Crops a raw screencap to the clip and encodes an RGB PNG, sampling every n-th pixel until it fits the response. */
133
+ export function cropPng(raw, clip, maxBytes) {
134
+ if (raw.length < 12)
135
+ return { captured: false, reason: 'SCREENCAP_FAILED' };
136
+ const screenWidth = raw.readUInt32LE(0);
137
+ const screenHeight = raw.readUInt32LE(4);
138
+ const header = raw.length - screenWidth * screenHeight * 4;
139
+ // 1 is RGBA_8888 and 2 is RGBX_8888; other formats are not 4 bytes per pixel.
140
+ if (![1, 2].includes(raw.readUInt32LE(8)) || (header !== 12 && header !== 16))
141
+ return { captured: false, reason: 'UNSUPPORTED_PIXEL_FORMAT' };
142
+ // ponytail: viewport pixels equal screen pixels only for a full-screen game, as for adb taps; add the window offset for windowed apps.
143
+ const left = Math.max(0, Math.floor(clip.x));
144
+ const top = Math.max(0, Math.floor(clip.y));
145
+ const right = Math.min(screenWidth, Math.ceil(clip.x + clip.width));
146
+ const bottom = Math.min(screenHeight, Math.ceil(clip.y + clip.height));
147
+ if (right <= left || bottom <= top)
148
+ return { captured: false, reason: 'OUTSIDE_VIEWPORT' };
149
+ // ponytail: nearest-pixel sampling keeps this dependency-free; box filtering would read text better when downscaled.
150
+ for (const step of [1, 2, 3, 4, 6, 8]) {
151
+ const width = Math.ceil((right - left) / step);
152
+ const height = Math.ceil((bottom - top) / step);
153
+ const rows = Buffer.alloc((width * 3 + 1) * height);
154
+ for (let y = 0; y < height; y++) {
155
+ const out = y * (width * 3 + 1) + 1;
156
+ const source = header + ((top + y * step) * screenWidth + left) * 4;
157
+ for (let x = 0; x < width; x++)
158
+ raw.copy(rows, out + x * 3, source + x * step * 4, source + x * step * 4 + 3);
159
+ }
160
+ const data = png(width, height, rows).toString('base64');
161
+ if (fitsImage(data, maxBytes))
162
+ return { captured: true, mimeType: 'image/png', data, width: right - left, height: bottom - top, ...(step > 1 ? { scale: 1 / step } : {}) };
163
+ }
164
+ return { captured: false, reason: 'RESPONSE_LIMIT' };
165
+ }
166
+ function png(width, height, rows) {
167
+ const chunk = (type, body) => {
168
+ const head = Buffer.alloc(8);
169
+ head.writeUInt32BE(body.length, 0);
170
+ head.write(type, 4, 'ascii');
171
+ const crc = Buffer.alloc(4);
172
+ crc.writeUInt32BE(crc32(body, crc32(type)), 0);
173
+ return Buffer.concat([head, body, crc]);
174
+ };
175
+ const ihdr = Buffer.alloc(13);
176
+ ihdr.writeUInt32BE(width, 0);
177
+ ihdr.writeUInt32BE(height, 4);
178
+ ihdr.set([8, 2, 0, 0, 0], 8); // 8-bit RGB, no interlace
179
+ return Buffer.concat([Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), chunk('IHDR', ihdr), chunk('IDAT', deflateSync(rows, { level: 9 })), chunk('IEND', Buffer.alloc(0))]);
180
+ }
113
181
  // The app's own process, from its files directory, so logs of other Cocos apps on the device never mix in.
114
182
  async function appProcess(page) {
115
183
  const path = String(await page.evaluate(() => globalThis.jsb?.fileUtils?.getWritablePath?.() ?? ''));
@@ -121,6 +189,21 @@ async function appProcess(page) {
121
189
  throw new InspectorError('CDP_UNAVAILABLE', `App ${name} is not running`);
122
190
  return pid;
123
191
  }
192
+ /**
193
+ * The inspector port from the engine's own "Debugger listening" logcat lines: the latest one, or `expected` only when
194
+ * some line names it. Any app can log under the Cocos tag, so reconnects pass the port they already used.
195
+ */
196
+ export function inspectorPort(logcat, expected) {
197
+ const ports = [...logcat.matchAll(/js_app\.html\?v8only=true&ws=[^:\s]+:(\d{2,5})\//g)].map(match => Number(match[1])).filter(port => port >= 1_024 && port <= 65_535);
198
+ return expected === undefined ? ports.at(-1) : ports.includes(expected) ? expected : undefined;
199
+ }
200
+ /** Forwards the device's inspector port to the same loopback port; returns it, or undefined when no debug build has logged one. */
201
+ export async function forwardInspector(expected) {
202
+ const port = inspectorPort(await adb(['logcat', '-d', '-s', 'Cocos:D']), expected);
203
+ if (port !== undefined)
204
+ await adb(['forward', `tcp:${port}`, `tcp:${port}`]);
205
+ return port;
206
+ }
124
207
  const levels = { V: 'debug', D: 'log', I: 'info', W: 'warning', E: 'error', F: 'error' };
125
208
  /** Parses `logcat -v epoch` lines of the Cocos tag; console.log and console.debug share one level there and map to log. */
126
209
  export function parseCocosLogcat(output) {
@@ -146,6 +229,17 @@ export async function nativeConsoleMessages(page, options) {
146
229
  note: 'Cocos-tagged logcat lines of the app process since it started, bounded by the device log buffer. Engine lines start with D/ or E/; an uncaught JS error spans several error lines (message, then stack).',
147
230
  };
148
231
  }
232
+ const androidKeys = { Enter: 'KEYCODE_ENTER', Escape: 'KEYCODE_ESCAPE', Tab: 'KEYCODE_TAB', Delete: 'KEYCODE_DEL', Space: 'KEYCODE_SPACE', ArrowUp: 'KEYCODE_DPAD_UP', ArrowDown: 'KEYCODE_DPAD_DOWN', ArrowLeft: 'KEYCODE_DPAD_LEFT', ArrowRight: 'KEYCODE_DPAD_RIGHT', Home: 'KEYCODE_MOVE_HOME', End: 'KEYCODE_MOVE_END', PageUp: 'KEYCODE_PAGE_UP', PageDown: 'KEYCODE_PAGE_DOWN' };
233
+ // The native engine reports AKEYCODE_DEL (the physical Backspace key) as Delete, has no entry for FORWARD_DEL, and reports Backspace only for
234
+ // AKEYCODE_BACK, which leaves the app. So Delete maps to DEL and Backspace is refused rather than sent as a key the game would misread.
235
+ export async function nativePressKey(_page, key) {
236
+ if (key === 'Backspace')
237
+ throw new Error('Invalid mutation: native builds deliver Backspace only for the system Back key, which leaves the app; use Delete, which the engine reports for the device Backspace key');
238
+ // Only validated key names reach adb, never free text.
239
+ await adb(['shell', 'input', 'keyevent', androidKeys[key] ?? `KEYCODE_${key.toUpperCase()}`]);
240
+ await settle();
241
+ return { pressed: true, key, input: 'adb', runtimeOnly: true };
242
+ }
149
243
  // Tapping an EditBox on Android opens CocosEditBoxActivity, a native EditText whose every change fires text-changed in the game.
150
244
  export async function nativeTypeText(page, uuid, text, submit) {
151
245
  // input text only types printable ASCII and turns "%s" into a space, so refuse what it would silently mangle.
@@ -182,3 +276,173 @@ export async function nativeTypeText(page, uuid, text, submit) {
182
276
  const matches = typeof properties?.string === 'string' ? properties.string === text : undefined;
183
277
  return { typed: true, target: { nodeUuid: uuid }, length: text.length, submitted: submit, after, ...(matches === undefined ? {} : { matches }), ...(matches === false ? { hint: 'The device keyboard changed the text; switch it to a plain English layout without autocorrect' } : {}), input: 'adb', runtimeOnly: true };
184
278
  }
279
+ function nativeNetwork(query) {
280
+ const root = globalThis;
281
+ const MAX_ENTRIES = 200;
282
+ const MAX_BODY = 100_000;
283
+ const clip = (value) => typeof value === 'string' ? value.slice(0, MAX_BODY)
284
+ : value instanceof ArrayBuffer ? `[binary ${value.byteLength} bytes]` : value == null ? '' : `[${typeof value}]`;
285
+ let state = root.__cocosWebInspectorNetwork;
286
+ const installedNow = !state;
287
+ if (!state) {
288
+ state = root.__cocosWebInspectorNetwork = { entries: [], nextId: 1, installedAt: Date.now() };
289
+ const record = (entry) => {
290
+ entry.id = state.nextId++;
291
+ state.entries.push(entry);
292
+ if (state.entries.length > MAX_ENTRIES)
293
+ state.entries.shift();
294
+ return entry;
295
+ };
296
+ const Xhr = root.XMLHttpRequest;
297
+ if (typeof Xhr === 'function') {
298
+ const proto = Xhr.prototype;
299
+ const open = proto.open;
300
+ const send = proto.send;
301
+ const setHeader = proto.setRequestHeader;
302
+ proto.open = function (method, url, ...rest) {
303
+ this.__cwi = { method: String(method).toUpperCase(), url: String(url), headers: {} };
304
+ return open.call(this, method, url, ...rest);
305
+ };
306
+ proto.setRequestHeader = function (name, value) {
307
+ // The ring lives in game memory that other game code can read, so credential headers are masked as they are captured.
308
+ // ponytail: bodies stay raw in the ring and are redacted only when returned; redact here too if third-party SDKs are a concern.
309
+ if (this.__cwi)
310
+ this.__cwi.headers[String(name)] = /auth|cookie|token|secret|api-?key|password|session/i.test(String(name)) ? '[redacted]' : String(value);
311
+ return setHeader.call(this, name, value);
312
+ };
313
+ proto.send = function (body) {
314
+ const meta = this.__cwi;
315
+ if (meta) {
316
+ const entry = record({ kind: 'xhr', method: meta.method, url: meta.url, requestHeaders: meta.headers, requestBody: clip(body), startedAt: Date.now() });
317
+ this.__cwiDone = (failure) => {
318
+ if (entry.endedAt)
319
+ return;
320
+ entry.endedAt = Date.now();
321
+ entry.status = Number(this.status) || null;
322
+ if (failure)
323
+ entry.failure = failure;
324
+ try {
325
+ entry.responseHeaders = String(this.getAllResponseHeaders() ?? '').slice(0, 10_000);
326
+ }
327
+ catch { /* not ready */ }
328
+ try {
329
+ entry.responseBody = clip(this.responseType === '' || this.responseType === 'text' ? this.responseText : this.response);
330
+ }
331
+ catch { /* unreadable */ }
332
+ };
333
+ // Native XHR dispatches only through on* properties, and its addEventListener just assigns them, so listeners
334
+ // would replace the game's handlers. Chain through accessors instead; the native side reads them with a plain get.
335
+ // ponytail: reading xhr.onload returns the chain, not the game's function; keep a handler map if identity matters.
336
+ if (!this.__cwiHooked) {
337
+ this.__cwiHooked = true;
338
+ const xhr = this;
339
+ for (const [name, failure] of [['onload', undefined], ['onerror', 'error'], ['ontimeout', 'timeout'], ['onabort', 'abort']]) {
340
+ let handler = this[name];
341
+ const chain = function (event) {
342
+ try {
343
+ xhr.__cwiDone(failure);
344
+ }
345
+ catch { /* never break the game */ }
346
+ return typeof handler === 'function' ? handler.call(this, event) : undefined;
347
+ };
348
+ Object.defineProperty(this, name, { configurable: true, get: () => chain, set: (fn) => { handler = fn; } });
349
+ }
350
+ }
351
+ }
352
+ return send.call(this, body);
353
+ };
354
+ }
355
+ const NativeSocket = root.WebSocket;
356
+ if (typeof NativeSocket === 'function') {
357
+ // Pass every argument through: native WebSocket takes (url, protocols, caFilePath), and dropping the CA file
358
+ // changes how the game's TLS connection is verified.
359
+ const Wrapped = function (...args) {
360
+ const socket = new NativeSocket(...args);
361
+ const entry = record({ kind: 'websocket', method: 'GET', url: String(args[0]), startedAt: Date.now(), frames: [], framesSeen: 0 });
362
+ const frame = (direction, data) => {
363
+ entry.framesSeen++;
364
+ entry.frames.push({ direction, at: Date.now(), data: clip(data).slice(0, 2_000) });
365
+ if (entry.frames.length > 50)
366
+ entry.frames.shift();
367
+ };
368
+ const send = socket.send;
369
+ socket.send = function (data) { frame('sent', data); return send.call(socket, data); };
370
+ // Native sockets dispatch only through on* properties; wrap each handler as the game assigns it.
371
+ for (const [name, hook] of [
372
+ ['onopen', () => { entry.status = 101; }],
373
+ ['onmessage', (event) => frame('received', event?.data)],
374
+ ['onerror', () => { entry.failure = 'error'; }],
375
+ ['onclose', (event) => { entry.endedAt = Date.now(); entry.closeCode = Number(event?.code) || null; }],
376
+ ]) {
377
+ let handler = null;
378
+ Object.defineProperty(socket, name, {
379
+ configurable: true,
380
+ get: () => handler,
381
+ set: (fn) => { handler = typeof fn === 'function' ? function (event) { try {
382
+ hook(event);
383
+ }
384
+ catch { /* never break the game */ } return fn.call(this, event); } : fn; },
385
+ });
386
+ }
387
+ return socket;
388
+ };
389
+ Wrapped.prototype = NativeSocket.prototype;
390
+ for (const key of ['CONNECTING', 'OPEN', 'CLOSING', 'CLOSED'])
391
+ Wrapped[key] = NativeSocket[key];
392
+ root.WebSocket = Wrapped;
393
+ }
394
+ }
395
+ if (query.action === 'list')
396
+ return { installedAt: state.installedAt, installedNow, entries: state.entries.map(({ requestBody: _b, responseBody: _r, frames: _f, requestHeaders: _h, responseHeaders: _rh, ...summary }) => summary) };
397
+ const entry = state.entries.find(candidate => candidate.id === query.id);
398
+ return entry ? { installedAt: state.installedAt, entry } : { installedAt: state.installedAt, entry: null };
399
+ }
400
+ const sinceNote = (installedAt, installedNow) => installedNow
401
+ ? 'Capture started with this call; make the game send traffic, then list again.'
402
+ : `Requests and WebSocket frames since capture started at ${new Date(installedAt).toISOString()}; the game keeps the last 200.`;
403
+ export async function nativeNetworkRequests(page, options) {
404
+ const { installedAt, installedNow, entries } = await page.evaluate(nativeNetwork, { action: 'list' });
405
+ const rows = entries.map(entry => ({
406
+ id: entry.id,
407
+ method: entry.method,
408
+ url: redactUrl(entry.url),
409
+ resourceType: entry.kind,
410
+ status: entry.status ?? null,
411
+ ...(entry.failure ? { failure: String(entry.failure).slice(0, 300) } : {}),
412
+ ...(entry.endedAt ? { durationMs: entry.endedAt - entry.startedAt } : {}),
413
+ ...(entry.kind === 'websocket' ? { frames: entry.framesSeen } : {}),
414
+ }));
415
+ return selectRequests(rows, options, sinceNote(installedAt, installedNow));
416
+ }
417
+ export async function nativeNetworkRequest(page, id, includeBody) {
418
+ const { entry } = await page.evaluate(nativeNetwork, { action: 'get', id });
419
+ if (!entry)
420
+ throw new InspectorError('REQUEST_NOT_FOUND', `Request ${id} not found; list requests again, older entries are dropped`);
421
+ // Split each "name: value" line at its first colon only; dates and URLs carry more.
422
+ const headers = (raw) => Object.fromEntries(raw.split(/\r?\n/).map(line => /^([^:]+):\s*(.*)$/.exec(line)).filter(match => !!match).map(match => [match[1].trim().toLowerCase(), match[2]]));
423
+ const frames = entry.kind === 'websocket' && includeBody ? entry.frames.map((frame) => ({ direction: frame.direction, at: new Date(frame.at).toISOString(), data: redactBody(frame.data).body })) : undefined;
424
+ // Native results skip runBridge, so enforce the response ceiling here: drop the oldest frames, then the bodies.
425
+ let result = detail(entry, id, includeBody, headers, frames);
426
+ while (frames?.length && !fitsResponse(result)) {
427
+ frames.splice(0, Math.max(1, Math.ceil(frames.length / 4)));
428
+ result = { ...detail(entry, id, includeBody, headers, frames), framesDropped: true };
429
+ }
430
+ return fitsResponse(result) ? result : { ...detail(entry, id, false, headers, undefined), truncated: true, truncationReasons: ['RESPONSE_LIMIT'] };
431
+ }
432
+ function detail(entry, id, includeBody, headers, frames) {
433
+ return {
434
+ id,
435
+ method: entry.method,
436
+ url: redactUrl(entry.url),
437
+ resourceType: entry.kind,
438
+ ...(entry.requestHeaders ? { requestHeaders: redactHeaders(entry.requestHeaders) } : {}),
439
+ ...(includeBody && entry.requestBody ? { requestBody: redactBody(entry.requestBody) } : {}),
440
+ status: entry.status ?? null,
441
+ ...(entry.responseHeaders ? { responseHeaders: redactHeaders(headers(entry.responseHeaders)) } : {}),
442
+ ...(entry.failure ? { failure: String(entry.failure).slice(0, 300) } : {}),
443
+ ...(entry.closeCode ? { closeCode: entry.closeCode } : {}),
444
+ ...(includeBody && entry.responseBody ? { responseBody: redactBody(entry.responseBody) } : {}),
445
+ ...(entry.kind === 'websocket' ? { framesSeen: entry.framesSeen, ...(frames ? { frames } : {}) } : {}),
446
+ timing: { startedAt: new Date(entry.startedAt).toISOString(), ...(entry.endedAt ? { durationMs: entry.endedAt - entry.startedAt } : {}) },
447
+ };
448
+ }