@xenon-device-management/xenon 2.8.3 → 2.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/lib/package.json +1 -1
  2. package/lib/public/assets/{AnnotationOverlay-DZrFxYPZ.js → AnnotationOverlay-DhRFPZ9-.js} +1 -1
  3. package/lib/public/assets/{ApiKeyGate-DkyW0B7G.js → ApiKeyGate-BmZy1Srn.js} +1 -1
  4. package/lib/public/assets/{BugReportButton-TyYrNWYM.js → BugReportButton-ByLSqwSk.js} +1 -1
  5. package/lib/public/assets/{DeviceMosaicView-DY__bWHx.js → DeviceMosaicView-DVfXSoXH.js} +1 -1
  6. package/lib/public/assets/{EmptyState-CfkN-nUG.js → EmptyState-Bib6wal8.js} +1 -1
  7. package/lib/public/assets/{FieldGroup-DQu6Zo15.js → FieldGroup-D5sTaKGR.js} +1 -1
  8. package/lib/public/assets/{FilterMenu-BT_Q7-4t.js → FilterMenu-D3XXKTwF.js} +1 -1
  9. package/lib/public/assets/{Menu-DfwEC8pG.js → Menu-BiZhr0F7.js} +1 -1
  10. package/lib/public/assets/{Modal-DXyaClqh.js → Modal-BxGNa3Zp.js} +1 -1
  11. package/lib/public/assets/{RecordingPage-j7H4_iT1.js → RecordingPage-Czml77Wb.js} +1 -1
  12. package/lib/public/assets/{RecordingsPage-ST7B99o1.js → RecordingsPage-Di7nyHqE.js} +1 -1
  13. package/lib/public/assets/{SegmentedControl-DaqYH9BW.js → SegmentedControl-XEKvCRwO.js} +1 -1
  14. package/lib/public/assets/{SettingCard-CWTz_r_8.js → SettingCard-YIpOQAjO.js} +1 -1
  15. package/lib/public/assets/{Table-BDO5qlOf.js → Table-CqwX6v-1.js} +1 -1
  16. package/lib/public/assets/{activity-CGdR3Ff6.js → activity-Cur7Syd6.js} +1 -1
  17. package/lib/public/assets/{ai-settings-C8I0KSUg.js → ai-settings-B1OGdSKu.js} +1 -1
  18. package/lib/public/assets/{api-keys-BhbZQpGV.js → api-keys-abTul2Oa.js} +1 -1
  19. package/lib/public/assets/{apps-DaBl9nMx.js → apps-yQL1MLfG.js} +1 -1
  20. package/lib/public/assets/{arrow-left-WRIrign1.js → arrow-left-D0ipNN6o.js} +1 -1
  21. package/lib/public/assets/{arrow-right-BuIztKwL.js → arrow-right-DwVz_Fly.js} +1 -1
  22. package/lib/public/assets/{arrow-up-right-Cz072oos.js → arrow-up-right-xKuKJfZf.js} +1 -1
  23. package/lib/public/assets/{auth-shell-BS884uL2.js → auth-shell-00zHLFCk.js} +2 -2
  24. package/lib/public/assets/{builds-page-B05iZMmN.js → builds-page-D4RJAbOZ.js} +1 -1
  25. package/lib/public/assets/{button-DjeIU-Ty.js → button-Cic7S8s2.js} +1 -1
  26. package/lib/public/assets/{calendar-DUF9u2PF.js → calendar-DyNZ2bq9.js} +1 -1
  27. package/lib/public/assets/{check-DUR92DEE.js → check-CXGZdojb.js} +1 -1
  28. package/lib/public/assets/{chevron-left-CRvlIuad.js → chevron-left-BcNiIt1C.js} +1 -1
  29. package/lib/public/assets/{chevron-right-iRDZ5v7C.js → chevron-right-BxDjkb1I.js} +1 -1
  30. package/lib/public/assets/{circle-check-CqDvbZtW.js → circle-check-9isFAWQ6.js} +1 -1
  31. package/lib/public/assets/{circle-x-CsYtRLC8.js → circle-x-CfnPCFLK.js} +1 -1
  32. package/lib/public/assets/{clock-DB9ihtTE.js → clock-Dc4TvnFF.js} +1 -1
  33. package/lib/public/assets/{copy-BufiyibK.js → copy-TFQpalJ2.js} +1 -1
  34. package/lib/public/assets/{device-explorer-BapnPGYt.js → device-explorer-CB4bSJtl.js} +1 -1
  35. package/lib/public/assets/{download-D14AYHRg.js → download-1_bq_Ikd.js} +1 -1
  36. package/lib/public/assets/{forgot-password-2PqpH7ph.js → forgot-password-B92TZ1GF.js} +1 -1
  37. package/lib/public/assets/{funnel-CgV-CZex.js → funnel-DC7DsjHe.js} +1 -1
  38. package/lib/public/assets/{index-BE8OC-Wj.js → index-BSsnx09Q.js} +1 -1
  39. package/lib/public/assets/{index-CXxjosDd.js → index-CC2N_uMU.js} +2 -2
  40. package/lib/public/assets/{input-DPn63eF4.js → input-D2DBpxK2.js} +1 -1
  41. package/lib/public/assets/{keycodes-BYgvJDTE.js → keycodes-BQuIOMw0.js} +1 -1
  42. package/lib/public/assets/{layers-DJZQUOez.js → layers-DxtoRlvi.js} +1 -1
  43. package/lib/public/assets/{list-checks-YM2_VNMv.js → list-checks-jQ7YUgdE.js} +1 -1
  44. package/lib/public/assets/{lock-Cy-A3o47.js → lock-WHSsj2dj.js} +1 -1
  45. package/lib/public/assets/{login-CRZ2J7Up.js → login-BB_xyUWw.js} +1 -1
  46. package/lib/public/assets/{maintenance-settings-4HkUk10V.js → maintenance-settings-Dwadp8IQ.js} +1 -1
  47. package/lib/public/assets/{monitor-Cyvro78e.js → monitor-DIWQjUlh.js} +1 -1
  48. package/lib/public/assets/{mouse-pointer-2-26YTYUOD.js → mouse-pointer-2-D11Re9Gj.js} +1 -1
  49. package/lib/public/assets/{network-D-W1VWM_.js → network-BYBgSBjv.js} +1 -1
  50. package/lib/public/assets/{overview-pVmHrKhk.js → overview-BISAwoyi.js} +1 -1
  51. package/lib/public/assets/{page-header-DsTokPlH.js → page-header-ChX4YiMP.js} +1 -1
  52. package/lib/public/assets/{plus-OjIdQkG_.js → plus-BWNj1XSQ.js} +1 -1
  53. package/lib/public/assets/{profile-page-D0jy_p5L.js → profile-page-VZD3gRjV.js} +1 -1
  54. package/lib/public/assets/{reset-password-DptmkgSN.js → reset-password-Dz9LzFeX.js} +1 -1
  55. package/lib/public/assets/{runbook-page-9QcpCPUo.js → runbook-page-Bl2FodZf.js} +1 -1
  56. package/lib/public/assets/{select-CHKzkHre.js → select-Bs9CQu5Z.js} +1 -1
  57. package/lib/public/assets/{selector-detail-page-P5zDXMZC.js → selector-detail-page-By7ihhbY.js} +1 -1
  58. package/lib/public/assets/{selector-health-page-BHZfbxGg.js → selector-health-page-Y4CTbOLH.js} +1 -1
  59. package/lib/public/assets/{selector-health-Buz4eRW1.js → selector-health-xiBb8vNz.js} +1 -1
  60. package/lib/public/assets/{session-detail-page-JHU-GKSX.js → session-detail-page-DqeKRP6c.js} +1 -1
  61. package/lib/public/assets/{settings-CRTRggHW.js → settings-DDhV_Dnz.js} +1 -1
  62. package/lib/public/assets/{stat-tile-Be2twadZ.js → stat-tile-DMBTDbwt.js} +1 -1
  63. package/lib/public/assets/{tablet-C7QkOkHs.js → tablet-Cr5bOgng.js} +1 -1
  64. package/lib/public/assets/{teams-CwVhnDoL.js → teams-Bm0UQkR6.js} +1 -1
  65. package/lib/public/assets/{trash-2-yjlULOrf.js → trash-2-Di_lMeiP.js} +1 -1
  66. package/lib/public/assets/{upload-BcrYG7kK.js → upload-RvXDBmCX.js} +1 -1
  67. package/lib/public/assets/{use-password-reset-mode-BhRH2TTL.js → use-password-reset-mode-C7pUQ7xe.js} +1 -1
  68. package/lib/public/assets/{users-D3jRTB5K.js → users-CIILdbvy.js} +1 -1
  69. package/lib/public/assets/{users-BSZL_J7x.js → users-DZ7B5ePh.js} +1 -1
  70. package/lib/public/assets/{video-off-BwbiJtmA.js → video-off-aYil8B-X.js} +1 -1
  71. package/lib/public/assets/{webhook-settings-CTIdd7DN.js → webhook-settings-17JYW1ZR.js} +1 -1
  72. package/lib/public/assets/{zap-CZK3Cbup.js → zap-D9hchm-a.js} +1 -1
  73. package/lib/public/index.html +1 -1
  74. package/lib/src/device-managers/ios/IOSStreamService.js +4 -0
  75. package/lib/src/device-managers/ios/IOSTunnels.js +103 -19
  76. package/lib/src/device-managers/ios/WDAClient.js +18 -4
  77. package/lib/test/unit/ios-go-ios-tunnel-env.spec.js +38 -1
  78. package/lib/test/unit/ios-stream-appium-tunnel.spec.js +10 -0
  79. package/lib/test/unit/ios-tunnels.spec.js +66 -0
  80. package/package.json +1 -1
