@xenon-device-management/xenon 2.2.0 → 2.3.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 (99) hide show
  1. package/lib/package.json +1 -1
  2. package/lib/public/assets/{AnnotationOverlay-XpewX5nn.js → AnnotationOverlay-AxcBYoKp.js} +1 -1
  3. package/lib/public/assets/{ApiKeyGate-BQDv3ZCC.js → ApiKeyGate-CIU02EIx.js} +1 -1
  4. package/lib/public/assets/{BugReportButton-C7OmS61B.js → BugReportButton-_akEEjXm.js} +1 -1
  5. package/lib/public/assets/DeviceMosaicView-Bhk1udmn.js +2 -0
  6. package/lib/public/assets/{EmptyState-fPxujqqE.js → EmptyState-DRKRm19_.js} +1 -1
  7. package/lib/public/assets/{FieldGroup-5bCutWrs.js → FieldGroup-zQQHPh7x.js} +1 -1
  8. package/lib/public/assets/{FilterMenu-owsBiDT1.js → FilterMenu-C9_StnDi.js} +1 -1
  9. package/lib/public/assets/{Menu-DFhqzPSt.js → Menu-0J7io6pF.js} +1 -1
  10. package/lib/public/assets/{Modal-Bw-a-E-e.js → Modal-C677ljNX.js} +1 -1
  11. package/lib/public/assets/{RecordingPage-WtByf5bC.js → RecordingPage-BDJFck-L.js} +1 -1
  12. package/lib/public/assets/{RecordingsPage-FpWWFNgT.js → RecordingsPage-DQkimbKK.js} +1 -1
  13. package/lib/public/assets/{SegmentedControl-Jzn9c7o8.js → SegmentedControl-DvGMZBIE.js} +1 -1
  14. package/lib/public/assets/{SettingCard-BR7nOhpT.js → SettingCard-ogyouYBq.js} +1 -1
  15. package/lib/public/assets/{Table-B_Va3V05.js → Table-BLFCOLlO.js} +1 -1
  16. package/lib/public/assets/{activity-DxRFWvkN.js → activity-BU55TGRG.js} +1 -1
  17. package/lib/public/assets/{ai-settings-CQf-9_V6.js → ai-settings-GpDAiHav.js} +1 -1
  18. package/lib/public/assets/{api-keys-SoE1Kd7N.js → api-keys-dOzP0k8_.js} +1 -1
  19. package/lib/public/assets/{apps-CgGXTlgn.js → apps-mAwAQmkn.js} +1 -1
  20. package/lib/public/assets/{arrow-left-uQ6WIwiC.js → arrow-left-Dg9mxe9T.js} +1 -1
  21. package/lib/public/assets/{arrow-right-C9RyfzXS.js → arrow-right-BVcmUyq3.js} +1 -1
  22. package/lib/public/assets/{arrow-up-right-DFHi_8yG.js → arrow-up-right-ByR5pZrH.js} +1 -1
  23. package/lib/public/assets/{auth-shell-Bii1t4qc.js → auth-shell-Jpgk8CgY.js} +2 -2
  24. package/lib/public/assets/{builds-page-Bnv8Z9hi.js → builds-page-VnRTrcdl.js} +1 -1
  25. package/lib/public/assets/{button-_Y78wXiP.js → button-D-X4wjO8.js} +1 -1
  26. package/lib/public/assets/{calendar-D4MqSOc2.js → calendar-ur_p5ETA.js} +1 -1
  27. package/lib/public/assets/{check-uGHCXe-L.js → check-K33iMeyq.js} +1 -1
  28. package/lib/public/assets/{chevron-left-Dy1OqpzB.js → chevron-left-DMVWShd-.js} +1 -1
  29. package/lib/public/assets/{chevron-right-BUsldEli.js → chevron-right-DphLJ2_X.js} +1 -1
  30. package/lib/public/assets/{circle-check-D3Qq0tKp.js → circle-check-CNFhu8oE.js} +1 -1
  31. package/lib/public/assets/{circle-x-B4IOD1DE.js → circle-x-BH_cUbqZ.js} +1 -1
  32. package/lib/public/assets/{clock-BfYfzvSg.js → clock-CVMVdVvp.js} +1 -1
  33. package/lib/public/assets/{copy-DZwYE5Va.js → copy-CZ2XHafL.js} +1 -1
  34. package/lib/public/assets/{device-explorer-CRG4Utt5.js → device-explorer-B94BhaqF.js} +52 -52
  35. package/lib/public/assets/{download-CCDYo_TN.js → download-UUiyw7kO.js} +1 -1
  36. package/lib/public/assets/{forgot-password-DuLCmVFb.js → forgot-password-CrS14F79.js} +1 -1
  37. package/lib/public/assets/{funnel-CtKNPqzb.js → funnel-BMOE5qJY.js} +1 -1
  38. package/lib/public/assets/{index-3uxl3o7K.js → index-2XPPbPwy.js} +2 -2
  39. package/lib/public/assets/{index-Zy1Op5C1.js → index-D0R9Yic3.js} +1 -1
  40. package/lib/public/assets/{input-CzTWvUXZ.js → input-C-1b8J7i.js} +1 -1
  41. package/lib/public/assets/{keycodes-CI-nYDFM.js → keycodes-Cly7dQc9.js} +4 -4
  42. package/lib/public/assets/{layers-YZZGFyTX.js → layers-DFZfqGdX.js} +1 -1
  43. package/lib/public/assets/{lock-L8tzWgDo.js → lock-ylHMJpfP.js} +1 -1
  44. package/lib/public/assets/{login-DBbX-fu8.js → login-DWsWKqDx.js} +1 -1
  45. package/lib/public/assets/{maintenance-settings-AJwkOIsE.js → maintenance-settings-9B6_MTqW.js} +1 -1
  46. package/lib/public/assets/{monitor-Di_66F2m.js → monitor-CzX0hBH1.js} +1 -1
  47. package/lib/public/assets/{mouse-pointer-2-Cnab2zdg.js → mouse-pointer-2-CZHD7N3z.js} +1 -1
  48. package/lib/public/assets/{network-DiSumY-1.js → network-Bikc4MyT.js} +1 -1
  49. package/lib/public/assets/{overview-CwE4gXl5.js → overview-DzaA0aMe.js} +1 -1
  50. package/lib/public/assets/{page-header-CfR4Sm9V.js → page-header-DqGlDufy.js} +1 -1
  51. package/lib/public/assets/{plus-DOS8P9CQ.js → plus-Ct6aQTGM.js} +1 -1
  52. package/lib/public/assets/{profile-page-BYpHwQIH.js → profile-page-CCnUQ1Qk.js} +1 -1
  53. package/lib/public/assets/{reset-password-BRVJU_qV.js → reset-password-AmkGGXo5.js} +1 -1
  54. package/lib/public/assets/{runbook-page-Bi3JvAFZ.js → runbook-page-DQQn9VyE.js} +1 -1
  55. package/lib/public/assets/{select-Cqx7pEou.js → select-k5ogtEWW.js} +1 -1
  56. package/lib/public/assets/{selector-detail-page-C0DsncKj.js → selector-detail-page-BCcwC5ku.js} +1 -1
  57. package/lib/public/assets/{selector-health-CAhpdilF.js → selector-health-BVyX6kIh.js} +1 -1
  58. package/lib/public/assets/{selector-health-page-CrCVeW2m.js → selector-health-page-CElBB6XT.js} +1 -1
  59. package/lib/public/assets/{session-detail-page-F_hwYqEt.js → session-detail-page-6bqo_-ND.js} +1 -1
  60. package/lib/public/assets/{settings-C-s5pypL.js → settings-DKBj9KyN.js} +1 -1
  61. package/lib/public/assets/{status-pill-outline-DY5uzv6A.js → status-pill-outline-CZNpvqej.js} +1 -1
  62. package/lib/public/assets/{tablet-Dli7gwyg.js → tablet-B6HI5OxV.js} +1 -1
  63. package/lib/public/assets/{teams-ui4IhHNC.js → teams-owoDHbOU.js} +1 -1
  64. package/lib/public/assets/{trash-2-BV4hSmXJ.js → trash-2-D61PoUrZ.js} +1 -1
  65. package/lib/public/assets/{upload-DkGy_J-L.js → upload-1ZYEVvgD.js} +1 -1
  66. package/lib/public/assets/{use-password-reset-mode-BFtMqQOh.js → use-password-reset-mode-Dx0ghiiQ.js} +1 -1
  67. package/lib/public/assets/{users-Nb_rzRmF.js → users-C5CS5Qez.js} +1 -1
  68. package/lib/public/assets/{users-BU0nXIVe.js → users-PbGMd5sD.js} +1 -1
  69. package/lib/public/assets/{video-off-CUNzD2kH.js → video-off-_XusI0HK.js} +1 -1
  70. package/lib/public/assets/{webhook-settings-Ca3UFXZe.js → webhook-settings-Bi_HHQlQ.js} +1 -1
  71. package/lib/public/assets/{zap-B2bH2vKX.js → zap-uzHWFAKI.js} +1 -1
  72. package/lib/public/index.html +1 -1
  73. package/lib/src/app/routers/control.js +114 -61
  74. package/lib/src/app/routers/nodePhoneControl.js +265 -46
  75. package/lib/src/data-service/device-store.js +7 -3
  76. package/lib/src/data-service/deviceClaims.js +14 -0
  77. package/lib/src/data-service/deviceFieldOwners.js +3 -1
  78. package/lib/src/data-service/prisma-store.js +3 -3
  79. package/lib/src/device-managers/AndroidDeviceManager.js +11 -1
  80. package/lib/src/gateway/forwardToNode.js +10 -3
  81. package/lib/src/generated/client/edge.js +5 -4
  82. package/lib/src/generated/client/index-browser.js +2 -1
  83. package/lib/src/generated/client/index.d.ts +36 -1
  84. package/lib/src/generated/client/index.js +5 -4
  85. package/lib/src/generated/client/package.json +1 -1
  86. package/lib/src/generated/client/schema.prisma +4 -0
  87. package/lib/src/generated/client/wasm.js +2 -1
  88. package/lib/test/unit/AndroidDeviceManager.spec.js +12 -0
  89. package/lib/test/unit/hub-node-control-forward.spec.js +66 -10
  90. package/lib/test/unit/hub-node-race.spec.js +29 -3
  91. package/lib/test/unit/mark-node-busy-loki.spec.js +17 -4
  92. package/lib/test/unit/node-phone-control.spec.js +98 -4
  93. package/lib/test/unit/screenshot-route.spec.js +104 -0
  94. package/lib/test/unit/upload-install-route.spec.js +205 -0
  95. package/lib/tsconfig.tsbuildinfo +1 -1
  96. package/package.json +1 -1
  97. package/prisma/migrations/20261001120000_device_node_hold/migration.sql +2 -0
  98. package/prisma/schema.prisma +4 -0
  99. package/lib/public/assets/DeviceMosaicView-wl3l2Zlt.js +0 -2
