@xenon-device-management/xenon 2.1.0 → 2.2.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 (110) hide show
  1. package/lib/package.json +3 -3
  2. package/lib/public/assets/{AnnotationOverlay-C-Pw_hvw.js → AnnotationOverlay-XpewX5nn.js} +1 -1
  3. package/lib/public/assets/{ApiKeyGate-DmYQ0oSk.js → ApiKeyGate-BQDv3ZCC.js} +1 -1
  4. package/lib/public/assets/{BugReportButton-n8T1dFUp.js → BugReportButton-C7OmS61B.js} +1 -1
  5. package/lib/public/assets/{DeviceMosaicView-D-rGOBbJ.js → DeviceMosaicView-wl3l2Zlt.js} +1 -1
  6. package/lib/public/assets/{EmptyState-DExETevp.js → EmptyState-fPxujqqE.js} +1 -1
  7. package/lib/public/assets/{FieldGroup-CCdFlZm6.js → FieldGroup-5bCutWrs.js} +1 -1
  8. package/lib/public/assets/{FilterMenu-DiUSOSL-.js → FilterMenu-owsBiDT1.js} +1 -1
  9. package/lib/public/assets/{Menu-ZOfpH6gh.js → Menu-DFhqzPSt.js} +1 -1
  10. package/lib/public/assets/{Modal-CZNtoB-1.js → Modal-Bw-a-E-e.js} +1 -1
  11. package/lib/public/assets/{RecordingPage-BKp6h8Jl.js → RecordingPage-WtByf5bC.js} +1 -1
  12. package/lib/public/assets/{RecordingsPage-Bf881FX4.js → RecordingsPage-FpWWFNgT.js} +1 -1
  13. package/lib/public/assets/{SegmentedControl-EUoyizvW.js → SegmentedControl-Jzn9c7o8.js} +1 -1
  14. package/lib/public/assets/{SettingCard-ClT4hf4_.js → SettingCard-BR7nOhpT.js} +1 -1
  15. package/lib/public/assets/{Table-ucR4bnmc.js → Table-B_Va3V05.js} +1 -1
  16. package/lib/public/assets/{activity-K-hdylwq.js → activity-DxRFWvkN.js} +1 -1
  17. package/lib/public/assets/{ai-settings-BLhdwI3c.js → ai-settings-CQf-9_V6.js} +1 -1
  18. package/lib/public/assets/{api-keys-BbiN7rBI.js → api-keys-SoE1Kd7N.js} +1 -1
  19. package/lib/public/assets/{apps-Dez1petz.js → apps-CgGXTlgn.js} +1 -1
  20. package/lib/public/assets/{arrow-left-UjmFHLAJ.js → arrow-left-uQ6WIwiC.js} +1 -1
  21. package/lib/public/assets/{arrow-right-BVyEigbu.js → arrow-right-C9RyfzXS.js} +1 -1
  22. package/lib/public/assets/{arrow-up-right-B-ZlBof3.js → arrow-up-right-DFHi_8yG.js} +1 -1
  23. package/lib/public/assets/{auth-shell-DAQjAgUv.js → auth-shell-Bii1t4qc.js} +2 -2
  24. package/lib/public/assets/{builds-page-CmIPa5JK.js → builds-page-Bnv8Z9hi.js} +1 -1
  25. package/lib/public/assets/{button-Dd-IhMC1.js → button-_Y78wXiP.js} +1 -1
  26. package/lib/public/assets/{calendar-CAIeJwa5.js → calendar-D4MqSOc2.js} +1 -1
  27. package/lib/public/assets/{check-C4Jrjg8f.js → check-uGHCXe-L.js} +1 -1
  28. package/lib/public/assets/{chevron-left-BGzlf-4i.js → chevron-left-Dy1OqpzB.js} +1 -1
  29. package/lib/public/assets/{chevron-right-_wYqDjDC.js → chevron-right-BUsldEli.js} +1 -1
  30. package/lib/public/assets/{circle-check-Ckn00yMB.js → circle-check-D3Qq0tKp.js} +1 -1
  31. package/lib/public/assets/{circle-x-pdVkomLa.js → circle-x-B4IOD1DE.js} +1 -1
  32. package/lib/public/assets/{clock-B8vmwkHj.js → clock-BfYfzvSg.js} +1 -1
  33. package/lib/public/assets/{copy-BAqnFI-F.js → copy-DZwYE5Va.js} +1 -1
  34. package/lib/public/assets/{device-explorer-CYq_4F_N.js → device-explorer-CRG4Utt5.js} +1 -1
  35. package/lib/public/assets/{download-B6UEAp_1.js → download-CCDYo_TN.js} +1 -1
  36. package/lib/public/assets/{forgot-password-CLqNvmrN.js → forgot-password-DuLCmVFb.js} +1 -1
  37. package/lib/public/assets/{funnel-Da90BBbT.js → funnel-CtKNPqzb.js} +1 -1
  38. package/lib/public/assets/{index-BuIIcmGp.js → index-3uxl3o7K.js} +43 -43
  39. package/lib/public/assets/{index-MX7IzqyW.js → index-Zy1Op5C1.js} +1 -1
  40. package/lib/public/assets/{input-Bq9aY-We.js → input-CzTWvUXZ.js} +1 -1
  41. package/lib/public/assets/{keycodes-CIhXIJyP.js → keycodes-CI-nYDFM.js} +1 -1
  42. package/lib/public/assets/{layers-bXjpswQI.js → layers-YZZGFyTX.js} +1 -1
  43. package/lib/public/assets/{lock-pJ72y5Fy.js → lock-L8tzWgDo.js} +1 -1
  44. package/lib/public/assets/{login-UOcHALV2.js → login-DBbX-fu8.js} +1 -1
  45. package/lib/public/assets/{maintenance-settings-XnJgp5yb.js → maintenance-settings-AJwkOIsE.js} +1 -1
  46. package/lib/public/assets/{monitor-QkAhI1Jx.js → monitor-Di_66F2m.js} +1 -1
  47. package/lib/public/assets/{mouse-pointer-2-B42Da0m4.js → mouse-pointer-2-Cnab2zdg.js} +1 -1
  48. package/lib/public/assets/{network-CKVqcyp6.js → network-DiSumY-1.js} +1 -1
  49. package/lib/public/assets/{overview-BORGkN3N.js → overview-CwE4gXl5.js} +1 -1
  50. package/lib/public/assets/{page-header-CKWFOjOI.js → page-header-CfR4Sm9V.js} +1 -1
  51. package/lib/public/assets/{plus-BpFKVdC8.js → plus-DOS8P9CQ.js} +1 -1
  52. package/lib/public/assets/{profile-page-CcbKBicY.js → profile-page-BYpHwQIH.js} +1 -1
  53. package/lib/public/assets/{reset-password-BA0W9bxl.js → reset-password-BRVJU_qV.js} +1 -1
  54. package/lib/public/assets/{runbook-page-Cp7ZBq3s.js → runbook-page-Bi3JvAFZ.js} +1 -1
  55. package/lib/public/assets/{select-DIZWIoVR.js → select-Cqx7pEou.js} +1 -1
  56. package/lib/public/assets/{selector-detail-page-C_4YuvAp.js → selector-detail-page-C0DsncKj.js} +1 -1
  57. package/lib/public/assets/{selector-health-CXjD_Xth.js → selector-health-CAhpdilF.js} +1 -1
  58. package/lib/public/assets/{selector-health-page-CTqIHeoG.js → selector-health-page-CrCVeW2m.js} +1 -1
  59. package/lib/public/assets/{session-detail-page-B0KYkk8z.js → session-detail-page-F_hwYqEt.js} +1 -1
  60. package/lib/public/assets/{settings-BcRBKUXd.js → settings-C-s5pypL.js} +1 -1
  61. package/lib/public/assets/{status-pill-outline-Dh2PXSf_.js → status-pill-outline-DY5uzv6A.js} +1 -1
  62. package/lib/public/assets/{tablet-_sXqZztt.js → tablet-Dli7gwyg.js} +1 -1
  63. package/lib/public/assets/{teams-DGHT8MKm.js → teams-ui4IhHNC.js} +1 -1
  64. package/lib/public/assets/{trash-2-BV88cV3Z.js → trash-2-BV4hSmXJ.js} +1 -1
  65. package/lib/public/assets/{upload-CIDH9wkj.js → upload-DkGy_J-L.js} +1 -1
  66. package/lib/public/assets/{use-password-reset-mode-DYGuSNwl.js → use-password-reset-mode-BFtMqQOh.js} +1 -1
  67. package/lib/public/assets/{users-BUE7MoTq.js → users-BU0nXIVe.js} +1 -1
  68. package/lib/public/assets/{users-7yviqflc.js → users-Nb_rzRmF.js} +1 -1
  69. package/lib/public/assets/{video-off-BTRxtlGo.js → video-off-CUNzD2kH.js} +1 -1
  70. package/lib/public/assets/{webhook-settings-DMKD9Pgm.js → webhook-settings-Ca3UFXZe.js} +1 -1
  71. package/lib/public/assets/{zap-CWE94qcK.js → zap-B2bH2vKX.js} +1 -1
  72. package/lib/public/index.html +1 -1
  73. package/lib/src/app/routers/control.js +5 -142
  74. package/lib/src/app/routers/nodePhoneControl.js +301 -0
  75. package/lib/src/app/ws/h264StreamWs.js +30 -4
  76. package/lib/src/app/ws/logcatWs.js +36 -7
  77. package/lib/src/app/ws/nodeSocketRelay.js +167 -0
  78. package/lib/src/data-service/device-store.js +11 -0
  79. package/lib/src/data-service/deviceClaims.js +18 -0
  80. package/lib/src/data-service/prisma-store.js +8 -0
  81. package/lib/src/device-managers/AndroidDeviceManager.js +27 -27
  82. package/lib/src/device-managers/android/AndroidH264StreamService.js +10 -2
  83. package/lib/src/device-managers/android/AndroidStreamService.js +3 -1
  84. package/lib/src/device-managers/ios/IOSStreamService.js +4 -2
  85. package/lib/src/middleware/deviceAccessGuard.js +4 -1
  86. package/lib/src/services/ServerManager.js +13 -0
  87. package/lib/src/services/device-access/ticketActorAccess.js +3 -1
  88. package/lib/src/services/lease/LeaseOrphanSweeper.js +4 -2
  89. package/lib/test/helpers/own-node-id.js +29 -0
  90. package/lib/test/setup/scratch-default-database.js +32 -0
  91. package/lib/test/unit/AndroidDeviceManager.spec.js +48 -0
  92. package/lib/test/unit/android-h264-service.spec.js +18 -0
  93. package/lib/test/unit/clipboard-route.spec.js +4 -0
  94. package/lib/test/unit/control-forward-decision.spec.js +17 -17
  95. package/lib/test/unit/device-access-guard.spec.js +38 -0
  96. package/lib/test/unit/hub-node-control-forward.spec.js +318 -1
  97. package/lib/test/unit/hub-node-race.spec.js +25 -0
  98. package/lib/test/unit/install-repository-app-team.spec.js +4 -0
  99. package/lib/test/unit/lease/lease-sweeper-expiry.spec.js +15 -0
  100. package/lib/test/unit/logcat-ws.spec.js +8 -0
  101. package/lib/test/unit/mark-node-busy-loki.spec.js +53 -0
  102. package/lib/test/unit/node-phone-control.spec.js +131 -0
  103. package/lib/test/unit/node-socket-relay.spec.js +357 -0
  104. package/lib/test/unit/stream-leave-route.spec.js +4 -0
  105. package/lib/test/unit/stream-stop-ownership.spec.js +4 -0
  106. package/lib/test/unit/stream-stop-recording.spec.js +4 -0
  107. package/lib/test/unit/stream-ticket-identity.spec.js +11 -1
  108. package/lib/test/unit/stream-watchdog-timers.spec.js +46 -0
  109. package/lib/tsconfig.tsbuildinfo +1 -1
  110. package/package.json +3 -3
