@xenon-device-management/xenon 2.2.0 → 2.4.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 (105) hide show
  1. package/lib/package.json +1 -1
  2. package/lib/public/assets/{AnnotationOverlay-XpewX5nn.js → AnnotationOverlay-CpoWcXKP.js} +1 -1
  3. package/lib/public/assets/{ApiKeyGate-BQDv3ZCC.js → ApiKeyGate-DAYuoJDl.js} +1 -1
  4. package/lib/public/assets/{BugReportButton-C7OmS61B.js → BugReportButton-DEXyENUv.js} +1 -1
  5. package/lib/public/assets/DeviceMosaicView-C-sJRz63.js +2 -0
  6. package/lib/public/assets/{EmptyState-fPxujqqE.js → EmptyState-B_JRYedM.js} +1 -1
  7. package/lib/public/assets/{FieldGroup-5bCutWrs.js → FieldGroup-CorXlSLU.js} +1 -1
  8. package/lib/public/assets/{FilterMenu-owsBiDT1.js → FilterMenu-BKv95DEc.js} +1 -1
  9. package/lib/public/assets/{Menu-DFhqzPSt.js → Menu-Cpy5yMwg.js} +1 -1
  10. package/lib/public/assets/{Modal-Bw-a-E-e.js → Modal-Djp4NvyZ.js} +1 -1
  11. package/lib/public/assets/{RecordingPage-WtByf5bC.js → RecordingPage-CP57OKw9.js} +1 -1
  12. package/lib/public/assets/{RecordingsPage-FpWWFNgT.js → RecordingsPage-BjrTit38.js} +1 -1
  13. package/lib/public/assets/{SegmentedControl-Jzn9c7o8.js → SegmentedControl-MGTzJhBf.js} +1 -1
  14. package/lib/public/assets/{SettingCard-BR7nOhpT.js → SettingCard-CYBPhKPe.js} +1 -1
  15. package/lib/public/assets/{Table-B_Va3V05.js → Table-CdKQ9hnU.js} +1 -1
  16. package/lib/public/assets/{activity-DxRFWvkN.js → activity-QsRK21QF.js} +1 -1
  17. package/lib/public/assets/{ai-settings-CQf-9_V6.js → ai-settings-Dn6DtRn7.js} +1 -1
  18. package/lib/public/assets/{api-keys-SoE1Kd7N.js → api-keys-DYyf17eU.js} +1 -1
  19. package/lib/public/assets/{apps-CgGXTlgn.js → apps-HKFKTe1J.js} +1 -1
  20. package/lib/public/assets/{arrow-left-uQ6WIwiC.js → arrow-left-BkA7oMRe.js} +1 -1
  21. package/lib/public/assets/{arrow-right-C9RyfzXS.js → arrow-right-BsSr58EU.js} +1 -1
  22. package/lib/public/assets/{arrow-up-right-DFHi_8yG.js → arrow-up-right-B7TEV_Gr.js} +1 -1
  23. package/lib/public/assets/{auth-shell-Bii1t4qc.js → auth-shell-BlmNHGcG.js} +2 -2
  24. package/lib/public/assets/{builds-page-Bnv8Z9hi.js → builds-page-E4WIKtl8.js} +1 -1
  25. package/lib/public/assets/{button-_Y78wXiP.js → button-BIwCF2bR.js} +1 -1
  26. package/lib/public/assets/{calendar-D4MqSOc2.js → calendar-CMLnXC7N.js} +1 -1
  27. package/lib/public/assets/{check-uGHCXe-L.js → check-zugO3p5U.js} +1 -1
  28. package/lib/public/assets/{chevron-left-Dy1OqpzB.js → chevron-left-sCqxpTY6.js} +1 -1
  29. package/lib/public/assets/{chevron-right-BUsldEli.js → chevron-right-Bb59xwZc.js} +1 -1
  30. package/lib/public/assets/{circle-check-D3Qq0tKp.js → circle-check-DsvxbnTg.js} +1 -1
  31. package/lib/public/assets/{circle-x-B4IOD1DE.js → circle-x-w69AbmbW.js} +1 -1
  32. package/lib/public/assets/{clock-BfYfzvSg.js → clock-C6XOs4bc.js} +1 -1
  33. package/lib/public/assets/{copy-DZwYE5Va.js → copy-pHk-SWBM.js} +1 -1
  34. package/lib/public/assets/{device-explorer-CRG4Utt5.js → device-explorer-BDq38IcO.js} +52 -52
  35. package/lib/public/assets/{download-CCDYo_TN.js → download-CAHtPZmY.js} +1 -1
  36. package/lib/public/assets/{forgot-password-DuLCmVFb.js → forgot-password-cmvhGOUu.js} +1 -1
  37. package/lib/public/assets/{funnel-CtKNPqzb.js → funnel-BrDuhOtM.js} +1 -1
  38. package/lib/public/assets/{index-3uxl3o7K.js → index-BQCXPOXt.js} +2 -2
  39. package/lib/public/assets/{index-Zy1Op5C1.js → index-C-Q4QkXb.js} +1 -1
  40. package/lib/public/assets/{input-CzTWvUXZ.js → input-DnUVHYax.js} +1 -1
  41. package/lib/public/assets/{keycodes-CI-nYDFM.js → keycodes-CmOptFVa.js} +4 -4
  42. package/lib/public/assets/{layers-YZZGFyTX.js → layers-DRtz_bzF.js} +1 -1
  43. package/lib/public/assets/{lock-L8tzWgDo.js → lock-DF2VnPFm.js} +1 -1
  44. package/lib/public/assets/{login-DBbX-fu8.js → login-DHYffYa4.js} +1 -1
  45. package/lib/public/assets/{maintenance-settings-AJwkOIsE.js → maintenance-settings-r0uPsJ6n.js} +1 -1
  46. package/lib/public/assets/{monitor-Di_66F2m.js → monitor-7nt9oGNx.js} +1 -1
  47. package/lib/public/assets/{mouse-pointer-2-Cnab2zdg.js → mouse-pointer-2-ys1MKVr2.js} +1 -1
  48. package/lib/public/assets/{network-DiSumY-1.js → network-B8m-F79k.js} +1 -1
  49. package/lib/public/assets/{overview-CwE4gXl5.js → overview-BHEntlXz.js} +1 -1
  50. package/lib/public/assets/{page-header-CfR4Sm9V.js → page-header-CuFXVUop.js} +1 -1
  51. package/lib/public/assets/{plus-DOS8P9CQ.js → plus-DUSlMac2.js} +1 -1
  52. package/lib/public/assets/{profile-page-BYpHwQIH.js → profile-page-CBamPy_h.js} +1 -1
  53. package/lib/public/assets/{reset-password-BRVJU_qV.js → reset-password-DG0vZl-o.js} +1 -1
  54. package/lib/public/assets/{runbook-page-Bi3JvAFZ.js → runbook-page-5S3T-I4g.js} +1 -1
  55. package/lib/public/assets/{select-Cqx7pEou.js → select-f0ci1QC7.js} +1 -1
  56. package/lib/public/assets/{selector-detail-page-C0DsncKj.js → selector-detail-page-BXA9LUf9.js} +1 -1
  57. package/lib/public/assets/{selector-health-CAhpdilF.js → selector-health-TyMPgAWo.js} +1 -1
  58. package/lib/public/assets/{selector-health-page-CrCVeW2m.js → selector-health-page-uBlXy33G.js} +1 -1
  59. package/lib/public/assets/{session-detail-page-F_hwYqEt.js → session-detail-page-DECim5lh.js} +1 -1
  60. package/lib/public/assets/{settings-C-s5pypL.js → settings-IfOFbqtv.js} +1 -1
  61. package/lib/public/assets/{status-pill-outline-DY5uzv6A.js → status-pill-outline-grXLf8I8.js} +1 -1
  62. package/lib/public/assets/{tablet-Dli7gwyg.js → tablet-lmFk4qxW.js} +1 -1
  63. package/lib/public/assets/{teams-ui4IhHNC.js → teams-CwMSjVVW.js} +1 -1
  64. package/lib/public/assets/{trash-2-BV4hSmXJ.js → trash-2-BKsgmBS6.js} +1 -1
  65. package/lib/public/assets/{upload-DkGy_J-L.js → upload-DXH47sQS.js} +1 -1
  66. package/lib/public/assets/{use-password-reset-mode-BFtMqQOh.js → use-password-reset-mode-DU98dxpc.js} +1 -1
  67. package/lib/public/assets/{users-Nb_rzRmF.js → users-V5O64rVx.js} +1 -1
  68. package/lib/public/assets/{users-BU0nXIVe.js → users-eXWwQZjX.js} +1 -1
  69. package/lib/public/assets/{video-off-CUNzD2kH.js → video-off-BuoIHEYy.js} +1 -1
  70. package/lib/public/assets/{webhook-settings-Ca3UFXZe.js → webhook-settings-ClsxsKnC.js} +1 -1
  71. package/lib/public/assets/{zap-B2bH2vKX.js → zap-I10vdQXd.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 +288 -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/src/services/recording/RecordingOrchestrator.js +39 -5
  89. package/lib/src/services/recording/busy-precheck.js +5 -2
  90. package/lib/src/services/recording/nodeRecordingSource.js +156 -0
  91. package/lib/test/unit/AndroidDeviceManager.spec.js +12 -0
  92. package/lib/test/unit/busy-precheck.spec.js +32 -0
  93. package/lib/test/unit/hub-node-control-forward.spec.js +66 -10
  94. package/lib/test/unit/hub-node-race.spec.js +29 -3
  95. package/lib/test/unit/mark-node-busy-loki.spec.js +17 -4
  96. package/lib/test/unit/node-phone-control.spec.js +124 -4
  97. package/lib/test/unit/node-recording-relay.spec.js +165 -0
  98. package/lib/test/unit/recording-orchestrator.spec.js +86 -2
  99. package/lib/test/unit/screenshot-route.spec.js +104 -0
  100. package/lib/test/unit/upload-install-route.spec.js +205 -0
  101. package/lib/tsconfig.tsbuildinfo +1 -1
  102. package/package.json +1 -1
  103. package/prisma/migrations/20261001120000_device_node_hold/migration.sql +2 -0
  104. package/prisma/schema.prisma +4 -0
  105. 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,7 +54,10 @@ 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");
60
+ const recording_store_1 = require("../../services/recording/recording-store");
28
61
  const deviceAccessPolicy_1 = require("../../services/device-access/deviceAccessPolicy");
29
62
  const controlDevice_1 = require("../../middleware/controlDevice");
30
63
  /**
@@ -66,6 +99,8 @@ exports.NODE_FORWARDED_CONTROL = [
66
99
  'POST stream/stop',
67
100
  // The MJPEG stream itself: relayed for as long as the viewer watches.
68
101
  'GET stream',
102
+ // The upload is streamed on as it came: nothing here reads it first.
103
+ 'POST upload-install',
69
104
  ];
70
105
  /**
71
106
  * Answered by this server's own handler.
@@ -77,19 +112,36 @@ exports.NODE_FORWARDED_CONTROL = [
77
112
  * which get a ticket of the node's own.
78
113
  */
79
114
  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.
115
+ /**
116
+ * Answered by this server's own handler for a node's phone (a cloud
117
+ * provider's is refused), which does its part here and asks the node for
118
+ * the rest:
119
+ * - install-repository-app: the app is in this server's library, checked
120
+ * against the caller's teams here, and sent to the node's upload-install
121
+ * (installFileOnNode).
122
+ * - omni-scan, test-locator: Omni-Vision runs here, with this server's AI
123
+ * settings, on the node's screenshot (screenshotFromNode).
124
+ */
125
+ exports.ANSWERED_HERE_FOR_NODES = [
126
+ 'POST install-repository-app',
127
+ 'GET omni-scan',
128
+ 'POST test-locator',
129
+ ];
130
+ // Not on any list, so refused: install takes a path, which names a file on
131
+ // one machine only.
87
132
  /**
88
133
  * A node that hasn't started answering in this long is treated as gone. Once
89
134
  * it has, the answer may take as long as it takes: a stream lasts as long as
90
135
  * its viewer.
91
136
  */
92
137
  exports.NODE_CONTROL_TIMEOUT_MS = 60000;
138
+ /**
139
+ * An install is answered when it is done, which can take minutes (a large
140
+ * app, a slow phone): its node gets this long to start answering instead.
141
+ */
142
+ exports.NODE_INSTALL_TIMEOUT_MS = 15 * 60000;
143
+ /** Forwarded actions that are installs, and get NODE_INSTALL_TIMEOUT_MS. */
144
+ const INSTALLS = ['POST upload-install'];
93
145
  // Cloud metadata endpoints — never proxy to these regardless of caller.
94
146
  const FORBIDDEN_PROXY_HOSTS = new Set([
95
147
  '169.254.169.254', // AWS/Azure/GCP IMDS
@@ -166,10 +218,16 @@ function refuse(res, error, message) {
166
218
  return res.status(501).json({ success: false, error, message });
167
219
  }
168
220
  function nodePhoneControl(deps = {}) {
169
- var _a, _b, _c;
221
+ var _a, _b, _c, _d, _e, _f, _g;
170
222
  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));
223
+ const activeRecordingFor = (_b = deps.activeRecordingFor) !== null && _b !== void 0 ? _b : ((udid) => typedi_1.Container.get(recording_store_1.RecordingStore).activeRecordingFor(udid));
224
+ const forwardDeps = {
225
+ timeoutMs: (_c = deps.timeoutMs) !== null && _c !== void 0 ? _c : exports.NODE_CONTROL_TIMEOUT_MS,
226
+ installTimeoutMs: (_d = deps.installTimeoutMs) !== null && _d !== void 0 ? _d : exports.NODE_INSTALL_TIMEOUT_MS,
227
+ markNodeBusy: (_e = deps.markNodeBusy) !== null && _e !== void 0 ? _e : ((udid, host, hold) => device_store_1.DeviceStoreFactory.getStore().markNodeBusy(udid, host, hold)),
228
+ describeHolder: (_f = deps.describeHolder) !== null && _f !== void 0 ? _f : ((id) => typedi_1.Container.get(SessionOwnerResolver_1.SessionOwnerResolver).displayName(id)),
229
+ controlToken: (_g = deps.controlToken) !== null && _g !== void 0 ? _g : defaultControlToken,
230
+ };
173
231
  return function (req, res, next) {
174
232
  return __awaiter(this, void 0, void 0, function* () {
175
233
  var _a;
@@ -201,30 +259,53 @@ function nodePhoneControl(deps = {}) {
201
259
  if (device.cloud) {
202
260
  return refuse(res, 'not_available_for_cloud_phone', "This phone is a cloud provider's. Device control isn't available for it here.");
203
261
  }
262
+ if (exports.ANSWERED_HERE_FOR_NODES.includes(key))
263
+ return next();
204
264
  const node = originOf(device.host);
205
265
  if (!exports.NODE_FORWARDED_CONTROL.includes(key)) {
206
266
  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
267
  }
208
- return forward(req, res, { udid, device, node, timeoutMs, markNodeBusy });
268
+ if (key === 'POST stream/stop') {
269
+ // This server may be recording the phone from the node's stream
270
+ // (nodeRecordingSource.ts). The node doesn't know, and stopping its
271
+ // stream would cut the recording short: refused as /control refuses a
272
+ // stop for its own phones being recorded.
273
+ const recording = yield activeRecordingFor(udid).catch((e) => {
274
+ var _a;
275
+ logger_1.default.error(`nodePhoneControl: recording lookup failed for ${udid}: ${(_a = e === null || e === void 0 ? void 0 : e.message) !== null && _a !== void 0 ? _a : e}`);
276
+ return undefined;
277
+ });
278
+ if (recording === undefined)
279
+ return res.status(503).json((0, deviceAccessPolicy_1.ownershipUnavailableBody)());
280
+ if (recording) {
281
+ return res.status(409).json({
282
+ success: false,
283
+ error: 'device_recording',
284
+ message: 'This device is being recorded. Stop the recording first.',
285
+ groupId: recording.groupId,
286
+ });
287
+ }
288
+ }
289
+ return forward(req, res, { udid, device, node }, forwardDeps);
209
290
  });
210
291
  };
211
292
  }
212
- function forward(req, res, target) {
293
+ const defaultControlToken = (grant) => typedi_1.Container.get(hubSessionToken_1.HubSessionTokenIssuer).controlTokenFor(grant);
294
+ /**
295
+ * The headers of a call to the phone's node, signed for the caller. Null
296
+ * token when this server can't sign (it warns once): the call goes without,
297
+ * which a node with auth disabled accepts and one with auth enabled refuses.
298
+ */
299
+ function signedHeaders(req, udid, device, controlToken) {
213
300
  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' });
301
+ var _a;
219
302
  const actor = (0, actor_1.resolveActor)(req);
220
303
  const headers = {
221
304
  accept: (_a = req.headers.accept) !== null && _a !== void 0 ? _a : 'application/json',
222
305
  'accept-encoding': 'identity',
223
306
  };
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
307
  const token = actor.userId
227
- ? yield typedi_1.Container.get(hubSessionToken_1.HubSessionTokenIssuer).controlTokenFor({
308
+ ? yield controlToken({
228
309
  userId: actor.userId,
229
310
  isAdmin: actor.isAdmin,
230
311
  udid,
@@ -233,44 +314,42 @@ function forward(req, res, target) {
233
314
  : null;
234
315
  if (token)
235
316
  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
- }
317
+ return headers;
318
+ });
319
+ }
320
+ /**
321
+ * Send one call to the node and relay its answer to `res`. A node that can't
322
+ * be reached is 502, and one that hasn't started answering in `timeoutMs` is
323
+ * 504; once it has, the answer takes as long as it takes. `onAnswer` sees the
324
+ * node's answer first and may reply itself (returning true). Sent once: a
325
+ * retry after a slow node's timeout or 5xx could land the same tap twice.
326
+ */
327
+ function relayNodeCall(res, call, onAnswer) {
328
+ return __awaiter(this, void 0, void 0, function* () {
329
+ var _a, _b, _c;
241
330
  const controller = new AbortController();
242
331
  let timedOut = false;
243
332
  const timer = setTimeout(() => {
244
333
  timedOut = true;
245
334
  controller.abort();
246
- }, timeoutMs);
335
+ }, call.timeoutMs);
247
336
  // The caller left: stop waiting for the node on their behalf.
248
337
  const onClose = () => {
249
338
  if (!res.writableFinished)
250
339
  controller.abort();
251
340
  };
252
341
  res.on('close', onClose);
253
- logger_1.default.debug(`Forwarding ${req.method} ${(0, controlDevice_1.controlAction)(req)} for ${udid} to node ${node}`);
254
342
  try {
255
343
  const upstream = yield (0, forwardToNode_1.sendToNode)({
256
- url,
257
- method: req.method,
258
- headers,
259
- body,
344
+ url: call.url,
345
+ method: call.method,
346
+ headers: call.headers,
347
+ body: call.body,
260
348
  signal: controller.signal,
261
349
  });
262
- // The node has answered: from here the answer takes as long as it takes.
263
350
  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
- }
351
+ if (onAnswer && (yield onAnswer(upstream)))
352
+ return;
274
353
  yield (0, forwardToNode_1.relayAnswer)(upstream, res, false);
275
354
  }
276
355
  catch (e) {
@@ -281,15 +360,15 @@ function forward(req, res, target) {
281
360
  res.status(504).json({
282
361
  success: false,
283
362
  error: 'node_timeout',
284
- message: `Node ${node} didn't answer within ${Math.round(timeoutMs / 1000)} s.`,
363
+ message: `Node ${call.node} didn't answer within ${Math.round(call.timeoutMs / 1000)} s.`,
285
364
  });
286
365
  }
287
366
  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}`);
367
+ 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
368
  res.status(502).json({
290
369
  success: false,
291
370
  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}`,
371
+ 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
372
  });
294
373
  }
295
374
  }
@@ -299,3 +378,166 @@ function forward(req, res, target) {
299
378
  }
300
379
  });
