@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.
Files changed (112) hide show
  1. package/LICENSE +15 -0
  2. package/README.md +223 -488
  3. package/lib/package.json +1 -1
  4. package/lib/public/assets/{AnnotationOverlay-CzbFYtPI.js → AnnotationOverlay-BXruClDk.js} +1 -1
  5. package/lib/public/assets/{ApiKeyGate-CA5KFHHJ.js → ApiKeyGate-qTS4LPLC.js} +1 -1
  6. package/lib/public/assets/{BugReportButton-D-DSGg8C.js → BugReportButton-BoGhlI7F.js} +1 -1
  7. package/lib/public/assets/{DeviceMosaicView-paDEFU4B.js → DeviceMosaicView-8J0oYqMM.js} +1 -1
  8. package/lib/public/assets/{EmptyState-BE_5f8Vw.js → EmptyState-Cr17PdDv.js} +1 -1
  9. package/lib/public/assets/{FieldGroup-DqMf7Rf3.js → FieldGroup-Bvn08svv.js} +1 -1
  10. package/lib/public/assets/{FilterMenu-BQY75Su3.js → FilterMenu-DDSO8aDf.js} +1 -1
  11. package/lib/public/assets/{Menu-BiW3os9u.js → Menu-B-Qn2X3T.js} +1 -1
  12. package/lib/public/assets/{Modal-DfawTxih.js → Modal-Cg9V5JYU.js} +1 -1
  13. package/lib/public/assets/{RecordingPage-BvemyRiy.js → RecordingPage-BTUs9VTW.js} +1 -1
  14. package/lib/public/assets/{RecordingsPage-CXfw7wpd.js → RecordingsPage-DYcfxITK.js} +2 -2
  15. package/lib/public/assets/{SegmentedControl-B7m9kpom.js → SegmentedControl-Zy7suhT5.js} +1 -1
  16. package/lib/public/assets/{SettingCard-Cw6lDD0l.js → SettingCard-CWYmycoe.js} +1 -1
  17. package/lib/public/assets/{Table-DlEuFgv-.js → Table-io9fwa98.js} +1 -1
  18. package/lib/public/assets/{activity-CnLvNwae.js → activity-Bdf_uIWl.js} +1 -1
  19. package/lib/public/assets/ai-settings-Bce5SJob.js +21 -0
  20. package/lib/public/assets/{api-keys-DphSVzBr.js → api-keys-CPqtVpm3.js} +1 -1
  21. package/lib/public/assets/{apps-vUfu15lX.js → apps-BgZan0H9.js} +2 -2
  22. package/lib/public/assets/{arrow-left-CLQWAiCY.js → arrow-left-B27ckB31.js} +1 -1
  23. package/lib/public/assets/{arrow-right-K6A5e_Ee.js → arrow-right-DOF8ITCz.js} +1 -1
  24. package/lib/public/assets/{arrow-up-right-DUA_lq6f.js → arrow-up-right-eFG5KTFS.js} +1 -1
  25. package/lib/public/assets/{auth-shell-BYRHcZd6.js → auth-shell-DlC3_27j.js} +2 -2
  26. package/lib/public/assets/{builds-page-C0fTvIG8.js → builds-page-BuXpPfKo.js} +1 -1
  27. package/lib/public/assets/{button-BxnHCCsX.js → button-fIW8owMI.js} +1 -1
  28. package/lib/public/assets/{calendar-CqVsn28A.js → calendar-WNjmrF-a.js} +1 -1
  29. package/lib/public/assets/{check-R6V3bot0.js → check-BdTh1Sq7.js} +1 -1
  30. package/lib/public/assets/{chevron-right-BA2Fntu-.js → chevron-right-B4BKlsqk.js} +1 -1
  31. package/lib/public/assets/{circle-check-D4EzyGzs.js → circle-check-zJU80_EF.js} +1 -1
  32. package/lib/public/assets/{circle-x-Cf6AF2B2.js → circle-x-5Uwquo0h.js} +1 -1
  33. package/lib/public/assets/{clock-EZfet0sS.js → clock-BtUprDsZ.js} +1 -1
  34. package/lib/public/assets/{copy-Bc6P8v9V.js → copy-DGSbZI3u.js} +1 -1
  35. package/lib/public/assets/device-explorer-C-KnhMHI.js +279 -0
  36. package/lib/public/assets/{download-Bd8OGLzi.js → download-WG6l7BgD.js} +1 -1
  37. package/lib/public/assets/{forgot-password-CoE7Ugtx.js → forgot-password-DnudqFFo.js} +1 -1
  38. package/lib/public/assets/{index-BTYMAqNW.css → index-Bxoxt9sV.css} +1 -1
  39. package/lib/public/assets/{index-CHOr4JCs.js → index-DI1fGIyj.js} +43 -43
  40. package/lib/public/assets/{index-BiL0Enl_.js → index-HDQVe1gi.js} +1 -1
  41. package/lib/public/assets/{input-CJ8z3IO5.js → input-BjDRKCea.js} +1 -1
  42. package/lib/public/assets/{line-chart-CPK0ObjV.js → line-chart-C0zdgd0v.js} +1 -1
  43. package/lib/public/assets/{list-checks-mXGHUVbB.js → list-checks-BdW5CjM8.js} +1 -1
  44. package/lib/public/assets/{lock-BSXM1xvq.js → lock-Dy3L4I8E.js} +1 -1
  45. package/lib/public/assets/{login-Ct2iW066.js → login-BHbY9CLG.js} +1 -1
  46. package/lib/public/assets/maintenance-settings-zDVlOMVK.js +6 -0
  47. package/lib/public/assets/{monitor-D8WQK8md.js → monitor-8xjkeHHi.js} +1 -1
  48. package/lib/public/assets/{mouse-pointer-2-Cw2tWzDX.js → mouse-pointer-2-CXdKMXQ8.js} +1 -1
  49. package/lib/public/assets/{network-Ba_uFxWo.js → network-Cj8JOKON.js} +1 -1
  50. package/lib/public/assets/{overview-CxrZ7_6k.js → overview-CTg7F0im.js} +1 -1
  51. package/lib/public/assets/{page-header-Cm1XL49f.js → page-header--aTyJXq6.js} +1 -1
  52. package/lib/public/assets/{play-C0CuKLXj.js → play-B-FodgHV.js} +1 -1
  53. package/lib/public/assets/{plus-CIIvR_6Z.js → plus-CsIk10Cq.js} +1 -1
  54. package/lib/public/assets/{profile-page-BZ_e-Bgw.js → profile-page-DkFKkMfl.js} +1 -1
  55. package/lib/public/assets/{recording-group-store-BWeeNs2_.js → recording-group-store-WVM3hoXn.js} +1 -1
  56. package/lib/public/assets/{reset-password-lfNz8Rm1.js → reset-password-I2-zqLlI.js} +1 -1
  57. package/lib/public/assets/{runbook-page-Cnec3VSx.js → runbook-page-DJTVCJX7.js} +1 -1
  58. package/lib/public/assets/{select-CZuCnold.js → select-DvCSv25z.js} +1 -1
  59. package/lib/public/assets/{selector-detail-redirect-BG3pxFLw.js → selector-detail-redirect-BLVfMJs_.js} +1 -1
  60. package/lib/public/assets/{selector-health-page-DsyL58tb.js → selector-health-page-oqadRokn.js} +2 -2
  61. package/lib/public/assets/session-detail-page-CGn55Pvl.js +4 -0
  62. package/lib/public/assets/settings-MsVU2ukE.js +6 -0
  63. package/lib/public/assets/{stat-tile-DFhv8JoI.js → stat-tile-DV9Ix-4O.js} +1 -1
  64. package/lib/public/assets/{tablet-68J5f6zh.js → tablet-6b_SEN2X.js} +1 -1
  65. package/lib/public/assets/{teams-eHSyEpbD.js → teams-BjZyfuHh.js} +1 -1
  66. package/lib/public/assets/{trash-2-CcSsv4fS.js → trash-2-CTZpPSdd.js} +1 -1
  67. package/lib/public/assets/{upload-BYf26K71.js → upload-P3XzwQUb.js} +1 -1
  68. package/lib/public/assets/{use-builds-data-CHW-MhaP.js → use-builds-data-1cOCMf-z.js} +1 -1
  69. package/lib/public/assets/{use-password-reset-mode-DozShXkk.js → use-password-reset-mode-DgF9w088.js} +1 -1
  70. package/lib/public/assets/{users-CcSQRoK9.js → users-CiNTKUiz.js} +1 -1
  71. package/lib/public/assets/{users-BpYnpOl2.js → users-DMF9Mk5H.js} +1 -1
  72. package/lib/public/assets/{video-off-dFKgps73.js → video-off-CHp1KU_T.js} +1 -1
  73. package/lib/public/assets/webhook-settings-DiS8geIm.js +1 -0
  74. package/lib/public/assets/{zap-COC2tZaH.js → zap-DRz0tDq5.js} +1 -1
  75. package/lib/public/index.html +2 -2
  76. package/lib/schema.json +0 -25
  77. package/lib/src/app/routers/control.js +19 -4
  78. package/lib/src/app/routers/streamStartConflict.js +3 -0
  79. package/lib/src/data-service/device-service.js +40 -0
  80. package/lib/src/data-service/device-store.js +4 -1
  81. package/lib/src/data-service/prisma-store.js +2 -1
  82. package/lib/src/device-managers/ios/WDAClient.js +128 -16
  83. package/lib/src/device-utils.js +63 -13
  84. package/lib/src/middleware/deviceAccessGuard.js +63 -9
  85. package/lib/src/services/ServerManager.js +3 -0
  86. package/lib/src/services/SessionLifecycleService.js +16 -4
  87. package/lib/src/services/device-access/SessionOwnerResolver.js +26 -0
  88. package/lib/src/services/device-access/deviceAccessPolicy.js +38 -6
  89. package/lib/src/services/device-access/leaseHold.js +26 -0
  90. package/lib/src/services/device-access/ticketActorAccess.js +6 -0
  91. package/lib/src/services/lease/activeLeases.js +37 -0
  92. package/lib/src/services/recording/busy-precheck.js +24 -3
  93. package/lib/test/unit/device-access-lease.spec.js +270 -0
  94. package/lib/test/unit/lease/allocate-lease-branch.spec.js +22 -0
  95. package/lib/test/unit/lease/session-lease-proof.spec.js +59 -0
  96. package/lib/test/unit/lease-held-phone.spec.js +196 -0
  97. package/lib/test/unit/partial-config-file.spec.js +100 -0
  98. package/lib/test/unit/schema-required-args.spec.js +21 -16
  99. package/lib/test/unit/wda-client-session.spec.js +128 -0
  100. package/lib/test/unit/wda-clipboard-write.spec.js +135 -0
  101. package/lib/tsconfig.tsbuildinfo +1 -1
  102. package/package.json +1 -1
  103. package/schema.json +0 -25
  104. package/scripts/dev/readme-screenshots.js +231 -0
  105. package/scripts/generate-types-from-schema.js +6 -0
  106. package/scripts/lib/always-present-plugin-args.js +39 -0
  107. package/lib/public/assets/ai-settings-D7Te8o-a.js +0 -21
  108. package/lib/public/assets/device-explorer-BFXpRq6d.js +0 -279
  109. package/lib/public/assets/maintenance-settings-DaOs00P6.js +0 -6
  110. package/lib/public/assets/session-detail-page-ZZyGM413.js +0 -4
  111. package/lib/public/assets/settings-DGZ7B_AR.js +0 -6
  112. 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