@@ -18,6 +18,7 @@ exports.attachH264Ws = attachH264Ws;
18
18
  const ws_1 = require("ws");
19
19
  const logger_1 = __importDefault(require("../../logger"));
20
20
  const upgradeRouter_1 = require("./upgradeRouter");
21
+ const nodeSocketRelay_1 = require("./nodeSocketRelay");
21
22
  /**
22
23
  * Authenticated H.264 video WebSocket.
23
24
  *
@@ -62,6 +63,7 @@ function attachH264Ws(server, deps) {
62
63
  function upgrade(req, socket, head) {
63
64
  const parsed = parseH264WsPath(req.url);
64
65
  wss.handleUpgrade(req, socket, head, (ws) => __awaiter(this, void 0, void 0, function* () {
66
+ var _a, _b, _c;
65
67
  // Register cleanup BEFORE the awaits below. redeem + startStream can take
66
68
  // seconds; if the client disconnects during that window we must still
67
69
  // remove it from the multiplexer — otherwise clientCount stays inflated
@@ -74,20 +76,44 @@ function attachH264Ws(server, deps) {
74
76
  };
75
77
  ws.on('close', onClose);
76
78
  ws.on('error', onClose);
79
+ let actor;
77
80
  try {
78
- yield deps.redeem(parsed.ticket, parsed.udid);
81
+ actor = yield deps.redeem(parsed.ticket, parsed.udid);
79
82
  }
80
- catch (_a) {
83
+ catch (_d) {
81
84
  try {
82
85
  ws.close(1008, 'unauthorized');
83
86
  }
84
- catch (_b) {
87
+ catch (_e) {
85
88
  /* noop */
