@xenon-device-management/xenon 2.13.1 → 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 (223) hide show
  1. package/README.md +11 -5
  2. package/lib/package.json +1 -1
  3. package/lib/public/assets/{AnnotationOverlay-Cvpkk4r9.js → AnnotationOverlay-B_VNtKBH.js} +1 -1
  4. package/lib/public/assets/{ApiKeyGate-Bhn5ZtVM.js → ApiKeyGate-BwuaMjkX.js} +1 -1
  5. package/lib/public/assets/{BugReportButton-BA6aEjTC.js → BugReportButton-BAt0_pzq.js} +1 -1
  6. package/lib/public/assets/DeviceMosaicView-BYaQHhTf.js +2 -0
  7. package/lib/public/assets/{EmptyState-CgzbuUvO.js → EmptyState-BSjhk_IV.js} +1 -1
  8. package/lib/public/assets/{FieldGroup-BHc_5qNc.js → FieldGroup-Cb5QZsE1.js} +1 -1
  9. package/lib/public/assets/{FilterMenu-D7OMGuRc.js → FilterMenu-BAaSlWbW.js} +1 -1
  10. package/lib/public/assets/{Menu-C173JJ0l.js → Menu-JGGYrSQQ.js} +1 -1
  11. package/lib/public/assets/{Modal-ChvVUI3B.js → Modal-CFCp3n9d.js} +1 -1
  12. package/lib/public/assets/{RecordingPage-Dhw4d_O6.js → RecordingPage-CbJj_EBT.js} +1 -1
  13. package/lib/public/assets/{RecordingsPage-z97ftGK-.js → RecordingsPage-BJeYnygR.js} +2 -2
  14. package/lib/public/assets/{SegmentedControl-DbXhBGZC.js → SegmentedControl-Bg42Atjy.js} +1 -1
  15. package/lib/public/assets/{SettingCard-C908GgiI.js → SettingCard-Njz1pUIJ.js} +1 -1
  16. package/lib/public/assets/{Table-B1DB_ZN-.js → Table-BcTm5xV2.js} +1 -1
  17. package/lib/public/assets/{activity-Css0xNvp.js → activity-DOVdsfMQ.js} +1 -1
  18. package/lib/public/assets/{ai-settings-BMQK3-yN.js → ai-settings-CsuJroRf.js} +2 -2
  19. package/lib/public/assets/{api-keys-CbJ42eDQ.js → api-keys-BKnNSTw9.js} +1 -1
  20. package/lib/public/assets/{apps-DW1pw2re.js → apps-D0SC6uw6.js} +2 -2
  21. package/lib/public/assets/{arrow-left-Dpog9L0F.js → arrow-left-MMbTmgoE.js} +1 -1
  22. package/lib/public/assets/{arrow-right-CsBIlSIw.js → arrow-right-BMfnGmxW.js} +1 -1
  23. package/lib/public/assets/{arrow-up-right-z1tRQE4R.js → arrow-up-right-DuAftMWB.js} +1 -1
  24. package/lib/public/assets/{auth-shell-mXiX5vNk.js → auth-shell-nZC2JNDO.js} +2 -2
  25. package/lib/public/assets/{builds-page-BcFn23bE.js → builds-page-hDCgTwkS.js} +1 -1
  26. package/lib/public/assets/{button-Cq_zHETv.js → button-Dda4irn_.js} +1 -1
  27. package/lib/public/assets/{calendar-CKxABX9q.js → calendar-BMr0Q62v.js} +1 -1
  28. package/lib/public/assets/{check-C7xu-hGU.js → check-CZ9iw9-e.js} +1 -1
  29. package/lib/public/assets/{chevron-right-BiqfdCGA.js → chevron-right-BRYJJVC9.js} +1 -1
  30. package/lib/public/assets/{circle-check-CyBjjPxR.js → circle-check-CqAJjQ5O.js} +1 -1
  31. package/lib/public/assets/{circle-x-6gMRcx6z.js → circle-x-DlDJtnVz.js} +1 -1
  32. package/lib/public/assets/{clock-U1rDTT1h.js → clock-CpuDKysU.js} +1 -1
  33. package/lib/public/assets/{copy-BIM0ZX0L.js → copy-CAQydNFe.js} +1 -1
  34. package/lib/public/assets/device-explorer-CdO6_nrn.js +282 -0
  35. package/lib/public/assets/{download-DDBRXnAZ.js → download-C9XG6lLv.js} +1 -1
  36. package/lib/public/assets/{forgot-password-Dpg7yxM8.js → forgot-password-CGnkRgE9.js} +1 -1
  37. package/lib/public/assets/h264Stream-VQBphYhg.js +26 -0
  38. package/lib/public/assets/{index-BGBqohfQ.js → index-BbKEXUFH.js} +5 -5
  39. package/lib/public/assets/{index-BTYMAqNW.css → index-Bxoxt9sV.css} +1 -1
  40. package/lib/public/assets/{index-BQ2luT7L.js → index-DJGc1M2f.js} +1 -1
  41. package/lib/public/assets/{input-BvB1cjHE.js → input-Cm0tmSoc.js} +1 -1
  42. package/lib/public/assets/{line-chart-m78VggnW.js → line-chart-D2DyJCml.js} +1 -1
  43. package/lib/public/assets/{list-checks-cY7o1QTL.js → list-checks-UWf1GY99.js} +1 -1
  44. package/lib/public/assets/{lock-loz8txQm.js → lock-CCJv9m_I.js} +1 -1
  45. package/lib/public/assets/{login-BYOvz4lx.js → login-f8vOVW-U.js} +1 -1
  46. package/lib/public/assets/maintenance-settings-C1PrkuAH.js +6 -0
  47. package/lib/public/assets/{monitor-C4HPHb6s.js → monitor-B2jDgwm7.js} +1 -1
  48. package/lib/public/assets/{mouse-pointer-2-BkdKtCzi.js → mouse-pointer-2-DLI1RtzU.js} +1 -1
  49. package/lib/public/assets/{network-C5e0MIWo.js → network-ebT86qoi.js} +1 -1
  50. package/lib/public/assets/{overview-tlu8ijGd.js → overview-C0aykVrd.js} +1 -1
  51. package/lib/public/assets/{page-header-FXPlo6az.js → page-header-BjzgWz-T.js} +1 -1
  52. package/lib/public/assets/{play-CWdk0_Sy.js → play-DLG9t8FU.js} +1 -1
  53. package/lib/public/assets/{plus-B2xCnntV.js → plus-nBFSMu_X.js} +1 -1
  54. package/lib/public/assets/{profile-page-D080qzr4.js → profile-page-CSl0UYlv.js} +1 -1
  55. package/lib/public/assets/{reset-password-D3u4Mbvn.js → reset-password-LeOuHqmB.js} +1 -1
  56. package/lib/public/assets/{runbook-page-hPRZMy7s.js → runbook-page-CMMeQml9.js} +1 -1
  57. package/lib/public/assets/{select-12RThAXc.js → select-88jHcl-Z.js} +1 -1
  58. package/lib/public/assets/{selector-detail-redirect-uQwvV7EQ.js → selector-detail-redirect-BbXKJjZM.js} +1 -1
  59. package/lib/public/assets/{selector-health-page-CThoxDFO.js → selector-health-page-B4rA1Xkv.js} +2 -2
  60. package/lib/public/assets/session-detail-page-COL_RZxw.js +4 -0
  61. package/lib/public/assets/settings-GF6ZMSQ6.js +6 -0
  62. package/lib/public/assets/{stat-tile-B02WEte8.js → stat-tile-BV98e_Sv.js} +1 -1
  63. package/lib/public/assets/{tablet-Dj30VGZl.js → tablet-BiAsnFDj.js} +1 -1
  64. package/lib/public/assets/{teams-uL4fjTDR.js → teams-B4-jrr-1.js} +1 -1
  65. package/lib/public/assets/{trash-2-B54e-TfY.js → trash-2-C5RSuX0e.js} +1 -1
  66. package/lib/public/assets/{upload-B3k8WCSP.js → upload-BG7_rMXK.js} +1 -1
  67. package/lib/public/assets/{use-builds-data-BFn90NH3.js → use-builds-data-J-riQYyC.js} +1 -1
  68. package/lib/public/assets/{use-password-reset-mode-DL2TmWZ0.js → use-password-reset-mode-C8-eH-UG.js} +1 -1
  69. package/lib/public/assets/{users-CqBuxMlX.js → users-CFba5TSy.js} +1 -1
  70. package/lib/public/assets/{users-DjQSucKb.js → users-Cp0beJm3.js} +1 -1
  71. package/lib/public/assets/{video-off-T0rlt0GA.js → video-off-DDsEISjV.js} +1 -1
  72. package/lib/public/assets/webhook-settings-BGu32vSB.js +1 -0
  73. package/lib/public/assets/{zap-gBHqutZV.js → zap-l4VaXzq8.js} +1 -1
  74. package/lib/public/index.html +2 -2
  75. package/lib/schema.json +19 -46
  76. package/lib/src/XenonCapabilityManager.js +22 -5
  77. package/lib/src/app/openapi/control.yaml +71 -20
  78. package/lib/src/app/openapi/grid.yaml +23 -5
  79. package/lib/src/app/openapi/platform.yaml +136 -28
  80. package/lib/src/app/routers/control.js +30 -5
  81. package/lib/src/app/routers/dashboard.js +29 -1
  82. package/lib/src/app/routers/grid.js +32 -10
  83. package/lib/src/app/routers/streamStartConflict.js +3 -0
  84. package/lib/src/app/routers/streamType.js +7 -2
  85. package/lib/src/app/routers/webhook.js +8 -4
  86. package/lib/src/config.js +50 -5
  87. package/lib/src/dashboard/commands.js +185 -141
  88. package/lib/src/dashboard/event-manager.js +39 -6
  89. package/lib/src/data-service/appleFamily.js +43 -0
  90. package/lib/src/data-service/db.js +11 -0
  91. package/lib/src/data-service/device-service.js +46 -1
  92. package/lib/src/data-service/device-store.js +66 -3
  93. package/lib/src/data-service/deviceClaims.js +37 -0
  94. package/lib/src/data-service/deviceFieldOwners.js +25 -11
  95. package/lib/src/data-service/deviceSettings.js +79 -0
  96. package/lib/src/data-service/prisma-store.js +152 -9
  97. package/lib/src/data-service/web-config-service.js +43 -92
  98. package/lib/src/device-managers/AndroidDeviceManager.js +9 -31
  99. package/lib/src/device-managers/ios/WDAClient.js +44 -43
  100. package/lib/src/device-managers/shellCommands.js +96 -0
  101. package/lib/src/device-utils.js +143 -37
  102. package/lib/src/generated/client/edge.js +17 -3
  103. package/lib/src/generated/client/index-browser.js +14 -0
  104. package/lib/src/generated/client/index.d.ts +2054 -379
  105. package/lib/src/generated/client/index.js +17 -3
  106. package/lib/src/generated/client/package.json +1 -1
  107. package/lib/src/generated/client/schema.prisma +35 -7
  108. package/lib/src/generated/client/wasm.js +14 -0
  109. package/lib/src/interceptors/CommandInterceptor.js +29 -8
  110. package/lib/src/interceptors/xenonScripts.js +55 -0
  111. package/lib/src/interfaces/IPluginArgs.js +1 -2
  112. package/lib/src/middleware/deviceAccessGuard.js +63 -9
  113. package/lib/src/plugin.js +15 -1
  114. package/lib/src/prisma.js +1 -1
  115. package/lib/src/scripts/database-check.js +124 -0
  116. package/lib/src/services/AIService.js +99 -0
  117. package/lib/src/services/CleanupService.js +6 -1
  118. package/lib/src/services/InterceptorService.js +70 -13
  119. package/lib/src/services/NetworkConditioningService.js +85 -88
  120. package/lib/src/services/NotificationService.js +36 -29
  121. package/lib/src/services/OrphanSweeper.js +5 -0
  122. package/lib/src/services/PortAllocator.js +5 -0
  123. package/lib/src/services/ServerManager.js +97 -8
  124. package/lib/src/services/SessionLifecycleService.js +63 -53
  125. package/lib/src/services/ShutdownCoordinator.js +33 -0
  126. package/lib/src/services/TeamService.js +20 -3
  127. package/lib/src/services/device-access/SessionOwnerResolver.js +26 -0
  128. package/lib/src/services/device-access/deviceAccessPolicy.js +38 -6
  129. package/lib/src/services/device-access/leaseHold.js +26 -0
  130. package/lib/src/services/device-access/ticketActorAccess.js +6 -0
  131. package/lib/src/services/healing/HealingOrchestrator.js +32 -1
  132. package/lib/src/services/interceptor/AndroidProxyAdapter.js +18 -0
  133. package/lib/src/services/interceptor/interceptorOptions.js +44 -0
  134. package/lib/src/services/lease/activeLeases.js +37 -0
  135. package/lib/src/services/network/AndroidRadios.js +71 -0
  136. package/lib/src/services/network/PhoneNetworkLedger.js +184 -0
  137. package/lib/src/services/network/PhoneNetworkRestore.js +330 -0
  138. package/lib/src/services/network/adbForPhone.js +60 -0
  139. package/lib/src/services/network/phoneNetworkLock.js +20 -0
  140. package/lib/src/services/network/xenonProxy.js +107 -0
  141. package/lib/src/services/omni-vision/OmniVisionService.js +53 -43
  142. package/lib/src/services/omni-vision/VisionAssertionService.js +28 -25
  143. package/lib/src/services/omni-vision/ocrTextMatch.js +105 -0
  144. package/lib/src/services/omni-vision/screenScale.js +68 -0
  145. package/lib/src/services/recording/busy-precheck.js +24 -3
  146. package/lib/src/services/recording/concurrency-gate.js +12 -3
  147. package/lib/src/services/settings/SelfHealingSwitch.js +70 -0
  148. package/lib/src/services/settings/labSettings.js +149 -0
  149. package/lib/src/services/webhookEvents.js +173 -0
  150. package/lib/test/helpers/fake-adb.js +58 -0
  151. package/lib/test/helpers/fake-device-table.js +63 -24
  152. package/lib/test/helpers/scratch-database.js +1 -0
  153. package/lib/test/unit/TeamService.test.js +3 -0
  154. package/lib/test/unit/XenonCapabilityManager.spec.js +4 -2
  155. package/lib/test/unit/apple-family.spec.js +36 -0
  156. package/lib/test/unit/cleanup-settings-db.spec.js +103 -0
  157. package/lib/test/unit/cleanup-settings.spec.js +244 -0
  158. package/lib/test/unit/dashboard-api-paths.spec.js +101 -0
  159. package/lib/test/unit/dashboard-commands.spec.js +249 -0
  160. package/lib/test/unit/dashboard-events-team-scope.spec.js +9 -6
  161. package/lib/test/unit/database-check.spec.js +108 -0
  162. package/lib/test/unit/default-plugin-args.spec.js +33 -0
  163. package/lib/test/unit/device-access-lease.spec.js +270 -0
  164. package/lib/test/unit/device-offline-webhook.spec.js +81 -0
  165. package/lib/test/unit/device-setting-columns.spec.js +66 -0
  166. package/lib/test/unit/device-settings-loki.spec.js +87 -0
  167. package/lib/test/unit/device-settings-survive.spec.js +555 -0
  168. package/lib/test/unit/device-sync-lost-update.spec.js +5 -0
  169. package/lib/test/unit/emulator-boot.spec.js +91 -0
  170. package/lib/test/unit/hub-node-gateway-appium-server.spec.js +48 -0
  171. package/lib/test/unit/hub-node-same-host.spec.js +5 -0
  172. package/lib/test/unit/iphone-ipad-only.spec.js +132 -0
  173. package/lib/test/unit/json-logging-option.spec.js +74 -0
  174. package/lib/test/unit/lab-settings.spec.js +218 -0
  175. package/lib/test/unit/lease/allocate-lease-branch.spec.js +22 -0
  176. package/lib/test/unit/lease/session-lease-proof.spec.js +59 -0
  177. package/lib/test/unit/lease-held-phone.spec.js +196 -0
  178. package/lib/test/unit/max-sessions.spec.js +240 -0
  179. package/lib/test/unit/ocr-text-match.spec.js +44 -0
  180. package/lib/test/unit/omni-vision-analyze-screen.spec.js +72 -0
  181. package/lib/test/unit/omni-vision-ios-points.spec.js +161 -0
  182. package/lib/test/unit/omni-vision-tap-text.spec.js +129 -0
  183. package/lib/test/unit/omni-vision.spec.js +1 -1
  184. package/lib/test/unit/partial-config-file.spec.js +114 -0
  185. package/lib/test/unit/phone-network/boot-clean-up.spec.js +64 -0
  186. package/lib/test/unit/phone-network/interceptor-options.spec.js +77 -0
  187. package/lib/test/unit/phone-network/interceptor-proxy-restore.spec.js +145 -0
  188. package/lib/test/unit/phone-network/network-conditioning.spec.js +145 -0
  189. package/lib/test/unit/phone-network/phone-network-restore.spec.js +246 -0
  190. package/lib/test/unit/phone-network/session-end-paths.spec.js +182 -0
  191. package/lib/test/unit/phone-network/session-start.spec.js +110 -0
  192. package/lib/test/unit/phone-network/xenon-proxy.spec.js +69 -0
  193. package/lib/test/unit/plugin.spec.js +3 -3
  194. package/lib/test/unit/recording-options.spec.js +181 -0
  195. package/lib/test/unit/schema-required-args.spec.js +21 -16
  196. package/lib/test/unit/self-healing-boot.spec.js +75 -0
  197. package/lib/test/unit/self-healing-setting.spec.js +272 -0
  198. package/lib/test/unit/session-failed-webhook.spec.js +170 -0
  199. package/lib/test/unit/shell-commands.spec.js +133 -0
  200. package/lib/test/unit/socketServer.scopedEmit.spec.js +2 -0
  201. package/lib/test/unit/stream-start-pipelines.spec.js +210 -0
  202. package/lib/test/unit/stream-status-type.spec.js +4 -0
  203. package/lib/test/unit/vision-assertion.spec.js +118 -0
  204. package/lib/test/unit/wda-client-shell.spec.js +133 -0
  205. package/lib/test/unit/web-config-service.spec.js +79 -0
  206. package/lib/test/unit/webhook-delivery.spec.js +24 -0
  207. package/lib/test/unit/webhook-payloads.spec.js +273 -0
  208. package/lib/test/unit/xenon-execute-scripts.spec.js +138 -0
  209. package/lib/tsconfig.tsbuildinfo +1 -1
  210. package/lib/web/src/components/webhook-settings/webhookEventVariables.json +30 -0
  211. package/package.json +1 -1
  212. package/prisma/migrations/20261004140000_keep_device_settings/migration.sql +19 -0
  213. package/prisma/schema.prisma +35 -7
  214. package/schema.json +19 -46
  215. package/scripts/generate-types-from-schema.js +7 -2
  216. package/scripts/lib/always-present-plugin-args.js +42 -0
  217. package/lib/public/assets/DeviceMosaicView-ChhHnpFQ.js +0 -2
  218. package/lib/public/assets/device-explorer-DSpAA_t9.js +0 -279
  219. package/lib/public/assets/maintenance-settings-BxbA7SkB.js +0 -6
  220. package/lib/public/assets/recording-group-store-B75435xR.js +0 -26
  221. package/lib/public/assets/session-detail-page-Bc02YjMm.js +0 -4
  222. package/lib/public/assets/settings-C1VIluuT.js +0 -6
  223. package/lib/public/assets/webhook-settings-DAHgnJyZ.js +0 -1
