react-native-ai-debugger 1.1.4 → 1.4.21

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 (144) hide show
  1. package/LICENSE +12 -1
  2. package/README.md +198 -1063
  3. package/build/__tests__/helpers/tap-test-helpers.d.ts +36 -0
  4. package/build/__tests__/helpers/tap-test-helpers.d.ts.map +1 -0
  5. package/build/__tests__/helpers/tap-test-helpers.js +130 -0
  6. package/build/__tests__/helpers/tap-test-helpers.js.map +1 -0
  7. package/build/__tests__/integration/connection-health.test.js +12 -6
  8. package/build/__tests__/integration/connection-health.test.js.map +1 -1
  9. package/build/__tests__/integration/execute-in-app.test.js +2 -1
  10. package/build/__tests__/integration/execute-in-app.test.js.map +1 -1
  11. package/build/__tests__/integration/tap-strategies.test.d.ts +2 -0
  12. package/build/__tests__/integration/tap-strategies.test.d.ts.map +1 -0
  13. package/build/__tests__/integration/tap-strategies.test.js +329 -0
  14. package/build/__tests__/integration/tap-strategies.test.js.map +1 -0
  15. package/build/__tests__/integration/tools.test.js +9 -10
  16. package/build/__tests__/integration/tools.test.js.map +1 -1
  17. package/build/__tests__/unit/burst.test.d.ts +2 -0
  18. package/build/__tests__/unit/burst.test.d.ts.map +1 -0
  19. package/build/__tests__/unit/burst.test.js +50 -0
  20. package/build/__tests__/unit/burst.test.js.map +1 -0
  21. package/build/__tests__/unit/connection-health.test.js +26 -16
  22. package/build/__tests__/unit/connection-health.test.js.map +1 -1
  23. package/build/__tests__/unit/fingerprint.test.d.ts +2 -0
  24. package/build/__tests__/unit/fingerprint.test.d.ts.map +1 -0
  25. package/build/__tests__/unit/fingerprint.test.js +89 -0
  26. package/build/__tests__/unit/fingerprint.test.js.map +1 -0
  27. package/build/__tests__/unit/imageBuffer.test.d.ts +2 -0
  28. package/build/__tests__/unit/imageBuffer.test.d.ts.map +1 -0
  29. package/build/__tests__/unit/imageBuffer.test.js +128 -0
  30. package/build/__tests__/unit/imageBuffer.test.js.map +1 -0
  31. package/build/__tests__/unit/logs.test.js +9 -0
  32. package/build/__tests__/unit/logs.test.js.map +1 -1
  33. package/build/__tests__/unit/networkInterceptor.test.d.ts +2 -0
  34. package/build/__tests__/unit/networkInterceptor.test.d.ts.map +1 -0
  35. package/build/__tests__/unit/networkInterceptor.test.js +104 -0
  36. package/build/__tests__/unit/networkInterceptor.test.js.map +1 -0
  37. package/build/__tests__/unit/screenshot-diff.test.d.ts +2 -0
  38. package/build/__tests__/unit/screenshot-diff.test.d.ts.map +1 -0
  39. package/build/__tests__/unit/screenshot-diff.test.js +97 -0
  40. package/build/__tests__/unit/screenshot-diff.test.js.map +1 -0
  41. package/build/__tests__/unit/tap.test.js +433 -28
  42. package/build/__tests__/unit/tap.test.js.map +1 -1
  43. package/build/core/android.d.ts.map +1 -1
  44. package/build/core/android.js +64 -29
  45. package/build/core/android.js.map +1 -1
  46. package/build/core/appDetection.d.ts +8 -0
  47. package/build/core/appDetection.d.ts.map +1 -0
  48. package/build/core/appDetection.js +91 -0
  49. package/build/core/appDetection.js.map +1 -0
  50. package/build/core/bundle.d.ts.map +1 -1
  51. package/build/core/bundle.js +15 -23
  52. package/build/core/bundle.js.map +1 -1
  53. package/build/core/config.d.ts +8 -0
  54. package/build/core/config.d.ts.map +1 -0
  55. package/build/core/config.js +27 -0
  56. package/build/core/config.js.map +1 -0
  57. package/build/core/connection.d.ts +47 -3
  58. package/build/core/connection.d.ts.map +1 -1
  59. package/build/core/connection.js +519 -124
  60. package/build/core/connection.js.map +1 -1
  61. package/build/core/executor.d.ts +57 -14
  62. package/build/core/executor.d.ts.map +1 -1
  63. package/build/core/executor.js +936 -208
  64. package/build/core/executor.js.map +1 -1
  65. package/build/core/fingerprint.d.ts +4 -0
  66. package/build/core/fingerprint.d.ts.map +1 -0
  67. package/build/core/fingerprint.js +47 -0
  68. package/build/core/fingerprint.js.map +1 -0
  69. package/build/core/guides.d.ts.map +1 -1
  70. package/build/core/guides.js +48 -9
  71. package/build/core/guides.js.map +1 -1
  72. package/build/core/httpServer.js.map +1 -1
  73. package/build/core/imageBuffer.d.ts +37 -0
  74. package/build/core/imageBuffer.d.ts.map +1 -0
  75. package/build/core/imageBuffer.js +58 -0
  76. package/build/core/imageBuffer.js.map +1 -0
  77. package/build/core/index.d.ts +21 -17
  78. package/build/core/index.d.ts.map +1 -1
  79. package/build/core/index.js +21 -19
  80. package/build/core/index.js.map +1 -1
  81. package/build/core/ios.d.ts +36 -7
  82. package/build/core/ios.d.ts.map +1 -1
  83. package/build/core/ios.js +553 -323
  84. package/build/core/ios.js.map +1 -1
  85. package/build/core/license.d.ts +38 -0
  86. package/build/core/license.d.ts.map +1 -0
  87. package/build/core/license.js +265 -0
  88. package/build/core/license.js.map +1 -0
  89. package/build/core/logbox.d.ts +51 -0
  90. package/build/core/logbox.d.ts.map +1 -0
  91. package/build/core/logbox.js +258 -0
  92. package/build/core/logbox.js.map +1 -0
  93. package/build/core/logs.d.ts +1 -0
  94. package/build/core/logs.d.ts.map +1 -1
  95. package/build/core/logs.js +5 -0
  96. package/build/core/logs.js.map +1 -1
  97. package/build/core/metro.d.ts +8 -0
  98. package/build/core/metro.d.ts.map +1 -1
  99. package/build/core/metro.js +28 -0
  100. package/build/core/metro.js.map +1 -1
  101. package/build/core/networkInterceptor.d.ts +30 -0
  102. package/build/core/networkInterceptor.d.ts.map +1 -0
  103. package/build/core/networkInterceptor.js +207 -0
  104. package/build/core/networkInterceptor.js.map +1 -0
  105. package/build/core/ocr.d.ts +8 -3
  106. package/build/core/ocr.d.ts.map +1 -1
  107. package/build/core/ocr.js +116 -15
  108. package/build/core/ocr.js.map +1 -1
  109. package/build/core/posthog.d.ts +5 -0
  110. package/build/core/posthog.d.ts.map +1 -0
  111. package/build/core/posthog.js +46 -0
  112. package/build/core/posthog.js.map +1 -0
  113. package/build/core/sdkBridge.d.ts +67 -0
  114. package/build/core/sdkBridge.d.ts.map +1 -0
  115. package/build/core/sdkBridge.js +148 -0
  116. package/build/core/sdkBridge.js.map +1 -0
  117. package/build/core/state.d.ts +26 -5
  118. package/build/core/state.d.ts.map +1 -1
  119. package/build/core/state.js +83 -10
  120. package/build/core/state.js.map +1 -1
  121. package/build/core/telemetry.d.ts +19 -1
  122. package/build/core/telemetry.d.ts.map +1 -1
  123. package/build/core/telemetry.js +165 -8
  124. package/build/core/telemetry.js.map +1 -1
  125. package/build/core/types.d.ts +11 -0
  126. package/build/core/types.d.ts.map +1 -1
  127. package/build/index.js +1304 -304
  128. package/build/index.js.map +1 -1
  129. package/build/pro/screenshot-diff.d.ts +10 -0
  130. package/build/pro/screenshot-diff.d.ts.map +1 -0
  131. package/build/pro/screenshot-diff.js +38 -0
  132. package/build/pro/screenshot-diff.js.map +1 -0
  133. package/build/pro/tap.d.ts +160 -0
  134. package/build/pro/tap.d.ts.map +1 -0
  135. package/build/pro/tap.js +1215 -0
  136. package/build/pro/tap.js.map +1 -0
  137. package/build/tools/accountTools.d.ts +44 -0
  138. package/build/tools/accountTools.d.ts.map +1 -0
  139. package/build/tools/accountTools.js +217 -0
  140. package/build/tools/accountTools.js.map +1 -0
  141. package/package.json +11 -8
  142. package/build/core/httpServer.js +0 -2459
  143. package/build/core/httpServerProcess.js +0 -153
  144. package/build/httpServerStandalone.js +0 -31
