@xenon-device-management/xenon 2.9.0 → 2.9.2

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 (91) hide show
  1. package/lib/package.json +1 -1
  2. package/lib/public/assets/{AnnotationOverlay-DhRFPZ9-.js → AnnotationOverlay-B87n4pUu.js} +1 -1
  3. package/lib/public/assets/{ApiKeyGate-BmZy1Srn.js → ApiKeyGate-CNZJH6Cs.js} +1 -1
  4. package/lib/public/assets/{BugReportButton-ByLSqwSk.js → BugReportButton-DQ-R87ZV.js} +1 -1
  5. package/lib/public/assets/{DeviceMosaicView-DVfXSoXH.js → DeviceMosaicView-dX3e7O-w.js} +1 -1
  6. package/lib/public/assets/{EmptyState-Bib6wal8.js → EmptyState-D3fG2GHY.js} +1 -1
  7. package/lib/public/assets/{FieldGroup-D5sTaKGR.js → FieldGroup-CUmbJZxI.js} +1 -1
  8. package/lib/public/assets/{FilterMenu-D3XXKTwF.js → FilterMenu-Dm2c-UCk.js} +1 -1
  9. package/lib/public/assets/{Menu-BiZhr0F7.js → Menu-Dl4uXMkH.js} +1 -1
  10. package/lib/public/assets/{Modal-BxGNa3Zp.js → Modal-DNmn2RDs.js} +1 -1
  11. package/lib/public/assets/{RecordingPage-Czml77Wb.js → RecordingPage-Dn3By34o.js} +1 -1
  12. package/lib/public/assets/{RecordingsPage-Di7nyHqE.js → RecordingsPage-lCUIM4pm.js} +1 -1
  13. package/lib/public/assets/{SegmentedControl-XEKvCRwO.js → SegmentedControl-BdA_mHiZ.js} +1 -1
  14. package/lib/public/assets/{SettingCard-YIpOQAjO.js → SettingCard-Dx3WhK7w.js} +1 -1
  15. package/lib/public/assets/{Table-CqwX6v-1.js → Table-D_2lhvRM.js} +1 -1
  16. package/lib/public/assets/{activity-Cur7Syd6.js → activity-cNHHOKx5.js} +1 -1
  17. package/lib/public/assets/{ai-settings-B1OGdSKu.js → ai-settings-t6RMrDkg.js} +1 -1
  18. package/lib/public/assets/{api-keys-abTul2Oa.js → api-keys-CK_qyQvW.js} +1 -1
  19. package/lib/public/assets/{apps-yQL1MLfG.js → apps-DCiI3UOb.js} +1 -1
  20. package/lib/public/assets/{arrow-left-D0ipNN6o.js → arrow-left-CnYs8HVw.js} +1 -1
  21. package/lib/public/assets/{arrow-right-DwVz_Fly.js → arrow-right-Cc9qm6UG.js} +1 -1
  22. package/lib/public/assets/{arrow-up-right-xKuKJfZf.js → arrow-up-right-e7yBOb-c.js} +1 -1
  23. package/lib/public/assets/{auth-shell-00zHLFCk.js → auth-shell-DKrtcIjG.js} +2 -2
  24. package/lib/public/assets/{builds-page-D4RJAbOZ.js → builds-page-CRd5oEz6.js} +1 -1
  25. package/lib/public/assets/{button-Cic7S8s2.js → button-C0caCjJB.js} +1 -1
  26. package/lib/public/assets/{calendar-DyNZ2bq9.js → calendar-BqOOkln_.js} +1 -1
  27. package/lib/public/assets/{check-CXGZdojb.js → check-DYSXRG79.js} +1 -1
  28. package/lib/public/assets/{chevron-left-BcNiIt1C.js → chevron-left-BSG6mcpo.js} +1 -1
  29. package/lib/public/assets/{chevron-right-BxDjkb1I.js → chevron-right-hCNu0CA-.js} +1 -1
  30. package/lib/public/assets/{circle-check-9isFAWQ6.js → circle-check-DnQ263fa.js} +1 -1
  31. package/lib/public/assets/{circle-x-CfnPCFLK.js → circle-x-BaRKZyz9.js} +1 -1
  32. package/lib/public/assets/{clock-Dc4TvnFF.js → clock-XP3iBAhg.js} +1 -1
  33. package/lib/public/assets/{copy-TFQpalJ2.js → copy-D8d9UzWE.js} +1 -1
  34. package/lib/public/assets/{device-explorer-CB4bSJtl.js → device-explorer-pdM3L9TC.js} +1 -1
  35. package/lib/public/assets/{download-1_bq_Ikd.js → download-SaN9-umX.js} +1 -1
  36. package/lib/public/assets/{forgot-password-B92TZ1GF.js → forgot-password-DfUhEZW5.js} +1 -1
  37. package/lib/public/assets/{funnel-DC7DsjHe.js → funnel-OefHE-54.js} +1 -1
  38. package/lib/public/assets/{index-CC2N_uMU.js → index-CzaIyL9t.js} +2 -2
  39. package/lib/public/assets/{index-BSsnx09Q.js → index-IefGCFQQ.js} +1 -1
  40. package/lib/public/assets/{input-D2DBpxK2.js → input-BqFzAQT3.js} +1 -1
  41. package/lib/public/assets/{keycodes-BQuIOMw0.js → keycodes-DDV2OlpQ.js} +1 -1
  42. package/lib/public/assets/{layers-DxtoRlvi.js → layers-a3GNz2DA.js} +1 -1
  43. package/lib/public/assets/{list-checks-jQ7YUgdE.js → list-checks-DMrMAFDt.js} +1 -1
  44. package/lib/public/assets/{lock-WHSsj2dj.js → lock-Eye1CG8p.js} +1 -1
  45. package/lib/public/assets/{login-BB_xyUWw.js → login-Co1IXuen.js} +1 -1
  46. package/lib/public/assets/{maintenance-settings-Dwadp8IQ.js → maintenance-settings-D1fw6iqm.js} +1 -1
  47. package/lib/public/assets/{monitor-DIWQjUlh.js → monitor-U5Y-PxW5.js} +1 -1
  48. package/lib/public/assets/{mouse-pointer-2-D11Re9Gj.js → mouse-pointer-2-CDAwphUm.js} +1 -1
  49. package/lib/public/assets/{network-BYBgSBjv.js → network-BP_U4iDZ.js} +1 -1
  50. package/lib/public/assets/{overview-BISAwoyi.js → overview-CsPyPv10.js} +1 -1
  51. package/lib/public/assets/{page-header-ChX4YiMP.js → page-header-ZdzNEJp9.js} +1 -1
  52. package/lib/public/assets/{plus-BWNj1XSQ.js → plus-1k0sxXyU.js} +1 -1
  53. package/lib/public/assets/{profile-page-VZD3gRjV.js → profile-page-B_GWKRy3.js} +1 -1
  54. package/lib/public/assets/{reset-password-Dz9LzFeX.js → reset-password-DizCTkdw.js} +1 -1
  55. package/lib/public/assets/{runbook-page-Bl2FodZf.js → runbook-page-DWzRiOLZ.js} +1 -1
  56. package/lib/public/assets/{select-Bs9CQu5Z.js → select-CiRW6v17.js} +1 -1
  57. package/lib/public/assets/{selector-detail-page-By7ihhbY.js → selector-detail-page-pA913STP.js} +1 -1
  58. package/lib/public/assets/{selector-health-xiBb8vNz.js → selector-health-CHLX8KqY.js} +1 -1
  59. package/lib/public/assets/{selector-health-page-Y4CTbOLH.js → selector-health-page-vVkg8Mii.js} +1 -1
  60. package/lib/public/assets/{session-detail-page-DqeKRP6c.js → session-detail-page-BCD49zDi.js} +1 -1
  61. package/lib/public/assets/{settings-DDhV_Dnz.js → settings-DhwZoWv7.js} +1 -1
  62. package/lib/public/assets/{stat-tile-DMBTDbwt.js → stat-tile-BaE76cnK.js} +1 -1
  63. package/lib/public/assets/{tablet-Cr5bOgng.js → tablet-CPJV3dBq.js} +1 -1
  64. package/lib/public/assets/{teams-Bm0UQkR6.js → teams-D0IBa2Ov.js} +1 -1
  65. package/lib/public/assets/{trash-2-Di_lMeiP.js → trash-2-u8MlXUBD.js} +1 -1
  66. package/lib/public/assets/{upload-RvXDBmCX.js → upload-Cq8L3kpw.js} +1 -1
  67. package/lib/public/assets/{use-password-reset-mode-C7pUQ7xe.js → use-password-reset-mode-Dbr2HEfw.js} +1 -1
  68. package/lib/public/assets/{users-DZ7B5ePh.js → users-CZqCE511.js} +1 -1
  69. package/lib/public/assets/{users-CIILdbvy.js → users-DxLHYmFm.js} +1 -1
  70. package/lib/public/assets/{video-off-aYil8B-X.js → video-off-mh1EEzP9.js} +1 -1
  71. package/lib/public/assets/{webhook-settings-17JYW1ZR.js → webhook-settings-BYuOib-Q.js} +1 -1
  72. package/lib/public/assets/{zap-D9hchm-a.js → zap-BOpUur0M.js} +1 -1
  73. package/lib/public/index.html +1 -1
  74. package/lib/src/device-managers/ios/IOSDiscoveryService.js +6 -67
  75. package/lib/src/device-managers/ios/IOSStreamService.js +15 -80
  76. package/lib/src/device-managers/ios/IOSTunnels.js +25 -16
  77. package/lib/src/device-managers/ios/WDAClient.js +65 -86
  78. package/lib/src/device-managers/ios/goIosBinary.js +16 -0
  79. package/lib/src/plugin.js +2 -10
  80. package/lib/src/services/PortAllocator.js +8 -1
  81. package/lib/src/services/ServerManager.js +22 -0
  82. package/lib/test/unit/PortAllocator.test.js +1 -1
  83. package/lib/test/unit/boot-go-ios-reap.spec.js +80 -0
  84. package/lib/test/unit/ios-discovery-same-host.spec.js +0 -1
  85. package/lib/test/unit/ios-no-network-fallback.spec.js +219 -0
  86. package/lib/test/unit/ios-stream-appium-tunnel.spec.js +0 -1
  87. package/lib/test/unit/ios-stream-port-leases.spec.js +0 -1
  88. package/lib/test/unit/ios-tunnels.spec.js +25 -3
  89. package/lib/test/unit/port-allocator-pairs.spec.js +34 -0
  90. package/lib/tsconfig.tsbuildinfo +1 -1
  91. package/package.json +1 -1
