@xenon-device-management/xenon 2.3.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 (82) hide show
  1. package/lib/package.json +1 -1
  2. package/lib/public/assets/{AnnotationOverlay-AxcBYoKp.js → AnnotationOverlay-CpoWcXKP.js} +1 -1
  3. package/lib/public/assets/{ApiKeyGate-CIU02EIx.js → ApiKeyGate-DAYuoJDl.js} +1 -1
  4. package/lib/public/assets/{BugReportButton-_akEEjXm.js → BugReportButton-DEXyENUv.js} +1 -1
  5. package/lib/public/assets/{DeviceMosaicView-Bhk1udmn.js → DeviceMosaicView-C-sJRz63.js} +1 -1
  6. package/lib/public/assets/{EmptyState-DRKRm19_.js → EmptyState-B_JRYedM.js} +1 -1
  7. package/lib/public/assets/{FieldGroup-zQQHPh7x.js → FieldGroup-CorXlSLU.js} +1 -1
  8. package/lib/public/assets/{FilterMenu-C9_StnDi.js → FilterMenu-BKv95DEc.js} +1 -1
  9. package/lib/public/assets/{Menu-0J7io6pF.js → Menu-Cpy5yMwg.js} +1 -1
  10. package/lib/public/assets/{Modal-C677ljNX.js → Modal-Djp4NvyZ.js} +1 -1
  11. package/lib/public/assets/{RecordingPage-BDJFck-L.js → RecordingPage-CP57OKw9.js} +1 -1
  12. package/lib/public/assets/{RecordingsPage-DQkimbKK.js → RecordingsPage-BjrTit38.js} +1 -1
  13. package/lib/public/assets/{SegmentedControl-DvGMZBIE.js → SegmentedControl-MGTzJhBf.js} +1 -1
  14. package/lib/public/assets/{SettingCard-ogyouYBq.js → SettingCard-CYBPhKPe.js} +1 -1
  15. package/lib/public/assets/{Table-BLFCOLlO.js → Table-CdKQ9hnU.js} +1 -1
  16. package/lib/public/assets/{activity-BU55TGRG.js → activity-QsRK21QF.js} +1 -1
  17. package/lib/public/assets/{ai-settings-GpDAiHav.js → ai-settings-Dn6DtRn7.js} +1 -1
  18. package/lib/public/assets/{api-keys-dOzP0k8_.js → api-keys-DYyf17eU.js} +1 -1
  19. package/lib/public/assets/{apps-mAwAQmkn.js → apps-HKFKTe1J.js} +1 -1
  20. package/lib/public/assets/{arrow-left-Dg9mxe9T.js → arrow-left-BkA7oMRe.js} +1 -1
  21. package/lib/public/assets/{arrow-right-BVcmUyq3.js → arrow-right-BsSr58EU.js} +1 -1
  22. package/lib/public/assets/{arrow-up-right-ByR5pZrH.js → arrow-up-right-B7TEV_Gr.js} +1 -1
  23. package/lib/public/assets/{auth-shell-Jpgk8CgY.js → auth-shell-BlmNHGcG.js} +2 -2
  24. package/lib/public/assets/{builds-page-VnRTrcdl.js → builds-page-E4WIKtl8.js} +1 -1
  25. package/lib/public/assets/{button-D-X4wjO8.js → button-BIwCF2bR.js} +1 -1
  26. package/lib/public/assets/{calendar-ur_p5ETA.js → calendar-CMLnXC7N.js} +1 -1
  27. package/lib/public/assets/{check-K33iMeyq.js → check-zugO3p5U.js} +1 -1
  28. package/lib/public/assets/{chevron-left-DMVWShd-.js → chevron-left-sCqxpTY6.js} +1 -1
  29. package/lib/public/assets/{chevron-right-DphLJ2_X.js → chevron-right-Bb59xwZc.js} +1 -1
  30. package/lib/public/assets/{circle-check-CNFhu8oE.js → circle-check-DsvxbnTg.js} +1 -1
  31. package/lib/public/assets/{circle-x-BH_cUbqZ.js → circle-x-w69AbmbW.js} +1 -1
  32. package/lib/public/assets/{clock-CVMVdVvp.js → clock-C6XOs4bc.js} +1 -1
  33. package/lib/public/assets/{copy-CZ2XHafL.js → copy-pHk-SWBM.js} +1 -1
  34. package/lib/public/assets/{device-explorer-B94BhaqF.js → device-explorer-BDq38IcO.js} +1 -1
  35. package/lib/public/assets/{download-UUiyw7kO.js → download-CAHtPZmY.js} +1 -1
  36. package/lib/public/assets/{forgot-password-CrS14F79.js → forgot-password-cmvhGOUu.js} +1 -1
  37. package/lib/public/assets/{funnel-BMOE5qJY.js → funnel-BrDuhOtM.js} +1 -1
  38. package/lib/public/assets/{index-2XPPbPwy.js → index-BQCXPOXt.js} +2 -2
  39. package/lib/public/assets/{index-D0R9Yic3.js → index-C-Q4QkXb.js} +1 -1
  40. package/lib/public/assets/{input-C-1b8J7i.js → input-DnUVHYax.js} +1 -1
  41. package/lib/public/assets/{keycodes-Cly7dQc9.js → keycodes-CmOptFVa.js} +1 -1
  42. package/lib/public/assets/{layers-DFZfqGdX.js → layers-DRtz_bzF.js} +1 -1
  43. package/lib/public/assets/{lock-ylHMJpfP.js → lock-DF2VnPFm.js} +1 -1
  44. package/lib/public/assets/{login-DWsWKqDx.js → login-DHYffYa4.js} +1 -1
  45. package/lib/public/assets/{maintenance-settings-9B6_MTqW.js → maintenance-settings-r0uPsJ6n.js} +1 -1
  46. package/lib/public/assets/{monitor-CzX0hBH1.js → monitor-7nt9oGNx.js} +1 -1
  47. package/lib/public/assets/{mouse-pointer-2-CZHD7N3z.js → mouse-pointer-2-ys1MKVr2.js} +1 -1
  48. package/lib/public/assets/{network-Bikc4MyT.js → network-B8m-F79k.js} +1 -1
  49. package/lib/public/assets/{overview-DzaA0aMe.js → overview-BHEntlXz.js} +1 -1
  50. package/lib/public/assets/{page-header-DqGlDufy.js → page-header-CuFXVUop.js} +1 -1
  51. package/lib/public/assets/{plus-Ct6aQTGM.js → plus-DUSlMac2.js} +1 -1
  52. package/lib/public/assets/{profile-page-CCnUQ1Qk.js → profile-page-CBamPy_h.js} +1 -1
  53. package/lib/public/assets/{reset-password-AmkGGXo5.js → reset-password-DG0vZl-o.js} +1 -1
  54. package/lib/public/assets/{runbook-page-DQQn9VyE.js → runbook-page-5S3T-I4g.js} +1 -1
  55. package/lib/public/assets/{select-k5ogtEWW.js → select-f0ci1QC7.js} +1 -1
  56. package/lib/public/assets/{selector-detail-page-BCcwC5ku.js → selector-detail-page-BXA9LUf9.js} +1 -1
  57. package/lib/public/assets/{selector-health-BVyX6kIh.js → selector-health-TyMPgAWo.js} +1 -1
  58. package/lib/public/assets/{selector-health-page-CElBB6XT.js → selector-health-page-uBlXy33G.js} +1 -1
  59. package/lib/public/assets/{session-detail-page-6bqo_-ND.js → session-detail-page-DECim5lh.js} +1 -1
  60. package/lib/public/assets/{settings-DKBj9KyN.js → settings-IfOFbqtv.js} +1 -1
  61. package/lib/public/assets/{status-pill-outline-CZNpvqej.js → status-pill-outline-grXLf8I8.js} +1 -1
  62. package/lib/public/assets/{tablet-B6HI5OxV.js → tablet-lmFk4qxW.js} +1 -1
  63. package/lib/public/assets/{teams-owoDHbOU.js → teams-CwMSjVVW.js} +1 -1
  64. package/lib/public/assets/{trash-2-D61PoUrZ.js → trash-2-BKsgmBS6.js} +1 -1
  65. package/lib/public/assets/{upload-1ZYEVvgD.js → upload-DXH47sQS.js} +1 -1
  66. package/lib/public/assets/{use-password-reset-mode-Dx0ghiiQ.js → use-password-reset-mode-DU98dxpc.js} +1 -1
  67. package/lib/public/assets/{users-C5CS5Qez.js → users-V5O64rVx.js} +1 -1
  68. package/lib/public/assets/{users-PbGMd5sD.js → users-eXWwQZjX.js} +1 -1
  69. package/lib/public/assets/{video-off-_XusI0HK.js → video-off-BuoIHEYy.js} +1 -1
  70. package/lib/public/assets/{webhook-settings-Bi_HHQlQ.js → webhook-settings-ClsxsKnC.js} +1 -1
  71. package/lib/public/assets/{zap-uzHWFAKI.js → zap-I10vdQXd.js} +1 -1
  72. package/lib/public/index.html +1 -1
  73. package/lib/src/app/routers/nodePhoneControl.js +29 -6
  74. package/lib/src/services/recording/RecordingOrchestrator.js +39 -5
  75. package/lib/src/services/recording/busy-precheck.js +5 -2
  76. package/lib/src/services/recording/nodeRecordingSource.js +156 -0
  77. package/lib/test/unit/busy-precheck.spec.js +32 -0
  78. package/lib/test/unit/node-phone-control.spec.js +26 -0
  79. package/lib/test/unit/node-recording-relay.spec.js +165 -0
  80. package/lib/test/unit/recording-orchestrator.spec.js +86 -2
  81. package/lib/tsconfig.tsbuildinfo +1 -1
  82. package/package.json +1 -1
