@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.
- package/lib/package.json +1 -1
- package/lib/public/assets/{AnnotationOverlay-XpewX5nn.js → AnnotationOverlay-CpoWcXKP.js} +1 -1
- package/lib/public/assets/{ApiKeyGate-BQDv3ZCC.js → ApiKeyGate-DAYuoJDl.js} +1 -1
- package/lib/public/assets/{BugReportButton-C7OmS61B.js → BugReportButton-DEXyENUv.js} +1 -1
- package/lib/public/assets/DeviceMosaicView-C-sJRz63.js +2 -0
- package/lib/public/assets/{EmptyState-fPxujqqE.js → EmptyState-B_JRYedM.js} +1 -1
- package/lib/public/assets/{FieldGroup-5bCutWrs.js → FieldGroup-CorXlSLU.js} +1 -1
- package/lib/public/assets/{FilterMenu-owsBiDT1.js → FilterMenu-BKv95DEc.js} +1 -1
- package/lib/public/assets/{Menu-DFhqzPSt.js → Menu-Cpy5yMwg.js} +1 -1
- package/lib/public/assets/{Modal-Bw-a-E-e.js → Modal-Djp4NvyZ.js} +1 -1
- package/lib/public/assets/{RecordingPage-WtByf5bC.js → RecordingPage-CP57OKw9.js} +1 -1
- package/lib/public/assets/{RecordingsPage-FpWWFNgT.js → RecordingsPage-BjrTit38.js} +1 -1
- package/lib/public/assets/{SegmentedControl-Jzn9c7o8.js → SegmentedControl-MGTzJhBf.js} +1 -1
- package/lib/public/assets/{SettingCard-BR7nOhpT.js → SettingCard-CYBPhKPe.js} +1 -1
- package/lib/public/assets/{Table-B_Va3V05.js → Table-CdKQ9hnU.js} +1 -1
- package/lib/public/assets/{activity-DxRFWvkN.js → activity-QsRK21QF.js} +1 -1
- package/lib/public/assets/{ai-settings-CQf-9_V6.js → ai-settings-Dn6DtRn7.js} +1 -1
- package/lib/public/assets/{api-keys-SoE1Kd7N.js → api-keys-DYyf17eU.js} +1 -1
- package/lib/public/assets/{apps-CgGXTlgn.js → apps-HKFKTe1J.js} +1 -1
- package/lib/public/assets/{arrow-left-uQ6WIwiC.js → arrow-left-BkA7oMRe.js} +1 -1
- package/lib/public/assets/{arrow-right-C9RyfzXS.js → arrow-right-BsSr58EU.js} +1 -1
- package/lib/public/assets/{arrow-up-right-DFHi_8yG.js → arrow-up-right-B7TEV_Gr.js} +1 -1
- package/lib/public/assets/{auth-shell-Bii1t4qc.js → auth-shell-BlmNHGcG.js} +2 -2
- package/lib/public/assets/{builds-page-Bnv8Z9hi.js → builds-page-E4WIKtl8.js} +1 -1
- package/lib/public/assets/{button-_Y78wXiP.js → button-BIwCF2bR.js} +1 -1
- package/lib/public/assets/{calendar-D4MqSOc2.js → calendar-CMLnXC7N.js} +1 -1
- package/lib/public/assets/{check-uGHCXe-L.js → check-zugO3p5U.js} +1 -1
- package/lib/public/assets/{chevron-left-Dy1OqpzB.js → chevron-left-sCqxpTY6.js} +1 -1
- package/lib/public/assets/{chevron-right-BUsldEli.js → chevron-right-Bb59xwZc.js} +1 -1
- package/lib/public/assets/{circle-check-D3Qq0tKp.js → circle-check-DsvxbnTg.js} +1 -1
- package/lib/public/assets/{circle-x-B4IOD1DE.js → circle-x-w69AbmbW.js} +1 -1
- package/lib/public/assets/{clock-BfYfzvSg.js → clock-C6XOs4bc.js} +1 -1
- package/lib/public/assets/{copy-DZwYE5Va.js → copy-pHk-SWBM.js} +1 -1
- package/lib/public/assets/{device-explorer-CRG4Utt5.js → device-explorer-BDq38IcO.js} +52 -52
- package/lib/public/assets/{download-CCDYo_TN.js → download-CAHtPZmY.js} +1 -1
- package/lib/public/assets/{forgot-password-DuLCmVFb.js → forgot-password-cmvhGOUu.js} +1 -1
- package/lib/public/assets/{funnel-CtKNPqzb.js → funnel-BrDuhOtM.js} +1 -1
- package/lib/public/assets/{index-3uxl3o7K.js → index-BQCXPOXt.js} +2 -2
- package/lib/public/assets/{index-Zy1Op5C1.js → index-C-Q4QkXb.js} +1 -1
- package/lib/public/assets/{input-CzTWvUXZ.js → input-DnUVHYax.js} +1 -1
- package/lib/public/assets/{keycodes-CI-nYDFM.js → keycodes-CmOptFVa.js} +4 -4
- package/lib/public/assets/{layers-YZZGFyTX.js → layers-DRtz_bzF.js} +1 -1
- package/lib/public/assets/{lock-L8tzWgDo.js → lock-DF2VnPFm.js} +1 -1
- package/lib/public/assets/{login-DBbX-fu8.js → login-DHYffYa4.js} +1 -1
- package/lib/public/assets/{maintenance-settings-AJwkOIsE.js → maintenance-settings-r0uPsJ6n.js} +1 -1
- package/lib/public/assets/{monitor-Di_66F2m.js → monitor-7nt9oGNx.js} +1 -1
- package/lib/public/assets/{mouse-pointer-2-Cnab2zdg.js → mouse-pointer-2-ys1MKVr2.js} +1 -1
- package/lib/public/assets/{network-DiSumY-1.js → network-B8m-F79k.js} +1 -1
- package/lib/public/assets/{overview-CwE4gXl5.js → overview-BHEntlXz.js} +1 -1
- package/lib/public/assets/{page-header-CfR4Sm9V.js → page-header-CuFXVUop.js} +1 -1
- package/lib/public/assets/{plus-DOS8P9CQ.js → plus-DUSlMac2.js} +1 -1
- package/lib/public/assets/{profile-page-BYpHwQIH.js → profile-page-CBamPy_h.js} +1 -1
- package/lib/public/assets/{reset-password-BRVJU_qV.js → reset-password-DG0vZl-o.js} +1 -1
- package/lib/public/assets/{runbook-page-Bi3JvAFZ.js → runbook-page-5S3T-I4g.js} +1 -1
- package/lib/public/assets/{select-Cqx7pEou.js → select-f0ci1QC7.js} +1 -1
- package/lib/public/assets/{selector-detail-page-C0DsncKj.js → selector-detail-page-BXA9LUf9.js} +1 -1
- package/lib/public/assets/{selector-health-CAhpdilF.js → selector-health-TyMPgAWo.js} +1 -1
- package/lib/public/assets/{selector-health-page-CrCVeW2m.js → selector-health-page-uBlXy33G.js} +1 -1
- package/lib/public/assets/{session-detail-page-F_hwYqEt.js → session-detail-page-DECim5lh.js} +1 -1
- package/lib/public/assets/{settings-C-s5pypL.js → settings-IfOFbqtv.js} +1 -1
- package/lib/public/assets/{status-pill-outline-DY5uzv6A.js → status-pill-outline-grXLf8I8.js} +1 -1
- package/lib/public/assets/{tablet-Dli7gwyg.js → tablet-lmFk4qxW.js} +1 -1
- package/lib/public/assets/{teams-ui4IhHNC.js → teams-CwMSjVVW.js} +1 -1
- package/lib/public/assets/{trash-2-BV4hSmXJ.js → trash-2-BKsgmBS6.js} +1 -1
- package/lib/public/assets/{upload-DkGy_J-L.js → upload-DXH47sQS.js} +1 -1
- package/lib/public/assets/{use-password-reset-mode-BFtMqQOh.js → use-password-reset-mode-DU98dxpc.js} +1 -1
- package/lib/public/assets/{users-Nb_rzRmF.js → users-V5O64rVx.js} +1 -1
- package/lib/public/assets/{users-BU0nXIVe.js → users-eXWwQZjX.js} +1 -1
- package/lib/public/assets/{video-off-CUNzD2kH.js → video-off-BuoIHEYy.js} +1 -1
- package/lib/public/assets/{webhook-settings-Ca3UFXZe.js → webhook-settings-ClsxsKnC.js} +1 -1
- package/lib/public/assets/{zap-B2bH2vKX.js → zap-I10vdQXd.js} +1 -1
- package/lib/public/index.html +1 -1
- package/lib/src/app/routers/control.js +114 -61
- package/lib/src/app/routers/nodePhoneControl.js +288 -46
- package/lib/src/data-service/device-store.js +7 -3
- package/lib/src/data-service/deviceClaims.js +14 -0
- package/lib/src/data-service/deviceFieldOwners.js +3 -1
- package/lib/src/data-service/prisma-store.js +3 -3
- package/lib/src/device-managers/AndroidDeviceManager.js +11 -1
- package/lib/src/gateway/forwardToNode.js +10 -3
- package/lib/src/generated/client/edge.js +5 -4
- package/lib/src/generated/client/index-browser.js +2 -1
- package/lib/src/generated/client/index.d.ts +36 -1
- package/lib/src/generated/client/index.js +5 -4
- package/lib/src/generated/client/package.json +1 -1
- package/lib/src/generated/client/schema.prisma +4 -0
- package/lib/src/generated/client/wasm.js +2 -1
- package/lib/src/services/recording/RecordingOrchestrator.js +39 -5
- package/lib/src/services/recording/busy-precheck.js +5 -2
- package/lib/src/services/recording/nodeRecordingSource.js +156 -0
- package/lib/test/unit/AndroidDeviceManager.spec.js +12 -0
- package/lib/test/unit/busy-precheck.spec.js +32 -0
- package/lib/test/unit/hub-node-control-forward.spec.js +66 -10
- package/lib/test/unit/hub-node-race.spec.js +29 -3
- package/lib/test/unit/mark-node-busy-loki.spec.js +17 -4
- package/lib/test/unit/node-phone-control.spec.js +124 -4
- package/lib/test/unit/node-recording-relay.spec.js +165 -0
- package/lib/test/unit/recording-orchestrator.spec.js +86 -2
- package/lib/test/unit/screenshot-route.spec.js +104 -0
- package/lib/test/unit/upload-install-route.spec.js +205 -0
- package/lib/tsconfig.tsbuildinfo +1 -1
- package/package.json +1 -1
- package/prisma/migrations/20261001120000_device_node_hold/migration.sql +2 -0
- package/prisma/schema.prisma +4 -0
- 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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
|
172
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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:
|
|
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
|
-
|
|
265
|
-
|
|
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}: ${(
|
|
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: ${(
|
|
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
|
|
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
|
-
|
|
78
|
-
|
|
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
|
-
|
|
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) {
|