@xenon-device-management/xenon 2.13.2 → 2.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (209) hide show
  1. package/README.md +11 -5
  2. package/lib/package.json +1 -1
  3. package/lib/public/assets/{AnnotationOverlay-BXruClDk.js → AnnotationOverlay-B_VNtKBH.js} +1 -1
  4. package/lib/public/assets/{ApiKeyGate-qTS4LPLC.js → ApiKeyGate-BwuaMjkX.js} +1 -1
  5. package/lib/public/assets/{BugReportButton-BoGhlI7F.js → BugReportButton-BAt0_pzq.js} +1 -1
  6. package/lib/public/assets/DeviceMosaicView-BYaQHhTf.js +2 -0
  7. package/lib/public/assets/{EmptyState-Cr17PdDv.js → EmptyState-BSjhk_IV.js} +1 -1
  8. package/lib/public/assets/{FieldGroup-Bvn08svv.js → FieldGroup-Cb5QZsE1.js} +1 -1
  9. package/lib/public/assets/{FilterMenu-DDSO8aDf.js → FilterMenu-BAaSlWbW.js} +1 -1
  10. package/lib/public/assets/{Menu-B-Qn2X3T.js → Menu-JGGYrSQQ.js} +1 -1
  11. package/lib/public/assets/{Modal-Cg9V5JYU.js → Modal-CFCp3n9d.js} +1 -1
  12. package/lib/public/assets/{RecordingPage-BTUs9VTW.js → RecordingPage-CbJj_EBT.js} +1 -1
  13. package/lib/public/assets/{RecordingsPage-DYcfxITK.js → RecordingsPage-BJeYnygR.js} +2 -2
  14. package/lib/public/assets/{SegmentedControl-Zy7suhT5.js → SegmentedControl-Bg42Atjy.js} +1 -1
  15. package/lib/public/assets/{SettingCard-CWYmycoe.js → SettingCard-Njz1pUIJ.js} +1 -1
  16. package/lib/public/assets/{Table-io9fwa98.js → Table-BcTm5xV2.js} +1 -1
  17. package/lib/public/assets/{activity-Bdf_uIWl.js → activity-DOVdsfMQ.js} +1 -1
  18. package/lib/public/assets/{ai-settings-Bce5SJob.js → ai-settings-CsuJroRf.js} +2 -2
  19. package/lib/public/assets/{api-keys-CPqtVpm3.js → api-keys-BKnNSTw9.js} +1 -1
  20. package/lib/public/assets/{apps-BgZan0H9.js → apps-D0SC6uw6.js} +2 -2
  21. package/lib/public/assets/{arrow-left-B27ckB31.js → arrow-left-MMbTmgoE.js} +1 -1
  22. package/lib/public/assets/{arrow-right-DOF8ITCz.js → arrow-right-BMfnGmxW.js} +1 -1
  23. package/lib/public/assets/{arrow-up-right-eFG5KTFS.js → arrow-up-right-DuAftMWB.js} +1 -1
  24. package/lib/public/assets/{auth-shell-DlC3_27j.js → auth-shell-nZC2JNDO.js} +2 -2
  25. package/lib/public/assets/{builds-page-BuXpPfKo.js → builds-page-hDCgTwkS.js} +1 -1
  26. package/lib/public/assets/{button-fIW8owMI.js → button-Dda4irn_.js} +1 -1
  27. package/lib/public/assets/{calendar-WNjmrF-a.js → calendar-BMr0Q62v.js} +1 -1
  28. package/lib/public/assets/{check-BdTh1Sq7.js → check-CZ9iw9-e.js} +1 -1
  29. package/lib/public/assets/{chevron-right-B4BKlsqk.js → chevron-right-BRYJJVC9.js} +1 -1
  30. package/lib/public/assets/{circle-check-zJU80_EF.js → circle-check-CqAJjQ5O.js} +1 -1
  31. package/lib/public/assets/{circle-x-5Uwquo0h.js → circle-x-DlDJtnVz.js} +1 -1
  32. package/lib/public/assets/{clock-BtUprDsZ.js → clock-CpuDKysU.js} +1 -1
  33. package/lib/public/assets/{copy-DGSbZI3u.js → copy-CAQydNFe.js} +1 -1
  34. package/lib/public/assets/device-explorer-CdO6_nrn.js +282 -0
  35. package/lib/public/assets/{download-WG6l7BgD.js → download-C9XG6lLv.js} +1 -1
  36. package/lib/public/assets/{forgot-password-DnudqFFo.js → forgot-password-CGnkRgE9.js} +1 -1
  37. package/lib/public/assets/h264Stream-VQBphYhg.js +26 -0
  38. package/lib/public/assets/{index-DI1fGIyj.js → index-BbKEXUFH.js} +5 -5
  39. package/lib/public/assets/{index-HDQVe1gi.js → index-DJGc1M2f.js} +1 -1
  40. package/lib/public/assets/{input-BjDRKCea.js → input-Cm0tmSoc.js} +1 -1
  41. package/lib/public/assets/{line-chart-C0zdgd0v.js → line-chart-D2DyJCml.js} +1 -1
  42. package/lib/public/assets/{list-checks-BdW5CjM8.js → list-checks-UWf1GY99.js} +1 -1
  43. package/lib/public/assets/{lock-Dy3L4I8E.js → lock-CCJv9m_I.js} +1 -1
  44. package/lib/public/assets/{login-BHbY9CLG.js → login-f8vOVW-U.js} +1 -1
  45. package/lib/public/assets/maintenance-settings-C1PrkuAH.js +6 -0
  46. package/lib/public/assets/{monitor-8xjkeHHi.js → monitor-B2jDgwm7.js} +1 -1
  47. package/lib/public/assets/{mouse-pointer-2-CXdKMXQ8.js → mouse-pointer-2-DLI1RtzU.js} +1 -1
  48. package/lib/public/assets/{network-Cj8JOKON.js → network-ebT86qoi.js} +1 -1
  49. package/lib/public/assets/{overview-CTg7F0im.js → overview-C0aykVrd.js} +1 -1
  50. package/lib/public/assets/{page-header--aTyJXq6.js → page-header-BjzgWz-T.js} +1 -1
  51. package/lib/public/assets/{play-B-FodgHV.js → play-DLG9t8FU.js} +1 -1
  52. package/lib/public/assets/{plus-CsIk10Cq.js → plus-nBFSMu_X.js} +1 -1
  53. package/lib/public/assets/{profile-page-DkFKkMfl.js → profile-page-CSl0UYlv.js} +1 -1
  54. package/lib/public/assets/{reset-password-I2-zqLlI.js → reset-password-LeOuHqmB.js} +1 -1
  55. package/lib/public/assets/{runbook-page-DJTVCJX7.js → runbook-page-CMMeQml9.js} +1 -1
  56. package/lib/public/assets/{select-DvCSv25z.js → select-88jHcl-Z.js} +1 -1
  57. package/lib/public/assets/{selector-detail-redirect-BLVfMJs_.js → selector-detail-redirect-BbXKJjZM.js} +1 -1
  58. package/lib/public/assets/{selector-health-page-oqadRokn.js → selector-health-page-B4rA1Xkv.js} +2 -2
  59. package/lib/public/assets/session-detail-page-COL_RZxw.js +4 -0
  60. package/lib/public/assets/settings-GF6ZMSQ6.js +6 -0
  61. package/lib/public/assets/{stat-tile-DV9Ix-4O.js → stat-tile-BV98e_Sv.js} +1 -1
  62. package/lib/public/assets/{tablet-6b_SEN2X.js → tablet-BiAsnFDj.js} +1 -1
  63. package/lib/public/assets/{teams-BjZyfuHh.js → teams-B4-jrr-1.js} +1 -1
  64. package/lib/public/assets/{trash-2-CTZpPSdd.js → trash-2-C5RSuX0e.js} +1 -1
  65. package/lib/public/assets/{upload-P3XzwQUb.js → upload-BG7_rMXK.js} +1 -1
  66. package/lib/public/assets/{use-builds-data-1cOCMf-z.js → use-builds-data-J-riQYyC.js} +1 -1
  67. package/lib/public/assets/{use-password-reset-mode-DgF9w088.js → use-password-reset-mode-C8-eH-UG.js} +1 -1
  68. package/lib/public/assets/{users-DMF9Mk5H.js → users-CFba5TSy.js} +1 -1
  69. package/lib/public/assets/{users-CiNTKUiz.js → users-Cp0beJm3.js} +1 -1
  70. package/lib/public/assets/{video-off-CHp1KU_T.js → video-off-DDsEISjV.js} +1 -1
  71. package/lib/public/assets/webhook-settings-BGu32vSB.js +1 -0
  72. package/lib/public/assets/{zap-DRz0tDq5.js → zap-l4VaXzq8.js} +1 -1
  73. package/lib/public/index.html +1 -1
  74. package/lib/schema.json +19 -21
  75. package/lib/src/XenonCapabilityManager.js +22 -5
  76. package/lib/src/app/openapi/control.yaml +71 -20
  77. package/lib/src/app/openapi/grid.yaml +23 -5
  78. package/lib/src/app/openapi/platform.yaml +136 -28
  79. package/lib/src/app/routers/control.js +12 -2
  80. package/lib/src/app/routers/dashboard.js +29 -1
  81. package/lib/src/app/routers/grid.js +32 -10
  82. package/lib/src/app/routers/streamType.js +7 -2
  83. package/lib/src/app/routers/webhook.js +8 -4
  84. package/lib/src/config.js +50 -5
  85. package/lib/src/dashboard/commands.js +185 -141
  86. package/lib/src/dashboard/event-manager.js +39 -6
  87. package/lib/src/data-service/appleFamily.js +43 -0
  88. package/lib/src/data-service/db.js +11 -0
  89. package/lib/src/data-service/device-service.js +6 -1
  90. package/lib/src/data-service/device-store.js +62 -2
  91. package/lib/src/data-service/deviceClaims.js +37 -0
  92. package/lib/src/data-service/deviceFieldOwners.js +25 -11
  93. package/lib/src/data-service/deviceSettings.js +79 -0
  94. package/lib/src/data-service/prisma-store.js +150 -8
  95. package/lib/src/data-service/web-config-service.js +43 -92
  96. package/lib/src/device-managers/AndroidDeviceManager.js +9 -31
  97. package/lib/src/device-managers/ios/WDAClient.js +44 -43
  98. package/lib/src/device-managers/shellCommands.js +96 -0
  99. package/lib/src/device-utils.js +96 -40
  100. package/lib/src/generated/client/edge.js +17 -3
  101. package/lib/src/generated/client/index-browser.js +14 -0
  102. package/lib/src/generated/client/index.d.ts +2054 -379
  103. package/lib/src/generated/client/index.js +17 -3
  104. package/lib/src/generated/client/package.json +1 -1
  105. package/lib/src/generated/client/schema.prisma +35 -7
  106. package/lib/src/generated/client/wasm.js +14 -0
  107. package/lib/src/interceptors/CommandInterceptor.js +29 -8
  108. package/lib/src/interceptors/xenonScripts.js +55 -0
  109. package/lib/src/interfaces/IPluginArgs.js +1 -2
  110. package/lib/src/plugin.js +15 -1
  111. package/lib/src/prisma.js +1 -1
  112. package/lib/src/scripts/database-check.js +124 -0
  113. package/lib/src/services/AIService.js +99 -0
  114. package/lib/src/services/CleanupService.js +6 -1
  115. package/lib/src/services/InterceptorService.js +70 -13
  116. package/lib/src/services/NetworkConditioningService.js +85 -88
  117. package/lib/src/services/NotificationService.js +36 -29
  118. package/lib/src/services/OrphanSweeper.js +5 -0
  119. package/lib/src/services/PortAllocator.js +5 -0
  120. package/lib/src/services/ServerManager.js +94 -8
  121. package/lib/src/services/SessionLifecycleService.js +47 -49
  122. package/lib/src/services/ShutdownCoordinator.js +33 -0
  123. package/lib/src/services/TeamService.js +20 -3
  124. package/lib/src/services/healing/HealingOrchestrator.js +32 -1
  125. package/lib/src/services/interceptor/AndroidProxyAdapter.js +18 -0
  126. package/lib/src/services/interceptor/interceptorOptions.js +44 -0
  127. package/lib/src/services/network/AndroidRadios.js +71 -0
  128. package/lib/src/services/network/PhoneNetworkLedger.js +184 -0
  129. package/lib/src/services/network/PhoneNetworkRestore.js +330 -0
  130. package/lib/src/services/network/adbForPhone.js +60 -0
  131. package/lib/src/services/network/phoneNetworkLock.js +20 -0
  132. package/lib/src/services/network/xenonProxy.js +107 -0
  133. package/lib/src/services/omni-vision/OmniVisionService.js +53 -43
  134. package/lib/src/services/omni-vision/VisionAssertionService.js +28 -25
  135. package/lib/src/services/omni-vision/ocrTextMatch.js +105 -0
  136. package/lib/src/services/omni-vision/screenScale.js +68 -0
  137. package/lib/src/services/recording/concurrency-gate.js +12 -3
  138. package/lib/src/services/settings/SelfHealingSwitch.js +70 -0
  139. package/lib/src/services/settings/labSettings.js +149 -0
  140. package/lib/src/services/webhookEvents.js +173 -0
  141. package/lib/test/helpers/fake-adb.js +58 -0
  142. package/lib/test/helpers/fake-device-table.js +63 -24
  143. package/lib/test/helpers/scratch-database.js +1 -0
  144. package/lib/test/unit/TeamService.test.js +3 -0
  145. package/lib/test/unit/XenonCapabilityManager.spec.js +4 -2
  146. package/lib/test/unit/apple-family.spec.js +36 -0
  147. package/lib/test/unit/cleanup-settings-db.spec.js +103 -0
  148. package/lib/test/unit/cleanup-settings.spec.js +244 -0
  149. package/lib/test/unit/dashboard-api-paths.spec.js +101 -0
  150. package/lib/test/unit/dashboard-commands.spec.js +249 -0
  151. package/lib/test/unit/dashboard-events-team-scope.spec.js +9 -6
  152. package/lib/test/unit/database-check.spec.js +108 -0
  153. package/lib/test/unit/default-plugin-args.spec.js +33 -0
  154. package/lib/test/unit/device-offline-webhook.spec.js +81 -0
  155. package/lib/test/unit/device-setting-columns.spec.js +66 -0
  156. package/lib/test/unit/device-settings-loki.spec.js +87 -0
  157. package/lib/test/unit/device-settings-survive.spec.js +555 -0
  158. package/lib/test/unit/device-sync-lost-update.spec.js +5 -0
  159. package/lib/test/unit/emulator-boot.spec.js +91 -0
  160. package/lib/test/unit/hub-node-gateway-appium-server.spec.js +48 -0
  161. package/lib/test/unit/hub-node-same-host.spec.js +5 -0
  162. package/lib/test/unit/iphone-ipad-only.spec.js +132 -0
  163. package/lib/test/unit/json-logging-option.spec.js +74 -0
  164. package/lib/test/unit/lab-settings.spec.js +218 -0
  165. package/lib/test/unit/max-sessions.spec.js +240 -0
  166. package/lib/test/unit/ocr-text-match.spec.js +44 -0
  167. package/lib/test/unit/omni-vision-analyze-screen.spec.js +72 -0
  168. package/lib/test/unit/omni-vision-ios-points.spec.js +161 -0
  169. package/lib/test/unit/omni-vision-tap-text.spec.js +129 -0
  170. package/lib/test/unit/omni-vision.spec.js +1 -1
  171. package/lib/test/unit/partial-config-file.spec.js +14 -0
  172. package/lib/test/unit/phone-network/boot-clean-up.spec.js +64 -0
  173. package/lib/test/unit/phone-network/interceptor-options.spec.js +77 -0
  174. package/lib/test/unit/phone-network/interceptor-proxy-restore.spec.js +145 -0
  175. package/lib/test/unit/phone-network/network-conditioning.spec.js +145 -0
  176. package/lib/test/unit/phone-network/phone-network-restore.spec.js +246 -0
  177. package/lib/test/unit/phone-network/session-end-paths.spec.js +182 -0
  178. package/lib/test/unit/phone-network/session-start.spec.js +110 -0
  179. package/lib/test/unit/phone-network/xenon-proxy.spec.js +69 -0
  180. package/lib/test/unit/plugin.spec.js +3 -3
  181. package/lib/test/unit/recording-options.spec.js +181 -0
  182. package/lib/test/unit/self-healing-boot.spec.js +75 -0
  183. package/lib/test/unit/self-healing-setting.spec.js +272 -0
  184. package/lib/test/unit/session-failed-webhook.spec.js +170 -0
  185. package/lib/test/unit/shell-commands.spec.js +133 -0
  186. package/lib/test/unit/socketServer.scopedEmit.spec.js +2 -0
  187. package/lib/test/unit/stream-start-pipelines.spec.js +210 -0
  188. package/lib/test/unit/stream-status-type.spec.js +4 -0
  189. package/lib/test/unit/vision-assertion.spec.js +118 -0
  190. package/lib/test/unit/wda-client-shell.spec.js +133 -0
  191. package/lib/test/unit/web-config-service.spec.js +79 -0
  192. package/lib/test/unit/webhook-delivery.spec.js +24 -0
  193. package/lib/test/unit/webhook-payloads.spec.js +273 -0
  194. package/lib/test/unit/xenon-execute-scripts.spec.js +138 -0
  195. package/lib/tsconfig.tsbuildinfo +1 -1
  196. package/lib/web/src/components/webhook-settings/webhookEventVariables.json +30 -0
  197. package/package.json +1 -1
  198. package/prisma/migrations/20261004140000_keep_device_settings/migration.sql +19 -0
  199. package/prisma/schema.prisma +35 -7
  200. package/schema.json +19 -21
  201. package/scripts/generate-types-from-schema.js +1 -2
  202. package/scripts/lib/always-present-plugin-args.js +4 -1
  203. package/lib/public/assets/DeviceMosaicView-8J0oYqMM.js +0 -2
  204. package/lib/public/assets/device-explorer-C-KnhMHI.js +0 -279
  205. package/lib/public/assets/maintenance-settings-zDVlOMVK.js +0 -6
  206. package/lib/public/assets/recording-group-store-WVM3hoXn.js +0 -26
  207. package/lib/public/assets/session-detail-page-CGn55Pvl.js +0 -4
  208. package/lib/public/assets/settings-MsVU2ukE.js +0 -6
  209. package/lib/public/assets/webhook-settings-DiS8geIm.js +0 -1
