@xenon-device-management/xenon 2.7.0 → 2.8.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 (86) hide show
  1. package/lib/package.json +1 -1
  2. package/lib/public/assets/{AnnotationOverlay-oISPBknJ.js → AnnotationOverlay-XmDGA9CJ.js} +1 -1
  3. package/lib/public/assets/{ApiKeyGate-BOm-SKAJ.js → ApiKeyGate-BilRCzB-.js} +1 -1
  4. package/lib/public/assets/{BugReportButton-C1WMf_3A.js → BugReportButton-_buEV98z.js} +1 -1
  5. package/lib/public/assets/{DeviceMosaicView-BPJ5n5fY.js → DeviceMosaicView-DTEiC4gk.js} +1 -1
  6. package/lib/public/assets/{EmptyState-CZg80GnN.js → EmptyState-BhQfPUga.js} +1 -1
  7. package/lib/public/assets/{FieldGroup-D7zftz-q.js → FieldGroup-VPsU3LwN.js} +1 -1
  8. package/lib/public/assets/{FilterMenu-BKHKlX-8.js → FilterMenu-Br9o7dBS.js} +1 -1
  9. package/lib/public/assets/{Menu-Ccznqijj.js → Menu-dF_HlxcB.js} +1 -1
  10. package/lib/public/assets/{Modal-FxdOVK12.js → Modal-51305CEc.js} +1 -1
  11. package/lib/public/assets/{RecordingPage-BL8B1IKY.js → RecordingPage-BYQfiIEd.js} +1 -1
  12. package/lib/public/assets/{RecordingsPage-CCCL2xwa.js → RecordingsPage-C7A2ZMU_.js} +1 -1
  13. package/lib/public/assets/{SegmentedControl-B3EMys8L.js → SegmentedControl--_Zp_x-E.js} +1 -1
  14. package/lib/public/assets/{SettingCard-BwvdX-C6.js → SettingCard-DKlLEbu9.js} +1 -1
  15. package/lib/public/assets/{Table-sGNs22pM.js → Table-mknFnLyL.js} +1 -1
  16. package/lib/public/assets/{activity-rQQOy6SD.js → activity-D4657Amj.js} +1 -1
  17. package/lib/public/assets/{ai-settings-uIrII3el.js → ai-settings-_sXFJMDz.js} +1 -1
  18. package/lib/public/assets/{api-keys-B1-QmrZJ.js → api-keys-BV7AQW2N.js} +1 -1
  19. package/lib/public/assets/{apps-B8gYjYhN.js → apps-D-qPk58H.js} +1 -1
  20. package/lib/public/assets/{arrow-left-CLT_rU6o.js → arrow-left-B6xZI-lv.js} +1 -1
  21. package/lib/public/assets/{arrow-right-DqK8eHkB.js → arrow-right-KOusXQT_.js} +1 -1
  22. package/lib/public/assets/{arrow-up-right-D96XxKQY.js → arrow-up-right--y5SjylA.js} +1 -1
  23. package/lib/public/assets/{auth-shell-DpBFmfin.js → auth-shell-BOGfjiRi.js} +2 -2
  24. package/lib/public/assets/{builds-page-DqD7ySkz.js → builds-page-CgobTBVN.js} +1 -1
  25. package/lib/public/assets/{button-BrVVNvSB.js → button-Ci58y6Ou.js} +1 -1
  26. package/lib/public/assets/{calendar-FX_no0De.js → calendar-BYLnN-JG.js} +1 -1
  27. package/lib/public/assets/{check-C6RAxVq9.js → check-BZFRUKhB.js} +1 -1
  28. package/lib/public/assets/{chevron-left-CcTJUfpS.js → chevron-left-C-olFL5E.js} +1 -1
  29. package/lib/public/assets/{chevron-right-C39WUDQP.js → chevron-right-CmxzLyEE.js} +1 -1
  30. package/lib/public/assets/{circle-check-Fo1eEzZe.js → circle-check-Duvlh9lf.js} +1 -1
  31. package/lib/public/assets/{circle-x-CXy5XzIu.js → circle-x-DoQgpxKQ.js} +1 -1
  32. package/lib/public/assets/{clock-PBGPHMrI.js → clock-yvaEQMYo.js} +1 -1
  33. package/lib/public/assets/{copy-ZGa-scbM.js → copy-CIcmMH4s.js} +1 -1
  34. package/lib/public/assets/{device-explorer-BRYO7iUM.js → device-explorer-Cf-d-udL.js} +1 -1
  35. package/lib/public/assets/{download-vaODl7Bn.js → download-Ck75G4Sr.js} +1 -1
  36. package/lib/public/assets/{forgot-password-YfGbiIOM.js → forgot-password-DcOi08XK.js} +1 -1
  37. package/lib/public/assets/{funnel-C05BIqke.js → funnel-CafvAmsz.js} +1 -1
  38. package/lib/public/assets/{index-DdsuniRs.js → index-9Npot8Nw.js} +2 -2
  39. package/lib/public/assets/{index-1ST_YG3o.js → index-QT33_ARg.js} +1 -1
  40. package/lib/public/assets/{input-C6Zp2Ibb.js → input-DUA3Tk33.js} +1 -1
  41. package/lib/public/assets/{keycodes-BlkuYXfQ.js → keycodes-Dj17elyp.js} +1 -1
  42. package/lib/public/assets/{layers-B-3IDp3k.js → layers-5SyoCGaK.js} +1 -1
  43. package/lib/public/assets/{list-checks-BUUYV0Vf.js → list-checks-Blsk6Sts.js} +1 -1
  44. package/lib/public/assets/{lock-BUdNqIao.js → lock-DUyg9vcf.js} +1 -1
  45. package/lib/public/assets/{login-CD8bag9i.js → login-DWRTaaNU.js} +1 -1
  46. package/lib/public/assets/{maintenance-settings-BWQP_nfK.js → maintenance-settings-CwthiKtl.js} +1 -1
  47. package/lib/public/assets/{monitor-qfb5f9oQ.js → monitor-D7kOVM-I.js} +1 -1
  48. package/lib/public/assets/{mouse-pointer-2-DAAlUY2l.js → mouse-pointer-2-BzwITYs-.js} +1 -1
  49. package/lib/public/assets/{network-Qib4-4Yj.js → network-3c26VFLV.js} +1 -1
  50. package/lib/public/assets/{overview-BZQf20qe.js → overview-BamopQBx.js} +1 -1
  51. package/lib/public/assets/{page-header-4jNxdU1M.js → page-header-Cr568Bj2.js} +1 -1
  52. package/lib/public/assets/{plus-DVhPNSQy.js → plus-egFVGGUW.js} +1 -1
  53. package/lib/public/assets/{profile-page-DfITRDDC.js → profile-page-BetCnNKO.js} +1 -1
  54. package/lib/public/assets/{reset-password-s2xU05oa.js → reset-password-BgjuXPN-.js} +1 -1
  55. package/lib/public/assets/{runbook-page-DdEiXQcm.js → runbook-page-BbAREoE4.js} +1 -1
  56. package/lib/public/assets/{select-Bzs3ZUvb.js → select-BfZw_3u2.js} +1 -1
  57. package/lib/public/assets/{selector-detail-page-QvRRVchI.js → selector-detail-page-BqswNrpr.js} +1 -1
  58. package/lib/public/assets/{selector-health-DwUFxrZx.js → selector-health-Cbdio_9h.js} +1 -1
  59. package/lib/public/assets/{selector-health-page-N0tA9q9c.js → selector-health-page-qdM7e6Lx.js} +1 -1
  60. package/lib/public/assets/{session-detail-page-DcGGXXxL.js → session-detail-page-DFnhbs82.js} +1 -1
  61. package/lib/public/assets/{settings-BUqAjzeY.js → settings-Bg81ksOI.js} +1 -1
  62. package/lib/public/assets/{stat-tile-BwZr7q7_.js → stat-tile-CCR1O7iE.js} +1 -1
  63. package/lib/public/assets/{tablet-DiOf-kLM.js → tablet-DAHOR-iq.js} +1 -1
  64. package/lib/public/assets/{teams-DA6AfWGO.js → teams-Bz13DBCK.js} +1 -1
  65. package/lib/public/assets/{trash-2-BZuuog-n.js → trash-2-BgqAVxUr.js} +1 -1
  66. package/lib/public/assets/{upload-DLHV2k4Z.js → upload-Rvl2CCKn.js} +1 -1
  67. package/lib/public/assets/{use-password-reset-mode-DRY4lLdd.js → use-password-reset-mode--PRWElm2.js} +1 -1
  68. package/lib/public/assets/{users-BlhcXeTG.js → users-BRkxjzNQ.js} +1 -1
  69. package/lib/public/assets/{users-Bd4LKBWY.js → users-O123SAYx.js} +1 -1
  70. package/lib/public/assets/{video-off-DIaDZGNl.js → video-off-DkNxxAWE.js} +1 -1
  71. package/lib/public/assets/{webhook-settings-IWvqbRk2.js → webhook-settings-D6UmShyI.js} +1 -1
  72. package/lib/public/assets/{zap-4Qy4hVbF.js → zap-D6UAiMKL.js} +1 -1
  73. package/lib/public/index.html +1 -1
  74. package/lib/src/device-managers/ios/IOSLogStreamService.js +6 -3
  75. package/lib/src/device-managers/ios/IOSStreamService.js +38 -122
  76. package/lib/src/device-managers/ios/IOSTunnels.js +376 -0
  77. package/lib/src/device-managers/ios/WDAClient.js +5 -2
  78. package/lib/src/services/PortAllocator.js +65 -0
  79. package/lib/test/unit/ios-go-ios-tunnel-env.spec.js +154 -0
  80. package/lib/test/unit/ios-stream-appium-tunnel.spec.js +122 -32
  81. package/lib/test/unit/ios-stream-port-leases.spec.js +26 -5
  82. package/lib/test/unit/ios-tunnels.spec.js +387 -0
  83. package/lib/test/unit/port-allocator-pairs.spec.js +110 -0
  84. package/lib/test/unit/stream-own-device-row.spec.js +1 -1
  85. package/lib/tsconfig.tsbuildinfo +1 -1
  86. package/package.json +1 -1