@@ -71,15 +71,19 @@ 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"));
77
78
  const IPluginArgs_1 = require("../interfaces/IPluginArgs");
78
79
  const config_service_1 = require("../data-service/config-service");
79
80
  const PluginContext_1 = require("../PluginContext");
81
+ const SelfHealingSwitch_1 = require("./settings/SelfHealingSwitch");
80
82
  const device_store_1 = require("../data-service/device-store");
81
83
  const device_utils_1 = require("../device-utils");
82
84
  const localDeviceHosts_1 = require("../device-managers/localDeviceHosts");
85
+ const deviceSettings_1 = require("../data-service/deviceSettings");
86
+ const PhoneNetworkRestore_1 = require("./network/PhoneNetworkRestore");
83
87
  const app_1 = require("../app");
84
88
  const registerCommandAuth_1 = require("../app/registerCommandAuth");
85
89
  const defaultGateway_1 = require("../gateway/defaultGateway");
@@ -128,10 +132,14 @@ let ServerManager = ServerManager_1 = class ServerManager {
128
132
  this.logger.warn('Authentication is DISABLED — every /xenon/api caller is treated as a ' +
129
133
  'synthetic SUPER_ADMIN. Local development only.');
130
134
  }
135
+ this.applyRecordingOptions(pluginArgs);
131
136
  yield this.syncDatabaseAndAIConfig(pluginArgs);
