@xenon-device-management/xenon 2.0.0 → 2.1.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 (279) hide show
  1. package/README.md +2 -1
  2. package/lib/package.json +1 -2
  3. package/lib/public/assets/{AnnotationOverlay-BmTPCAds.js → AnnotationOverlay-C-Pw_hvw.js} +1 -1
  4. package/lib/public/assets/{ApiKeyGate-B5eB7f8B.js → ApiKeyGate-DmYQ0oSk.js} +1 -1
  5. package/lib/public/assets/BugReportButton-n8T1dFUp.js +10 -0
  6. package/lib/public/assets/{DeviceMosaicView-9DUoZnQi.js → DeviceMosaicView-D-rGOBbJ.js} +1 -1
  7. package/lib/public/assets/{EmptyState-lvyNiXev.js → EmptyState-DExETevp.js} +1 -1
  8. package/lib/public/assets/{FieldGroup-BduMPM9K.js → FieldGroup-CCdFlZm6.js} +1 -1
  9. package/lib/public/assets/{FilterMenu-BSqX6I94.js → FilterMenu-DiUSOSL-.js} +1 -1
  10. package/lib/public/assets/{Menu-Ba5cT7jZ.js → Menu-ZOfpH6gh.js} +1 -1
  11. package/lib/public/assets/{Modal-CceoMY7D.js → Modal-CZNtoB-1.js} +1 -1
  12. package/lib/public/assets/{RecordingPage-6T9dOidc.js → RecordingPage-BKp6h8Jl.js} +1 -1
  13. package/lib/public/assets/{RecordingsPage-qj6FdZXR.js → RecordingsPage-Bf881FX4.js} +1 -1
  14. package/lib/public/assets/{SegmentedControl-CSRuZ8TR.js → SegmentedControl-EUoyizvW.js} +1 -1
  15. package/lib/public/assets/{SettingCard-Bp1kz4By.js → SettingCard-ClT4hf4_.js} +1 -1
  16. package/lib/public/assets/{Table-LV7zFyZg.js → Table-ucR4bnmc.js} +1 -1
  17. package/lib/public/assets/{activity-D17TUloi.js → activity-K-hdylwq.js} +1 -1
  18. package/lib/public/assets/{ai-settings-qvbhY89P.js → ai-settings-BLhdwI3c.js} +1 -1
  19. package/lib/public/assets/{api-keys-BfpvdG7G.js → api-keys-BbiN7rBI.js} +1 -1
  20. package/lib/public/assets/{apps-Dbnzd0fW.js → apps-Dez1petz.js} +1 -1
  21. package/lib/public/assets/{arrow-left-DD1VjtC7.js → arrow-left-UjmFHLAJ.js} +1 -1
  22. package/lib/public/assets/{arrow-right-D0pnM3ma.js → arrow-right-BVyEigbu.js} +1 -1
  23. package/lib/public/assets/{arrow-up-right-BIC8JOV6.js → arrow-up-right-B-ZlBof3.js} +1 -1
  24. package/lib/public/assets/{auth-shell-DWHPXSnI.js → auth-shell-DAQjAgUv.js} +2 -2
  25. package/lib/public/assets/{builds-page-ClCNBUv_.js → builds-page-CmIPa5JK.js} +1 -1
  26. package/lib/public/assets/{button-xtAhYR-f.js → button-Dd-IhMC1.js} +1 -1
  27. package/lib/public/assets/{calendar-CupwJ7Bm.js → calendar-CAIeJwa5.js} +1 -1
  28. package/lib/public/assets/{check-CyuEoKkd.js → check-C4Jrjg8f.js} +1 -1
  29. package/lib/public/assets/{chevron-left-DgX-F9xK.js → chevron-left-BGzlf-4i.js} +1 -1
  30. package/lib/public/assets/{chevron-right-BKJgJnU9.js → chevron-right-_wYqDjDC.js} +1 -1
  31. package/lib/public/assets/{circle-check-CZdg8mMa.js → circle-check-Ckn00yMB.js} +1 -1
  32. package/lib/public/assets/{circle-x-CZH6t9z-.js → circle-x-pdVkomLa.js} +1 -1
  33. package/lib/public/assets/{clock-Cd-t7xpe.js → clock-B8vmwkHj.js} +1 -1
  34. package/lib/public/assets/{copy-C6zerX3I.js → copy-BAqnFI-F.js} +1 -1
  35. package/lib/public/assets/{device-explorer-B1pI6b_M.js → device-explorer-CYq_4F_N.js} +3 -3
  36. package/lib/public/assets/device-explorer-D2AqrrLN.css +1 -0
  37. package/lib/public/assets/{download-x9WP7mcL.js → download-B6UEAp_1.js} +1 -1
  38. package/lib/public/assets/{forgot-password-B0TEH4N-.js → forgot-password-CLqNvmrN.js} +1 -1
  39. package/lib/public/assets/{funnel-Cb40MfFN.js → funnel-Da90BBbT.js} +1 -1
  40. package/lib/public/assets/{index-BR1wTIPC.js → index-BuIIcmGp.js} +2 -2
  41. package/lib/public/assets/{index-BvH_ekRy.js → index-MX7IzqyW.js} +1 -1
  42. package/lib/public/assets/{input-DejcCiTI.js → input-Bq9aY-We.js} +1 -1
  43. package/lib/public/assets/{keycodes-Mavx5iGg.js → keycodes-CIhXIJyP.js} +1 -1
  44. package/lib/public/assets/{layers-BKqYLiHE.js → layers-bXjpswQI.js} +1 -1
  45. package/lib/public/assets/{lock-LFsFhc17.js → lock-pJ72y5Fy.js} +1 -1
  46. package/lib/public/assets/{login-x2pkzt-t.js → login-UOcHALV2.js} +1 -1
  47. package/lib/public/assets/{maintenance-settings-CYafcoV-.js → maintenance-settings-XnJgp5yb.js} +1 -1
  48. package/lib/public/assets/{monitor-_7_uJ8d9.js → monitor-QkAhI1Jx.js} +1 -1
  49. package/lib/public/assets/{mouse-pointer-2-BfLuURKN.js → mouse-pointer-2-B42Da0m4.js} +1 -1
  50. package/lib/public/assets/{network-Z_zBhpsD.js → network-CKVqcyp6.js} +1 -1
  51. package/lib/public/assets/{overview-BY5uv08S.js → overview-BORGkN3N.js} +1 -1
  52. package/lib/public/assets/{page-header-CP2UkykG.js → page-header-CKWFOjOI.js} +1 -1
  53. package/lib/public/assets/{plus-BTD0dnQ0.js → plus-BpFKVdC8.js} +1 -1
  54. package/lib/public/assets/{profile-page-BhJZuCS7.js → profile-page-CcbKBicY.js} +1 -1
  55. package/lib/public/assets/{reset-password-CMN0SO81.js → reset-password-BA0W9bxl.js} +1 -1
  56. package/lib/public/assets/{runbook-page-CBpPX9_5.js → runbook-page-Cp7ZBq3s.js} +1 -1
  57. package/lib/public/assets/{select-Uc9E5pye.js → select-DIZWIoVR.js} +1 -1
  58. package/lib/public/assets/{selector-detail-page-BSyBnRuU.js → selector-detail-page-C_4YuvAp.js} +1 -1
  59. package/lib/public/assets/{selector-health-DzqxNkzm.js → selector-health-CXjD_Xth.js} +1 -1
  60. package/lib/public/assets/{selector-health-page-CyPLjoD1.js → selector-health-page-CTqIHeoG.js} +1 -1
  61. package/lib/public/assets/{session-detail-page-BZQZN1P-.js → session-detail-page-B0KYkk8z.js} +1 -1
  62. package/lib/public/assets/{settings-CkSWiZq8.js → settings-BcRBKUXd.js} +1 -1
  63. package/lib/public/assets/{status-pill-outline-BFYni2qo.js → status-pill-outline-Dh2PXSf_.js} +1 -1
  64. package/lib/public/assets/{tablet-BSd-xSZK.js → tablet-_sXqZztt.js} +1 -1
  65. package/lib/public/assets/{teams-PpoEQz31.js → teams-DGHT8MKm.js} +1 -1
  66. package/lib/public/assets/{trash-2-BorJv5_u.js → trash-2-BV88cV3Z.js} +1 -1
  67. package/lib/public/assets/{upload-C3i7sAZl.js → upload-CIDH9wkj.js} +1 -1
  68. package/lib/public/assets/{use-password-reset-mode-CeZQ3EsH.js → use-password-reset-mode-DYGuSNwl.js} +1 -1
  69. package/lib/public/assets/{users-CEJ3fC78.js → users-7yviqflc.js} +1 -1
  70. package/lib/public/assets/{users-Drd7ifJx.js → users-BUE7MoTq.js} +1 -1
  71. package/lib/public/assets/{video-off-CNzn6bPE.js → video-off-BTRxtlGo.js} +1 -1
  72. package/lib/public/assets/{webhook-settings-BFqX6QXV.js → webhook-settings-DMKD9Pgm.js} +1 -1
  73. package/lib/public/assets/{zap-4yN8Jdfw.js → zap-CWE94qcK.js} +1 -1
  74. package/lib/public/index.html +1 -1
  75. package/lib/src/InternalHttpClient.js +5 -1
  76. package/lib/src/XenonCapabilityManager.js +15 -1
  77. package/lib/src/app/appiumBasePath.js +32 -0
  78. package/lib/src/app/index.js +12 -0
  79. package/lib/src/app/insertBeforeRoutes.js +24 -4
  80. package/lib/src/app/registerCommandAuth.js +152 -33
  81. package/lib/src/app/routers/control.js +57 -13
  82. package/lib/src/app/routers/grid.js +28 -8
  83. package/lib/src/app/ws/h264StreamWs.js +13 -8
  84. package/lib/src/app/ws/logcatWs.js +12 -7
  85. package/lib/src/app/ws/upgradeRouter.js +215 -0
  86. package/lib/src/dashboard/event-manager.js +7 -6
  87. package/lib/src/data-service/device-service.js +152 -49
  88. package/lib/src/data-service/device-store.js +81 -10
  89. package/lib/src/data-service/deviceClaims.js +98 -0
  90. package/lib/src/data-service/deviceFieldOwners.js +32 -10
  91. package/lib/src/data-service/prisma-store.js +94 -31
  92. package/lib/src/device-managers/AndroidDeviceManager.js +13 -15
  93. package/lib/src/device-managers/HealthMonitorService.js +17 -2
  94. package/lib/src/device-managers/IOSDeviceManager.js +5 -1
  95. package/lib/src/device-managers/NodeDevices.js +7 -2
  96. package/lib/src/device-managers/android/AndroidH264StreamService.js +3 -3
  97. package/lib/src/device-managers/android/AndroidStreamService.js +6 -5
  98. package/lib/src/device-managers/android/previewHold.js +2 -2
  99. package/lib/src/device-managers/ios/IOSDiscoveryService.js +19 -8
  100. package/lib/src/device-managers/ios/IOSStreamService.js +158 -38
  101. package/lib/src/device-managers/localDeviceHosts.js +86 -0
  102. package/lib/src/device-managers/ownDeviceRow.js +38 -0
  103. package/lib/src/device-utils.js +78 -24
  104. package/lib/src/gateway/createHandoff.js +73 -0
  105. package/lib/src/gateway/defaultGateway.js +80 -0
  106. package/lib/src/gateway/forwardToNode.js +147 -0
  107. package/lib/src/gateway/hubSessionToken.js +356 -0
  108. package/lib/src/gateway/internalCall.js +90 -0
  109. package/lib/src/gateway/nodeSessionStatus.js +140 -0
  110. package/lib/src/gateway/nodeWebDriverUrl.js +139 -0
  111. package/lib/src/gateway/sessionCreate.js +136 -0
  112. package/lib/src/gateway/sessionGateway.js +201 -0
  113. package/lib/src/gateway/sessionLocator.js +100 -0
  114. package/lib/src/generated/client/edge.js +7 -4
  115. package/lib/src/generated/client/index-browser.js +4 -1
  116. package/lib/src/generated/client/index.d.ts +112 -1
  117. package/lib/src/generated/client/index.js +7 -4
  118. package/lib/src/generated/client/package.json +1 -1
  119. package/lib/src/generated/client/schema.prisma +9 -0
  120. package/lib/src/generated/client/wasm.js +4 -1
  121. package/lib/src/helpers/index.js +5 -1
  122. package/lib/src/logger.js +5 -0
  123. package/lib/src/middleware/authMiddleware.js +80 -0
  124. package/lib/src/middleware/commandAuth.js +31 -17
  125. package/lib/src/middleware/csrfMiddleware.js +3 -2
  126. package/lib/src/middleware/sessionListingFilter.js +153 -0
  127. package/lib/src/middleware/sessionUpgradeGuard.js +203 -0
  128. package/lib/src/plugin.js +27 -1
  129. package/lib/src/prisma.js +2 -1
  130. package/lib/src/services/DeviceReconciler.js +5 -2
  131. package/lib/src/services/OrphanSweeper.js +12 -19
  132. package/lib/src/services/PortAllocator.js +75 -16
  133. package/lib/src/services/ServerManager.js +50 -30
  134. package/lib/src/services/SessionLifecycleService.js +335 -76
  135. package/lib/src/services/ShutdownCoordinator.js +7 -1
  136. package/lib/src/services/SocketServer.js +11 -2
  137. package/lib/src/services/device-access/LiveSessionOwners.js +53 -0
  138. package/lib/src/services/device-access/SessionOwnerResolver.js +74 -0
  139. package/lib/src/services/lease/LeaseOrphanSweeper.js +32 -6
  140. package/lib/src/services/lease/LeaseService.js +19 -5
  141. package/lib/src/services/session/nodeCreateCaps.js +55 -0
  142. package/lib/src/services/session/sessionCredentials.js +7 -59
  143. package/lib/src/sessions/CloudSession.js +12 -0
  144. package/lib/src/sessions/LocalSession.js +52 -26
  145. package/lib/src/sessions/RemoteSession.js +116 -9
  146. package/lib/src/sessions/SessionManager.js +49 -6
  147. package/lib/src/sessions/appiumUmbrella.js +32 -0
  148. package/lib/test/helpers/appium-umbrella.js +84 -0
  149. package/lib/test/helpers/fake-device-table.js +97 -0
  150. package/lib/test/helpers/loki-stores.js +41 -0
  151. package/lib/test/helpers/loopbackRequest.js +77 -0
  152. package/lib/test/helpers/scratch-database.js +100 -0
  153. package/lib/test/helpers/scratch-port-leases.js +119 -0
  154. package/lib/test/helpers/test-container.js +35 -27
  155. package/lib/test/integration/androidDevices.spec.js +5 -4
  156. package/lib/test/integration/ios/01iOSSimulator.spec.js +6 -5
  157. package/lib/test/integration/ios/02iOSDevices.spec.js +3 -2
  158. package/lib/test/integration/prisma-device-store-add-devices.spec.js +14 -4
  159. package/lib/test/unit/AndroidDeviceManager.spec.js +8 -1
  160. package/lib/test/unit/DeviceModel.spec.js +9 -9
  161. package/lib/test/unit/HealingCascade.test.js +5 -1
  162. package/lib/test/unit/OrphanSweeper.test.js +17 -13
  163. package/lib/test/unit/SessionHeartbeatWrite.test.js +6 -7
  164. package/lib/test/unit/StreamTicketService.spec.js +4 -1
  165. package/lib/test/unit/TokenEndpoint.spec.js +17 -3
  166. package/lib/test/unit/aggregate-hotspots-session-scope.spec.js +5 -5
  167. package/lib/test/unit/android-h264-service.spec.js +6 -0
  168. package/lib/test/unit/android-stream-stall-eof.spec.js +10 -0
  169. package/lib/test/unit/apps-team-visibility.spec.js +16 -16
  170. package/lib/test/unit/auth-me-auth-disabled.test.js +7 -4
  171. package/lib/test/unit/authMiddleware.appTicket.spec.js +5 -5
  172. package/lib/test/unit/authMiddleware.bearer.spec.js +4 -1
  173. package/lib/test/unit/authMiddleware.mcpAudience.spec.js +4 -1
  174. package/lib/test/unit/authMiddleware.streamTicket.spec.js +19 -7
  175. package/lib/test/unit/authMiddleware.test.js +3 -0
  176. package/lib/test/unit/bug-report/route.spec.js +9 -9
  177. package/lib/test/unit/cliargs-admin-only.spec.js +3 -3
  178. package/lib/test/unit/clipboard-route.spec.js +18 -8
  179. package/lib/test/unit/command-auth-appium-server.spec.js +187 -32
  180. package/lib/test/unit/command-auth.spec.js +31 -31
  181. package/lib/test/unit/control-device-lookup.spec.js +9 -9
  182. package/lib/test/unit/control-forward-decision.spec.js +115 -0
  183. package/lib/test/unit/control-lookup-failure.spec.js +3 -3
  184. package/lib/test/unit/create-error-message.spec.js +43 -0
  185. package/lib/test/unit/create-handoff.spec.js +86 -0
  186. package/lib/test/unit/dashboard-events-team-scope.spec.js +6 -3
  187. package/lib/test/unit/device-access-guard.spec.js +28 -28
  188. package/lib/test/unit/device-service.spec.js +4 -0
  189. package/lib/test/unit/device-store-clear-at-boot.spec.js +48 -0
  190. package/lib/test/unit/device-sync-lost-update.spec.js +17 -89
  191. package/lib/test/unit/device-team-guard.spec.js +20 -20
  192. package/lib/test/unit/device-utils.spec.js +8 -0
  193. package/lib/test/unit/extract-team-cap.spec.js +65 -0
  194. package/lib/test/unit/grid-admin-only.spec.js +3 -3
  195. package/lib/test/unit/healing-tier-gate.spec.js +5 -1
  196. package/lib/test/unit/health-monitor-own-phones.spec.js +102 -0
  197. package/lib/test/unit/health-monitor-stream-lookup.spec.js +11 -1
  198. package/lib/test/unit/helpers.spec.js +27 -0
  199. package/lib/test/unit/hub-boot-sweep-recovery.spec.js +192 -0
  200. package/lib/test/unit/hub-create-once.spec.js +190 -0
  201. package/lib/test/unit/hub-create-session-umbrella.spec.js +160 -0
  202. package/lib/test/unit/hub-gateway-create-appium-server.spec.js +591 -0
  203. package/lib/test/unit/hub-node-control-forward.spec.js +291 -0
  204. package/lib/test/unit/hub-node-gateway-appium-server.spec.js +594 -0
  205. package/lib/test/unit/hub-node-race.spec.js +401 -0
  206. package/lib/test/unit/hub-node-same-host.spec.js +356 -0
  207. package/lib/test/unit/hub-register-removal.spec.js +165 -0
  208. package/lib/test/unit/hub-remote-session-heartbeat.spec.js +277 -0
  209. package/lib/test/unit/hub-routed-session-recovery.spec.js +151 -0
  210. package/lib/test/unit/hub-session-token.spec.js +265 -0
  211. package/lib/test/unit/insert-before-routes.spec.js +5 -5
  212. package/lib/test/unit/install-repository-app-team.spec.js +2 -2
  213. package/lib/test/unit/interceptor/MitmProxyHost.spec.js +23 -10
  214. package/lib/test/unit/internal-call.spec.js +181 -0
  215. package/lib/test/unit/ios-discovery-same-host.spec.js +193 -0
  216. package/lib/test/unit/ios-stream-appium-tunnel.spec.js +317 -0
  217. package/lib/test/unit/ios-stream-port-leases.spec.js +299 -0
  218. package/lib/test/unit/ios-wda-status-identity.spec.js +84 -0
  219. package/lib/test/unit/is-xenon-running.spec.js +69 -0
  220. package/lib/test/unit/lease/LeaseOrphanSweeper.spec.js +2 -2
  221. package/lib/test/unit/lease/LeaseService.spec.js +28 -1
  222. package/lib/test/unit/lease/lease-reap-held-phone.spec.js +154 -0
  223. package/lib/test/unit/lease/lease-sweeper-expiry.spec.js +92 -0
  224. package/lib/test/unit/lease/lease-team-visibility.spec.js +2 -2
  225. package/lib/test/unit/lease/lease-token-log-filter.spec.js +242 -64
  226. package/lib/test/unit/lease/lease-token-oracle.spec.js +20 -7
  227. package/lib/test/unit/lease/session-lease-proof.spec.js +8 -3
  228. package/lib/test/unit/logcat-ws.spec.js +4 -1
  229. package/lib/test/unit/loopback-request.spec.js +81 -0
  230. package/lib/test/unit/node-create-caps.spec.js +54 -0
  231. package/lib/test/unit/node-gateway-create-appium-server.spec.js +354 -0
  232. package/lib/test/unit/node-hub-session-owner.spec.js +223 -0
  233. package/lib/test/unit/omni-vision.spec.js +7 -3
  234. package/lib/test/unit/plugin.spec.js +45 -4
  235. package/lib/test/unit/port-allocator-os-probe.spec.js +151 -0
  236. package/lib/test/unit/profile-router.test.js +11 -11
  237. package/lib/test/unit/queue-requester.spec.js +5 -0
  238. package/lib/test/unit/queue-team-scope-routes.spec.js +17 -16
  239. package/lib/test/unit/queue-visibility.spec.js +1 -1
  240. package/lib/test/unit/recording-downloads-visibility.spec.js +2 -2
  241. package/lib/test/unit/recording-instrumentation.spec.js +5 -0
  242. package/lib/test/unit/recording-source-ended.spec.js +5 -0
  243. package/lib/test/unit/recording-store.spec.js +9 -3
  244. package/lib/test/unit/recordings-library-routes.spec.js +51 -51
  245. package/lib/test/unit/recordings-write-visibility.spec.js +2 -2
  246. package/lib/test/unit/remote-session-create.spec.js +130 -0
  247. package/lib/test/unit/reservation-team-visibility.spec.js +5 -5
  248. package/lib/test/unit/reset-link.test.js +14 -14
  249. package/lib/test/unit/session-app-resolution.spec.js +14 -10
  250. package/lib/test/unit/session-assets.spec.js +9 -9
  251. package/lib/test/unit/session-attribution-wiring.spec.js +29 -2
  252. package/lib/test/unit/session-create-layer.spec.js +255 -0
  253. package/lib/test/unit/session-credential-strip.spec.js +25 -9
  254. package/lib/test/unit/session-credentials.spec.js +0 -47
  255. package/lib/test/unit/session-listing-auth.spec.js +345 -0
  256. package/lib/test/unit/session-locator.spec.js +250 -0
  257. package/lib/test/unit/session-node-calls.spec.js +174 -0
  258. package/lib/test/unit/session-owner-resolver.spec.js +107 -0
  259. package/lib/test/unit/session-recovery-routing.spec.js +118 -0
  260. package/lib/test/unit/session-upgrade-guard.spec.js +571 -0
  261. package/lib/test/unit/shutdown-drain-remote.spec.js +58 -0
  262. package/lib/test/unit/socketServer.bearer.spec.js +3 -1
  263. package/lib/test/unit/stream-leave-route.spec.js +15 -7
  264. package/lib/test/unit/stream-own-device-row.spec.js +164 -0
  265. package/lib/test/unit/stream-stop-ownership.spec.js +13 -5
  266. package/lib/test/unit/stream-stop-recording.spec.js +13 -5
  267. package/lib/test/unit/stream-ticket-identity.spec.js +15 -5
  268. package/lib/test/unit/upgrade-router.spec.js +446 -0
  269. package/lib/test/unit/users-router.test.js +11 -8
  270. package/lib/test/unit/verify-credential.spec.js +4 -1
  271. package/lib/test/unit/webdriver-info-route.spec.js +64 -0
  272. package/lib/test/unit/ws-upgrade-routing-appium-server.spec.js +500 -0
  273. package/lib/tsconfig.tsbuildinfo +1 -1
  274. package/package.json +1 -2
  275. package/prisma/migrations/20260929120000_device_claims/migration.sql +8 -0
  276. package/prisma/schema.prisma +9 -0
  277. package/lib/public/assets/BugReportButton-DKYsOdGV.js +0 -10
  278. package/lib/public/assets/device-explorer-DV8rWRxL.css +0 -1
  279. package/lib/src/proxy/wd-command-proxy.js +0 -221