@@ -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.TUNNEL_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
  /**
@@ -69,6 +69,13 @@ exports.TUNNEL_READY_POLL_MS = 500;
69
69
  exports.TUNNEL_LEASE_TTL_MS = 90 * 60 * 1000;
70
70
  /** How often running tunnels are checked for a phone that went away. */
71
71
  exports.TUNNEL_CHECK_MS = 5000;
72
+ /**
73
+ * How long a tunnel opened for screenshots stays after the last one: long
74
+ * enough that a client taking screenshots in a loop pays the start once.
75
+ */
76
+ exports.TUNNEL_BORROW_IDLE_MS = 2 * 60 * 1000;
77
+ /** How long a phone's iOS version is trusted before `ios info` is asked again. */
78
+ const VERSION_CACHE_MS = 10 * 60 * 1000;
72
79
  /** The iOS version `ios info` reports, as a number: 17.2 for "17.2.1" or "iOS 17.2", 0 for none. */
73
80
  function iosVersionOf(info) {
74
81
  const versionStr = String(info.ProductVersion || info.HumanReadableProductVersionString || '0');
@@ -118,29 +125,48 @@ let IOSTunnels = class IOSTunnels {
118
125
  this.log = logger_1.default.scope('IOSTunnels');
119
126
  this.tunnels = new Map();
120
127
  this.checking = false;
128
+ this.versionCache = new Map();
121
129
  this.goIOSPath = path_1.default.join((0, helpers_1.cachePath)('goIOS'), 'ios');
122
130
  }
123
131
  /**
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.
132
+ * The phone's tunnel-info port for its stream, starting its tunnel if it
133
+ * has none. A tunnel opened for screenshots is taken over: from then on
134
+ * only the stream's stop ends it. Null for a phone below iOS 17, or whose
135
+ * version can't be read: it needs no tunnel. Throws when no pair of ports
136
+ * is left, or when the tunnel exits before it is ready.
128
137
  */
129
138
  ensure(udid) {
130
139
  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;
140
+ return this.open(udid, 'stream');
141
+ });
142
+ }
143
+ /**
144
+ * The phone's tunnel-info port for a screenshot, which on iOS 17+ is
145
+ * reached only through the tunnel. A phone that isn't streaming has none,
146
+ * so one is started; it stays while screenshots keep coming and stops
147
+ * {@link TUNNEL_BORROW_IDLE_MS} after the last one (checkTunnels). Null and
148
+ * throws as ensure.
149
+ */
150
+ borrow(udid) {
151
+ return __awaiter(this, void 0, void 0, function* () {
152
+ return this.open(udid, 'screenshot');
153
+ });
154
+ }
155
+ /** Whether the phone's tunnel was opened for screenshots and no stream has taken it. */
156
+ isOnDemand(udid) {
157
+ const tunnel = this.tunnels.get(udid);
158
+ return !!tunnel && tunnel.process.exitCode === null && !tunnel.streamOwned;
159
+ }
160
+ open(udid, use) {
161
+ return __awaiter(this, void 0, void 0, function* () {
162
+ const running = this.tunnels.get(udid);
163
+ if (running && running.process.exitCode === null) {
164
+ this.claim(running, use);
165
+ return running.whenReady;
142
166
  }
143
- if (version < 17)
167
+ // A screenshot also asks on a simulator, which has no version to read.
168
+ const version = yield this.versionOf(udid, use === 'screenshot');
169
+ if (version === null || version < 17)
144
170
  return null;
145
171
  const port = yield this.allocator().acquirePair('tunnel', udid, {
146
172
  ttlMs: exports.TUNNEL_LEASE_TTL_MS,
@@ -161,7 +187,15 @@ let IOSTunnels = class IOSTunnels {
161
187
  yield this.releasePair(port, udid);
162
188
  throw e;
163
189
  }
164
- const tunnel = { port, process: proc, ready: false };
190
+ const tunnel = {
191
+ port,
192
+ process: proc,
193
+ ready: false,
194
+ whenReady: Promise.resolve(port),
195
+ streamOwned: false,
196
+ borrowedUntil: 0,
197
+ };
198
+ this.claim(tunnel, use);
165
199
  this.tunnels.set(udid, tunnel);
166
200
  this.watch();
167
201
  // A tunnel whose process ends (go-ios crashed, or something killed it)
@@ -180,7 +214,22 @@ let IOSTunnels = class IOSTunnels {
180
214
  ended();
181
215
  });
182
216
  this.logOutput(udid, tunnel);
183
- this.log.info(`[${udid}] iOS ${version}: starting a go-ios tunnel on ${port} (traffic on ${port + 1})`);
217
+ this.log.info(`[${udid}] iOS ${version}: starting a go-ios tunnel on ${port} (traffic on ${port + 1})` +
218
+ (use === 'screenshot' ? ' for screenshots' : ''));
219
+ // Whoever asks while it starts waits for the same start.
220
+ tunnel.whenReady = this.waitUntilReady(udid, tunnel);
221
+ return tunnel.whenReady;
222
+ });
223
+ }
224
+ claim(tunnel, use) {
225
+ if (use === 'stream')
226
+ tunnel.streamOwned = true;
227
+ else
228
+ tunnel.borrowedUntil = this.now() + exports.TUNNEL_BORROW_IDLE_MS;
229
+ }
230
+ waitUntilReady(udid, tunnel) {
231
+ return __awaiter(this, void 0, void 0, function* () {
232
+ const { port, process: proc } = tunnel;
184
233
  const polls = exports.TUNNEL_READY_TIMEOUT_MS / exports.TUNNEL_READY_POLL_MS;
185
234
  for (let poll = 0; poll <= polls; poll++) {
186
235
  if (poll > 0)
@@ -206,6 +255,33 @@ let IOSTunnels = class IOSTunnels {
206
255
  return port;
207
256
  });
208
257
  }
258
+ /**
259
+ * The phone's iOS version, from `ios info`, kept for VERSION_CACHE_MS: a
260
+ * screenshot on a phone below iOS 17 would otherwise ask every time. Null
261
+ * when it can't be read (not cached, so a phone still pairing is asked
262
+ * again); logged at debug when `quiet`.
263
+ */
264
+ versionOf(udid_1) {
265
+ return __awaiter(this, arguments, void 0, function* (udid, quiet = false) {
266
+ var _a;
267
+ const cached = this.versionCache.get(udid);
268
+ if (cached && this.now() - cached.at < VERSION_CACHE_MS)
269
+ return cached.version;
270
+ try {
271
+ const version = yield this.iosVersion(udid);
272
+ this.versionCache.set(udid, { version, at: this.now() });
273
+ return version;
274
+ }
275
+ catch (e) {
276
+ 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}`;
277
+ if (quiet)
278
+ this.log.debug(message);
279
+ else
280
+ this.log.warn(message);
281
+ return null;
282
+ }
283
+ });
284
+ }
209
285
  /** The phone's tunnel-info port, while its tunnel runs. */
210
286
  portFor(udid) {
211
287
  const tunnel = this.tunnels.get(udid);
@@ -253,6 +329,11 @@ let IOSTunnels = class IOSTunnels {
253
329
  this.checking = true;
254
330
  try {
255
331
  for (const [udid, tunnel] of [...this.tunnels]) {
332
+ if (!tunnel.streamOwned && tunnel.borrowedUntil <= this.now()) {
333
+ this.log.info(`[${udid}] go-ios tunnel on ${tunnel.port}, opened for screenshots, is idle; stopping it`);
334
+ yield this.stop(udid);
335
+ continue;
336
+ }
256
337
  const info = yield this.tunnelInfo(tunnel.port, udid);
257
338
  if (this.tunnels.get(udid) !== tunnel)
258
339
  continue;
@@ -400,6 +481,9 @@ let IOSTunnels = class IOSTunnels {
400
481
  req.on('error', () => resolve({ state: 'unknown' }));
401
482
  });
402
483
  }
484
+ now() {
485
+ return Date.now();
486
+ }
403
487
  sleep(ms) {
404
488
  return new Promise((resolve) => setTimeout(resolve, ms));
405
489
  }
@@ -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) {
@@ -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;
@@ -198,6 +198,7 @@ describe('go-ios tunnels under a live Appium session (fake exec, fake processes)
198
198
  envFor: sinon_1.default.stub().callsFake(() => (Object.assign(Object.assign({}, process.env), { ENABLE_GO_IOS_AGENT: 'yes' }))),
199
199
  stop: sinon_1.default.stub().resolves(),
200
200
  touch: sinon_1.default.stub().resolves(),
201
+ isOnDemand: sinon_1.default.stub().returns(false),
201
202
  };
202
203
  svc = iosService();
203
204
  const real = typedi_1.Container.get.bind(typedi_1.Container);
@@ -363,6 +364,15 @@ describe('go-ios tunnels under a live Appium session (fake exec, fake processes)
363
364
  (0, chai_1.expect)(defaultPorts()).to.deep.equal([]);
364
365
  }));
365
366
  });
367
+ describe('a start on a phone whose tunnel was opened for screenshots', () => {
368
+ it('takes that tunnel over, neither stopping nor reaping it', () => __awaiter(void 0, void 0, void 0, function* () {
369
+ Object.assign(device, { busy: false, session_id: null });
370
+ tunnels.isOnDemand.withArgs(IPHONE).returns(true);
371
+ yield svc.cleanupOrphanTunnels(IPHONE);
372
+ (0, chai_1.expect)(tunnels.stop.called, 'the tracked tunnel').to.equal(false);
373
+ (0, chai_1.expect)(swept(), 'no pgrep reap of its process').to.deep.equal([]);
374
+ }));
375
+ });
366
376
  describe('boot', () => {
367
377
  it('reaps every go-ios process, then drops every tunnel port lease', () => __awaiter(void 0, void 0, void 0, function* () {
368
378
  yield svc.reapOrphanTunnels();
@@ -80,10 +80,16 @@ class TestTunnels extends IOSTunnels_1.IOSTunnels {
80
80
  /** Each tunnel-info poll: true is the phone's tunnel on P + 1, false a 404. */
81
81
  this.answers = () => true;
82
82
  this.ports = fakeAllocator();
83
+ this.clock = 1000000;
84
+ this.versionReads = 0;
83
85
  this.nextPid = 7000;
84
86
  }
87
+ now() {
88
+ return this.clock;
89
+ }
85
90
  iosVersion(udid) {
86
91
  return __awaiter(this, void 0, void 0, function* () {
92
+ this.versionReads += 1;
87
93
  const version = this.versions.get(udid);
88
94
  if (version === undefined)
89
95
  throw new Error(`no such phone: ${udid}`);
@@ -332,6 +338,66 @@ describe('IOSTunnels: a phone that goes away (go-ios keeps its agent running)',
332
338
  }
333
339
  }));
334
340
  });
341
+ // A screenshot on iOS 17+ goes through the phone's tunnel, and a phone that
342
+ // isn't streaming has none: one is opened for screenshots and kept while they
343
+ // keep coming.
344
+ describe('IOSTunnels: a tunnel opened for screenshots', () => {
345
+ let t;
346
+ beforeEach(() => {
347
+ sinon_1.default.stub(process, 'kill');
348
+ t = new TestTunnels();
349
+ t.versions.set(PHONE_A, 17.4);
350
+ });
351
+ afterEach(() => __awaiter(void 0, void 0, void 0, function* () {
352
+ yield t.stop(PHONE_A);
353
+ sinon_1.default.restore();
354
+ }));
355
+ it('opens one when the phone has none, and the next screenshot reuses it', () => __awaiter(void 0, void 0, void 0, function* () {
356
+ (0, chai_1.expect)(yield t.borrow(PHONE_A)).to.equal(12100);
357
+ (0, chai_1.expect)(yield t.borrow(PHONE_A)).to.equal(12100);
358
+ (0, chai_1.expect)(t.spawned.length).to.equal(1);
359
+ (0, chai_1.expect)(t.envFor(PHONE_A).GO_IOS_AGENT_PORT).to.equal('12100');
360
+ }));
361
+ it('stops it 2 minutes after the last screenshot, and gives its ports back', () => __awaiter(void 0, void 0, void 0, function* () {
362
+ yield t.borrow(PHONE_A);
363
+ t.clock += IOSTunnels_1.TUNNEL_BORROW_IDLE_MS - 1000;
364
+ yield t.borrow(PHONE_A); // another screenshot: the 2 minutes start again
365
+ t.clock += IOSTunnels_1.TUNNEL_BORROW_IDLE_MS - 1000;
366
+ yield t.checkTunnels();
367
+ (0, chai_1.expect)(t.portFor(PHONE_A), 'last used 119 s ago').to.equal(12100);
368
+ t.clock += 2000;
369
+ yield t.checkTunnels();
370
+ yield settle();
371
+ (0, chai_1.expect)(t.portFor(PHONE_A)).to.equal(undefined);
372
+ (0, chai_1.expect)(t.killed).to.deep.equal([t.spawned[0].proc.pid]);
373
+ (0, chai_1.expect)([...t.ports.leased.keys()]).to.deep.equal([]);
374
+ }));
375
+ it('never stops one a stream took over', () => __awaiter(void 0, void 0, void 0, function* () {
376
+ yield t.borrow(PHONE_A);
377
+ (0, chai_1.expect)(t.isOnDemand(PHONE_A)).to.equal(true);
378
+ (0, chai_1.expect)(yield t.ensure(PHONE_A), 'the stream takes it').to.equal(12100);
379
+ (0, chai_1.expect)(t.isOnDemand(PHONE_A)).to.equal(false);
380
+ t.clock += 10 * IOSTunnels_1.TUNNEL_BORROW_IDLE_MS;
381
+ yield t.checkTunnels();
382
+ (0, chai_1.expect)(t.portFor(PHONE_A)).to.equal(12100);
383
+ (0, chai_1.expect)(t.spawned.length).to.equal(1);
384
+ }));
385
+ it("never stops a stream's own tunnel for being idle", () => __awaiter(void 0, void 0, void 0, function* () {
386
+ yield t.ensure(PHONE_A);
387
+ yield t.borrow(PHONE_A); // a screenshot while the stream runs
388
+ t.clock += 10 * IOSTunnels_1.TUNNEL_BORROW_IDLE_MS;
389
+ yield t.checkTunnels();
390
+ (0, chai_1.expect)(t.portFor(PHONE_A)).to.equal(12100);
391
+ (0, chai_1.expect)(t.isOnDemand(PHONE_A)).to.equal(false);
392
+ }));
393
+ it('opens none for a phone below iOS 17, and reads its version once', () => __awaiter(void 0, void 0, void 0, function* () {
394
+ t.versions.set('old-iphone', 16.7);
395
+ (0, chai_1.expect)(yield t.borrow('old-iphone')).to.equal(null);
396
+ (0, chai_1.expect)(yield t.borrow('old-iphone')).to.equal(null);
397
+ (0, chai_1.expect)(t.versionReads).to.equal(1);
398
+ (0, chai_1.expect)(t.spawned).to.deep.equal([]);
399
+ }));
400
+ });
335
401
  describe('goIosReason', () => {
336
402
  it("reads go-ios's last error or warning", () => {
337
403
  (0, chai_1.expect)((0, IOSTunnels_1.goIosReason)('{"level":"INFO","msg":"Tunnel server started"}\n')).to.equal(undefined);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xenon-device-management/xenon",
3
- "version": "2.8.3",
3
+ "version": "2.9.0",
4
4
  "description": "Xenon - Intelligent Mobile Infrastructure. A self-healing device orchestration platform for Appium.",
5
5
  "main": "./lib/src/index.js",
6
6
  "exports": {