@deeeed/metamask-harness 0.41.1 → 0.42.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/CHANGELOG.md +18 -0
- package/README.md +7 -0
- package/adapters/manifest.json +8 -0
- package/adapters/mobile/bridge-runtime/cdp-bridge.cjs +70 -10
- package/adapters/mobile/bridge-runtime/console-forwarder.cjs +115 -15
- package/adapters/mobile/bridge-runtime/lib/cdp-broker.cjs +752 -0
- package/adapters/mobile/bridge-runtime/lib/devtools-proxy.cjs +177 -0
- package/adapters/mobile/bridge-runtime/lib/target-discovery.cjs +13 -3
- package/adapters/mobile/reload-app.mjs +67 -0
- package/adapters/mobile/start-console-forwarder.sh +17 -2
- package/adapters/mobile/start-metro.sh +6 -11
- package/adapters/mobile/stop-metro.sh +10 -1
- package/adapters/shared/open-debug.mjs +172 -2
- package/dist/adapters/extension/network-observer.js +300 -0
- package/dist/adapters/mobile/metro-env.js +0 -5
- package/dist/adapters/mobile/prepare.js +1 -3
- package/dist/adapters/mobile/runtime-decision.js +6 -9
- package/dist/adapters.js +14 -1
- package/dist/cli-commands.js +6 -3
- package/dist/cli.js +4 -0
- package/dist/command-contract.js +3 -0
- package/dist/commands/call.js +45 -20
- package/dist/commands/reload.js +80 -0
- package/dist/commands/run.js +49 -22
- package/dist/mm-harness-cli.js +17 -1
- package/dist/network-observation.js +271 -0
- package/docs/NETWORK-CAPTURE.md +98 -0
- package/docs/RECIPES.md +10 -0
- package/library/actions/mobile/app/network_assert.mjs +14 -0
- package/library/actions/mobile/app/network_capture.mjs +72 -0
- package/library/actions/mobile/platform/bridge.mjs +7 -2
- package/library/actions/shared/app/network-artifact.mjs +10 -0
- package/library/actions/shared/app/network-assert.mjs +154 -0
- package/library/manifests/extension.action-manifest.json +88 -0
- package/library/manifests/mobile.action-manifest.json +107 -0
- package/library/recipes/mobile/perps/performance.recipe.json +11 -11
- package/package.json +1 -1
- package/scripts/completions.sh +2 -1
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const { WebSocket, WebSocketServer } = require('ws');
|
|
5
|
+
|
|
6
|
+
const MAX_FRAME_BYTES = 8 * 1024 * 1024;
|
|
7
|
+
const SESSION_TIMEOUT_MS = 10_000;
|
|
8
|
+
const EVENT_GATED_DOMAINS = new Set([
|
|
9
|
+
'Debugger',
|
|
10
|
+
'Log',
|
|
11
|
+
'Network',
|
|
12
|
+
'Page',
|
|
13
|
+
'ReactNativeApplication',
|
|
14
|
+
'Runtime',
|
|
15
|
+
]);
|
|
16
|
+
|
|
17
|
+
function createDevtoolsProxy({
|
|
18
|
+
descriptorPath,
|
|
19
|
+
sessions,
|
|
20
|
+
sendCommand,
|
|
21
|
+
requestDiscovery,
|
|
22
|
+
allowedOrigins,
|
|
23
|
+
}) {
|
|
24
|
+
if (!Array.isArray(allowedOrigins) || allowedOrigins.length === 0) {
|
|
25
|
+
throw new Error('DevTools proxy requires at least one allowed Origin');
|
|
26
|
+
}
|
|
27
|
+
let listeningPort = null;
|
|
28
|
+
const clients = new Set();
|
|
29
|
+
const server = new WebSocketServer({
|
|
30
|
+
host: '127.0.0.1',
|
|
31
|
+
port: 0,
|
|
32
|
+
maxPayload: MAX_FRAME_BYTES,
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
async function waitForSession(deviceId) {
|
|
36
|
+
const ready = sessions.get(deviceId);
|
|
37
|
+
if (ready?.brokerReady) return ready;
|
|
38
|
+
requestDiscovery();
|
|
39
|
+
const deadline = Date.now() + SESSION_TIMEOUT_MS;
|
|
40
|
+
while (Date.now() < deadline) {
|
|
41
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
42
|
+
const session = sessions.get(deviceId);
|
|
43
|
+
if (session?.brokerReady) return session;
|
|
44
|
+
}
|
|
45
|
+
throw new Error('Hermes runtime is unavailable or reloading; retry the command');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function writeDescriptor() {
|
|
49
|
+
if (!listeningPort) return;
|
|
50
|
+
fs.writeFileSync(
|
|
51
|
+
descriptorPath,
|
|
52
|
+
`${JSON.stringify({ schemaVersion: 1, pid: process.pid, port: listeningPort })}\n`,
|
|
53
|
+
{ mode: 0o600 },
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function forward(client, message) {
|
|
58
|
+
if (
|
|
59
|
+
!message ||
|
|
60
|
+
typeof message !== 'object' ||
|
|
61
|
+
!Number.isInteger(message.id) ||
|
|
62
|
+
typeof message.method !== 'string'
|
|
63
|
+
) {
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
if (message.method.endsWith('.disable')) {
|
|
67
|
+
client.enabled.delete(message.method.replace(/\.disable$/u, '.enable'));
|
|
68
|
+
if (client.socket.readyState === WebSocket.OPEN) {
|
|
69
|
+
client.socket.send(JSON.stringify({ id: message.id, result: {} }));
|
|
70
|
+
}
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
const isEnable = message.method.endsWith('.enable');
|
|
74
|
+
if (isEnable) {
|
|
75
|
+
client.enabled.set(message.method, message.params || {});
|
|
76
|
+
}
|
|
77
|
+
try {
|
|
78
|
+
const session = await waitForSession(client.deviceId);
|
|
79
|
+
const result = await sendCommand(
|
|
80
|
+
session,
|
|
81
|
+
message.method,
|
|
82
|
+
message.params || {},
|
|
83
|
+
SESSION_TIMEOUT_MS,
|
|
84
|
+
);
|
|
85
|
+
if (client.socket.readyState === WebSocket.OPEN) {
|
|
86
|
+
client.socket.send(JSON.stringify({ id: message.id, result }));
|
|
87
|
+
}
|
|
88
|
+
} catch (error) {
|
|
89
|
+
if (isEnable) client.enabled.delete(message.method);
|
|
90
|
+
if (client.socket.readyState === WebSocket.OPEN) {
|
|
91
|
+
client.socket.send(
|
|
92
|
+
JSON.stringify({
|
|
93
|
+
id: message.id,
|
|
94
|
+
error: {
|
|
95
|
+
code: -32000,
|
|
96
|
+
message: String(error?.message || error).slice(0, 256),
|
|
97
|
+
},
|
|
98
|
+
}),
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
server.on('connection', (socket, request) => {
|
|
105
|
+
const url = new URL(request.url || '/', 'http://127.0.0.1');
|
|
106
|
+
const deviceId = url.searchParams.get('device');
|
|
107
|
+
if (
|
|
108
|
+
!deviceId ||
|
|
109
|
+
!allowedOrigins.includes(request.headers.origin)
|
|
110
|
+
) {
|
|
111
|
+
socket.close(1008, 'invalid DevTools proxy Origin or device');
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
for (const client of clients) {
|
|
115
|
+
if (client.deviceId === deviceId) {
|
|
116
|
+
client.socket.close(1000, 'replaced by a newer DevTools window');
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
const client = { socket, deviceId, enabled: new Map() };
|
|
120
|
+
clients.add(client);
|
|
121
|
+
socket.on('message', (data) => {
|
|
122
|
+
let message;
|
|
123
|
+
try {
|
|
124
|
+
message = JSON.parse(String(data));
|
|
125
|
+
} catch {
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
void forward(client, message);
|
|
129
|
+
});
|
|
130
|
+
const drop = () => clients.delete(client);
|
|
131
|
+
socket.on('close', drop);
|
|
132
|
+
socket.on('error', drop);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
server.on('listening', () => {
|
|
136
|
+
const address = server.address();
|
|
137
|
+
if (!address || typeof address === 'string') return;
|
|
138
|
+
listeningPort = address.port;
|
|
139
|
+
writeDescriptor();
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
return {
|
|
143
|
+
onSessionOpen(deviceId, session) {
|
|
144
|
+
for (const client of clients) {
|
|
145
|
+
if (client.deviceId !== deviceId) continue;
|
|
146
|
+
for (const [method, params] of client.enabled) {
|
|
147
|
+
void sendCommand(session, method, params, SESSION_TIMEOUT_MS).catch(
|
|
148
|
+
() => undefined,
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
},
|
|
153
|
+
onCdpEvent(deviceId, method, params) {
|
|
154
|
+
const payload = JSON.stringify({ method, params });
|
|
155
|
+
const domain = method.split('.', 1)[0];
|
|
156
|
+
for (const client of clients) {
|
|
157
|
+
if (
|
|
158
|
+
client.deviceId === deviceId &&
|
|
159
|
+
client.socket.readyState === WebSocket.OPEN &&
|
|
160
|
+
(!EVENT_GATED_DOMAINS.has(domain) ||
|
|
161
|
+
client.enabled.has(`${domain}.enable`))
|
|
162
|
+
) {
|
|
163
|
+
client.socket.send(payload);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
},
|
|
167
|
+
close() {
|
|
168
|
+
for (const client of clients) client.socket.close();
|
|
169
|
+
server.close();
|
|
170
|
+
try {
|
|
171
|
+
fs.unlinkSync(descriptorPath);
|
|
172
|
+
} catch {}
|
|
173
|
+
},
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
module.exports = { createDevtoolsProxy };
|
|
@@ -138,7 +138,7 @@ function targetDeviceIdentity(target) {
|
|
|
138
138
|
* page 1 = native C++ runtime, page 2+ = JS runtime (where __AGENTIC__ lives)
|
|
139
139
|
* - We probe candidates to find the one with __AGENTIC__ installed
|
|
140
140
|
*/
|
|
141
|
-
async function discoverTarget(port) {
|
|
141
|
+
async function discoverTarget(port, { probe = true } = {}) {
|
|
142
142
|
const listUrl = `http://localhost:${port}/json/list`;
|
|
143
143
|
const androidTargetName = loadAndroidTargetDeviceName();
|
|
144
144
|
const androidDevice = loadAndroidDevice();
|
|
@@ -162,7 +162,7 @@ async function discoverTarget(port) {
|
|
|
162
162
|
if (androidPinned) {
|
|
163
163
|
const pinnedCandidates = runtimeCandidates.filter(matchesAndroidPin);
|
|
164
164
|
for (const candidate of pinnedCandidates) {
|
|
165
|
-
if (await probeTarget(candidate.webSocketDebuggerUrl)) {
|
|
165
|
+
if (!probe || (await probeTarget(candidate.webSocketDebuggerUrl))) {
|
|
166
166
|
acceptedPinnedCandidate = candidate;
|
|
167
167
|
return true;
|
|
168
168
|
}
|
|
@@ -174,7 +174,7 @@ async function discoverTarget(port) {
|
|
|
174
174
|
(candidate) => candidate.deviceName === simName,
|
|
175
175
|
);
|
|
176
176
|
for (const candidate of pinnedCandidates) {
|
|
177
|
-
if (await probeTarget(candidate.webSocketDebuggerUrl)) {
|
|
177
|
+
if (!probe || (await probeTarget(candidate.webSocketDebuggerUrl))) {
|
|
178
178
|
acceptedPinnedCandidate = candidate;
|
|
179
179
|
return true;
|
|
180
180
|
}
|
|
@@ -307,6 +307,16 @@ async function discoverTarget(port) {
|
|
|
307
307
|
}
|
|
308
308
|
}
|
|
309
309
|
|
|
310
|
+
// The persistent console forwarder already owns and validates brokered CDP
|
|
311
|
+
// sessions. Re-probing here would evict that debugger; preserve the normal
|
|
312
|
+
// platform/device filtering and select the highest-ranked runtime instead.
|
|
313
|
+
if (!probe) {
|
|
314
|
+
return {
|
|
315
|
+
wsUrl: candidates[0].webSocketDebuggerUrl,
|
|
316
|
+
deviceName: candidates[0].deviceName || '',
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
|
|
310
320
|
// Sort by page number descending (JS runtime has higher page number than C++
|
|
311
321
|
// native). rankRuntimeCandidates output arrives pre-sorted; this re-sort is
|
|
312
322
|
// load-bearing only when the raw targets.filter() fallback above repopulated
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { createRequire } from 'node:module';
|
|
4
|
+
|
|
5
|
+
const require = createRequire(import.meta.url);
|
|
6
|
+
const WebSocket = require('ws');
|
|
7
|
+
|
|
8
|
+
function parseArgs(argv) {
|
|
9
|
+
const args = { port: process.env.WATCHER_PORT || process.env.METRO_PORT || '8081', json: false };
|
|
10
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
11
|
+
if (argv[index] === '--port') args.port = argv[++index];
|
|
12
|
+
else if (argv[index] === '--json') args.json = true;
|
|
13
|
+
else if (argv[index] === '--help' || argv[index] === '-h') {
|
|
14
|
+
console.log('Usage: reload-app.mjs [--port <Metro port>] [--json]');
|
|
15
|
+
process.exit(0);
|
|
16
|
+
} else {
|
|
17
|
+
throw new Error(`Unknown argument: ${argv[index]}`);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
return args;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function reload(port) {
|
|
24
|
+
const numericPort = Number.parseInt(String(port), 10);
|
|
25
|
+
if (!Number.isInteger(numericPort) || numericPort <= 0) {
|
|
26
|
+
throw new Error(`Metro port is invalid: ${port}`);
|
|
27
|
+
}
|
|
28
|
+
await new Promise((resolve, reject) => {
|
|
29
|
+
const socket = new WebSocket(`ws://127.0.0.1:${numericPort}/message`);
|
|
30
|
+
const timeout = setTimeout(() => {
|
|
31
|
+
socket.close();
|
|
32
|
+
reject(new Error(`Metro reload timed out on port ${numericPort}`));
|
|
33
|
+
}, 5000);
|
|
34
|
+
socket.on('open', () => {
|
|
35
|
+
socket.send(JSON.stringify({ method: 'reload', version: 2 }));
|
|
36
|
+
setTimeout(() => {
|
|
37
|
+
clearTimeout(timeout);
|
|
38
|
+
socket.close();
|
|
39
|
+
resolve();
|
|
40
|
+
}, 50);
|
|
41
|
+
});
|
|
42
|
+
socket.on('error', () => {
|
|
43
|
+
clearTimeout(timeout);
|
|
44
|
+
reject(new Error(`Metro is not reachable on port ${numericPort}`));
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
return {
|
|
48
|
+
ok: true,
|
|
49
|
+
adapter: 'mobile',
|
|
50
|
+
method: 'metro-message',
|
|
51
|
+
command: 'reload',
|
|
52
|
+
port: numericPort,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const args = parseArgs(process.argv.slice(2));
|
|
57
|
+
reload(args.port)
|
|
58
|
+
.then((result) => {
|
|
59
|
+
if (args.json) process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
60
|
+
else console.log(`Reload requested through Metro :${result.port}`);
|
|
61
|
+
})
|
|
62
|
+
.catch((error) => {
|
|
63
|
+
const result = { ok: false, adapter: 'mobile', error: String(error?.message || error) };
|
|
64
|
+
if (args.json) process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
65
|
+
else console.error(result.error);
|
|
66
|
+
process.exitCode = 1;
|
|
67
|
+
});
|
|
@@ -32,12 +32,27 @@ APP_LOG_FILE="$LOG_DIR/app-console.log"
|
|
|
32
32
|
{ [ -f "$FORWARDER" ] && command -v node >/dev/null 2>&1; } || exit 0
|
|
33
33
|
mkdir -p "$LOG_DIR"
|
|
34
34
|
|
|
35
|
+
stopped_pids=()
|
|
36
|
+
stop_forwarder() {
|
|
37
|
+
local pid="$1"
|
|
38
|
+
case " ${stopped_pids[*]-} " in
|
|
39
|
+
*" $pid "*) return ;;
|
|
40
|
+
esac
|
|
41
|
+
stopped_pids+=("$pid")
|
|
42
|
+
kill "$pid" 2>/dev/null || true
|
|
43
|
+
for _ in {1..20}; do
|
|
44
|
+
kill -0 "$pid" 2>/dev/null || return
|
|
45
|
+
sleep 0.05
|
|
46
|
+
done
|
|
47
|
+
kill -KILL "$pid" 2>/dev/null || true
|
|
48
|
+
}
|
|
49
|
+
|
|
35
50
|
# One debugger owns a React Native page. Reap any collector for this Metro port,
|
|
36
51
|
# including one left by an older globally installed harness.
|
|
37
52
|
while read -r pid cmd; do
|
|
38
53
|
case "$cmd" in
|
|
39
54
|
*console-forwarder.cjs*" --port $PORT "*|*console-forwarder.cjs*" --port=$PORT "*)
|
|
40
|
-
|
|
55
|
+
stop_forwarder "$pid"
|
|
41
56
|
;;
|
|
42
57
|
esac
|
|
43
58
|
done < <(ps -axo pid=,command= 2>/dev/null || true)
|
|
@@ -46,7 +61,7 @@ if [ -f "$PID_FILE" ]; then
|
|
|
46
61
|
old_pid="$(cat "$PID_FILE" 2>/dev/null || true)"
|
|
47
62
|
if [ -n "$old_pid" ]; then
|
|
48
63
|
case "$(ps -p "$old_pid" -o command= 2>/dev/null)" in
|
|
49
|
-
*console-forwarder.cjs*)
|
|
64
|
+
*console-forwarder.cjs*) stop_forwarder "$old_pid" ;;
|
|
50
65
|
esac
|
|
51
66
|
fi
|
|
52
67
|
rm -f "$PID_FILE"
|
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
# Checks if Metro is already running and owned by this checkout with required env vars.
|
|
5
5
|
# Clears stale or foreign listeners before starting. Uses the checkout's canonical
|
|
6
6
|
# yarn watch script and sets EXPO_NO_TYPESCRIPT_SETUP=1. Cache clearing is
|
|
7
|
-
#
|
|
8
|
-
#
|
|
7
|
+
# passed to Expo only for the explicit --clear option. Metro defaults to four
|
|
8
|
+
# workers; METRO_MAX_WORKERS remains an explicit override.
|
|
9
9
|
#
|
|
10
10
|
# Inputs:
|
|
11
11
|
# --target <metamask-mobile dir> (default $PWD)
|
|
@@ -227,8 +227,8 @@ fi
|
|
|
227
227
|
|
|
228
228
|
stop_metro_listener || exit 1
|
|
229
229
|
|
|
230
|
-
METRO_WORKERS="${METRO_MAX_WORKERS:-}"
|
|
231
|
-
if
|
|
230
|
+
METRO_WORKERS="${METRO_MAX_WORKERS:-4}"
|
|
231
|
+
if ! printf '%s' "$METRO_WORKERS" | grep -Eq '^[1-9][0-9]*$'; then
|
|
232
232
|
printf 'start-metro: METRO_MAX_WORKERS must be a positive integer, got %s\n' "$METRO_WORKERS" >&2
|
|
233
233
|
exit 2
|
|
234
234
|
fi
|
|
@@ -240,13 +240,8 @@ fi
|
|
|
240
240
|
CLEAR_LABEL=""
|
|
241
241
|
[ "$CLEAR" = true ] && CLEAR_LABEL=", clear"
|
|
242
242
|
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
"$PORT" "$METRO_WORKERS" "$CLEAR_LABEL" >&2
|
|
246
|
-
else
|
|
247
|
-
printf 'Starting Metro on port %s (workers=Metro default%s)\n' \
|
|
248
|
-
"$PORT" "$CLEAR_LABEL" >&2
|
|
249
|
-
fi
|
|
243
|
+
printf 'Starting Metro on port %s (workers=%s%s)\n' \
|
|
244
|
+
"$PORT" "$METRO_WORKERS" "$CLEAR_LABEL" >&2
|
|
250
245
|
printf '(Full log: %s — mm-harness logs | mm-harness logs --full)\n' "$LOG_FILE" >&2
|
|
251
246
|
|
|
252
247
|
[ -f "$METRO_LOG_GENERATION" ] && [ -f "$METRO_LOG_COALESCER" ] || {
|
|
@@ -21,7 +21,7 @@ while [ $# -gt 0 ]; do
|
|
|
21
21
|
esac
|
|
22
22
|
done
|
|
23
23
|
|
|
24
|
-
TARGET="$(cd "$TARGET" && pwd)"
|
|
24
|
+
TARGET="$(cd "$TARGET" && pwd -P)"
|
|
25
25
|
|
|
26
26
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
27
27
|
# shellcheck disable=SC1091
|
|
@@ -102,6 +102,15 @@ if [ -f "$TMUX_FILE" ] && command -v tmux >/dev/null 2>&1; then
|
|
|
102
102
|
fi
|
|
103
103
|
rm -f "$TMUX_FILE"
|
|
104
104
|
|
|
105
|
+
# A stopped checkout must not remain in Watchman's global root set. Metro will
|
|
106
|
+
# add the root again on the next launch; removing only this exact target keeps
|
|
107
|
+
# every other active slot untouched.
|
|
108
|
+
if command -v watchman >/dev/null 2>&1; then
|
|
109
|
+
if watchman watch-del "$TARGET" >/dev/null 2>&1; then
|
|
110
|
+
printf 'Removed Watchman root %s\n' "$TARGET" >&2
|
|
111
|
+
fi
|
|
112
|
+
fi
|
|
113
|
+
|
|
105
114
|
# Reaching here = cleanup done (nothing needed stopping, or it stopped and
|
|
106
115
|
# windows/pids were cleared). The idempotent-stop contract is success; do not
|
|
107
116
|
# leak an incidental non-zero from the last command on any platform.
|
|
@@ -2,12 +2,23 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* open-debug.mjs — open human-facing debugger UIs for recipe runtimes.
|
|
4
4
|
*
|
|
5
|
-
* Mobile:
|
|
5
|
+
* Mobile: broker-backed React Native DevTools or Metro /open-dev-menu.
|
|
6
6
|
* Extension: Chrome DevTools frontend for an extension page or service worker.
|
|
7
7
|
*/
|
|
8
|
-
import { spawnSync } from 'node:child_process';
|
|
8
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
9
|
+
import { createHash } from 'node:crypto';
|
|
10
|
+
import fs from 'node:fs';
|
|
9
11
|
import http from 'node:http';
|
|
10
12
|
import { createRequire } from 'node:module';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
|
|
15
|
+
const require = createRequire(import.meta.url);
|
|
16
|
+
const {
|
|
17
|
+
discoverTarget,
|
|
18
|
+
} = require('../mobile/bridge-runtime/lib/target-discovery.cjs');
|
|
19
|
+
const {
|
|
20
|
+
deviceIdFromUrl,
|
|
21
|
+
} = require('../mobile/bridge-runtime/lib/cdp-broker.cjs');
|
|
11
22
|
|
|
12
23
|
function usage() {
|
|
13
24
|
console.error(`Usage:
|
|
@@ -236,6 +247,9 @@ async function tryDevSettingsCdpFallback(numericPort, normalizedAction) {
|
|
|
236
247
|
|
|
237
248
|
async function openMobileDebugger(port, action, shouldOpen) {
|
|
238
249
|
const normalizedAction = action === 'dev-menu' ? 'dev-menu' : 'debug';
|
|
250
|
+
if (normalizedAction === 'debug') {
|
|
251
|
+
return openBrokeredMobileDebugger(port, shouldOpen);
|
|
252
|
+
}
|
|
239
253
|
const urlPath = normalizedAction === 'dev-menu' ? '/open-dev-menu' : '/open-debugger';
|
|
240
254
|
const numericPort = Number.parseInt(String(port), 10);
|
|
241
255
|
if (!Number.isInteger(numericPort) || numericPort <= 0) {
|
|
@@ -302,6 +316,162 @@ async function openMobileDebugger(port, action, shouldOpen) {
|
|
|
302
316
|
};
|
|
303
317
|
}
|
|
304
318
|
|
|
319
|
+
function processIsAlive(pid) {
|
|
320
|
+
if (!Number.isInteger(pid) || pid < 1) return false;
|
|
321
|
+
try {
|
|
322
|
+
process.kill(pid, 0);
|
|
323
|
+
return true;
|
|
324
|
+
} catch {
|
|
325
|
+
return false;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
async function openBrokeredMobileDebugger(port, shouldOpen) {
|
|
330
|
+
const numericPort = Number.parseInt(String(port), 10);
|
|
331
|
+
if (!Number.isInteger(numericPort) || numericPort <= 0) {
|
|
332
|
+
return { ok: false, adapter: 'mobile', action: 'debug', error: 'invalid-port', port };
|
|
333
|
+
}
|
|
334
|
+
const runtimeDir = path.resolve(
|
|
335
|
+
process.cwd(),
|
|
336
|
+
process.env.RECIPE_RUNTIME_DIR || path.join('temp', 'recipe', 'runtime'),
|
|
337
|
+
);
|
|
338
|
+
const descriptorPath = path.join(runtimeDir, 'devtools-proxy.json');
|
|
339
|
+
let descriptor;
|
|
340
|
+
try {
|
|
341
|
+
descriptor = JSON.parse(fs.readFileSync(descriptorPath, 'utf8'));
|
|
342
|
+
} catch {
|
|
343
|
+
descriptor = null;
|
|
344
|
+
}
|
|
345
|
+
if (
|
|
346
|
+
descriptor?.schemaVersion !== 1 ||
|
|
347
|
+
!Number.isInteger(descriptor.port) ||
|
|
348
|
+
descriptor.port <= 0 ||
|
|
349
|
+
!processIsAlive(descriptor.pid)
|
|
350
|
+
) {
|
|
351
|
+
return {
|
|
352
|
+
ok: false,
|
|
353
|
+
adapter: 'mobile',
|
|
354
|
+
action: 'debug',
|
|
355
|
+
error: 'broker-unavailable',
|
|
356
|
+
port: numericPort,
|
|
357
|
+
hint: 'Start the Mobile runtime first: mm-harness launch ios|android',
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
let target;
|
|
362
|
+
try {
|
|
363
|
+
const selected = await discoverTarget(numericPort, { probe: false });
|
|
364
|
+
const targets = await fetchJson(`http://127.0.0.1:${numericPort}/json/list`);
|
|
365
|
+
const selectedUrl = new URL(selected.wsUrl);
|
|
366
|
+
target = targets.find(
|
|
367
|
+
(candidate) => {
|
|
368
|
+
try {
|
|
369
|
+
const candidateUrl = new URL(candidate.webSocketDebuggerUrl);
|
|
370
|
+
return (
|
|
371
|
+
candidateUrl.pathname === selectedUrl.pathname &&
|
|
372
|
+
candidateUrl.search === selectedUrl.search
|
|
373
|
+
);
|
|
374
|
+
} catch {
|
|
375
|
+
return false;
|
|
376
|
+
}
|
|
377
|
+
},
|
|
378
|
+
);
|
|
379
|
+
} catch (error) {
|
|
380
|
+
return {
|
|
381
|
+
ok: false,
|
|
382
|
+
adapter: 'mobile',
|
|
383
|
+
action: 'debug',
|
|
384
|
+
error: 'target-unavailable',
|
|
385
|
+
port: numericPort,
|
|
386
|
+
hint: 'Wait for the app runtime, then retry: mm-harness debug',
|
|
387
|
+
detail: error instanceof Error ? error.message : String(error),
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
if (!target?.webSocketDebuggerUrl) {
|
|
391
|
+
return {
|
|
392
|
+
ok: false,
|
|
393
|
+
adapter: 'mobile',
|
|
394
|
+
action: 'debug',
|
|
395
|
+
error: 'target-unavailable',
|
|
396
|
+
port: numericPort,
|
|
397
|
+
hint: 'Wait for the app runtime, then retry: mm-harness debug',
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
const deviceId = deviceIdFromUrl(target.webSocketDebuggerUrl);
|
|
402
|
+
const proxyPath = `/devtools?device=${encodeURIComponent(deviceId)}`;
|
|
403
|
+
const publicProxyEndpoint = `ws://127.0.0.1:${descriptor.port}${proxyPath}`;
|
|
404
|
+
const params = new URLSearchParams([
|
|
405
|
+
['ws', `127.0.0.1:${descriptor.port}${proxyPath}`],
|
|
406
|
+
['sources.hide_add_folder', 'true'],
|
|
407
|
+
['unstable_enableNetworkPanel', 'true'],
|
|
408
|
+
]);
|
|
409
|
+
if (target.appId) params.set('appId', target.appId);
|
|
410
|
+
const frontendUrl = `http://127.0.0.1:${numericPort}/debugger-frontend/rn_fusebox.html?${params}`;
|
|
411
|
+
if (!shouldOpen) {
|
|
412
|
+
return {
|
|
413
|
+
ok: true,
|
|
414
|
+
adapter: 'mobile',
|
|
415
|
+
action: 'debug',
|
|
416
|
+
method: 'broker-proxy',
|
|
417
|
+
endpoint: publicProxyEndpoint,
|
|
418
|
+
port: numericPort,
|
|
419
|
+
opened: false,
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
try {
|
|
424
|
+
const mobileRequire = createRequire(path.join(process.cwd(), 'package.json'));
|
|
425
|
+
const dotslash = mobileRequire('fb-dotslash');
|
|
426
|
+
const shellPackage = mobileRequire.resolve(
|
|
427
|
+
'@react-native/debugger-shell/package.json',
|
|
428
|
+
);
|
|
429
|
+
const shellDescriptor = path.join(
|
|
430
|
+
path.dirname(shellPackage),
|
|
431
|
+
'bin',
|
|
432
|
+
'react-native-devtools',
|
|
433
|
+
);
|
|
434
|
+
const windowKey = createHash('sha256')
|
|
435
|
+
.update([frontendUrl, target.appId || '', deviceId].join('-'))
|
|
436
|
+
.digest('hex');
|
|
437
|
+
await new Promise((resolve, reject) => {
|
|
438
|
+
const child = spawn(
|
|
439
|
+
dotslash,
|
|
440
|
+
[
|
|
441
|
+
shellDescriptor,
|
|
442
|
+
`--frontendUrl=${frontendUrl}`,
|
|
443
|
+
`--windowKey=${windowKey}`,
|
|
444
|
+
],
|
|
445
|
+
{ detached: true, stdio: 'ignore' },
|
|
446
|
+
);
|
|
447
|
+
child.once('spawn', resolve);
|
|
448
|
+
child.once('error', reject);
|
|
449
|
+
child.unref();
|
|
450
|
+
});
|
|
451
|
+
} catch (error) {
|
|
452
|
+
return {
|
|
453
|
+
ok: false,
|
|
454
|
+
adapter: 'mobile',
|
|
455
|
+
action: 'debug',
|
|
456
|
+
error: 'devtools-launch-failed',
|
|
457
|
+
endpoint: publicProxyEndpoint,
|
|
458
|
+
port: numericPort,
|
|
459
|
+
hint: 'Install the checkout dependencies, then retry: mm-harness debug',
|
|
460
|
+
detail: error instanceof Error ? error.message : String(error),
|
|
461
|
+
};
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
return {
|
|
465
|
+
ok: true,
|
|
466
|
+
adapter: 'mobile',
|
|
467
|
+
action: 'debug',
|
|
468
|
+
method: 'broker-proxy',
|
|
469
|
+
endpoint: publicProxyEndpoint,
|
|
470
|
+
port: numericPort,
|
|
471
|
+
opened: true,
|
|
472
|
+
};
|
|
473
|
+
}
|
|
474
|
+
|
|
305
475
|
function pickExtensionTarget(targets, targetKind) {
|
|
306
476
|
const extensionTargets = targets.filter((target) => String(target.url || '').startsWith('chrome-extension://'));
|
|
307
477
|
if (targetKind === 'worker') {
|