@@ -8,15 +8,45 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
8
8
  step((generator = generator.apply(thisArg, _arguments || [])).next());
9
9
  });
10
10
  };
11
+ var __await = (this && this.__await) || function (v) { return this instanceof __await ? (this.v = v, this) : new __await(v); }
12
+ var __asyncValues = (this && this.__asyncValues) || function (o) {
13
+ if (!Symbol.asyncIterator) throw new TypeError("Symbol.asyncIterator is not defined.");
14
+ var m = o[Symbol.asyncIterator], i;
15
+ return m ? m.call(o) : (o = typeof __values === "function" ? __values(o) : o[Symbol.iterator](), i = {}, verb("next"), verb("throw"), verb("return"), i[Symbol.asyncIterator] = function () { return this; }, i);
16
+ function verb(n) { i[n] = o[n] && function (v) { return new Promise(function (resolve, reject) { v = o[n](v), settle(resolve, reject, v.done, v.value); }); }; }
17
+ function settle(resolve, reject, d, v) { Promise.resolve(v).then(function(v) { resolve({ value: v, done: d }); }, reject); }
18
+ };
19
+ var __asyncDelegator = (this && this.__asyncDelegator) || function (o) {
20
+ var i, p;
21
+ return i = {}, verb("next"), verb("throw", function (e) { throw e; }), verb("return"), i[Symbol.iterator] = function () { return this; }, i;
22
+ function verb(n, f) { i[n] = o[n] ? function (v) { return (p = !p) ? { value: __await(o[n](v)), done: false } : f ? f(v) : v; } : f; }
23
+ };
24
+ var __asyncGenerator = (this && this.__asyncGenerator) || function (thisArg, _arguments, generator) {
25
+ if (!Symbol.asyncIterator) throw new TypeError("Symbol.asyncIterator is not defined.");
26
+ var g = generator.apply(thisArg, _arguments || []), i, q = [];
27
+ return i = Object.create((typeof AsyncIterator === "function" ? AsyncIterator : Object).prototype), verb("next"), verb("throw"), verb("return", awaitReturn), i[Symbol.asyncIterator] = function () { return this; }, i;
28
+ function awaitReturn(f) { return function (v) { return Promise.resolve(v).then(f, reject); }; }
29
+ function verb(n, f) { if (g[n]) { i[n] = function (v) { return new Promise(function (a, b) { q.push([n, v, a, b]) > 1 || resume(n, v); }); }; if (f) i[n] = f(i[n]); } }
30
+ function resume(n, v) { try { step(g[n](v)); } catch (e) { settle(q[0][3], e); } }
31
+ function step(r) { r.value instanceof __await ? Promise.resolve(r.value.v).then(fulfill, reject) : settle(q[0][2], r); }
32
+ function fulfill(value) { resume("next", value); }
33
+ function reject(value) { resume("throw", value); }
34
+ function settle(f, v) { if (f(v), q.shift(), q.length) resume(q[0][0], q[0][1]); }
35
+ };
11
36
  var __importDefault = (this && this.__importDefault) || function (mod) {
12
37
  return (mod && mod.__esModule) ? mod : { "default": mod };
13
38
  };