@@ -1,11 +1,15 @@
1
1
  import WebSocket from "ws";
2
- import { connectedApps, pendingExecutions, getNextMessageId, logBuffer, networkBuffer, setActiveSimulatorUdid, clearActiveSimulatorIfSource, updateLastCDPMessageTime, getLastCDPMessageTime } from "./state.js";
2
+ import { connectedApps, pendingExecutions, getNextMessageId, getLogBuffer, getNetworkBuffer, setActiveSimulatorUdid, clearActiveSimulatorIfSource, updateLastCDPMessageTime, getLastCDPMessageTime, clearLastCDPMessageTime } from "./state.js";
3
3
  import { mapConsoleType } from "./logs.js";
4
+ import { injectNetworkInterceptor, sendNetworkEnable, isInterceptorEvent, applyInterceptedEvent } from "./networkInterceptor.js";
4
5
  import { findSimulatorByName } from "./ios.js";
5
6
  import { fetchDevices, selectMainDevice, scanMetroPorts } from "./metro.js";
7
+ import { scheduleAppDetection } from "./appDetection.js";
6
8
  import { DEFAULT_RECONNECTION_CONFIG, MIN_STABLE_CONNECTION_MS, initConnectionState, updateConnectionState, getConnectionState, recordConnectionGap, closeConnectionGap, saveConnectionMetadata, getConnectionMetadata, saveReconnectionTimer, cancelReconnectionTimer, calculateBackoffDelay, initContextHealth, markContextHealthy, markContextStale, getContextHealth, updateContextHealth, formatDuration, } from "./connectionState.js";
7
9
  // Connection locks to prevent concurrent connection attempts to the same device
8
10
  const connectionLocks = new Set();
11
+ // Track Network.enable message IDs to detect CDP network support
12
+ const pendingNetworkEnableIds = new Set();
9
13
  // Suppress auto-reconnection for intentionally disconnected devices
10
14
  const reconnectionSuppressed = new Set();
11
15
  /**
@@ -17,6 +21,13 @@ export function suppressReconnection() {
17
21
  reconnectionSuppressed.add(key);
18
22
  }
19
23
  }
24
+ /**
25
+ * Suppress auto-reconnection for a specific connection key.
26
+ * Used by disconnect_metro with device targeting.
27
+ */
28
+ export function suppressReconnectionForKey(appKey) {
29
+ reconnectionSuppressed.add(appKey);
30
+ }
20
31
  /**
21
32
  * Clear reconnection suppression (called when user explicitly reconnects via scan_metro).
22
33
  */
@@ -24,7 +35,76 @@ export function clearReconnectionSuppression() {
24
35
  reconnectionSuppressed.clear();
25
36
  }
26
37
  const STALE_ACTIVITY_THRESHOLD_MS = 30_000;
38
+ const PING_INTERVAL_MS = 1_000; // WebSocket ping/pong keepalive interval
27
39
  const RECONNECT_SETTLE_MS = 500;