@@ -0,0 +1,43 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.appleFamilyOf = appleFamilyOf;
4
+ /** Whichever of the two words a name carries; iPad first, as "iPad" holds no "iPhone". */
5
+ function familyInName(name) {
6
+ const lower = (name !== null && name !== void 0 ? name : '').toLowerCase();
7
+ if (lower.includes('ipad'))
8
+ return 'ipad';
9
+ if (lower.includes('iphone'))
10
+ return 'iphone';
11
+ return undefined;
12
+ }
13
+ /**
14
+ * Whether a phone is an iPhone or an iPad, or neither (an Android device, an
15
+ * Apple TV). Read from what the phone reports, in order of how far it can be
16
+ * trusted:
17
+ *
18
+ * 1. its model, lockdown's ProductType ("iPhone17,3", "iPad13,4");
19
+ * 2. its form factor, which a simulator gets from its own name and a real
20
+ * phone from its DeviceClass;
21
+ * 3. its marketing name, then its name, for a row written before the
22
+ * identity columns existed.
23
+ *
24
+ * A real phone's own name is whatever its owner typed ("QA-Phone-3", or
25
+ * "iPhone spare" for an iPad), so it is the last resort, not the first.
26
+ */
27
+ function appleFamilyOf(device) {
28
+ var _a, _b, _c;
29
+ if (((_a = device.platform) !== null && _a !== void 0 ? _a : '').toLowerCase() !== 'ios')
30
+ return undefined;
31
+ const model = (_b = device.model) !== null && _b !== void 0 ? _b : '';
32
+ if (/^iPhone/i.test(model))
33
+ return 'iphone';
34
+ if (/^iPad/i.test(model))
35
+ return 'ipad';
36
+ if (device.formFactor === 'phone')
37
+ return 'iphone';
38
+ if (device.formFactor === 'tablet')
39
+ return 'ipad';
40
+ if (device.formFactor === 'tv')
41
+ return undefined;
42
+ return (_c = familyInName(device.marketingName)) !== null && _c !== void 0 ? _c : familyInName(device.name);
43
+ }
@@ -20,6 +20,10 @@ class XenonDatabase {
20
20
  static get DeviceModel() {
21
21
  return XenonDatabase.getDeviceModel();
22
22
  }
23
+ /** Each phone's saved settings (deviceSettings.ts), by udid and host. */
24
+ static get DeviceSettingsModel() {
25
+ return XenonDatabase.getDeviceSettingsModel();
26
+ }
23
27
  static get PendingSessionsModel() {
24
28
  return XenonDatabase.getPendingSessionsModel();
25
29
  }
@@ -49,6 +53,12 @@ class XenonDatabase {
49
53
  return db.getCollection('devices') || db.addCollection('devices');
50
54
  });