14
39
  Object.defineProperty(exports, "__esModule", { value: true });
15
- exports.NODE_CONTROL_TIMEOUT_MS = exports.ANSWERED_HERE = exports.NODE_FORWARDED_CONTROL = void 0;
40
+ exports.NODE_INSTALL_TIMEOUT_MS = exports.NODE_CONTROL_TIMEOUT_MS = exports.ANSWERED_HERE_FOR_NODES = exports.ANSWERED_HERE = exports.NODE_FORWARDED_CONTROL = void 0;
16
41
  exports.safeNodeOrigin = safeNodeOrigin;
17
42
  exports.nodeControlUrl = nodeControlUrl;
18
43
  exports.isOtherServersPhone = isOtherServersPhone;
19
44
  exports.nodePhoneControl = nodePhoneControl;
45
+ exports.installFileOnNode = installFileOnNode;
46
+ exports.screenshotFromNode = screenshotFromNode;
47
+ const crypto_1 = require("crypto");
48
+ const fs_1 = __importDefault(require("fs"));
49
+ const stream_1 = require("stream");
20
50
  const typedi_1 = require("typedi");
21
51
  const logger_1 = __importDefault(require("../../logger"));
22
52
  const PluginContext_1 = require("../../PluginContext");
@@ -24,6 +54,8 @@ const localDeviceHosts_1 = require("../../device-managers/localDeviceHosts");
24
54
  const actor_1 = require("../../services/device-access/actor");
25
55
  const hubSessionToken_1 = require("../../gateway/hubSessionToken");
26
56
  const forwardToNode_1 = require("../../gateway/forwardToNode");
57
+ const manualLock_1 = require("../../services/recording/manualLock");
58
+ const SessionOwnerResolver_1 = require("../../services/device-access/SessionOwnerResolver");
27
59
  const device_store_1 = require("../../data-service/device-store");
28
60
  const deviceAccessPolicy_1 = require("../../services/device-access/deviceAccessPolicy");
29
61
  const controlDevice_1 = require("../../middleware/controlDevice");
@@ -66,6 +98,8 @@ exports.NODE_FORWARDED_CONTROL = [
66
98
  'POST stream/stop',
67
99
  // The MJPEG stream itself: relayed for as long as the viewer watches.
68
100
  'GET stream',
101
+ // The upload is streamed on as it came: nothing here reads it first.
102
+ 'POST upload-install',
69
103
  ];
70
104
  /**
71
105
  * Answered by this server's own handler.
@@ -77,19 +111,36 @@ exports.NODE_FORWARDED_CONTROL = [
77
111
  * which get a ticket of the node's own.
78
112
  */
79
113
  exports.ANSWERED_HERE = ['GET appium-session', 'POST stream/ticket'];
80
- // Not on either list, and why, so nobody adds them as a plain forward:
81
- // - upload-install is a multipart upload;
82
- // - install-repository-app installs a file from this server's app library,
83
- // which the node doesn't have;
84
- // - install takes a path, which names a file on one machine only;
85
- // - omni-scan and test-locator run this server's AI settings on a
86
- // screenshot.
114
+ /**
115
+ * Answered by this server's own handler for a node's phone (a cloud
116
+ * provider's is refused), which does its part here and asks the node for
117
+ * the rest:
118
+ * - install-repository-app: the app is in this server's library, checked
119
+ * against the caller's teams here, and sent to the node's upload-install
120
+ * (installFileOnNode).
121
+ * - omni-scan, test-locator: Omni-Vision runs here, with this server's AI
122
+ * settings, on the node's screenshot (screenshotFromNode).
123
+ */
124
+ exports.ANSWERED_HERE_FOR_NODES = [
125
+ 'POST install-repository-app',
126
+ 'GET omni-scan',
127
+ 'POST test-locator',
128
+ ];
129
+ // Not on any list, so refused: install takes a path, which names a file on
130
+ // one machine only.
87
131
  /**
88
132
  * A node that hasn't started answering in this long is treated as gone. Once
89
133
  * it has, the answer may take as long as it takes: a stream lasts as long as
90
134
  * its viewer.
91
135
  */