@@ -1,43 +1,10 @@
1
1
  "use strict";
2
- var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
- if (k2 === undefined) k2 = k;
4
- var desc = Object.getOwnPropertyDescriptor(m, k);
5
- if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
- desc = { enumerable: true, get: function() { return m[k]; } };
7
- }
8
- Object.defineProperty(o, k2, desc);
9
- }) : (function(o, m, k, k2) {
10
- if (k2 === undefined) k2 = k;
11
- o[k2] = m[k];
12
- }));
13
- var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
- Object.defineProperty(o, "default", { enumerable: true, value: v });
15
- }) : function(o, v) {
16
- o["default"] = v;
17
- });
18
2
  var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
19
3
  var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
20
4
  if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
21
5
  else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
22
6
  return c > 3 && r && Object.defineProperty(target, key, r), r;
23
7
  };
24
- var __importStar = (this && this.__importStar) || (function () {
25
- var ownKeys = function(o) {
26
- ownKeys = Object.getOwnPropertyNames || function (o) {
27
- var ar = [];
28
- for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
29
- return ar;
30
- };
31
- return ownKeys(o);
32
- };
33
- return function (mod) {
34
- if (mod && mod.__esModule) return mod;
35
- var result = {};
36
- if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
37
- __setModuleDefault(result, mod);
38
- return result;
39
- };
40
- })();
41
8
  var __metadata = (this && this.__metadata) || function (k, v) {
42
9
  if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
43
10
  };