@@ -57,6 +57,7 @@ const forwardToNode_1 = require("../../gateway/forwardToNode");
57
57
  const manualLock_1 = require("../../services/recording/manualLock");
58
58
  const SessionOwnerResolver_1 = require("../../services/device-access/SessionOwnerResolver");
59
59
  const device_store_1 = require("../../data-service/device-store");
60
+ const recording_store_1 = require("../../services/recording/recording-store");
60
61
  const deviceAccessPolicy_1 = require("../../services/device-access/deviceAccessPolicy");
61
62
  const controlDevice_1 = require("../../middleware/controlDevice");
62
63
  /**
@@ -217,14 +218,15 @@ function refuse(res, error, message) {
217
218
  return res.status(501).json({ success: false, error, message });
218
219
  }
219
220
  function nodePhoneControl(deps = {}) {
220
- var _a, _b, _c, _d, _e, _f;
221
+ var _a, _b, _c, _d, _e, _f, _g;
221
222
  const findDevice = (_a = deps.findDevice) !== null && _a !== void 0 ? _a : controlDevice_1.findControlDeviceInStore;
223
+ const activeRecordingFor = (_b = deps.activeRecordingFor) !== null && _b !== void 0 ? _b : ((udid) => typedi_1.Container.get(recording_store_1.RecordingStore).activeRecordingFor(udid));
222
224
  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,
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,
228
230
  };
229
231
  return function (req, res, next) {
230
232
  return __awaiter(this, void 0, void 0, function* () {
@@ -263,6 +265,27 @@ function nodePhoneControl(deps = {}) {
263
265
  if (!exports.NODE_FORWARDED_CONTROL.includes(key)) {
264
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.`);
265
267
  }
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
+ }
266
289
  return forward(req, res, { udid, device, node }, forwardDeps);
267
290
  });
268
291
  };
@@ -70,6 +70,7 @@ const concurrency_gate_1 = require("./concurrency-gate");
70
70
  const device_service_1 = require("../../data-service/device-service");
71
71
  const device_store_1 = require("../../data-service/device-store");
72
72
  const manualLock_1 = require("./manualLock");
73
+ const nodeRecordingSource_1 = require("./nodeRecordingSource");
73
74
  const ensureMjpegForRecording_1 = require("./ensureMjpegForRecording");
74
75
  const probeDuration_1 = require("./probeDuration");
75
76
  const annotationImage_1 = require("./annotationImage");
@@ -136,13 +137,15 @@ class RecordingError extends Error {
136
137
  exports.RecordingError = RecordingError;
137
138
  let RecordingOrchestrator = class RecordingOrchestrator {
138
139
  constructor(deps = {}) {
139
- var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k;
140
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l;
140
141
  /**
141
142
  * Recording ids currently being closed out. Both the user-initiated stop and
142
143
  * an ffmpeg self-exit finalize rows, and they can land at the same instant —
143
144
  * this keeps exactly one of them doing it.
144
145
  */