92
136
  exports.NODE_CONTROL_TIMEOUT_MS = 60000;
137
+ /**
138
+ * An install is answered when it is done, which can take minutes (a large
139
+ * app, a slow phone): its node gets this long to start answering instead.
140
+ */
141
+ exports.NODE_INSTALL_TIMEOUT_MS = 15 * 60000;
142
+ /** Forwarded actions that are installs, and get NODE_INSTALL_TIMEOUT_MS. */
143
+ const INSTALLS = ['POST upload-install'];
93
144
  // Cloud metadata endpoints — never proxy to these regardless of caller.
94
145
  const FORBIDDEN_PROXY_HOSTS = new Set([
95
146
  '169.254.169.254', // AWS/Azure/GCP IMDS
@@ -166,10 +217,15 @@ function refuse(res, error, message) {
166
217
  return res.status(501).json({ success: false, error, message });
167
218
  }
168
219
  function nodePhoneControl(deps = {}) {
169
- var _a, _b, _c;
220
+ var _a, _b, _c, _d, _e, _f;
170
221
  const findDevice = (_a = deps.findDevice) !== null && _a !== void 0 ? _a : controlDevice_1.findControlDeviceInStore;
171
- const timeoutMs = (_b = deps.timeoutMs) !== null && _b !== void 0 ? _b : exports.NODE_CONTROL_TIMEOUT_MS;
172
- const markNodeBusy = (_c = deps.markNodeBusy) !== null && _c !== void 0 ? _c : ((udid, host) => device_store_1.DeviceStoreFactory.getStore().markNodeBusy(udid, host));
222
+ const forwardDeps = {
223
+ timeoutMs: (_b = deps.timeoutMs) !== null && _b !== void 0 ? _b : exports.NODE_CONTROL_TIMEOUT_MS,
224
+ installTimeoutMs: (_c = deps.installTimeoutMs) !== null && _c !== void 0 ? _c : exports.NODE_INSTALL_TIMEOUT_MS,
225
+ markNodeBusy: (_d = deps.markNodeBusy) !== null && _d !== void 0 ? _d : ((udid, host, hold) => device_store_1.DeviceStoreFactory.getStore().markNodeBusy(udid, host, hold)),
226
+ describeHolder: (_e = deps.describeHolder) !== null && _e !== void 0 ? _e : ((id) => typedi_1.Container.get(SessionOwnerResolver_1.SessionOwnerResolver).displayName(id)),
227
+ controlToken: (_f = deps.controlToken) !== null && _f !== void 0 ? _f : defaultControlToken,
228
+ };
173
229
  return function (req, res, next) {
174
230
  return __awaiter(this, void 0, void 0, function* () {
175
231
  var _a;
@@ -201,30 +257,32 @@ function nodePhoneControl(deps = {}) {
201
257
  if (device.cloud) {
202
258
  return refuse(res, 'not_available_for_cloud_phone', "This phone is a cloud provider's. Device control isn't available for it here.");
203
259
  }
260
+ if (exports.ANSWERED_HERE_FOR_NODES.includes(key))
261
+ return next();
204
262
  const node = originOf(device.host);
205
263
  if (!exports.NODE_FORWARDED_CONTROL.includes(key)) {
206
264
  return refuse(res, 'not_available_through_hub', `This phone is on node ${node}, and the hub doesn't pass ${action} on to nodes yet.`);
207
265
  }
208
- return forward(req, res, { udid, device, node, timeoutMs, markNodeBusy });
266
+ return forward(req, res, { udid, device, node }, forwardDeps);
209
267
  });
210
268
  };
211
269
  }
212
- function forward(req, res, target) {
270
+ const defaultControlToken = (grant) => typedi_1.Container.get(hubSessionToken_1.HubSessionTokenIssuer).controlTokenFor(grant);
271
+ /**
272
+ * The headers of a call to the phone's node, signed for the caller. Null
273
+ * token when this server can't sign (it warns once): the call goes without,
274
+ * which a node with auth disabled accepts and one with auth enabled refuses.
275
+ */
276
+ function signedHeaders(req, udid, device, controlToken) {
213
277
  return __awaiter(this, void 0, void 0, function* () {
214
- var _a, _b, _c, _d, _e, _f;
215
- const { udid, device, node, timeoutMs } = target;
216
- const url = nodeControlUrl(device.host, req);
217
- if (!url)
218
- return res.status(400).json({ error: 'Unsafe device host' });
278
+ var _a;
219
279
  const actor = (0, actor_1.resolveActor)(req);
220
280
  const headers = {
221
281
  accept: (_a = req.headers.accept) !== null && _a !== void 0 ? _a : 'application/json',
222
282
  'accept-encoding': 'identity',
223
283
  };
224
- // Null when this server can't sign (it warns once): the call goes without,
225
- // which a node with auth disabled accepts and one with auth enabled refuses.
226
284
  const token = actor.userId
227
- ? yield typedi_1.Container.get(hubSessionToken_1.HubSessionTokenIssuer).controlTokenFor({
285
+ ? yield controlToken({
228
286
  userId: actor.userId,
229
287
  isAdmin: actor.isAdmin,
230
288
  udid,
@@ -233,44 +291,42 @@ function forward(req, res, target) {
233
291
  : null;
234
292
  if (token)
235
293
  headers[hubSessionToken_1.HUB_TOKEN_HEADER] = token;
236
- let body;
237
- if (req.method !== 'GET' && req.method !== 'HEAD') {
238
- body = Buffer.from(JSON.stringify((_b = req.body) !== null && _b !== void 0 ? _b : {}));
239
- headers['content-type'] = 'application/json';
240
- }
294
+ return headers;
295
+ });
296
+ }
297
+ /**
298
+ * Send one call to the node and relay its answer to `res`. A node that can't
299
+ * be reached is 502, and one that hasn't started answering in `timeoutMs` is
300
+ * 504; once it has, the answer takes as long as it takes. `onAnswer` sees the
301
+ * node's answer first and may reply itself (returning true). Sent once: a
302
+ * retry after a slow node's timeout or 5xx could land the same tap twice.
303
+ */
304
+ function relayNodeCall(res, call, onAnswer) {
305
+ return __awaiter(this, void 0, void 0, function* () {
306
+ var _a, _b, _c;
241
307
  const controller = new AbortController();
242
308
  let timedOut = false;
243
309
  const timer = setTimeout(() => {
244
310
  timedOut = true;
245
311
  controller.abort();
246
- }, timeoutMs);
312
+ }, call.timeoutMs);
247
313
  // The caller left: stop waiting for the node on their behalf.
248
314
  const onClose = () => {
249
315
  if (!res.writableFinished)
250
316
  controller.abort();
251
317
  };
252
318
  res.on('close', onClose);
253
- logger_1.default.debug(`Forwarding ${req.method} ${(0, controlDevice_1.controlAction)(req)} for ${udid} to node ${node}`);
254
319
  try {
255
320
  const upstream = yield (0, forwardToNode_1.sendToNode)({
256
- url,
257
- method: req.method,
258
- headers,
259
- body,
321
+ url: call.url,
322
+ method: call.method,
323
+ headers: call.headers,
324
+ body: call.body,
260
325
  signal: controller.signal,
261
326
  });
262
- // The node has answered: from here the answer takes as long as it takes.
263
327
  clearTimeout(timer);
264
- const status = (_c = upstream.statusCode) !== null && _c !== void 0 ? _c : 502;
265
- if (req.method === 'POST' && (0, controlDevice_1.controlAction)(req) === 'stream/start' && status < 300) {
266
- // The node holds the phone for the preview now, and says so in its
267
- // next report, up to an interval away. Until then this server could
268
- // hand the phone to a session the node would refuse.
269
- yield target.markNodeBusy(udid, device.host).catch((e) => {
270
- var _a;
271
- logger_1.default.warn(`nodePhoneControl: could not mark ${udid} busy for its node: ${(_a = e === null || e === void 0 ? void 0 : e.message) !== null && _a !== void 0 ? _a : e}`);
272
- });
273
- }
328
+ if (onAnswer && (yield onAnswer(upstream)))
329
+ return;
274
330
  yield (0, forwardToNode_1.relayAnswer)(upstream, res, false);
275
331
  }
276
332
  catch (e) {
@@ -281,15 +337,15 @@ function forward(req, res, target) {
281
337
  res.status(504).json({
282
338
  success: false,
283
339
  error: 'node_timeout',
284
- message: `Node ${node} didn't answer within ${Math.round(timeoutMs / 1000)} s.`,
340
+ message: `Node ${call.node} didn't answer within ${Math.round(call.timeoutMs / 1000)} s.`,
285
341
  });
286
342
  }
287
343
  else {
288
- logger_1.default.warn(`nodePhoneControl: node ${node} unreachable for ${udid}: ${(_d = e === null || e === void 0 ? void 0 : e.message) !== null && _d !== void 0 ? _d : e}`);
344
+ logger_1.default.warn(`nodePhoneControl: node ${call.node} unreachable for ${call.udid}: ${(_a = e === null || e === void 0 ? void 0 : e.message) !== null && _a !== void 0 ? _a : e}`);
289
345
  res.status(502).json({
290
346
  success: false,
291
347
  error: 'node_unreachable',
292
- message: `Xenon could not reach node ${node} for this phone: ${(_f = (_e = e === null || e === void 0 ? void 0 : e.code) !== null && _e !== void 0 ? _e : e === null || e === void 0 ? void 0 : e.message) !== null && _f !== void 0 ? _f : e}`,
348
+ message: `Xenon could not reach node ${call.node} for this phone: ${(_c = (_b = e === null || e === void 0 ? void 0 : e.code) !== null && _b !== void 0 ? _b : e === null || e === void 0 ? void 0 : e.message) !== null && _c !== void 0 ? _c : e}`,
293
349
  });
294
350
  }
295
351
  }
@@ -299,3 +355,166 @@ function forward(req, res, target) {
299
355
  }
300
356
  });
301
357
  }
358
+ function forward(req, res, target, deps) {
359
+ return __awaiter(this, void 0, void 0, function* () {
360
+ var _a;
361
+ const { udid, device, node } = target;
362
+ const url = nodeControlUrl(device.host, req);
363
+ if (!url)
364
+ return res.status(400).json({ error: 'Unsafe device host' });
365
+ const key = `${req.method} ${(0, controlDevice_1.controlAction)(req)}`;
366
+ const actor = (0, actor_1.resolveActor)(req);
367
+ const headers = yield signedHeaders(req, udid, device, deps.controlToken);
368
+ let body;
369
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
370
+ const type = req.headers['content-type'];
371
+ if (typeof type === 'string' && type.toLowerCase().startsWith('multipart/')) {
372
+ // An upload, streamed on as it came: the route's own parser runs after
373
+ // this, so nothing here has read it.
374
+ body = req;
375
+ headers['content-type'] = type;
376
+ if (req.headers['content-length'])
377
+ headers['content-length'] = req.headers['content-length'];
378
+ }
379
+ else {
380
+ body = Buffer.from(JSON.stringify((_a = req.body) !== null && _a !== void 0 ? _a : {}));
381
+ headers['content-type'] = 'application/json';
382
+ }
383
+ }
384
+ logger_1.default.debug(`Forwarding ${key} for ${udid} to node ${node}`);
385
+ const timeoutMs = INSTALLS.includes(key) ? deps.installTimeoutMs : deps.timeoutMs;
386
+ yield relayNodeCall(res, { url, method: req.method, headers, body, timeoutMs, node, udid }, (upstream) => __awaiter(this, void 0, void 0, function* () {
387
+ var _a;
388
+ const status = (_a = upstream.statusCode) !== null && _a !== void 0 ? _a : 502;
389
+ if (key === 'POST stream/start' && status < 300) {
390
+ // The node holds the phone for the preview now, and says so in its
391
+ // next report, up to an interval away. Until then this server could
392
+ // hand the phone to a session the node would refuse, and would show
393
+ // no holder. The node writes the same hold for the same user.
394
+ const hold = actor.userId ? (0, manualLock_1.formatManualLock)(actor.userId, udid) : undefined;
395
+ yield deps.markNodeBusy(udid, device.host, hold).catch((e) => {
396
+ var _a;
397
+ logger_1.default.warn(`nodePhoneControl: could not mark ${udid} busy for its node: ${(_a = e === null || e === void 0 ? void 0 : e.message) !== null && _a !== void 0 ? _a : e}`);
398
+ });
399
+ }
400
+ if (status === 409) {
401
+ yield relayRefusal(yield (0, forwardToNode_1.readAnswer)(upstream), res, deps.describeHolder);
402
+ return true;
403
+ }
404
+ return false;
405
+ }));
406
+ });
407
+ }
408
+ /**
409
+ * A file as a multipart upload in one field, streamed from disk, with its
410
+ * length known so the node's parser can apply its limit up front.
411
+ */
412
+ function multipartFile(field, file) {
413
+ return __awaiter(this, void 0, void 0, function* () {
414
+ const boundary = `----xenon-${(0, crypto_1.randomBytes)(12).toString('hex')}`;
415
+ const name = file.name.replace(/["\\\r\n]/g, '_');
416
+ const head = Buffer.from(`--${boundary}\r\n` +
417
+ `Content-Disposition: form-data; name="${field}"; filename="${name}"\r\n` +
418
+ 'Content-Type: application/octet-stream\r\n\r\n');
419
+ const tail = Buffer.from(`\r\n--${boundary}--\r\n`);
420
+ const { size } = yield fs_1.default.promises.stat(file.path);
421
+ const stream = stream_1.Readable.from((function () {
422
+ return __asyncGenerator(this, arguments, function* () {
423
+ yield yield __await(head);
424
+ yield __await(yield* __asyncDelegator(__asyncValues(fs_1.default.createReadStream(file.path))));
425
+ yield yield __await(tail);
426
+ });
427
+ })());
428
+ return {
429
+ type: `multipart/form-data; boundary=${boundary}`,
430
+ length: head.length + size + tail.length,
431
+ stream,
432
+ };
433
+ });
434
+ }
435
+ /**
436
+ * Install a file of this server's on another server's phone: sent to the
437
+ * node's upload-install as a multipart upload, signed for the caller, and
438
+ * the node's answer relayed. install-repository-app's, for an app from this
439
+ * server's library, which the node doesn't have. The file's name keeps the
440
+ * extension the node's installer reads.
441
+ */
442
+ function installFileOnNode(req_1, res_1, udid_1, device_1, file_1) {
443
+ return __awaiter(this, arguments, void 0, function* (req, res, udid, device, file, deps = {}) {
444
+ var _a, _b;
445
+ const origin = safeNodeOrigin(device.host);
446
+ if (!origin) {
447
+ res.status(400).json({ error: 'Unsafe device host' });
448
+ return;
449
+ }
450
+ const headers = yield signedHeaders(req, udid, device, (_a = deps.controlToken) !== null && _a !== void 0 ? _a : defaultControlToken);
451
+ const upload = yield multipartFile('app', file);
452
+ headers['content-type'] = upload.type;
453
+ headers['content-length'] = upload.length;
454
+ logger_1.default.info(`Sending ${file.name} to node ${origin} to install on ${udid}`);
455
+ yield relayNodeCall(res, {
456
+ url: `${origin}/xenon/api/control/${encodeURIComponent(udid)}/upload-install`,
457
+ method: 'POST',
458
+ headers,
459
+ body: upload.stream,
460
+ timeoutMs: (_b = deps.timeoutMs) !== null && _b !== void 0 ? _b : exports.NODE_INSTALL_TIMEOUT_MS,
461
+ node: origin,
462
+ udid,
463
+ });
464
+ });
465
+ }
466
+ /**
467
+ * The node's screenshot of another server's phone, base64, for work this
468
+ * server does on it: Omni-Vision, with this server's AI settings. Throws when
469
+ * the node doesn't give one.
470
+ */
471
+ function screenshotFromNode(req_1, udid_1, device_1) {
472
+ return __awaiter(this, arguments, void 0, function* (req, udid, device, deps = {}) {
473
+ var _a, _b, _c;
474
+ const origin = safeNodeOrigin(device.host);
475
+ if (!origin)
476
+ throw new Error('Unsafe device host');
477
+ const headers = yield signedHeaders(req, udid, device, (_a = deps.controlToken) !== null && _a !== void 0 ? _a : defaultControlToken);
478
+ headers.accept = 'application/json';
479
+ const answer = yield (0, forwardToNode_1.readAnswer)(yield (0, forwardToNode_1.sendToNode)({
480
+ url: `${origin}/xenon/api/control/${encodeURIComponent(udid)}/screenshot`,
481
+ method: 'GET',
482
+ headers,
483
+ signal: AbortSignal.timeout((_b = deps.timeoutMs) !== null && _b !== void 0 ? _b : exports.NODE_CONTROL_TIMEOUT_MS),
484
+ }));
485
+ if (answer.status !== 200) {
486
+ throw new Error(`Node ${origin} answered ${answer.status} for a screenshot`);
487
+ }
488
+ const shot = (_c = JSON.parse(answer.body.toString('utf8'))) === null || _c === void 0 ? void 0 : _c.screenshot;
489
+ if (typeof shot !== 'string' || !shot)
490
+ throw new Error(`Node ${origin} sent no screenshot`);
491
+ return shot;
492
+ });
493
+ }
494
+ const HOLDER_REFUSALS = new Set(['device_held_by_another_user', 'device_in_use_by_session']);
495
+ /**
496
+ * A node's 409, with the holder's name filled in when the node could only
497
+ * give their id: its database has no rows for this server's users, so it
498
+ * says "another user". The wording is denyBody's, as for this server's own
499
+ * refusals. Anything else is passed on as it came.
500
+ */
501
+ function relayRefusal(answer, res, describeHolder) {
502
+ return __awaiter(this, void 0, void 0, function* () {
503
+ var _a;
504
+ let refusal;
505
+ try {
506
+ refusal = JSON.parse(answer.body.toString('utf8'));
507
+ }
508
+ catch (_b) {
509
+ return (0, forwardToNode_1.sendAnswer)(res, answer);
510
+ }
511
+ const holderId = (_a = refusal === null || refusal === void 0 ? void 0 : refusal.holder) === null || _a === void 0 ? void 0 : _a.userId;
512
+ if (!HOLDER_REFUSALS.has(refusal === null || refusal === void 0 ? void 0 : refusal.error) || typeof holderId !== 'string' || refusal.holder.name) {
513
+ return (0, forwardToNode_1.sendAnswer)(res, answer);
514
+ }
515
+ const name = yield describeHolder(holderId).catch(() => null);
516
+ if (!name)
517
+ return (0, forwardToNode_1.sendAnswer)(res, answer);
518
+ res.status(409).json(Object.assign(Object.assign({}, refusal), (0, deviceAccessPolicy_1.denyBody)(refusal.error, holderId, name)));
519
+ });
520
+ }
@@ -173,8 +173,9 @@ class LokiDeviceStore {
173
173
  const existing = deviceModel.findOne({ udid: device.udid, host: device.host });
174
174
  // A node's report (PrismaDeviceStore.addNodeReport has the rule).
175
175
  const nodeBusy = device.busy === true;
176
+ const nodeHold = (0, deviceClaims_1.nodeHoldOf)(device.session_id);
176
177
  if (existing && options.nodeReport) {
177
- Object.assign(existing, (0, deviceFieldOwners_1.pickNodeReportFields)(device), { nodeBusy });
178
+ Object.assign(existing, (0, deviceFieldOwners_1.pickNodeReportFields)(device), { nodeBusy, nodeHold });
178
179
  if (nodeBusy)
179
180
  existing.busy = true;
180
181
  else if ((0, deviceClaims_1.isUnheld)(existing))
@@ -184,7 +185,8 @@ class LokiDeviceStore {
184
185
  }
185
186
  if (!existing) {
186
187
  const cleanDevice = options.nodeReport
187
- ? Object.assign(Object.assign({}, (0, deviceFieldOwners_1.pickNodeReportFields)(device)), { udid: device.udid, host: device.host, busy: nodeBusy, nodeBusy })
188
+ ? Object.assign(Object.assign({}, (0, deviceFieldOwners_1.pickNodeReportFields)(device)), { udid: device.udid, host: device.host, busy: nodeBusy, nodeBusy,
189
+ nodeHold })
188
190
  : Object.assign({}, device);
189
191
  if (cleanDevice.host === undefined)
190
192
  cleanDevice.host = 'Local';
@@ -293,7 +295,7 @@ class LokiDeviceStore {
293
295
  return true;
294
296
  });
295
297
  }
296
- markNodeBusy(udid, host) {
298
+ markNodeBusy(udid, host, hold) {
297
299
  return __awaiter(this, void 0, void 0, function* () {
298
300
  const model = yield db_1.XenonDatabase.DeviceModel;
299
301
  const device = model.findOne({ udid, host });
@@ -301,6 +303,8 @@ class LokiDeviceStore {
301
303
  return;
302
304
  device.nodeBusy = true;
303
305
  device.busy = true;
306
+ if (hold)
307
+ device.nodeHold = hold;
304
308
  model.update(device);
305
309
  });
306
310
  }
@@ -7,7 +7,9 @@ exports.claimWhere = claimWhere;
7
7
  exports.holdsClaim = holdsClaim;
8
8
  exports.hasNoClaim = hasNoClaim;
9
9
  exports.isUnheld = isUnheld;
10
+ exports.nodeHoldOf = nodeHoldOf;
10
11
  exports.heldHere = heldHere;
12
+ const manualLock_1 = require("../services/recording/manualLock");
11
13
  /**
12
14
  * Why a phone is busy, on the server that allocates it.
13
15
  *
@@ -95,6 +97,18 @@ exports.UNHELD = Object.assign(Object.assign({}, exports.NO_CLAIM), { session_id
95
97
  function isUnheld(device) {
96
98
  return hasNoClaim(device) && device.session_id == null && device.nodeBusy !== true;
97
99
  }
100
+ /**
101
+ * Who holds a node's phone there, from the node's report of it: its
102
+ * `session_id` when that is a preview hold (`manual_<user>_<udid>`), else
103
+ * null. A session the node runs is not a hold. The hub keeps it as
104
+ * `nodeHold`, apart from its own `session_id`, and shows it; the node decides
105
+ * access, so a stale value can change a label and nothing else.
106
+ */
107
+ function nodeHoldOf(reportedSessionId) {
108
+ return typeof reportedSessionId === 'string' && (0, manualLock_1.isManualLock)(reportedSessionId)
109
+ ? reportedSessionId
110
+ : null;
111
+ }
98
112
  /**
99
113
  * Busy for a reason this server has on record: its claim, a session or hold
100
114
  * in `session_id`, or plain `busy` on a phone of its own. False for a node's
@@ -89,6 +89,7 @@ const OWNER = {
89
89
  thermalStatus: 'observed',
90
90
  storageFree: 'observed',
91
91
  nodeBusy: 'report',
92
+ nodeHold: 'report',
92
93
  };
93
94
  /** The columns a sync may write to a phone it already knows. */
94
95
  exports.DISCOVERY_FIELDS = new Set(Object.keys(OWNER).filter((column) => OWNER[column] === 'discovery'));
@@ -98,7 +99,8 @@ function pickDiscoveryFields(data) {
98
99
  }
99
100
  /**
100
101
  * The columns a hub takes from a node's report of a phone: what the node
101
- * observes of it. Not `nodeBusy`, which the hub sets from the report's `busy`.
102
+ * observes of it. Not `nodeBusy` and `nodeHold`, which the hub sets from the
103
+ * report's `busy` and `session_id` (deviceClaims.ts nodeHoldOf).
102
104
  */
103
105
  exports.NODE_REPORT_FIELDS = new Set(Object.keys(OWNER).filter((column) => OWNER[column] === 'discovery' || OWNER[column] === 'observed'));
104
106
  /** Only the columns of `data` a hub takes from a node's report. */
@@ -254,7 +254,7 @@ class PrismaDeviceStore {
254
254
  return __awaiter(this, void 0, void 0, function* () {
255
255
  const { udid, host } = device;
256
256
  const nodeBusy = device.busy === true;
257
- const reported = Object.assign(Object.assign({}, (0, deviceFieldOwners_1.pickNodeReportFields)(this.fromIDevice(device))), { nodeBusy });
257
+ const reported = Object.assign(Object.assign({}, (0, deviceFieldOwners_1.pickNodeReportFields)(this.fromIDevice(device))), { nodeBusy, nodeHold: (0, deviceClaims_1.nodeHoldOf)(device.session_id) });
258
258
  const row = yield this.prisma.device.upsert({
259
259
  where: { udid_host: { udid, host } },
260
260
  update: nodeBusy ? Object.assign(Object.assign({}, reported), { busy: true }) : reported,
@@ -386,11 +386,11 @@ class PrismaDeviceStore {
386
386
  return cleared.count > 0;
387
387
  });
388
388
  }
389
- markNodeBusy(udid, host) {
389
+ markNodeBusy(udid, host, hold) {
390
390
  return __awaiter(this, void 0, void 0, function* () {
391
391
  yield this.prisma.device.updateMany({
392
392
  where: { udid, host },
393
- data: { nodeBusy: true, busy: true },
393
+ data: Object.assign({ nodeBusy: true, busy: true }, (hold ? { nodeHold: hold } : {})),
394
394
  });
395
395
  });
396
396
  }
@@ -54,6 +54,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
54
54
  return (mod && mod.__esModule) ? mod : { "default": mod };
55
55
  };
56
56
  Object.defineProperty(exports, "__esModule", { value: true });
57
+ exports.ANDROID_INSTALL_TIMEOUT_MS = void 0;
57
58
  exports.includesAndroidDevice = includesAndroidDevice;
58
59
  const displayState_1 = require("./android/displayState");
59
60
  const networkAddresses_1 = require("../helpers/networkAddresses");
@@ -81,6 +82,13 @@ const deviceIdentity_1 = require("./deviceIdentity");
81
82
  const localDeviceHosts_1 = require("./localDeviceHosts");
82
83
  const PluginContext_1 = require("../PluginContext");
83
84
  const typedi_2 = require("typedi");
85
+ /**
86
+ * How long `adb install` may take. It answers when the phone has installed
87
+ * the app, which for a large app on a slow phone is minutes; appium-adb's
88
+ * own limit (adbExecTimeout, 20 s) reported such installs as failed though
89
+ * they had worked. Within the 15 minutes a hub gives a node's install.
90
+ */
91
+ exports.ANDROID_INSTALL_TIMEOUT_MS = 10 * 60000;
84
92
  /**
85
93
  * Whether a server set to `androidDeviceType` serves this phone: real phones,
86
94
  * emulators ('simulated'), or both, and with `bootedEmulators` only the
@@ -784,7 +792,9 @@ let AndroidDeviceManager = class AndroidDeviceManager {
784
792
  const { adbInstance } = yield this.getAdb();
785
793
  if (!adbInstance)
786
794
  throw new Error('ADB is not available');
787
- yield adbInstance.adbExec(['-s', udid, 'install', '-r', appPath]);
795
+ yield adbInstance.adbExec(['-s', udid, 'install', '-r', appPath], {
796
+ timeout: exports.ANDROID_INSTALL_TIMEOUT_MS,
797
+ });
788
798
  });
789
799
  }
790
800
  uninstallApp(udid, bundleId) {
@@ -74,12 +74,19 @@ function sendToNode(request) {
74
74
  const url = new URL(request.url);
75
75
  const lib = url.protocol === 'https:' ? https_1.default : http_1.default;
76
76
  const headers = Object.assign({}, request.headers);
77
- if (request.body)
78
- headers['content-length'] = request.body.length;
77
+ const body = request.body;
78
+ if (Buffer.isBuffer(body))
79
+ headers['content-length'] = body.length;
79
80
  return new Promise((resolve, reject) => {
80
81
  const outgoing = lib.request(url, { method: request.method, headers, agent: proxyAgentFor(url), signal: request.signal }, resolve);
81
82
  outgoing.on('error', reject);
82
- outgoing.end(request.body);
83
+ if (body && !Buffer.isBuffer(body)) {
84
+ body.on('error', (err) => outgoing.destroy(err));
85
+ body.pipe(outgoing);
86
+ }
87
+ else {
88
+ outgoing.end(body);
89
+ }
83
90
  });
84
91
  }
85
92
  function copyResponseHeaders(upstream, res) {