@@ -61,10 +28,7 @@ const lodash_1 = require("lodash");
61
28
  const appium_ios_device_1 = require("appium-ios-device");
62
29
  const appleIdentity_1 = require("./appleIdentity");
63
30
  const deviceIdentity_1 = require("../deviceIdentity");
64
- const helpers_1 = require("../../helpers");
65
31
  const logger_1 = __importDefault(require("../../logger"));
66
- const path_1 = __importDefault(require("path"));
67
- const fs_extra_1 = __importDefault(require("fs-extra"));
68
32
  const device_utils_1 = require("../../device-utils");
69
33
  const device_store_1 = require("../../data-service/device-store");
70
34
  const PluginContext_1 = require("../../PluginContext");
@@ -161,8 +125,7 @@ let IOSDiscoveryService = class IOSDiscoveryService {
161
125
  try {
162
126
  const existingDevice = existingDeviceDetails.find((device) => device.udid === udid && device.host === ownHost);
163
127
  if (existingDevice) {
164
- const networkIp = yield this.fetchRealDeviceNetworkIp(udid);
165
- return Object.assign(Object.assign({}, existingDevice), { ip: networkIp || (0, networkAddresses_1.sanitizeDeviceNetworkIp)(existingDevice.ip) || '' });
128
+ return Object.assign(Object.assign({}, existingDevice), { ip: (0, networkAddresses_1.sanitizeDeviceNetworkIp)(existingDevice.ip) || '' });
166
129
  }
167
130
  return yield this.getDeviceInfo(udid);
168
131
  }
@@ -193,7 +156,6 @@ let IOSDiscoveryService = class IOSDiscoveryService {
193
156
  const totalUtilizationTimeMilliSec = yield (0, device_utils_1.getUtilizationTime)(udid);
194
157
  let sdk = 'Unknown';
195
158
  let name = 'iPhone';
196
- const ipAddress = yield this.fetchRealDeviceNetworkIp(udid);
197
159
  try {
198
160
  [sdk, name] = yield Promise.all([appium_ios_device_1.utilities.getOSVersion(udid), appium_ios_device_1.utilities.getDeviceName(udid)]);
199
161
  }
@@ -212,7 +174,11 @@ let IOSDiscoveryService = class IOSDiscoveryService {
212
174
  mjpegServerPort,
213
175
  udid,
214
176
  sdk,
215
- name, ip: ipAddress, busy: false, realDevice: true, deviceType: 'real', cpuArchitecture: 'arm64', platform: (name.toLowerCase().includes('tv') ? 'tvos' : 'ios'), host: host, totalUtilizationTimeMilliSec, sessionStartTime: 0, state: (storeDevice === null || storeDevice === void 0 ? void 0 : storeDevice.state) || 'Unknown', userBlocked: (storeDevice === null || storeDevice === void 0 ? void 0 : storeDevice.userBlocked) || false }, (storeDevice || {})), (0, deviceIdentity_1.nonNullIdentity)(identity));
177
+ name,
178
+ // go-ios can't tell a phone's network address (its lockdown values
179
+ // carry none), and Xenon reaches a phone only through its forwarded
180
+ // ports, so nothing asks for it.
181
+ ip: '', busy: false, realDevice: true, deviceType: 'real', cpuArchitecture: 'arm64', platform: (name.toLowerCase().includes('tv') ? 'tvos' : 'ios'), host: host, totalUtilizationTimeMilliSec, sessionStartTime: 0, state: (storeDevice === null || storeDevice === void 0 ? void 0 : storeDevice.state) || 'Unknown', userBlocked: (storeDevice === null || storeDevice === void 0 ? void 0 : storeDevice.userBlocked) || false }, (storeDevice || {})), (0, deviceIdentity_1.nonNullIdentity)(identity));
216
182
  });
217
183
  }