@@ -0,0 +1,376 @@
1
+ "use strict";
2
+ var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
3
+ var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
4
+ if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
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;
6
+ return c > 3 && r && Object.defineProperty(target, key, r), r;
7
+ };
8
+ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
9
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
10
+ return new (P || (P = Promise))(function (resolve, reject) {
11
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
12
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
13
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
14
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
15
+ });
16
+ };
17
+ var __importDefault = (this && this.__importDefault) || function (mod) {
18
+ return (mod && mod.__esModule) ? mod : { "default": mod };
19
+ };
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;
22
+ exports.iosVersionOf = iosVersionOf;
23
+ /**
24
+ * One go-ios tunnel per iOS 17+ phone, each on its own leased pair of ports.
25
+ *
26
+ * On iOS 17 and later, the go-ios commands Xenon runs against a phone's
27
+ * services (`runwda`, `ostrace`, `syslog`, `screenshot`) reach it through a
28
+ * tunnel, which they find through that tunnel's info API. Through 2.7 every
29
+ * tunnel took go-ios's default ports (60105 for the API, 60106 for the
30
+ * traffic), so a second iPhone's stream killed the first one's tunnel.
31
+ *
32
+ * Each phone now gets `tunnel start --udid <phone> --tunnel-info-port <P>`,
33
+ * an isolated per-device agent: P from the `tunnel` port range, and P + 1,
34
+ * which go-ios derives for the phone's traffic. A command finds its phone's
35
+ * tunnel through GO_IOS_AGENT_PORT, set by {@link IOSTunnels.envFor}.
36
+ *
37
+ * go-ios does not end an agent when its phone is unplugged. The agent only
38
+ * drops the phone's tunnel, and on the replug starts a new one on its next
39
+ * traffic port, P + 2, then P + 3: the next phone's leased pair. So a tunnel
40
+ * whose phone went away, or whose traffic port moved, is stopped
41
+ * ({@link IOSTunnels.checkTunnels}, every few seconds), and the phone's next
42
+ * stream start makes a new one on a fresh pair.
43
+ *
44
+ * This is mechanism only. When a tunnel may be stopped (never under a live
45
+ * Appium session) is IOSStreamService's decision.
46
+ */
47
+ const typedi_1 = require("typedi");
48
+ const child_process_1 = require("child_process");
49
+ const http_1 = __importDefault(require("http"));
50
+ const path_1 = __importDefault(require("path"));
51
+ const util_1 = require("util");
52
+ const logger_1 = __importDefault(require("../../logger"));
53
+ const helpers_1 = require("../../helpers");
54
+ const PortAllocator_1 = require("../../services/PortAllocator");
55
+ const ProcessRegistry_1 = require("../../services/ProcessRegistry");
56
+ const ResourceIsolationService_1 = require("../../services/ResourceIsolationService");
57
+ const iosStreamDiagnostics_1 = require("./iosStreamDiagnostics");
58
+ const tunnelProcess_1 = require("./tunnelProcess");
59
+ const execPromise = (0, util_1.promisify)(child_process_1.exec);
60
+ /** How long a new tunnel has to answer for its phone before the start goes on without it. */
61
+ exports.TUNNEL_READY_TIMEOUT_MS = 20000;
62
+ exports.TUNNEL_READY_POLL_MS = 500;
63
+ /**
64
+ * A tunnel's port lease: the same as the stream's own ports
65
+ * (IOSStreamService's STREAM_PORT_TTL_MS). The stream watchdog refreshes both
66
+ * every hour, the tunnel's through {@link IOSTunnels.touch}.
67
+ */
68
+ exports.TUNNEL_LEASE_TTL_MS = 90 * 60 * 1000;
69
+ /** How often running tunnels are checked for a phone that went away. */
70
+ exports.TUNNEL_CHECK_MS = 5000;
71
+ /** The iOS version `ios info` reports, as a number: 17.2 for "17.2.1" or "iOS 17.2", 0 for none. */
72
+ function iosVersionOf(info) {
73
+ const versionStr = String(info.ProductVersion || info.HumanReadableProductVersionString || '0');
74
+ const match = versionStr.match(/(\d+\.?\d*)/);
75
+ return match ? parseFloat(match[0]) : 0;
76
+ }
77
+ /** An answer that puts the phone's traffic anywhere but its leased P + 1. */
78
+ function moved(info, port) {
79
+ return info.state === 'up' && info.userspacePort !== undefined && info.userspacePort !== port + 1;
80
+ }
81
+ let IOSTunnels = class IOSTunnels {
82
+ constructor() {
83
+ this.log = logger_1.default.scope('IOSTunnels');
84
+ this.tunnels = new Map();
85
+ this.checking = false;
86
+ this.goIOSPath = path_1.default.join((0, helpers_1.cachePath)('goIOS'), 'ios');
87
+ }
88
+ /**
89
+ * The phone's tunnel-info port, starting its tunnel if it has none. Null
90
+ * for a phone below iOS 17, or whose version can't be read: it needs no
91
+ * tunnel. Throws when no pair of ports is left, or when the tunnel exits
92
+ * before it is ready.
93
+ */
94
+ ensure(udid) {
95
+ return __awaiter(this, void 0, void 0, function* () {
96
+ var _a;
97
+ const running = this.portFor(udid);
98
+ if (running !== undefined)
99
+ return running;
100
+ let version;
101
+ try {
102
+ version = yield this.iosVersion(udid);
103
+ }
104
+ catch (e) {
105
+ 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}`);
106
+ return null;
107
+ }
108
+ if (version < 17)
109
+ return null;
110
+ const port = yield this.allocator().acquirePair('tunnel', udid, {
111
+ ttlMs: exports.TUNNEL_LEASE_TTL_MS,
112
+ });
113
+ let proc;
114
+ try {
115
+ proc = this.spawnTunnel(udid, [
116
+ 'tunnel',
117
+ 'start',
118
+ '--udid',
119
+ udid,
120
+ '--userspace',
121
+ '--tunnel-info-port',
122
+ String(port),
123
+ ]);
124
+ }
125
+ catch (e) {
126
+ yield this.releasePair(port, udid);
127
+ throw e;
128
+ }
129
+ const tunnel = { port, process: proc, ready: false };
130
+ this.tunnels.set(udid, tunnel);
131
+ this.watch();
132
+ // A tunnel whose process ends (go-ios crashed, or something killed it)
133
+ // gives its ports back, and the phone's next start gets a new one. An
134
+ // unplug doesn't end it: see checkTunnels.
135
+ const ended = () => {
136
+ if (this.tunnels.get(udid) !== tunnel)
137
+ return;
138
+ this.tunnels.delete(udid);
139
+ this.unwatchIfIdle();
140
+ void this.releasePair(port, udid);
141
+ };
142
+ proc.once('exit', ended);
143
+ proc.once('error', (err) => {
144
+ this.log.warn(`[${udid}] go-ios tunnel failed: ${err.message}`);
145
+ ended();
146
+ });
147
+ this.logOutput(udid, proc);
148
+ this.log.info(`[${udid}] iOS ${version}: starting a go-ios tunnel on ${port} (traffic on ${port + 1})`);
149
+ const polls = exports.TUNNEL_READY_TIMEOUT_MS / exports.TUNNEL_READY_POLL_MS;
150
+ for (let poll = 0; poll <= polls; poll++) {
151
+ if (poll > 0)
152
+ yield this.sleep(exports.TUNNEL_READY_POLL_MS);
153
+ if (this.tunnels.get(udid) !== tunnel) {
154
+ throw new Error(`The go-ios tunnel for ${udid} exited before it was ready (exit code ${proc.exitCode})`);
155
+ }
156
+ const info = yield this.tunnelInfo(port, udid);
157
+ if (moved(info, port)) {
158
+ // A first attempt failed and go-ios retried on its next port, which
159
+ // may be another phone's.
160
+ yield this.stop(udid);
161
+ throw new Error(`The go-ios tunnel for ${udid} came up on port ${info.userspacePort}, not its leased ${port + 1}, so it was stopped`);
162
+ }
163
+ if (info.state === 'up') {
164
+ tunnel.ready = true;
165
+ this.log.info(`[${udid}] go-ios tunnel ready on ${port}`);
166
+ return port;
167
+ }
168
+ }
169
+ this.log.warn(`[${udid}] go-ios tunnel on ${port} not ready after ${exports.TUNNEL_READY_TIMEOUT_MS / 1000}s, proceeding anyway`);
170
+ return port;
171
+ });
172
+ }
173
+ /** The phone's tunnel-info port, while its tunnel runs. */
174
+ portFor(udid) {
175
+ const tunnel = this.tunnels.get(udid);
176
+ return tunnel && tunnel.process.exitCode === null ? tunnel.port : undefined;
177
+ }
178
+ /**
179
+ * The environment for a go-ios command about this phone: today's, plus
180
+ * GO_IOS_AGENT_PORT while the phone has a tunnel, so the command finds that
181
+ * tunnel and not go-ios's default 60105.
182
+ */
183
+ envFor(udid) {
184
+ const env = Object.assign(Object.assign({}, process.env), { ENABLE_GO_IOS_AGENT: 'yes' });
185
+ const port = this.portFor(udid);
186
+ if (port !== undefined)
187
+ env.GO_IOS_AGENT_PORT = String(port);
188
+ return env;
189
+ }
190
+ /** Stop the phone's tunnel and give its ports back. Other phones' tunnels are untouched. */
191
+ stop(udid) {
192
+ return __awaiter(this, void 0, void 0, function* () {
193
+ const tunnel = this.tunnels.get(udid);
194
+ if (!tunnel)
195
+ return;
196
+ this.tunnels.delete(udid);
197
+ this.unwatchIfIdle();
198
+ this.killGroup(tunnel.process.pid);
199
+ yield this.releasePair(tunnel.port, udid);
200
+ });
201
+ }
202
+ /**
203
+ * Stop each tunnel whose phone has gone, and give its ports back. go-ios
204
+ * keeps a per-device agent running when its phone is unplugged and, on the
205
+ * replug, starts the tunnel on its next traffic port: the next phone's
206
+ * pair. So a tunnel that had its phone and now answers 404, or that answers
207
+ * on a traffic port other than P + 1, is stopped. Either way the phone's
208
+ * stream has already lost it, and its next start makes a new one.
209
+ *
210
+ * An agent that doesn't answer is left alone: a busy agent isn't a gone
211
+ * phone, and one whose process died is handled by its exit.
212
+ */
213
+ checkTunnels() {
214
+ return __awaiter(this, void 0, void 0, function* () {
215
+ if (this.checking)
216
+ return;
217
+ this.checking = true;
218
+ try {
219
+ for (const [udid, tunnel] of [...this.tunnels]) {
220
+ const info = yield this.tunnelInfo(tunnel.port, udid);
221
+ if (this.tunnels.get(udid) !== tunnel)
222
+ continue;
223
+ if (moved(info, tunnel.port)) {
224
+ this.log.warn(`[${udid}] go-ios moved the phone's tunnel from port ${tunnel.port + 1} to ${info.userspacePort} (it reconnected); stopping it`);
225
+ }
226
+ else if (info.state === 'gone' && tunnel.ready) {
227
+ this.log.warn(`[${udid}] go-ios tunnel on ${tunnel.port} lost its phone (unplugged?); stopping it and freeing its ports`);
228
+ }
229
+ else {
230
+ if (info.state === 'up')
231
+ tunnel.ready = true;
232
+ continue;
233
+ }
234
+ yield this.stop(udid);
235
+ }
236
+ }
237
+ finally {
238
+ this.checking = false;
239
+ }
240
+ });
241
+ }
242
+ watch() {
243
+ var _a, _b;
244
+ if (this.watcher)
245
+ return;
246
+ this.watcher = setInterval(() => void this.checkTunnels(), exports.TUNNEL_CHECK_MS);
247
+ (_b = (_a = this.watcher).unref) === null || _b === void 0 ? void 0 : _b.call(_a);
248
+ }
249
+ unwatchIfIdle() {
250
+ if (this.tunnels.size > 0 || !this.watcher)
251
+ return;
252
+ clearInterval(this.watcher);
253
+ this.watcher = undefined;
254
+ }
255
+ /** Keep the phone's tunnel ports leased. The stream watchdog calls this hourly. */
256
+ touch(udid) {
257
+ return __awaiter(this, void 0, void 0, function* () {
258
+ const tunnel = this.tunnels.get(udid);
259
+ if (!tunnel)
260
+ return;
261
+ const allocator = this.allocator();
262
+ yield allocator.touch(tunnel.port, exports.TUNNEL_LEASE_TTL_MS);
263
+ yield allocator.touch(tunnel.port + 1, exports.TUNNEL_LEASE_TTL_MS);
264
+ });
265
+ }
266
+ releasePair(port, udid) {
267
+ return __awaiter(this, void 0, void 0, function* () {
268
+ var _a;
269
+ try {
270
+ const allocator = this.allocator();
271
+ yield allocator.release(port, udid);
272
+ yield allocator.release(port + 1, udid);
273
+ }
274
+ catch (e) {
275
+ this.log.warn(`[${udid}] Could not release tunnel ports ${port}-${port + 1}: ${(_a = e === null || e === void 0 ? void 0 : e.message) !== null && _a !== void 0 ? _a : e}`);
276
+ }
277
+ });
278
+ }
279
+ /**
280
+ * go-ios's output, at debug. Its repeated warning about a connected
281
+ * pre-iOS 17 phone (which needs no tunnel) is logged once per phone.
282
+ */
283
+ logOutput(udid, proc) {
284
+ var _a, _b;
285
+ (_a = proc.stdout) === null || _a === void 0 ? void 0 : _a.on('data', (data) => this.log.debug(`Tunnel [${udid}]: ${data}`));
286
+ const loggedUnsupported = new Set();
287
+ (_b = proc.stderr) === null || _b === void 0 ? void 0 : _b.on('data', (data) => {
288
+ const text = String(data);
289
+ const { unsupported, udid: targetUdid } = (0, iosStreamDiagnostics_1.classifyTunnelStderr)(text);
290
+ if (unsupported) {
291
+ const key = targetUdid !== null && targetUdid !== void 0 ? targetUdid : 'unknown';
292
+ if (!loggedUnsupported.has(key)) {
293
+ loggedUnsupported.add(key);
294
+ this.log.debug(`Tunnel [${udid}]: device ${key} is pre-iOS 17 and needs no tunnel; suppressing repeated go-ios warnings`);
295
+ }
296
+ return;
297
+ }
298
+ this.log.debug(`Tunnel Err [${udid}]: ${text}`);
299
+ });
300
+ }
301
+ // ---------------------------------------------------------------------
302
+ // Seams. Overridden by tests; the defaults are the real thing.
303
+ // ---------------------------------------------------------------------
304
+ iosVersion(udid) {
305
+ return __awaiter(this, void 0, void 0, function* () {
306
+ const { stdout } = yield execPromise(`"${this.goIOSPath}" info --udid ${udid}`, {
307
+ env: Object.assign(Object.assign({}, process.env), { ENABLE_GO_IOS_AGENT: 'yes' }),
308
+ });
309
+ return iosVersionOf(JSON.parse(stdout));
310
+ });
311
+ }
312
+ /**
313
+ * Detached, so the tunnel leads its own process group and its self-forking
314
+ * agent children die with it (see ./tunnelProcess).
315
+ */
316
+ spawnTunnel(udid, args) {
317
+ const wrapped = typedi_1.Container.get(ResourceIsolationService_1.ResourceIsolationService).wrapSpawn(this.goIOSPath, args, 'Performance');
318
+ const proc = (0, child_process_1.spawn)(wrapped.command, wrapped.args, (0, tunnelProcess_1.tunnelSpawnOptions)(Object.assign(Object.assign({}, process.env), { ENABLE_GO_IOS_AGENT: 'yes' })));
319
+ typedi_1.Container.get(ProcessRegistry_1.ProcessRegistry).track({ kind: 'other', udid, process: proc });
320
+ return proc;
321
+ }
322
+ /**
323
+ * What the tunnel's agent on `port` says about the phone. go-ios answers
324
+ * `GET /tunnel/<udid>` with 404 until it has the phone's tunnel, then 200
325
+ * with the tunnel, `userspaceTunPort` included.
326
+ */
327
+ tunnelInfo(port, udid) {
328
+ return new Promise((resolve) => {
329
+ const req = http_1.default.get({
330
+ host: '127.0.0.1',
331
+ port,
332
+ path: `/tunnel/${encodeURIComponent(udid)}`,
333
+ timeout: 1000,
334
+ agent: false,
335
+ }, (res) => {
336
+ if (res.statusCode !== 200) {
337
+ res.resume();
338
+ resolve(res.statusCode === 404 ? { state: 'gone' } : { state: 'unknown' });
339
+ return;
340
+ }
341
+ let body = '';
342
+ res.setEncoding('utf8');
343
+ res.on('data', (chunk) => (body += chunk));
344
+ res.on('end', () => {
345
+ var _a;
346
+ let userspacePort;
347
+ try {
348
+ const value = (_a = JSON.parse(body)) === null || _a === void 0 ? void 0 : _a.userspaceTunPort;
349
+ if (typeof value === 'number')
350
+ userspacePort = value;
351
+ }
352
+ catch (_b) {
353
+ // A 200 without JSON still says the tunnel is up.
354
+ }
355
+ resolve({ state: 'up', userspacePort });
356
+ });
357
+ res.on('error', () => resolve({ state: 'unknown' }));
358
+ });
359
+ req.on('timeout', () => req.destroy());
360
+ req.on('error', () => resolve({ state: 'unknown' }));
361
+ });
362
+ }
363
+ sleep(ms) {
364
+ return new Promise((resolve) => setTimeout(resolve, ms));
365
+ }
366
+ killGroup(pid) {
367
+ (0, tunnelProcess_1.killProcessGroup)(pid);
368
+ }
369
+ allocator() {
370
+ return typedi_1.Container.get(PortAllocator_1.PortAllocator);
371
+ }
372
+ };
373
+ exports.IOSTunnels = IOSTunnels;
374
+ exports.IOSTunnels = IOSTunnels = __decorate([
375
+ (0, typedi_1.Service)()
376
+ ], IOSTunnels);
@@ -26,6 +26,7 @@ const logger_1 = __importDefault(require("../../logger"));
26
26
  const typedi_1 = require("typedi");