301
380
  }
381
+ function forward(req, res, target, deps) {
382
+ return __awaiter(this, void 0, void 0, function* () {
383
+ var _a;
384
+ const { udid, device, node } = target;
385
+ const url = nodeControlUrl(device.host, req);
386
+ if (!url)
387
+ return res.status(400).json({ error: 'Unsafe device host' });
388
+ const key = `${req.method} ${(0, controlDevice_1.controlAction)(req)}`;
389
+ const actor = (0, actor_1.resolveActor)(req);
390
+ const headers = yield signedHeaders(req, udid, device, deps.controlToken);
391
+ let body;
392
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
393
+ const type = req.headers['content-type'];
394
+ if (typeof type === 'string' && type.toLowerCase().startsWith('multipart/')) {
395
+ // An upload, streamed on as it came: the route's own parser runs after
396
+ // this, so nothing here has read it.
397
+ body = req;
398
+ headers['content-type'] = type;
399
+ if (req.headers['content-length'])
400
+ headers['content-length'] = req.headers['content-length'];
401
+ }
402
+ else {
403
+ body = Buffer.from(JSON.stringify((_a = req.body) !== null && _a !== void 0 ? _a : {}));
404
+ headers['content-type'] = 'application/json';
405
+ }
406
+ }
407
+ logger_1.default.debug(`Forwarding ${key} for ${udid} to node ${node}`);
408
+ const timeoutMs = INSTALLS.includes(key) ? deps.installTimeoutMs : deps.timeoutMs;
409
+ yield relayNodeCall(res, { url, method: req.method, headers, body, timeoutMs, node, udid }, (upstream) => __awaiter(this, void 0, void 0, function* () {
410
+ var _a;
411
+ const status = (_a = upstream.statusCode) !== null && _a !== void 0 ? _a : 502;
412
+ if (key === 'POST stream/start' && status < 300) {
413
+ // The node holds the phone for the preview now, and says so in its
414
+ // next report, up to an interval away. Until then this server could
415
+ // hand the phone to a session the node would refuse, and would show
416
+ // no holder. The node writes the same hold for the same user.
417
+ const hold = actor.userId ? (0, manualLock_1.formatManualLock)(actor.userId, udid) : undefined;
418
+ yield deps.markNodeBusy(udid, device.host, hold).catch((e) => {
419
+ var _a;
420
+ 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}`);
421
+ });
422
+ }
423
+ if (status === 409) {
424
+ yield relayRefusal(yield (0, forwardToNode_1.readAnswer)(upstream), res, deps.describeHolder);
425
+ return true;
426
+ }
427
+ return false;
428
+ }));
429
+ });
430
+ }
431
+ /**
432
+ * A file as a multipart upload in one field, streamed from disk, with its
433
+ * length known so the node's parser can apply its limit up front.
434
+ */
435
+ function multipartFile(field, file) {
436
+ return __awaiter(this, void 0, void 0, function* () {
437
+ const boundary = `----xenon-${(0, crypto_1.randomBytes)(12).toString('hex')}`;
438
+ const name = file.name.replace(/["\\\r\n]/g, '_');
439
+ const head = Buffer.from(`--${boundary}\r\n` +
440
+ `Content-Disposition: form-data; name="${field}"; filename="${name}"\r\n` +
441
+ 'Content-Type: application/octet-stream\r\n\r\n');
442
+ const tail = Buffer.from(`\r\n--${boundary}--\r\n`);
443
+ const { size } = yield fs_1.default.promises.stat(file.path);
444
+ const stream = stream_1.Readable.from((function () {
445
+ return __asyncGenerator(this, arguments, function* () {
446
+ yield yield __await(head);
447
+ yield __await(yield* __asyncDelegator(__asyncValues(fs_1.default.createReadStream(file.path))));
448
+ yield yield __await(tail);
449
+ });
450
+ })());
451
+ return {
452
+ type: `multipart/form-data; boundary=${boundary}`,
453
+ length: head.length + size + tail.length,
454
+ stream,
455
+ };
456
+ });
457
+ }
458
+ /**
459
+ * Install a file of this server's on another server's phone: sent to the
460
+ * node's upload-install as a multipart upload, signed for the caller, and
461
+ * the node's answer relayed. install-repository-app's, for an app from this
462
+ * server's library, which the node doesn't have. The file's name keeps the
463
+ * extension the node's installer reads.
464
+ */
465
+ function installFileOnNode(req_1, res_1, udid_1, device_1, file_1) {
466
+ return __awaiter(this, arguments, void 0, function* (req, res, udid, device, file, deps = {}) {
467
+ var _a, _b;
468
+ const origin = safeNodeOrigin(device.host);
469
+ if (!origin) {
470
+ res.status(400).json({ error: 'Unsafe device host' });
471
+ return;
472
+ }
473
+ const headers = yield signedHeaders(req, udid, device, (_a = deps.controlToken) !== null && _a !== void 0 ? _a : defaultControlToken);
474
+ const upload = yield multipartFile('app', file);
475
+ headers['content-type'] = upload.type;
476
+ headers['content-length'] = upload.length;
477
+ logger_1.default.info(`Sending ${file.name} to node ${origin} to install on ${udid}`);
478
+ yield relayNodeCall(res, {
479
+ url: `${origin}/xenon/api/control/${encodeURIComponent(udid)}/upload-install`,
480
+ method: 'POST',
481
+ headers,
482
+ body: upload.stream,
483
+ timeoutMs: (_b = deps.timeoutMs) !== null && _b !== void 0 ? _b : exports.NODE_INSTALL_TIMEOUT_MS,
484
+ node: origin,
485
+ udid,
486
+ });
487
+ });
488
+ }
489
+ /**
490
+ * The node's screenshot of another server's phone, base64, for work this
491
+ * server does on it: Omni-Vision, with this server's AI settings. Throws when
492
+ * the node doesn't give one.
493
+ */
494
+ function screenshotFromNode(req_1, udid_1, device_1) {
495
+ return __awaiter(this, arguments, void 0, function* (req, udid, device, deps = {}) {
496
+ var _a, _b, _c;
497
+ const origin = safeNodeOrigin(device.host);
498
+ if (!origin)
499
+ throw new Error('Unsafe device host');
500
+ const headers = yield signedHeaders(req, udid, device, (_a = deps.controlToken) !== null && _a !== void 0 ? _a : defaultControlToken);
501
+ headers.accept = 'application/json';
502
+ const answer = yield (0, forwardToNode_1.readAnswer)(yield (0, forwardToNode_1.sendToNode)({
503
+ url: `${origin}/xenon/api/control/${encodeURIComponent(udid)}/screenshot`,
504
+ method: 'GET',
505
+ headers,
506
+ signal: AbortSignal.timeout((_b = deps.timeoutMs) !== null && _b !== void 0 ? _b : exports.NODE_CONTROL_TIMEOUT_MS),
507
+ }));
508
+ if (answer.status !== 200) {
509
+ throw new Error(`Node ${origin} answered ${answer.status} for a screenshot`);
510
+ }
511
+ const shot = (_c = JSON.parse(answer.body.toString('utf8'))) === null || _c === void 0 ? void 0 : _c.screenshot;
512
+ if (typeof shot !== 'string' || !shot)
513
+ throw new Error(`Node ${origin} sent no screenshot`);
514
+ return shot;
515
+ });
516
+ }
517
+ const HOLDER_REFUSALS = new Set(['device_held_by_another_user', 'device_in_use_by_session']);
518
+ /**
519
+ * A node's 409, with the holder's name filled in when the node could only
520
+ * give their id: its database has no rows for this server's users, so it
521
+ * says "another user". The wording is denyBody's, as for this server's own
522
+ * refusals. Anything else is passed on as it came.
523
+ */
524
+ function relayRefusal(answer, res, describeHolder) {
525
+ return __awaiter(this, void 0, void 0, function* () {
526
+ var _a;
527
+ let refusal;
528
+ try {
529
+ refusal = JSON.parse(answer.body.toString('utf8'));
530
+ }
531
+ catch (_b) {
532
+ return (0, forwardToNode_1.sendAnswer)(res, answer);
533
+ }
534
+ const holderId = (_a = refusal === null || refusal === void 0 ? void 0 : refusal.holder) === null || _a === void 0 ? void 0 : _a.userId;
535
+ if (!HOLDER_REFUSALS.has(refusal === null || refusal === void 0 ? void 0 : refusal.error) || typeof holderId !== 'string' || refusal.holder.name) {
536
+ return (0, forwardToNode_1.sendAnswer)(res, answer);
537
+ }
538
+ const name = yield describeHolder(holderId).catch(() => null);
539
+ if (!name)
540
+ return (0, forwardToNode_1.sendAnswer)(res, answer);
541
+ res.status(409).json(Object.assign(Object.assign({}, refusal), (0, deviceAccessPolicy_1.denyBody)(refusal.error, holderId, name)));
542
+ });
543
+ }
@@ -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) {