cocos-web-inspector-mcp 2.0.1 → 2.2.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.
- package/README.md +85 -11
- package/dist/src/bridge.d.ts +40 -0
- package/dist/src/bridge.js +833 -24
- package/dist/src/browser-data.d.ts +23 -0
- package/dist/src/browser-data.js +166 -0
- package/dist/src/browser.d.ts +1 -1
- package/dist/src/index.js +21 -3
- package/dist/src/native.d.ts +26 -0
- package/dist/src/native.js +184 -0
- package/dist/src/server.d.ts +5 -1
- package/dist/src/server.js +205 -97
- package/package.json +1 -1
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { Page } from 'playwright-core';
|
|
2
|
+
export declare function isSensitiveKey(key: string): boolean;
|
|
3
|
+
export declare function redactText(text: string, limit?: number): string;
|
|
4
|
+
export declare function redactUrl(raw: string): string;
|
|
5
|
+
export declare function redactHeaders(headers: Record<string, string>): Record<string, string>;
|
|
6
|
+
/** Redacts a body: JSON by key, form data by field, anything else as text. */
|
|
7
|
+
export declare function redactBody(text: string): {
|
|
8
|
+
body: string;
|
|
9
|
+
truncated: boolean;
|
|
10
|
+
};
|
|
11
|
+
export declare function consoleMessages(page: Page, options: {
|
|
12
|
+
types?: string[] | undefined;
|
|
13
|
+
textContains?: string | undefined;
|
|
14
|
+
limit?: number | undefined;
|
|
15
|
+
}): Promise<unknown>;
|
|
16
|
+
export declare function networkRequests(page: Page, options: {
|
|
17
|
+
urlContains?: string | undefined;
|
|
18
|
+
resourceType?: string | undefined;
|
|
19
|
+
failedOnly?: boolean | undefined;
|
|
20
|
+
limit?: number | undefined;
|
|
21
|
+
}): Promise<unknown>;
|
|
22
|
+
export declare function networkRequest(page: Page, id: number, includeBody: boolean): Promise<unknown>;
|
|
23
|
+
export declare function storage(page: Page, area: 'local' | 'session' | 'cookies', keyContains?: string, limit?: number): Promise<unknown>;
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { InspectorError } from './browser.js';
|
|
2
|
+
// Browser data tools (console, network, storage) run only behind --allow-browser-data.
|
|
3
|
+
// Redaction is best effort: secret-like keys, auth headers, cookie values, JWTs, and bearer tokens are masked; free text can still leak.
|
|
4
|
+
const REDACTED = '[redacted]';
|
|
5
|
+
const MAX_TEXT = 2_000;
|
|
6
|
+
const MAX_BODY = 20_000;
|
|
7
|
+
const sensitiveParts = ['token', 'cookie', 'authorization', 'password', 'passwd', 'secret', 'credential', 'jwt', 'apikey', 'privatekey', 'accesskey', 'signingkey', 'authheader', 'signature', 'otp', 'pin'];
|
|
8
|
+
const sensitiveHeaders = new Set(['authorization', 'proxy-authorization', 'cookie', 'set-cookie', 'x-api-key', 'x-auth-token', 'x-csrf-token', 'x-xsrf-token']);
|
|
9
|
+
export function isSensitiveKey(key) {
|
|
10
|
+
const normalized = key.toLowerCase().replace(/[^a-z]/g, '');
|
|
11
|
+
return sensitiveParts.some(part => part === 'pin' || part === 'otp' ? normalized === part : normalized.includes(part))
|
|
12
|
+
|| normalized.includes('bearer')
|
|
13
|
+
|| normalized.startsWith('session') && ['id', 'key', 'token'].some(part => normalized.includes(part));
|
|
14
|
+
}
|
|
15
|
+
export function redactText(text, limit = MAX_TEXT) {
|
|
16
|
+
return text
|
|
17
|
+
.replace(/eyJ[\w-]{5,}\.[\w-]{5,}\.[\w-]{5,}/g, '[redacted-jwt]')
|
|
18
|
+
.replace(/\b(Bearer|Basic)\s+[\w.~+/=-]{8,}/gi, '$1 [redacted]')
|
|
19
|
+
.replace(/((?:^|[?&;\s"'{,])([\w-]+)["']?\s*[=:]\s*["']?)([^&;\s"',}]+)/g, (match, prefix, key) => isSensitiveKey(key) ? `${prefix}${REDACTED}` : match)
|
|
20
|
+
.slice(0, limit);
|
|
21
|
+
}
|
|
22
|
+
export function redactUrl(raw) {
|
|
23
|
+
try {
|
|
24
|
+
const url = new URL(raw);
|
|
25
|
+
url.username = '';
|
|
26
|
+
url.password = '';
|
|
27
|
+
for (const key of [...url.searchParams.keys()])
|
|
28
|
+
if (isSensitiveKey(key))
|
|
29
|
+
url.searchParams.set(key, REDACTED);
|
|
30
|
+
return redactText(url.toString());
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return redactText(raw);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
export function redactHeaders(headers) {
|
|
37
|
+
return Object.fromEntries(Object.entries(headers).slice(0, 100).map(([name, value]) => [name, sensitiveHeaders.has(name.toLowerCase()) || isSensitiveKey(name) ? REDACTED : redactText(value, 500)]));
|
|
38
|
+
}
|
|
39
|
+
function redactJson(value, depth = 0) {
|
|
40
|
+
if (typeof value === 'string')
|
|
41
|
+
return redactText(value);
|
|
42
|
+
if (!value || typeof value !== 'object')
|
|
43
|
+
return value;
|
|
44
|
+
if (depth > 20)
|
|
45
|
+
return '[MaxDepth]';
|
|
46
|
+
if (Array.isArray(value))
|
|
47
|
+
return value.slice(0, 500).map(item => redactJson(item, depth + 1));
|
|
48
|
+
return Object.fromEntries(Object.entries(value).slice(0, 500).map(([key, item]) => [key, isSensitiveKey(key) ? REDACTED : redactJson(item, depth + 1)]));
|
|
49
|
+
}
|
|
50
|
+
/** Redacts a body: JSON by key, form data by field, anything else as text. */
|
|
51
|
+
export function redactBody(text) {
|
|
52
|
+
let body;
|
|
53
|
+
try {
|
|
54
|
+
body = JSON.stringify(redactJson(JSON.parse(text)));
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
body = /^[\w.%+-]+=[^&]*(&[\w.%+-]+=[^&]*)*$/.test(text.trim())
|
|
58
|
+
? text.split('&').map(pair => { const [key = '', ...rest] = pair.split('='); return isSensitiveKey(decodeURIComponent(key)) ? `${key}=${REDACTED}` : `${key}=${rest.join('=')}`; }).join('&')
|
|
59
|
+
: redactText(text, Number.MAX_SAFE_INTEGER);
|
|
60
|
+
}
|
|
61
|
+
return { body: body.slice(0, MAX_BODY), truncated: body.length > MAX_BODY };
|
|
62
|
+
}
|
|
63
|
+
export async function consoleMessages(page, options) {
|
|
64
|
+
const limit = options.limit ?? 50;
|
|
65
|
+
const messages = (await page.consoleMessages()).filter(message => !options.types?.length || options.types.includes(message.type()));
|
|
66
|
+
const errors = (await page.pageErrors()).map(error => ({ type: 'pageerror', text: `${error.name}: ${error.message}`, location: (error.stack ?? '').split('\n')[1]?.trim().slice(0, 300) }));
|
|
67
|
+
const items = [
|
|
68
|
+
...messages.map(message => {
|
|
69
|
+
const location = message.location();
|
|
70
|
+
return { type: message.type(), text: message.text(), location: location.url ? `${redactUrl(location.url)}:${location.lineNumber}:${location.columnNumber}` : undefined };
|
|
71
|
+
}),
|
|
72
|
+
...(!options.types?.length || options.types.includes('pageerror') ? errors : []),
|
|
73
|
+
].filter(item => !options.textContains || item.text.includes(options.textContains));
|
|
74
|
+
const selected = items.slice(-limit).map(item => ({ ...item, text: redactText(item.text) }));
|
|
75
|
+
return { messages: selected, matched: items.length, truncated: items.length > limit, note: 'Messages since this server attached to the page (Playwright keeps the last 200); reload the page to capture startup logs.' };
|
|
76
|
+
}
|
|
77
|
+
// Playwright Request objects carry no stable id; number them per page as they are first listed.
|
|
78
|
+
const requestIds = new WeakMap();
|
|
79
|
+
let nextRequestId = 1;
|
|
80
|
+
const idOf = (request) => {
|
|
81
|
+
let id = requestIds.get(request);
|
|
82
|
+
if (id === undefined)
|
|
83
|
+
requestIds.set(request, id = nextRequestId++);
|
|
84
|
+
return id;
|
|
85
|
+
};
|
|
86
|
+
export async function networkRequests(page, options) {
|
|
87
|
+
const limit = options.limit ?? 50;
|
|
88
|
+
const rows = await Promise.all((await page.requests()).map(async (request) => {
|
|
89
|
+
const response = await request.response().catch(() => null);
|
|
90
|
+
const failure = request.failure()?.errorText;
|
|
91
|
+
const timing = request.timing();
|
|
92
|
+
return {
|
|
93
|
+
id: idOf(request),
|
|
94
|
+
method: request.method(),
|
|
95
|
+
url: redactUrl(request.url()),
|
|
96
|
+
resourceType: request.resourceType(),
|
|
97
|
+
status: response?.status() ?? null,
|
|
98
|
+
...(failure ? { failure: failure.slice(0, 300) } : {}),
|
|
99
|
+
...(timing.responseEnd > 0 ? { durationMs: Math.round(timing.responseEnd) } : {}),
|
|
100
|
+
};
|
|
101
|
+
}));
|
|
102
|
+
const matched = rows.filter(row => (!options.urlContains || row.url.includes(options.urlContains))
|
|
103
|
+
&& (!options.resourceType || row.resourceType === options.resourceType)
|
|
104
|
+
&& (!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.' };
|
|
106
|
+
}
|
|
107
|
+
export async function networkRequest(page, id, includeBody) {
|
|
108
|
+
const request = (await page.requests()).find(candidate => requestIds.get(candidate) === id);
|
|
109
|
+
if (!request)
|
|
110
|
+
throw new InspectorError('REQUEST_NOT_FOUND', `Request ${id} not found; list requests again, older entries are dropped`);
|
|
111
|
+
const response = await request.response().catch(() => null);
|
|
112
|
+
const post = request.postData();
|
|
113
|
+
let responseBody;
|
|
114
|
+
if (includeBody && response) {
|
|
115
|
+
const type = (await response.headerValue('content-type').catch(() => null)) ?? '';
|
|
116
|
+
responseBody = /json|text|javascript|xml|x-www-form-urlencoded/.test(type)
|
|
117
|
+
? await response.text().then(redactBody, () => ({ unavailable: 'BODY_EVICTED' }))
|
|
118
|
+
: { unavailable: `BINARY ${type.slice(0, 100)}` };
|
|
119
|
+
}
|
|
120
|
+
return {
|
|
121
|
+
id,
|
|
122
|
+
method: request.method(),
|
|
123
|
+
url: redactUrl(request.url()),
|
|
124
|
+
resourceType: request.resourceType(),
|
|
125
|
+
requestHeaders: redactHeaders(await request.allHeaders().catch(() => request.headers())),
|
|
126
|
+
...(includeBody && post ? { requestBody: redactBody(post) } : {}),
|
|
127
|
+
...(response ? { status: response.status(), statusText: response.statusText().slice(0, 200), responseHeaders: redactHeaders(await response.allHeaders().catch(() => response.headers())) } : {}),
|
|
128
|
+
...(request.failure() ? { failure: request.failure().errorText.slice(0, 300) } : {}),
|
|
129
|
+
...(responseBody ? { responseBody } : {}),
|
|
130
|
+
timing: request.timing(),
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
export async function storage(page, area, keyContains, limit = 100) {
|
|
134
|
+
if (area === 'cookies') {
|
|
135
|
+
// Cookie values are always withheld; names and attributes are enough to debug expiry and scope.
|
|
136
|
+
const cookies = (await page.context().cookies(page.url())).filter(cookie => !keyContains || cookie.name.includes(keyContains));
|
|
137
|
+
return {
|
|
138
|
+
area,
|
|
139
|
+
entries: cookies.slice(0, limit).map(cookie => ({ name: cookie.name, value: REDACTED, domain: cookie.domain, path: cookie.path, expires: cookie.expires, httpOnly: cookie.httpOnly, secure: cookie.secure, sameSite: cookie.sameSite })),
|
|
140
|
+
matched: cookies.length,
|
|
141
|
+
truncated: cookies.length > limit,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
const entries = await page.evaluate(store => {
|
|
145
|
+
const target = store === 'local' ? localStorage : sessionStorage;
|
|
146
|
+
const out = [];
|
|
147
|
+
for (let index = 0; index < target.length && index < 5_000; index++) {
|
|
148
|
+
const key = target.key(index);
|
|
149
|
+
if (key !== null)
|
|
150
|
+
out.push([key, String(target.getItem(key) ?? '').slice(0, 200_000)]);
|
|
151
|
+
}
|
|
152
|
+
return out;
|
|
153
|
+
}, area);
|
|
154
|
+
const matched = entries.filter(([key]) => !keyContains || key.includes(keyContains));
|
|
155
|
+
return {
|
|
156
|
+
area,
|
|
157
|
+
entries: matched.slice(0, limit).map(([key, value]) => {
|
|
158
|
+
if (isSensitiveKey(key))
|
|
159
|
+
return { key, value: REDACTED, bytes: value.length };
|
|
160
|
+
const { body, truncated } = redactBody(value);
|
|
161
|
+
return { key, value: body.slice(0, MAX_TEXT), bytes: value.length, ...(truncated || body.length > MAX_TEXT ? { truncated: true } : {}) };
|
|
162
|
+
}),
|
|
163
|
+
matched: matched.length,
|
|
164
|
+
truncated: matched.length > limit,
|
|
165
|
+
};
|
|
166
|
+
}
|
package/dist/src/browser.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type Browser, type Page } from 'playwright-core';
|
|
2
|
-
export type InspectorErrorCode = 'CDP_UNAVAILABLE' | 'NO_LOCAL_PAGE' | 'MULTIPLE_PAGES' | 'PAGE_NOT_FOUND' | 'COCOS_NOT_FOUND' | 'SCENE_NOT_READY' | 'NODE_NOT_FOUND' | 'COMPONENT_NOT_FOUND' | 'AMBIGUOUS_COMPONENT' | 'MUTATION_DISABLED' | 'INVALID_MUTATION' | 'OUTPUT_TRUNCATED';
|
|
2
|
+
export type InspectorErrorCode = 'CDP_UNAVAILABLE' | 'NO_LOCAL_PAGE' | 'MULTIPLE_PAGES' | 'PAGE_NOT_FOUND' | 'COCOS_NOT_FOUND' | 'SCENE_NOT_READY' | 'NODE_NOT_FOUND' | 'COMPONENT_NOT_FOUND' | 'AMBIGUOUS_COMPONENT' | 'AMBIGUOUS_NODE' | 'MUTATION_DISABLED' | 'INVALID_MUTATION' | 'OUTPUT_TRUNCATED' | 'REQUEST_NOT_FOUND';
|
|
3
3
|
export declare class InspectorError extends Error {
|
|
4
4
|
readonly code: InspectorErrorCode;
|
|
5
5
|
constructor(code: InspectorErrorCode, message: string);
|
package/dist/src/index.js
CHANGED
|
@@ -1,16 +1,28 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
3
3
|
import { BrowserConnection } from './browser.js';
|
|
4
|
+
import { NativeConnection } from './native.js';
|
|
4
5
|
import { createServer } from './server.js';
|
|
5
6
|
function optionsFromArgs(args) {
|
|
6
7
|
let endpoint = process.env.COCOS_CDP_ENDPOINT ?? 'http://127.0.0.1:9222';
|
|
8
|
+
let nativeEndpoint;
|
|
7
9
|
let allowRuntimeMutation = false;
|
|
10
|
+
let allowBrowserData = false;
|
|
11
|
+
let allowMethodCall = false;
|
|
8
12
|
for (let index = 0; index < args.length; index++) {
|
|
9
13
|
const arg = args[index];
|
|
10
14
|
if (arg === '--allow-runtime-mutation') {
|
|
11
15
|
allowRuntimeMutation = true;
|
|
12
16
|
continue;
|
|
13
17
|
}
|
|
18
|
+
if (arg === '--allow-browser-data') {
|
|
19
|
+
allowBrowserData = true;
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
if (arg === '--allow-method-call') {
|
|
23
|
+
allowMethodCall = true;
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
14
26
|
if (arg === '--cdp-endpoint') {
|
|
15
27
|
const value = args[++index];
|
|
16
28
|
if (!value)
|
|
@@ -18,18 +30,24 @@ function optionsFromArgs(args) {
|
|
|
18
30
|
endpoint = value;
|
|
19
31
|
continue;
|
|
20
32
|
}
|
|
33
|
+
if (arg === '--native-endpoint') {
|
|
34
|
+
nativeEndpoint = args[++index];
|
|
35
|
+
if (!nativeEndpoint)
|
|
36
|
+
throw new Error('--native-endpoint requires a value');
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
21
39
|
if (arg.startsWith('-'))
|
|
22
40
|
throw new Error(`Unknown argument: ${arg}`);
|
|
23
41
|
}
|
|
24
|
-
return { endpoint, allowRuntimeMutation };
|
|
42
|
+
return { endpoint, nativeEndpoint, allowRuntimeMutation, allowBrowserData, allowMethodCall };
|
|
25
43
|
}
|
|
26
44
|
async function main() {
|
|
27
45
|
const [command, ...rest] = process.argv.slice(2);
|
|
28
46
|
if (command === 'launch' || command === 'doctor')
|
|
29
47
|
return (await import('./cli.js')).commands[command](rest);
|
|
30
48
|
const options = optionsFromArgs(process.argv.slice(2));
|
|
31
|
-
const browser = new BrowserConnection(options.endpoint);
|
|
32
|
-
const server = createServer(browser, { allowRuntimeMutation: options.allowRuntimeMutation });
|
|
49
|
+
const browser = options.nativeEndpoint ? new NativeConnection(options.nativeEndpoint) : new BrowserConnection(options.endpoint);
|
|
50
|
+
const server = createServer(browser, { allowRuntimeMutation: options.allowRuntimeMutation, allowBrowserData: options.allowBrowserData, allowMethodCall: options.allowMethodCall, native: !!options.nativeEndpoint });
|
|
33
51
|
let closing = false;
|
|
34
52
|
const close = async () => {
|
|
35
53
|
if (closing)
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { Page } from 'playwright-core';
|
|
2
|
+
type Evaluate = (webSocketUrl: string, expression: string, timeout: number) => Promise<unknown>;
|
|
3
|
+
export declare class NativeConnection {
|
|
4
|
+
#private;
|
|
5
|
+
private readonly timeout;
|
|
6
|
+
private readonly evaluateExpression;
|
|
7
|
+
constructor(endpoint: string, timeout?: number, evaluateExpression?: Evaluate);
|
|
8
|
+
pages(): Promise<Page[]>;
|
|
9
|
+
page(_pageUrl?: string): Promise<Page>;
|
|
10
|
+
close(): Promise<void>;
|
|
11
|
+
}
|
|
12
|
+
export declare function nativeClick(page: Page, uuid: string): Promise<unknown>;
|
|
13
|
+
export declare function nativeDrag(page: Page, uuid: string, dx: number, dy: number, durationMs?: number): Promise<unknown>;
|
|
14
|
+
/** Parses `logcat -v epoch` lines of the Cocos tag; console.log and console.debug share one level there and map to log. */
|
|
15
|
+
export declare function parseCocosLogcat(output: string): Array<{
|
|
16
|
+
type: string;
|
|
17
|
+
text: string;
|
|
18
|
+
time: number;
|
|
19
|
+
}>;
|
|
20
|
+
export declare function nativeConsoleMessages(page: Page, options: {
|
|
21
|
+
types?: string[] | undefined;
|
|
22
|
+
textContains?: string | undefined;
|
|
23
|
+
limit?: number | undefined;
|
|
24
|
+
}): Promise<unknown>;
|
|
25
|
+
export declare function nativeTypeText(page: Page, uuid: string, text: string, submit: boolean): Promise<unknown>;
|
|
26
|
+
export {};
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { promisify } from 'node:util';
|
|
3
|
+
import { nodeCenter, runBridge } from './bridge.js';
|
|
4
|
+
import { InspectorError, validateLocalUrl } from './browser.js';
|
|
5
|
+
import { redactText } from './browser-data.js';
|
|
6
|
+
// Cocos native debug builds (V8) run a Node-style inspector: Runtime and Debugger domains only, no pages, one session at a time.
|
|
7
|
+
// ponytail: one WebSocket per call so Chrome DevTools can attach between calls; keep a socket open if per-call latency matters.
|
|
8
|
+
export class NativeConnection {
|
|
9
|
+
timeout;
|
|
10
|
+
evaluateExpression;
|
|
11
|
+
#http;
|
|
12
|
+
#page;
|
|
13
|
+
constructor(endpoint, timeout = 60_000, evaluateExpression = evaluateOverWebSocket) {
|
|
14
|
+
this.timeout = timeout;
|
|
15
|
+
this.evaluateExpression = evaluateExpression;
|
|
16
|
+
const url = validateLocalUrl(endpoint);
|
|
17
|
+
url.protocol = url.protocol === 'wss:' || url.protocol === 'https:' ? 'https:' : 'http:';
|
|
18
|
+
url.pathname = '/';
|
|
19
|
+
this.#http = url;
|
|
20
|
+
// Only the evaluate/url subset of Page that runBridge and cocos_list_pages use.
|
|
21
|
+
this.#page = { url: () => this.#http.href, evaluate: (fn, arg) => this.#evaluate(fn, arg) };
|
|
22
|
+
}
|
|
23
|
+
async pages() {
|
|
24
|
+
return [this.#page];
|
|
25
|
+
}
|
|
26
|
+
async page(_pageUrl) {
|
|
27
|
+
return this.#page;
|
|
28
|
+
}
|
|
29
|
+
async close() { }
|
|
30
|
+
async #evaluate(fn, arg) {
|
|
31
|
+
let targets;
|
|
32
|
+
try {
|
|
33
|
+
const response = await fetch(new URL('/json/list', this.#http), { signal: AbortSignal.timeout(10_000) });
|
|
34
|
+
targets = await response.json();
|
|
35
|
+
}
|
|
36
|
+
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>`);
|
|
38
|
+
}
|
|
39
|
+
if (!Array.isArray(targets) || targets.length === 0)
|
|
40
|
+
throw new InspectorError('NO_LOCAL_PAGE', 'Native inspector lists no target');
|
|
41
|
+
const debuggerUrl = targets.find(target => target.webSocketDebuggerUrl)?.webSocketDebuggerUrl;
|
|
42
|
+
if (!debuggerUrl)
|
|
43
|
+
throw new InspectorError('CDP_UNAVAILABLE', 'Native inspector already has a session; close Chrome DevTools attached to the game');
|
|
44
|
+
// Connect to the validated endpoint host, never to the host the target list names.
|
|
45
|
+
const socketUrl = new URL(new URL(debuggerUrl).pathname, this.#http);
|
|
46
|
+
socketUrl.protocol = this.#http.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
47
|
+
// Functions passed here are self-contained (inspectCocos, inspectCocosPage), so their source runs as is.
|
|
48
|
+
return this.evaluateExpression(socketUrl.href, `(${fn.toString()})(${arg === undefined ? '' : JSON.stringify(arg)})`, this.timeout);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
async function evaluateOverWebSocket(webSocketUrl, expression, timeout) {
|
|
52
|
+
const socket = new WebSocket(webSocketUrl);
|
|
53
|
+
let timer;
|
|
54
|
+
try {
|
|
55
|
+
return await new Promise((resolve, reject) => {
|
|
56
|
+
timer = setTimeout(() => reject(new InspectorError('CDP_UNAVAILABLE', 'Native inspector did not answer in time')), timeout);
|
|
57
|
+
socket.onerror = () => reject(new InspectorError('CDP_UNAVAILABLE', 'Native inspector connection failed'));
|
|
58
|
+
socket.onclose = () => reject(new InspectorError('CDP_UNAVAILABLE', 'Native inspector closed the connection'));
|
|
59
|
+
socket.onopen = () => socket.send(JSON.stringify({ id: 1, method: 'Runtime.evaluate', params: { expression, returnByValue: true, awaitPromise: true } }));
|
|
60
|
+
socket.onmessage = event => {
|
|
61
|
+
const message = JSON.parse(String(event.data));
|
|
62
|
+
if (message.id !== 1)
|
|
63
|
+
return;
|
|
64
|
+
if (message.error)
|
|
65
|
+
return reject(new InspectorError('CDP_UNAVAILABLE', `Native inspector error: ${String(message.error.message).slice(0, 500)}`));
|
|
66
|
+
const exception = message.result?.exceptionDetails;
|
|
67
|
+
// Match Playwright's in-page error text, so the server maps bridge errors to the same codes.
|
|
68
|
+
if (exception)
|
|
69
|
+
return reject(new Error(String(exception.exception?.description ?? exception.text ?? 'Evaluation failed').split('\n')[0].replace(/^Error: /, '')));
|
|
70
|
+
resolve(message.result?.result?.value);
|
|
71
|
+
};
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
finally {
|
|
75
|
+
clearTimeout(timer);
|
|
76
|
+
socket.close();
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
// Real input and logs go through adb on the device the inspector is forwarded from. adb picks the device itself;
|
|
80
|
+
// with several attached, set ANDROID_SERIAL. Arguments are numbers or a validated package name, never free text.
|
|
81
|
+
const run = promisify(execFile);
|
|
82
|
+
async function adb(args) {
|
|
83
|
+
try {
|
|
84
|
+
return (await run('adb', args, { timeout: 15_000, maxBuffer: 16 * 1024 * 1024 })).stdout;
|
|
85
|
+
}
|
|
86
|
+
catch (error) {
|
|
87
|
+
const message = error instanceof Error ? error.message.split('\n')[0] : 'unknown error';
|
|
88
|
+
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
|
+
}
|
|
90
|
+
}
|
|
91
|
+
// ponytail: viewport coordinates equal screen pixels only for a full-screen game (the Cocos default); add the window offset for windowed apps.
|
|
92
|
+
const pixel = (value) => String(Math.round(value));
|
|
93
|
+
const settle = () => new Promise(resolve => setTimeout(resolve, 300));
|
|
94
|
+
export async function nativeClick(page, uuid) {
|
|
95
|
+
const point = await nodeCenter(page, uuid);
|
|
96
|
+
if ('reason' in point)
|
|
97
|
+
return { clicked: false, reason: point.reason };
|
|
98
|
+
await adb(['shell', 'input', 'tap', pixel(point.x), pixel(point.y)]);
|
|
99
|
+
// adb returns once the event is injected; the game handles it on its next frames.
|
|
100
|
+
await settle();
|
|
101
|
+
return { clicked: true, target: { nodeUuid: uuid }, point, input: 'adb', runtimeOnly: true };
|
|
102
|
+
}
|
|
103
|
+
export async function nativeDrag(page, uuid, dx, dy, durationMs = 300) {
|
|
104
|
+
const from = await nodeCenter(page, uuid);
|
|
105
|
+
if ('reason' in from)
|
|
106
|
+
return { dragged: false, reason: from.reason };
|
|
107
|
+
const to = { x: from.x + dx, y: from.y + dy };
|
|
108
|
+
// input swipe interpolates its own moves, so steps does not apply.
|
|
109
|
+
await adb(['shell', 'input', 'swipe', pixel(from.x), pixel(from.y), pixel(to.x), pixel(to.y), String(Math.max(durationMs, 1))]);
|
|
110
|
+
await settle();
|
|
111
|
+
return { dragged: true, target: { nodeUuid: uuid }, from, to, input: 'adb', runtimeOnly: true };
|
|
112
|
+
}
|
|
113
|
+
// The app's own process, from its files directory, so logs of other Cocos apps on the device never mix in.
|
|
114
|
+
async function appProcess(page) {
|
|
115
|
+
const path = String(await page.evaluate(() => globalThis.jsb?.fileUtils?.getWritablePath?.() ?? ''));
|
|
116
|
+
const name = /^\/data\/(?:user\/\d+|data)\/([A-Za-z][\w.]{0,200})\//.exec(path)?.[1];
|
|
117
|
+
if (!name)
|
|
118
|
+
throw new InspectorError('CDP_UNAVAILABLE', 'Unable to identify the app package from the native runtime');
|
|
119
|
+
const pid = (await adb(['shell', 'pidof', name]).catch(() => '')).trim().split(/\s+/)[0] ?? '';
|
|
120
|
+
if (!/^\d+$/.test(pid))
|
|
121
|
+
throw new InspectorError('CDP_UNAVAILABLE', `App ${name} is not running`);
|
|
122
|
+
return pid;
|
|
123
|
+
}
|
|
124
|
+
const levels = { V: 'debug', D: 'log', I: 'info', W: 'warning', E: 'error', F: 'error' };
|
|
125
|
+
/** Parses `logcat -v epoch` lines of the Cocos tag; console.log and console.debug share one level there and map to log. */
|
|
126
|
+
export function parseCocosLogcat(output) {
|
|
127
|
+
const messages = [];
|
|
128
|
+
for (const line of output.split(/\r?\n/)) {
|
|
129
|
+
const match = /^\s*(\d+\.\d+)\s+\d+\s+\d+\s+([VDIWEF])\s+Cocos\s*:\s?(?:\d\d:\d\d:\d\d \[\w+\]: )?(.*)$/.exec(line);
|
|
130
|
+
if (match)
|
|
131
|
+
messages.push({ type: levels[match[2]], text: match[3], time: Number(match[1]) });
|
|
132
|
+
}
|
|
133
|
+
return messages;
|
|
134
|
+
}
|
|
135
|
+
export async function nativeConsoleMessages(page, options) {
|
|
136
|
+
const limit = options.limit ?? 50;
|
|
137
|
+
const output = await adb(['logcat', '-d', '-v', 'epoch', `--pid=${await appProcess(page)}`, '-s', 'Cocos:V']);
|
|
138
|
+
const items = parseCocosLogcat(output)
|
|
139
|
+
.filter(item => !options.types?.length || options.types.includes(item.type))
|
|
140
|
+
.filter(item => !options.textContains || item.text.includes(options.textContains));
|
|
141
|
+
const selected = items.slice(-limit).map(item => ({ type: item.type, text: redactText(item.text), time: new Date(item.time * 1_000).toISOString() }));
|
|
142
|
+
return {
|
|
143
|
+
messages: selected,
|
|
144
|
+
matched: items.length,
|
|
145
|
+
truncated: items.length > limit,
|
|
146
|
+
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
|
+
};
|
|
148
|
+
}
|
|
149
|
+
// Tapping an EditBox on Android opens CocosEditBoxActivity, a native EditText whose every change fires text-changed in the game.
|
|
150
|
+
export async function nativeTypeText(page, uuid, text, submit) {
|
|
151
|
+
// input text only types printable ASCII and turns "%s" into a space, so refuse what it would silently mangle.
|
|
152
|
+
if (!/^[\x20-\x7e]*$/.test(text) || text.includes('%s'))
|
|
153
|
+
throw new Error('Invalid mutation: native typing supports printable ASCII without "%s"');
|
|
154
|
+
const { components } = await runBridge(page, { action: 'getComponents', uuid });
|
|
155
|
+
if (!components?.some(component => component.type === 'EditBox'))
|
|
156
|
+
throw new Error('Component not found');
|
|
157
|
+
const clicked = await nativeClick(page, uuid);
|
|
158
|
+
if (!clicked.clicked)
|
|
159
|
+
return { typed: false, reason: clicked.reason };
|
|
160
|
+
let focused = false;
|
|
161
|
+
for (let attempt = 0; attempt < 10 && !focused; attempt++) {
|
|
162
|
+
focused = (await adb(['shell', 'dumpsys window | grep mCurrentFocus'])).includes('CocosEditBoxActivity');
|
|
163
|
+
if (!focused)
|
|
164
|
+
await settle();
|
|
165
|
+
}
|
|
166
|
+
if (!focused)
|
|
167
|
+
return { typed: false, reason: 'NOT_FOCUSED' };
|
|
168
|
+
// End, Shift+Home, Delete: select the current content and remove it. keycombination needs Android 12+.
|
|
169
|
+
await adb(['shell', 'input', 'keyevent', 'KEYCODE_MOVE_END']);
|
|
170
|
+
await adb(['shell', 'input', 'keycombination', 'KEYCODE_SHIFT_LEFT', 'KEYCODE_MOVE_HOME']);
|
|
171
|
+
await adb(['shell', 'input', 'keyevent', 'KEYCODE_DEL']);
|
|
172
|
+
// adb shell joins its arguments into one device shell command line: single-quote the text so it stays one literal word.
|
|
173
|
+
if (text)
|
|
174
|
+
await adb(['shell', `input text '${text.replace(/ /g, '%s').replace(/'/g, `'\\''`)}'`]);
|
|
175
|
+
// Enter closes a single-line box (editing-did-ended); only the activity's confirm button fires editing-return.
|
|
176
|
+
if (submit)
|
|
177
|
+
await adb(['shell', 'input', 'keyevent', 'KEYCODE_ENTER']);
|
|
178
|
+
await settle();
|
|
179
|
+
const { properties } = await runBridge(page, { action: 'getProperties', uuid, componentType: 'EditBox', maxDepth: 0 });
|
|
180
|
+
const after = typeof properties?.string === 'string' ? { string: properties.string } : { redacted: true };
|
|
181
|
+
// The device keyboard still sees the keys: autocorrect or a Telex layout can rewrite "test" as "tét".
|
|
182
|
+
const matches = typeof properties?.string === 'string' ? properties.string === text : undefined;
|
|
183
|
+
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
|
+
}
|
package/dist/src/server.d.ts
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
2
|
import { BrowserConnection } from './browser.js';
|
|
3
|
-
|
|
3
|
+
import { type NativeConnection } from './native.js';
|
|
4
|
+
export declare function createServer(browser: BrowserConnection | NativeConnection, options?: {
|
|
4
5
|
allowRuntimeMutation?: boolean;
|
|
6
|
+
allowBrowserData?: boolean;
|
|
7
|
+
allowMethodCall?: boolean;
|
|
8
|
+
native?: boolean;
|
|
5
9
|
}): McpServer;
|