@xenon-device-management/xenon 2.8.3 → 2.9.1

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 (90) hide show
  1. package/lib/package.json +1 -1
  2. package/lib/public/assets/{AnnotationOverlay-DZrFxYPZ.js → AnnotationOverlay-Dm_URoOL.js} +1 -1
  3. package/lib/public/assets/{ApiKeyGate-DkyW0B7G.js → ApiKeyGate-BdGOuXDm.js} +1 -1
  4. package/lib/public/assets/{BugReportButton-TyYrNWYM.js → BugReportButton-BuWGEjgw.js} +1 -1
  5. package/lib/public/assets/{DeviceMosaicView-DY__bWHx.js → DeviceMosaicView-Pu3rJ99D.js} +1 -1
  6. package/lib/public/assets/{EmptyState-CfkN-nUG.js → EmptyState-syyHElMd.js} +1 -1
  7. package/lib/public/assets/{FieldGroup-DQu6Zo15.js → FieldGroup-BZR5ZBVv.js} +1 -1
  8. package/lib/public/assets/{FilterMenu-BT_Q7-4t.js → FilterMenu-CsgtrmPb.js} +1 -1
  9. package/lib/public/assets/{Menu-DfwEC8pG.js → Menu-DP8XOm1A.js} +1 -1
  10. package/lib/public/assets/{Modal-DXyaClqh.js → Modal-Dtd-8uI2.js} +1 -1
  11. package/lib/public/assets/{RecordingPage-j7H4_iT1.js → RecordingPage-CdAtAmHj.js} +1 -1
  12. package/lib/public/assets/{RecordingsPage-ST7B99o1.js → RecordingsPage-Br3CKxTe.js} +1 -1
  13. package/lib/public/assets/{SegmentedControl-DaqYH9BW.js → SegmentedControl-BOaNmUL-.js} +1 -1
  14. package/lib/public/assets/{SettingCard-CWTz_r_8.js → SettingCard-CQuK9ZZX.js} +1 -1
  15. package/lib/public/assets/{Table-BDO5qlOf.js → Table-CswCefnT.js} +1 -1
  16. package/lib/public/assets/{activity-CGdR3Ff6.js → activity-6fhH3u4C.js} +1 -1
  17. package/lib/public/assets/{ai-settings-C8I0KSUg.js → ai-settings-CU24L3D4.js} +1 -1
  18. package/lib/public/assets/{api-keys-BhbZQpGV.js → api-keys-DC3BZDaG.js} +1 -1
  19. package/lib/public/assets/{apps-DaBl9nMx.js → apps-Ce5Z-wNc.js} +1 -1
  20. package/lib/public/assets/{arrow-left-WRIrign1.js → arrow-left-DZJqYn7r.js} +1 -1
  21. package/lib/public/assets/{arrow-right-BuIztKwL.js → arrow-right-nIvK8CyN.js} +1 -1
  22. package/lib/public/assets/{arrow-up-right-Cz072oos.js → arrow-up-right-BfugJj3f.js} +1 -1
  23. package/lib/public/assets/{auth-shell-BS884uL2.js → auth-shell-B1Cz75Vq.js} +2 -2
  24. package/lib/public/assets/{builds-page-B05iZMmN.js → builds-page-5mlJGjIk.js} +1 -1
  25. package/lib/public/assets/{button-DjeIU-Ty.js → button-D_W88sn-.js} +1 -1
  26. package/lib/public/assets/{calendar-DUF9u2PF.js → calendar-Dc6pWZS9.js} +1 -1
  27. package/lib/public/assets/{check-DUR92DEE.js → check-BbvtnP8m.js} +1 -1
  28. package/lib/public/assets/{chevron-left-CRvlIuad.js → chevron-left-DQDpR9ne.js} +1 -1
  29. package/lib/public/assets/{chevron-right-iRDZ5v7C.js → chevron-right-CgBUaRCA.js} +1 -1
  30. package/lib/public/assets/{circle-check-CqDvbZtW.js → circle-check-BWjEKvA4.js} +1 -1
  31. package/lib/public/assets/{circle-x-CsYtRLC8.js → circle-x-BLUdeRhT.js} +1 -1
  32. package/lib/public/assets/{clock-DB9ihtTE.js → clock-PAhKG1jT.js} +1 -1
  33. package/lib/public/assets/{copy-BufiyibK.js → copy-2CHdAPBf.js} +1 -1
  34. package/lib/public/assets/{device-explorer-BapnPGYt.js → device-explorer-EyAhEL_S.js} +1 -1
  35. package/lib/public/assets/{download-D14AYHRg.js → download-B1vKGhGP.js} +1 -1
  36. package/lib/public/assets/{forgot-password-2PqpH7ph.js → forgot-password-NSrrBA5N.js} +1 -1
  37. package/lib/public/assets/{funnel-CgV-CZex.js → funnel-C9mzkCSI.js} +1 -1
  38. package/lib/public/assets/{index-BE8OC-Wj.js → index-BEk1D5-B.js} +1 -1
  39. package/lib/public/assets/{index-CXxjosDd.js → index-BxaH84UO.js} +2 -2
  40. package/lib/public/assets/{input-DPn63eF4.js → input-cA_lrNIw.js} +1 -1
  41. package/lib/public/assets/{keycodes-BYgvJDTE.js → keycodes-DZLCZ-uv.js} +1 -1
  42. package/lib/public/assets/{layers-DJZQUOez.js → layers-BuXMIJz3.js} +1 -1
  43. package/lib/public/assets/{list-checks-YM2_VNMv.js → list-checks-B8q68tPG.js} +1 -1
  44. package/lib/public/assets/{lock-Cy-A3o47.js → lock-ChoclF8X.js} +1 -1
  45. package/lib/public/assets/{login-CRZ2J7Up.js → login-63DiXJ0M.js} +1 -1
  46. package/lib/public/assets/{maintenance-settings-4HkUk10V.js → maintenance-settings-A47Y-PYy.js} +1 -1
  47. package/lib/public/assets/{monitor-Cyvro78e.js → monitor-BYPF43Ds.js} +1 -1
  48. package/lib/public/assets/{mouse-pointer-2-26YTYUOD.js → mouse-pointer-2-Pcl7inmZ.js} +1 -1
  49. package/lib/public/assets/{network-D-W1VWM_.js → network-ChaLfnWe.js} +1 -1
  50. package/lib/public/assets/{overview-pVmHrKhk.js → overview-bj_vktQG.js} +1 -1
  51. package/lib/public/assets/{page-header-DsTokPlH.js → page-header-bv2AQSMG.js} +1 -1
  52. package/lib/public/assets/{plus-OjIdQkG_.js → plus-u0x2Y5uW.js} +1 -1
  53. package/lib/public/assets/{profile-page-D0jy_p5L.js → profile-page-Cvmef2iY.js} +1 -1
  54. package/lib/public/assets/{reset-password-DptmkgSN.js → reset-password-DqStVhBo.js} +1 -1
  55. package/lib/public/assets/{runbook-page-9QcpCPUo.js → runbook-page-UK0C95QF.js} +1 -1
  56. package/lib/public/assets/{select-CHKzkHre.js → select-CQ6O804R.js} +1 -1
  57. package/lib/public/assets/{selector-detail-page-P5zDXMZC.js → selector-detail-page-D-PjU6mC.js} +1 -1
  58. package/lib/public/assets/{selector-health-Buz4eRW1.js → selector-health-CVm0cTOU.js} +1 -1
  59. package/lib/public/assets/{selector-health-page-BHZfbxGg.js → selector-health-page-BdibBs8g.js} +1 -1
  60. package/lib/public/assets/{session-detail-page-JHU-GKSX.js → session-detail-page-BijicGOF.js} +1 -1
  61. package/lib/public/assets/{settings-CRTRggHW.js → settings-Cva7V4n-.js} +1 -1
  62. package/lib/public/assets/{stat-tile-Be2twadZ.js → stat-tile-BdIoEoou.js} +1 -1
  63. package/lib/public/assets/{tablet-C7QkOkHs.js → tablet-BPtlTsKn.js} +1 -1
  64. package/lib/public/assets/{teams-CwVhnDoL.js → teams-D4r1UMME.js} +1 -1
  65. package/lib/public/assets/{trash-2-yjlULOrf.js → trash-2-CnysPtdR.js} +1 -1
  66. package/lib/public/assets/{upload-BcrYG7kK.js → upload-Uyj4-Cyq.js} +1 -1
  67. package/lib/public/assets/{use-password-reset-mode-BhRH2TTL.js → use-password-reset-mode-Du-ykGHP.js} +1 -1
  68. package/lib/public/assets/{users-D3jRTB5K.js → users-Cs00GNmy.js} +1 -1
  69. package/lib/public/assets/{users-BSZL_J7x.js → users-ttkwKBos.js} +1 -1
  70. package/lib/public/assets/{video-off-BwbiJtmA.js → video-off-BYa4qD8N.js} +1 -1
  71. package/lib/public/assets/{webhook-settings-CTIdd7DN.js → webhook-settings-DuEnPlet.js} +1 -1
  72. package/lib/public/assets/{zap-CZK3Cbup.js → zap-CxHn6Vwu.js} +1 -1
  73. package/lib/public/index.html +1 -1
  74. package/lib/src/device-managers/ios/IOSDiscoveryService.js +2 -4
  75. package/lib/src/device-managers/ios/IOSStreamService.js +12 -14
  76. package/lib/src/device-managers/ios/IOSTunnels.js +127 -34
  77. package/lib/src/device-managers/ios/WDAClient.js +18 -4
  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-go-ios-tunnel-env.spec.js +38 -1
  85. package/lib/test/unit/ios-stream-appium-tunnel.spec.js +10 -1
  86. package/lib/test/unit/ios-stream-port-leases.spec.js +0 -1
  87. package/lib/test/unit/ios-tunnels.spec.js +91 -3
  88. package/lib/test/unit/port-allocator-pairs.spec.js +34 -0
  89. package/lib/tsconfig.tsbuildinfo +1 -1
  90. package/package.json +1 -1