27
27
  const ProcessRegistry_1 = require("../../services/ProcessRegistry");
28
28
  const IOSStreamService_1 = __importDefault(require("./IOSStreamService"));
29
+ const IOSTunnels_1 = require("./IOSTunnels");
29
30
  const device_store_1 = require("../../data-service/device-store");
30
31
  const child_process_1 = require("child_process");
31
32
  const util_1 = require("util");
@@ -398,7 +399,8 @@ let WDAClient = WDAClient_1 = class WDAClient {
398
399
  try {
399
400
  const p = path_1.default.join(os_1.default.tmpdir(), `screenshot-${udid}.png`);
400
401
  yield execFilePromise(s.goIOSPath, ['screenshot', '--udid', udid, '--output', p], {
401
- env: Object.assign(Object.assign({}, process.env), { ENABLE_GO_IOS_AGENT: 'yes' }),
402
+ // This phone's own go-ios tunnel on iOS 17+ (see IOSTunnels).
403
+ env: typedi_1.Container.get(IOSTunnels_1.IOSTunnels).envFor(udid),
402
404
  });
403
405
  const b = yield fs_extra_1.default.readFile(p);
404
406
  yield fs_extra_1.default.remove(p);
@@ -763,7 +765,8 @@ let WDAClient = WDAClient_1 = class WDAClient {
763
765
  }
764
766
  this.log.info(`[LogStream] Starting persistent syslog for ${udid}: ${command} ${args.join(' ')}`);
765
767
  const proc = (0, child_process_1.spawn)(command, args, {
766
- env: Object.assign(Object.assign({}, process.env), { ENABLE_GO_IOS_AGENT: 'yes' }),
768
+ // This phone's own go-ios tunnel on iOS 17+ (see IOSTunnels).
769
+ env: typedi_1.Container.get(IOSTunnels_1.IOSTunnels).envFor(udid),
767
770
  stdio: ['ignore', 'pipe', 'pipe'],
768
771
  });
769
772
  typedi_1.Container.get(ProcessRegistry_1.ProcessRegistry).track({ kind: 'log-tailer', udid, process: proc });
@@ -31,6 +31,7 @@ const DEFAULT_RANGES = {
31
31
  mjpeg: [9100, 9199],
32
32
  system: [10100, 10199],
33
33
  proxy: [11100, 11199],
34
+ tunnel: [12100, 12199],
34
35
  };
35
36
  /** Loopback and wildcard, in both families. See isOsFree. */
36
37
  const PROBE_HOSTS = ['127.0.0.1', '::1', '0.0.0.0', '::'];
@@ -145,6 +146,70 @@ let PortAllocator = class PortAllocator {
145
146
  }
146
147
  });
