@houwert/conductor 0.12.3 → 0.13.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.
@@ -12,41 +12,36 @@ exports.LogCollector = void 0;
12
12
  * simctl / adb logcat). For web, polls the co-located web-server's
13
13
  * /consoleLogs endpoint.
14
14
  *
15
- * Optionally discovers and connects to a Metro dev server for React Native
16
- * JS-level console logs. This is opt-in: call enableMetro(port) to start
17
- * background discovery. Metro entries are merged into the same buffer with
18
- * source='metro'.
15
+ * Also deterministically detects whether this device is connected to a Metro
16
+ * dev server and, if so, connects to it. Metro entries are merged into the
17
+ * same buffer with source='metro'. Discovery is automatic callers do not
18
+ * pass a port.
19
+ *
20
+ * iOS/tvOS discovery: locate PIDs running inside the simulator via
21
+ * `xcrun simctl spawn <UDID> launchctl list`, then `lsof` each PID for
22
+ * ESTABLISHED TCP connections to a Metro port on localhost. The remote
23
+ * port is the Metro port for this specific simulator.
24
+ * Android discovery: `adb -s <serial> reverse --list` for forwarded Metro
25
+ * ports — already device-scoped.
26
+ * Target selection: Metro's /json exposes `deviceName` (e.g. the simulator
27
+ * display name, or Android Build.MODEL). We resolve the device's display
28
+ * name from its UDID/serial and filter targets to only those matching —
29
+ * this disambiguates multiple devices sharing a single Metro instance.
19
30
  */
20
31
  const http_1 = __importDefault(require("http"));
21
- const child_process_1 = require("child_process");
22
32
  const types_js_1 = require("../drivers/log-sources/types.js");
23
33
  const ios_js_1 = require("../drivers/log-sources/ios.js");
24
34
  const android_js_1 = require("../drivers/log-sources/android.js");
25
35
  const metro_js_1 = require("../drivers/log-sources/metro.js");
36
+ const metro_discovery_js_1 = require("../drivers/log-sources/metro-discovery.js");
26
37
  const MAX_BUFFER = 5000;
27
38
  const RESTART_DELAY_MS = 2000;
28
39
  const WEB_POLL_INTERVAL_MS = 500;