132
137
  yield this.initializeCoreSubsystems(pluginArgs, cliArgs.port);
133
138
  // Before anything below starts go-ios: the reap would kill it too.
134
139
  yield this.reapLeftoverGoIos();
140
+ // The Settings page's AI self-healing toggle, which the command interceptor
141
+ // reads from memory at every command: load it before a command can arrive.
142
+ yield typedi_1.Container.get(SelfHealingSwitch_1.SelfHealingSwitch).load();
135
143
  this.registerRoutes(expressApp, httpServer, cliArgs, pluginArgs);
136
144
  yield this.bootEmulators(pluginArgs);
137
145
  this.registerDependenciesInContainer(pluginArgs, cliArgs, nodeId);
@@ -169,6 +177,8 @@ let ServerManager = ServerManager_1 = class ServerManager {
169
177
  authorize: (0, ticketActorAccess_1.makeTicketActorAuthorizer)({
170
178
  findDevice: (udid) => device_store_1.DeviceStoreFactory.getStore().findDevice({ udid }),
171
179
  resolveSessionOwner: (sessionId) => typedi_1.Container.get(SessionOwnerResolver_1.SessionOwnerResolver).ownerOf(sessionId),
180
+ findActiveLease: (udid, host) => (0, activeLeases_1.activeLeaseOn)(udid, host),
181
+ resolveLeaseHolder: (actorId) => typedi_1.Container.get(SessionOwnerResolver_1.SessionOwnerResolver).leaseHolderOf(actorId),
172
182
  }),
173
183
  // One WebSocket, two transports. The device's platform picks which —
174
184
  // see resolveLogSource. The filter is only meaningful for ostrace,
@@ -255,6 +265,9 @@ let ServerManager = ServerManager_1 = class ServerManager {
255
265
  yield (0, device_utils_1.updateDeviceList)(localHosts, pluginArgs.hub, pluginArgs.tlsRejectUnauthorized);
256
266
  // remove stale devices
257
267
  yield (0, device_utils_1.removeStaleDevices)(localHosts, pluginArgs.tlsRejectUnauthorized);
268
+ // In the background: put back the network a previous run left changed on
269
+ // this server's phones (an Offline profile, an interceptor proxy).
270
+ void this.cleanUpPhoneNetworks(localHosts, nodeId);
258
271
  this.logger.info(`🚀 Xenon will be served at http://${pluginArgs.bindHostOrIp}:${cliArgs.port}/xenon with id ${nodeId}`);
259
272
  this.logger.info('You can provide the following URLs to access Xenon:');
260
273
  for (const url of (0, networkAddresses_1.listReachableBaseUrls)(cliArgs.port)) {
@@ -263,6 +276,29 @@ let ServerManager = ServerManager_1 = class ServerManager {
263
276
  }
264
277
  });
265
278
  }