86
89
  }
87
90
  return;
88
91
  }
89
92
  if (closed)
90
93
  return; // disconnected during redeem
94
+ // Another server's phone: its node streams it, relayed from here.
95
+ let upstream = null;
96
+ try {
97
+ upstream = (_b = (yield ((_a = deps.nodeSocket) === null || _a === void 0 ? void 0 : _a.call(deps, parsed.udid, actor)))) !== null && _b !== void 0 ? _b : null;
98
+ }
99
+ catch (e) {
100
+ const refused = e instanceof nodeSocketRelay_1.NodeSocketRefused;
101
+ logger_1.default.warn(`[${parsed.udid}] H.264 WS from its node failed: ${(_c = e === null || e === void 0 ? void 0 : e.message) !== null && _c !== void 0 ? _c : e}`);
102
+ try {
103
+ ws.close(refused ? 1008 : 1011, refused ? 'refused by the node' : 'node unreachable');
104
+ }
105
+ catch (_f) {
106
+ /* noop */
107
+ }
108
+ return;
109
+ }
110
+ if (upstream) {
111
+ if (closed)
112
+ return void upstream.terminate();
113
+ (0, nodeSocketRelay_1.relaySocket)(ws, upstream, maxBuffered);
114
+ logger_1.default.info(`[${parsed.udid}] H.264 WS client connected, relayed from its node`);
115
+ return;
116
+ }
91
117
  let mux;