51
55
  }
56
+ static getDeviceSettingsModel() {
57
+ return __awaiter(this, void 0, void 0, function* () {
58
+ const db = yield XenonDatabase.getDB();
59
+ return db.getCollection('device-settings') || db.addCollection('device-settings');
60
+ });
61
+ }
52
62
  static getPendingSessionsModel() {
53
63
  return __awaiter(this, void 0, void 0, function* () {
54
64
  const db = yield XenonDatabase.getDB();
@@ -63,6 +73,7 @@ class XenonDatabase {
63
73
  }
64
74
  static initCollections(db) {
65
75
  db.addCollection('devices');
76
+ db.addCollection('device-settings');
66
77
  db.addCollection('pending-sessions');
67
78
  db.addCollection('cliArgs');
68
79
  }
@@ -81,8 +81,13 @@ function removeDevice(devices_1) {
81
81
  const team = socket.hasScopedDashboard()
82
82
  ? yield typedi_1.Container.get(DeviceTeamResolver_1.DeviceTeamResolver).resolve(device.udid)
83
83
  : undefined;
84
+ // The callers hold a udid and a host. The device_offline webhook promises
85
+ // its name and platform too, so read them while the row still exists.
86
+ const known = yield store
87
+ .findDevice({ udid: device.udid, host: device.host })
88
+ .catch(() => null);
84
89
  yield store.removeDevices({ udid: device.udid, host: device.host }, options);
85
- typedi_1.Container.get(NotificationService_1.NotificationService).dispatchEvent('device_offline', device);
90
+ typedi_1.Container.get(NotificationService_1.NotificationService).dispatchEvent('device_offline', Object.assign(Object.assign({}, known), device));
86
91
  void socket.emitToDashboardForDevices('device_removed', device, deviceScope(device.udid, (team === null || team === void 0 ? void 0 : team.known) ? team.teamId : undefined));
87
92
  }
88
93
  });
@@ -15,6 +15,9 @@ Object.defineProperty(exports, "__esModule", { value: true });
15
15
  exports.DeviceStoreFactory = void 0;
16
16
  const deviceFieldOwners_1 = require("./deviceFieldOwners");
17
17
  const deviceClaims_1 = require("./deviceClaims");
18
+ const appleFamily_1 = require("./appleFamily");
19
+ const deviceSettings_1 = require("./deviceSettings");
20
+ const lodash_1 = __importDefault(require("lodash"));
18
21
  const logger_1 = __importDefault(require("../logger"));
19
22
  const semver_1 = __importDefault(require("semver"));
20
23
  const db_1 = require("./db");
@@ -63,6 +66,10 @@ class LokiDeviceStore {
63
66
  if (!nameRegex.test(device.name || ''))
64
67
  return false;
65
68
  }
69
+ // iPhone / iPad Filter (see appleFamilyOf)
70
+ if (filterOptions.appleFamily && (0, appleFamily_1.appleFamilyOf)(device) !== filterOptions.appleFamily) {
71
+ return false;
72
+ }
66
73
  // UDID Filter
67
74
  if (filterOptions.udid) {
68
75
  if (Array.isArray(filterOptions.udid)) {
@@ -147,6 +154,16 @@ class LokiDeviceStore {
147
154
  }
148
155
  updateDevice(udid, host, updateData) {
149
156
  return __awaiter(this, void 0, void 0, function* () {
157
+ // Saved for the phone first, as PrismaDeviceStore.updateDevice does.
158
+ const settings = lodash_1.default.cloneDeep(lodash_1.default.omitBy((0, deviceFieldOwners_1.pickSettingFields)(updateData), (v) => v === undefined));
159
+ if (Object.keys(settings).length > 0) {
160
+ const model = yield db_1.XenonDatabase.DeviceSettingsModel;
161
+ const saved = model.findOne({ udid, host });
162
+ if (saved)
163
+ model.update(Object.assign(saved, settings));
164
+ else
165
+ model.insert(Object.assign({ udid, host, userBlocked: false }, settings));
166
+ }
150
167
  (yield db_1.XenonDatabase.DeviceModel)
151
168
  .chain()
152
169
  .find({ udid, host })
@@ -167,7 +184,10 @@ class LokiDeviceStore {
167
184
  }
168
185
  addDevices(devices_1) {
169
186
  return __awaiter(this, arguments, void 0, function* (devices, options = {}) {
187
+ var _a;
170
188
  const deviceModel = yield db_1.XenonDatabase.DeviceModel;
189
+ const settingsModel = yield db_1.XenonDatabase.DeviceSettingsModel;
190
+ const now = Date.now();
171
191
  const added = [];
172
192
  for (const device of devices) {
173
193
  const existing = deviceModel.findOne({ udid: device.udid, host: device.host });
@@ -184,10 +204,13 @@ class LokiDeviceStore {
184
204
  continue;
185
205
  }
186
206
  if (!existing) {
207
+ // The phone's saved settings, as PrismaDeviceStore.addDevices.
208
+ const saved = settingsModel.findOne({ udid: device.udid, host: (_a = device.host) !== null && _a !== void 0 ? _a : 'Local' });
209
+ const start = saved ? (0, deviceSettings_1.restoredColumns)(saved, now) : {};
187
210
  const cleanDevice = options.nodeReport
188
- ? Object.assign(Object.assign({}, (0, deviceFieldOwners_1.pickNodeReportFields)(device)), { udid: device.udid, host: device.host, busy: nodeBusy, nodeBusy,
211
+ ? Object.assign(Object.assign(Object.assign({}, (0, deviceFieldOwners_1.pickNodeReportFields)(device)), start), { udid: device.udid, host: device.host, busy: nodeBusy, nodeBusy,
189
212
  nodeHold })
190
- : Object.assign({}, device);
213
+ : Object.assign(Object.assign({}, device), start);
191
214
  if (cleanDevice.host === undefined)
192
215
  cleanDevice.host = 'Local';
193
216
  if (cleanDevice.userBlocked === undefined)
@@ -227,6 +250,43 @@ class LokiDeviceStore {
227
250
  }
228
251
  });
229
252
  }
253
+ adoptSettings() {
254
+ return __awaiter(this, void 0, void 0, function* () {
255
+ const now = Date.now();
256
+ const settingsModel = yield db_1.XenonDatabase.DeviceSettingsModel;
257
+ let adopted = 0;
258
+ for (const device of (yield db_1.XenonDatabase.DeviceModel).find()) {
259
+ if (!(0, deviceSettings_1.hasSettings)(device, now))
260
+ continue;
261
+ if (settingsModel.findOne({ udid: device.udid, host: device.host }))
262
+ continue;
263
+ settingsModel.insert(Object.assign(Object.assign({}, (0, deviceFieldOwners_1.pickSettingFields)(device)), { userBlocked: device.userBlocked === true, udid: device.udid, host: device.host }));
264
+ adopted++;
265
+ }
266
+ return adopted;
267
+ });
268
+ }
269
+ forgetSettings(onlyHosts) {
270
+ return __awaiter(this, void 0, void 0, function* () {
271
+ const model = yield db_1.XenonDatabase.DeviceSettingsModel;
272
+ if (onlyHosts) {
273
+ model
274
+ .chain()
275
+ .find({ host: { $in: [...onlyHosts] } })
276
+ .remove();
277
+ }
278
+ else {
279
+ model.removeDataOnly();
280
+ }
281
+ });
282
+ }
283
+ findSavedPhones(udid) {
284
+ return __awaiter(this, void 0, void 0, function* () {
285
+ return (yield db_1.XenonDatabase.DeviceSettingsModel)
286
+ .find({ udid })
287
+ .map((saved) => ({ udid: saved.udid, host: saved.host }));
288
+ });
289
+ }
230
290
  findDevice(filter) {
231
291
  return __awaiter(this, void 0, void 0, function* () {
232
292
  return (yield db_1.XenonDatabase.DeviceModel).findOne(filter);
@@ -9,6 +9,8 @@ exports.hasNoClaim = hasNoClaim;
9
9
  exports.isUnheld = isUnheld;
10
10
  exports.nodeHoldOf = nodeHoldOf;
11
11
  exports.heldHere = heldHere;
12
+ exports.countsTowardMaxSessions = countsTowardMaxSessions;
13
+ exports.sessionCap = sessionCap;
12
14
  const manualLock_1 = require("../services/recording/manualLock");
13
15
  /**
14
16
  * Why a phone is busy, on the server that allocates it.
@@ -126,5 +128,40 @@ function heldHere(device) {
126
128
  return true;
127
129
  return device.session_id != null || device.claimedAt != null || device.claimSessionId != null;
128
130
  }
131
+ /**
132
+ * Whether a busy phone uses one of `maxSessions`' slots: an Appium session
133
+ * runs on it, or is being created on it.
134
+ *
135
+ * - **Counts:** this server's claim, pending (the session is being created) or
136
+ * with its session id; an Appium session's id in `session_id`; on a hub, a
137
+ * node's phone the node reports busy, unless the node says that is a preview.
138
+ * - **Does not count:** a live preview or a recording (`manual_...` in
139
+ * `session_id`), and a phone an SDK lease holds that has no session on it
140
+ * (`busy` alone, see above). Neither is an Appium session, and a person
141
+ * watching three phones must not stop tests running on a fourth. A session
142
+ * started on a leased phone claims it, so it counts like any other.
143
+ *
144
+ * `busy` alone is not enough: until this existed the cap counted every busy
145
+ * phone, and compared with `===`.
146
+ */
147
+ function countsTowardMaxSessions(device) {
148
+ if (!device.busy)
149
+ return false;
150
+ if (device.claimedAt != null || device.claimSessionId != null)
151
+ return true;
152
+ if (device.session_id != null)
153
+ return !(0, manualLock_1.isManualLock)(device.session_id);
154
+ return device.nodeBusy === true && !device.nodeHold;
155
+ }
156
+ /**
157
+ * The `maxSessions` setting as a limit: undefined (no limit) unless it is a
158
+ * positive number. 0 or less would hold every session back for ever, which
159
+ * nobody sets on purpose.
160
+ */
161
+ function sessionCap(maxSessions) {
162
+ return typeof maxSessions === 'number' && Number.isFinite(maxSessions) && maxSessions >= 1
163
+ ? maxSessions
164
+ : undefined;
165
+ }
129
166
  /** What ending a claim resets: the claim, and the session's bookkeeping. */
130
167
  exports.CLAIM_RESET = Object.assign(Object.assign({}, exports.NO_CLAIM), { session_id: null, lastCmdExecutedAt: null, sessionStartTime: 0, newCommandTimeout: null, sessionProgress: '', owningSessionId: null, lockedAt: null });
@@ -3,7 +3,8 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.NODE_REPORT_FIELDS = exports.DISCOVERY_FIELDS = void 0;
6
+ exports.NODE_REPORT_FIELDS = exports.DISCOVERY_FIELDS = exports.SETTING_FIELDS = void 0;
7
+ exports.pickSettingFields = pickSettingFields;
7
8
  exports.pickDiscoveryFields = pickDiscoveryFields;
8
9
  exports.pickNodeReportFields = pickNodeReportFields;
9
10
  exports.discoveryChanges = discoveryChanges;
@@ -23,11 +24,15 @@ const lodash_1 = __importDefault(require("lodash"));
23
24
  * (`POST /xenon/api/register`, NODE_REPORT_FIELDS). The report carries what
24
25
  * the node observes of its own phone: `discovery`, and `observed` (its health,
25
26
  * which only the server driving the phone can check). It never writes a
26
- * `runtime` column: the hub's claim for a session, and the settings the hub
27
- * owns for the phone (team, tags, reservation, block). Whether the node says
28
- * the phone is busy goes to `nodeBusy` (`report`), never to `busy`
27
+ * `runtime` column (the hub's claim for a session) or a `setting`. Whether the
28
+ * node says the phone is busy goes to `nodeBusy` (`report`), never to `busy`
29
29
  * (deviceClaims.ts).
30
30
  *
31
+ * A `setting` is what people give the phone: an admin its team, tags and
32
+ * maintenance, a member a reservation. Those are also saved apart from the
33
+ * row (DeviceSetting, deviceSettings.ts), which is deleted whenever the phone
34
+ * goes, and a new row for the phone starts from them.
35
+ *
31
36
  * Typed over every column, so a new column won't compile until it is placed.
32
37
  */
33
38
  const OWNER = {
@@ -71,13 +76,6 @@ const OWNER = {
71
76
  lockedAt: 'runtime',
72
77
  claimSessionId: 'runtime',
73
78
  claimedAt: 'runtime',
74
- userBlocked: 'runtime',
75
- reservationReason: 'runtime',
76
- reservedBy: 'runtime',
77
- reservedByUserId: 'runtime',
78
- reservedUntil: 'runtime',
79
- teamId: 'runtime',
80
- tags: 'runtime',
81
79
  totalUtilizationTimeMilliSec: 'runtime',
82
80
  total_session_count: 'runtime',
83
81
  dashboard_link: 'runtime',
@@ -91,7 +89,23 @@ const OWNER = {
91
89
  storageFree: 'observed',
92
90
  nodeBusy: 'report',
93
91
  nodeHold: 'report',
92
+ teamId: 'setting',
93
+ tags: 'setting',
94
+ userBlocked: 'setting',
95
+ reservationReason: 'setting',
96
+ reservedBy: 'setting',
97
+ reservedByUserId: 'setting',
98
+ reservedUntil: 'setting',
94
99
  };
100
+ /**
101
+ * The settings people give a phone, saved apart from its row (DeviceSetting
102
+ * has exactly these columns, besides its key).
103
+ */
104
+ exports.SETTING_FIELDS = Object.keys(OWNER).filter((column) => OWNER[column] === 'setting');
105
+ /** Only the setting columns of `data`. */
106
+ function pickSettingFields(data) {
107
+ return lodash_1.default.pick(data, exports.SETTING_FIELDS);
108
+ }
95
109
  /** The columns a sync may write to a phone it already knows. */
96
110
  exports.DISCOVERY_FIELDS = new Set(Object.keys(OWNER).filter((column) => OWNER[column] === 'discovery'));
97
111
  /** Only the discovery columns of `data`. */
@@ -0,0 +1,79 @@
1
+ "use strict";
2
+ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
3
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
4
+ return new (P || (P = Promise))(function (resolve, reject) {
5
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
6
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
7
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
8
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
9
+ });
10
+ };
11
+ var __importDefault = (this && this.__importDefault) || function (mod) {
12
+ return (mod && mod.__esModule) ? mod : { "default": mod };
13
+ };
14
+ Object.defineProperty(exports, "__esModule", { value: true });
15
+ exports.restoredColumns = restoredColumns;
16
+ exports.settingsDiffer = settingsDiffer;
17
+ exports.hasSettings = hasSettings;
18
+ exports.resetDevicesAtBoot = resetDevicesAtBoot;
19
+ const localDeviceHosts_1 = require("../device-managers/localDeviceHosts");
20
+ const logger_1 = __importDefault(require("../logger"));
21
+ const deviceFieldOwners_1 = require("./deviceFieldOwners");
22
+ /**
23
+ * The setting columns a new row for the phone starts with: all of them, so
24
+ * what was cleared stays cleared, and a reservation only while it holds.
25
+ */
26
+ function restoredColumns(saved, now) {
27
+ var _a, _b, _c, _d, _e;
28
+ const holds = typeof saved.reservedUntil === 'number' && saved.reservedUntil > now;
29
+ return {
30
+ teamId: (_a = saved.teamId) !== null && _a !== void 0 ? _a : null,
31
+ // A copy: the in-memory store keeps the array itself.
32
+ tags: Array.isArray(saved.tags) ? [...saved.tags] : ((_b = saved.tags) !== null && _b !== void 0 ? _b : null),
33
+ userBlocked: saved.userBlocked === true,
34
+ reservationReason: holds ? ((_c = saved.reservationReason) !== null && _c !== void 0 ? _c : null) : null,
35
+ reservedBy: holds ? ((_d = saved.reservedBy) !== null && _d !== void 0 ? _d : null) : null,
36
+ reservedByUserId: holds ? ((_e = saved.reservedByUserId) !== null && _e !== void 0 ? _e : null) : null,
37
+ reservedUntil: holds ? saved.reservedUntil : null,
38
+ };
39
+ }
40
+ /** Whether two sets of setting columns differ (restoredColumns' shape). */
41
+ function settingsDiffer(a, b) {
42
+ return deviceFieldOwners_1.SETTING_FIELDS.some((field) => JSON.stringify(a[field]) !== JSON.stringify(b[field]));
43
+ }
44
+ function hasTags(tags) {
45
+ if (tags == null)
46
+ return false;
47
+ if (Array.isArray(tags))
48
+ return tags.length > 0;
49
+ return tags !== '' && tags !== '[]';
50
+ }
51
+ /** Whether a row carries a setting that is worth keeping (adoptSettings). */
52
+ function hasSettings(row, now) {
53
+ return (row.teamId != null ||
54
+ row.userBlocked === true ||
55
+ hasTags(row.tags) ||
56
+ (typeof row.reservedUntil === 'number' && row.reservedUntil > now));
57
+ }
58
+ /**
59
+ * The device table as a server starts. It forgets the phones
60
+ * devicesClearedAtBoot names, but first saves the settings of rows that have
61
+ * none saved (rows written through 2.13 kept them only on the row). With
62
+ * `removeDevicesFromDatabaseBeforeRunningThePlugin`, the saved settings of
63
+ * those phones are forgotten with them.
64
+ */
65
+ function resetDevicesAtBoot(store, args, local) {
66
+ return __awaiter(this, void 0, void 0, function* () {
67
+ const cleared = (0, localDeviceHosts_1.devicesClearedAtBoot)(args, local);
68
+ const adopted = yield store.adoptSettings();
69
+ if (adopted > 0) {
70
+ logger_1.default.info(`Saved the team, tags, maintenance or reservation of ${adopted} phone(s)`);
71
+ }
72
+ if (args.removeDevicesFromDatabaseBeforeRunningThePlugin) {
73
+ yield store.forgetSettings(cleared);
74
+ logger_1.default.info('removeDevicesFromDatabaseBeforeRunningThePlugin is on: forgot the saved settings of ' +
75
+ (cleared ? "this server's own phones" : 'every phone'));
76
+ }
77
+ yield store.clearStorage(cleared);
78
+ });
79
+ }
@@ -41,6 +41,17 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
41
41
  step((generator = generator.apply(thisArg, _arguments || [])).next());
42
42
  });
43
43
  };
44
+ var __rest = (this && this.__rest) || function (s, e) {
45
+ var t = {};
46
+ for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p) && e.indexOf(p) < 0)
47
+ t[p] = s[p];
48
+ if (s != null && typeof Object.getOwnPropertySymbols === "function")
49
+ for (var i = 0, p = Object.getOwnPropertySymbols(s); i < p.length; i++) {
50
+ if (e.indexOf(p[i]) < 0 && Object.prototype.propertyIsEnumerable.call(s, p[i]))
51
+ t[p[i]] = s[p[i]];
52
+ }
53
+ return t;
54
+ };
44
55
  var __importDefault = (this && this.__importDefault) || function (mod) {
45
56
  return (mod && mod.__esModule) ? mod : { "default": mod };
46
57
  };
@@ -53,8 +64,13 @@ const logger_1 = __importDefault(require("../logger"));
53
64
  const deviceColumns_1 = require("./deviceColumns");
54
65
  const deviceFieldOwners_1 = require("./deviceFieldOwners");
55
66
  const deviceClaims_1 = require("./deviceClaims");
67
+ const appleFamily_1 = require("./appleFamily");
68
+ const deviceSettings_1 = require("./deviceSettings");
56
69
  /** Logged once per key, so a chatty node doesn't flood the log. */
57
70
  const droppedDeviceKeys = new Set();
71
+ const keyOf = (d) => `${d.udid}@${d.host}`;
72
+ /** Prisma's `select` for a phone's key and its setting columns. */
73
+ const SETTINGS_SELECT = Object.fromEntries(['udid', 'host', ...deviceFieldOwners_1.SETTING_FIELDS].map((field) => [field, true]));
58
74
  class PrismaDeviceStore {
59
75
  get prisma() {
60
76
  return typedi_1.Container.get(prisma_service_1.PrismaService).client;
@@ -104,7 +120,7 @@ class PrismaDeviceStore {
104
120
  }
105
121
  getDevices(filterOptions) {
106
122
  return __awaiter(this, void 0, void 0, function* () {
107
- var _a;
123
+ var _a, _b;
108
124
  const where = {};
109
125
  if (filterOptions.platform) {
110
126
  where.platform = filterOptions.platform;
@@ -191,6 +207,22 @@ class PrismaDeviceStore {
191
207
  return filterOptions.tags.every((tag) => d.tags.includes(tag));
192
208
  });
193
209
  }
210
+ // `appium:iPhoneOnly` / `appium:iPadOnly`. Read from the phone's model and
211
+ // form factor first, not its name: see appleFamilyOf.
212
+ if (filterOptions.appleFamily) {
213
+ results = results.filter((d) => (0, appleFamily_1.appleFamilyOf)(d) === filterOptions.appleFamily);
214
+ }
215
+ // A name filter, as the in-memory store has always applied it: a part of
216
+ // the phone's name. Case does not matter, and a phone's marketing name
217
+ // ("iPhone 16 Pro") counts as much as the name its owner gave it.
218
+ const wanted = (_b = filterOptions.name) === null || _b === void 0 ? void 0 : _b.trim().toLowerCase();
219
+ if (wanted) {
220
+ results = results.filter((d) => {
221
+ var _a, _b;
222
+ return ((_a = d.name) !== null && _a !== void 0 ? _a : '').toLowerCase().includes(wanted) ||
223
+ ((_b = d.marketingName) !== null && _b !== void 0 ? _b : '').toLowerCase().includes(wanted);
224
+ });
225
+ }
194
226
  return results;
195
227
  });
196
228
  }
@@ -202,6 +234,16 @@ class PrismaDeviceStore {
202
234
  updateDevice(udid, host, update) {
203
235
  return __awaiter(this, void 0, void 0, function* () {
204
236
  const data = this.fromIDevice(update);
237
+ // Saved first: a row created meanwhile reads them after this, or is
238
+ // corrected by addDevices' second look (settleRestored).
239
+ const settings = (0, deviceFieldOwners_1.pickSettingFields)(data);
240
+ if (Object.keys(settings).length > 0) {
241
+ yield this.prisma.deviceSetting.upsert({
242
+ where: { udid_host: { udid, host } },
243
+ create: Object.assign({ udid, host }, settings),
244
+ update: settings,
245
+ });
246
+ }
205
247
  const result = yield this.prisma.device.updateMany({
206
248
  where: { udid, host },
207
249
  data,
@@ -209,6 +251,21 @@ class PrismaDeviceStore {
209
251
  logger_1.default.debug(`[PrismaStore] Update device ${udid} at ${host}: ${result.count} records affected`);
210
252
  });
211
253
  }
254
+ /** The settings saved for these phones, by `udid@host`. */
255
+ savedSettings(phones) {
256
+ return __awaiter(this, void 0, void 0, function* () {
257
+ if (phones.length === 0)
258
+ return new Map();
259
+ const rows = yield this.prisma.deviceSetting.findMany({
260
+ where: { OR: phones.map(({ udid, host }) => ({ udid, host })) },
261
+ select: SETTINGS_SELECT,
262
+ });
263
+ return new Map(rows.map((r) => [
264
+ keyOf(r),
265
+ r,
266
+ ]));
267
+ });
268
+ }
212
269
  touchSession(sessionId, at) {
213
270
  return __awaiter(this, void 0, void 0, function* () {
214
271
  yield this.prisma.device.updateMany({
@@ -225,22 +282,58 @@ class PrismaDeviceStore {
225
282
  where: { OR: devices.map((d) => ({ udid: d.udid, host: d.host })) },
226
283
  select: { udid: true, host: true },
227
284
  });
228
- const known = new Set(existing.map((d) => `${d.udid}@${d.host}`));
285
+ const known = new Set(existing.map((d) => keyOf(d)));
286
+ const fresh = devices.filter((d) => !known.has(keyOf(d)));
287
+ // A new row starts from the phone's saved settings, in the write that
288
+ // creates it: it is never listed, even for a moment, outside its team.
289
+ const saved = yield this.savedSettings(fresh);
290
+ const now = Date.now();
291
+ const started = new Map();
229
292
  const added = [];
230
293
  for (const device of devices) {
294
+ const key = keyOf(device);
295
+ const was = known.has(key) ? undefined : saved.get(key);
296
+ const start = was ? (0, deviceSettings_1.restoredColumns)(was, now) : {};
297
+ if (was)
298
+ started.set(key, start);
231
299
  const d = options.nodeReport
232
- ? yield this.addNodeReport(device)
300
+ ? yield this.addNodeReport(device, start)
233
301
  : yield this.prisma.device.upsert({
234
302
  // Use upsert to avoid race conditions and unique constraint errors. A
235
303
  // phone already here gets only its discovery columns (AddDevicesOptions).
236
304
  where: { udid_host: { udid: device.udid, host: device.host } },
237
305
  update: (0, deviceFieldOwners_1.pickDiscoveryFields)(this.fromIDevice(device)),
238
- create: this.fromIDevice(device),
306
+ create: Object.assign(Object.assign({}, this.fromIDevice(device)), start),
239
307
  });
240
- if (!known.has(`${device.udid}@${device.host}`))
308
+ if (!known.has(key))
241
309
  added.push(this.toIDevice(d));
242
310
  }
243
- return added;
311
+ return this.settleRestored(added, started, now);
312
+ });
313
+ }
314
+ /**
315
+ * A second look at the settings of the rows just created. A setting saved
316
+ * between the read above and the create (updateDevice saves before it
317
+ * writes the row, and found no row to write) would otherwise be missing
318
+ * from the new row until the phone came back again. Rows that need it are
319
+ * corrected and read again; the others are returned as they are.
320
+ */
321
+ settleRestored(added, started, now) {
322
+ return __awaiter(this, void 0, void 0, function* () {
323
+ const saved = yield this.savedSettings(added);
324
+ return Promise.all(added.map((device) => __awaiter(this, void 0, void 0, function* () {
325
+ const latest = saved.get(keyOf(device));
326
+ if (!latest)
327
+ return device;
328
+ const columns = (0, deviceSettings_1.restoredColumns)(latest, now);
329
+ const before = started.get(keyOf(device));
330
+ if (before && !(0, deviceSettings_1.settingsDiffer)(before, columns))
331
+ return device;
332
+ const where = { udid: device.udid, host: device.host };
333
+ yield this.prisma.device.updateMany({ where, data: columns });
334
+ const row = yield this.prisma.device.findUnique({ where: { udid_host: where } });
335
+ return row ? this.toIDevice(row) : device;
336
+ })));
244
337
  });
245
338
  }
246
339
  /**
@@ -250,7 +343,7 @@ class PrismaDeviceStore {
250
343
  * update, so a report the node sent before the hub claimed the phone can't
251
344
  * undo the claim.
252
345
  */
253
- addNodeReport(device) {
346
+ addNodeReport(device, start) {
254
347
  return __awaiter(this, void 0, void 0, function* () {
255
348
  const { udid, host } = device;
256
349
  const nodeBusy = device.busy === true;
@@ -258,7 +351,8 @@ class PrismaDeviceStore {
258
351
  const row = yield this.prisma.device.upsert({
259
352
  where: { udid_host: { udid, host } },
260
353
  update: nodeBusy ? Object.assign(Object.assign({}, reported), { busy: true }) : reported,
261
- create: Object.assign(Object.assign({}, reported), { udid, host, busy: nodeBusy }),
354
+ // The hub's own settings for the phone, never the report's.
355
+ create: Object.assign(Object.assign(Object.assign({}, reported), start), { udid, host, busy: nodeBusy }),
262
356
  });
263
357
  if (nodeBusy)
264
358
  return row;
@@ -299,6 +393,54 @@ class PrismaDeviceStore {
299
393
  yield this.prisma.device.deleteMany(onlyHosts ? { where: { host: { in: [...onlyHosts] } } } : undefined);
300
394
  });
301
395
  }
396
+ adoptSettings() {
397
+ return __awaiter(this, void 0, void 0, function* () {
398
+ const now = Date.now();
399
+ const rows = (yield this.prisma.device.findMany({
400
+ where: {
401
+ OR: [
402
+ { teamId: { not: null } },
403
+ { userBlocked: true },
404
+ { tags: { notIn: ['', '[]'] } },
405
+ { reservedUntil: { gt: now } },
406
+ ],
407
+ },
408
+ select: SETTINGS_SELECT,
409
+ }));
410
+ const candidates = rows.filter((row) => (0, deviceSettings_1.hasSettings)(row, now));
411
+ if (candidates.length === 0)
412
+ return 0;
413
+ const saved = yield this.savedSettings(candidates);
414
+ let adopted = 0;
415
+ for (let _a of candidates) {
416
+ const { udid, host } = _a, settings = __rest(_a, ["udid", "host"]);
417
+ if (saved.has(keyOf({ udid, host })))
418
+ continue;
419
+ const data = (0, deviceFieldOwners_1.pickSettingFields)(settings);
420
+ yield this.prisma.deviceSetting.upsert({
421
+ where: { udid_host: { udid, host } },
422
+ create: Object.assign(Object.assign({}, data), { userBlocked: settings.userBlocked === true, udid, host }),
423
+ // Saved meanwhile: that one is newer.
424
+ update: {},
425
+ });
426
+ adopted++;
427
+ }
428
+ return adopted;
429
+ });
430
+ }
431
+ forgetSettings(onlyHosts) {
432
+ return __awaiter(this, void 0, void 0, function* () {
433
+ yield this.prisma.deviceSetting.deleteMany(onlyHosts ? { where: { host: { in: [...onlyHosts] } } } : undefined);
434
+ });
435
+ }
436
+ findSavedPhones(udid) {
437
+ return __awaiter(this, void 0, void 0, function* () {
438
+ return this.prisma.deviceSetting.findMany({
439
+ where: { udid },
440
+ select: { udid: true, host: true },
441
+ });
442
+ });
443
+ }
302
444
  findDevice(filter) {
303
445
  return __awaiter(this, void 0, void 0, function* () {
304
446
  const device = yield this.prisma.device.findFirst({ where: filter });