@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.
Files changed (38) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/README.md +7 -0
  3. package/adapters/manifest.json +8 -0
  4. package/adapters/mobile/bridge-runtime/cdp-bridge.cjs +70 -10
  5. package/adapters/mobile/bridge-runtime/console-forwarder.cjs +115 -15
  6. package/adapters/mobile/bridge-runtime/lib/cdp-broker.cjs +752 -0
  7. package/adapters/mobile/bridge-runtime/lib/devtools-proxy.cjs +177 -0
  8. package/adapters/mobile/bridge-runtime/lib/target-discovery.cjs +13 -3
  9. package/adapters/mobile/reload-app.mjs +67 -0
  10. package/adapters/mobile/start-console-forwarder.sh +17 -2
  11. package/adapters/mobile/start-metro.sh +6 -11
  12. package/adapters/mobile/stop-metro.sh +10 -1
  13. package/adapters/shared/open-debug.mjs +172 -2
  14. package/dist/adapters/extension/network-observer.js +300 -0
  15. package/dist/adapters/mobile/metro-env.js +0 -5
  16. package/dist/adapters/mobile/prepare.js +1 -3
  17. package/dist/adapters/mobile/runtime-decision.js +6 -9
  18. package/dist/adapters.js +14 -1
  19. package/dist/cli-commands.js +6 -3
  20. package/dist/cli.js +4 -0
  21. package/dist/command-contract.js +3 -0
  22. package/dist/commands/call.js +45 -20
  23. package/dist/commands/reload.js +80 -0
  24. package/dist/commands/run.js +49 -22
  25. package/dist/mm-harness-cli.js +17 -1
  26. package/dist/network-observation.js +271 -0
  27. package/docs/NETWORK-CAPTURE.md +98 -0
  28. package/docs/RECIPES.md +10 -0
  29. package/library/actions/mobile/app/network_assert.mjs +14 -0
  30. package/library/actions/mobile/app/network_capture.mjs +72 -0
  31. package/library/actions/mobile/platform/bridge.mjs +7 -2
  32. package/library/actions/shared/app/network-artifact.mjs +10 -0
  33. package/library/actions/shared/app/network-assert.mjs +154 -0
  34. package/library/manifests/extension.action-manifest.json +88 -0
  35. package/library/manifests/mobile.action-manifest.json +107 -0
  36. package/library/recipes/mobile/perps/performance.recipe.json +11 -11
  37. package/package.json +1 -1
  38. 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
- kill "$pid" 2>/dev/null || true
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*) kill "$old_pid" 2>/dev/null || true ;;
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
- # injected as Expo --clear without invoking Mobile's global watchman cleanup.
8
- # Metro owns its default worker count; METRO_MAX_WORKERS is an explicit override.
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 [ -n "$METRO_WORKERS" ] && ! printf '%s' "$METRO_WORKERS" | grep -Eq '^[1-9][0-9]*$'; then
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
- if [ -n "$METRO_WORKERS" ]; then
244
- printf 'Starting Metro on port %s (workers=%s%s)\n' \
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: Metro /open-debugger or /open-dev-menu HTTP endpoints.
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') {