- if (!(cached === null || cached === void 0 ? void 0 : cached.sessionId)) {
95
- const sid = typedi_1.Container.get(IOSStreamService_1.default).getWDASessionId(udid);
96
- if (sid) {
97
- cached = { host: '127.0.0.1', pathPrefix: `/session/${sid}`, sessionId: sid };
98
- this.wdaConnectionCache.set(cacheKey, cached);
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
- (['/status', '/health', '/wda/healthcheck'].includes(endpoint));
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
- this.log.warn(`[WDA] Session ${cached === null || cached === void 0 ? void 0 : cached.sessionId} appears dead. Resetting and retrying...`);
159
- this.wdaConnectionCache.delete(cacheKey);
160
- typedi_1.Container.get(IOSStreamService_1.default).setWDASessionId(udid, '');
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
- if (e === null || e === void 0 ? void 0 : e.response)
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
- yield this.sendWDACommand(udid, 'post', '/wda/setPasteboard', {
598
- content: Buffer.from(content).toString('base64'),
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) {
@@ -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 and find one that is not reserved
207
- const matchingDevices = yield (0, device_service_1.getDevices)(filters);
208
- const availableCandidate = matchingDevices.find((d) => !(0, device_service_1.isDeviceReserved)(d));
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
- // Attempt to atomically claim this specific device: busy, with a
212
- // pending claim for the session being created (deviceClaims.ts).
213
- device = yield store.findAndLockDevice(Object.assign(Object.assign({}, filters), { udid: [availableCandidate.udid] }), { claim: true });
214
- return device !== null;
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
- let newCommandTimeout = firstMatch['appium:newCommandTimeout'];
262
- if (!newCommandTimeout) {
263
- newCommandTimeout = pluginArgs.newCommandTimeoutSec;
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}: ${(_b = e === null || e === void 0 ? void 0 : e.message) !== null && _b !== void 0 ? _b : e}`);
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}: ${(_c = e === null || e === void 0 ? void 0 : e.message) !== null && _c !== void 0 ? _c : e}`);
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: ${actor.userId} -> ${req.method} ${req.originalUrl} ` +
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.status(409).json((0, deviceAccessPolicy_1.denyBody)(decision.code, decision.holderId, holderName));
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 lockName = this.getLockName(caps);
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)