218
184
  getSimulators() {
@@ -281,33 +247,6 @@ let IOSDiscoveryService = class IOSDiscoveryService {
281
247
  resolveNodeLanIp() {
282
248
  return (0, networkAddresses_1.resolveAdvertisedBindHost)(this.pluginArgs.bindHostOrIp);
283
249
  }
284
- fetchRealDeviceNetworkIp(udid) {
285
- return __awaiter(this, void 0, void 0, function* () {
286
- return (0, networkAddresses_1.sanitizeDeviceNetworkIp)(yield this.fetchIpViaGoIos(udid));
287
- });
288
- }
289
- fetchIpViaGoIos(udid) {
290
- return __awaiter(this, void 0, void 0, function* () {
291
- const goIOSDir = (0, helpers_1.cachePath)('goIOS');
292
- const goIOSPath = path_1.default.join(goIOSDir, 'ios');
293
- if (!fs_extra_1.default.existsSync(goIOSPath))
294
- return '';
295
- const { exec } = yield Promise.resolve().then(() => __importStar(require('child_process')));
296
- const { promisify } = yield Promise.resolve().then(() => __importStar(require('util')));
297
- const execPromise = promisify(exec);
298
- try {
299
- const { stdout } = yield execPromise(`"${goIOSPath}" info --udid ${udid}`, {
300
- env: Object.assign(Object.assign({}, process.env), { ENABLE_GO_IOS_AGENT: 'yes' }),
301
- });
302
- const info = JSON.parse(stdout);
303
- return info.IPAddress || '';
304
- }
305
- catch (e) {
306
- logger_1.default.debug(`Failed to fetch IP via go-ios for ${udid}: ${e}`);
307
- return '';
308
- }
309
- });
310
- }
311
250
  trackIOSDevices() {
312
251
  if (this.trackingInitialized)
313
252
  return;
@@ -74,7 +74,6 @@ const fs_extra_1 = __importDefault(require("fs-extra"));
74
74
  const tcp_port_used_1 = __importDefault(require("tcp-port-used"));
75
75
  const InternalHttpClient_1 = require("../../InternalHttpClient");
76
76
  const logger_1 = __importDefault(require("../../logger"));
77
- const helpers_1 = require("../../helpers");
78
77
  const singleFlight_1 = require("../../helpers/singleFlight");
79
78
  const PortAllocator_1 = require("../../services/PortAllocator");
80
79
  const device_store_1 = require("../../data-service/device-store");
@@ -82,6 +81,7 @@ const ownDeviceRow_1 = require("../ownDeviceRow");
82
81
  const iosStreamDiagnostics_1 = require("./iosStreamDiagnostics");
83
82
  const tunnelProcess_1 = require("./tunnelProcess");
84
83
  const IOSTunnels_1 = require("./IOSTunnels");
84
+ const goIosBinary_1 = require("./goIosBinary");
85
85
  const device_service_1 = require("../../data-service/device-service");
86
86
  const manualLock_1 = require("../../services/recording/manualLock");
87
87
  const recording_store_1 = require("../../services/recording/recording-store");
@@ -119,11 +119,7 @@ let IOSStreamService = class IOSStreamService {
119
119
  this.startFlight = new singleFlight_1.SingleFlight();
120
120
  this.recoveryCooldowns = new Map(); // Track last recovery attempt time
121
121
  this.RECOVERY_COOLDOWN_MS = 30000; // 30s cooldown between recovery attempts
122
- // Port-lease TTL for an active stream. Longer than the watchdog interval (1h),
123
- // which refreshes it each tick, so a long-lived stream's port never expires
124
- // and gets reallocated to another device; stopStream() releases it explicitly.
125
- this.STREAM_PORT_TTL_MS = 90 * 60 * 1000; // 1.5h
126
- this.goIOSPath = this.getGoIOSPath();
122
+ this.goIOSPath = (0, goIosBinary_1.goIosBinaryPath)();
127
123
  this.startWatchdog();
128
124
  }
129
125
  /**
@@ -143,8 +139,8 @@ let IOSStreamService = class IOSStreamService {
143
139
  // its ports to another device mid-stream (TTL > this 1h interval).
144
140
  try {
145
141
  const portAllocator = typedi_2.Container.get(PortAllocator_1.PortAllocator);
146
- yield portAllocator.touch(session.wdaPort, this.STREAM_PORT_TTL_MS);
147
- yield portAllocator.touch(session.mjpegPort, this.STREAM_PORT_TTL_MS);
142
+ yield portAllocator.touch(session.wdaPort, IOSTunnels_1.IOS_STREAM_LEASE_TTL_MS);
143
+ yield portAllocator.touch(session.mjpegPort, IOSTunnels_1.IOS_STREAM_LEASE_TTL_MS);
148
144
  yield this.tunnels().touch(udid);
149
145
  }
150
146
  catch (_a) {
@@ -223,10 +219,6 @@ let IOSStreamService = class IOSStreamService {
223
219
  }
224
220
  });
225
221
  }
226
- getGoIOSPath() {
227
- const goIOSDir = (0, helpers_1.cachePath)('goIOS');
228
- return path_1.default.join(goIOSDir, 'ios');
229
- }
230
222
  /**
231
223
  * Check if go-ios binary exists
232
224
  */
@@ -409,9 +401,10 @@ let IOSStreamService = class IOSStreamService {
409
401
  });
410
402
  }
411
403
  /**
412
- * Comprehensive process-level and endpoint-level health check.
413
- * Principal Intelligence: Differentiates between 'Process dead' and 'Network unreachable'.
414
- * If only the tunnel is dead but WDA is alive via IP, it will trigger a tunnel restart.
404
+ * Comprehensive process-level and endpoint-level health check: WDA and its
405
+ * forwarders are running, and WDA answers on the stream's own port. Only
406
+ * that port: WDA names no phone, so nothing that answers elsewhere (a
407
+ * simulator's address is this Mac's own) is known to be this phone's.
415
408
  */
416
409
  isStreamResponsive(udid) {
417
410
  return __awaiter(this, void 0, void 0, function* () {
@@ -427,78 +420,18 @@ let IOSStreamService = class IOSStreamService {
427
420
  return false;
428
421
  }
429
422
  if (!isWdaIproxyAlive || !isMjpegIproxyAlive) {
430
- logger_1.default.warn(`🛡️ [${udid}] [Watchdog] Tunnel processes are dead. Attempting tunnel-only recovery...`);
431
- const device = yield (0, ownDeviceRow_1.findOwnDevice)(udid);
432
- if (device && device.ip) {
433
- // Double check if WDA is alive via network IP
434
- const isWdaAccessibleViaNetwork = yield this.isWDARunningOnHost(device.ip, 8100);
435
- if (isWdaAccessibleViaNetwork) {
436
- logger_1.default.info(`🛡️ [${udid}] [Watchdog] WDA is alive on network ${device.ip}. Restarting tunnels...`);
437
- yield this.restartTunnelsOnly(session);
438
- return true; // We healed it!
439
- }
440
- }
441
- return false; // Cannot heal without network access or if WDA is also dead
423
+ logger_1.default.warn(`🛡️ [${udid}] [Watchdog] Port forwarder processes are dead.`);
424
+ return false;
442
425
  }
443
426
  // 2. Network Check: verify endpoint is responding via tunnel
444
427
  const isRespondingViaTunnel = yield this.isWDARunning(session.wdaPort);
445
428
  if (!isRespondingViaTunnel) {
446
- logger_1.default.warn(`🛡️ [${udid}] [Watchdog] WDA tunnel on port ${session.wdaPort} is unresponsive. checking network...`);
447
- const device = yield (0, ownDeviceRow_1.findOwnDevice)(udid);
448
- if (device && device.ip) {
449
- const isWdaAccessibleViaNetwork = yield this.isWDARunningOnHost(device.ip, 8100);
450
- if (isWdaAccessibleViaNetwork) {
451
- logger_1.default.info(`🛡️ [${udid}] [Watchdog] WDA is alive on network but tunnel is hung. Restarting tunnels...`);
452
- yield this.restartTunnelsOnly(session);
453
- return true;
454
- }
455
- }
429
+ logger_1.default.warn(`🛡️ [${udid}] [Watchdog] WDA on port ${session.wdaPort} is unresponsive.`);
456
430
  return false;
457
431
  }
458
432
  return true;
459
433
  });
460
434
  }
461
- /**
462
- * Check if WDA is running on a specific host/port
463
- */
464
- isWDARunningOnHost(host, port) {
465
- return __awaiter(this, void 0, void 0, function* () {
466
- var _a, _b;
467
- const axios = (yield Promise.resolve().then(() => __importStar(require('axios')))).default;
468
- try {
469
- const response = yield axios.get(`http://${host}:${port}/status`, {
470
- timeout: 3000,
471
- validateStatus: (status) => status === 200,
472
- });
473
- return ((_b = (_a = response.data) === null || _a === void 0 ? void 0 : _a.value) === null || _b === void 0 ? void 0 : _b.ready) === true;
474
- }
475
- catch (e) {
476
- return false;
477
- }
478
- });
479
- }
480
- /**
481
- * Restarts only the iproxy tunnels without stopping WDA
482
- */
483
- restartTunnelsOnly(session) {
484
- return __awaiter(this, void 0, void 0, function* () {
485
- const udid = session.udid;
486
- const isolationService = typedi_2.Container.get(ResourceIsolationService_1.ResourceIsolationService);
487
- // 1. Kill old tunnels
488
- if (session.forwardWDAProcess)
489
- session.forwardWDAProcess.kill('SIGKILL');
490
- if (session.forwardMJPEGProcess)
491
- session.forwardMJPEGProcess.kill('SIGKILL');
492
- // 2. Start new tunnels
493
- const wdaIproxy = isolationService.wrapSpawn('iproxy', ['-u', udid, `${session.wdaPort}:8100`], 'Performance');
494
- const mjpegIproxy = isolationService.wrapSpawn('iproxy', ['-u', udid, `${session.mjpegPort}:9100`], 'Performance');
495
- session.forwardWDAProcess = (0, child_process_1.spawn)(wdaIproxy.command, wdaIproxy.args);
496
- typedi_2.Container.get(ProcessRegistry_1.ProcessRegistry).track({ kind: 'other', udid, process: session.forwardWDAProcess });
497
- session.forwardMJPEGProcess = (0, child_process_1.spawn)(mjpegIproxy.command, mjpegIproxy.args);
498
- typedi_2.Container.get(ProcessRegistry_1.ProcessRegistry).track({ kind: 'ios-mjpeg', udid, process: session.forwardMJPEGProcess });
499
- logger_1.default.info(`🛡️ [${udid}] [Watchdog] Tunnels restarted successfully.`);
500
- });
501
- }
502
435
  /**
503
436
  * Start streaming for a device
504
437
  */
@@ -594,7 +527,7 @@ let IOSStreamService = class IOSStreamService {
594
527
  // guarantee a non-colliding port; the lease is refreshed by the watchdog
595
528
  // and released in stopStream().
596
529
  const portAllocator = typedi_2.Container.get(PortAllocator_1.PortAllocator);
597
- const ttl = { ttlMs: this.STREAM_PORT_TTL_MS };
530
+ const ttl = { ttlMs: IOSTunnels_1.IOS_STREAM_LEASE_TTL_MS };
598
531
  // Principal Discovery: attach to the WDA an Appium session runs on this
599
532
  // device instead of launching a second one over it. The one exception
600
533
  // to the rule above: that session's forwarder listens on the Device
@@ -1101,7 +1034,9 @@ let IOSStreamService = class IOSStreamService {
1101
1034
  * server owns no legitimate tunnel — i.e. on fresh boot (orphans from a
1102
1035
  * previous run, or a hard-killed / crashed process whose graceful cleanup
1103
1036
  * never ran) or during full shutdown. This is the catch-all that stops the
1104
- * self-forking go-ios agent storm from surviving across restarts.
1037
+ * self-forking go-ios agent storm from surviving across restarts. It kills
1038
+ * this process's own go-ios calls too, so boot runs it before device
1039
+ * detection starts (ServerManager.reapLeftoverGoIos).
1105
1040
  */
1106
1041
  reapOrphanTunnels() {
1107
1042
  return __awaiter(this, void 0, void 0, function* () {
@@ -18,7 +18,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
18
18
  return (mod && mod.__esModule) ? mod : { "default": mod };
19
19
  };
20
20
  Object.defineProperty(exports, "__esModule", { value: true });
21
- exports.IOSTunnels = exports.TUNNEL_BORROW_IDLE_MS = exports.TUNNEL_CHECK_MS = exports.TUNNEL_LEASE_TTL_MS = exports.TUNNEL_READY_POLL_MS = exports.TUNNEL_READY_TIMEOUT_MS = void 0;
21
+ exports.IOSTunnels = exports.TUNNEL_BORROW_IDLE_MS = exports.TUNNEL_CHECK_MS = exports.IOS_STREAM_LEASE_TTL_MS = exports.TUNNEL_READY_POLL_MS = exports.TUNNEL_READY_TIMEOUT_MS = void 0;
22
22
  exports.iosVersionOf = iosVersionOf;
23
23
  exports.goIosReason = goIosReason;
24
24
  /**
@@ -48,25 +48,26 @@ exports.goIosReason = goIosReason;
48
48
  const typedi_1 = require("typedi");
49
49
  const child_process_1 = require("child_process");
50
50
  const http_1 = __importDefault(require("http"));
51
- const path_1 = __importDefault(require("path"));
52
51
  const util_1 = require("util");
53
52
  const logger_1 = __importDefault(require("../../logger"));
54
- const helpers_1 = require("../../helpers");
55
53
  const PortAllocator_1 = require("../../services/PortAllocator");
56
54
  const ProcessRegistry_1 = require("../../services/ProcessRegistry");
57
55
  const ResourceIsolationService_1 = require("../../services/ResourceIsolationService");
58
56
  const iosStreamDiagnostics_1 = require("./iosStreamDiagnostics");
57
+ const goIosBinary_1 = require("./goIosBinary");
59
58
  const tunnelProcess_1 = require("./tunnelProcess");
60
59
  const execPromise = (0, util_1.promisify)(child_process_1.exec);
61
60
  /** How long a new tunnel has to answer for its phone before the start goes on without it. */
62
61
  exports.TUNNEL_READY_TIMEOUT_MS = 20000;
63
62
  exports.TUNNEL_READY_POLL_MS = 500;
64
63
  /**
65
- * A tunnel's port lease: the same as the stream's own ports
66
- * (IOSStreamService's STREAM_PORT_TTL_MS). The stream watchdog refreshes both
67
- * every hour, the tunnel's through {@link IOSTunnels.touch}.
64
+ * How long an iPhone stream's port leases last: its WDA and MJPEG ports
65
+ * (IOSStreamService) and its tunnel's pair. Longer than the stream watchdog's
66
+ * hourly tick, which refreshes them (the tunnel's through
67
+ * {@link IOSTunnels.touch}), so a long stream never loses its ports to another
68
+ * phone. Stopping the stream or the tunnel releases them.
68
69
  */
69
- exports.TUNNEL_LEASE_TTL_MS = 90 * 60 * 1000;
70
+ exports.IOS_STREAM_LEASE_TTL_MS = 90 * 60 * 1000;
70
71
  /** How often running tunnels are checked for a phone that went away. */
71
72
  exports.TUNNEL_CHECK_MS = 5000;
72
73
  /**
@@ -126,7 +127,7 @@ let IOSTunnels = class IOSTunnels {
126
127
  this.tunnels = new Map();
127
128
  this.checking = false;
128
129
  this.versionCache = new Map();
129
- this.goIOSPath = path_1.default.join((0, helpers_1.cachePath)('goIOS'), 'ios');
130
+ this.goIOSPath = (0, goIosBinary_1.goIosBinaryPath)();
130
131
  }
131
132
  /**
132
133
  * The phone's tunnel-info port for its stream, starting its tunnel if it
@@ -169,7 +170,7 @@ let IOSTunnels = class IOSTunnels {
169
170
  if (version === null || version < 17)
170
171
  return null;
171
172
  const port = yield this.allocator().acquirePair('tunnel', udid, {
172
- ttlMs: exports.TUNNEL_LEASE_TTL_MS,
173
+ ttlMs: exports.IOS_STREAM_LEASE_TTL_MS,
173
174
  });
174
175
  let proc;
175
176
  try {
@@ -209,7 +210,9 @@ let IOSTunnels = class IOSTunnels {
209
210
  void this.releasePair(port, udid);
210
211
  };
211
212
  proc.once('exit', ended);
212
- proc.once('error', (err) => {
213
+ // `on`, not `once`: a second 'error' with no listener left would be
214
+ // thrown, and take the server down. ended() only acts once.
215
+ proc.on('error', (err) => {
213
216
  this.log.warn(`[${udid}] go-ios tunnel failed: ${err.message}`);
214
217
  ended();
215
218
  });
@@ -230,10 +233,16 @@ let IOSTunnels = class IOSTunnels {
230
233
  waitUntilReady(udid, tunnel) {
231
234
  return __awaiter(this, void 0, void 0, function* () {
232
235
  const { port, process: proc } = tunnel;
233
- const polls = exports.TUNNEL_READY_TIMEOUT_MS / exports.TUNNEL_READY_POLL_MS;
234
- for (let poll = 0; poll <= polls; poll++) {
235
- if (poll > 0)
236
- yield this.sleep(exports.TUNNEL_READY_POLL_MS);
236
+ // By the clock, not a count of checks: each check can take up to its own
237
+ // 1 s timeout, which stretched 41 checks to about a minute.
238
+ const deadline = this.now() + exports.TUNNEL_READY_TIMEOUT_MS;
239
+ for (let first = true;; first = false) {
240
+ if (!first) {
241
+ const left = deadline - this.now();
242
+ if (left <= 0)
243
+ break;
244
+ yield this.sleep(Math.min(exports.TUNNEL_READY_POLL_MS, left));
245
+ }
237
246
  if (this.tunnels.get(udid) !== tunnel) {
238
247
  throw new Error(`The go-ios tunnel for ${udid} exited before it was ready (exit code ${proc.exitCode})` +
239
248
  (tunnel.reason ? `: ${tunnel.reason}` : ''));
@@ -376,8 +385,8 @@ let IOSTunnels = class IOSTunnels {
376
385
  if (!tunnel)
377
386
  return;
378
387
  const allocator = this.allocator();
379
- yield allocator.touch(tunnel.port, exports.TUNNEL_LEASE_TTL_MS);
380
- yield allocator.touch(tunnel.port + 1, exports.TUNNEL_LEASE_TTL_MS);
388
+ yield allocator.touch(tunnel.port, exports.IOS_STREAM_LEASE_TTL_MS);
389
+ yield allocator.touch(tunnel.port + 1, exports.IOS_STREAM_LEASE_TTL_MS);
381
390
  });
382
391
  }
383
392
  releasePair(port, udid) {
@@ -92,96 +92,75 @@ let WDAClient = WDAClient_1 = class WDAClient {
92
92
  }
93
93
  const prefixes = (cached === null || cached === void 0 ? void 0 : cached.sessionId) ? [`/session/${cached.sessionId}`] : ['', '/session/any'];
94
94
  let lastError;
95
- // Principal Intelligence: Prioritize the host that worked last time.
96
- // Usually localhost (tunnel) is fastest, but if it's lagging, network IP is a solid fallback.
97
- const hosts = [];
98
- if (cached === null || cached === void 0 ? void 0 : cached.host) {
99
- hosts.push(cached.host);
100
- if (cached.host === '127.0.0.1' && device.ip)
101
- hosts.push(device.ip);
102
- if (cached.host !== '127.0.0.1')
103
- hosts.push('127.0.0.1');
104
- }
105
- else {
106
- hosts.push('127.0.0.1');
107
- if (device.ip)
108
- hosts.push(device.ip);
109
- }
110
- for (const targetHost of hosts) {
111
- const isNetworkHost = targetHost !== '127.0.0.1';
112
- const targetPort = isNetworkHost ? 8100 : port;
113
- for (const prefix of prefixes) {
114
- try {
115
- const isSessionless = !(options === null || options === void 0 ? void 0 : options.useSessionPath) &&
116
- (['/status', '/health', '/wda/healthcheck'].includes(endpoint));
117
- const url = `http://${targetHost}:${targetPort}${isSessionless ? '' : prefix}${endpoint}`;
118
- // Principal Stability: Screenshots, activation, and script execution need longer timeouts.
119
- // Heartbeats and Status checks should be snappy to fail-over quickly.
120
- const isHeavyCommand = endpoint.includes('screenshot') ||
121
- endpoint.includes('source') ||
122
- endpoint.includes('swipe') ||
123
- endpoint.includes('touchAndHold') ||
124
- endpoint.includes('activate') ||
125
- endpoint.includes('execute') ||
126
- endpoint.includes('actions');
127
- const timeout = isHeavyCommand ? 30000 : 5000; // Snappy 5s timeout for standard commands
128
- const res = method === 'post'
129
- ? yield axios_1.default.post(url, data || {}, { timeout })
130
- : yield axios_1.default.get(url, { timeout });
131
- // If we reached a host but it returned a logical error (e.g. 404 for session),
132
- // we should update our cache and potentially stop retrying this host/prefix combo.
133
- // However, for simplicity, we treat any successful HTTP response as "Host is alive".
134
- if (isNetworkHost && targetHost !== (cached === null || cached === void 0 ? void 0 : cached.host)) {
135
- this.log.debug(`[WDA] Successful fallback to network IP ${targetHost} for ${udid}`);
136
- }
137
- const sid = ((_a = res.data) === null || _a === void 0 ? void 0 : _a.sessionId) || ((_c = (_b = res.data) === null || _b === void 0 ? void 0 : _b.value) === null || _c === void 0 ? void 0 : _c.sessionId);
138
- if (sid && sid !== (cached === null || cached === void 0 ? void 0 : cached.sessionId)) {
139
- this.log.debug(`[WDA] Detected new session ID: ${sid} via ${targetHost}`);
140
- this.wdaConnectionCache.set(cacheKey, {
141
- host: targetHost,
142
- pathPrefix: `/session/${sid}`,
143
- sessionId: sid,
144
- });
145
- typedi_1.Container.get(IOSStreamService_1.default).setWDASessionId(udid, sid);
146
- }
147
- return res;
95
+ // Only the phone's own forwarded port. WDA names no phone, so no address
96
+ // on the network is known to be this phone's: a simulator's is this
97
+ // Mac's own, where port 8100 can be an iPhone's WDA forward.
98
+ const targetHost = '127.0.0.1';
99
+ for (const prefix of prefixes) {
100
+ try {
101
+ const isSessionless = !(options === null || options === void 0 ? void 0 : options.useSessionPath) &&
102
+ (['/status', '/health', '/wda/healthcheck'].includes(endpoint));
103
+ const url = `http://${targetHost}:${port}${isSessionless ? '' : prefix}${endpoint}`;
104
+ // Principal Stability: Screenshots, activation, and script execution need longer timeouts.
105
+ // Heartbeats and Status checks should be snappy to fail-over quickly.
106
+ const isHeavyCommand = endpoint.includes('screenshot') ||
107
+ endpoint.includes('source') ||
108
+ endpoint.includes('swipe') ||
109
+ endpoint.includes('touchAndHold') ||
110
+ endpoint.includes('activate') ||
111
+ endpoint.includes('execute') ||
112
+ endpoint.includes('actions');
113
+ const timeout = isHeavyCommand ? 30000 : 5000; // Snappy 5s timeout for standard commands
114
+ const res = method === 'post'
115
+ ? yield axios_1.default.post(url, data || {}, { timeout })
116
+ : yield axios_1.default.get(url, { timeout });
117
+ // If we reached a host but it returned a logical error (e.g. 404 for session),
118
+ // we should update our cache and potentially stop retrying this host/prefix combo.
119
+ // However, for simplicity, we treat any successful HTTP response as "Host is alive".
120
+ const sid = ((_a = res.data) === null || _a === void 0 ? void 0 : _a.sessionId) || ((_c = (_b = res.data) === null || _b === void 0 ? void 0 : _b.value) === null || _c === void 0 ? void 0 : _c.sessionId);
121
+ if (sid && sid !== (cached === null || cached === void 0 ? void 0 : cached.sessionId)) {
122
+ this.log.debug(`[WDA] Detected new session ID: ${sid} via ${targetHost}`);
123
+ this.wdaConnectionCache.set(cacheKey, {
124
+ host: targetHost,
125
+ pathPrefix: `/session/${sid}`,
126
+ sessionId: sid,
127
+ });
128
+ typedi_1.Container.get(IOSStreamService_1.default).setWDASessionId(udid, sid);
148
129
  }
149
- catch (err) {
150
- lastError = err;
151
- if (err.response) {
152
- const status = err.response.status;
153
- const errorMsg = JSON.stringify(err.response.data);
154
- this.log.info(`[WDA] Command ${method.toUpperCase()} ${endpoint} failed (${status}): ${errorMsg}`);
155
- // Principal Resiliency: Multi-Phase Recovery
156
- if (retryCount < 2) {
157
- // Phase 1: Dead Session Recovery (404)
158
- if (status === 404 && prefix.includes('/session/')) {
159
- // Special Intelligence: Some WDA versions don't support /execute, /wda/homescreen, etc.
160
- // We should NOT clear the session if these specific commands fail with 404,
161
- // as they might just be unsupported features.
162
- const NON_FATAL_ENDPOINTS = ['execute', 'homescreen', 'pressButton'];
163
- if (NON_FATAL_ENDPOINTS.some((e) => endpoint.includes(e))) {
164
- this.log.debug(`[WDA] Command ${endpoint} failed with 404 (Unsupported or Logical Error). Skipping session reset.`);
165
- throw err;
166
- }
167
- this.log.warn(`[WDA] Session ${cached === null || cached === void 0 ? void 0 : cached.sessionId} appears dead. Resetting and retrying...`);
168
- this.wdaConnectionCache.delete(cacheKey);
169
- typedi_1.Container.get(IOSStreamService_1.default).setWDASessionId(udid, '');
170
- return this.performWDACommand(udid, method, endpoint, data, retryCount + 1, options);
171
- }
172
- // Phase 2: Transient State Recovery (500)
173
- // This handles "Focused" errors, "Internal" errors, or transient locks during app switches.
174
- if (status === 500) {
175
- this.log.warn(`[WDA] Transient error (500) for ${udid}, retrying in 1s... (Reason: ${errorMsg})`);
176
- yield new Promise((r) => setTimeout(r, 1000));
177
- return this.performWDACommand(udid, method, endpoint, data, retryCount + 1, options);
130
+ return res;
131
+ }
132
+ catch (err) {
133
+ lastError = err;
134
+ if (err.response) {
135
+ const status = err.response.status;
136
+ const errorMsg = JSON.stringify(err.response.data);
137
+ this.log.info(`[WDA] Command ${method.toUpperCase()} ${endpoint} failed (${status}): ${errorMsg}`);
138
+ // Principal Resiliency: Multi-Phase Recovery
139
+ if (retryCount < 2) {
140
+ // Phase 1: Dead Session Recovery (404)
141
+ if (status === 404 && prefix.includes('/session/')) {
142
+ // Special Intelligence: Some WDA versions don't support /execute, /wda/homescreen, etc.
143
+ // We should NOT clear the session if these specific commands fail with 404,
144
+ // as they might just be unsupported features.
145
+ const NON_FATAL_ENDPOINTS = ['execute', 'homescreen', 'pressButton'];
146
+ if (NON_FATAL_ENDPOINTS.some((e) => endpoint.includes(e))) {
147
+ this.log.debug(`[WDA] Command ${endpoint} failed with 404 (Unsupported or Logical Error). Skipping session reset.`);
148
+ throw err;
178
149
  }
150
+ this.log.warn(`[WDA] Session ${cached === null || cached === void 0 ? void 0 : cached.sessionId} appears dead. Resetting and retrying...`);
151
+ this.wdaConnectionCache.delete(cacheKey);
152
+ typedi_1.Container.get(IOSStreamService_1.default).setWDASessionId(udid, '');
153
+ return this.performWDACommand(udid, method, endpoint, data, retryCount + 1, options);
154
+ }
155
+ // Phase 2: Transient State Recovery (500)
156
+ // This handles "Focused" errors, "Internal" errors, or transient locks during app switches.
157
+ if (status === 500) {
158
+ this.log.warn(`[WDA] Transient error (500) for ${udid}, retrying in 1s... (Reason: ${errorMsg})`);
159
+ yield new Promise((r) => setTimeout(r, 1000));
160
+ return this.performWDACommand(udid, method, endpoint, data, retryCount + 1, options);
179
161
  }
180
- throw err;
181
162
  }
182
- // If network host fails, don't retry other prefixes if it's just a status check
183
- if (isNetworkHost && ['/status', '/health'].includes(endpoint))
184
- break;
163
+ throw err;
185
164
  }
186
165
  }
187
166
  }
@@ -0,0 +1,16 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.goIosBinaryPath = goIosBinaryPath;
7
+ const path_1 = __importDefault(require("path"));
8
+ const helpers_1 = require("../../helpers");
9
+ /**
10
+ * The go-ios binary Xenon vendors, where src/scripts/install-go-ios.ts puts
11
+ * it. Every go-ios command Xenon runs uses this one path: the boot and
12
+ * shutdown reaps find go-ios processes by it.
13
+ */
14
+ function goIosBinaryPath() {
15
+ return path_1.default.join((0, helpers_1.cachePath)('goIOS'), 'ios');
16
+ }
package/lib/src/plugin.js CHANGED
@@ -157,16 +157,8 @@ class XenonPlugin extends base_plugin_1.default {
157
157
  catch (err) {
158
158
  logger_1.default.warn(`[plugin] RecordingOrchestrator.recoverOnBoot failed: ${err === null || err === void 0 ? void 0 : err.message}`);
159
159
  }
160
- // Reap orphan go-ios tunnels left by a previous run or a hard-killed/crashed
161
- // process. A fresh server owns no tunnels, so any surviving go-ios agents are
162
- // orphans that would otherwise keep self-forking into a runaway process storm.
163
- try {
164
- const { default: IOSStreamService } = yield Promise.resolve().then(() => __importStar(require('./device-managers/ios/IOSStreamService')));
165
- yield typedi_1.Container.get(IOSStreamService).reapOrphanTunnels();
166
- }
167
- catch (err) {
168
- logger_1.default.warn(`[plugin] IOSStreamService.reapOrphanTunnels failed: ${err === null || err === void 0 ? void 0 : err.message}`);
169
- }
160
+ // Leftover go-ios is reaped by ServerManager.updateServer, before device
161
+ // detection starts: here, the reap killed detection's own go-ios calls.
170
162
  });
171
163
  }
172
164
  createSession(next, driver, jwpDesCaps, jwpReqCaps, caps) {
@@ -291,9 +291,16 @@ let PortAllocator = class PortAllocator {
291
291
  .catch(() => undefined);
292
292
  });
293
293
  }
294
+ /**
295
+ * Delete a phone's leases when a session on it ends. Not its go-ios
296
+ * tunnel's pair: the phone's stream, and so its tunnel, can outlive the
297
+ * session, and IOSTunnels gives the pair back when the tunnel stops.
298
+ */
294
299
  releaseForUdid(udid) {
295
300
  return __awaiter(this, void 0, void 0, function* () {
296
- yield prisma_1.prisma.portLease.deleteMany({ where: { leasedToUdid: udid } });
301
+ yield prisma_1.prisma.portLease.deleteMany({
302
+ where: { leasedToUdid: udid, purpose: { not: 'tunnel' } },
303
+ });
297
304
  });
298
305
  }
299
306
  purgeExpired() {