@houwert/conductor 0.12.2 → 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.
- package/README.md +3 -28
- package/dist/commands/install.js +1 -139
- package/dist/commands/logs.js +81 -85
- package/dist/daemon/client.js +2 -9
- package/dist/daemon/log-collector.js +91 -198
- package/dist/daemon/server.js +1 -11
- package/dist/drivers/log-sources/daemon.js +2 -9
- package/dist/drivers/log-sources/metro-discovery.js +208 -0
- package/dist/index.js +1 -21
- package/package.json +3 -6
- package/.claude-plugin/plugin.json +0 -9
- package/dist/commands/cheat-sheet.js +0 -109
- package/dist/postinstall.js +0 -12
- package/skills/conductor/SKILL.md +0 -825
- package/skills/conductor/references/flow-syntax.md +0 -182
- package/skills/skills.yaml +0 -8
|
@@ -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
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
const
|
|
33
|
-
|
|
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.
|
|
70
|
-
this.
|
|
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
|
-
|
|
213
|
+
// ── Metro auto-discovery (deterministic) ─────────────────────────────────
|
|
214
|
+
startMetroAutoDiscovery() {
|
|
246
215
|
if (this.stopped || this.metroConnected)
|
|
247
216
|
return;
|
|
248
|
-
this.
|
|
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.
|
|
224
|
+
const port = await (0, metro_discovery_js_1.discoverMetroPortForDevice)(this.platform, this.deviceId);
|
|
251
225
|
if (port !== null) {
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
return;
|
|
226
|
+
const connected = await this.connectMetro(port);
|
|
227
|
+
if (connected)
|
|
228
|
+
return;
|
|
256
229
|
}
|
|
257
230
|
}
|
|
258
231
|
catch {
|
|
259
|
-
//
|
|
232
|
+
// fall through to retry
|
|
260
233
|
}
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
this.
|
|
264
|
-
|
|
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.
|
|
271
|
-
},
|
|
243
|
+
void this.tryDiscoverAndConnect();
|
|
244
|
+
}, delay);
|
|
272
245
|
}
|
|
273
246
|
}
|
|
274
247
|
/**
|
|
275
|
-
*
|
|
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
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
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
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
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
|
-
|
|
351
|
-
|
|
352
|
-
|
|
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)(
|
|
355
|
-
const
|
|
356
|
-
if (!
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
const
|
|
363
|
-
|
|
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.
|
|
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
|
-
|
|
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;
|
package/dist/daemon/server.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
+
}
|