@xenon-device-management/xenon 2.13.0 → 2.13.2
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/LICENSE +15 -0
- package/README.md +223 -488
- package/lib/package.json +1 -1
- package/lib/public/assets/{AnnotationOverlay-CzbFYtPI.js → AnnotationOverlay-BXruClDk.js} +1 -1
- package/lib/public/assets/{ApiKeyGate-CA5KFHHJ.js → ApiKeyGate-qTS4LPLC.js} +1 -1
- package/lib/public/assets/{BugReportButton-D-DSGg8C.js → BugReportButton-BoGhlI7F.js} +1 -1
- package/lib/public/assets/{DeviceMosaicView-paDEFU4B.js → DeviceMosaicView-8J0oYqMM.js} +1 -1
- package/lib/public/assets/{EmptyState-BE_5f8Vw.js → EmptyState-Cr17PdDv.js} +1 -1
- package/lib/public/assets/{FieldGroup-DqMf7Rf3.js → FieldGroup-Bvn08svv.js} +1 -1
- package/lib/public/assets/{FilterMenu-BQY75Su3.js → FilterMenu-DDSO8aDf.js} +1 -1
- package/lib/public/assets/{Menu-BiW3os9u.js → Menu-B-Qn2X3T.js} +1 -1
- package/lib/public/assets/{Modal-DfawTxih.js → Modal-Cg9V5JYU.js} +1 -1
- package/lib/public/assets/{RecordingPage-BvemyRiy.js → RecordingPage-BTUs9VTW.js} +1 -1
- package/lib/public/assets/{RecordingsPage-CXfw7wpd.js → RecordingsPage-DYcfxITK.js} +2 -2
- package/lib/public/assets/{SegmentedControl-B7m9kpom.js → SegmentedControl-Zy7suhT5.js} +1 -1
- package/lib/public/assets/{SettingCard-Cw6lDD0l.js → SettingCard-CWYmycoe.js} +1 -1
- package/lib/public/assets/{Table-DlEuFgv-.js → Table-io9fwa98.js} +1 -1
- package/lib/public/assets/{activity-CnLvNwae.js → activity-Bdf_uIWl.js} +1 -1
- package/lib/public/assets/ai-settings-Bce5SJob.js +21 -0
- package/lib/public/assets/{api-keys-DphSVzBr.js → api-keys-CPqtVpm3.js} +1 -1
- package/lib/public/assets/{apps-vUfu15lX.js → apps-BgZan0H9.js} +2 -2
- package/lib/public/assets/{arrow-left-CLQWAiCY.js → arrow-left-B27ckB31.js} +1 -1
- package/lib/public/assets/{arrow-right-K6A5e_Ee.js → arrow-right-DOF8ITCz.js} +1 -1
- package/lib/public/assets/{arrow-up-right-DUA_lq6f.js → arrow-up-right-eFG5KTFS.js} +1 -1
- package/lib/public/assets/{auth-shell-BYRHcZd6.js → auth-shell-DlC3_27j.js} +2 -2
- package/lib/public/assets/{builds-page-C0fTvIG8.js → builds-page-BuXpPfKo.js} +1 -1
- package/lib/public/assets/{button-BxnHCCsX.js → button-fIW8owMI.js} +1 -1
- package/lib/public/assets/{calendar-CqVsn28A.js → calendar-WNjmrF-a.js} +1 -1
- package/lib/public/assets/{check-R6V3bot0.js → check-BdTh1Sq7.js} +1 -1
- package/lib/public/assets/{chevron-right-BA2Fntu-.js → chevron-right-B4BKlsqk.js} +1 -1
- package/lib/public/assets/{circle-check-D4EzyGzs.js → circle-check-zJU80_EF.js} +1 -1
- package/lib/public/assets/{circle-x-Cf6AF2B2.js → circle-x-5Uwquo0h.js} +1 -1
- package/lib/public/assets/{clock-EZfet0sS.js → clock-BtUprDsZ.js} +1 -1
- package/lib/public/assets/{copy-Bc6P8v9V.js → copy-DGSbZI3u.js} +1 -1
- package/lib/public/assets/device-explorer-C-KnhMHI.js +279 -0
- package/lib/public/assets/{download-Bd8OGLzi.js → download-WG6l7BgD.js} +1 -1
- package/lib/public/assets/{forgot-password-CoE7Ugtx.js → forgot-password-DnudqFFo.js} +1 -1
- package/lib/public/assets/{index-BTYMAqNW.css → index-Bxoxt9sV.css} +1 -1
- package/lib/public/assets/{index-CHOr4JCs.js → index-DI1fGIyj.js} +43 -43
- package/lib/public/assets/{index-BiL0Enl_.js → index-HDQVe1gi.js} +1 -1
- package/lib/public/assets/{input-CJ8z3IO5.js → input-BjDRKCea.js} +1 -1
- package/lib/public/assets/{line-chart-CPK0ObjV.js → line-chart-C0zdgd0v.js} +1 -1
- package/lib/public/assets/{list-checks-mXGHUVbB.js → list-checks-BdW5CjM8.js} +1 -1
- package/lib/public/assets/{lock-BSXM1xvq.js → lock-Dy3L4I8E.js} +1 -1
- package/lib/public/assets/{login-Ct2iW066.js → login-BHbY9CLG.js} +1 -1
- package/lib/public/assets/maintenance-settings-zDVlOMVK.js +6 -0
- package/lib/public/assets/{monitor-D8WQK8md.js → monitor-8xjkeHHi.js} +1 -1
- package/lib/public/assets/{mouse-pointer-2-Cw2tWzDX.js → mouse-pointer-2-CXdKMXQ8.js} +1 -1
- package/lib/public/assets/{network-Ba_uFxWo.js → network-Cj8JOKON.js} +1 -1
- package/lib/public/assets/{overview-CxrZ7_6k.js → overview-CTg7F0im.js} +1 -1
- package/lib/public/assets/{page-header-Cm1XL49f.js → page-header--aTyJXq6.js} +1 -1
- package/lib/public/assets/{play-C0CuKLXj.js → play-B-FodgHV.js} +1 -1
- package/lib/public/assets/{plus-CIIvR_6Z.js → plus-CsIk10Cq.js} +1 -1
- package/lib/public/assets/{profile-page-BZ_e-Bgw.js → profile-page-DkFKkMfl.js} +1 -1
- package/lib/public/assets/{recording-group-store-BWeeNs2_.js → recording-group-store-WVM3hoXn.js} +1 -1
- package/lib/public/assets/{reset-password-lfNz8Rm1.js → reset-password-I2-zqLlI.js} +1 -1
- package/lib/public/assets/{runbook-page-Cnec3VSx.js → runbook-page-DJTVCJX7.js} +1 -1
- package/lib/public/assets/{select-CZuCnold.js → select-DvCSv25z.js} +1 -1
- package/lib/public/assets/{selector-detail-redirect-BG3pxFLw.js → selector-detail-redirect-BLVfMJs_.js} +1 -1
- package/lib/public/assets/{selector-health-page-DsyL58tb.js → selector-health-page-oqadRokn.js} +2 -2
- package/lib/public/assets/session-detail-page-CGn55Pvl.js +4 -0
- package/lib/public/assets/settings-MsVU2ukE.js +6 -0
- package/lib/public/assets/{stat-tile-DFhv8JoI.js → stat-tile-DV9Ix-4O.js} +1 -1
- package/lib/public/assets/{tablet-68J5f6zh.js → tablet-6b_SEN2X.js} +1 -1
- package/lib/public/assets/{teams-eHSyEpbD.js → teams-BjZyfuHh.js} +1 -1
- package/lib/public/assets/{trash-2-CcSsv4fS.js → trash-2-CTZpPSdd.js} +1 -1
- package/lib/public/assets/{upload-BYf26K71.js → upload-P3XzwQUb.js} +1 -1
- package/lib/public/assets/{use-builds-data-CHW-MhaP.js → use-builds-data-1cOCMf-z.js} +1 -1
- package/lib/public/assets/{use-password-reset-mode-DozShXkk.js → use-password-reset-mode-DgF9w088.js} +1 -1
- package/lib/public/assets/{users-CcSQRoK9.js → users-CiNTKUiz.js} +1 -1
- package/lib/public/assets/{users-BpYnpOl2.js → users-DMF9Mk5H.js} +1 -1
- package/lib/public/assets/{video-off-dFKgps73.js → video-off-CHp1KU_T.js} +1 -1
- package/lib/public/assets/webhook-settings-DiS8geIm.js +1 -0
- package/lib/public/assets/{zap-COC2tZaH.js → zap-DRz0tDq5.js} +1 -1
- package/lib/public/index.html +2 -2
- package/lib/schema.json +0 -25
- package/lib/src/app/routers/control.js +19 -4
- package/lib/src/app/routers/streamStartConflict.js +3 -0
- package/lib/src/data-service/device-service.js +40 -0
- package/lib/src/data-service/device-store.js +4 -1
- package/lib/src/data-service/prisma-store.js +2 -1
- package/lib/src/device-managers/ios/WDAClient.js +128 -16
- package/lib/src/device-utils.js +63 -13
- package/lib/src/middleware/deviceAccessGuard.js +63 -9
- package/lib/src/services/ServerManager.js +3 -0
- package/lib/src/services/SessionLifecycleService.js +16 -4
- package/lib/src/services/device-access/SessionOwnerResolver.js +26 -0
- package/lib/src/services/device-access/deviceAccessPolicy.js +38 -6
- package/lib/src/services/device-access/leaseHold.js +26 -0
- package/lib/src/services/device-access/ticketActorAccess.js +6 -0
- package/lib/src/services/lease/activeLeases.js +37 -0
- package/lib/src/services/recording/busy-precheck.js +24 -3
- package/lib/test/unit/device-access-lease.spec.js +270 -0
- package/lib/test/unit/lease/allocate-lease-branch.spec.js +22 -0
- package/lib/test/unit/lease/session-lease-proof.spec.js +59 -0
- package/lib/test/unit/lease-held-phone.spec.js +196 -0
- package/lib/test/unit/partial-config-file.spec.js +100 -0
- package/lib/test/unit/schema-required-args.spec.js +21 -16
- package/lib/test/unit/wda-client-session.spec.js +128 -0
- package/lib/test/unit/wda-clipboard-write.spec.js +135 -0
- package/lib/tsconfig.tsbuildinfo +1 -1
- package/package.json +1 -1
- package/schema.json +0 -25
- package/scripts/dev/readme-screenshots.js +231 -0
- package/scripts/generate-types-from-schema.js +6 -0
- package/scripts/lib/always-present-plugin-args.js +39 -0
- package/lib/public/assets/ai-settings-D7Te8o-a.js +0 -21
- package/lib/public/assets/device-explorer-BFXpRq6d.js +0 -279
- package/lib/public/assets/maintenance-settings-DaOs00P6.js +0 -6
- package/lib/public/assets/session-detail-page-ZZyGM413.js +0 -4
- package/lib/public/assets/settings-DGZ7B_AR.js +0 -6
- package/lib/public/assets/webhook-settings-DD1XjiOD.js +0 -1
|
@@ -36,6 +36,11 @@ const os_1 = __importDefault(require("os"));
|
|
|
36
36
|
const fs_extra_1 = __importDefault(require("fs-extra"));
|
|
37
37
|
const execFilePromise = (0, util_1.promisify)(child_process_1.execFile);
|
|
38
38
|
const execPromise = (0, util_1.promisify)(child_process_1.exec);
|
|
39
|
+
/** WDA's answer for a session that no longer exists: 404 "invalid session id". */
|
|
40
|
+
function isInvalidSession(err) {
|
|
41
|
+
var _a, _b, _c;
|
|
42
|
+
return ((_c = (_b = (_a = err === null || err === void 0 ? void 0 : err.response) === null || _a === void 0 ? void 0 : _a.data) === null || _b === void 0 ? void 0 : _b.value) === null || _c === void 0 ? void 0 : _c.error) === 'invalid session id';
|
|
43
|
+
}
|
|
39
44
|
/** A key name the iPhone has no equivalent for. Device control answers 400. */
|
|
40
45
|
class UnsupportedKeyError extends Error {
|
|
41
46
|
constructor(key) {
|
|
@@ -79,7 +84,7 @@ let WDAClient = WDAClient_1 = class WDAClient {
|
|
|
79
84
|
}
|
|
80
85
|
performWDACommand(udid_1, method_1, endpoint_1, data_1) {
|
|
81
86
|
return __awaiter(this, arguments, void 0, function* (udid, method, endpoint, data, retryCount = 0, options) {
|
|
82
|
-
var _a, _b, _c;
|
|
87
|
+
var _a, _b, _c, _d;
|
|
83
88
|
const device = yield device_store_1.DeviceStoreFactory.getStore().findDevice({ udid });
|
|
84
89
|
if (!device)
|
|
85
90
|
throw new Error(`Device ${udid} not found`);
|
|
@@ -91,12 +96,12 @@ let WDAClient = WDAClient_1 = class WDAClient {
|
|
|
91
96
|
throw new Error(`WDA port not available for ${udid}`);
|
|
92
97
|
const cacheKey = `${udid}:${port}`;
|
|
93
98
|
let cached = this.wdaConnectionCache.get(cacheKey);
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
99
|
+
// The preview's session wins: each preview creates its own, so after a
|
|
100
|
+
// restart on the same port the cached one is dead.
|
|
101
|
+
const previewSid = typedi_1.Container.get(IOSStreamService_1.default).getWDASessionId(udid);
|
|
102
|
+
if (previewSid && previewSid !== (cached === null || cached === void 0 ? void 0 : cached.sessionId)) {
|
|
103
|
+
cached = { host: '127.0.0.1', pathPrefix: `/session/${previewSid}`, sessionId: previewSid };
|
|
104
|
+
this.wdaConnectionCache.set(cacheKey, cached);
|
|
100
105
|
}
|
|
101
106
|
const prefixes = (cached === null || cached === void 0 ? void 0 : cached.sessionId) ? [`/session/${cached.sessionId}`] : ['', '/session/any'];
|
|
102
107
|
let lastError;
|
|
@@ -106,8 +111,10 @@ let WDAClient = WDAClient_1 = class WDAClient {
|
|
|
106
111
|
const targetHost = '127.0.0.1';
|
|
107
112
|
for (const prefix of prefixes) {
|
|
108
113
|
try {
|
|
114
|
+
// WDA serves /wda/homescreen only without a session; inside one it
|
|
115
|
+
// answers 404 "unknown command".
|
|
109
116
|
const isSessionless = !(options === null || options === void 0 ? void 0 : options.useSessionPath) &&
|
|
110
|
-
|
|
117
|
+
['/status', '/health', '/wda/healthcheck', '/wda/homescreen'].includes(endpoint);
|
|
111
118
|
const url = `http://${targetHost}:${port}${isSessionless ? '' : prefix}${endpoint}`;
|
|
112
119
|
// Principal Stability: Screenshots, activation, and script execution need longer timeouts.
|
|
113
120
|
// Heartbeats and Status checks should be snappy to fail-over quickly.
|
|
@@ -147,17 +154,33 @@ let WDAClient = WDAClient_1 = class WDAClient {
|
|
|
147
154
|
if (retryCount < 2) {
|
|
148
155
|
// Phase 1: Dead Session Recovery (404)
|
|
149
156
|
if (status === 404 && prefix.includes('/session/')) {
|
|
157
|
+
const deadSession = isInvalidSession(err);
|
|
150
158
|
// Special Intelligence: Some WDA versions don't support /execute, /wda/homescreen, etc.
|
|
151
159
|
// We should NOT clear the session if these specific commands fail with 404,
|
|
152
|
-
// as they might just be unsupported features.
|
|
160
|
+
// as they might just be unsupported features. A dead session is
|
|
161
|
+
// not one of those: it was, for every button after a preview restart.
|
|
153
162
|
const NON_FATAL_ENDPOINTS = ['execute', 'homescreen', 'pressButton'];
|
|
154
|
-
if (NON_FATAL_ENDPOINTS.some((e) => endpoint.includes(e))) {
|
|
163
|
+
if (!deadSession && NON_FATAL_ENDPOINTS.some((e) => endpoint.includes(e))) {
|
|
155
164
|
this.log.debug(`[WDA] Command ${endpoint} failed with 404 (Unsupported or Logical Error). Skipping session reset.`);
|
|
156
165
|
throw err;
|
|
157
166
|
}
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
typedi_1.Container.get(IOSStreamService_1.default)
|
|
167
|
+
// WDA names its live session in every answer, a refusal included.
|
|
168
|
+
const liveSid = (_d = err.response.data) === null || _d === void 0 ? void 0 : _d.sessionId;
|
|
169
|
+
const stream = typedi_1.Container.get(IOSStreamService_1.default);
|
|
170
|
+
if (deadSession && typeof liveSid === 'string' && liveSid !== (cached === null || cached === void 0 ? void 0 : cached.sessionId)) {
|
|
171
|
+
this.log.warn(`[WDA] Session ${cached === null || cached === void 0 ? void 0 : cached.sessionId} is gone; WebDriverAgent's is ${liveSid}. Retrying in it...`);
|
|
172
|
+
this.wdaConnectionCache.set(cacheKey, {
|
|
173
|
+
host: targetHost,
|
|
174
|
+
pathPrefix: `/session/${liveSid}`,
|
|
175
|
+
sessionId: liveSid,
|
|
176
|
+
});
|
|
177
|
+
stream.setWDASessionId(udid, liveSid);
|
|
178
|
+
}
|
|
179
|
+
else {
|
|
180
|
+
this.log.warn(`[WDA] Session ${cached === null || cached === void 0 ? void 0 : cached.sessionId} appears dead. Resetting and retrying...`);
|
|
181
|
+
this.wdaConnectionCache.delete(cacheKey);
|
|
182
|
+
stream.setWDASessionId(udid, '');
|
|
183
|
+
}
|
|
161
184
|
return this.performWDACommand(udid, method, endpoint, data, retryCount + 1, options);
|
|
162
185
|
}
|
|
163
186
|
// Phase 2: Transient State Recovery (500)
|
|
@@ -376,7 +399,9 @@ let WDAClient = WDAClient_1 = class WDAClient {
|
|
|
376
399
|
}
|
|
377
400
|
catch (e) {
|
|
378
401
|
this.log.debug(`[WDA] Generic pressButton failed for '${n}': ${e.message}`);
|
|
379
|
-
|
|
402
|
+
// A 404 is a missing session or route, never a missing button: WDA
|
|
403
|
+
// refuses a button it doesn't know with a 500.
|
|
404
|
+
if ((e === null || e === void 0 ? void 0 : e.response) && e.response.status !== 404)
|
|
380
405
|
throw new UnsupportedKeyError(String(key));
|
|
381
406
|
throw e;
|
|
382
407
|
}
|
|
@@ -592,12 +617,99 @@ let WDAClient = WDAClient_1 = class WDAClient {
|
|
|
592
617
|
// These three used to catch WDA's error and log it at debug level, so
|
|
593
618
|
// device control answered 200 for a clipboard write, lock or unlock that
|
|
594
619
|
// never happened. The error now reaches the route, which answers 500.
|
|
620
|
+
//
|
|
621
|
+
// On an iPhone, iOS ignores a pasteboard write from an app in the
|
|
622
|
+
// background and WDA still answers success, so the write is made with WDA's
|
|
623
|
+
// own app in front and read back before it counts.
|
|
595
624
|
setClipboard(udid, content) {
|
|
596
625
|
return __awaiter(this, void 0, void 0, function* () {
|
|
597
|
-
|
|
598
|
-
|
|
626
|
+
const encoded = Buffer.from(content).toString('base64');
|
|
627
|
+
const write = () => this.sendWDACommand(udid, 'post', '/wda/setPasteboard', {
|
|
628
|
+
content: encoded,
|
|
599
629
|
contentType: 'plaintext',
|
|
600
630
|
});
|
|
631
|
+
if (!(yield this.isRealDevice(udid))) {
|
|
632
|
+
yield write();
|
|
633
|
+
return;
|
|
634
|
+
}
|
|
635
|
+
yield this.withWDAInFront(udid, () => __awaiter(this, void 0, void 0, function* () {
|
|
636
|
+
var _a;
|
|
637
|
+
yield write();
|
|
638
|
+
const res = yield this.sendWDACommand(udid, 'post', '/wda/getPasteboard', {
|
|
639
|
+
contentType: 'plaintext',
|
|
640
|
+
});
|
|
641
|
+
const readBack = (_a = res.data) === null || _a === void 0 ? void 0 : _a.value;
|
|
642
|
+
if (readBack !== encoded && this.parsePasteboardResponse(res.data) !== content.trim()) {
|
|
643
|
+
throw new Error('The iPhone didn’t take the clipboard text.');
|
|
644
|
+
}
|
|
645
|
+
}));
|
|
646
|
+
});
|
|
647
|
+
}
|
|
648
|
+
isRealDevice(udid) {
|
|
649
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
650
|
+
const device = yield device_store_1.DeviceStoreFactory.getStore()
|
|
651
|
+
.findDevice({ udid })
|
|
652
|
+
.catch(() => undefined);
|
|
653
|
+
return !!(device === null || device === void 0 ? void 0 : device.realDevice);
|
|
654
|
+
});
|
|
655
|
+
}
|
|
656
|
+
activeBundleId(udid) {
|
|
657
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
658
|
+
var _a, _b, _c;
|
|
659
|
+
const res = yield this.sendWDACommand(udid, 'get', '/wda/activeAppInfo');
|
|
660
|
+
return ((_b = (_a = res.data) === null || _a === void 0 ? void 0 : _a.value) === null || _b === void 0 ? void 0 : _b.bundleId) || ((_c = res.data) === null || _c === void 0 ? void 0 : _c.bundleId);
|
|
661
|
+
});
|
|
662
|
+
}
|
|
663
|
+
/**
|
|
664
|
+
* Runs `action` with WDA's own app in front, then puts back the app that
|
|
665
|
+
* was: the home screen, or the app by its bundle id.
|
|
666
|
+
*
|
|
667
|
+
* Bringing WDA's app forward waits for it to go idle, which it never does:
|
|
668
|
+
* about 21 s on an iPhone 14 Plus, against 0.1 s with the wait off. The wait
|
|
669
|
+
* is a WDA setting shared with any Appium session on the phone, so it is
|
|
670
|
+
* turned off only when its value could be read, and always put back.
|
|
671
|
+
*/
|
|
672
|
+
withWDAInFront(udid, action) {
|
|
673
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
674
|
+
const previous = yield this.activeBundleId(udid).catch(() => undefined);
|
|
675
|
+
const wdaBundleId = (yield typedi_1.Container.get(IOSStreamService_1.default).detectWDABundleId(udid)) ||
|
|
676
|
+
'com.qasecret.WebDriverAgentRunner.xctrunner';
|
|
677
|
+
const idleWait = yield this.sendWDACommand(udid, 'get', '/appium/settings')
|
|
678
|
+
.then((res) => { var _a, _b; return (_b = (_a = res.data) === null || _a === void 0 ? void 0 : _a.value) === null || _b === void 0 ? void 0 : _b.waitForIdleTimeout; })
|
|
679
|
+
.catch(() => undefined);
|
|
680
|
+
const setIdleWait = (seconds) => this.sendWDACommand(udid, 'post', '/appium/settings', {
|
|
681
|
+
settings: { waitForIdleTimeout: seconds },
|
|
682
|
+
});
|
|
683
|
+
try {
|
|
684
|
+
if (typeof idleWait === 'number')
|
|
685
|
+
yield setIdleWait(0);
|
|
686
|
+
yield this.sendWDACommand(udid, 'post', '/wda/apps/activate', { bundleId: wdaBundleId });
|
|
687
|
+
// Activation answers before the app is in front, and a write made
|
|
688
|
+
// before then is lost.
|
|
689
|
+
const deadline = Date.now() + 3000;
|
|
690
|
+
while (Date.now() < deadline) {
|
|
691
|
+
if ((yield this.activeBundleId(udid).catch(() => undefined)) === wdaBundleId)
|
|
692
|
+
break;
|
|
693
|
+
yield new Promise((r) => setTimeout(r, 100));
|
|
694
|
+
}
|
|
695
|
+
yield action();
|
|
696
|
+
}
|
|
697
|
+
finally {
|
|
698
|
+
try {
|
|
699
|
+
if (previous === 'com.apple.springboard') {
|
|
700
|
+
yield this.sendWDACommand(udid, 'post', '/wda/homescreen', {});
|
|
701
|
+
}
|
|
702
|
+
else if (previous && previous !== wdaBundleId) {
|
|
703
|
+
yield this.sendWDACommand(udid, 'post', '/wda/apps/activate', { bundleId: previous });
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
catch (e) {
|
|
707
|
+
this.log.warn(`[Clipboard] Couldn't put back ${previous} on ${udid}: ${e.message}`);
|
|
708
|
+
}
|
|
709
|
+
if (typeof idleWait === 'number') {
|
|
710
|
+
yield setIdleWait(idleWait).catch((e) => this.log.warn(`[Clipboard] Couldn't restore WDA's idle wait on ${udid}: ${e.message}`));
|
|
711
|
+
}
|
|
712
|
+
}
|
|
601
713
|
});
|
|
602
714
|
}
|
|
603
715
|
lock(udid) {
|
package/lib/src/device-utils.js
CHANGED
|
@@ -99,6 +99,7 @@ const config_1 = require("./config");
|
|
|
99
99
|
const device_store_1 = require("./data-service/device-store");
|
|
100
100
|
const leaseSessionCaps_1 = require("./services/lease/leaseSessionCaps");
|
|
101
101
|
const deviceVisibility_1 = require("./services/device-access/deviceVisibility");
|
|
102
|
+
const activeLeases_1 = require("./services/lease/activeLeases");
|
|
102
103
|
// Use a Proxy to ensure we're always using the latest store from the factory,
|
|
103
104
|
// which is critical for test isolation when the factory cache is cleared.
|
|
104
105
|
const pendingStore = new Proxy({}, {
|
|
@@ -182,6 +183,14 @@ function allocateDeviceForSession(capability_1, deviceTimeOutMs_1, deviceQueryIn
|
|
|
182
183
|
// text or its stack tells them apart.
|
|
183
184
|
if (!leasedDevice)
|
|
184
185
|
throw new Error((0, leaseSessionCaps_1.leaseRefusalMessage)(leaseIdCap));
|
|
186
|
+
// As for an allocated phone below: the idle sweeper reads this session's
|
|
187
|
+
// own timeout from the row, and its idle time from now, not from when the
|
|
188
|
+
// lease was taken. Without it a leased session was ended after the
|
|
189
|
+
// server-wide newCommandTimeoutSec, whatever the session asked for.
|
|
190
|
+
yield (0, device_service_1.updatedAllocatedDevice)(leasedDevice, {
|
|
191
|
+
newCommandTimeout: sessionNewCommandTimeout(firstMatch, pluginArgs),
|
|
192
|
+
lastCmdExecutedAt: Date.now(),
|
|
193
|
+
});
|
|
185
194
|
return leasedDevice;
|
|
186
195
|
}
|
|
187
196
|
const filters = getDeviceFiltersFromCapability(firstMatch, pluginArgs);
|
|
@@ -195,29 +204,45 @@ function allocateDeviceForSession(capability_1, deviceTimeOutMs_1, deviceQueryIn
|
|
|
195
204
|
const intervalBetweenAttempts = firstMatch[customCapability.deviceQueryInterval] || deviceQueryIntervalMs;
|
|
196
205
|
let device = null;
|
|
197
206
|
const store = device_store_1.DeviceStoreFactory.getStore();
|
|
207
|
+
// Set once the wait has given up. waitUntil rejects on its timeout but
|
|
208
|
+
// doesn't stop an attempt already running, which could then claim a phone
|
|
209
|
+
// for a create that has failed: a pending claim nobody would ever use.
|
|
210
|
+
let abandoned = false;
|
|
198
211
|
try {
|
|
199
212
|
yield (0, async_wait_until_1.default)(() => __awaiter(this, void 0, void 0, function* () {
|
|
213
|
+
if (abandoned)
|
|
214
|
+
return false;
|
|
200
215
|
const maxSessions = getDeviceManager().getMaxSessionCount();
|
|
201
216
|
const busyDevicesCount = yield getBusyDevicesCount();
|
|
202
217
|
if (maxSessions !== undefined && busyDevicesCount === maxSessions) {
|
|
203
218
|
logger_1.default.info(`Waiting for session available, already at max session count of: ${maxSessions}`);
|
|
204
219
|
return false;
|
|
205
220
|
}
|
|
206
|
-
// Get matching devices
|
|
207
|
-
const
|
|
208
|
-
|
|
209
|
-
if (availableCandidate) {
|
|
221
|
+
// Get matching devices that are not reserved
|
|
222
|
+
const candidates = (yield (0, device_service_1.getDevices)(filters)).filter((d) => !(0, device_service_1.isDeviceReserved)(d));
|
|
223
|
+
if (candidates.length > 0) {
|
|
210
224
|
// Principal Intelligence: Multi-node consistent locking
|
|
211
|
-
//
|
|
212
|
-
//
|
|
213
|
-
|
|
214
|
-
|
|
225
|
+
// Atomically claim one of them: busy, with a pending claim for the
|
|
226
|
+
// session being created (deviceClaims.ts). Every candidate, not just
|
|
227
|
+
// the first: the lock skips a phone an SDK lease holds, and offering
|
|
228
|
+
// it alone stalled the create while other phones were free. Exact
|
|
229
|
+
// (udid, host) pairs: a udid repeats across hosts, and a reserved
|
|
230
|
+
// twin of a candidate must not be locked in its place.
|
|
231
|
+
const locked = yield store.findAndLockDevice(Object.assign(Object.assign({}, filters), { udid: [...new Set(candidates.map((d) => d.udid))] }), { claim: true, only: new Set(candidates.map((d) => `${d.udid}@${d.host}`)) });
|
|
232
|
+
if (locked && abandoned) {
|
|
233
|
+
yield (0, device_service_1.releasePendingClaim)(locked);
|
|
234
|
+
return false;
|
|
235
|
+
}
|
|
236
|
+
device = locked;
|
|
237
|
+
if (device !== null)
|
|
238
|
+
return true;
|
|
215
239
|
}
|
|
216
240
|
logger_1.default.info(`Waiting for free device. Filter: ${JSON.stringify(filters)}}`);
|
|
217
241
|
return false;
|
|
218
242
|
}), { timeout, intervalBetweenAttempts });
|
|
219
243
|
}
|
|
220
244
|
catch (err) {
|
|
245
|
+
abandoned = true;
|
|
221
246
|
// figure out whether the device is simply busy or non-existent
|
|
222
247
|
const filterCopy = Object.assign({}, filters);
|
|
223
248
|
delete filterCopy.busy;
|
|
@@ -258,11 +283,9 @@ function allocateDeviceForSession(capability_1, deviceTimeOutMs_1, deviceQueryIn
|
|
|
258
283
|
// Since findAndLockDevice already sets busy: true, we don't need blockDevice(device.udid, device.host);
|
|
259
284
|
logger_1.default.info(`📱 Locked device ${lockedDevice.udid} at host ${lockedDevice.host} for new session`);
|
|
260
285
|
yield updateCapabilityForDevice(capability, lockedDevice);
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
}
|
|
265
|
-
yield (0, device_service_1.updatedAllocatedDevice)(lockedDevice, { newCommandTimeout });
|
|
286
|
+
yield (0, device_service_1.updatedAllocatedDevice)(lockedDevice, {
|
|
287
|
+
newCommandTimeout: sessionNewCommandTimeout(firstMatch, pluginArgs),
|
|
288
|
+
});
|
|
266
289
|
return lockedDevice;
|
|
267
290
|
}
|
|
268
291
|
else {
|
|
@@ -271,6 +294,10 @@ function allocateDeviceForSession(capability_1, deviceTimeOutMs_1, deviceQueryIn
|
|
|
271
294
|
}
|
|
272
295
|
});
|
|
273
296
|
}
|
|
297
|
+
/** The session's own `appium:newCommandTimeout` (seconds), else the server's. */
|
|
298
|
+
function sessionNewCommandTimeout(firstMatch, pluginArgs) {
|
|
299
|
+
return firstMatch['appium:newCommandTimeout'] || pluginArgs.newCommandTimeoutSec;
|
|
300
|
+
}
|
|
274
301
|
/**
|
|
275
302
|
* The device a lease-bound session may use, or null when it may not: the lease
|
|
276
303
|
* is missing, inactive or expired, the session did not prove it holds it, or
|
|
@@ -604,6 +631,23 @@ function releaseBlockedDevices(newCommandTimeout) {
|
|
|
604
631
|
return __awaiter(this, void 0, void 0, function* () {
|
|
605
632
|
var _a;
|
|
606
633
|
const busyDevices = yield unblockCandidateDevices();
|
|
634
|
+
// A phone an SDK lease holds is the lease's to time out (its heartbeats and
|
|
635
|
+
// expiry, LeaseOrphanSweeper), not idle until a session runs on it. A lease
|
|
636
|
+
// sets lastCmdExecutedAt when taken, so without this the sweeper freed a
|
|
637
|
+
// leased phone the new-command timeout later, before a slow first session
|
|
638
|
+
// had even started on it.
|
|
639
|
+
let leases = new Map();
|
|
640
|
+
if (busyDevices.length) {
|
|
641
|
+
try {
|
|
642
|
+
leases = yield (0, activeLeases_1.activeLeasesByDevice)();
|
|
643
|
+
}
|
|
644
|
+
catch (err) {
|
|
645
|
+
// Not knowing which phones are leased, sweeping would free all of them:
|
|
646
|
+
// their lastCmdExecutedAt is when the lease was taken. Next tick.
|
|
647
|
+
logger_1.default.warn(`Idle sweep skipped: could not read active leases: ${err}`);
|
|
648
|
+
return;
|
|
649
|
+
}
|
|
650
|
+
}
|
|
607
651
|
logger_1.default.debug(`Found ${busyDevices.length} device candidates to be released`);
|
|
608
652
|
for (const device of busyDevices) {
|
|
609
653
|
// need to keep this to make typescript happy. good thing tho.
|
|
@@ -622,6 +666,12 @@ function releaseBlockedDevices(newCommandTimeout) {
|
|
|
622
666
|
}
|
|
623
667
|
continue;
|
|
624
668
|
}
|
|
669
|
+
// Leased, with no session or hold on it: not idle.
|
|
670
|
+
if (leases.has((0, activeLeases_1.leaseKey)(device.udid, device.host)) &&
|
|
671
|
+
!device.claimSessionId &&
|
|
672
|
+
!device.session_id) {
|
|
673
|
+
continue;
|
|
674
|
+
}
|
|
625
675
|
const timeoutSeconds = device.newCommandTimeout != undefined ? device.newCommandTimeout : newCommandTimeout;
|
|
626
676
|
const timeSinceLastCmdExecuted = (currentEpoch - device.lastCmdExecutedAt) / 1000;
|
|
627
677
|
if (timeSinceLastCmdExecuted > timeoutSeconds) {
|
|
@@ -12,7 +12,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
12
12
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
13
13
|
};
|
|
14
14
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
15
|
-
exports.OWNERSHIP_CHECKED_READS = exports.UNGUARDED_CONTROL_MUTATIONS = void 0;
|
|
15
|
+
exports.LEASE_CHECKED_MUTATIONS = exports.OWNERSHIP_CHECKED_READS = exports.UNGUARDED_CONTROL_MUTATIONS = void 0;
|
|
16
16
|
exports.deviceAccessGuard = deviceAccessGuard;
|
|
17
17
|
const typedi_1 = require("typedi");
|
|
18
18
|
const logger_1 = __importDefault(require("../logger"));
|
|
@@ -20,8 +20,10 @@ const manualLock_1 = require("../services/recording/manualLock");
|
|
|
20
20
|
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
|
+
const leaseHold_1 = require("../services/device-access/leaseHold");
|
|
23
24
|
const actor_1 = require("../services/device-access/actor");
|
|
24
25
|
const deviceClaims_1 = require("../data-service/deviceClaims");
|
|
26
|
+
const activeLeases_1 = require("../services/lease/activeLeases");
|
|
25
27
|
const STATE_CHANGING = new Set(['POST', 'PUT', 'DELETE', 'PATCH']);
|
|
26
28
|
/**
|
|
27
29
|
* Mutations under /control that this guard must NOT handle.
|
|
@@ -56,6 +58,14 @@ exports.UNGUARDED_CONTROL_MUTATIONS = [
|
|
|
56
58
|
* "reads are cheap" assumption the mosaic depends on.
|
|
57
59
|
*/
|
|
58
60
|
exports.OWNERSHIP_CHECKED_READS = ['clipboard', 'logs'];
|
|
61
|
+
/**
|
|
62
|
+
* Unguarded mutations that still take the lease rule: starting a preview
|
|
63
|
+
* takes a hold on the phone. stream/start's own conflict handling knows
|
|
64
|
+
* holds and sessions, not leases, and on a hub the call goes on to the
|
|
65
|
+
* node, which knows nothing of the hub's leases. So a stranger is refused a
|
|
66
|
+
* leased phone here, before either, and the lease holder passes on to it.
|
|
67
|
+
*/
|
|
68
|
+
exports.LEASE_CHECKED_MUTATIONS = ['stream/start'];
|
|
59
69
|
/**
|
|
60
70
|
* Refuse /control mutations against a device somebody else holds.
|
|
61
71
|
*
|
|
@@ -65,14 +75,16 @@ exports.OWNERSHIP_CHECKED_READS = ['clipboard', 'logs'];
|
|
|
65
75
|
* handlers and was remembered in none of them.
|
|
66
76
|
*/
|
|
67
77
|
function deviceAccessGuard(deps = {}) {
|
|
68
|
-
var _a, _b, _c;
|
|
78
|
+
var _a, _b, _c, _d, _e;
|
|
69
79
|
const findDevice = (_a = deps.findDevice) !== null && _a !== void 0 ? _a : controlDevice_1.findControlDeviceInStore;
|
|
70
80
|
const resolveSessionOwner = (_b = deps.resolveSessionOwner) !== null && _b !== void 0 ? _b : ((sid) => typedi_1.Container.get(SessionOwnerResolver_1.SessionOwnerResolver).ownerOf(sid));
|
|
71
81
|
const describeHolder = (_c = deps.describeHolder) !== null && _c !== void 0 ? _c : ((id) => typedi_1.Container.get(SessionOwnerResolver_1.SessionOwnerResolver).displayName(id));
|
|
82
|
+
const findActiveLease = (_d = deps.findActiveLease) !== null && _d !== void 0 ? _d : ((udid, host) => (0, activeLeases_1.activeLeaseOn)(udid, host));
|
|
83
|
+
const resolveLeaseHolder = (_e = deps.resolveLeaseHolder) !== null && _e !== void 0 ? _e : ((id) => typedi_1.Container.get(SessionOwnerResolver_1.SessionOwnerResolver).leaseHolderOf(id));
|
|
72
84
|
const unavailable = (res) => res.status(503).json((0, deviceAccessPolicy_1.ownershipUnavailableBody)());
|
|
73
85
|
return function (req, res, next) {
|
|
74
86
|
return __awaiter(this, void 0, void 0, function* () {
|
|
75
|
-
var _a, _b, _c;
|
|
87
|
+
var _a, _b, _c, _d, _e;
|
|
76
88
|
// Router-level middleware has no req.params; controlAction reads the path.
|
|
77
89
|
// It lowercases, as Express routes case-insensitively: `Stream/Start`
|
|
78
90
|
// still reaches stream/start's own richer handling, and `Clipboard`
|
|
@@ -81,8 +93,9 @@ function deviceAccessGuard(deps = {}) {
|
|
|
81
93
|
// Decide whether this request is in scope BEFORE touching the udid, so an
|
|
82
94
|
// ordinary read (screenshot, stream, logs, page source) short-circuits here
|
|
83
95
|
// exactly as it did when this guard was mutations-only.
|
|
96
|
+
const leaseOnly = req.method === 'POST' && exports.LEASE_CHECKED_MUTATIONS.includes(action);
|
|
84
97
|
const inScope = STATE_CHANGING.has(req.method)
|
|
85
|
-
? !exports.UNGUARDED_CONTROL_MUTATIONS.includes(action)
|
|
98
|
+
? !exports.UNGUARDED_CONTROL_MUTATIONS.includes(action) || leaseOnly
|
|
86
99
|
: req.method === 'GET' && exports.OWNERSHIP_CHECKED_READS.includes(action);
|
|
87
100
|
if (!action || !inScope)
|
|
88
101
|
return next();
|
|
@@ -116,6 +129,26 @@ function deviceAccessGuard(deps = {}) {
|
|
|
116
129
|
// resolved device to reason about ownership at all.
|
|
117
130
|
if (!device)
|
|
118
131
|
return next();
|
|
132
|
+
if (leaseOnly) {
|
|
133
|
+
if (actor.isAdmin)
|
|
134
|
+
return next();
|
|
135
|
+
let held;
|
|
136
|
+
try {
|
|
137
|
+
held = yield (0, leaseHold_1.leaseHoldFor)({ udid, host: device.host }, findActiveLease, resolveLeaseHolder);
|
|
138
|
+
}
|
|
139
|
+
catch (e) {
|
|
140
|
+
logger_1.default.error(`deviceAccessGuard: lease lookup failed for ${udid}: ${(_b = e === null || e === void 0 ? void 0 : e.message) !== null && _b !== void 0 ? _b : e}`);
|
|
141
|
+
return unavailable(res);
|
|
142
|
+
}
|
|
143
|
+
if (!held || (0, deviceAccessPolicy_1.isLeaseHolder)(held, actor.userId, actor.apiKeyId))
|
|
144
|
+
return next();
|
|
145
|
+
return deny(req, res, udid, actor.userId, {
|
|
146
|
+
allow: false,
|
|
147
|
+
code: 'device_held_by_another_user',
|
|
148
|
+
holderId: (_c = held.holderUserId) !== null && _c !== void 0 ? _c : '',
|
|
149
|
+
heldBy: 'lease',
|
|
150
|
+
});
|
|
151
|
+
}
|
|
119
152
|
let sessionOwnerUserId = null;
|
|
120
153
|
if (device.busy && device.session_id && !(0, manualLock_1.isManualLock)(device.session_id)) {
|
|
121
154
|
try {
|
|
@@ -124,7 +157,19 @@ function deviceAccessGuard(deps = {}) {
|
|
|
124
157
|
catch (e) {
|
|
125
158
|
// Same reasoning as the device lookup above: not knowing who owns a
|
|
126
159
|
// live session is exactly the case where we must not guess allow.
|
|
127
|
-
logger_1.default.error(`deviceAccessGuard: session owner lookup failed for ${device.session_id}: ${(
|
|
160
|
+
logger_1.default.error(`deviceAccessGuard: session owner lookup failed for ${device.session_id}: ${(_d = e === null || e === void 0 ? void 0 : e.message) !== null && _d !== void 0 ? _d : e}`);
|
|
161
|
+
return unavailable(res);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
let lease = null;
|
|
165
|
+
if (!actor.isAdmin) {
|
|
166
|
+
try {
|
|
167
|
+
lease = yield (0, leaseHold_1.leaseHoldFor)({ udid, host: device.host }, findActiveLease, resolveLeaseHolder);
|
|
168
|
+
}
|
|
169
|
+
catch (e) {
|
|
170
|
+
// A leased phone is held whatever `busy` says: not knowing whether a
|
|
171
|
+
// lease holds it is not knowing who owns it.
|
|
172
|
+
logger_1.default.error(`deviceAccessGuard: lease lookup failed for ${udid}: ${(_e = e === null || e === void 0 ? void 0 : e.message) !== null && _e !== void 0 ? _e : e}`);
|
|
128
173
|
return unavailable(res);
|
|
129
174
|
}
|
|
130
175
|
}
|
|
@@ -138,9 +183,16 @@ function deviceAccessGuard(deps = {}) {
|
|
|
138
183
|
actorUserId: actor.userId,
|
|
139
184
|
actorApiKeyId: actor.apiKeyId,
|
|
140
185
|
isAdmin: actor.isAdmin,
|
|
186
|
+
lease,
|
|
141
187
|
});
|
|
142
188
|
if (decision.allow)
|
|
143
189
|
return next();
|
|
190
|
+
return deny(req, res, udid, actor.userId, decision);
|
|
191
|
+
});
|
|
192
|
+
};
|
|
193
|
+
function deny(req, res, udid, actorUserId, decision) {
|
|
194
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
195
|
+
var _a;
|
|
144
196
|
// describeHolder is cosmetic — it only resolves a display name for the
|
|
145
197
|
// deny message. A failure here must still deny (never flip to allow) but
|
|
146
198
|
// must not hang the request either; fall back to no holder name.
|
|
@@ -150,12 +202,14 @@ function deviceAccessGuard(deps = {}) {
|
|
|
150
202
|
holderName = yield describeHolder(decision.holderId);
|
|
151
203
|
}
|
|
152
204
|
catch (e) {
|
|
153
|
-
logger_1.default.warn(`deviceAccessGuard: holder name lookup failed for ${decision.holderId}: ${(
|
|
205
|
+
logger_1.default.warn(`deviceAccessGuard: holder name lookup failed for ${decision.holderId}: ${(_a = e === null || e === void 0 ? void 0 : e.message) !== null && _a !== void 0 ? _a : e}`);
|
|
154
206
|
}
|
|
155
207
|
}
|
|
156
|
-
logger_1.default.warn(`Device access denied: ${
|
|
208
|
+
logger_1.default.warn(`Device access denied: ${actorUserId} -> ${req.method} ${req.originalUrl} ` +
|
|
157
209
|
`on ${udid} (${decision.code}, holder=${decision.holderId || 'unknown'})`);
|
|
158
|
-
return res
|
|
210
|
+
return res
|
|
211
|
+
.status(409)
|
|
212
|
+
.json((0, deviceAccessPolicy_1.denyBody)(decision.code, decision.holderId, holderName, decision.heldBy));
|
|
159
213
|
});
|
|
160
|
-
}
|
|
214
|
+
}
|
|
161
215
|
}
|
|
@@ -71,6 +71,7 @@ const LogcatStreamService_1 = require("../device-managers/android/LogcatStreamSe
|
|
|
71
71
|
const IOSLogStreamService_1 = require("../device-managers/ios/IOSLogStreamService");
|
|
72
72
|
const logSource_1 = require("./logcat/logSource");
|
|
73
73
|
const SessionOwnerResolver_1 = require("./device-access/SessionOwnerResolver");
|
|
74
|
+
const activeLeases_1 = require("./lease/activeLeases");
|
|
74
75
|
const ticketActorAccess_1 = require("./device-access/ticketActorAccess");
|
|
75
76
|
// enable resolveJsonModule in tsconfig must be true for this to work
|
|
76
77
|
const package_json_1 = __importDefault(require("../../package.json"));
|
|
@@ -169,6 +170,8 @@ let ServerManager = ServerManager_1 = class ServerManager {
|
|
|
169
170
|
authorize: (0, ticketActorAccess_1.makeTicketActorAuthorizer)({
|
|
170
171
|
findDevice: (udid) => device_store_1.DeviceStoreFactory.getStore().findDevice({ udid }),
|
|
171
172
|
resolveSessionOwner: (sessionId) => typedi_1.Container.get(SessionOwnerResolver_1.SessionOwnerResolver).ownerOf(sessionId),
|
|
173
|
+
findActiveLease: (udid, host) => (0, activeLeases_1.activeLeaseOn)(udid, host),
|
|
174
|
+
resolveLeaseHolder: (actorId) => typedi_1.Container.get(SessionOwnerResolver_1.SessionOwnerResolver).leaseHolderOf(actorId),
|
|
172
175
|
}),
|
|
173
176
|
// One WebSocket, two transports. The device's platform picks which —
|
|
174
177
|
// see resolveLogSource. The filter is only meaningful for ostrace,
|
|
@@ -222,9 +222,7 @@ let SessionLifecycleService = class SessionLifecycleService {
|
|
|
222
222
|
// Who asked, for the queue's team scoping (queueVisibility). On this row
|
|
223
223
|
// only, never in `caps`, and last so a client can't supply its own.
|
|
224
224
|
[queueVisibility_1.REQUESTER_KEY]: authResult.requester }));
|
|
225
|
-
const
|
|
226
|
-
this.logger.debug(`📱 Acquiring lock: ${lockName}`);
|
|
227
|
-
const device = yield commandsQueueGuard.acquire(lockName, () => __awaiter(this, void 0, void 0, function* () {
|
|
225
|
+
const allocate = () => __awaiter(this, void 0, void 0, function* () {
|
|
228
226
|
var _a;
|
|
229
227
|
try {
|
|
230
228
|
return yield (0, device_utils_1.allocateDeviceForSession)(caps, pluginArgs.deviceAvailabilityTimeoutMs, pluginArgs.deviceAvailabilityQueryIntervalMs, pluginArgs, sessionTeamIds, {
|
|
@@ -238,7 +236,21 @@ let SessionLifecycleService = class SessionLifecycleService {
|
|
|
238
236
|
yield (0, pending_sessions_service_1.removePendingSession)(pendingSessionId);
|
|
239
237
|
throw err;
|
|
240
238
|
}
|
|
241
|
-
})
|
|
239
|
+
});
|
|
240
|
+
// Creates for a platform queue on one lock, first come first served, and
|
|
241
|
+
// each has its full deviceAvailabilityTimeoutMs from the head of the
|
|
242
|
+
// queue. A lease-bound session allocates nothing (its phone is the
|
|
243
|
+
// lease's) and doesn't queue: behind the lock it waited for every create
|
|
244
|
+
// ahead of it, each of which may wait minutes for a busy phone.
|
|
245
|
+
let device;
|
|
246
|
+
if ((0, leaseSessionCaps_1.leaseIdOf)(caps)) {
|
|
247
|
+
device = yield allocate();
|
|
248
|
+
}
|
|
249
|
+
else {
|
|
250
|
+
const lockName = this.getLockName(caps);
|
|
251
|
+
this.logger.debug(`📱 Acquiring lock: ${lockName}`);
|
|
252
|
+
device = yield commandsQueueGuard.acquire(lockName, allocate);
|
|
253
|
+
}
|
|
242
254
|
yield (0, device_service_1.updateDeviceProgress)(device.udid, device.host, 'Allocating node resources...');
|
|
243
255
|
this.logger.debug(`device.host: ${device.host} and pluginArgs.bindHostOrIp: ${pluginArgs.bindHostOrIp}`);
|
|
244
256
|
const allocation = {
|
|
@@ -46,9 +46,34 @@ let SessionOwnerResolver = class SessionOwnerResolver {
|
|
|
46
46
|
this.db = db;
|
|
47
47
|
this.ownerCache = new Map();
|
|
48
48
|
this.nameCache = new Map();
|
|
49
|
+
this.leaseHolderCache = new Map();
|
|
49
50
|
/** The owners of this server's live sessions. A field, not a constructor parameter: TypeDI. */
|
|
50
51
|
this.liveOwners = () => typedi_1.Container.get(LiveSessionOwners_1.LiveSessionOwners);
|
|
51
52
|
}
|
|
53
|
+
/**
|
|
54
|
+
* The user behind a lease's `actorId`: the owner of the API key that
|
|
55
|
+
* created it, or, for a lease made with a bearer token, the user id itself.
|
|
56
|
+
* Positive results are cached, as for sessions: a lease's creator never
|
|
57
|
+
* changes.
|
|
58
|
+
*/
|
|
59
|
+
leaseHolderOf(actorId) {
|
|
60
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
61
|
+
var _a;
|
|
62
|
+
if (!actorId)
|
|
63
|
+
return null;
|
|
64
|
+
const cached = this.leaseHolderCache.get(actorId);
|
|
65
|
+
if (cached)
|
|
66
|
+
return cached;
|
|
67
|
+
const key = yield this.db.apiKey.findUnique({
|
|
68
|
+
where: { id: actorId },
|
|
69
|
+
select: { userId: true },
|
|
70
|
+
});
|
|
71
|
+
const holder = key ? ((_a = key.userId) !== null && _a !== void 0 ? _a : null) : actorId;
|
|
72
|
+
if (holder)
|
|
73
|
+
this.remember(this.leaseHolderCache, actorId, holder);
|
|
74
|
+
return holder;
|
|
75
|
+
});
|
|
76
|
+
}
|
|
52
77
|
ownerOf(sessionId) {
|
|
53
78
|
return __awaiter(this, void 0, void 0, function* () {
|
|
54
79
|
var _a, _b;
|
|
@@ -165,6 +190,7 @@ let SessionOwnerResolver = class SessionOwnerResolver {
|
|
|
165
190
|
clear() {
|
|
166
191
|
this.ownerCache.clear();
|
|
167
192
|
this.nameCache.clear();
|
|
193
|
+
this.leaseHolderCache.clear();
|
|
168
194
|
}
|
|
169
195
|
remember(cache, key, value) {
|
|
170
196
|
if (cache.size >= MAX_CACHE_ENTRIES)
|