279
+ /**
280
+ * What a previous run of this server left on its phones' network: the
281
+ * changes its ledger holds, and on this server's own Android phones a proxy
282
+ * that points at a capture port of this machine where nothing answers.
283
+ * Each change is logged. A phone not connected now is put back at its next
284
+ * session here, or at the next start.
285
+ */
286
+ cleanUpPhoneNetworks(localHosts, nodeId) {
287
+ return __awaiter(this, void 0, void 0, function* () {
288
+ var _a;
289
+ try {
290
+ const devices = yield device_store_1.DeviceStoreFactory.getStore().getAllDevices();
291
+ const androids = devices
292
+ .filter((d) => { var _a; return ((_a = d.platform) === null || _a === void 0 ? void 0 : _a.toLowerCase()) === 'android' && !d.cloud; })
293
+ .filter((d) => (0, localDeviceHosts_1.isOwnDevice)(localHosts, nodeId, d))
294
+ .map((d) => d.udid);
295
+ yield typedi_1.Container.get(PhoneNetworkRestore_1.PhoneNetworkRestore).cleanUpAtBoot([...new Set(androids)]);
296
+ }
297
+ catch (err) {
298
+ this.logger.warn(`Could not check the phones' network settings: ${(_a = err === null || err === void 0 ? void 0 : err.message) !== null && _a !== void 0 ? _a : err}`);
299
+ }
300
+ });
301
+ }
266
302
  resolvePluginArgs(cliArgs) {
267
303
  return __awaiter(this, void 0, void 0, function* () {
268
304
  const pluginConfigs = cliArgs.plugin;
@@ -287,6 +323,24 @@ let ServerManager = ServerManager_1 = class ServerManager {
287
323
  return pluginArgs;
288
324
  });
289
325
  }
326
+ /**
327
+ * `maxConcurrentRecordings` and `recordingsAssetsPath` are schema options,
328
+ * but only their environment variables ever reached `config`. Apply the
329
+ * options now (option, else the variable, else the default) before the
330
+ * artifact store is built from the path and the recordings router first
331
+ * reads the cap. See `recordingConfigFrom`.
332
+ */
333
+ applyRecordingOptions(pluginArgs) {
334
+ const envCap = process.env.XENON_MAX_CONCURRENT_RECORDINGS;
335
+ const applied = (0, config_1.recordingConfigFrom)(pluginArgs);
336
+ if (pluginArgs.maxConcurrentRecordings === undefined &&
337
+ (envCap === null || envCap === void 0 ? void 0 : envCap.trim()) &&
338
+ Number(envCap) !== applied.maxConcurrentRecordings) {
339
+ this.logger.warn(`Ignoring XENON_MAX_CONCURRENT_RECORDINGS=${JSON.stringify(envCap)}: ` +
340
+ `it must be a whole number of at least 1. The cap is ${applied.maxConcurrentRecordings}.`);
341
+ }
342
+ (0, config_1.updateConfig)(applied);
343
+ }
290
344
  syncDatabaseAndAIConfig(pluginArgs) {
291
345
  return __awaiter(this, void 0, void 0, function* () {
292
346
  const { updateConfig } = yield Promise.resolve().then(() => __importStar(require('../config')));
@@ -331,11 +385,15 @@ let ServerManager = ServerManager_1 = class ServerManager {
331
385
  }
332
386
  initializeCoreSubsystems(pluginArgs, port) {
333
387
  return __awaiter(this, void 0, void 0, function* () {
388
+ // Before anything opens the database: the URL must suit the database
389
+ // client this install was built with (SQLite for the published plugin).
390
+ const { assertSupportedDatabase } = yield Promise.resolve().then(() => __importStar(require('../scripts/database-check')));
391
+ assertSupportedDatabase();
334
392
  yield (0, device_utils_1.initializeStorage)();
335
393
  const { runMigrations } = yield Promise.resolve().then(() => __importStar(require('../scripts/run-migrations')));
336
394
  yield runMigrations();
337
395
  // A hub keeps its nodes' phones: the sessions on them outlive its restart.
338
- yield device_store_1.DeviceStoreFactory.getStore().clearStorage((0, localDeviceHosts_1.devicesClearedAtBoot)(pluginArgs, (0, localDeviceHosts_1.localDeviceHosts)(pluginArgs, port)));
396
+ yield (0, deviceSettings_1.resetDevicesAtBoot)(device_store_1.DeviceStoreFactory.getStore(), pluginArgs, (0, localDeviceHosts_1.localDeviceHosts)(pluginArgs, port));
339
397
  const { bootstrapIdentity } = yield Promise.resolve().then(() => __importStar(require('./identity/bootstrap')));
340
398
  yield bootstrapIdentity();
341
399
  typedi_1.Container.get(EmailService_1.EmailService).warnIfLogFallbackEnabled();
@@ -414,16 +472,47 @@ let ServerManager = ServerManager_1 = class ServerManager {
414
472
  `${router.describeAppiumPath()}.`);
415
473
  }
416
474
  }
475
+ /**
476
+ * Boot the emulators `emulators` lists, with each one's launch options.
477
+ *
478
+ * An iOS-only server, or one told to use real Android devices only
479
+ * (`androidDeviceType: real`, which discovery would then ignore them for),
480
+ * boots none. Anything else boots them, `platform: both` included: it used
481
+ * to boot only when `platform` named Android, so with the default it did
482
+ * nothing. A boot that fails is logged and the rest go on: an emulator that
483
+ * won't start must not keep the server from coming up with the phones it
484
+ * has, and with `both` counted this step now runs on many more servers.
485
+ */
417
486
  bootEmulators(pluginArgs) {
418
487
  return __awaiter(this, void 0, void 0, function* () {
419
- if (pluginArgs.emulators &&
420
- pluginArgs.emulators.length > 0 &&
421
- pluginArgs.platform.toLowerCase().includes('android')) {
422
- this.logger.info('Emulators will be booted!!');
423
- const adb = yield appium_adb_1.ADB.createADB({});
424
- const array = pluginArgs.emulators || [];
425
- yield Promise.all(array.map((arr) => adb.launchAVD(arr.avdName, arr)));
488
+ const emulators = pluginArgs.emulators || [];
489
+ if (emulators.length === 0)
490
+ return;
491
+ const platform = String(pluginArgs.platform).toLowerCase();
492
+ if (platform === 'ios') {
493
+ this.logger.warn(`Not booting the ${emulators.length} configured emulator(s): platform is ios.`);
494
+ return;
495
+ }
496
+ if (pluginArgs.androidDeviceType === 'real') {
497
+ this.logger.warn(`Not booting the ${emulators.length} configured emulator(s): androidDeviceType is real.`);
498
+ return;
426
499
  }
500
+ this.logger.info(`Booting ${emulators.length} configured emulator(s)...`);
501
+ let adb;
502
+ try {
503
+ adb = yield appium_adb_1.ADB.createADB({});
504
+ }
505
+ catch (err) {
506
+ this.logger.warn(`Not booting the configured emulators: no usable adb (${err === null || err === void 0 ? void 0 : err.message}).`);
507
+ return;
508
+ }
509
+ const results = yield Promise.allSettled(emulators.map((emulator) => adb.launchAVD(emulator.avdName, emulator)));
510
+ results.forEach((result, index) => {
511
+ var _a, _b;
512
+ if (result.status === 'rejected') {
513
+ this.logger.warn(`Could not boot emulator ${emulators[index].avdName}: ${(_b = (_a = result.reason) === null || _a === void 0 ? void 0 : _a.message) !== null && _b !== void 0 ? _b : result.reason}`);
514
+ }
515
+ });
427
516
  });
428
517
  }
429
518
  registerDependenciesInContainer(pluginArgs, cliArgs, nodeId) {
@@ -98,6 +98,8 @@ const SessionStatus_1 = require("../types/SessionStatus");
98
98
  const SessionType_1 = __importDefault(require("../enums/SessionType"));
99
99
  const async_lock_1 = __importDefault(require("async-lock"));
100
100
  const base_driver_1 = require("@appium/base-driver");
101
+ const PhoneNetworkRestore_1 = require("./network/PhoneNetworkRestore");
102
+ const interceptorOptions_1 = require("./interceptor/interceptorOptions");
101
103
  /**
102
104
  * How long the hub waits for a node (or cloud provider) to create a session.
103
105
  * A create is sent once (InternalRequestConfig `retry: false`): the node may
@@ -222,9 +224,7 @@ let SessionLifecycleService = class SessionLifecycleService {
222
224
  // Who asked, for the queue's team scoping (queueVisibility). On this row
223
225
  // only, never in `caps`, and last so a client can't supply its own.
224
226
  [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* () {
227
+ const allocate = () => __awaiter(this, void 0, void 0, function* () {
228
228
  var _a;
229
229
  try {
230
230
  return yield (0, device_utils_1.allocateDeviceForSession)(caps, pluginArgs.deviceAvailabilityTimeoutMs, pluginArgs.deviceAvailabilityQueryIntervalMs, pluginArgs, sessionTeamIds, {
@@ -238,7 +238,21 @@ let SessionLifecycleService = class SessionLifecycleService {
238
238
  yield (0, pending_sessions_service_1.removePendingSession)(pendingSessionId);
239
239
  throw err;
240
240
  }
241
- }));
241
+ });
242
+ // Creates for a platform queue on one lock, first come first served, and
243
+ // each has its full deviceAvailabilityTimeoutMs from the head of the
244
+ // queue. A lease-bound session allocates nothing (its phone is the
245
+ // lease's) and doesn't queue: behind the lock it waited for every create
246
+ // ahead of it, each of which may wait minutes for a busy phone.
247
+ let device;
248
+ if ((0, leaseSessionCaps_1.leaseIdOf)(caps)) {
249
+ device = yield allocate();
250
+ }
251
+ else {
252
+ const lockName = this.getLockName(caps);
253
+ this.logger.debug(`📱 Acquiring lock: ${lockName}`);
254
+ device = yield commandsQueueGuard.acquire(lockName, allocate);
255
+ }
242
256
  yield (0, device_service_1.updateDeviceProgress)(device.udid, device.host, 'Allocating node resources...');
243
257
  this.logger.debug(`device.host: ${device.host} and pluginArgs.bindHostOrIp: ${pluginArgs.bindHostOrIp}`);
244
258
  const allocation = {
@@ -865,26 +879,11 @@ let SessionLifecycleService = class SessionLifecycleService {
865
879
  applyPostSessionLogic(session, caps, device) {
866
880
  return __awaiter(this, void 0, void 0, function* () {
867
881
  const context = typedi_1.Container.get(PluginContext_1.PluginContext);
868
- const networkProfile = caps[XenonCapabilityManager_1.XENON_CAPABILITIES.NETWORK_PROFILE];
869
- if (networkProfile) {
870
- const { NetworkConditioningService } = yield Promise.resolve().then(() => __importStar(require('./NetworkConditioningService')));
871
- yield typedi_1.Container.get(NetworkConditioningService).applyProfile(session.getId(), device, networkProfile);
872
- }
873
- if (caps[XenonCapabilityManager_1.XENON_CAPABILITIES.INTERCEPTOR_ENABLED]) {
874
- try {
875
- const { InterceptorService } = yield Promise.resolve().then(() => __importStar(require('./InterceptorService')));
876
- yield typedi_1.Container.get(InterceptorService).start(session.getId(), device, {
877
- enabled: true,
878
- bufferSize: caps[XenonCapabilityManager_1.XENON_CAPABILITIES.INTERCEPTOR_BUFFER_SIZE],
879
- captureBodies: caps[XenonCapabilityManager_1.XENON_CAPABILITIES.INTERCEPTOR_CAPTURE_BODIES] !== false,
880
- mocks: caps[XenonCapabilityManager_1.XENON_CAPABILITIES.INTERCEPTOR_MOCKS] || [],
881
- includeHosts: caps[XenonCapabilityManager_1.XENON_CAPABILITIES.INTERCEPTOR_INCLUDE_HOSTS] || [],
882
- excludeHosts: caps[XenonCapabilityManager_1.XENON_CAPABILITIES.INTERCEPTOR_EXCLUDE_HOSTS] || [],
883
- });
884
- }
885
- catch (err) {
886
- this.logger.warn(`🕸️ Failed to start interceptor for ${session.getId()}: ${err.message}`);
887
- }
882
+ // Only the server that drives the phone changes its network: a node
883
+ // applies its own sessions' profile and interceptor, and a hub that did
884
+ // it too ran its own adb against the node's phone.
885
+ if (session.getType() === SessionType_1.default.LOCAL) {
886
+ yield this.applySessionNetwork(session.getId(), caps, device, context.pluginArgs);
888
887
  }
889
888
  const isDashboardEnabled = !!context.pluginArgs.enableDashboard;
890
889
  const shouldSaveLogs = session.getType() !== SessionType_1.default.CLOUD;
@@ -918,6 +917,36 @@ let SessionLifecycleService = class SessionLifecycleService {
918
917
  }
919
918
  });
920
919
  }
920
+ /**
921
+ * The session's network profile and network capture, on a phone this
922
+ * server drives. First what an earlier session left on the phone and
923
+ * couldn't put back is undone, so this session starts from the phone's own
924
+ * settings and records those as the ones to put back. Each ending undoes
925
+ * these through PhoneNetworkRestore.
926
+ */
927
+ applySessionNetwork(sessionId, caps, device, pluginArgs) {
928
+ return __awaiter(this, void 0, void 0, function* () {
929
+ var _a;
930
+ if (((_a = device.platform) === null || _a === void 0 ? void 0 : _a.toLowerCase()) === 'android') {
931
+ yield typedi_1.Container.get(PhoneNetworkRestore_1.PhoneNetworkRestore).restoreLeftovers({ udid: device.udid });
932
+ }
933
+ const networkProfile = caps[XenonCapabilityManager_1.XENON_CAPABILITIES.NETWORK_PROFILE];
934
+ if (networkProfile) {
935
+ const { NetworkConditioningService } = yield Promise.resolve().then(() => __importStar(require('./NetworkConditioningService')));
936
+ yield typedi_1.Container.get(NetworkConditioningService).applyProfile(sessionId, device, networkProfile);
937
+ }
938
+ const interceptor = (0, interceptorOptions_1.resolveInterceptorOptions)(caps, pluginArgs.interceptor);
939
+ if (interceptor.enabled) {
940
+ try {
941
+ const { InterceptorService } = yield Promise.resolve().then(() => __importStar(require('./InterceptorService')));
942
+ yield typedi_1.Container.get(InterceptorService).start(sessionId, device, interceptor);
943
+ }
944
+ catch (err) {
945
+ this.logger.warn(`🕸️ Failed to start interceptor for ${sessionId}: ${err.message}`);
946
+ }
947
+ }
948
+ });
949
+ }
921
950
  /**
922
951
  * The row a hub keeps for a session it routes to a node or a cloud when the
923
952
  * dashboard's own (onSessionStarted) isn't written: with the dashboard off,
@@ -1085,6 +1114,10 @@ let SessionLifecycleService = class SessionLifecycleService {
1085
1114
  // which releases ports and archives video twice.
1086
1115
  if (sessionId) {
1087
1116
  yield sessionCleanupLock.acquire(sessionId, () => __awaiter(this, void 0, void 0, function* () {
1117
+ // The phone's network (profile, interceptor proxy) and the capture,
1118
+ // before the phone is released to the next session. Whether or not
1119
+ // SESSION_MANAGER holds the session.
1120
+ yield typedi_1.Container.get(PhoneNetworkRestore_1.PhoneNetworkRestore).restoreSession(sessionId, 'session deleted');
1088
1121
  yield (0, device_service_1.releaseSessionDevices)(sessionId);
1089
1122
  this.logger.info(`📱 Unblocking the device that is blocked for session ${sessionId}`);
1090
1123
  const session = SessionManager_1.SESSION_MANAGER.getSession(sessionId);
@@ -1125,37 +1158,10 @@ let SessionLifecycleService = class SessionLifecycleService {
1125
1158
  // Another concurrent deleteSession already cleaned up.
1126
1159
  return;
1127
1160
  }
1128
- const device = session.getDevice();
1129
- try {
1130
- const { NetworkConditioningService } = yield Promise.resolve().then(() => __importStar(require('./NetworkConditioningService')));
1131
- yield typedi_1.Container.get(NetworkConditioningService).reset(sessionId, device);
1132
- }
1133
- catch (resetErr) {
1134
- this.logger.warn(`⚠️ NetworkConditioningService.reset failed for session ${sessionId}: ${resetErr.message}`);
1135
- }
1136
- try {
1137
- const { InterceptorService } = yield Promise.resolve().then(() => __importStar(require('./InterceptorService')));
1138
- const interceptor = typedi_1.Container.get(InterceptorService);
1139
- if (interceptor.isActive(sessionId))
1140
- yield interceptor.stop(sessionId);
1141
- }
1142
- catch (interceptorErr) {
1143
- this.logger.warn(`⚠️ InterceptorService.stop failed for session ${sessionId}: ${interceptorErr.message}`);
1144
- }
1161
+ // The phone's network was put back before it was released (above).
1145
1162
  yield event_manager_1.DASHBORD_EVENT_MANAGER.onSessionStopped(sessionId, status, reason);
1146
1163
  SessionManager_1.SESSION_MANAGER.removeSession(sessionId);
1147
1164
  typedi_1.Container.get(hubSessionToken_1.HubSessionTokenIssuer).forget(sessionId);
1148
- try {
1149
- const { getSessionById } = yield Promise.resolve().then(() => __importStar(require('../dashboard/services/session-service')));
1150
- const sessionData = yield getSessionById(sessionId);
1151
- if (sessionData && (sessionData.status === 'failed' || sessionData.failure_reason)) {
1152
- const { NotificationService } = yield Promise.resolve().then(() => __importStar(require('./NotificationService')));
1153
- yield typedi_1.Container.get(NotificationService).dispatchEvent('session_failed', sessionData);
1154
- }
1155
- }
1156
- catch (err) {
1157
- /* ignore notification errors */
1158
- }
1159
1165
  }));
1160
1166
  }
1161
1167
  }
@@ -1228,6 +1234,7 @@ let SessionLifecycleService = class SessionLifecycleService {
1228
1234
  stopSessionForShutdown(sessionId, reason) {
1229
1235
  return __awaiter(this, void 0, void 0, function* () {
1230
1236
  yield sessionCleanupLock.acquire(sessionId, () => __awaiter(this, void 0, void 0, function* () {
1237
+ yield typedi_1.Container.get(PhoneNetworkRestore_1.PhoneNetworkRestore).restoreSession(sessionId, reason);
1231
1238
  try {
1232
1239
  yield (0, device_service_1.releaseSessionDevices)(sessionId);
1233
1240
  }
@@ -1246,7 +1253,10 @@ let SessionLifecycleService = class SessionLifecycleService {
1246
1253
  }
1247
1254
  }
1248
1255
  try {
1249
- yield event_manager_1.DASHBORD_EVENT_MANAGER.onSessionStopped(sessionId, SessionStatus_1.SessionStatus.FAILED, reason);
1256
+ // A shutdown fails the sessions it drains; no test caused it, so no webhook.
1257
+ yield event_manager_1.DASHBORD_EVENT_MANAGER.onSessionStopped(sessionId, SessionStatus_1.SessionStatus.FAILED, reason, {
1258
+ notify: false,
1259
+ });
1250
1260
  }
1251
1261
  catch (err) {
1252
1262
  this.logger.warn(`[shutdown] onSessionStopped failed for ${sessionId}: ${err.message}`);
@@ -24,6 +24,7 @@ const logger_1 = __importDefault(require("../logger"));
24
24
  const SessionManager_1 = require("../sessions/SessionManager");
25
25
  const SessionLifecycleService_1 = require("./SessionLifecycleService");
26
26
  const SessionType_1 = __importDefault(require("../enums/SessionType"));
27
+ const PhoneNetworkRestore_1 = require("./network/PhoneNetworkRestore");
27
28
  // Graceful shutdown phase that runs BEFORE the existing infrastructure
28
29
  // teardown in index.ts:cleanup(). Active sessions get a bounded chance to
29
30
  // archive their video + release ports + mark themselves failed, so we don't
@@ -57,6 +58,7 @@ let ShutdownCoordinator = class ShutdownCoordinator {
57
58
  const sessions = SessionManager_1.SESSION_MANAGER.getAllSessions().filter((session) => session.getType() === SessionType_1.default.LOCAL);
58
59
  if (sessions.length === 0) {
59
60
  this.logger.info('No active sessions to drain');
61
+ yield this.restorePhoneNetworks(timeoutMs);
60
62
  return { attempted: 0, completed: 0 };
61
63
  }
62
64
  this.logger.info(`Draining ${sessions.length} active session(s) (overall=${timeoutMs}ms, per-session=${perSessionTimeoutMs}ms)`);
@@ -84,10 +86,41 @@ let ShutdownCoordinator = class ShutdownCoordinator {
84
86
  }));
85
87
  const results = yield Promise.allSettled(tasks);
86
88
  const completed = results.filter((r) => r.status === 'fulfilled' && r.value).length;
89
+ yield this.restorePhoneNetworks(Math.max(0, deadline - Date.now()));
87
90
  this.logger.info(`Drain complete: ${completed}/${sessions.length} session(s) finalized`);
88
91
  return { attempted: sessions.length, completed };
89
92
  });
90
93
  }
94
+ /**
95
+ * The network of every phone a session of this server still has changed
96
+ * (profile, interceptor proxy), sessions SESSION_MANAGER doesn't hold
97
+ * included, within what is left of the budget. A shutdown that never gets
98
+ * here (Appium exits on SIGTERM first) leaves it to the next start, from
99
+ * the ledger (PhoneNetworkRestore.cleanUpAtBoot).
100
+ */
101
+ restorePhoneNetworks(budgetMs) {
102
+ return __awaiter(this, void 0, void 0, function* () {
103
+ var _a;
104
+ if (budgetMs <= 0)
105
+ return;
106
+ let timer;
107
+ try {
108
+ yield Promise.race([
109
+ typedi_1.Container.get(PhoneNetworkRestore_1.PhoneNetworkRestore).restoreAll('Xenon is shutting down'),
110
+ new Promise((resolve) => {
111
+ timer = setTimeout(resolve, budgetMs);
112
+ }),
113
+ ]);
114
+ }
115
+ catch (err) {
116
+ this.logger.warn(`[shutdown] Putting phone networks back failed: ${(_a = err === null || err === void 0 ? void 0 : err.message) !== null && _a !== void 0 ? _a : err}`);
117
+ }
118
+ finally {
119
+ if (timer)
120
+ clearTimeout(timer);
121
+ }
122
+ });
123
+ }
91
124
  };
92
125
  exports.ShutdownCoordinator = ShutdownCoordinator;
93
126
  exports.ShutdownCoordinator = ShutdownCoordinator = __decorate([
@@ -52,20 +52,37 @@ let TeamService = class TeamService {
52
52
  delete(id) {
53
53
  return __awaiter(this, void 0, void 0, function* () {
54
54
  // Apps too: App.teamId is onDelete SetNull, so deleting a team that owns
55
- // apps would quietly share them with every member.
56
- const [devices, members, apps] = yield Promise.all([
55
+ // apps would quietly share them with every member. So is a phone's saved
56
+ // team (DeviceSetting): a phone of the team that is not connected now
57
+ // would come back in the shared pool.
58
+ const [connected, saved, members, apps] = yield Promise.all([
57
59
  prisma_1.prisma.device.count({ where: { teamId: id } }),
60
+ prisma_1.prisma.deviceSetting.findMany({ where: { teamId: id }, select: { udid: true, host: true } }),
58
61
  prisma_1.prisma.teamMember.count({ where: { teamId: id } }),
59
62
  prisma_1.prisma.app.count({ where: { teamId: id } }),
60
63
  ]);
64
+ const away = yield this.notConnected(saved);
65
+ const devices = connected + away;
61
66
  if (devices > 0 || members > 0 || apps > 0) {
62
- throw new Error(`Team still has ${devices} device(s), ${members} member(s) and ${apps} app(s). Reassign them before deleting.`);
67
+ const phones = away > 0 ? `${devices} device(s) (${away} not connected now)` : `${devices} device(s)`;
68
+ throw new Error(`Team still has ${phones}, ${members} member(s) and ${apps} app(s). Reassign them before deleting.`);
63
69
  }
64
70
  // Clear teamId on any (revoked) ApiKey rows so the FK doesn't block delete.
65
71
  yield prisma_1.prisma.apiKey.updateMany({ where: { teamId: id }, data: { teamId: null } });
66
72
  yield prisma_1.prisma.team.delete({ where: { id } });
67
73
  });
68
74
  }
75
+ /** How many of these saved phones have no Device row: not connected now. */
76
+ notConnected(saved) {
77
+ return __awaiter(this, void 0, void 0, function* () {
78
+ if (saved.length === 0)
79
+ return 0;
80
+ const here = yield prisma_1.prisma.device.count({
81
+ where: { OR: saved.map(({ udid, host }) => ({ udid, host })) },
82
+ });
83
+ return saved.length - here;
84
+ });
85
+ }
69
86
  listMembers(teamId) {
70
87
  return __awaiter(this, void 0, void 0, function* () {
71
88
  const rows = yield prisma_1.prisma.teamMember.findMany({
@@ -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)
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.isSelfManualLock = isSelfManualLock;
4
4
  exports.isOwnSession = isOwnSession;
5
+ exports.isLeaseHolder = isLeaseHolder;
5
6
  exports.evaluateDeviceAccess = evaluateDeviceAccess;
6
7
  exports.ownershipUnavailableBody = ownershipUnavailableBody;
7
8
  exports.denyBody = denyBody;
@@ -23,10 +24,39 @@ function isOwnSession(sessionOwnerUserId, actorUserId) {
23
24
  const owner = sessionOwnerUserId !== null && sessionOwnerUserId !== void 0 ? sessionOwnerUserId : '';
24
25
  return !!(owner && actorUserId && owner === actorUserId);
25
26
  }
27
+ /**
28
+ * True when the caller holds the lease: by the key that created it, or as
29
+ * the user behind it (any credential of theirs, the dashboard included), as
30
+ * ownership is keyed on the user everywhere else.
31
+ */
32
+ function isLeaseHolder(lease, actorUserId, actorApiKeyId) {
33
+ if (!lease)
34
+ return false;
35
+ if (actorApiKeyId && lease.actorId === actorApiKeyId)
36
+ return true;
37
+ return !!actorUserId && (lease.actorId === actorUserId || lease.holderUserId === actorUserId);
38
+ }
26
39
  function evaluateDeviceAccess(input) {
27
- var _a;
40
+ var _a, _b;
28
41
  if (input.isAdmin)
29
42
  return ALLOW;
43
+ if (input.lease) {
44
+ if (isLeaseHolder(input.lease, input.actorUserId, input.actorApiKeyId))
45
+ return ALLOW;
46
+ // A session on a leased phone proved it may use the lease; its owner
47
+ // keeps driving it, as with any session of their own.
48
+ if (input.sessionId &&
49
+ !(0, manualLock_1.isManualLock)(input.sessionId) &&
50
+ isOwnSession(input.sessionOwnerUserId, input.actorUserId)) {
51
+ return ALLOW;
52
+ }
53
+ return {
54
+ allow: false,
55
+ code: 'device_held_by_another_user',
56
+ holderId: (_a = input.lease.holderUserId) !== null && _a !== void 0 ? _a : '',
57
+ heldBy: 'lease',
58
+ };
59
+ }
30
60
  if (!input.busy)
31
61
  return ALLOW;
32
62
  const asUser = (0, manualLock_1.inspectManualLock)(input.sessionId, input.actorUserId, input.udid);
@@ -42,7 +72,7 @@ function evaluateDeviceAccess(input) {
42
72
  }
43
73
  // Not a manual lock: a real Appium session holds the device (or the row is
44
74
  // busy with no id at all, which is an inconsistent state we fail closed on).
45
- const owner = (_a = input.sessionOwnerUserId) !== null && _a !== void 0 ? _a : '';
75
+ const owner = (_b = input.sessionOwnerUserId) !== null && _b !== void 0 ? _b : '';
46
76
  if (isOwnSession(input.sessionOwnerUserId, input.actorUserId))
47
77
  return ALLOW;
48
78
  return { allow: false, code: 'device_in_use_by_session', holderId: owner };
@@ -62,11 +92,13 @@ function ownershipUnavailableBody() {
62
92
  message: 'Could not verify device ownership. Try again.',
63
93
  };
64
94
  }
65
- function denyBody(code, holderId, holderName) {
95
+ function denyBody(code, holderId, holderName, heldBy) {
66
96
  const who = holderName || 'another user';
67
- const message = code === 'device_held_by_another_user'
68
- ? `Device is being controlled by ${who}. Ask them to release it, or use an admin key to force-release.`
69
- : `Device is in use by an Appium session owned by ${who}.`;
97
+ const message = heldBy === 'lease'
98
+ ? `Device is leased by ${who} through the SDK. It is free again when their lease ends.`
99
+ : code === 'device_held_by_another_user'
100
+ ? `Device is being controlled by ${who}. Ask them to release it, or use an admin key to force-release.`
101
+ : `Device is in use by an Appium session owned by ${who}.`;
70
102
  return {
71
103
  success: false,
72
104
  error: code,
@@ -0,0 +1,26 @@
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
+ Object.defineProperty(exports, "__esModule", { value: true });
12
+ exports.leaseHoldFor = leaseHoldFor;
13
+ /**
14
+ * The lease hold on a device row for an ownership decision, or null. Throws
15
+ * when either lookup does: the caller fails closed.
16
+ */
17
+ function leaseHoldFor(device, findActiveLease, resolveLeaseHolder) {
18
+ return __awaiter(this, void 0, void 0, function* () {
19
+ if (!(device === null || device === void 0 ? void 0 : device.udid) || !device.host)
20
+ return null;
21
+ const lease = yield findActiveLease(device.udid, device.host);
22
+ if (!lease)
23
+ return null;
24
+ return { actorId: lease.actorId, holderUserId: yield resolveLeaseHolder(lease.actorId) };
25
+ });
26
+ }
@@ -13,6 +13,7 @@ exports.makeTicketActorAuthorizer = makeTicketActorAuthorizer;
13
13
  const manualLock_1 = require("../recording/manualLock");
14
14
  const deviceAccessPolicy_1 = require("./deviceAccessPolicy");
15
15
  const deviceClaims_1 = require("../../data-service/deviceClaims");
16
+ const leaseHold_1 = require("./leaseHold");
16
17
  /**
17
18
  * Build the ownership check for a caller authenticated by a stream ticket.
18
19
  *
@@ -53,6 +54,10 @@ function makeTicketActorAuthorizer(deps) {
53
54
  const sessionOwnerUserId = device.busy && device.session_id && !(0, manualLock_1.isManualLock)(device.session_id)
54
55
  ? yield deps.resolveSessionOwner(device.session_id)
55
56
  : null;
57
+ // As deviceAccessGuard: a leased phone is its lease holder's.
58
+ const lease = deps.findActiveLease && deps.resolveLeaseHolder && !actor.isAdmin
59
+ ? yield (0, leaseHold_1.leaseHoldFor)({ udid, host: device.host }, deps.findActiveLease, deps.resolveLeaseHolder)
60
+ : null;
56
61
  return (0, deviceAccessPolicy_1.evaluateDeviceAccess)({
57
62
  udid,
58
63
  // heldHere, as deviceAccessGuard reads it.
@@ -63,6 +68,7 @@ function makeTicketActorAuthorizer(deps) {
63
68
  actorUserId: actor.actorId,
64
69
  actorApiKeyId: actor.apiKeyId,
65
70
  isAdmin: !!actor.isAdmin,
71
+ lease,
66
72
  }).allow;
67
73
  });
68
74
  };