145
146
  this.finalizing = new Set();
147
+ /** The relays node phones' recordings read, by recording id, until finalized. */
148
+ this.nodeSources = new Map();
146
149
  this._deps = {
147
150
  busyPrecheck: (_a = deps.busyPrecheck) !== null && _a !== void 0 ? _a : typedi_1.Container.get(busy_precheck_1.BusyPrecheck),
148
151
  store: (_b = deps.store) !== null && _b !== void 0 ? _b : typedi_1.Container.get(recording_store_1.RecordingStore),
@@ -153,8 +156,9 @@ let RecordingOrchestrator = class RecordingOrchestrator {
153
156
  this.blockDeviceFn = (_f = deps.blockDeviceFn) !== null && _f !== void 0 ? _f : device_service_1.blockDevice;
154
157
  this.unblockDeviceFn = (_g = deps.unblockDeviceFn) !== null && _g !== void 0 ? _g : device_service_1.unblockDevice;
155
158
  this.ensureMjpegPortFn = (_h = deps.ensureMjpegPortFn) !== null && _h !== void 0 ? _h : ensureMjpegForRecording_1.ensureMjpegForRecording;
156
- this.probeDurationMsFn = (_j = deps.probeDurationMsFn) !== null && _j !== void 0 ? _j : probeDuration_1.probeVideoDurationMs;
157
- this.now = (_k = deps.now) !== null && _k !== void 0 ? _k : Date.now;
159
+ this.nodeSourceFn = (_j = deps.nodeSourceFn) !== null && _j !== void 0 ? _j : ((udid, actorId) => (0, nodeRecordingSource_1.recordingSourceFor)(udid, actorId));
160
+ this.probeDurationMsFn = (_k = deps.probeDurationMsFn) !== null && _k !== void 0 ? _k : probeDuration_1.probeVideoDurationMs;
161
+ this.now = (_l = deps.now) !== null && _l !== void 0 ? _l : Date.now;
158
162
  }
159
163
  get busyPrecheck() {
160
164
  return this._deps.busyPrecheck;
@@ -267,7 +271,7 @@ let RecordingOrchestrator = class RecordingOrchestrator {
267
271
  const host = deviceHosts[udid];
268
272
  const filePath = typedi_1.Container.get(ArtifactStore_1.ARTIFACT_STORE).resolve(id, 'video', `${id}.mp4`);
269
273
  try {
270
- const mjpegPort = yield this.ensureMjpegPortFn(udid);
274
+ const mjpegPort = yield this.sourcePort(id, udid, actorId);
271
275
  mjpegPorts[udid] = mjpegPort;
272
276
  yield this.store.create({
273
277
  id,
@@ -294,6 +298,7 @@ let RecordingOrchestrator = class RecordingOrchestrator {
294
298
  }
295
299
  catch (err) {
296
300
  recLog.error(`Failed to start recording for ${udid}: ${err === null || err === void 0 ? void 0 : err.message}`);
301
+ yield this.closeNodeSource(id);
297
302
  // Mark this one failed but continue with the rest of the group.
298
303
  try {
299
304
  yield this.store.finalize(id, {
@@ -392,6 +397,8 @@ let RecordingOrchestrator = class RecordingOrchestrator {
392
397
  status = 'FAILED';
393
398
  failReason = (_a = err === null || err === void 0 ? void 0 : err.message) !== null && _a !== void 0 ? _a : 'ffmpeg_stop_failed';
394
399
  }
400
+ // Its ffmpeg is done reading; a group's composite stops before this.
401
+ yield this.closeNodeSource(r.id);
395
402
  // How long the video actually is, not how long the session was open. Those
396
403
  // agree only while capture keeps up: a recording whose device died at 26s
397
404
  // and was stopped 5 minutes later reported 5m36s for a 35s file (#204).
@@ -804,7 +811,7 @@ let RecordingOrchestrator = class RecordingOrchestrator {
804
811
  }
805
812
  const filePath = typedi_1.Container.get(ArtifactStore_1.ARTIFACT_STORE).resolve(recordingId, 'video', `${recordingId}.mp4`);
806
813
  try {
807
- const mjpegPort = yield this.ensureMjpegPortFn(udid);
814
+ const mjpegPort = yield this.sourcePort(recordingId, udid, actorId);
808
815
  yield this.store.create({
809
816
  id: recordingId,
810
817
  groupId,
@@ -829,6 +836,7 @@ let RecordingOrchestrator = class RecordingOrchestrator {
829
836
  }
830
837
  catch (err) {
831
838
  recLog.error(`Failed to add device ${udid} to group ${groupId}: ${err === null || err === void 0 ? void 0 : err.message}`);
839
+ yield this.closeNodeSource(recordingId);
832
840
  this.gate.release(recordingId);
833
841
  yield this.tryUnblock(udid, host);
834
842
  try {
@@ -940,6 +948,32 @@ let RecordingOrchestrator = class RecordingOrchestrator {
940
948
  }
941
949
  });
942
950
  }
951
+ /**
952
+ * The loopback port a recording reads: this server's own phone's MJPEG
953
+ * stream, or, for another server's phone, a relay to its node's, kept until
954
+ * the recording is finalized (nodeRecordingSource.ts).
955
+ */
956
+ sourcePort(recordingId, udid, actorId) {
957
+ return __awaiter(this, void 0, void 0, function* () {
958
+ const relay = yield this.nodeSourceFn(udid, actorId);
959
+ if (!relay)
960
+ return this.ensureMjpegPortFn(udid);
961
+ this.nodeSources.set(recordingId, relay);
962
+ return relay.port;
963
+ });
964
+ }
965
+ closeNodeSource(recordingId) {
966
+ return __awaiter(this, void 0, void 0, function* () {
967
+ const relay = this.nodeSources.get(recordingId);
968
+ if (!relay)
969
+ return;
970
+ this.nodeSources.delete(recordingId);
971
+ yield relay.close().catch((err) => {
972
+ var _a;
973
+ recLog.warn(`Closing the node relay of ${recordingId} failed: ${(_a = err === null || err === void 0 ? void 0 : err.message) !== null && _a !== void 0 ? _a : err}`);
974
+ });
975
+ });
976
+ }
943
977
  tryUnblock(udid, host) {
944
978
  return __awaiter(this, void 0, void 0, function* () {
945
979
  try {
@@ -64,7 +64,7 @@ let BusyPrecheck = class BusyPrecheck {
64
64
  */
65
65
  findBusy(udids, actorId, actorApiKeyId) {
66
66
  return __awaiter(this, void 0, void 0, function* () {
67
- var _a;
67
+ var _a, _b;
68
68
  const store = this.storeProvider();
69
69
  const out = [];
70
70
  for (const udid of udids) {
@@ -81,7 +81,10 @@ let BusyPrecheck = class BusyPrecheck {
81
81
  }
82
82
  if (!device.busy)
83
83
  continue;
84
- const blockId = (_a = device.session_id) !== null && _a !== void 0 ? _a : undefined;
84
+ // This server's own hold, else, on a hub, a node phone's preview hold
85
+ // its node reports (nodeHold): session_id is the hub's and stays empty
86
+ // for it, which read as unknown and refused the holder their own phone.
87
+ const blockId = (_b = (_a = device.session_id) !== null && _a !== void 0 ? _a : device.nodeHold) !== null && _b !== void 0 ? _b : undefined;
85
88
  const lock = (0, manualLock_1.inspectManualLock)(blockId, actorId, udid);
86
89
  // Single source of truth for "is this lock mine" — the same helper
87
90
  // /control's guard, stream/start and stream/stop use. Checking only
@@ -0,0 +1,156 @@
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
+ exports.NODE_STREAM_READY_TIMEOUT_MS = void 0;
16
+ exports.openNodeMjpegRelay = openNodeMjpegRelay;
17
+ exports.recordingSourceFor = recordingSourceFor;
18
+ const http_1 = __importDefault(require("http"));
19
+ const typedi_1 = require("typedi");
20
+ const logger_1 = __importDefault(require("../../logger"));
21
+ const hubSessionToken_1 = require("../../gateway/hubSessionToken");
22
+ const forwardToNode_1 = require("../../gateway/forwardToNode");
23
+ const nodePhoneControl_1 = require("../../app/routers/nodePhoneControl");
24
+ const controlDevice_1 = require("../../middleware/controlDevice");
25
+ const relayLog = logger_1.default.scope('NodeRecordingSource');
26
+ /**
27
+ * How long a node's phone has to start streaming: an iPhone's WebDriverAgent
28
+ * and tunnel take tens of seconds on a cold start.
29
+ */
30
+ exports.NODE_STREAM_READY_TIMEOUT_MS = 90000;
31
+ const defaultControlToken = (grant) => typedi_1.Container.get(hubSessionToken_1.HubSessionTokenIssuer).controlTokenFor(grant);
32
+ /**
33
+ * A node's phone, recorded on this server: a loopback server whose every
34
+ * connection gets the node's MJPEG stream (`GET /control/<udid>/stream`),
35
+ * signed afresh for the recording's user. ffmpeg reads it like one of this
36
+ * server's own phones, and reconnects on any hiccup; the hub's control token
37
+ * lasts a minute and a recording much longer, so each connection signs again.
38
+ * The node counts the connection as a viewer, so its idle release leaves the
39
+ * stream running for as long as the recording reads it. Loopback only, like
40
+ * this server's own phones' MJPEG servers.
41
+ */
42
+ function openNodeMjpegRelay(target_1) {
43
+ return __awaiter(this, arguments, void 0, function* (target, deps = {}) {
44
+ var _a, _b;
45
+ const { udid, host, actorId } = target;
46
+ const origin = (0, nodePhoneControl_1.safeNodeOrigin)(host);
47
+ if (!origin)
48
+ throw new Error(`Unsafe device host for ${udid}`);
49
+ const controlToken = (_a = deps.controlToken) !== null && _a !== void 0 ? _a : defaultControlToken;
50
+ const url = `${origin}/xenon/api/control/${encodeURIComponent(udid)}/stream`;
51
+ const headers = () => __awaiter(this, void 0, void 0, function* () {
52
+ const token = yield controlToken({ userId: actorId, isAdmin: false, udid, host });
53
+ return Object.assign({ accept: '*/*', 'accept-encoding': 'identity' }, (token ? { [hubSessionToken_1.HUB_TOKEN_HEADER]: token } : {}));
54
+ });
55
+ // As local recording starts the phone's stream before ffmpeg, the node's
56
+ // must be sending frames before the port is handed out. Its GET stream
57
+ // starts the stream; an iPhone's takes seconds. Otherwise the video began
58
+ // late, and every mark on it, timed from ffmpeg's start, was off by that.
59
+ yield untilStreaming(url, yield headers(), (_b = deps.readyTimeoutMs) !== null && _b !== void 0 ? _b : exports.NODE_STREAM_READY_TIMEOUT_MS);
60
+ const server = http_1.default.createServer((_req, res) => __awaiter(this, void 0, void 0, function* () {
61
+ var _a, _b, _c, _d;
62
+ const controller = new AbortController();
63
+ res.on('close', () => controller.abort());
64
+ try {
65
+ const upstream = yield (0, forwardToNode_1.sendToNode)({
66
+ url,
67
+ method: 'GET',
68
+ headers: yield headers(),
69
+ signal: controller.signal,
70
+ });
71
+ const type = upstream.headers['content-type'];
72
+ res.writeHead((_a = upstream.statusCode) !== null && _a !== void 0 ? _a : 502, Object.assign(Object.assign({}, (type ? { 'content-type': type } : {})), { 'cache-control': 'no-cache' }));
73
+ upstream.on('error', () => res.destroy());
74
+ upstream.pipe(res);
75
+ }
76
+ catch (err) {
77
+ if (res.headersSent) {
78
+ res.destroy();
79
+ return;
80
+ }
81
+ relayLog.warn(`[${udid}] node ${origin} stream unavailable: ${(_b = err === null || err === void 0 ? void 0 : err.message) !== null && _b !== void 0 ? _b : err}`);
82
+ res.writeHead(502, { 'content-type': 'text/plain' });
83
+ res.end(`node unreachable: ${(_d = (_c = err === null || err === void 0 ? void 0 : err.code) !== null && _c !== void 0 ? _c : err === null || err === void 0 ? void 0 : err.message) !== null && _d !== void 0 ? _d : err}`);
84
+ }
85
+ }));
86
+ yield new Promise((resolve, reject) => {
87
+ server.once('error', reject);
88
+ server.listen(0, '127.0.0.1', () => resolve());
89
+ });
90
+ const { port } = server.address();
91
+ relayLog.info(`[${udid}] recording relay for node ${origin} on 127.0.0.1:${port}`);
92
+ return {
93
+ port,
94
+ close: () => new Promise((resolve) => {
95
+ var _a;
96
+ (_a = server.closeAllConnections) === null || _a === void 0 ? void 0 : _a.call(server);
97
+ server.close(() => resolve());
98
+ }),
99
+ };
100
+ });
101
+ }
102
+ /**
103
+ * Resolves once the node's stream sends its first bytes; throws when the node
104
+ * refuses it, ends it first, or sends nothing within `timeoutMs`.
105
+ */
106
+ function untilStreaming(url, headers, timeoutMs) {
107
+ return __awaiter(this, void 0, void 0, function* () {
108
+ var _a;
109
+ const controller = new AbortController();
110
+ let timedOut = false;
111
+ const timer = setTimeout(() => {
112
+ timedOut = true;
113
+ controller.abort();
114
+ }, timeoutMs);
115
+ try {
116
+ const upstream = yield (0, forwardToNode_1.sendToNode)({ url, method: 'GET', headers, signal: controller.signal });
117
+ if (((_a = upstream.statusCode) !== null && _a !== void 0 ? _a : 502) !== 200) {
118
+ const answer = yield (0, forwardToNode_1.readAnswer)(upstream);
119
+ throw new Error(`the node answered ${answer.status} for the stream: ${answer.body.toString('utf8').slice(0, 200)}`);
120
+ }
121
+ yield new Promise((resolve, reject) => {
122
+ upstream.once('data', () => resolve());
123
+ upstream.once('end', () => reject(new Error("the node's stream ended before its first frame")));
124
+ upstream.once('error', reject);
125
+ });
126
+ }
127
+ catch (err) {
128
+ if (timedOut) {
129
+ throw new Error(`the node's stream sent no first frame within ${Math.round(timeoutMs / 1000)} s`);
130
+ }
131
+ throw err;
132
+ }
133
+ finally {
134
+ clearTimeout(timer);
135
+ controller.abort();
136
+ }
137
+ });
138
+ }
139
+ /**
140
+ * The recording source for a phone: a relay to its node for another server's
141
+ * phone, null for this server's own (or an unknown) phone, which the caller
142
+ * reads locally as before. A cloud provider's phone can't be recorded here.
143
+ */
144
+ function recordingSourceFor(udid_1, actorId_1) {
145
+ return __awaiter(this, arguments, void 0, function* (udid, actorId, deps = {}) {
146
+ var _a;
147
+ const findDevice = (_a = deps.findDevice) !== null && _a !== void 0 ? _a : controlDevice_1.findControlDeviceInStore;
148
+ const device = yield findDevice(udid);
149
+ if (!device || !(0, nodePhoneControl_1.isOtherServersPhone)(device))
150
+ return null;
151
+ if (device.cloud) {
152
+ throw new Error(`${udid} is a cloud provider's phone and can't be recorded here.`);
153
+ }
154
+ return openNodeMjpegRelay({ udid, host: device.host, actorId }, deps);
155
+ });
156
+ }
@@ -99,6 +99,38 @@ describe('BusyPrecheck', () => {
99
99
  const out = yield pc.findBusy(['U1'], 'actor-1');
100
100
  (0, chai_1.expect)(out[0]).to.deep.include({ udid: 'U1', reason: 'manual_other', blockId: 'manual_U1' });
101
101
  }));
102
+ // On a hub, a node's phone held by a preview has the holder in nodeHold,
103
+ // from the node's report; session_id is the hub's own and stays empty.
104
+ // Read as unknown, the user's own preview kept them from recording it.
105
+ it('reads a node phone’s preview hold from nodeHold: mine to take over, another user’s not', () => __awaiter(void 0, void 0, void 0, function* () {
106
+ const pc = precheck(withDevices({
107
+ MINE: {
108
+ udid: 'MINE',
109
+ busy: true,
110
+ nodeBusy: true,
111
+ session_id: null,
112
+ nodeHold: 'manual_alice_MINE',
113
+ },
114
+ THEIRS: {
115
+ udid: 'THEIRS',
116
+ busy: true,
117
+ nodeBusy: true,
118
+ session_id: null,
119
+ nodeHold: 'manual_bob_THEIRS',
120
+ },
121
+ NODE_SESSION: {
122
+ udid: 'NODE_SESSION',
123
+ busy: true,
124
+ nodeBusy: true,
125
+ session_id: null,
126
+ nodeHold: null,
127
+ },
128
+ }));
129
+ (0, chai_1.expect)(yield pc.findBusy(['MINE', 'THEIRS', 'NODE_SESSION'], 'alice')).to.deep.equal([
130
+ { udid: 'THEIRS', reason: 'manual_other', blockId: 'manual_bob_THEIRS' },
131
+ { udid: 'NODE_SESSION', reason: 'unknown' },
132
+ ]);
133
+ }));
102
134
  it('returns reason=unknown for missing devices', () => __awaiter(void 0, void 0, void 0, function* () {
103
135
  const pc = precheck(withDevices({}));
104
136
  const out = yield pc.findBusy(['MISSING']);
@@ -216,6 +216,32 @@ describe('nodePhoneControl', () => {
216
216
  (0, chai_1.expect)(cloud.body.error, action).to.equal('not_available_for_cloud_phone');
217
217
  }
218
218
  }));
219
+ // The node doesn't know this server is recording its phone, so it would stop
220
+ // the stream the recording reads; /control refuses a local phone's the same way.
221
+ it('refuses to stop a node phone’s preview while this server records it', () => __awaiter(void 0, void 0, void 0, function* () {
222
+ const node = (0, express_1.default)();
223
+ const hits = [];
224
+ node.use((req, res) => {
225
+ hits.push(req.path);
226
+ res.json({ success: true });
227
+ });
228
+ const origin = `http://127.0.0.1:${(yield loopback.serve(node)).address().port}`;
229
+ findDevice = () => __awaiter(void 0, void 0, void 0, function* () { return ({ udid: 'p', host: origin, nodeId: 'node-1' }); });
230
+ let recording = { groupId: 'g-1' };
231
+ const a = (0, express_1.default)();
232
+ a.use(express_1.default.json());
233
+ a.use('/xenon/api/control', (0, nodePhoneControl_1.nodePhoneControl)({
234
+ findDevice: (udid) => findDevice(udid),
235
+ activeRecordingFor: () => __awaiter(void 0, void 0, void 0, function* () { return recording; }),
236
+ }));
237
+ const refused = yield (0, loopbackRequest_1.default)(a).post('/xenon/api/control/p/stream/stop').send({});
238
+ (0, chai_1.expect)(refused.status).to.equal(409);
239
+ (0, chai_1.expect)(refused.body).to.include({ error: 'device_recording', groupId: 'g-1' });
240
+ (0, chai_1.expect)(hits).to.deep.equal([]);
241
+ recording = null;
242
+ yield (0, loopbackRequest_1.default)(a).post('/xenon/api/control/p/stream/stop').send({}).expect(200);
243
+ (0, chai_1.expect)(hits).to.deep.equal(['/xenon/api/control/p/stream/stop']);
244
+ }));
219
245
  it('refuses a host it must never call', () => __awaiter(void 0, void 0, void 0, function* () {
220
246
  findDevice = () => __awaiter(void 0, void 0, void 0, function* () { return ({ udid: 'p', host: 'http://169.254.169.254', nodeId: 'node-1' }); });
221
247
  const res = yield (0, loopbackRequest_1.default)(app()).get('/xenon/api/control/p/screenshot');
@@ -0,0 +1,165 @@
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 http_1 = __importDefault(require("http"));
18
+ const express_1 = __importDefault(require("express"));
19
+ const typedi_1 = require("typedi");
20
+ const loopbackServer_1 = require("../helpers/loopbackServer");
21
+ const PluginContext_1 = require("../../src/PluginContext");
22
+ const IPluginArgs_1 = require("../../src/interfaces/IPluginArgs");
23
+ const nodeRecordingSource_1 = require("../../src/services/recording/nodeRecordingSource");
24
+ /**
25
+ * Recording a node's phone on a hub. The recording's ffmpeg reads a loopback
26
+ * port, as for this server's own phones; for a node's phone that port is a
27
+ * relay to the node's MJPEG stream, signed for the recording's user on every
28
+ * connection. ffmpeg reconnects on any hiccup, and the hub's control token
29
+ * lasts a minute, so a token taken once would end the recording on the first
30
+ * reconnect after that.
31
+ */
32
+ describe('nodeRecordingSource', () => {
33
+ const loopback = (0, loopbackServer_1.loopbackServers)();
34
+ const closers = [];
35
+ let context;
36
+ let saved;
37
+ beforeEach(() => {
38
+ context = typedi_1.Container.get(PluginContext_1.PluginContext);
39
+ saved = Object.assign({}, context);
40
+ context.setContext(Object.assign(Object.assign({}, IPluginArgs_1.DefaultPluginArgs), { bindHostOrIp: '127.0.0.1' }), 1, 'hub-1', '');
41
+ });
42
+ afterEach(() => __awaiter(void 0, void 0, void 0, function* () {
43
+ Object.assign(context, saved);
44
+ for (const close of closers.splice(0))
45
+ yield close();
46
+ yield loopback.closeAll();
47
+ }));
48
+ /** A node serving GET /control/p/stream, recording each request's token. */
49
+ function fakeNode() {
50
+ return __awaiter(this, arguments, void 0, function* (status = 200, firstFrameAfterMs = 0) {
51
+ const seen = [];
52
+ const app = (0, express_1.default)();
53
+ app.get('/xenon/api/control/p/stream', (req, res) => {
54
+ const call = { token: req.header('x-xenon-hub-token'), closed: false };
55
+ seen.push(call);
56
+ req.on('close', () => (call.closed = true));
57
+ if (status !== 200)
58
+ return res.status(status).json({ error: 'node says no' });
59
+ res.writeHead(200, { 'content-type': 'multipart/x-mixed-replace; boundary=f' });
60
+ let timer;
61
+ const wait = setTimeout(() => {
62
+ timer = setInterval(() => res.write('--f\r\nFRAME\r\n'), 30);
63
+ }, firstFrameAfterMs);
64
+ res.on('close', () => {
65
+ clearTimeout(wait);
66
+ if (timer)
67
+ clearInterval(timer);
68
+ });
69
+ });
70
+ const origin = `http://127.0.0.1:${(yield loopback.serve(app)).address().port}`;
71
+ return { origin, seen };
72
+ });
73
+ }
74
+ /** Read from the relay until `n` frames arrived, then hang up. */
75
+ const read = (port, n = 2) => new Promise((resolve, reject) => {
76
+ const req = http_1.default.get({ host: '127.0.0.1', port, path: '/' }, (res) => {
77
+ let body = '';
78
+ res.on('data', (c) => {
79
+ body += c.toString();
80
+ if (body.split('FRAME').length > n) {
81
+ req.destroy();
82
+ resolve({ status: res.statusCode, type: String(res.headers['content-type']), body });
83
+ }
84
+ });
85
+ res.on('end', () => resolve({ status: res.statusCode, body }));
86
+ });
87
+ req.on('error', (e) => (e.code === 'ECONNRESET' ? undefined : reject(e)));
88
+ });
89
+ it('relays the node’s stream, signed afresh for each connection', () => __awaiter(void 0, void 0, void 0, function* () {
90
+ const node = yield fakeNode();
91
+ let n = 0;
92
+ const relay = yield (0, nodeRecordingSource_1.openNodeMjpegRelay)({ udid: 'p', host: node.origin, actorId: 'alice' }, { controlToken: (grant) => __awaiter(void 0, void 0, void 0, function* () { return `token-${++n}-${grant.userId}-${grant.udid}`; }) });
93
+ closers.push(() => relay.close());
94
+ const first = yield read(relay.port);
95
+ const second = yield read(relay.port);
96
+ (0, chai_1.expect)(first.status).to.equal(200);
97
+ (0, chai_1.expect)(first.type).to.include('multipart/x-mixed-replace');
98
+ (0, chai_1.expect)(second.body).to.include('FRAME');
99
+ // The first is the relay making sure the node is streaming before it
100
+ // hands out the port.
101
+ (0, chai_1.expect)(node.seen.map((s) => s.token)).to.deep.equal([
102
+ 'token-1-alice-p',
103
+ 'token-2-alice-p',
104
+ 'token-3-alice-p',
105
+ ]);
106
+ }));
107
+ it('hangs up on the node when the reader goes, and stops listening when closed', () => __awaiter(void 0, void 0, void 0, function* () {
108
+ const node = yield fakeNode();
109
+ const relay = yield (0, nodeRecordingSource_1.openNodeMjpegRelay)({ udid: 'p', host: node.origin, actorId: 'alice' }, { controlToken: () => __awaiter(void 0, void 0, void 0, function* () { return null; }) });
110
+ yield read(relay.port);
111
+ yield new Promise((r) => setTimeout(r, 100));
112
+ (0, chai_1.expect)(node.seen[1].closed, 'the node’s request ends with the reader').to.equal(true);
113
+ yield relay.close();
114
+ const refused = yield new Promise((resolve) => {
115
+ http_1.default
116
+ .get({ host: '127.0.0.1', port: relay.port, path: '/' }, () => resolve('answered'))
117
+ .on('error', (e) => resolve(e.code));
118
+ });
119
+ (0, chai_1.expect)(refused).to.equal('ECONNREFUSED');
120
+ }));
121
+ // Local recording starts the phone's stream before ffmpeg (ensureMjpegForRecording).
122
+ // Handed the port at once, ffmpeg started before the node's stream was up
123
+ // (an iPhone's WebDriverAgent takes seconds): the video began late, and
124
+ // every mark on it, timed from ffmpeg's start, was off by as much.
125
+ it('opens once the node is streaming, not before', () => __awaiter(void 0, void 0, void 0, function* () {
126
+ const node = yield fakeNode(200, 400);
127
+ const t0 = Date.now();
128
+ const relay = yield (0, nodeRecordingSource_1.openNodeMjpegRelay)({ udid: 'p', host: node.origin, actorId: 'alice' }, { controlToken: () => __awaiter(void 0, void 0, void 0, function* () { return null; }) });
129
+ closers.push(() => relay.close());
130
+ (0, chai_1.expect)(Date.now() - t0).to.be.at.least(350);
131
+ }));
132
+ it('fails to open when the node refuses the stream, or never sends a frame', () => __awaiter(void 0, void 0, void 0, function* () {
133
+ const refusing = yield fakeNode(409);
134
+ let error;
135
+ yield (0, nodeRecordingSource_1.openNodeMjpegRelay)({ udid: 'p', host: refusing.origin, actorId: 'alice' }, { controlToken: () => __awaiter(void 0, void 0, void 0, function* () { return null; }) }).catch((e) => (error = e));
136
+ (0, chai_1.expect)(error === null || error === void 0 ? void 0 : error.message).to.include('409');
137
+ const silent = yield fakeNode(200, 60000);
138
+ error = undefined;
139
+ yield (0, nodeRecordingSource_1.openNodeMjpegRelay)({ udid: 'p', host: silent.origin, actorId: 'alice' }, { controlToken: () => __awaiter(void 0, void 0, void 0, function* () { return null; }), readyTimeoutMs: 200 }).catch((e) => (error = e));
140
+ (0, chai_1.expect)(error === null || error === void 0 ? void 0 : error.message).to.match(/first frame/);
141
+ }));
142
+ describe('recordingSourceFor', () => {
143
+ const rows = {};
144
+ const find = (udid) => __awaiter(void 0, void 0, void 0, function* () { var _a; return (_a = rows[udid]) !== null && _a !== void 0 ? _a : null; });
145
+ it('is null for this server’s own phone and an unknown one', () => __awaiter(void 0, void 0, void 0, function* () {
146
+ rows.own = { udid: 'own', host: 'http://10.0.0.9:4723', nodeId: 'hub-1' };
147
+ (0, chai_1.expect)(yield (0, nodeRecordingSource_1.recordingSourceFor)('own', 'alice', { findDevice: find })).to.equal(null);
148
+ (0, chai_1.expect)(yield (0, nodeRecordingSource_1.recordingSourceFor)('ghost', 'alice', { findDevice: find })).to.equal(null);
149
+ }));
150
+ it('opens a relay for a node’s phone, and refuses a cloud provider’s', () => __awaiter(void 0, void 0, void 0, function* () {
151
+ const node = yield fakeNode();
152
+ rows.p = { udid: 'p', host: node.origin, nodeId: 'node-1' };
153
+ const relay = yield (0, nodeRecordingSource_1.recordingSourceFor)('p', 'alice', {
154
+ findDevice: find,
155
+ controlToken: () => __awaiter(void 0, void 0, void 0, function* () { return null; }),
156
+ });
157
+ (0, chai_1.expect)(relay === null || relay === void 0 ? void 0 : relay.port).to.be.a('number');
158
+ closers.push(() => relay.close());
159
+ rows.c = { udid: 'c', host: 'https://hub.cloud.example', cloud: '{"cloudName":"x"}' };
160
+ let error;
161
+ yield (0, nodeRecordingSource_1.recordingSourceFor)('c', 'alice', { findDevice: find }).catch((e) => (error = e));
162
+ (0, chai_1.expect)(error === null || error === void 0 ? void 0 : error.message).to.match(/cloud provider/);
163
+ }));
164
+ });
165
+ });