@@ -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
  */
@@ -283,6 +275,10 @@ let IOSStreamService = class IOSStreamService {
283
275
  return __awaiter(this, void 0, void 0, function* () {
284
276
  if (yield this.appiumSessionMayUse(udid, 'go-ios tunnels'))
285
277
  return;
278
+ // A tunnel opened for screenshots is live and tracked, not a leftover:
279
+ // the start's ensure takes it over (IOSTunnels.borrow).
280
+ if (this.tunnels().isOnDemand(udid))
281
+ return;
286
282
  logger_1.default.debug(`Cleaning up orphan tunnels for ${udid}...`);
287
283
  yield this.tunnels().stop(udid);
288
284
  // Reap the tunnel process *group* for this udid so the self-forking go-ios
@@ -590,7 +586,7 @@ let IOSStreamService = class IOSStreamService {
590
586
  // guarantee a non-colliding port; the lease is refreshed by the watchdog
591
587
  // and released in stopStream().
592
588
  const portAllocator = typedi_2.Container.get(PortAllocator_1.PortAllocator);
593
- const ttl = { ttlMs: this.STREAM_PORT_TTL_MS };
589
+ const ttl = { ttlMs: IOSTunnels_1.IOS_STREAM_LEASE_TTL_MS };
594
590
  // Principal Discovery: attach to the WDA an Appium session runs on this
595
591
  // device instead of launching a second one over it. The one exception
596
592
  // to the rule above: that session's forwarder listens on the Device
@@ -1097,7 +1093,9 @@ let IOSStreamService = class IOSStreamService {
1097
1093
  * server owns no legitimate tunnel — i.e. on fresh boot (orphans from a
1098
1094
  * previous run, or a hard-killed / crashed process whose graceful cleanup
1099
1095
  * never ran) or during full shutdown. This is the catch-all that stops the
1100
- * self-forking go-ios agent storm from surviving across restarts.
1096
+ * self-forking go-ios agent storm from surviving across restarts. It kills
1097
+ * this process's own go-ios calls too, so boot runs it before device
1098
+ * detection starts (ServerManager.reapLeftoverGoIos).
1101
1099
  */
1102
1100
  reapOrphanTunnels() {
1103
1101
  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_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,27 +48,35 @@ 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;
73
+ /**
74
+ * How long a tunnel opened for screenshots stays after the last one: long
75
+ * enough that a client taking screenshots in a loop pays the start once.
76
+ */
77
+ exports.TUNNEL_BORROW_IDLE_MS = 2 * 60 * 1000;
78
+ /** How long a phone's iOS version is trusted before `ios info` is asked again. */
79
+ const VERSION_CACHE_MS = 10 * 60 * 1000;
72
80
  /** The iOS version `ios info` reports, as a number: 17.2 for "17.2.1" or "iOS 17.2", 0 for none. */
73
81
  function iosVersionOf(info) {
74
82
  const versionStr = String(info.ProductVersion || info.HumanReadableProductVersionString || '0');
@@ -118,32 +126,51 @@ let IOSTunnels = class IOSTunnels {
118
126
  this.log = logger_1.default.scope('IOSTunnels');
119
127
  this.tunnels = new Map();
120
128
  this.checking = false;
121
- this.goIOSPath = path_1.default.join((0, helpers_1.cachePath)('goIOS'), 'ios');
129
+ this.versionCache = new Map();
130
+ this.goIOSPath = (0, goIosBinary_1.goIosBinaryPath)();
122
131
  }
123
132
  /**
124
- * The phone's tunnel-info port, starting its tunnel if it has none. Null
125
- * for a phone below iOS 17, or whose version can't be read: it needs no
126
- * tunnel. Throws when no pair of ports is left, or when the tunnel exits
127
- * before it is ready.
133
+ * The phone's tunnel-info port for its stream, starting its tunnel if it
134
+ * has none. A tunnel opened for screenshots is taken over: from then on
135
+ * only the stream's stop ends it. Null for a phone below iOS 17, or whose
136
+ * version can't be read: it needs no tunnel. Throws when no pair of ports
137
+ * is left, or when the tunnel exits before it is ready.
128
138
  */
129
139
  ensure(udid) {
130
140
  return __awaiter(this, void 0, void 0, function* () {
131
- var _a;
132
- const running = this.portFor(udid);
133
- if (running !== undefined)
134
- return running;
135
- let version;
136
- try {
137
- version = yield this.iosVersion(udid);
138
- }
139
- catch (e) {
140
- this.log.warn(`[${udid}] Could not read the iOS version, so no go-ios tunnel: ${(_a = e === null || e === void 0 ? void 0 : e.message) !== null && _a !== void 0 ? _a : e}`);
141
- return null;
141
+ return this.open(udid, 'stream');
142
+ });
143
+ }
144
+ /**
145
+ * The phone's tunnel-info port for a screenshot, which on iOS 17+ is
146
+ * reached only through the tunnel. A phone that isn't streaming has none,
147
+ * so one is started; it stays while screenshots keep coming and stops
148
+ * {@link TUNNEL_BORROW_IDLE_MS} after the last one (checkTunnels). Null and
149
+ * throws as ensure.
150
+ */
151
+ borrow(udid) {
152
+ return __awaiter(this, void 0, void 0, function* () {
153
+ return this.open(udid, 'screenshot');
154
+ });
155
+ }
156
+ /** Whether the phone's tunnel was opened for screenshots and no stream has taken it. */
157
+ isOnDemand(udid) {
158
+ const tunnel = this.tunnels.get(udid);
159
+ return !!tunnel && tunnel.process.exitCode === null && !tunnel.streamOwned;
160
+ }
161
+ open(udid, use) {
162
+ return __awaiter(this, void 0, void 0, function* () {
163
+ const running = this.tunnels.get(udid);
164
+ if (running && running.process.exitCode === null) {
165
+ this.claim(running, use);
166
+ return running.whenReady;
142
167
  }
143
- if (version < 17)
168
+ // A screenshot also asks on a simulator, which has no version to read.
169
+ const version = yield this.versionOf(udid, use === 'screenshot');
170
+ if (version === null || version < 17)
144
171
  return null;
145
172
  const port = yield this.allocator().acquirePair('tunnel', udid, {
146
- ttlMs: exports.TUNNEL_LEASE_TTL_MS,
173
+ ttlMs: exports.IOS_STREAM_LEASE_TTL_MS,
147
174
  });
148
175
  let proc;
149
176
  try {
@@ -161,7 +188,15 @@ let IOSTunnels = class IOSTunnels {
161
188
  yield this.releasePair(port, udid);
162
189
  throw e;
163
190
  }
164
- const tunnel = { port, process: proc, ready: false };
191
+ const tunnel = {
192
+ port,
193
+ process: proc,
194
+ ready: false,
195
+ whenReady: Promise.resolve(port),
196
+ streamOwned: false,
197
+ borrowedUntil: 0,
198
+ };
199
+ this.claim(tunnel, use);
165
200
  this.tunnels.set(udid, tunnel);
166
201
  this.watch();
167
202
  // A tunnel whose process ends (go-ios crashed, or something killed it)
@@ -175,16 +210,39 @@ let IOSTunnels = class IOSTunnels {
175
210
  void this.releasePair(port, udid);
176
211
  };
177
212
  proc.once('exit', ended);
178
- 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) => {
179
216
  this.log.warn(`[${udid}] go-ios tunnel failed: ${err.message}`);
180
217
  ended();
181
218
  });
182
219
  this.logOutput(udid, tunnel);
183
- this.log.info(`[${udid}] iOS ${version}: starting a go-ios tunnel on ${port} (traffic on ${port + 1})`);
184
- const polls = exports.TUNNEL_READY_TIMEOUT_MS / exports.TUNNEL_READY_POLL_MS;
185
- for (let poll = 0; poll <= polls; poll++) {
186
- if (poll > 0)
187
- yield this.sleep(exports.TUNNEL_READY_POLL_MS);
220
+ this.log.info(`[${udid}] iOS ${version}: starting a go-ios tunnel on ${port} (traffic on ${port + 1})` +
221
+ (use === 'screenshot' ? ' for screenshots' : ''));
222
+ // Whoever asks while it starts waits for the same start.
223
+ tunnel.whenReady = this.waitUntilReady(udid, tunnel);
224
+ return tunnel.whenReady;
225
+ });
226
+ }
227
+ claim(tunnel, use) {
228
+ if (use === 'stream')
229
+ tunnel.streamOwned = true;
230
+ else
231
+ tunnel.borrowedUntil = this.now() + exports.TUNNEL_BORROW_IDLE_MS;
232
+ }
233
+ waitUntilReady(udid, tunnel) {
234
+ return __awaiter(this, void 0, void 0, function* () {
235
+ const { port, process: proc } = tunnel;
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
+ }
188
246
  if (this.tunnels.get(udid) !== tunnel) {
189
247
  throw new Error(`The go-ios tunnel for ${udid} exited before it was ready (exit code ${proc.exitCode})` +
190
248
  (tunnel.reason ? `: ${tunnel.reason}` : ''));
@@ -206,6 +264,33 @@ let IOSTunnels = class IOSTunnels {
206
264
  return port;
207
265
  });
208
266
  }
267
+ /**
268
+ * The phone's iOS version, from `ios info`, kept for VERSION_CACHE_MS: a
269
+ * screenshot on a phone below iOS 17 would otherwise ask every time. Null
270
+ * when it can't be read (not cached, so a phone still pairing is asked
271
+ * again); logged at debug when `quiet`.
272
+ */
273
+ versionOf(udid_1) {
274
+ return __awaiter(this, arguments, void 0, function* (udid, quiet = false) {
275
+ var _a;
276
+ const cached = this.versionCache.get(udid);
277
+ if (cached && this.now() - cached.at < VERSION_CACHE_MS)
278
+ return cached.version;
279
+ try {
280
+ const version = yield this.iosVersion(udid);
281
+ this.versionCache.set(udid, { version, at: this.now() });
282
+ return version;
283
+ }
284
+ catch (e) {
285
+ const message = `[${udid}] Could not read the iOS version, so no go-ios tunnel: ${(_a = e === null || e === void 0 ? void 0 : e.message) !== null && _a !== void 0 ? _a : e}`;
286
+ if (quiet)
287
+ this.log.debug(message);
288
+ else
289
+ this.log.warn(message);
290
+ return null;
291
+ }
292
+ });
293
+ }
209
294
  /** The phone's tunnel-info port, while its tunnel runs. */
210
295
  portFor(udid) {
211
296
  const tunnel = this.tunnels.get(udid);
@@ -253,6 +338,11 @@ let IOSTunnels = class IOSTunnels {
253
338
  this.checking = true;
254
339
  try {
255
340
  for (const [udid, tunnel] of [...this.tunnels]) {
341
+ if (!tunnel.streamOwned && tunnel.borrowedUntil <= this.now()) {
342
+ this.log.info(`[${udid}] go-ios tunnel on ${tunnel.port}, opened for screenshots, is idle; stopping it`);
343
+ yield this.stop(udid);
344
+ continue;
345
+ }
256
346
  const info = yield this.tunnelInfo(tunnel.port, udid);
257
347
  if (this.tunnels.get(udid) !== tunnel)
258
348
  continue;
@@ -295,8 +385,8 @@ let IOSTunnels = class IOSTunnels {
295
385
  if (!tunnel)
296
386
  return;
297
387
  const allocator = this.allocator();
298
- yield allocator.touch(tunnel.port, exports.TUNNEL_LEASE_TTL_MS);
299
- 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);
300
390
  });
301
391
  }
302
392
  releasePair(port, udid) {
@@ -400,6 +490,9 @@ let IOSTunnels = class IOSTunnels {
400
490
  req.on('error', () => resolve({ state: 'unknown' }));
401
491
  });
402
492
  }
493
+ now() {
494
+ return Date.now();
495
+ }
403
496
  sleep(ms) {
404
497
  return new Promise((resolve) => setTimeout(resolve, ms));
405
498
  }
@@ -395,12 +395,20 @@ let WDAClient = WDAClient_1 = class WDAClient {
395
395
  return __awaiter(this, void 0, void 0, function* () {
396
396
  var _a, _b, _c;
397
397
  const s = typedi_1.Container.get(IOSStreamService_1.default);
398
+ let tunnelError;
398
399
  if (yield s.isGoIOSAvailable()) {
399
400
  try {
401
+ // On iOS 17+ go-ios reaches the screenshot service only through the
402
+ // phone's own tunnel, and a phone that isn't streaming has none: open
403
+ // one, kept while screenshots keep coming (see IOSTunnels.borrow).
404
+ const tunnels = typedi_1.Container.get(IOSTunnels_1.IOSTunnels);
405
+ yield tunnels.borrow(udid).catch((e) => {
406
+ tunnelError = e;
407
+ throw e;
408
+ });
400
409
  const p = path_1.default.join(os_1.default.tmpdir(), `screenshot-${udid}.png`);
401
410
  yield execFilePromise(s.goIOSPath, ['screenshot', '--udid', udid, '--output', p], {
402
- // This phone's own go-ios tunnel on iOS 17+ (see IOSTunnels).
403
- env: typedi_1.Container.get(IOSTunnels_1.IOSTunnels).envFor(udid),
411
+ env: tunnels.envFor(udid),
404
412
  });
405
413
  const b = yield fs_extra_1.default.readFile(p);
406
414
  yield fs_extra_1.default.remove(p);
@@ -410,8 +418,14 @@ let WDAClient = WDAClient_1 = class WDAClient {
410
418
  this.log.debug(`go-ios screenshot failed for ${udid}: ${e.message}\n${e.stack}`);
411
419
  }
412
420
  }
413
- const res = yield this.sendWDACommand(udid, 'get', '/screenshot');
414
- return ((_b = (_a = res.data) === null || _a === void 0 ? void 0 : _a.value) === null || _b === void 0 ? void 0 : _b.screenshot) || ((_c = res.data) === null || _c === void 0 ? void 0 : _c.value) || '';
421
+ try {
422
+ const res = yield this.sendWDACommand(udid, 'get', '/screenshot');
423
+ return ((_b = (_a = res.data) === null || _a === void 0 ? void 0 : _a.value) === null || _b === void 0 ? void 0 : _b.screenshot) || ((_c = res.data) === null || _c === void 0 ? void 0 : _c.value) || '';
424
+ }
425
+ catch (e) {
426
+ // WebDriverAgent couldn't answer either; the tunnel's reason says more.
427
+ throw tunnelError !== null && tunnelError !== void 0 ? tunnelError : e;
428
+ }
415
429
  });
416
430
  }
417
431
  getClipboard(udid) {
@@ -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() {
@@ -130,6 +130,8 @@ let ServerManager = ServerManager_1 = class ServerManager {
130
130
  }
131
131
  yield this.syncDatabaseAndAIConfig(pluginArgs);
132
132
  yield this.initializeCoreSubsystems(pluginArgs, cliArgs.port);
133
+ // Before anything below starts go-ios: the reap would kill it too.
134
+ yield this.reapLeftoverGoIos();
133
135
  this.registerRoutes(expressApp, httpServer, cliArgs, pluginArgs);
134
136
  yield this.bootEmulators(pluginArgs);
135
137
  this.registerDependenciesInContainer(pluginArgs, cliArgs, nodeId);
@@ -364,6 +366,26 @@ let ServerManager = ServerManager_1 = class ServerManager {
364
366
  typedi_1.Container.set(ARTIFACT_STORE, new FsArtifactStore(config_1.config.recordingsAssetsPath));
365
367
  });
366
368
  }
369
+ /**
370
+ * Reap what a previous run (or a hard-killed one) left of go-ios: tunnels
371
+ * whose agents keep self-forking, WebDriverAgent runners and log streams.
372
+ * It kills every process running the go-ios binary, this process's own
373
+ * included, so it runs before anything here starts one. It used to run at
374
+ * the end of boot, after device detection had started, and killed the
375
+ * `ios info` call detection makes in the background for each plugged-in
376
+ * iPhone ("Failed to fetch IP via go-ios" at every boot).
377
+ */
378
+ reapLeftoverGoIos() {
379
+ return __awaiter(this, void 0, void 0, function* () {
380
+ try {
381
+ const { default: IOSStreamService } = yield Promise.resolve().then(() => __importStar(require('../device-managers/ios/IOSStreamService')));
382
+ yield typedi_1.Container.get(IOSStreamService).reapOrphanTunnels();
383
+ }
384
+ catch (err) {
385
+ this.logger.warn(`Reaping go-ios left by a previous run failed: ${err === null || err === void 0 ? void 0 : err.message}`);
386
+ }
387
+ });
388
+ }
367
389
  registerRoutes(expressApp, httpServer, cliArgs, pluginArgs) {
368
390
  expressApp.use('/xenon', (0, app_1.createRouter)(pluginArgs));
369
391
  // The session gateway goes in front of Appium's routes: Xenon's own
@@ -93,7 +93,7 @@ describe('PortAllocator', () => {
93
93
  (0, chai_1.expect)(deleteStub.calledWith({ where: { port: 9100 } })).to.be.true;
94
94
  (0, chai_1.expect)(createStub.called, 'should allocate a replacement lease').to.be.true;
95
95
  }));
96
- it('releaseForUdid deletes all leases for that UDID', () => __awaiter(void 0, void 0, void 0, function* () {
96
+ it("releaseForUdid deletes that UDID's leases", () => __awaiter(void 0, void 0, void 0, function* () {
97
97
  deleteManyStub.resolves({ count: 2 });
98
98
  const allocator = makeAllocator({ wda: [8100, 8102] });
99
99
  yield allocator.releaseForUdid('udid-1');
@@ -0,0 +1,80 @@
1
+ "use strict";
2
+ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
3
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
4
+ return new (P || (P = Promise))(function (resolve, reject) {
5
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
6
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
7
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
8
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
9
+ });
10
+ };
11
+ var __importDefault = (this && this.__importDefault) || function (mod) {
12
+ return (mod && mod.__esModule) ? mod : { "default": mod };
13
+ };
14
+ Object.defineProperty(exports, "__esModule", { value: true });
15
+ require("reflect-metadata");
16
+ const chai_1 = require("chai");
17
+ const sinon_1 = __importDefault(require("sinon"));
18
+ const typedi_1 = require("typedi");
19
+ const plugin_1 = require("../../src/plugin");
20
+ const ServerManager_1 = require("../../src/services/ServerManager");
21
+ const PluginContext_1 = require("../../src/PluginContext");
22
+ const IOSStreamService_1 = __importDefault(require("../../src/device-managers/ios/IOSStreamService"));
23
+ const RecordingOrchestrator_1 = require("../../src/services/recording/RecordingOrchestrator");
24
+ const logger_1 = __importDefault(require("../../src/logger"));
25
+ /**
26
+ * At boot Xenon reaps what a previous run left of go-ios (tunnels whose agents
27
+ * self-fork, WebDriverAgent runners, log streams). The reap kills every process
28
+ * running the vendored go-ios binary, so it also kills this process's own
29
+ * go-ios calls. It used to run last, after device detection had started:
30
+ * detection asks `ios info` about each plugged-in iPhone in the background,
31
+ * and those calls were killed ("Failed to fetch IP via go-ios" for every
32
+ * iPhone, at every boot). It now runs as soon as the database is ready,
33
+ * before this process starts go-ios at all.
34
+ */
35
+ describe('reaping go-ios left by a previous run, at boot', () => {
36
+ let statics;
37
+ beforeEach(() => {
38
+ statics = {
39
+ nodeId: plugin_1.XenonPlugin.NODE_ID,
40
+ port: plugin_1.XenonPlugin.port,
41
+ basePath: plugin_1.XenonPlugin.nodeBasePath,
42
+ };
43
+ });
44
+ afterEach(() => {
45
+ sinon_1.default.restore();
46
+ plugin_1.XenonPlugin.NODE_ID = statics.nodeId;
47
+ plugin_1.XenonPlugin.port = statics.port;
48
+ plugin_1.XenonPlugin.nodeBasePath = statics.basePath;
49
+ });
50
+ it('runs once the database is ready, before routes and device detection start', () => __awaiter(void 0, void 0, void 0, function* () {
51
+ const steps = [];
52
+ const step = (name) => () => {
53
+ steps.push(name);
54
+ return Promise.resolve();
55
+ };
56
+ const boot = ServerManager_1.ServerManager.prototype;
57
+ sinon_1.default.stub(boot, 'resolvePluginArgs').resolves({});
58
+ sinon_1.default.stub(logger_1.default, 'banner');
59
+ sinon_1.default.stub(PluginContext_1.PluginContext.prototype, 'setContext');
60
+ sinon_1.default.stub(boot, 'syncDatabaseAndAIConfig').callsFake(step('database config'));
61
+ sinon_1.default.stub(boot, 'initializeCoreSubsystems').callsFake(step('database and migrations'));
62
+ sinon_1.default.stub(IOSStreamService_1.default.prototype, 'reapOrphanTunnels').callsFake(step('reap'));
63
+ // Everything that starts go-ios (device managers, detection, the hub's and
64
+ // node's discovery crons) comes after the routes; stop there.
65
+ sinon_1.default.stub(boot, 'registerRoutes').callsFake(() => {
66
+ steps.push('routes');
67
+ throw new Error('boot stopped by the test');
68
+ });
69
+ const booting = typedi_1.Container.get(ServerManager_1.ServerManager).updateServer({}, null, { port: 4723 });
70
+ yield booting.then(() => chai_1.expect.fail('the stubbed routes step should have stopped the boot'), (err) => (0, chai_1.expect)(err.message).to.equal('boot stopped by the test'));
71
+ (0, chai_1.expect)(steps).to.deep.equal(['database config', 'database and migrations', 'reap', 'routes']);
72
+ }));
73
+ it('is not left to the plugin, which boots after detection has started', () => __awaiter(void 0, void 0, void 0, function* () {
74
+ sinon_1.default.stub(ServerManager_1.ServerManager.prototype, 'updateServer').resolves();
75
+ sinon_1.default.stub(RecordingOrchestrator_1.RecordingOrchestrator.prototype, 'recoverOnBoot').resolves();
76
+ const reap = sinon_1.default.stub(IOSStreamService_1.default.prototype, 'reapOrphanTunnels').resolves();
77
+ yield plugin_1.XenonPlugin.updateServer({}, null, {});
78
+ (0, chai_1.expect)(reap.called).to.equal(false);
79
+ }));
80
+ });
@@ -43,13 +43,33 @@ case "$1" in
43
43
  syslog) printf '{"msg":"port=%s"}\\n' "$GO_IOS_AGENT_PORT"; exec sleep 5 ;;
44
44
  esac
45
45
  `;
46
- /** IOSTunnels as if WITH_TUNNEL's tunnel ran on 12100 and NO_TUNNEL had none. */
46
+ /** iOS 17+, not streaming: it has a tunnel only once one is opened for it. */
47
+ const NEEDS_TUNNEL = 'test-iphone-idle-00008120';
48
+ /**
49
+ * IOSTunnels as if WITH_TUNNEL's tunnel ran on 12100, NO_TUNNEL needed none
50
+ * (below iOS 17), and NEEDS_TUNNEL got one on 12102 once borrowed.
51
+ */
47
52
  const tunnels = {
53
+ borrowed: new Set(),
54
+ borrowCalls: [],
55
+ borrowError: undefined,
56
+ borrow(udid) {
57
+ return __awaiter(this, void 0, void 0, function* () {
58
+ this.borrowCalls.push(udid);
59
+ if (this.borrowError)
60
+ throw this.borrowError;
61
+ if (udid === NEEDS_TUNNEL)
62
+ this.borrowed.add(udid);
63
+ return udid === WITH_TUNNEL ? 12100 : udid === NEEDS_TUNNEL ? 12102 : null;
64
+ });
65
+ },
48
66
  envFor(udid) {
49
67
  const env = Object.assign(Object.assign({}, process.env), { ENABLE_GO_IOS_AGENT: 'yes' });
50
68
  delete env.GO_IOS_AGENT_PORT;
51
69
  if (udid === WITH_TUNNEL)
52
70
  env.GO_IOS_AGENT_PORT = '12100';
71
+ if (this.borrowed.has(udid))
72
+ env.GO_IOS_AGENT_PORT = '12102';
53
73
  return env;
54
74
  },
55
75
  };
@@ -63,6 +83,9 @@ describe("go-ios commands find their own phone's tunnel (GO_IOS_AGENT_PORT)", ()
63
83
  });
64
84
  after(() => fs_1.default.rmSync(dir, { recursive: true, force: true }));
65
85
  beforeEach(() => {
86
+ tunnels.borrowed.clear();
87
+ tunnels.borrowCalls = [];
88
+ tunnels.borrowError = undefined;
66
89
  const real = typedi_1.Container.get.bind(typedi_1.Container);
67
90
  sinon_1.default.stub(typedi_1.Container, 'get').callsFake((token) => {
68
91
  if (token === IOSTunnels_1.IOSTunnels)
@@ -88,6 +111,20 @@ describe("go-ios commands find their own phone's tunnel (GO_IOS_AGENT_PORT)", ()
88
111
  const png = yield new WDAClient_1.WDAClient().getScreenshot(NO_TUNNEL);
89
112
  (0, chai_1.expect)(Buffer.from(png, 'base64').toString()).to.equal('port=');
90
113
  }));
114
+ // On iOS 17+ the screenshot service is reached only through the tunnel,
115
+ // and a phone that isn't streaming has none.
116
+ it("opens the phone's tunnel for a screenshot when it has none", () => __awaiter(void 0, void 0, void 0, function* () {
117
+ const png = yield new WDAClient_1.WDAClient().getScreenshot(NEEDS_TUNNEL);
118
+ (0, chai_1.expect)(tunnels.borrowCalls).to.deep.equal([NEEDS_TUNNEL]);
119
+ (0, chai_1.expect)(Buffer.from(png, 'base64').toString()).to.equal('port=12102');
120
+ }));
121
+ it("says why, when the tunnel can't start and WebDriverAgent can't answer either", () => __awaiter(void 0, void 0, void 0, function* () {
122
+ tunnels.borrowError = new Error('The go-ios tunnel for X exited before it was ready (exit code 1): failed to start tunnel: EOF');
123
+ const client = new WDAClient_1.WDAClient();
124
+ sinon_1.default.stub(client, 'sendWDACommand').rejects(new Error('connect ECONNREFUSED'));
125
+ const err = yield client.getScreenshot(NEEDS_TUNNEL).then(() => null, (e) => e);
126
+ (0, chai_1.expect)(err === null || err === void 0 ? void 0 : err.message).to.match(/exited before it was ready .*failed to start tunnel: EOF/);
127
+ }));
91
128
  });
92
129
  describe('the logs read (WDAClient syslog)', () => {
93
130
  let client;