29
- const METRO_DISCOVERY_INTERVAL_MS = 3000;
30
- const METRO_AUTO_DISCOVERY_MAX_ATTEMPTS = 10;
31
- /** Known Metro dev server port ranges. */
32
- const METRO_PORT_RANGES = [
33
- [8080, 8099], // Metro default range
34
- [19000, 19002], // Expo
35
- ];
36
- function isMetroPort(port) {
37
- return METRO_PORT_RANGES.some(([lo, hi]) => port >= lo && port <= hi);
38
- }
39
- function spawnCapture(cmd, args) {
40
- return new Promise((resolve, reject) => {
41
- const proc = (0, child_process_1.spawn)(cmd, args, { stdio: ['ignore', 'pipe', 'ignore'] });
42
- let out = '';
43
- proc.stdout?.on('data', (chunk) => {
44
- out += chunk.toString();
45
- });
46
- proc.on('close', (code) => code === 0 ? resolve(out) : reject(new Error(`${cmd} failed (${code})`)));
47
- proc.on('error', reject);
48
- });
49
- }
40
+ // Metro discovery retries forever while the daemon is alive — the app may be
41
+ // launched long after the daemon starts. Backoff grows to a ceiling so we're
42
+ // not wasteful when discovery keeps failing (e.g. native-only app).
43
+ const METRO_DISCOVERY_MIN_INTERVAL_MS = 1500;
44
+ const METRO_DISCOVERY_MAX_INTERVAL_MS = 15000;
50
45
  class LogCollector {
51
46
  constructor(platform, deviceId, driverPort, appId, dlog) {
52
47
  this.platform = platform;
@@ -66,8 +61,9 @@ class LogCollector {
66
61
  this.metroDiscoveryTimer = null;
67
62
  this.metroPort = null;
68
63
  this.metroConnected = false;
69
- this.metroAutoDiscovery = false;
70
- this.metroAutoDiscoveryAttempts = 0;
64
+ this.metroDiscoveryAttempts = 0;
65
+ this.cachedDeviceName = null;
66
+ this.lastAnnouncedState = 'none';
71
67
  }
72
68
  async start() {
73
69
  this.stopped = false;
@@ -76,6 +72,12 @@ class LogCollector {
76
72
  return;
77
73
  }
78
74
  await this.startSource();
75
+ // Metro is always auto-discovered. Discovery retries forever with a
76
+ // backoff ceiling — the app may be launched long after the daemon starts,
77
+ // and the cost per attempt is tiny (a few spawns + one HTTP call).
78
+ if (this.platform === 'ios' || this.platform === 'tvos' || this.platform === 'android') {
79
+ this.startMetroAutoDiscovery();
80
+ }
79
81
  }
80
82
  stop() {
81
83
  this.stopped = true;
@@ -101,39 +103,6 @@ class LogCollector {
101
103
  this.metroConnected = false;
102
104
  }
103
105
  }
104
- /**
105
- * Enable Metro log collection for React Native apps.
106
- *
107
- * When `port` is given, connects directly to that Metro port.
108
- * When omitted, auto-discovers the Metro port by probing the device:
109
- * - Android: parses `adb reverse --list` for forwarded Metro ports
110
- * - iOS/tvOS: scans `lsof` for node listeners in Metro port ranges,
111
- * then probes `/json` and matches by deviceId (strict — no appId fallback)
112
- *
113
- * This is opt-in — only call this for React Native apps.
114
- */
115
- enableMetro(port) {
116
- if (this.platform === 'web')
117
- return; // Web already has console logs
118
- if (port !== undefined) {
119
- // Explicit port — same as before
120
- if (this.metroPort === port)
121
- return;
122
- this.teardownMetro();
123
- this.metroAutoDiscovery = false;
124
- this.metroPort = port;
125
- this.startMetroDiscovery();
126
- }
127
- else {
128
- // Auto-discover
129
- if (this.metroAutoDiscovery || this.metroConnected)
130
- return;
131
- this.teardownMetro();
132
- this.metroAutoDiscovery = true;
133
- this.metroAutoDiscoveryAttempts = 0;
134
- this.startAutoDiscovery();
135
- }
136
- }
137
106
  teardownMetro() {
138
107
  if (this.metroSource) {
139
108
  this.metroSource.disconnect();
@@ -241,168 +210,92 @@ class LogCollector {
241
210
  req.on('error', reject);
242
211
  });
243
212
  }
244
- // ── Metro auto-discovery ─────────────────────────────────────────────────
245
- async startAutoDiscovery() {
213
+ // ── Metro auto-discovery (deterministic) ─────────────────────────────────
214
+ startMetroAutoDiscovery() {
246
215
  if (this.stopped || this.metroConnected)
247
216
  return;
248
- this.metroAutoDiscoveryAttempts++;
217
+ void this.tryDiscoverAndConnect();
218
+ }
219
+ async tryDiscoverAndConnect() {
220
+ if (this.stopped || this.metroConnected)
221
+ return;
222
+ this.metroDiscoveryAttempts++;
249
223
  try {
250
- const port = await this.discoverMetroPort();
224
+ const port = await (0, metro_discovery_js_1.discoverMetroPortForDevice)(this.platform, this.deviceId);
251
225
  if (port !== null) {
252
- this.dlog?.(`Metro auto-discovered on port ${port} for device ${this.deviceId}`);
253
- this.metroPort = port;
254
- this.startMetroDiscovery();
255
- return;
226
+ const connected = await this.connectMetro(port);
227
+ if (connected)
228
+ return;
256
229
  }
257
230
  }
258
231
  catch {
259
- // Discovery failed retry
232
+ // fall through to retry
260
233
  }
261
- if (this.metroAutoDiscoveryAttempts >= METRO_AUTO_DISCOVERY_MAX_ATTEMPTS) {
262
- this.dlog?.(`Metro auto-discovery: no Metro instance found for device ${this.deviceId} after ${this.metroAutoDiscoveryAttempts} attempts. Use --metro-port to specify.`);
263
- this.metroAutoDiscovery = false;
264
- return;
234
+ // Announce "searching" exactly once so callers can tell discovery is live.
235
+ if (this.lastAnnouncedState === 'none' && this.metroDiscoveryAttempts >= 3) {
236
+ this.lastAnnouncedState = 'searching';
237
+ this.pushSyntheticMetroEntry(`[conductor] Searching for Metro connection on device ${this.deviceId}… (this is normal for native apps — ignore if not using React Native)`);
265
238
  }
266
- // Retry — app may not have started yet
267
239
  if (!this.stopped) {
240
+ const delay = Math.min(METRO_DISCOVERY_MIN_INTERVAL_MS * Math.pow(1.5, this.metroDiscoveryAttempts - 1), METRO_DISCOVERY_MAX_INTERVAL_MS);
268
241
  this.metroDiscoveryTimer = setTimeout(() => {
269
242
  this.metroDiscoveryTimer = null;
270
- this.startAutoDiscovery();
271
- }, METRO_DISCOVERY_INTERVAL_MS);
243
+ void this.tryDiscoverAndConnect();
244
+ }, delay);
272
245
  }
273
246
  }
274
247
  /**
275
- * Discover the Metro dev server port by probing the device.
276
- *
277
- * Android: parse `adb reverse --list` for forwarded ports in Metro ranges.
278
- * iOS/tvOS: scan `lsof` for node listeners in Metro ranges, probe `/json`,
279
- * and strictly match by deviceId (no appId/single-target fallback).
248
+ * Push a synthetic log entry into the buffer so that Metro connection state
249
+ * is visible to anyone reading the log stream.
280
250
  */
281
- async discoverMetroPort() {
282
- if (this.platform === 'android') {
283
- return this.discoverMetroPortAndroid();
284
- }
285
- if (this.platform === 'ios' || this.platform === 'tvos') {
286
- return this.discoverMetroPortIOS();
287
- }
288
- return null;
289
- }
290
- async discoverMetroPortAndroid() {
291
- try {
292
- const output = await spawnCapture('adb', ['-s', this.deviceId, 'reverse', '--list']);
293
- // Lines look like: host-13 tcp:8082 tcp:8082
294
- for (const line of output.split('\n')) {
295
- const match = line.match(/tcp:(\d+)\s+tcp:(\d+)/);
296
- if (!match)
297
- continue;
298
- const hostPort = parseInt(match[2], 10);
299
- if (isMetroPort(hostPort))
300
- return hostPort;
301
- }
302
- }
303
- catch {
304
- // adb not available or device not connected
305
- }
306
- return null;
307
- }
308
- async discoverMetroPortIOS() {
309
- try {
310
- const output = await spawnCapture('lsof', ['-iTCP', '-sTCP:LISTEN', '-n', '-P']);
311
- const ports = new Set();
312
- for (const line of output.split('\n')) {
313
- if (!line.startsWith('node'))
314
- continue;
315
- // Column 9 is NAME, e.g. "*:8082" or "[::1]:8082" or "127.0.0.1:8082"
316
- const match = line.match(/:(\d+)\s/);
317
- if (!match)
318
- continue;
319
- const port = parseInt(match[1], 10);
320
- if (isMetroPort(port))
321
- ports.add(port);
322
- }
323
- if (ports.size === 0)
324
- return null;
325
- // Probe all candidate ports in parallel
326
- const results = await Promise.all([...ports].map(async (port) => {
327
- try {
328
- const targets = await (0, metro_js_1.fetchTargets)(port, 'localhost');
329
- const withWs = targets.filter((t) => t.webSocketDebuggerUrl);
330
- // Strict deviceId match only — no appId or single-target fallback
331
- const match = withWs.find((t) => t.deviceId === this.deviceId || t.reactNative?.logicalDeviceId === this.deviceId);
332
- return match ? port : null;
333
- }
334
- catch {
335
- return null;
336
- }
337
- }));
338
- return results.find((p) => p !== null) ?? null;
339
- }
340
- catch {
341
- // lsof not available
342
- }
343
- return null;
251
+ pushSyntheticMetroEntry(message) {
252
+ this.pushEntry({
253
+ timestamp: new Date().toISOString(),
254
+ level: 'info',
255
+ message,
256
+ stackTrace: null,
257
+ source: 'metro',
258
+ });
344
259
  }
345
- startMetroDiscovery() {
346
- if (this.stopped || this.metroPort === null)
347
- return;
348
- this.tryConnectMetro();
260
+ /** Metro connection state for the /status endpoint. */
261
+ getMetroStatus() {
262
+ return {
263
+ connected: this.metroConnected,
264
+ port: this.metroPort,
265
+ attempts: this.metroDiscoveryAttempts,
266
+ };
349
267
  }
350
- async tryConnectMetro() {
351
- if (this.stopped || this.metroPort === null || this.metroConnected)
352
- return;
268
+ /**
269
+ * Connect to Metro on the given port, picking the target matching this
270
+ * device's display name. Returns true on success.
271
+ */
272
+ async connectMetro(port) {
273
+ if (this.stopped || this.metroConnected)
274
+ return false;
353
275
  try {
354
- const targets = await (0, metro_js_1.fetchTargets)(this.metroPort, 'localhost');
355
- const target = this.findTargetForDevice(targets);
356
- if (!target || !target.webSocketDebuggerUrl) {
357
- // Target not found yet — app may still be starting. Retry later.
358
- this.scheduleMetroDiscovery();
359
- return;
360
- }
361
- // Found a matching target — connect
362
- const targetIndex = targets.filter((t) => t.webSocketDebuggerUrl).indexOf(target);
363
- this.metroSource = new metro_js_1.MetroLogSource(this.metroPort, 'localhost', targetIndex >= 0 ? targetIndex : undefined);
276
+ const targets = await (0, metro_js_1.fetchTargets)(port, 'localhost');
277
+ const displayName = this.cachedDeviceName ?? (await (0, metro_discovery_js_1.getDeviceDisplayName)(this.platform, this.deviceId));
278
+ if (!displayName)
279
+ return false;
280
+ this.cachedDeviceName = displayName;
281
+ const target = (0, metro_discovery_js_1.selectTargetForDevice)(targets, displayName);
282
+ if (!target?.webSocketDebuggerUrl)
283
+ return false;
284
+ const withWs = targets.filter((t) => t.webSocketDebuggerUrl);
285
+ const targetIndex = withWs.indexOf(target);
286
+ this.metroSource = new metro_js_1.MetroLogSource(port, 'localhost', targetIndex >= 0 ? targetIndex : undefined);
364
287
  this.metroSource.onEntry((entry) => this.pushEntry(entry));
365
288
  await this.metroSource.connect();
366
289
  this.metroConnected = true;
367
- this.dlog?.(`Metro connected for device ${this.deviceId} on port ${this.metroPort}`);
290
+ this.metroPort = port;
291
+ this.lastAnnouncedState = 'connected';
292
+ this.pushSyntheticMetroEntry(`[conductor] Metro connected on port ${port} for device "${displayName}"`);
293
+ this.dlog?.(`Metro connected for device ${this.deviceId} on port ${port}`);
294
+ return true;
368
295
  }
369
296
  catch {
370
- // Metro not running or connection failed — retry
371
- this.scheduleMetroDiscovery();
297
+ return false;
372
298
  }
373
299
  }
374
- /**
375
- * Find a Metro debugger target that matches this daemon's device.
376
- * Checks deviceId (simulator UDID / emulator serial) first,
377
- * then falls back to matching by appId if available.
378
- */
379
- findTargetForDevice(targets) {
380
- const withWs = targets.filter((t) => t.webSocketDebuggerUrl);
381
- if (withWs.length === 0)
382
- return undefined;
383
- // Prefer exact deviceId match (simulator UDID / emulator serial)
384
- const byDevice = withWs.find((t) => t.deviceId === this.deviceId || t.reactNative?.logicalDeviceId === this.deviceId);
385
- if (byDevice)
386
- return byDevice;
387
- // Fall back to appId match if we know the app
388
- if (this.appId) {
389
- const byApp = withWs.find((t) => t.appId === this.appId);
390
- if (byApp)
391
- return byApp;
392
- }
393
- // Single target — safe to use without matching
394
- if (withWs.length === 1)
395
- return withWs[0];
396
- // Multiple targets, no match — don't guess
397
- return undefined;
398
- }
399
- scheduleMetroDiscovery() {
400
- if (this.stopped || this.metroConnected)
401
- return;
402
- this.metroDiscoveryTimer = setTimeout(() => {
403
- this.metroDiscoveryTimer = null;
404
- this.tryConnectMetro();
405
- }, METRO_DISCOVERY_INTERVAL_MS);
406
- }
407
300
  }
408
301
  exports.LogCollector = LogCollector;
@@ -288,6 +288,7 @@ async function main() {
288
288
  chromiumCdpPort: driverPlatform === 'web' ? (0, web_server_js_1.getCdpPort)() : null,
289
289
  pageTargetId: driverPlatform === 'web' ? (0, web_server_js_1.getPageTargetId)() : null,
290
290
  driverStartError: _driverStartError,
291
+ metro: logCollector?.getMetroStatus() ?? null,
291
292
  });
292
293
  return;
293
294
  }
@@ -297,17 +298,6 @@ async function main() {
297
298
  return;
298
299
  }
299
300
  const q = parsed.query;
300
- // Opt-in Metro discovery: ?metro=8081 uses that port directly,
301
- // ?metro (no value) or ?metro=auto triggers auto-discovery.
302
- if (q.metro !== undefined) {
303
- const metroPort = typeof q.metro === 'string' ? parseInt(q.metro, 10) : NaN;
304
- if (metroPort > 0) {
305
- logCollector.enableMetro(metroPort);
306
- }
307
- else {
308
- logCollector.enableMetro(); // auto-discover
309
- }
310
- }
311
301
  const entries = logCollector.query({
312
302
  since: typeof q.since === 'string' ? q.since : undefined,
313
303
  level: typeof q.level === 'string' ? q.level : undefined,
@@ -14,14 +14,12 @@ const http_1 = __importDefault(require("http"));
14
14
  const protocol_js_1 = require("../../daemon/protocol.js");
15
15
  const POLL_INTERVAL_MS = 500;
16
16
  class DaemonLogSource {
17
- constructor(sessionName, metroPort) {
17
+ constructor(sessionName) {
18
18
  this.sessionName = sessionName;
19
- this.metroPort = metroPort;
20
19
  this.callback = null;
21
20
  this.pollTimer = null;
22
21
  this.since = new Date().toISOString();
23
22
  this.stopped = false;
24
- this.metroSent = false;
25
23
  this.sockPath = (0, protocol_js_1.socketPath)(sessionName);
26
24
  }
27
25
  async connect() {
@@ -65,12 +63,7 @@ class DaemonLogSource {
65
63
  poll();
66
64
  }
67
65
  fetchLogs() {
68
- // Include metro param on the first poll to trigger discovery in the daemon
69
- let reqPath = `/logs?since=${encodeURIComponent(this.since)}`;
70
- if (this.metroPort !== undefined && !this.metroSent) {
71
- reqPath += this.metroPort === 'auto' ? '&metro' : `&metro=${this.metroPort}`;
72
- this.metroSent = true;
73
- }
66
+ const reqPath = `/logs?since=${encodeURIComponent(this.since)}`;
74
67
  return new Promise((resolve, reject) => {
75
68
  const req = http_1.default.get({
76
69
  socketPath: this.sockPath,
@@ -0,0 +1,208 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.isMetroPort = isMetroPort;
4
+ exports.discoverMetroPortForDevice = discoverMetroPortForDevice;
5
+ exports.getDeviceDisplayName = getDeviceDisplayName;
6
+ exports.selectTargetForDevice = selectTargetForDevice;
7
+ exports.targetsForDevice = targetsForDevice;
8
+ /**
9
+ * Deterministic Metro discovery helpers — shared between the daemon's
10
+ * log collector and CLI-side commands that need to query Metro for a
11
+ * specific device.
12
+ *
13
+ * The flow:
14
+ * 1. From a device ID (simulator UDID / emulator serial), find which Metro
15
+ * port the device is connected to via `lsof` (iOS/tvOS) or
16
+ * `adb reverse` (Android). No scanning / probing required.
17
+ * 2. From the same device ID, resolve the human-readable display name
18
+ * (`xcrun simctl list` / `adb getprop`).
19
+ * 3. Query Metro's /json and filter targets whose `deviceName` matches.
20
+ *
21
+ * Matching by `deviceName` handles the case where multiple devices share a
22
+ * single Metro instance. It is deterministic unless the user has created
23
+ * two devices with the exact same display name.
24
+ */
25
+ const child_process_1 = require("child_process");
26
+ const metro_js_1 = require("./metro.js");
27
+ /** Metro dev-server port ranges we consider. */
28
+ const METRO_PORT_RANGES = [
29
+ [8080, 8099], // Metro default range
30
+ [19000, 19002], // Expo
31
+ ];
32
+ function isMetroPort(port) {
33
+ return METRO_PORT_RANGES.some(([lo, hi]) => port >= lo && port <= hi);
34
+ }
35
+ function spawnCapture(cmd, args) {
36
+ return new Promise((resolve, reject) => {
37
+ const proc = (0, child_process_1.spawn)(cmd, args, { stdio: ['ignore', 'pipe', 'ignore'] });
38
+ let out = '';
39
+ proc.stdout?.on('data', (chunk) => {
40
+ out += chunk.toString();
41
+ });
42
+ proc.on('close', (code) => code === 0 ? resolve(out) : reject(new Error(`${cmd} failed (${code})`)));
43
+ proc.on('error', reject);
44
+ });
45
+ }
46
+ /**
47
+ * Deterministically locate the Metro port this specific device is connected
48
+ * to. Returns null if the device isn't connected to Metro.
49
+ */
50
+ async function discoverMetroPortForDevice(platform, deviceId) {
51
+ if (platform === 'android') {
52
+ return discoverMetroPortAndroid(deviceId);
53
+ }
54
+ if (platform === 'ios' || platform === 'tvos') {
55
+ return discoverMetroPortIOS(deviceId);
56
+ }
57
+ return null;
58
+ }
59
+ async function discoverMetroPortAndroid(deviceId) {
60
+ try {
61
+ const output = await spawnCapture('adb', ['-s', deviceId, 'reverse', '--list']);
62
+ // Lines look like: host-13 tcp:8082 tcp:8082
63
+ for (const line of output.split('\n')) {
64
+ const match = line.match(/tcp:(\d+)\s+tcp:(\d+)/);
65
+ if (!match)
66
+ continue;
67
+ const hostPort = parseInt(match[2], 10);
68
+ if (isMetroPort(hostPort))
69
+ return hostPort;
70
+ }
71
+ }
72
+ catch {
73
+ // adb not available or device not connected
74
+ }
75
+ return null;
76
+ }
77
+ async function discoverMetroPortIOS(deviceId) {
78
+ const pids = await getSimAppPIDs(deviceId);
79
+ if (pids.length === 0)
80
+ return null;
81
+ const ports = new Set();
82
+ for (const pid of pids) {
83
+ try {
84
+ const output = await spawnCapture('lsof', [
85
+ '-a',
86
+ '-p',
87
+ String(pid),
88
+ '-iTCP',
89
+ '-sTCP:ESTABLISHED',
90
+ '-n',
91
+ '-P',
92
+ ]);
93
+ for (const line of output.split('\n')) {
94
+ // e.g. "Plex 18684 douwe 24u IPv6 ... TCP [::1]:55493->[::1]:8082 (ESTABLISHED)"
95
+ const match = line.match(/->(?:\[::1\]|127\.0\.0\.1):(\d+)\b/);
96
+ if (!match)
97
+ continue;
98
+ const port = parseInt(match[1], 10);
99
+ if (isMetroPort(port))
100
+ ports.add(port);
101
+ }
102
+ }
103
+ catch {
104
+ // lsof failed for this pid — try the next
105
+ }
106
+ }
107
+ if (ports.size === 0)
108
+ return null;
109
+ // Always verify each candidate is actually Metro (not some other service
110
+ // that happens to be in the Metro port range). A single candidate is
111
+ // still verified — otherwise a phantom port would be returned repeatedly.
112
+ for (const port of ports) {
113
+ try {
114
+ const targets = await (0, metro_js_1.fetchTargets)(port, 'localhost');
115
+ if (targets.length > 0)
116
+ return port;
117
+ }
118
+ catch {
119
+ // not Metro — skip
120
+ }
121
+ }
122
+ return null;
123
+ }
124
+ /**
125
+ * Enumerate PIDs of all foreground apps running inside an iOS/tvOS simulator.
126
+ * We don't filter by appId — the session's appId may be stale (the user might
127
+ * have launched the RN app outside conductor), and detecting Metro only needs
128
+ * to find *any* app with an established socket to a Metro port.
129
+ */
130
+ async function getSimAppPIDs(deviceId) {
131
+ try {
132
+ const output = await spawnCapture('xcrun', ['simctl', 'spawn', deviceId, 'launchctl', 'list']);
133
+ const pids = [];
134
+ for (const line of output.split('\n')) {
135
+ // "18684\t0\tUIKitApplication:tv.plex.rn.app.dev[f218][rb-legacy]"
136
+ const match = line.match(/^(\d+)\s+\d+\s+UIKitApplication:/);
137
+ if (!match)
138
+ continue;
139
+ const pid = parseInt(match[1], 10);
140
+ if (pid <= 0)
141
+ continue;
142
+ pids.push(pid);
143
+ }
144
+ return pids;
145
+ }
146
+ catch {
147
+ return [];
148
+ }
149
+ }
150
+ /**
151
+ * Resolve a device's display name — the same string Metro reports as
152
+ * `deviceName` on /json targets.
153
+ *
154
+ * iOS/tvOS: the simulator's name from `xcrun simctl list devices`.
155
+ * Android: `ro.product.model` via adb.
156
+ */
157
+ async function getDeviceDisplayName(platform, deviceId) {
158
+ if (platform === 'ios' || platform === 'tvos') {
159
+ try {
160
+ const output = await spawnCapture('xcrun', ['simctl', 'list', 'devices', '--json']);
161
+ const parsed = JSON.parse(output);
162
+ for (const sims of Object.values(parsed.devices)) {
163
+ const match = sims.find((s) => s.udid === deviceId);
164
+ if (match)
165
+ return match.name;
166
+ }
167
+ }
168
+ catch {
169
+ // fall through
170
+ }
171
+ return null;
172
+ }
173
+ if (platform === 'android') {
174
+ try {
175
+ const output = await spawnCapture('adb', [
176
+ '-s',
177
+ deviceId,
178
+ 'shell',
179
+ 'getprop',
180
+ 'ro.product.model',
181
+ ]);
182
+ const name = output.trim();
183
+ return name || null;
184
+ }
185
+ catch {
186
+ return null;
187
+ }
188
+ }
189
+ return null;
190
+ }
191
+ /**
192
+ * Filter Metro /json targets to those belonging to this device (by display
193
+ * name), preferring the fusebox runtime if present. Returns undefined when
194
+ * no matching target is found — the device may not be connected to Metro,
195
+ * or may share its display name with another device.
196
+ */
197
+ function selectTargetForDevice(targets, displayName) {
198
+ const withWs = targets.filter((t) => t.webSocketDebuggerUrl);
199
+ const matches = withWs.filter((t) => t.deviceName === displayName);
200
+ if (matches.length === 0)
201
+ return undefined;
202
+ const fusebox = matches.find((t) => t.reactNative?.capabilities?.prefersFuseboxFrontend);
203
+ return fusebox ?? matches[0];
204
+ }
205
+ /** Convenience: all /json targets belonging to this device. */
206
+ function targetsForDevice(targets, displayName) {
207
+ return targets.filter((t) => t.webSocketDebuggerUrl && t.deviceName === displayName);
208
+ }