@@ -17,6 +17,7 @@ exports.parseH264WsPath = parseH264WsPath;
17
17
  exports.attachH264Ws = attachH264Ws;
18
18
  const ws_1 = require("ws");
19
19
  const logger_1 = __importDefault(require("../../logger"));
20
+ const upgradeRouter_1 = require("./upgradeRouter");
20
21
  /**
21
22
  * Authenticated H.264 video WebSocket.
22
23
  *
@@ -44,18 +45,22 @@ function parseH264WsPath(url) {
44
45
  }
45
46
  /**
46
47
  * Attach the h264 WebSocket upgrade handler to the plugin's HTTP server. Only
47
- * claims `/xenon/api/control/:udid/stream/h264` — other upgrades (e.g.
48
- * socket.io) are left for their own listeners. Every connection must redeem a
49
- * valid stream ticket before any video flows.
48
+ * claims `/xenon/api/control/:udid/stream/h264` (with a ticket), as a route of
49
+ * the server's upgrade router, so no other handler sees these sockets and this
50
+ * one sees no others (upgradeRouter.ts). Every connection must redeem a valid
51
+ * stream ticket before any video flows.
50
52
  */
51
53
  function attachH264Ws(server, deps) {
52
54
  var _a;
53
55
  const wss = new ws_1.WebSocketServer({ noServer: true });
54
56
  const maxBuffered = (_a = deps.maxBufferedBytes) !== null && _a !== void 0 ? _a : 4 * 1024 * 1024;
55
- server.on('upgrade', (req, socket, head) => {
56
- const parsed = req.url ? parseH264WsPath(req.url) : null;
57
- if (!parsed)
58
- return; // not our path — let other upgrade listeners handle it
57
+ (0, upgradeRouter_1.upgradeRouterFor)(server).add({
58
+ name: 'H.264 live preview',
59
+ matches: (req) => !!req.url && parseH264WsPath(req.url) !== null,
60
+ handle: upgrade,
61
+ });
62
+ function upgrade(req, socket, head) {
63
+ const parsed = parseH264WsPath(req.url);
59
64
  wss.handleUpgrade(req, socket, head, (ws) => __awaiter(this, void 0, void 0, function* () {
60
65
  // Register cleanup BEFORE the awaits below. redeem + startStream can take
61
66
  // seconds; if the client disconnects during that window we must still
@@ -111,5 +116,5 @@ function attachH264Ws(server, deps) {
111
116
  });
112
117
  logger_1.default.info(`[${parsed.udid}] H.264 WS client connected`);
113
118
  }));
114
- });
119
+ }
115
120
  }