147
148
  }
149
+ /**
150
+ * Lease two adjacent ports, an even P and P + 1, and return P. A go-ios
151
+ * tunnel needs both: P for its tunnel-info API, and P + 1, which go-ios
152
+ * itself picks for the phone's traffic.
153
+ *
154
+ * Both must be free in the lease table, whatever purpose holds them, and at
155
+ * the OS. A half-taken pair is given back before the next one is tried.
156
+ */
157
+ acquirePair(purpose_1, udid_1) {
158
+ return __awaiter(this, arguments, void 0, function* (purpose, udid, opts = {}) {
159
+ var _a;
160
+ const [start, end] = this.ranges[purpose];
161
+ const ttlMs = (_a = opts.ttlMs) !== null && _a !== void 0 ? _a : 60 * 60 * 1000;
162
+ const now = Date.now();
163
+ yield prisma_1.prisma.portLease.deleteMany({ where: { expiresAt: { lt: now } } });
164
+ const active = yield prisma_1.prisma.portLease.findMany({
165
+ where: { port: { gte: start, lte: end } },
166
+ select: { port: true },
167
+ });
168
+ const taken = new Set(active.map((l) => l.port));
169
+ const lease = {
170
+ purpose,
171
+ leasedToUdid: udid,
172
+ leasedToPid: opts.pid,
173
+ leasedAt: now,
174
+ expiresAt: now + ttlMs,
175
+ };
176
+ for (let port = start + (start % 2); port + 1 <= end; port += 2) {
177
+ if (taken.has(port) || taken.has(port + 1))
178
+ continue;
179
+ const leased = [];
180
+ try {
181
+ for (const p of [port, port + 1]) {
182
+ yield prisma_1.prisma.portLease.create({ data: Object.assign({ port: p }, lease) });
183
+ leased.push(p);
184
+ }
185
+ }
186
+ catch (err) {
187
+ yield this.releaseAll(leased, udid);
188
+ if (err.code === 'P2002')
189
+ continue;
190
+ throw err;
191
+ }
192
+ if ((yield this.isOsFree(port)) && (yield this.isOsFree(port + 1))) {
193
+ this.log.debug(`Leased ports ${port}-${port + 1} (${purpose}) to ${udid}`);
194
+ return port;
195
+ }
196
+ yield this.releaseAll(leased, udid);
197
+ }
198
+ throw new PortRangeExhaustedError(purpose);
199
+ });
200
+ }
201
+ /** Delete every lease of `purpose`. At boot, when nothing of this server holds one. */
202
+ releasePurpose(purpose) {
203
+ return __awaiter(this, void 0, void 0, function* () {
204
+ yield prisma_1.prisma.portLease.deleteMany({ where: { purpose } });
205
+ });
206
+ }
207
+ releaseAll(ports, udid) {
208
+ return __awaiter(this, void 0, void 0, function* () {
209
+ for (const port of ports)
210
+ yield this.release(port, udid);
211
+ });
212
+ }
148
213
  /**
149
214
  * Soft-block a port so the next `acquire` for the same purpose skips it.
150
215
  * Used when bind fails (EADDRINUSE) even though our lease owned the number —
@@ -0,0 +1,154 @@
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 events_1 = require("events");
18
+ const fs_1 = __importDefault(require("fs"));
19
+ const os_1 = __importDefault(require("os"));
20
+ const path_1 = __importDefault(require("path"));
21
+ const sinon_1 = __importDefault(require("sinon"));
22
+ const typedi_1 = require("typedi");
23
+ const IOSStreamService_1 = __importDefault(require("../../src/device-managers/ios/IOSStreamService"));
24
+ const IOSLogStreamService_1 = require("../../src/device-managers/ios/IOSLogStreamService");
25
+ const IOSTunnels_1 = require("../../src/device-managers/ios/IOSTunnels");
26
+ const WDAClient_1 = require("../../src/device-managers/ios/WDAClient");
27
+ const device_store_1 = require("../../src/data-service/device-store");
28
+ const ProcessRegistry_1 = require("../../src/services/ProcessRegistry");
29
+ /**
30
+ * On iOS 17+ a go-ios command finds its phone's tunnel through
31
+ * GO_IOS_AGENT_PORT. Each phone has its own tunnel now, so each command has
32
+ * to carry its own phone's port, or it reaches go-ios's default 60105, where
33
+ * no tunnel listens.
34
+ *
35
+ * `go-ios` here is a shell script that reports the port it was run with.
36
+ */
37
+ const WITH_TUNNEL = 'test-iphone-tunnel-00008150';
38
+ const NO_TUNNEL = 'test-iphone-plain-00008110';
39
+ const FAKE_GO_IOS = `#!/bin/sh
40
+ # Stands in for go-ios: reports the GO_IOS_AGENT_PORT it was run with.
41
+ case "$1" in
42
+ screenshot) printf 'port=%s' "$GO_IOS_AGENT_PORT" > "$5" ;;
43
+ syslog) printf '{"msg":"port=%s"}\\n' "$GO_IOS_AGENT_PORT"; exec sleep 5 ;;
44
+ esac
45
+ `;
46
+ /** IOSTunnels as if WITH_TUNNEL's tunnel ran on 12100 and NO_TUNNEL had none. */
47
+ const tunnels = {
48
+ envFor(udid) {
49
+ const env = Object.assign(Object.assign({}, process.env), { ENABLE_GO_IOS_AGENT: 'yes' });
50
+ delete env.GO_IOS_AGENT_PORT;
51
+ if (udid === WITH_TUNNEL)
52
+ env.GO_IOS_AGENT_PORT = '12100';
53
+ return env;
54
+ },
55
+ };
56
+ describe("go-ios commands find their own phone's tunnel (GO_IOS_AGENT_PORT)", () => {
57
+ let dir;
58
+ let goIOS;
59
+ before(() => {
60
+ dir = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'xenon-fake-goios-'));
61
+ goIOS = path_1.default.join(dir, 'ios');
62
+ fs_1.default.writeFileSync(goIOS, FAKE_GO_IOS, { mode: 0o755 });
63
+ });
64
+ after(() => fs_1.default.rmSync(dir, { recursive: true, force: true }));
65
+ beforeEach(() => {
66
+ const real = typedi_1.Container.get.bind(typedi_1.Container);
67
+ sinon_1.default.stub(typedi_1.Container, 'get').callsFake((token) => {
68
+ if (token === IOSTunnels_1.IOSTunnels)
69
+ return tunnels;
70
+ if (token === IOSStreamService_1.default) {
71
+ return { goIOSPath: goIOS, isGoIOSAvailable: () => __awaiter(void 0, void 0, void 0, function* () { return true; }) };
72
+ }
73
+ if (token === ProcessRegistry_1.ProcessRegistry)
74
+ return { track: () => 'tracked' };
75
+ return real(token);
76
+ });
77
+ sinon_1.default.stub(device_store_1.DeviceStoreFactory, 'getStore').returns({
78
+ findDevice: (_a) => __awaiter(void 0, [_a], void 0, function* ({ udid }) { return ({ udid, realDevice: true }); }),
79
+ });
80
+ });
81
+ afterEach(() => sinon_1.default.restore());
82
+ describe("device control's screenshot (WDAClient)", () => {
83
+ it("runs go-ios screenshot with the phone's tunnel port", () => __awaiter(void 0, void 0, void 0, function* () {
84
+ const png = yield new WDAClient_1.WDAClient().getScreenshot(WITH_TUNNEL);
85
+ (0, chai_1.expect)(Buffer.from(png, 'base64').toString()).to.equal('port=12100');
86
+ }));
87
+ it('runs it with no tunnel port for a phone that has no tunnel', () => __awaiter(void 0, void 0, void 0, function* () {
88
+ const png = yield new WDAClient_1.WDAClient().getScreenshot(NO_TUNNEL);
89
+ (0, chai_1.expect)(Buffer.from(png, 'base64').toString()).to.equal('port=');
90
+ }));
91
+ });
92
+ describe('the logs read (WDAClient syslog)', () => {
93
+ let client;
94
+ beforeEach(() => {
95
+ client = new WDAClient_1.WDAClient();
96
+ });
97
+ afterEach(() => {
98
+ client.stopLogStream(WITH_TUNNEL);
99
+ client.stopLogStream(NO_TUNNEL);
100
+ });
101
+ /** The first line the phone's syslog yields; the first read starts it. */
102
+ function firstLine(udid) {
103
+ return __awaiter(this, void 0, void 0, function* () {
104
+ for (let i = 0; i < 50; i++) {
105
+ const lines = yield client.getLogs(udid);
106
+ if (lines)
107
+ return lines;
108
+ yield new Promise((resolve) => setTimeout(resolve, 100));
109
+ }
110
+ return '';
111
+ });
112
+ }
113
+ it("runs go-ios syslog with the phone's tunnel port", () => __awaiter(void 0, void 0, void 0, function* () {
114
+ (0, chai_1.expect)(yield firstLine(WITH_TUNNEL)).to.equal('port=12100');
115
+ }));
116
+ it('runs it with no tunnel port for a phone that has no tunnel', () => __awaiter(void 0, void 0, void 0, function* () {
117
+ (0, chai_1.expect)(yield firstLine(NO_TUNNEL)).to.equal('port=');
118
+ }));
119
+ });
120
+ describe('the live logs pane (IOSLogStreamService ostrace)', () => {
121
+ class CapturingLogs extends IOSLogStreamService_1.IOSLogStreamService {
122
+ constructor() {
123
+ super(...arguments);
124
+ this.spawned = [];
125
+ }
126
+ goIOSPath() {
127
+ return __awaiter(this, void 0, void 0, function* () {
128
+ return goIOS;
129
+ });
130
+ }
131
+ spawnProcess(command, args, env) {
132
+ this.spawned.push({ command, args, env });
133
+ const proc = new events_1.EventEmitter();
134
+ proc.stdout = new events_1.EventEmitter();
135
+ proc.stderr = new events_1.EventEmitter();
136
+ return proc;
137
+ }
138
+ }
139
+ it("runs go-ios ostrace with the phone's tunnel port", () => __awaiter(void 0, void 0, void 0, function* () {
140
+ var _a;
141
+ const logs = new CapturingLogs();
142
+ yield logs.spawnOstrace(WITH_TUNNEL, {});
143
+ (0, chai_1.expect)(logs.spawned[0].args[0]).to.equal('ostrace');
144
+ (0, chai_1.expect)((_a = logs.spawned[0].env) === null || _a === void 0 ? void 0 : _a.GO_IOS_AGENT_PORT).to.equal('12100');
145
+ }));
146
+ it('runs it with no tunnel port for a phone that has no tunnel', () => __awaiter(void 0, void 0, void 0, function* () {
147
+ var _a;
148
+ const logs = new CapturingLogs();
149
+ yield logs.spawnOstrace(NO_TUNNEL, {});
150
+ (0, chai_1.expect)(logs.spawned[0].env, 'an environment is passed').to.not.equal(undefined);
151
+ (0, chai_1.expect)((_a = logs.spawned[0].env) === null || _a === void 0 ? void 0 : _a.GO_IOS_AGENT_PORT).to.equal(undefined);
152
+ }));
153
+ });
154
+ });