40
+ /**
41
+ * Creates a WebSocket connection with Origin header fallback.
42
+ * RN 0.85+ Metro requires the Origin header, but Expo SDK 55 Bridgeless
43
+ * rejects connections that include one. The rejection can manifest as:
44
+ * - Connection refused before open (error/close before open event)
45
+ * - Immediate close after open (open fires, then close with code 1006)
46
+ * Tries with Origin first, falls back to without on quick failure.
47
+ */
48
+ export function createWebSocketWithOriginFallback(url, timeoutMs = 5000) {
49
+ const STABILIZE_MS = 500; // Wait after open to detect immediate close
50
+ return new Promise((resolve, reject) => {
51
+ const wsUrl = new URL(url);
52
+ const origin = `http://${wsUrl.hostname}:${wsUrl.port}`;
53
+ let settled = false;
54
+ const tryConnect = (withOrigin) => {
55
+ const options = withOrigin ? { headers: { Origin: origin } } : undefined;
56
+ const ws = new WebSocket(url, options);
57
+ let opened = false;
58
+ ws.on("open", () => {
59
+ opened = true;
60
+ if (withOrigin) {
61
+ // Wait briefly to detect immediate close (Expo SDK 55 pattern)
62
+ setTimeout(() => {
63
+ if (settled)
64
+ return;
65
+ if (ws.readyState === WebSocket.OPEN) {
66
+ settled = true;
67
+ console.error("[rn-ai-debugger] WebSocket connected with Origin header");
68
+ resolve(ws);
69
+ }
70
+ // If not OPEN, the close handler will trigger fallback
71
+ }, STABILIZE_MS);
72
+ }
73
+ else {
74
+ if (settled)
75
+ return;
76
+ settled = true;
77
+ console.error("[rn-ai-debugger] WebSocket connected without Origin header");
78
+ resolve(ws);
79
+ }
80
+ });
81
+ const handleFailure = (error) => {
82
+ if (settled)
83
+ return;
84
+ ws.removeAllListeners();
85
+ ws.terminate();
86
+ if (withOrigin) {
87
+ const reason = opened ? "immediately closed after open" : "rejected before open";
88
+ console.error(`[rn-ai-debugger] Origin header connection ${reason}, retrying without...`);
89
+ tryConnect(false);
90
+ }
91
+ else {
92
+ settled = true;
93
+ reject(error || new Error("WebSocket connection failed"));
94
+ }
95
+ };
96
+ ws.on("error", (err) => handleFailure(err));
97
+ ws.on("close", () => handleFailure());
98
+ };
99
+ tryConnect(true);
100
+ setTimeout(() => {
101
+ if (!settled) {
102
+ settled = true;
103
+ reject(new Error("WebSocket connection timed out"));
104
+ }
105
+ }, timeoutMs);
106
+ });
107
+ }
28
108
  // Helper to find appKey from device info by searching connectedApps