@@ -16,6 +16,7 @@ exports.parseLogcatWsPath = parseLogcatWsPath;
16
16
  exports.attachLogcatWs = attachLogcatWs;
17
17
  const ws_1 = require("ws");
18
18
  const logger_1 = __importDefault(require("../../logger"));
19
+ const upgradeRouter_1 = require("./upgradeRouter");
19
20
  const ostraceParse_1 = require("../../services/logcat/ostraceParse");
20
21
  const LOGCAT_PATH_RE = /^\/xenon\/api\/control\/([^/]+)\/logcat$/;
21
22
  /** Parse the logcat stream upgrade path; returns null for any non-matching URL. */
@@ -42,8 +43,9 @@ function parseLogcatWsPath(url) {
42
43
  }
43
44
  /**
44
45
  * Attach the logcat WebSocket upgrade handler to the plugin's HTTP server.
45
- * Only claims `/xenon/api/control/:udid/logcat` — other upgrades (socket.io,
46
- * the h264 stream) are left for their own listeners.
46
+ * Only claims `/xenon/api/control/:udid/logcat` (with a ticket), as a route of
47
+ * the server's upgrade router, so no other handler sees these sockets and this
48
+ * one sees no others (upgradeRouter.ts).
47
49
  *
48
50
  * Authentication mirrors `attachH264Ws`: a single-use, udid-bound stream
49
51
  * ticket. Authorization does not — device logs routinely carry auth tokens,
@@ -58,10 +60,13 @@ function attachLogcatWs(server, deps) {
58
60
  var _a;
59
61
  const wss = new ws_1.WebSocketServer({ noServer: true });
60
62
  const maxBuffered = (_a = deps.maxBufferedBytes) !== null && _a !== void 0 ? _a : 4 * 1024 * 1024;
61
- server.on('upgrade', (req, socket, head) => {
62
- const parsed = req.url ? parseLogcatWsPath(req.url) : null;
63
- if (!parsed)
64
- return; // not our path — let other upgrade listeners handle it
63
+ (0, upgradeRouter_1.upgradeRouterFor)(server).add({
64
+ name: 'logcat',
65
+ matches: (req) => !!req.url && parseLogcatWsPath(req.url) !== null,
66
+ handle: upgrade,
67
+ });
68
+ function upgrade(req, socket, head) {
69
+ const parsed = parseLogcatWsPath(req.url);
65
70
  wss.handleUpgrade(req, socket, head, (ws) => __awaiter(this, void 0, void 0, function* () {
66
71
  var _a, _b;
67
72
  // Register cleanup BEFORE the awaits below. redeem + authorize +
@@ -282,5 +287,5 @@ function attachLogcatWs(server, deps) {
282
287
  };
283
288
  logger_1.default.info(`[${parsed.udid}] logcat WS client connected`);
284
289
  }));
285
- });
290
+ }
286
291
  }
@@ -0,0 +1,215 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.UpgradeRouter = void 0;
7
+ exports.appiumPatternMatcher = appiumPatternMatcher;
8
+ exports.dispatchToAppiumHandlers = dispatchToAppiumHandlers;
9
+ exports.appiumUpgradeListener = appiumUpgradeListener;
10
+ exports.upgradeRouterFor = upgradeRouterFor;
11
+ const logger_1 = __importDefault(require("../../logger"));
12
+ const appiumBasePath_1 = require("../appiumBasePath");
13
+ function escapeRegExp(text) {
14
+ return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
15
+ }
16
+ /**
17
+ * The path-to-regexp v8 syntax Appium's patterns use: literal segments and
18
+ * `:name` parameters. Anything else (groups, wildcards, escapes) is not
19
+ * compiled, and such a pattern matches nothing here; see appiumPatternMatcher.
20
+ */
21
+ const UNSUPPORTED_PATTERN_SYNTAX = /[{}()[\]*+?!\\"]/;
22
+ /** Splits a pattern into literal text (even indexes) and `:name` parameters (odd). */
23
+ const PARAM = /(:[A-Za-z_$][\w$]*)/;
24
+ const compiled = new Map();
25
+ const warnedPatterns = new Set();
26
+ /**
27
+ * base-driver matches an upgrade's pathname against each registered pattern
28
+ * with path-to-regexp's `match(pattern)` (v8, defaults): case-insensitive, an
29
+ * optional trailing slash, the whole pathname, each parameter one or more
30
+ * characters other than `/`, and each matched parameter decoded with
31
+ * decodeURIComponent, which throws on a malformed escape. This is that, for
32
+ * the patterns Appium and drivers register. It returns null for a pattern it
33
+ * does not speak.
34
+ */
35
+ function appiumPatternMatcher(pattern) {
36
+ const cached = compiled.get(pattern);
37
+ if (cached !== undefined)
38
+ return cached;
39
+ let matcher = null;
40
+ if (!UNSUPPORTED_PATTERN_SYNTAX.test(pattern)) {
41
+ const source = pattern
42
+ .split(PARAM)
43
+ .map((part, i) => (i % 2 === 1 ? '([^/]+)' : escapeRegExp(part)))
44
+ .join('');
45
+ const regex = new RegExp(`^${source}(?:/)?$`, 'i');
46
+ matcher = (pathname) => {
47
+ const m = regex.exec(pathname);
48
+ if (!m)
49
+ return false;
50
+ // path-to-regexp decodes every matched parameter, and a malformed
51
+ // escape throws out of Appium's dispatch. Do the same, so this behaves
52
+ // as Appium's own listener does.
53
+ for (const value of m.slice(1))
54
+ decodeURIComponent(value);
55
+ return true;
56
+ };
57
+ }
58
+ compiled.set(pattern, matcher);
59
+ return matcher;
60
+ }
61
+ /**
62
+ * base-driver's `tryHandleWebSocketUpgrade`, over the server's public
63
+ * `webSocketsMapping`: hand a WebSocket upgrade to the first registered
64
+ * handler whose pattern matches its pathname. False when none does.
65
+ */
66
+ function dispatchToAppiumHandlers(req, socket, head, mapping, logger) {
67
+ var _a, _b;
68
+ const upgrade = (_a = req.headers) === null || _a === void 0 ? void 0 : _a.upgrade;
69
+ if (typeof upgrade !== 'string' || upgrade.toLowerCase() !== 'websocket')
70
+ return false;
71
+ const pathname = (0, appiumBasePath_1.appiumPathname)((_b = req.url) !== null && _b !== void 0 ? _b : '');
72
+ for (const [pattern, wsServer] of Object.entries(mapping !== null && mapping !== void 0 ? mapping : {})) {
73
+ const matches = appiumPatternMatcher(pattern);
74
+ if (!matches) {
75
+ if (!warnedPatterns.has(pattern)) {
76
+ warnedPatterns.add(pattern);
77
+ logger.warn(`Appium WebSocket handler ${pattern} uses path syntax Xenon's copy of Appium's ` +
78
+ 'upgrade listener does not match; it is skipped on this Node (< 22.21).');
79
+ }
80
+ continue;
81
+ }
82
+ if (matches(pathname)) {
83
+ wsServer.handleUpgrade(req, socket, head, (ws) => wsServer.emit('connection', ws, req));
84
+ return true;
85
+ }
86
+ }
87
+ logger.info(`Did not match the websocket upgrade request at ${pathname} to any known route`);
88
+ return false;
89
+ }
90
+ /**
91
+ * The `upgrade` listener base-driver's configureHttp adds on Node >= 22.21:
92
+ * Appium's handlers take what they match, and every other socket is destroyed.
93
+ * `webSocketsMapping` is read per upgrade, since drivers add and remove
94
+ * handlers as sessions come and go.
95
+ */
96
+ function appiumUpgradeListener(server, logger) {
97
+ return function appiumUpgradeThroughXenon(req, socket, head) {
98
+ if (!dispatchToAppiumHandlers(req, socket, head, server.webSocketsMapping, logger)) {
99
+ socket.destroy();
100
+ }
101
+ };
102
+ }
103
+ /** The upgrade's path without the query, which carries tickets. */
104
+ function pathOf(req) {
105
+ var _a;
106
+ return String((_a = req.url) !== null && _a !== void 0 ? _a : '').split('?')[0];
107
+ }
108
+ class UpgradeRouter {
109
+ constructor(server, logger = logger_1.default.scope('UpgradeRouter')) {
110
+ this.server = server;
111
+ this.logger = logger;
112
+ this.routes = [];
113
+ // base-driver's own test (`hasShouldUpgradeCallback`): without the
114
+ // callback it took upgrades in Express and added no listener. A server
115
+ // with no listener at all (not Appium's) needs one too, or neither Node
116
+ // emits `upgrade` and Xenon's routes are never reached.
117
+ const appiumListens = typeof server.shouldUpgradeCallback !== 'undefined' && server.listenerCount('upgrade') > 0;
118
+ if (appiumListens) {
119
+ this.appiumPath = 'appium-listener';
120
+ }
121
+ else {
122
+ server.on('upgrade', appiumUpgradeListener(server, logger));
123
+ this.appiumPath = 'router-listener';
124
+ }
125
+ this.routeEmit();
126
+ }
127
+ /** Give every upgrade `route.matches` to `route.handle`, and to nothing else. */
128
+ add(route) {
129
+ this.routes.push(route);
130
+ }
131
+ /**
132
+ * Run `attach`, which attaches something that takes upgrades by adding an
133
+ * `upgrade` listener (socket.io). The listeners it added are taken off the
134
+ * server and become the route's handler, so they see only what `matches`.
135
+ */
136
+ adopt(name, matches, attach) {
137
+ const before = new Set(this.server.listeners('upgrade'));
138
+ const attached = attach();
139
+ const added = this.server.listeners('upgrade').filter((l) => !before.has(l));
140
+ for (const listener of added)
141
+ this.server.removeListener('upgrade', listener);
142
+ if (added.length > 0) {
143
+ const server = this.server;
144
+ this.add({
145
+ name,
146
+ matches,
147
+ handle: (req, socket, head) => {
148
+ for (const listener of added)
149
+ listener.call(server, req, socket, head);
150
+ },
151
+ });
152
+ }
153
+ return attached;
154
+ }
155
+ /** The route that owns an upgrade to `req`, or undefined when it is Appium's. */
156
+ routeFor(req) {
157
+ return this.routes.find((route) => route.matches(req));
158
+ }
159
+ /** Where the upgrades that are not Xenon's go, for the boot log. */
160
+ describeAppiumPath() {
161
+ return this.appiumPath === 'appium-listener'
162
+ ? "Appium's own upgrade listener"
163
+ : "Appium's WebSocket handlers, through Xenon's copy of Appium's upgrade listener " +
164
+ '(this Node has no http.Server shouldUpgradeCallback, so Appium took upgrades in ' +
165
+ 'Express, which no upgrade reaches once any upgrade listener exists)';
166
+ }
167
+ /** Close a socket whose upgrade could not be handed on, and say why. */
168
+ refuse(req, socket, what, error) {
169
+ var _a;
170
+ this.logger.error(`WebSocket upgrade ${pathOf(req)} ${what}: ${(_a = error === null || error === void 0 ? void 0 : error.message) !== null && _a !== void 0 ? _a : error}. Closing the socket.`);
171
+ socket.destroy();
172
+ return true;
173
+ }
174
+ routeEmit() {
175
+ const server = this.server;
176
+ const emit = server.emit;
177
+ const routeFor = (req) => this.routeFor(req);
178
+ const refuse = (req, socket, what, error) => this.refuse(req, socket, what, error);
179
+ server.emit = function emitThroughUpgradeRouter(event, ...args) {
180
+ if (event !== 'upgrade')
181
+ return emit.call(this, event, ...args);
182
+ const [req, socket, head] = args;
183
+ let route;
184
+ try {
185
+ route = routeFor(req);
186
+ }
187
+ catch (error) {
188
+ return refuse(req, socket, 'could not be routed', error);
189
+ }
190
+ try {
191
+ if (!route)
192
+ return emit.call(this, event, ...args);
193
+ route.handle(req, socket, head);
194
+ return true;
195
+ }
196
+ catch (error) {
197
+ return refuse(req, socket, `failed in the ${route ? route.name : 'Appium'} handler`, error);
198
+ }
199
+ };
200
+ }
201
+ }
202
+ exports.UpgradeRouter = UpgradeRouter;
203
+ const routers = new WeakMap();
204
+ /**
205
+ * The server's router, installed on first use. ServerManager installs it in
206
+ * registerRoutes; attaching a WebSocket installs it on a server that has none.
207
+ */
208
+ function upgradeRouterFor(server) {
209
+ let router = routers.get(server);
210
+ if (!router) {
211
+ router = new UpgradeRouter(server);
212
+ routers.set(server, router);
213
+ }
214
+ return router;
215
+ }
@@ -225,9 +225,10 @@ let DashboardEventManager = class DashboardEventManager {
225
225
  // Principal Resource Management: Unblock device immediately
226
226
  const device = this.sessionToDevice.get(sessionId);
227
227
  if (device) {
228
- const { unblockDevice } = yield Promise.resolve().then(() => __importStar(require('../data-service/device-service')));
228
+ const { releaseSessionDevice } = yield Promise.resolve().then(() => __importStar(require('../data-service/device-service')));
229
229
  try {
230
- yield unblockDevice(device.udid, device.host);
230
+ // This session's claim only: the phone may have gone on to another.
231
+ yield releaseSessionDevice(device.udid, device.host, sessionId);
231
232
  logger_1.default.info(`🔓 [${sessionId}] Device ${device.udid} released.`);
232
233
  }
233
234
  catch (unblockErr) {
@@ -239,9 +240,9 @@ let DashboardEventManager = class DashboardEventManager {
239
240
  yield this.stopIdleStreamForDevice(device);
240
241
  }
241
242
  else {
242
- const { unblockDeviceMatchingFilter } = yield Promise.resolve().then(() => __importStar(require('../data-service/device-service')));
243
+ const { releaseSessionDevices } = yield Promise.resolve().then(() => __importStar(require('../data-service/device-service')));
243
244
  try {
244
- yield unblockDeviceMatchingFilter({ session_id: sessionId });
245
+ yield releaseSessionDevices(sessionId);
245
246
  logger_1.default.info(`🔓 [${sessionId}] Device released via session_id fallback.`);
246
247
  }
247
248
  catch (unblockErr) {
@@ -261,9 +262,9 @@ let DashboardEventManager = class DashboardEventManager {
261
262
  else {
262
263
  logger_1.default.warn(`⚠️ Session ${sessionId} not found in SESSION_MANAGER`);
263
264
  // Fallback: If session not in manager, attempt to unblock by session_id in store
264
- const { unblockDeviceMatchingFilter } = yield Promise.resolve().then(() => __importStar(require('../data-service/device-service')));
265
+ const { releaseSessionDevices } = yield Promise.resolve().then(() => __importStar(require('../data-service/device-service')));
265
266
  try {
266
- yield unblockDeviceMatchingFilter({ session_id: sessionId });
267
+ yield releaseSessionDevices(sessionId);
267
268
  logger_1.default.info(`🔓 [${sessionId}] Orphaned device released via session_id fallback.`);
268
269
  }
269
270
  catch (unblockErr) {
@@ -13,7 +13,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
13
13
  };
14
14
  Object.defineProperty(exports, "__esModule", { value: true });
15
15
  exports.removeDevice = removeDevice;
16
- exports.removeDevicesByHost = removeDevicesByHost;
16
+ exports.removeNodeDevices = removeNodeDevices;
17
17
  exports.addNewDevice = addNewDevice;
18
18
  exports.syncDiscoveredDevices = syncDiscoveredDevices;
19
19
  exports.setSimulatorState = setSimulatorState;
@@ -23,6 +23,7 @@ exports.filterRowsByVisibleDevice = filterRowsByVisibleDevice;
23
23
  exports.getDevices = getDevices;
24
24
  exports.getDevice = getDevice;
25
25
  exports.updatedAllocatedDevice = updatedAllocatedDevice;
26
+ exports.claimDeviceForSession = claimDeviceForSession;
26
27
  exports.updateDeviceProgress = updateDeviceProgress;
27
28
  exports.updateCmdExecutedTime = updateCmdExecutedTime;
28
29
  exports.userBlockDevice = userBlockDevice;
@@ -30,6 +31,9 @@ exports.userUnblockDevice = userUnblockDevice;
30
31
  exports.blockDevice = blockDevice;
31
32
  exports.unblockDevice = unblockDevice;
32
33
  exports.unblockDeviceMatchingFilter = unblockDeviceMatchingFilter;
34
+ exports.releaseSessionDevices = releaseSessionDevices;
35
+ exports.releaseSessionDevice = releaseSessionDevice;
36
+ exports.releasePendingClaim = releasePendingClaim;
33
37
  exports.reserveDevice = reserveDevice;
34
38
  exports.releaseReservation = releaseReservation;
35
39
  exports.isDeviceReserved = isDeviceReserved;
@@ -43,6 +47,7 @@ const typedi_1 = require("typedi");
43
47
  const CircuitBreaker_1 = require("./CircuitBreaker");
44
48
  const NotificationService_1 = require("../services/NotificationService");
45
49
  const deviceFieldOwners_1 = require("./deviceFieldOwners");
50
+ const deviceClaims_1 = require("./deviceClaims");
46
51
  const SocketServer_1 = require("../services/SocketServer");
47
52
  const prisma_1 = require("../prisma");
48
53
  const manualLock_1 = require("../services/recording/manualLock");
@@ -63,8 +68,8 @@ const store = new Proxy({}, {
63
68
  function deviceScope(udid, teamId) {
64
69
  return teamId === undefined ? { udid } : { udid, teamId };
65
70
  }
66
- function removeDevice(devices) {
67
- return __awaiter(this, void 0, void 0, function* () {
71
+ function removeDevice(devices_1) {
72
+ return __awaiter(this, arguments, void 0, function* (devices, options = {}) {
68
73
  for (const device of devices) {
69
74
  logger_1.default.info(`Removing device ${device.udid} from host ${device.host}`);
70
75
  const socket = typedi_1.Container.get(SocketServer_1.SocketServer);
@@ -75,18 +80,44 @@ function removeDevice(devices) {
75
80
  const team = socket.hasScopedDashboard()
76
81
  ? yield typedi_1.Container.get(DeviceTeamResolver_1.DeviceTeamResolver).resolve(device.udid)
77
82
  : undefined;
78
- yield store.removeDevices({ udid: device.udid, host: device.host });
83
+ yield store.removeDevices({ udid: device.udid, host: device.host }, options);
79
84
  typedi_1.Container.get(NotificationService_1.NotificationService).dispatchEvent('device_offline', device);
80
85
  void socket.emitToDashboardForDevices('device_removed', device, deviceScope(device.udid, (team === null || team === void 0 ? void 0 : team.known) ? team.teamId : undefined));
81
86
  }
82
87
  });
83
88
  }
84
- function removeDevicesByHost(host) {
89
+ /**
90
+ * The phones a node asks the hub to forget (`POST /register`, `type=remove`
91
+ * for one phone, `type=unregister` when it shuts down). Only that node's: the
92
+ * rows its `nodeId` names when it sends one, else those filed under exactly
93
+ * its `host`, and never one of this server's own (`isOwn`). A udid-less
94
+ * `remove` names no phone and takes nothing.
95
+ *
96
+ * The store matched a host that isn't a URL as a substring, and a remove with
97
+ * no host by udid alone, so a node (an older one sends a bare IP) could delete
98
+ * the hub's own phones, or another node's. Returns how many rows went.
99
+ */
100
+ function removeNodeDevices(removals, isOwn) {
85
101
  return __awaiter(this, void 0, void 0, function* () {
86
- logger_1.default.info(`Removing all devices from host ${host}`);
87
- // We can't easily dispatch events here without fetching first,
88
- // but for now we'll stick to single device removal alerting
89
- yield store.removeDevices({ host });
102
+ const all = yield store.getAllDevices();
103
+ const gone = new Map();
104
+ for (const removal of removals) {
105
+ if (!removal.nodeId && !removal.host)
106
+ continue;
107
+ for (const device of all) {
108
+ if (isOwn(device))
109
+ continue;
110
+ if (removal.udid !== undefined && device.udid !== removal.udid)
111
+ continue;
112
+ const theirs = removal.nodeId
113
+ ? device.nodeId === removal.nodeId
114
+ : device.host === removal.host;
115
+ if (theirs)
116
+ gone.set(`${device.udid}\u0000${device.host}`, device);
117
+ }
118
+ }
119
+ yield removeDevice([...gone.values()].map((d) => ({ udid: d.udid, host: d.host })), { exactHost: true });
120
+ return gone.size;
90
121
  });
91
122
  }
92
123
  function addNewDevice(devices_1, host_1) {
@@ -237,6 +268,22 @@ function updatedAllocatedDevice(device, updateData) {
237
268
  yield store.updateDevice(device.udid, device.host, updateData);
238
269
  });
239
270
  }
271
+ /**
272
+ * Put a created session on the claim its phone was allocated with
273
+ * (`device.claimedAt`), writing `updateData` with it. False, and nothing
274
+ * written, when another session holds the phone now: its claim is not
275
+ * overwritten.
276
+ */
277
+ function claimDeviceForSession(device, sessionId, updateData) {
278
+ return __awaiter(this, void 0, void 0, function* () {
279
+ const claimed = yield store.claimForSession(device.udid, device.host, device.claimedAt, sessionId, updateData);
280
+ if (!claimed) {
281
+ logger_1.default.error(`Session ${sessionId} was created on ${device.udid} at ${device.host}, but another ` +
282
+ 'session has claimed the device since. Leaving that claim in place.');
283
+ }
284
+ return claimed;
285
+ });
286
+ }
240
287
  function updateDeviceProgress(udid_1, host_1, progress_1) {
241
288
  return __awaiter(this, arguments, void 0, function* (udid, host, progress, extra = {}) {
242
289
  logger_1.default.debug(`[${udid}] progress: ${progress}`);
@@ -249,9 +296,9 @@ function updateDeviceProgress(udid_1, host_1, progress_1) {
249
296
  }
250
297
  function updateCmdExecutedTime(sessionId) {
251
298
  return __awaiter(this, void 0, void 0, function* () {
252
- yield store.updateDevices({ session_id: sessionId }, (device) => {
253
- device.lastCmdExecutedAt = new Date().getTime();
254
- });
299
+ // One conditional write. Reading the row and writing all of it back would
300
+ // put back a claim released, or a node's report taken, in between.
301
+ yield store.touchSession(sessionId, new Date().getTime());
255
302
  });
256
303
  }
257
304
  /**
@@ -297,53 +344,109 @@ function blockDevice(udid, host, sessionId) {
297
344
  }, { udid });
298
345
  });
299
346
  }
347
+ /**
348
+ * Free a phone whatever holds it: a manual hold (a preview, a recording) or
349
+ * an admin's release. A session's phone is released with
350
+ * releaseSessionDevices / releaseSessionDevice / releasePendingClaim instead,
351
+ * keyed on its claim.
352
+ *
353
+ * With a host, only that row. A hub also stores its nodes' phones, and a node
354
+ * on the same machine lists the same udid under its own host: matching the
355
+ * udid alone freed that row too, with a session on it.
356
+ */
300
357
  function unblockDevice(udid, host) {
301
358
  return __awaiter(this, void 0, void 0, function* () {
302
- // Prefer udid-only: device.host can be `http://IP:port` while callers pass
303
- // `127.0.0.1` / bindHostOrIp, and a host mismatch silently no-ops (0 rows).
304
- // UDID uniquely identifies a device in this store.
305
- const byUdid = yield store.getDevices({ udid });
306
- if (byUdid.length > 0) {
307
- yield unblockDeviceMatchingFilter({ udid });
308
- return;
359
+ const devices = yield store.findDevices(host === undefined ? { udid } : { udid, host });
360
+ yield forceRelease(devices);
361
+ });
362
+ }
363
+ /** unblockDevice for every phone matching `filter` (getDevices' filter). */
364
+ function unblockDeviceMatchingFilter(filter) {
365
+ return __awaiter(this, void 0, void 0, function* () {
366
+ yield forceRelease(yield store.getDevices(filter));
367
+ });
368
+ }
369
+ function forceRelease(devices) {
370
+ return __awaiter(this, void 0, void 0, function* () {
371
+ yield Promise.all(devices.map((device) => __awaiter(this, void 0, void 0, function* () {
372
+ const totalUtilization = utilizationAfter(device);
373
+ yield (0, device_utils_1.setUtilizationTime)(device.udid, totalUtilization);
374
+ yield store.updateDevice(device.udid, device.host, Object.assign(Object.assign({}, deviceClaims_1.CLAIM_RESET), { busy: false, totalUtilizationTimeMilliSec: totalUtilization }));
375
+ logger_1.default.debug(`Unblocked device ${device.udid}`);
376
+ emitUnblocked(device);
377
+ }))).catch((error) => {
378
+ logger_1.default.error(`Unable to unblock device: ${error}`);
379
+ });
380
+ });
381
+ }
382
+ /**
383
+ * End a session's claim on its phone, keyed on the session's id: a release
384
+ * for a session that has ended, arriving after its phone went to another one,
385
+ * matches nothing. The phone stays busy while its node still reports it busy
386
+ * (deviceClaims.ts). Never throws.
387
+ */
388
+ function releaseSessionDevices(sessionId) {
389
+ return __awaiter(this, void 0, void 0, function* () {
390
+ try {
391
+ const rows = new Map();
392
+ for (const device of [
393
+ ...(yield store.findDevices({ claimSessionId: sessionId })),
394
+ ...(yield store.findDevices({ session_id: sessionId })),
395
+ ]) {
396
+ rows.set(`${device.udid}@${device.host}`, device);
397
+ }
398
+ yield Promise.all([...rows.values()].map((device) => releaseClaimOn(device, { sessionId })));
309
399
  }
310
- if (host) {
311
- yield unblockDeviceMatchingFilter({ udid, host });
400
+ catch (error) {
401
+ logger_1.default.error(`Unable to release the device of session ${sessionId}: ${error}`);
312
402
  }
313
403
  });
314
404
  }
315
- function unblockDeviceMatchingFilter(filter) {
405
+ /** releaseSessionDevices for one phone. */
406
+ function releaseSessionDevice(udid, host, sessionId) {
316
407
  return __awaiter(this, void 0, void 0, function* () {
317
- const devices = yield store.getDevices(filter);
318
- if (devices.length > 0) {
319
- yield Promise.all(devices.map((device) => __awaiter(this, void 0, void 0, function* () {
320
- const sessionStart = device.sessionStartTime;
321
- const currentTime = new Date().getTime();
322
- let utilization = currentTime - sessionStart;
323
- if (sessionStart === 0)
324
- utilization = 0;
325
- const totalUtilization = device.totalUtilizationTimeMilliSec + utilization;
326
- yield (0, device_utils_1.setUtilizationTime)(device.udid, totalUtilization);
327
- yield store.updateDevice(device.udid, device.host, {
328
- session_id: null,
329
- busy: false,
330
- lastCmdExecutedAt: null,
331
- sessionStartTime: 0,
332
- totalUtilizationTimeMilliSec: totalUtilization,
333
- newCommandTimeout: null,
334
- sessionProgress: '',
335
- });
336
- logger_1.default.debug(`Unblocked device ${device.udid}`);
337
- void typedi_1.Container.get(SocketServer_1.SocketServer).emitToDashboardForDevices('device_unblocked', {
338
- udid: device.udid,
339
- host: device.host,
340
- }, deviceScope(device.udid, device.teamId));
341
- }))).catch((error) => {
342
- logger_1.default.error(`Unable to unblock device: ${error}`);
343
- });
408
+ const device = yield store.findDevice({ udid, host });
409
+ return device ? releaseClaimOn(device, { sessionId }) : false;
410
+ });
411
+ }
412
+ /**
413
+ * Give back a phone allocated for a session that was never created: the
414
+ * pending claim it was allocated with (`claimedAt`, as the allocation read
415
+ * it). If that claim has gone (timed out, and the phone taken since) nothing
416
+ * is written.
417
+ */
418
+ function releasePendingClaim(allocated) {
419
+ return __awaiter(this, void 0, void 0, function* () {
420
+ var _a;
421
+ const device = yield store.findDevice({ udid: allocated.udid, host: allocated.host });
422
+ return device ? releaseClaimOn(device, { claimedAt: (_a = allocated.claimedAt) !== null && _a !== void 0 ? _a : null }) : false;
423
+ });
424
+ }
425
+ function releaseClaimOn(device, ref) {
426
+ return __awaiter(this, void 0, void 0, function* () {
427
+ const totalUtilization = utilizationAfter(device);
428
+ const released = yield store.releaseClaim(device.udid, device.host, ref, {
429
+ totalUtilizationTimeMilliSec: totalUtilization,
430
+ });
431
+ if (!released) {
432
+ logger_1.default.info(`Not releasing ${device.udid} at ${device.host}: it no longer holds ${JSON.stringify(ref)}`);
433
+ return false;
344
434
  }
435
+ yield (0, device_utils_1.setUtilizationTime)(device.udid, totalUtilization);
436
+ logger_1.default.debug(`Released ${JSON.stringify(ref)} on ${device.udid}`);
437
+ emitUnblocked(device);
438
+ return true;
345
439
  });
346
440
  }
441
+ /** The phone's total use once the session on it now ends. */
442
+ function utilizationAfter(device) {
443
+ var _a;
444
+ const started = device.sessionStartTime;
445
+ return ((_a = device.totalUtilizationTimeMilliSec) !== null && _a !== void 0 ? _a : 0) + (started ? Date.now() - started : 0);
446
+ }
447
+ function emitUnblocked(device) {
448
+ void typedi_1.Container.get(SocketServer_1.SocketServer).emitToDashboardForDevices('device_unblocked', { udid: device.udid, host: device.host }, deviceScope(device.udid, device.teamId));
449
+ }
347
450
  /**
348
451
  * Reserve a device for exclusive manual use
349
452
  * @param udid Device UDID