92
118
  try {
93
119
  mux = yield deps.startStream(parsed.udid);
@@ -97,7 +123,7 @@ function attachH264Ws(server, deps) {
97
123
  try {
98
124
  ws.close(1011, 'stream failed');
99
125
  }
100
- catch (_c) {
126
+ catch (_g) {
101
127
  /* noop */
102
128
  }
103
129
  return;
@@ -17,6 +17,7 @@ exports.attachLogcatWs = attachLogcatWs;
17
17
  const ws_1 = require("ws");
18
18
  const logger_1 = __importDefault(require("../../logger"));
19
19
  const upgradeRouter_1 = require("./upgradeRouter");
20
+ const nodeSocketRelay_1 = require("./nodeSocketRelay");
20
21
  const ostraceParse_1 = require("../../services/logcat/ostraceParse");
21
22
  const LOGCAT_PATH_RE = /^\/xenon\/api\/control\/([^/]+)\/logcat$/;
22
23
  /** Parse the logcat stream upgrade path; returns null for any non-matching URL. */
@@ -68,7 +69,7 @@ function attachLogcatWs(server, deps) {
68
69
  function upgrade(req, socket, head) {
69
70
  const parsed = parseLogcatWsPath(req.url);
70
71
  wss.handleUpgrade(req, socket, head, (ws) => __awaiter(this, void 0, void 0, function* () {
71
- var _a, _b;
72
+ var _a, _b, _c, _d, _e;
72
73
  // Register cleanup BEFORE the awaits below. redeem + authorize +
73
74
  // startStream can together take seconds; a client that disconnects in
74
75
  // that window must still be removed from the multiplexer once it's
@@ -86,11 +87,11 @@ function attachLogcatWs(server, deps) {
86
87
  try {
87
88
  actor = yield deps.redeem(parsed.ticket, parsed.udid);
88
89
  }
89
- catch (_c) {
90
+ catch (_f) {
90
91
  try {
91
92
  ws.close(1008, 'unauthorized');
92
93
  }
93
- catch (_d) {
94
+ catch (_g) {
94
95
  /* noop */
95
96
  }
96
97
  return;
@@ -110,7 +111,7 @@ function attachLogcatWs(server, deps) {
110
111
  try {
111
112
  ws.close(1011, 'ownership unavailable');
112
113
  }
113
- catch (_e) {
114
+ catch (_h) {
114
115
  /* noop */
115
116
  }
116
117
  return;
@@ -135,13 +136,41 @@ function attachLogcatWs(server, deps) {
135
136
  try {
136
137
  ws.close(1008, unresolvable ? 'device not found' : 'device held by another user');
137
138
  }
138
- catch (_f) {
139
+ catch (_j) {
139
140
  /* noop */
140
141
  }
141
142
  return;
142
143
  }
143
144
  if (closed)
144
145
  return; // disconnected during authorize
146
+ // Another server's phone: its node streams the log, relayed from here.
147
+ // The node keeps the replay, the drop markers and the close codes.
148
+ let upstream = null;
149
+ try {
150
+ upstream =
151
+ (_c = (yield ((_b = deps.nodeSocket) === null || _b === void 0 ? void 0 : _b.call(deps, parsed.udid, actor, {
152
+ levels: parsed.levels,
153
+ process: parsed.process,
154
+ })))) !== null && _c !== void 0 ? _c : null;
155
+ }
156
+ catch (e) {
157
+ const refused = e instanceof nodeSocketRelay_1.NodeSocketRefused;
158
+ logger_1.default.warn(`[${parsed.udid}] logcat WS from its node failed: ${(_d = e === null || e === void 0 ? void 0 : e.message) !== null && _d !== void 0 ? _d : e}`);
159
+ try {
160
+ ws.close(refused ? 1008 : 1011, refused ? 'refused by the node' : 'node unreachable');
161
+ }
162
+ catch (_k) {
163
+ /* noop */
164
+ }
165
+ return;
166
+ }
167
+ if (upstream) {
168
+ if (closed)
169
+ return void upstream.terminate();
170
+ (0, nodeSocketRelay_1.relaySocket)(ws, upstream, maxBuffered);
171
+ logger_1.default.info(`[${parsed.udid}] logcat WS client connected, relayed from its node`);
172
+ return;
173
+ }
145
174
  let mux;
146
175
  let effectiveLevels;
147
176
  try {
@@ -153,11 +182,11 @@ function attachLogcatWs(server, deps) {
153
182
  effectiveLevels = started.levels;
154
183
  }
155
184
  catch (e) {
156
- logger_1.default.warn(`[${parsed.udid}] logcat WS start failed: ${(_b = e === null || e === void 0 ? void 0 : e.message) !== null && _b !== void 0 ? _b : e}`);
185
+ logger_1.default.warn(`[${parsed.udid}] logcat WS start failed: ${(_e = e === null || e === void 0 ? void 0 : e.message) !== null && _e !== void 0 ? _e : e}`);
157
186
  try {
158
187
  ws.close(1011, 'stream failed');
159
188
  }
160
- catch (_g) {
189
+ catch (_l) {
161
190
  /* noop */
162
191
  }
163
192
  return;
@@ -0,0 +1,167 @@
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.RELAY_HIGH_WATER_BYTES = exports.NODE_SOCKET_TIMEOUT_MS = exports.NodeSocketRefused = void 0;
16
+ exports.openNodeSocket = openNodeSocket;
17
+ exports.relaySocket = relaySocket;
18
+ const typedi_1 = require("typedi");
19
+ const ws_1 = require("ws");
20
+ const logger_1 = __importDefault(require("../../logger"));
21
+ const hubSessionToken_1 = require("../../gateway/hubSessionToken");
22
+ const forwardToNode_1 = require("../../gateway/forwardToNode");
23
+ const controlDevice_1 = require("../../middleware/controlDevice");
24
+ const nodePhoneControl_1 = require("../routers/nodePhoneControl");
25
+ /**
26
+ * The live preview's H.264 socket and the logcat socket, for another server's
27
+ * phone. The viewer's ticket is this server's (stream/ticket is answered
28
+ * here, after the team check) and is redeemed here; the socket itself is the
29
+ * node's, opened with a ticket the node mints for the same user, and relayed.
30
+ * The node keeps doing what it does for its own viewers: the multiplexer,
31
+ * the replay, the slow-viewer drops and the idle release.
32
+ */
33
+ /** The node, or this server's own rule, refused: the viewer gets 1008. */
34
+ class NodeSocketRefused extends Error {
35
+ }
36
+ exports.NodeSocketRefused = NodeSocketRefused;
37
+ /** How long the node has to mint its ticket and open its socket. */
38
+ exports.NODE_SOCKET_TIMEOUT_MS = 15000;
39
+ /** Unwritten bytes to the viewer beyond which reading from the node pauses. */
40
+ exports.RELAY_HIGH_WATER_BYTES = 4 * 1024 * 1024;
41
+ /**
42
+ * The node's socket for another server's phone, open and paused until
43
+ * relaySocket reads it; null for this server's own phone, which the caller
44
+ * serves itself. Throws NodeSocketRefused when the phone is a cloud
45
+ * provider's or the node refuses the user, and any other error when the node
46
+ * can't be reached.
47
+ */
48
+ function openNodeSocket(request_1) {
49
+ return __awaiter(this, arguments, void 0, function* (request, deps = {}) {
50
+ var _a, _b, _c, _d;
51
+ const findDevice = (_a = deps.findDevice) !== null && _a !== void 0 ? _a : controlDevice_1.findControlDeviceInStore;
52
+ const controlToken = (_b = deps.controlToken) !== null && _b !== void 0 ? _b : ((grant) => typedi_1.Container.get(hubSessionToken_1.HubSessionTokenIssuer).controlTokenFor(grant));
53
+ const timeoutMs = (_c = deps.timeoutMs) !== null && _c !== void 0 ? _c : exports.NODE_SOCKET_TIMEOUT_MS;
54
+ const { udid, actor, path } = request;
55
+ const device = yield findDevice(udid);
56
+ if (!device || !(0, nodePhoneControl_1.isOtherServersPhone)(device))
57
+ return null;
58
+ if (device.cloud)
59
+ throw new NodeSocketRefused("a cloud provider's phone");
60
+ const origin = (0, nodePhoneControl_1.safeNodeOrigin)(device.host);
61
+ if (!origin)
62
+ throw new NodeSocketRefused('unsafe device host');
63
+ const token = yield controlToken({
64
+ userId: actor.actorId,
65
+ isAdmin: !!actor.isAdmin,
66
+ udid,
67
+ host: device.host,
68
+ });
69
+ const base = `/xenon/api/control/${encodeURIComponent(udid)}`;
70
+ const answer = yield (0, forwardToNode_1.readAnswer)(yield (0, forwardToNode_1.sendToNode)({
71
+ url: `${origin}${base}/stream/ticket`,
72
+ method: 'POST',
73
+ headers: Object.assign({ accept: 'application/json', 'content-type': 'application/json' }, (token ? { [hubSessionToken_1.HUB_TOKEN_HEADER]: token } : {})),
74
+ body: Buffer.from('{}'),
75
+ signal: AbortSignal.timeout(timeoutMs),
76
+ }));
77
+ if (answer.status >= 400 && answer.status < 500) {
78
+ throw new NodeSocketRefused(`the node answered ${answer.status} for a ticket`);
79
+ }
80
+ if (answer.status >= 300)
81
+ throw new Error(`the node answered ${answer.status} for a ticket`);
82
+ const ticket = (_d = JSON.parse(answer.body.toString('utf8'))) === null || _d === void 0 ? void 0 : _d.ticket;
83
+ if (typeof ticket !== 'string')
84
+ throw new Error('the node sent no ticket');
85
+ const query = new URLSearchParams(request.query);
86
+ query.set('ticket', ticket);
87
+ const url = `${origin.replace(/^http/, 'ws')}${base}/${path}?${query}`;
88
+ const socket = new ws_1.WebSocket(url, { handshakeTimeout: timeoutMs, perMessageDeflate: false });
89
+ yield new Promise((resolve, reject) => {
90
+ socket.once('open', () => {
91
+ // Paused until something reads it: the node sends H.264's config
92
+ // packet and logcat's replay as soon as it opens, and a message with
93
+ // no listener yet is lost. relaySocket resumes it.
94
+ socket.pause();
95
+ resolve();
96
+ });
97
+ socket.once('error', reject);
98
+ });
99
+ logger_1.default.info(`[${udid}] ${path} socket opened on node ${origin}`);
100
+ return socket;
101
+ });
102
+ }
103
+ /** A close code a server may send. 1005, 1006 and 1015 only describe a close. */
104
+ function isSendable(code) {
105
+ return ((code >= 1000 && code <= 1014 && ![1004, 1005, 1006].includes(code)) ||
106
+ (code >= 3000 && code <= 4999));
107
+ }
108
+ function closeWith(ws, code, reason) {
109
+ if (ws.readyState === ws_1.WebSocket.CLOSING || ws.readyState === ws_1.WebSocket.CLOSED)
110
+ return;
111
+ try {
112
+ if (code === 1005)
113
+ ws.close();
114
+ // The node's connection dropped with no close: the viewer retries.
115
+ else if (!isSendable(code))
116
+ ws.close(1011, 'node connection lost');
117
+ else
118
+ ws.close(code, reason.toString());
119
+ }
120
+ catch (_a) {
121
+ ws.terminate();
122
+ }
123
+ }
124
+ /**
125
+ * Relay `viewer` to and from `node` until either closes. Messages pass
126
+ * unchanged, binary or text as they came, and the node's close code and
127
+ * reason reach the viewer. While more than `highWaterBytes` sent to the
128
+ * viewer are still unwritten, reading from the node pauses: the node then
129
+ * sees a slow viewer and applies its own rule, rather than this server
130
+ * buffering without bound.
131
+ */
132
+ function relaySocket(viewer, node, highWaterBytes = exports.RELAY_HIGH_WATER_BYTES) {
133
+ let unwritten = 0;
134
+ let paused = false;
135
+ node.on('message', (data, isBinary) => {
136
+ if (viewer.readyState !== ws_1.WebSocket.OPEN)
137
+ return;
138
+ const size = data.length;
139
+ unwritten += size;
140
+ viewer.send(data, { binary: isBinary }, () => {
141
+ unwritten -= size;
142
+ if (paused && unwritten <= highWaterBytes / 2) {
143
+ paused = false;
144
+ node.resume();
145
+ }
146
+ });
147
+ if (!paused && unwritten > highWaterBytes) {
148
+ paused = true;
149
+ node.pause();
150
+ }
151
+ });
152
+ viewer.on('message', (data, isBinary) => {
153
+ if (node.readyState === ws_1.WebSocket.OPEN)
154
+ node.send(data, { binary: isBinary });
155
+ });
156
+ node.on('close', (code, reason) => closeWith(viewer, code, reason));
157
+ viewer.on('close', () => {
158
+ if (node.readyState === ws_1.WebSocket.OPEN || node.readyState === ws_1.WebSocket.CONNECTING) {
159
+ node.close(1000);
160
+ }
161
+ });
162
+ // A 'close' follows each; nothing more to do here.
163
+ node.on('error', () => undefined);
164
+ viewer.on('error', () => undefined);
165
+ // openNodeSocket hands the node's socket over paused; read it now.
166
+ node.resume();
167
+ }
@@ -293,6 +293,17 @@ class LokiDeviceStore {
293
293
  return true;
294
294
  });
295
295
  }
296
+ markNodeBusy(udid, host) {
297
+ return __awaiter(this, void 0, void 0, function* () {
298
+ const model = yield db_1.XenonDatabase.DeviceModel;
299
+ const device = model.findOne({ udid, host });
300
+ if (!device)
301
+ return;
302
+ device.nodeBusy = true;
303
+ device.busy = true;
304
+ model.update(device);
305
+ });
306
+ }
296
307
  resetMetrics() {
297
308
  return __awaiter(this, void 0, void 0, function* () {
298
309
  (yield db_1.XenonDatabase.DeviceModel)
@@ -7,6 +7,7 @@ exports.claimWhere = claimWhere;
7
7
  exports.holdsClaim = holdsClaim;
8
8
  exports.hasNoClaim = hasNoClaim;
9
9
  exports.isUnheld = isUnheld;
10
+ exports.heldHere = heldHere;
10
11
  /**
11
12
  * Why a phone is busy, on the server that allocates it.
12
13
  *
@@ -94,5 +95,22 @@ exports.UNHELD = Object.assign(Object.assign({}, exports.NO_CLAIM), { session_id
94
95
  function isUnheld(device) {
95
96
  return hasNoClaim(device) && device.session_id == null && device.nodeBusy !== true;
96
97
  }
98
+ /**
99
+ * Busy for a reason this server has on record: its claim, a session or hold
100
+ * in `session_id`, or plain `busy` on a phone of its own. False for a node's
101
+ * phone busy only by the node's report (`nodeBusy`), a preview the node
102
+ * holds say: this server knows no holder for it, and the node, which does,
103
+ * judges every call forwarded there with its own guard. An ownership check
104
+ * reads this, not `busy`, or it fails closed on every such phone, refusing
105
+ * the preview's own holder. A server with no nodes never has nodeBusy, so
106
+ * for it this is `busy`.
107
+ */
108
+ function heldHere(device) {
109
+ if (!device.busy)
110
+ return false;
111
+ if (device.nodeBusy !== true)
112
+ return true;
113
+ return device.session_id != null || device.claimedAt != null || device.claimSessionId != null;
114
+ }
97
115
  /** What ending a claim resets: the claim, and the session's bookkeeping. */
98
116
  exports.CLAIM_RESET = Object.assign(Object.assign({}, exports.NO_CLAIM), { session_id: null, lastCmdExecutedAt: null, sessionStartTime: 0, newCommandTimeout: null, sessionProgress: '', owningSessionId: null, lockedAt: null });
@@ -386,6 +386,14 @@ class PrismaDeviceStore {
386
386
  return cleared.count > 0;
387
387
  });
388
388
  }
389
+ markNodeBusy(udid, host) {
390
+ return __awaiter(this, void 0, void 0, function* () {
391
+ yield this.prisma.device.updateMany({
392
+ where: { udid, host },
393
+ data: { nodeBusy: true, busy: true },
394
+ });
395
+ });
396
+ }
389
397
  resetMetrics() {
390
398
  return __awaiter(this, void 0, void 0, function* () {
391
399
  yield this.prisma.device.updateMany({
@@ -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.includesAndroidDevice = includesAndroidDevice;
57
58
  const displayState_1 = require("./android/displayState");
58
59
  const networkAddresses_1 = require("../helpers/networkAddresses");
59
60
  const appium_adb_1 = require("appium-adb");
@@ -80,6 +81,21 @@ const deviceIdentity_1 = require("./deviceIdentity");
80
81
  const localDeviceHosts_1 = require("./localDeviceHosts");
81
82
  const PluginContext_1 = require("../PluginContext");
82
83
  const typedi_2 = require("typedi");
84
+ /**
85
+ * Whether a server set to `androidDeviceType` serves this phone: real phones,
86
+ * emulators ('simulated'), or both, and with `bootedEmulators` only the
87
+ * emulators that are up. Discovery and a plugged phone (onDeviceAdded) both
88
+ * ask this, so a phone the server excludes is never added by either.
89
+ */
90
+ function includesAndroidDevice(device, androidDeviceType, bootedEmulators) {
91
+ if (androidDeviceType === 'real')
92
+ return device.deviceType === 'real';
93
+ if (androidDeviceType === 'simulated' && device.deviceType !== 'emulator')
94
+ return false;
95
+ if (bootedEmulators && device.deviceType === 'emulator')
96
+ return device.state === 'device';
97
+ return true;
98
+ }
83
99
  let AndroidDeviceManager = class AndroidDeviceManager {
84
100
  constructor(context) {
85
101
  this.context = context;
@@ -149,33 +165,7 @@ let AndroidDeviceManager = class AndroidDeviceManager {
149
165
  devices = yield this.fetchAndroidDevices(existingDeviceDetails, this.pluginArgs);
150
166
  logger_1.default.info(`Found ${devices.length} android devices`);
151
167
  }
152
- if (deviceTypes.androidDeviceType === 'real') {
153
- return devices.filter((device) => {
154
- console.log(`Filtering device ${device.udid}, type: ${device.deviceType}, expected real`);
155
- return device.deviceType === 'real';
156
- });
157
- }
158
- else if (deviceTypes.androidDeviceType === 'simulated') {
159
- const simulated = devices.filter((device) => {
160
- return device.deviceType === 'emulator';
161
- });
162
- if (this.pluginArgs.bootedEmulators) {
163
- return simulated.filter((device) => device.state === 'device');
164
- }
165
- return simulated;
166
- // return both real and simulated (emulated) devices
167
- }
168
- else {
169
- if (this.pluginArgs.bootedEmulators) {
170
- return devices.filter((device) => {
171
- if (device.deviceType === 'emulator') {
172
- return device.state === 'device';
173
- }
174
- return true;
175
- });
176
- }
177
- return devices;
178
- }
168
+ return devices.filter((device) => includesAndroidDevice(device, deviceTypes.androidDeviceType, this.pluginArgs.bootedEmulators));
179
169
  }
180
170
  catch (e) {
181
171
  logger_1.default.error(`Error while getting android devices. Error: ${e instanceof Error ? e.message : e}`);
@@ -514,6 +504,16 @@ let AndroidDeviceManager = class AndroidDeviceManager {
514
504
  return;
515
505
  }
516
506
  this.cancelAbort(newDevice.udid);
507
+ // Discovery's rule, before anything is leased or written: a phone of a
508
+ // kind this server doesn't serve was listed (and could be allocated)
509
+ // until the next sync pruned it. A phone that can't say goes on to
510
+ // deviceInfo, which needs the same property and skips it.
511
+ const realDevice = yield this.isRealDevice(originalADB, newDevice.udid).catch(() => undefined);
512
+ if (realDevice !== undefined &&
513
+ !includesAndroidDevice({ deviceType: realDevice ? 'real' : 'emulator', state: newDevice.state }, this.pluginArgs.androidDeviceType, this.pluginArgs.bootedEmulators)) {
514
+ logger_1.default.info(`Device ${newDevice.udid} is ${realDevice ? 'a real phone' : 'an emulator'}, and androidDeviceType is ${this.pluginArgs.androidDeviceType}. Ignoring`);
515
+ return;
516
+ }
517
517
  const trackedDevice = yield this.deviceInfo(newDevice, originalADB, this.pluginArgs, this.hostPort);
518
518
  if (!trackedDevice) {
519
519
  logger_1.default.info(`Cannot get device info for ${newDevice.udid}. Skipping`);
@@ -91,6 +91,8 @@ let AndroidH264StreamService = class AndroidH264StreamService {
91
91
  this.startWatchdog();
92
92
  }
93
93
  startWatchdog() {
94
+ // unref()ed below: the server's listener keeps the process alive; this only
95
+ // needs to fire while it runs.
94
96
  setInterval(() => {
95
97
  const now = Date.now();
96
98
  for (const [udid, s] of this.sessions.entries()) {
@@ -111,7 +113,7 @@ let AndroidH264StreamService = class AndroidH264StreamService {
111
113
  void this.stop(udid).then(() => (0, previewHold_1.releaseIdlePreviewHold)(udid));
112
114
  }
113
115
  }
114
- }, 60000);
116
+ }, 60000).unref();
115
117
  }
116
118
  getMultiplexer(udid) {
117
119
  var _a;
@@ -150,7 +152,13 @@ let AndroidH264StreamService = class AndroidH264StreamService {
150
152
  session.capture = yield this.openCapture(udid, onPacket, source);
151
153
  // Give the first keyframe/config a moment so callers get a ready stream,
152
154
  // but never block start-up indefinitely.
153
- yield Promise.race([firstConfig, new Promise((r) => setTimeout(r, 3000))]);
155
+ let cap;
156
+ try {
157
+ yield Promise.race([firstConfig, new Promise((r) => (cap = setTimeout(r, 3000)))]);
158
+ }
159
+ finally {
160
+ clearTimeout(cap);
161
+ }
154
162
  return mux;
155
163
  }
156
164
  catch (e) {
@@ -81,6 +81,8 @@ let AndroidStreamService = class AndroidStreamService {
81
81
  this.startWatchdog();
82
82
  }
83
83
  startWatchdog() {
84
+ // unref()ed below: the server's listener keeps the process alive; this only
85
+ // needs to fire while it runs.
84
86
  setInterval(() => __awaiter(this, void 0, void 0, function* () {
85
87
  const now = Date.now();
86
88
  for (const [udid, session] of this.sessions.entries()) {
@@ -93,7 +95,7 @@ let AndroidStreamService = class AndroidStreamService {
93
95
  }
94
96
  // Every minute, not every hour: an hourly check left an abandoned
95
97
  // preview hold in place for up to 70 minutes. The loop is in memory.
96
- }), 60000);
98
+ }), 60000).unref();
97
99
  }
98
100
  /**
99
101
  * Resolve the actual adb binary path (and remote-host args) via the same
@@ -138,7 +138,9 @@ let IOSStreamService = class IOSStreamService {
138
138
  startWatchdog() {
139
139
  // Every minute, and cheap: an unwatched preview's hold was held for good,
140
140
  // because the only idle check ran hourly and kept every busy device.
141
- setInterval(() => void this.sweepIdle(), 60000);
141
+ // unref()ed: the server's listener keeps the process alive; these only
142
+ // need to fire while it runs.
143
+ setInterval(() => void this.sweepIdle(), 60000).unref();
142
144
  // Hourly: port leases and the stream health check, which probes WDA.
143
145
  setInterval(() => __awaiter(this, void 0, void 0, function* () {
144
146
  for (const [udid, session] of this.sessions.entries()) {
@@ -187,7 +189,7 @@ let IOSStreamService = class IOSStreamService {
187
189
  }
188
190
  }
189
191
  }
190
- }), 3600000); // 1hr interval for background stability
192
+ }), 3600000).unref(); // 1hr interval for background stability
191
193
  }
192
194
  /**
193
195
  * Stop streams nobody has watched for 10 minutes, which releases an
@@ -21,6 +21,7 @@ const controlDevice_1 = require("./controlDevice");
21
21
  const SessionOwnerResolver_1 = require("../services/device-access/SessionOwnerResolver");
22
22
  const deviceAccessPolicy_1 = require("../services/device-access/deviceAccessPolicy");
23
23
  const actor_1 = require("../services/device-access/actor");
24
+ const deviceClaims_1 = require("../data-service/deviceClaims");
24
25
  const STATE_CHANGING = new Set(['POST', 'PUT', 'DELETE', 'PATCH']);
25
26
  /**
26
27
  * Mutations under /control that this guard must NOT handle.
@@ -129,7 +130,9 @@ function deviceAccessGuard(deps = {}) {
129
130
  }
130
131
  const decision = (0, deviceAccessPolicy_1.evaluateDeviceAccess)({
131
132
  udid,
132
- busy: !!device.busy,
133
+ // Not device.busy: a node's phone busy only by its node's report is
134
+ // the node's to judge (heldHere).
135
+ busy: (0, deviceClaims_1.heldHere)(device),
133
136
  sessionId: device.session_id,
134
137
  sessionOwnerUserId,
135
138
  actorUserId: actor.userId,
@@ -63,6 +63,7 @@ const networkAddresses_1 = require("../helpers/networkAddresses");
63
63
  const logger_1 = __importDefault(require("../logger"));
64
64
  const h264StreamWs_1 = require("../app/ws/h264StreamWs");
65
65
  const logcatWs_1 = require("../app/ws/logcatWs");
66
+ const nodeSocketRelay_1 = require("../app/ws/nodeSocketRelay");
66
67
  const upgradeRouter_1 = require("../app/ws/upgradeRouter");
67
68
  const StreamTicketService_1 = require("./token/StreamTicketService");
68
69
  const AndroidH264StreamService_1 = __importDefault(require("../device-managers/android/AndroidH264StreamService"));
@@ -147,6 +148,8 @@ let ServerManager = ServerManager_1 = class ServerManager {
147
148
  // streaming.androidH264 config itself, so this WS auto-start honours a
148
149
  // { source: 'screenrecord' } rollback just like the REST stream/start.
149
150
  startStream: (udid) => typedi_1.Container.get(AndroidH264StreamService_1.default).start(udid),
151
+ // A node's phone is streamed by its node and relayed from here.
152
+ nodeSocket: (udid, actor) => (0, nodeSocketRelay_1.openNodeSocket)({ udid, actor, path: 'stream/h264' }),
150
153
  });
151
154
  // Continuous logcat WebSocket (Android; replaces the 3s dump-poll).
152
155
  // A route of the upgrade router: only /logcat upgrades reach it.
@@ -191,6 +194,16 @@ let ServerManager = ServerManager_1 = class ServerManager {
191
194
  // server-side level filter applies.
192
195
  return { mux: yield typedi_1.Container.get(LogcatStreamService_1.LogcatStreamService).start(udid) };
193
196
  }),
197
+ // A node's phone: its node streams the log, with the same filter.
198
+ nodeSocket: (udid, actor, filter) => {
199
+ var _a;
200
+ return (0, nodeSocketRelay_1.openNodeSocket)({
201
+ udid,
202
+ actor,
203
+ path: 'logcat',
204
+ query: new URLSearchParams(Object.assign(Object.assign({}, (((_a = filter.levels) === null || _a === void 0 ? void 0 : _a.length) ? { levels: filter.levels.join(',') } : {})), (filter.process ? { process: filter.process } : {}))),
205
+ });
206
+ },
194
207
  });
195
208
  }
196
209
  // Principal Cleaning: Attempt to recover remote sessions before marking as failed
@@ -12,6 +12,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
12
12
  exports.makeTicketActorAuthorizer = makeTicketActorAuthorizer;
13
13
  const manualLock_1 = require("../recording/manualLock");
14
14
  const deviceAccessPolicy_1 = require("./deviceAccessPolicy");
15
+ const deviceClaims_1 = require("../../data-service/deviceClaims");
15
16
  /**
16
17
  * Build the ownership check for a caller authenticated by a stream ticket.
17
18
  *
@@ -54,7 +55,8 @@ function makeTicketActorAuthorizer(deps) {
54
55
  : null;
55
56
  return (0, deviceAccessPolicy_1.evaluateDeviceAccess)({
56
57
  udid,
57
- busy: !!device.busy,
58
+ // heldHere, as deviceAccessGuard reads it.
59
+ busy: (0, deviceClaims_1.heldHere)(device),
58
60
  sessionId: device.session_id,
59
61
  sessionOwnerUserId,
60
62
  // The ticket's actorId is a User id — see StreamTicketService.
@@ -36,11 +36,13 @@ const logger_1 = __importDefault(require("../../logger"));
36
36
  */
37
37
  function leaseReapReason(lease, now) {
38
38
  var _a;
39
+ // The end time first: past it, heartbeats are refused, so a lease that
40
+ // expired has missed its heartbeats too by the next sweep.
41
+ if (lease.expiresAt < now)
42
+ return `expired at ${new Date(lease.expiresAt).toISOString()}`;
39
43
  const thresholdMs = ((_a = lease.heartbeatSeconds) !== null && _a !== void 0 ? _a : 30) * 1000 * 3;
40
44
  if (lease.lastHeartbeatAt + thresholdMs < now)
41
45
  return 'missed heartbeats';
42
- if (lease.expiresAt < now)
43
- return `expired at ${new Date(lease.expiresAt).toISOString()}`;
44
46
  return null;
45
47
  }
46
48
  let LeaseOrphanSweeper = class LeaseOrphanSweeper {