29
109
  function findAppKeyForDevice(device) {
30
110
  for (const [key, app] of connectedApps.entries()) {
@@ -44,6 +124,47 @@ function getWebSocketStateName(state) {
44
124
  default: return `UNKNOWN(${state})`;
45
125
  }
46
126
  }
127
+ /**
128
+ * Purge stale connections for scanned ports.
129
+ * When Metro restarts with a different app, /json returns new device IDs.
130
+ * Old connections keyed by the previous device IDs remain in connectedApps.
131
+ * This function removes connections on scanned ports whose device IDs
132
+ * are no longer present in the fresh device list from Metro.
133
+ */
134
+ export function purgeStaleConnectionsForPorts(freshDevicesByPort) {
135
+ const purged = [];
136
+ const scannedPorts = new Set(freshDevicesByPort.keys());
137
+ for (const [appKey, app] of connectedApps.entries()) {
138
+ // Only check connections on ports we just scanned
139
+ if (!scannedPorts.has(app.port))
140
+ continue;
141
+ const freshDevices = freshDevicesByPort.get(app.port) || [];
142
+ // Check both device ID and appId — device IDs are per-physical-device
143
+ // (stable across Metro restarts), so a different app on the same device
144
+ // would have the same ID but a different appId
145
+ const matchingDevice = freshDevices.find(d => d.id === app.deviceInfo.id);
146
+ const stillValid = matchingDevice && matchingDevice.appId === app.deviceInfo.appId;
147
+ if (!stillValid) {
148
+ const name = app.deviceInfo.deviceName || app.deviceInfo.title;
149
+ const reason = !matchingDevice
150
+ ? "device no longer in Metro /json"
151
+ : `appId changed: ${app.deviceInfo.appId} → ${matchingDevice.appId}`;
152
+ console.error(`[rn-ai-debugger] Purging stale connection: ${name} (port ${app.port}, id ${app.deviceInfo.id}) — ${reason}`);
153
+ // Suppress reconnection so close handler doesn't try to reconnect
154
+ reconnectionSuppressed.add(appKey);
155
+ cancelReconnectionTimer(appKey);
156
+ clearLastCDPMessageTime(appKey);
157
+ clearActiveSimulatorIfSource(appKey);
158
+ try {
159
+ app.ws.close();
160
+ }
161
+ catch { /* ignore */ }
162
+ connectedApps.delete(appKey);
163
+ purged.push(name);
164
+ }
165
+ }
166
+ return purged;
167
+ }
47
168
  // Format CDP RemoteObject to readable string
48
169
  export function formatRemoteObject(result) {
49
170
  if (result.type === "undefined") {
@@ -159,6 +280,10 @@ function formatPreview(preview) {
159
280
  }
160
281
  // Format a single CDP console argument (sync — without object resolution)
161
282
  function formatConsoleArg(arg) {
283
+ // Explicit undefined (matches browser: console.log(undefined) → "undefined")
284
+ if (arg.type === "undefined") {
285
+ return "undefined";
286
+ }
162
287
  // Primitives
163
288
  if (arg.type === "string" || arg.type === "number" || arg.type === "boolean") {
164
289
  return String(arg.value);
@@ -175,7 +300,11 @@ function formatConsoleArg(arg) {
175
300
  if (arg.description) {
176
301
  return arg.description;
177
302
  }
178
- return "[object]";
303
+ // Object without any resolved data
304
+ if (arg.type === "object") {
305
+ return "[Object]";
306
+ }
307
+ return `[${arg.type || "unknown"}]`;
179
308
  }
180
309
  // Fetch object properties via CDP Runtime.getProperties
181
310
  function fetchObjectProperties(ws, objectId, depth = 2) {
@@ -294,9 +423,13 @@ async function resolveConsoleArgs(ws, args) {
294
423
  return parts.join(" ");
295
424
  }
296
425
  // Handle CDP messages
297
- export function handleCDPMessage(message, _device, ws) {
298
- // Track last CDP activity for connection liveness detection
299
- updateLastCDPMessageTime(new Date());
426
+ export function handleCDPMessage(message, device, ws) {
427
+ // Track last CDP activity for connection liveness detection (per-device)
428
+ const cdpAppKey = findAppKeyForDevice(device);
429
+ if (cdpAppKey) {
430
+ updateLastCDPMessageTime(cdpAppKey, new Date());
431
+ }
432
+ const deviceName = device.deviceName || device.title || "unknown";
300
433
  // Handle responses to our requests (e.g., Runtime.evaluate)
301
434
  if (typeof message.id === "number") {
302
435
  const pending = pendingExecutions.get(message.id);
@@ -332,6 +465,24 @@ export function handleCDPMessage(message, _device, ws) {
332
465
  }
333
466
  pending.resolve({ success: true, result: "undefined" });
334
467
  }
468
+ // Track Network.enable responses to detect CDP network support
469
+ if (pendingNetworkEnableIds.has(message.id)) {
470
+ pendingNetworkEnableIds.delete(message.id);
471
+ const nAppKey = findAppKeyForDevice(device);
472
+ if (nAppKey) {
473
+ const app = connectedApps.get(nAppKey);
474
+ if (app) {
475
+ if (message.error) {
476
+ app.cdpNetworkSupported = false;
477
+ console.error(`[rn-ai-debugger] CDP Network domain not supported, using JS interceptor`);
478
+ }
479
+ else {
480
+ app.cdpNetworkSupported = true;
481
+ console.error(`[rn-ai-debugger] CDP Network domain supported`);
482
+ }
483
+ }
484
+ }
485
+ }
335
486
  return;
336
487
  }
337
488
  const method = message.method;
@@ -341,36 +492,42 @@ export function handleCDPMessage(message, _device, ws) {
341
492
  const type = params.type || "log";
342
493
  const level = mapConsoleType(type);
343
494
  const args = params.args || [];
495
+ // Check for network interceptor events before processing as logs
496
+ const interceptorJson = isInterceptorEvent(args);
497
+ if (interceptorJson !== null) {
498
+ const iAppKey = findAppKeyForDevice(device);
499
+ const iApp = iAppKey ? connectedApps.get(iAppKey) : null;
500
+ if (!iApp?.cdpNetworkSupported) {
501
+ applyInterceptedEvent(interceptorJson, getNetworkBuffer(deviceName));
502
+ }
503
+ return;
504
+ }
344
505
  // Check if any args need async object resolution
345
506
  const hasObjectArgs = ws && args.some((a) => a.type === "object" && a.objectId && !a.preview?.properties);
346
507
  if (hasObjectArgs) {
347
508
  // Resolve object args asynchronously via CDP
348
509
  resolveConsoleArgs(ws, args).then((messageText) => {
349
- if (messageText.trim()) {
350
- logBuffer.add({
351
- timestamp: new Date(),
352
- level,
353
- message: messageText,
354
- args: args.map((a) => a.value)
355
- });
356
- }
510
+ getLogBuffer(deviceName).add({
511
+ timestamp: new Date(),
512
+ level,
513
+ message: messageText || "[console call with empty resolution]",
514
+ args: args.map((a) => a.value)
515
+ });
357
516
  }).catch(() => {
358
517
  // Fallback to sync formatting on error
359
518
  const messageText = args.map(formatConsoleArg).join(" ");
360
- if (messageText.trim()) {
361
- logBuffer.add({
362
- timestamp: new Date(),
363
- level,
364
- message: messageText,
365
- args: args.map((a) => a.value)
366
- });
367
- }
519
+ getLogBuffer(deviceName).add({
520
+ timestamp: new Date(),
521
+ level,
522
+ message: messageText || "[console call with unresolvable args]",
523
+ args: args.map((a) => a.value)
524
+ });
368
525
  });
369
526
  }
370
527
  else {
371
528
  const messageText = args.map(formatConsoleArg).join(" ");
372
529
  if (messageText.trim()) {
373
- logBuffer.add({
530
+ getLogBuffer(deviceName).add({
374
531
  timestamp: new Date(),
375
532
  level,
376
533
  message: messageText,
@@ -384,7 +541,7 @@ export function handleCDPMessage(message, _device, ws) {
384
541
  const params = message.params;
385
542
  if (params.entry) {
386
543
  const level = mapConsoleType(params.entry.level || "log");
387
- logBuffer.add({
544
+ getLogBuffer(deviceName).add({
388
545
  timestamp: new Date(),
389
546
  level,
390
547
  message: params.entry.text || ""
@@ -406,12 +563,12 @@ export function handleCDPMessage(message, _device, ws) {
406
563
  },
407
564
  completed: false
408
565
  };
409
- networkBuffer.set(params.requestId, request);
566
+ getNetworkBuffer(deviceName).set(params.requestId, request);
410
567
  }
411
568
  // Handle Network.responseReceived
412
569
  if (method === "Network.responseReceived") {
413
570
  const params = message.params;
414
- const existing = networkBuffer.get(params.requestId);
571
+ const existing = getNetworkBuffer(deviceName).get(params.requestId);
415
572
  if (existing) {
416
573
  existing.status = params.response.status;
417
574
  existing.statusText = params.response.statusText;
@@ -420,40 +577,47 @@ export function handleCDPMessage(message, _device, ws) {
420
577
  if (params.timestamp && existing.timing?.requestTime) {
421
578
  existing.timing.responseTime = params.timestamp;
422
579
  }
423
- networkBuffer.set(params.requestId, existing);
580
+ getNetworkBuffer(deviceName).set(params.requestId, existing);
424
581
  }
425
582
  }
426
583
  // Handle Network.loadingFinished
427
584
  if (method === "Network.loadingFinished") {
428
585
  const params = message.params;
429
- const existing = networkBuffer.get(params.requestId);
586
+ const existing = getNetworkBuffer(deviceName).get(params.requestId);
430
587
  if (existing) {
431
588
  existing.completed = true;
432
589
  existing.contentLength = params.encodedDataLength;
433
590
  if (params.timestamp && existing.timing?.requestTime) {
434
591
  existing.timing.duration = Math.round((params.timestamp - existing.timing.requestTime) * 1000);
435
592
  }
436
- networkBuffer.set(params.requestId, existing);
593
+ getNetworkBuffer(deviceName).set(params.requestId, existing);
437
594
  }
438
595
  }
439
596
  // Handle Network.loadingFailed
440
597
  if (method === "Network.loadingFailed") {
441
598
  const params = message.params;
442
- const existing = networkBuffer.get(params.requestId);
599
+ const existing = getNetworkBuffer(deviceName).get(params.requestId);
443
600
  if (existing) {
444
601
  existing.completed = true;
445
602
  existing.error = params.canceled ? "Canceled" : (params.errorText || "Request failed");
446
- networkBuffer.set(params.requestId, existing);
603
+ getNetworkBuffer(deviceName).set(params.requestId, existing);
447
604
  }
448
605
  }
449
606
  // Handle Runtime context lifecycle events for health tracking
450
- const appKey = findAppKeyForDevice(_device);
607
+ const appKey = findAppKeyForDevice(device);
451
608
  if (appKey) {
452
609
  // Handle Runtime.executionContextCreated
453
610
  if (method === "Runtime.executionContextCreated") {
454
611
  const params = message.params;
455
612
  markContextHealthy(appKey, params.context.id);
456
613
  console.error(`[rn-ai-debugger] Context created: ${params.context.id}`);
614
+ // Re-inject network interceptor and re-check CDP Network support
615
+ const ctxApp = connectedApps.get(appKey);
616
+ if (ctxApp?.ws?.readyState === WebSocket.OPEN) {
617
+ injectNetworkInterceptor(ctxApp.ws);
618
+ const nEnableId = sendNetworkEnable(ctxApp.ws);
619
+ pendingNetworkEnableIds.add(nEnableId);
620
+ }
457
621
  }
458
622
  // Handle Runtime.executionContextDestroyed
459
623
  if (method === "Runtime.executionContextDestroyed") {
@@ -470,18 +634,61 @@ export function handleCDPMessage(message, _device, ws) {
470
634
  // Connect to a device via CDP WebSocket
471
635
  export async function connectToDevice(device, port, options = {}) {
472
636
  const { isReconnection = false, reconnectionConfig = DEFAULT_RECONNECTION_CONFIG } = options;
473
- return new Promise((resolve, reject) => {
637
+ return new Promise(async (resolve, reject) => {
474
638
  const appKey = `${port}-${device.id}`;
475
639
  // Check if already connected with a valid WebSocket
476
640
  const existingApp = connectedApps.get(appKey);
477
641
  if (existingApp) {
478
642
  if (existingApp.ws.readyState === WebSocket.OPEN) {
479
- resolve(`Already connected to ${device.title}`);
480
- return;
643
+ // Verify WebSocket is actually alive with a ping/pong check.
644
+ // readyState can show OPEN even when the remote end is gone
645
+ // (TCP hasn't timed out yet, especially after Metro restart).
646
+ const isAlive = await new Promise((pongResolve) => {
647
+ const timeout = setTimeout(() => pongResolve(false), 2000);
648
+ existingApp.ws.once("pong", () => {
649
+ clearTimeout(timeout);
650
+ pongResolve(true);
651
+ });
652
+ try {
653
+ existingApp.ws.ping();
654
+ }
655
+ catch {
656
+ clearTimeout(timeout);
657
+ pongResolve(false);
658
+ }
659
+ });
660
+ if (isAlive) {
661
+ resolve(`Already connected to ${device.title}`);
662
+ return;
663
+ }
664
+ // Ping failed — connection is dead, clean up
665
+ console.error(`[rn-ai-debugger] Existing connection to ${device.title} failed liveness check (no pong), reconnecting`);
666
+ reconnectionSuppressed.add(appKey);
667
+ try {
668
+ existingApp.ws.terminate();
669
+ }
670
+ catch { /* ignore */ }
671
+ connectedApps.delete(appKey);
672
+ clearLastCDPMessageTime(appKey);
673
+ clearActiveSimulatorIfSource(appKey);
674
+ }
675
+ else {
676
+ // WebSocket exists but not OPEN - clean up stale entry
677
+ console.error(`[rn-ai-debugger] Cleaning up stale connection for ${device.title} (state: ${getWebSocketStateName(existingApp.ws.readyState)})`);
678
+ connectedApps.delete(appKey);
679
+ }
680
+ }
681
+ // Skip if this device is already connected on a different port
682
+ // (Metro's /json advertises all devices it can see, even ones built by other Metro servers)
683
+ const deviceName = device.deviceName || device.title;
684
+ for (const [existingKey, existingApp] of connectedApps.entries()) {
685
+ if (existingKey !== appKey && existingApp.ws.readyState === WebSocket.OPEN) {
686
+ const existingName = existingApp.deviceInfo.deviceName || existingApp.deviceInfo.title;
687
+ if (existingName === deviceName) {
688
+ resolve(`Skipped ${deviceName} (already connected on port ${existingApp.port})`);
689
+ return;
690
+ }
481
691
  }
482
- // WebSocket exists but not OPEN - clean up stale entry
483
- console.error(`[rn-ai-debugger] Cleaning up stale connection for ${device.title} (state: ${getWebSocketStateName(existingApp.ws.readyState)})`);
484
- connectedApps.delete(appKey);
485
692
  }
486
693
  // Prevent concurrent connection attempts to the same device
487
694
  if (connectionLocks.has(appKey)) {
@@ -498,60 +705,79 @@ export async function connectToDevice(device, port, options = {}) {
498
705
  webSocketUrl: device.webSocketDebuggerUrl
499
706
  });
500
707
  try {
501
- const ws = new WebSocket(device.webSocketDebuggerUrl);
502
- ws.on("open", async () => {
503
- // Release connection lock
504
- connectionLocks.delete(appKey);
505
- connectedApps.set(appKey, { ws, deviceInfo: device, port, platform: "android" });
506
- // Initialize or update connection state
507
- // Note: We do NOT reset reconnectionAttempts here - that happens
508
- // only when connection has been stable for MIN_STABLE_CONNECTION_MS
509
- if (isReconnection) {
510
- closeConnectionGap(appKey);
511
- updateConnectionState(appKey, {
512
- status: "connected",
513
- lastConnectedTime: new Date()
514
- // reconnectionAttempts NOT reset here - see ws.on("close") for stable connection check
515
- });
516
- // Reset context health for reconnection
517
- initContextHealth(appKey);
518
- console.error(`[rn-ai-debugger] Reconnected to ${device.title}`);
519
- }
520
- else {
521
- initConnectionState(appKey);
522
- initContextHealth(appKey);
523
- console.error(`[rn-ai-debugger] Connected to ${device.title}`);
524
- }
525
- // Enable Runtime domain to receive console messages
526
- ws.send(JSON.stringify({
527
- id: getNextMessageId(),
528
- method: "Runtime.enable"
529
- }));
530
- // Also enable Log domain
531
- ws.send(JSON.stringify({
532
- id: getNextMessageId(),
533
- method: "Log.enable"
534
- }));
535
- // Enable Network domain to track requests
536
- ws.send(JSON.stringify({
537
- id: getNextMessageId(),
538
- method: "Network.enable"
539
- }));
540
- // Try to resolve iOS simulator UDID from device name
541
- // This enables automatic device scoping for iOS tools
542
- if (device.deviceName) {
543
- const simulatorUdid = await findSimulatorByName(device.deviceName);
544
- if (simulatorUdid) {
545
- setActiveSimulatorUdid(simulatorUdid, appKey);
546
- // Update platform to ios now that simulator is confirmed
547
- const connectedApp = connectedApps.get(appKey);
548
- if (connectedApp) {
549
- connectedApp.platform = "ios";
550
- }
551
- console.error(`[rn-ai-debugger] Linked to iOS simulator: ${simulatorUdid}`);
708
+ const ws = await createWebSocketWithOriginFallback(device.webSocketDebuggerUrl);
709
+ // Connection established — run setup
710
+ connectionLocks.delete(appKey);
711
+ connectedApps.set(appKey, { ws, deviceInfo: device, port, platform: "android" });
712
+ // Initialize or update connection state
713
+ // Note: We do NOT reset reconnectionAttempts here - that happens
714
+ // only when connection has been stable for MIN_STABLE_CONNECTION_MS
715
+ if (isReconnection) {
716
+ closeConnectionGap(appKey);
717
+ updateConnectionState(appKey, {
718
+ status: "connected",
719
+ lastConnectedTime: new Date()
720
+ // reconnectionAttempts NOT reset here - see ws.on("close") for stable connection check
721
+ });
722
+ // Reset context health for reconnection
723
+ initContextHealth(appKey);
724
+ console.error(`[rn-ai-debugger] Reconnected to ${device.title}`);
725
+ }
726
+ else {
727
+ initConnectionState(appKey);
728
+ initContextHealth(appKey);
729
+ console.error(`[rn-ai-debugger] Connected to ${device.title}`);
730
+ }
731
+ // Enable Runtime domain to receive console messages
732
+ ws.send(JSON.stringify({
733
+ id: getNextMessageId(),
734
+ method: "Runtime.enable"
735
+ }));
736
+ // Also enable Log domain
737
+ ws.send(JSON.stringify({
738
+ id: getNextMessageId(),
739
+ method: "Log.enable"
740
+ }));
741
+ // Inject JS network interceptor (immediate capture, may fail if context not ready)
742
+ injectNetworkInterceptor(ws);
743
+ // Also try CDP Network.enable (takes priority if supported)
744
+ const networkEnableId = sendNetworkEnable(ws);
745
+ pendingNetworkEnableIds.add(networkEnableId);
746
+ // Try to resolve iOS simulator UDID from device name
747
+ // This enables automatic device scoping for iOS tools
748
+ if (device.deviceName) {
749
+ const simulatorUdid = await findSimulatorByName(device.deviceName);
750
+ if (simulatorUdid) {
751
+ setActiveSimulatorUdid(simulatorUdid, appKey);
752
+ // Update platform to ios now that simulator is confirmed
753
+ const connectedApp = connectedApps.get(appKey);
754
+ if (connectedApp) {
755
+ connectedApp.platform = "ios";
756
+ connectedApp.simulatorUdid = simulatorUdid;
552
757
  }
758
+ console.error(`[rn-ai-debugger] Linked to iOS simulator: ${simulatorUdid}`);
553
759
  }
554
- resolve(`Connected to ${device.title} (${device.deviceName})`);
760
+ }
761
+ // Fire-and-forget app detection (500ms delayed, non-blocking)
762
+ const appForDetection = connectedApps.get(appKey);
763
+ if (appForDetection) {
764
+ scheduleAppDetection(appForDetection);
765
+ }
766
+ // Start WebSocket ping/pong keepalive to detect dead connections
767
+ // (especially important for physical devices over Wi-Fi)
768
+ let pongReceived = true;
769
+ const pingInterval = setInterval(() => {
770
+ if (!pongReceived) {
771
+ console.error(`[rn-ai-debugger] No pong from ${device.title}, terminating connection`);
772
+ clearInterval(pingInterval);
773
+ ws.terminate();
774
+ return;
775
+ }
776
+ pongReceived = false;
777
+ ws.ping();
778
+ }, PING_INTERVAL_MS);
779
+ ws.on("pong", () => {
780
+ pongReceived = true;
555
781
  });
556
782
  ws.on("message", (data) => {
557
783
  try {
@@ -563,11 +789,15 @@ export async function connectToDevice(device, port, options = {}) {
563
789
  }
564
790
  });
565
791
  ws.on("close", () => {
792
+ clearInterval(pingInterval);
566
793
  // Release connection lock if still held
567
794
  connectionLocks.delete(appKey);
568
795
  connectedApps.delete(appKey);
796
+ clearLastCDPMessageTime(appKey);
569
797
  // Clear active simulator UDID if this connection set it
570
798
  clearActiveSimulatorIfSource(appKey);
799
+ // Keep log and network buffers across reconnections
800
+ // They are only cleared on demand via clear_logs / clear_network tools
571
801
  // Check if connection was stable before resetting attempts
572
802
  const state = getConnectionState(appKey);
573
803
  let wasStable = false;
@@ -597,41 +827,23 @@ export async function connectToDevice(device, port, options = {}) {
597
827
  }
598
828
  });
599
829
  ws.on("error", (error) => {
600
- // Release connection lock
601
- connectionLocks.delete(appKey);
602
- // Cancel any pending reconnection timer to prevent orphaned loops
603
- cancelReconnectionTimer(appKey);
604
- connectedApps.delete(appKey);
605
- // Clear active simulator UDID if this connection set it
606
- clearActiveSimulatorIfSource(appKey);
607
- // Extract error message safely - some WebSocket errors may not have a message
608
- const errorMsg = error?.message || error?.toString() || 'Unknown WebSocket error';
609
- // Only reject if this is initial connection, not reconnection attempt
610
- if (!isReconnection) {
611
- reject(`Failed to connect to ${device.title}: ${errorMsg}`);
612
- }
613
- else {
614
- console.error(`[rn-ai-debugger] Reconnection error: ${errorMsg}`);
615
- }
830
+ console.error(`[rn-ai-debugger] WebSocket error for ${device.title}: ${error?.message || error}`);
616
831
  });
617
- // Timeout after 5 seconds
618
- setTimeout(() => {
619
- if (ws.readyState !== WebSocket.OPEN) {
620
- // Release connection lock on timeout
621
- connectionLocks.delete(appKey);
622
- ws.terminate();
623
- if (!isReconnection) {
624
- reject(`Connection to ${device.title} timed out`);
625
- }
626
- }
627
- }, 5000);
832
+ resolve(`Connected to ${device.title} (${device.deviceName})`);
628
833
  }
629
834
  catch (error) {
630
- // Release connection lock on exception
835
+ // Connection failed (both with and without Origin header)
631
836
  connectionLocks.delete(appKey);
837
+ cancelReconnectionTimer(appKey);
838
+ connectedApps.delete(appKey);
839
+ clearLastCDPMessageTime(appKey);
840
+ clearActiveSimulatorIfSource(appKey);
841
+ const errorMsg = error instanceof Error ? error.message : String(error);
632
842
  if (!isReconnection) {
633
- const errorMessage = error instanceof Error ? error.message : (error ? String(error) : "Unknown error");
634
- reject(`Failed to create WebSocket connection: ${errorMessage}`);
843
+ reject(`Failed to connect to ${device.title}: ${errorMsg}`);
844
+ }
845
+ else {
846
+ console.error(`[rn-ai-debugger] Reconnection error: ${errorMsg}`);
635
847
  }
636
848
  }
637
849
  });
@@ -669,6 +881,14 @@ async function attemptReconnection(appKey, config = DEFAULT_RECONNECTION_CONFIG)
669
881
  console.error(`[rn-ai-debugger] No metadata for reconnection: ${appKey}`);
670
882
  return false;
671
883
  }
884
+ // Quick check: is Metro even running on this port?
885
+ const { scanMetroPorts } = await import("./metro.js");
886
+ const openPorts = await scanMetroPorts(metadata.port, metadata.port);
887
+ if (openPorts.length === 0) {
888
+ console.error(`[rn-ai-debugger] Metro not running on port ${metadata.port}, stopping reconnection for ${appKey}`);
889
+ updateConnectionState(appKey, { status: "disconnected" });
890
+ return false;
891
+ }
672
892
  try {
673
893
  // Re-fetch devices to get fresh WebSocket URL (may have changed)
674
894
  const devices = await fetchDevices(metadata.port);
@@ -712,6 +932,31 @@ export function getFirstConnectedApp() {
712
932
  }
713
933
  return null;
714
934
  }
935
+ export function getConnectedAppByDevice(device) {
936
+ if (!device) {
937
+ return getFirstConnectedApp();
938
+ }
939
+ const lowerDevice = device.toLowerCase();
940
+ const matches = [];
941
+ for (const [key, app] of connectedApps.entries()) {
942
+ if (app.ws.readyState !== WebSocket.OPEN) {
943
+ connectedApps.delete(key);
944
+ continue;
945
+ }
946
+ const deviceName = app.deviceInfo.deviceName || app.deviceInfo.title || "";
947
+ if (deviceName.toLowerCase().includes(lowerDevice)) {
948
+ matches.push(app);
949
+ }
950
+ }
951
+ if (matches.length === 1) {
952
+ return matches[0];
953
+ }
954
+ if (matches.length > 1) {
955
+ const names = matches.map(a => a.deviceInfo.deviceName || a.deviceInfo.title).join(", ");
956
+ throw new Error(`Multiple devices match "${device}": ${names}. Be more specific.`);
957
+ }
958
+ throw new Error(`No connected device matches "${device}". Run get_apps to see available devices.`);
959
+ }
715
960
  // Check if any app is connected with an OPEN WebSocket
716
961
  export function hasConnectedApp() {
717
962
  for (const [, app] of connectedApps.entries()) {
@@ -764,6 +1009,115 @@ export async function runQuickHealthCheck(app) {
764
1009
  }
765
1010
  });
766
1011
  }
1012
+ const LOG_PIPELINE_MARKER_PREFIX = "__rn_devtools_health_";
1013
+ /**
1014
+ * Inject a sentinel console.log via CDP and wait for it to appear in the log buffer.
1015
+ * Returns true if the marker arrives within TIMEOUT_MS.
1016
+ */
1017
+ async function sendAndWaitForMarker(app, buffer) {
1018
+ const marker = `${LOG_PIPELINE_MARKER_PREFIX}${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
1019
+ const TIMEOUT_MS = 3000;
1020
+ try {
1021
+ app.ws.send(JSON.stringify({
1022
+ id: getNextMessageId(),
1023
+ method: "Runtime.evaluate",
1024
+ params: { expression: `console.log("${marker}")`, returnByValue: true },
1025
+ }));
1026
+ }
1027
+ catch {
1028
+ return false;
1029
+ }
1030
+ // Poll the buffer for the marker (check every 50ms, up to TIMEOUT_MS)
1031
+ const startTime = Date.now();
1032
+ while (Date.now() - startTime < TIMEOUT_MS) {
1033
+ if (buffer.getAll().some(entry => entry.message.includes(marker))) {
1034
+ buffer.removeByText(marker);
1035
+ return true;
1036
+ }
1037
+ await new Promise(resolve => setTimeout(resolve, 50));
1038
+ }
1039
+ return false;
1040
+ }
1041
+ /**
1042
+ * End-to-end log pipeline verification with automatic recovery.
1043
+ *
1044
+ * 1. Injects a sentinel console.log via CDP and verifies it appears in the buffer.
1045
+ * 2. If the marker doesn't arrive, attempts recovery:
1046
+ * a. Re-sends Runtime.enable + Log.enable (fixes missing domain subscription)
1047
+ * b. Retries the marker once
1048
+ * 3. If still broken, force-reconnects via ensureConnection and retries.
1049
+ *
1050
+ * Returns { ok, recovered, message } — the caller decides what to show.
1051
+ */
1052
+ export async function verifyLogPipeline(app) {
1053
+ const deviceName = app.deviceInfo.deviceName || app.deviceInfo.title || "unknown";
1054
+ const buffer = getLogBuffer(deviceName);
1055
+ // --- Attempt 1: quick check ---
1056
+ if (await sendAndWaitForMarker(app, buffer)) {
1057
+ return { ok: true, recovered: false, message: null };
1058
+ }
1059
+ // --- Attempt 2: re-enable Runtime/Log domains and retry ---
1060
+ try {
1061
+ app.ws.send(JSON.stringify({ id: getNextMessageId(), method: "Runtime.enable" }));
1062
+ app.ws.send(JSON.stringify({ id: getNextMessageId(), method: "Log.enable" }));
1063
+ }
1064
+ catch {
1065
+ // WS send failed — fall through to reconnect
1066
+ }
1067
+ // Brief pause for domain activation
1068
+ await new Promise(resolve => setTimeout(resolve, 300));
1069
+ if (await sendAndWaitForMarker(app, buffer)) {
1070
+ return { ok: true, recovered: true, message: "[CONNECTION] Log pipeline was stale — re-enabled CDP domains. Logs are flowing again." };
1071
+ }
1072
+ // --- Attempt 3: force reconnect and retry ---
1073
+ const appKey = `${app.port}-${app.deviceInfo.id}`;
1074
+ const port = app.port;
1075
+ cancelReconnectionTimer(appKey);
1076
+ try {
1077
+ app.ws.close();
1078
+ }
1079
+ catch { /* ignore */ }
1080
+ connectedApps.delete(appKey);
1081
+ const result = await ensureConnection({ port, forceRefresh: false, healthCheck: true });
1082
+ if (!result.connected || !result.healthCheckPassed) {
1083
+ return {
1084
+ ok: false,
1085
+ recovered: false,
1086
+ message: "[CONNECTION] Log pipeline broken and reconnection failed. Ensure the app is running, then call scan_metro.",
1087
+ };
1088
+ }
1089
+ // Wait for the new connection to settle
1090
+ await new Promise(resolve => setTimeout(resolve, 500));
1091
+ // Get the fresh app reference after reconnection
1092
+ const freshApp = getFirstConnectedApp();
1093
+ if (!freshApp) {
1094
+ return {
1095
+ ok: false,
1096
+ recovered: false,
1097
+ message: "[CONNECTION] Reconnected but no app available. Call scan_metro.",
1098
+ };
1099
+ }
1100
+ const freshDeviceName = freshApp.deviceInfo.deviceName || freshApp.deviceInfo.title || "unknown";
1101
+ const freshBuffer = getLogBuffer(freshDeviceName);
1102
+ if (await sendAndWaitForMarker(freshApp, freshBuffer)) {
1103
+ return {
1104
+ ok: true,
1105
+ recovered: true,
1106
+ message: "[CONNECTION] Log pipeline was broken — reconnected and verified. Logs are flowing again.",
1107
+ };
1108
+ }
1109
+ return {
1110
+ ok: false,
1111
+ recovered: false,
1112
+ message: "[WARNING] Log pipeline verification failed after reconnection. Console events are not reaching the buffer. Try reload_app or scan_metro.",
1113
+ };
1114
+ }
1115
+ /**
1116
+ * Check if a log message is a health check marker (for filtering from output).
1117
+ */
1118
+ export function isHealthCheckMarker(message) {
1119
+ return message.includes(LOG_PIPELINE_MARKER_PREFIX);
1120
+ }
767
1121
  /**
768
1122
  * Find the first available Metro port
769
1123
  */
@@ -897,8 +1251,9 @@ export async function ensureConnection(options = {}) {
897
1251
  const uptimeMs = Date.now() - connectionState.lastConnectedTime.getTime();
898
1252
  uptime = formatDuration(uptimeMs);
899
1253
  }
1254
+ const isConnected = app !== null && app.ws.readyState === WebSocket.OPEN;
900
1255
  return {
901
- connected: app !== null && app.ws.readyState === WebSocket.OPEN,
1256
+ connected: isConnected,
902
1257
  wasReconnected,
903
1258
  healthCheckPassed,
904
1259
  connectionInfo: app ? {
@@ -907,9 +1262,35 @@ export async function ensureConnection(options = {}) {
907
1262
  uptime,
908
1263
  contextId: contextHealth?.contextId ?? null,
909
1264
  } : null,
1265
+ ...(!isConnected && {
1266
+ error: app
1267
+ ? `Connection lost: WebSocket is in ${["CONNECTING", "OPEN", "CLOSING", "CLOSED"][app.ws.readyState] || "unknown"} state (expected OPEN). Health check ${healthCheckPassed ? "passed" : "failed"}. Try ensure_connection with forceRefresh=true.`
1268
+ : "App became unavailable after reconnection attempt. Try running scan_metro then ensure_connection."
1269
+ }),
910
1270
  };
911
1271
  }
912
- export function getPassiveConnectionStatus() {
1272
+ export function getPassiveConnectionStatus(targetAppKey) {
1273
+ if (targetAppKey) {
1274
+ // Check a specific device's connection
1275
+ const app = connectedApps.get(targetAppKey);
1276
+ if (!app || app.ws.readyState !== WebSocket.OPEN) {
1277
+ return { connected: false, needsPing: false, reason: "no_connection" };
1278
+ }
1279
+ const health = getContextHealth(targetAppKey);
1280
+ if (health?.isStale) {
1281
+ return { connected: false, needsPing: false, reason: "context_stale" };
1282
+ }
1283
+ const lastMessage = getLastCDPMessageTime(targetAppKey);
1284
+ if (!lastMessage) {
1285
+ return { connected: false, needsPing: false, reason: "no_activity" };
1286
+ }
1287
+ const elapsed = Date.now() - lastMessage.getTime();
1288
+ if (elapsed > STALE_ACTIVITY_THRESHOLD_MS) {
1289
+ return { connected: true, needsPing: true, reason: "activity_stale" };
1290
+ }
1291
+ return { connected: true, needsPing: false, reason: "ok" };
1292
+ }
1293
+ // Default: check first connected app (backwards compatible)
913
1294
  if (!hasConnectedApp()) {
914
1295
  return { connected: false, needsPing: false, reason: "no_connection" };
915
1296
  }
@@ -931,13 +1312,27 @@ export function getPassiveConnectionStatus() {
931
1312
  }
932
1313
  return { connected: true, needsPing: false, reason: "ok" };
933
1314
  }
934
- export async function checkAndEnsureConnection() {
935
- const passive = getPassiveConnectionStatus();
1315
+ export async function checkAndEnsureConnection(device) {
1316
+ // Resolve targeted device to appKey if specified
1317
+ let targetAppKey;
1318
+ let targetApp = null;
1319
+ if (device) {
1320
+ try {
1321
+ targetApp = getConnectedAppByDevice(device);
1322
+ if (targetApp) {
1323
+ targetAppKey = `${targetApp.port}-${targetApp.deviceInfo.id}`;
1324
+ }
1325
+ }
1326
+ catch {
1327
+ // Device not found — fall through to reconnection
1328
+ }
1329
+ }
1330
+ const passive = getPassiveConnectionStatus(targetAppKey);
936
1331
  if (passive.connected && !passive.needsPing) {
937
1332
  return { connected: true, wasReconnected: false, message: null };
938
1333
  }
939
1334
  if (passive.connected && passive.needsPing) {
940
- const app = getFirstConnectedApp();
1335
+ const app = targetApp ?? getFirstConnectedApp();
941
1336
  if (app) {
942
1337
  const healthy = await runQuickHealthCheck(app);
943
1338
  if (healthy) {