@xenon-device-management/xenon 2.0.0 → 2.2.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 (289) hide show
  1. package/README.md +2 -1
  2. package/lib/package.json +3 -4
  3. package/lib/public/assets/{AnnotationOverlay-BmTPCAds.js → AnnotationOverlay-XpewX5nn.js} +1 -1
  4. package/lib/public/assets/{ApiKeyGate-B5eB7f8B.js → ApiKeyGate-BQDv3ZCC.js} +1 -1
  5. package/lib/public/assets/BugReportButton-C7OmS61B.js +10 -0
  6. package/lib/public/assets/{DeviceMosaicView-9DUoZnQi.js → DeviceMosaicView-wl3l2Zlt.js} +1 -1
  7. package/lib/public/assets/{EmptyState-lvyNiXev.js → EmptyState-fPxujqqE.js} +1 -1
  8. package/lib/public/assets/{FieldGroup-BduMPM9K.js → FieldGroup-5bCutWrs.js} +1 -1
  9. package/lib/public/assets/{FilterMenu-BSqX6I94.js → FilterMenu-owsBiDT1.js} +1 -1
  10. package/lib/public/assets/{Menu-Ba5cT7jZ.js → Menu-DFhqzPSt.js} +1 -1
  11. package/lib/public/assets/{Modal-CceoMY7D.js → Modal-Bw-a-E-e.js} +1 -1
  12. package/lib/public/assets/{RecordingPage-6T9dOidc.js → RecordingPage-WtByf5bC.js} +1 -1
  13. package/lib/public/assets/{RecordingsPage-qj6FdZXR.js → RecordingsPage-FpWWFNgT.js} +1 -1
  14. package/lib/public/assets/{SegmentedControl-CSRuZ8TR.js → SegmentedControl-Jzn9c7o8.js} +1 -1
  15. package/lib/public/assets/{SettingCard-Bp1kz4By.js → SettingCard-BR7nOhpT.js} +1 -1
  16. package/lib/public/assets/{Table-LV7zFyZg.js → Table-B_Va3V05.js} +1 -1
  17. package/lib/public/assets/{activity-D17TUloi.js → activity-DxRFWvkN.js} +1 -1
  18. package/lib/public/assets/{ai-settings-qvbhY89P.js → ai-settings-CQf-9_V6.js} +1 -1
  19. package/lib/public/assets/{api-keys-BfpvdG7G.js → api-keys-SoE1Kd7N.js} +1 -1
  20. package/lib/public/assets/{apps-Dbnzd0fW.js → apps-CgGXTlgn.js} +1 -1
  21. package/lib/public/assets/{arrow-left-DD1VjtC7.js → arrow-left-uQ6WIwiC.js} +1 -1
  22. package/lib/public/assets/{arrow-right-D0pnM3ma.js → arrow-right-C9RyfzXS.js} +1 -1
  23. package/lib/public/assets/{arrow-up-right-BIC8JOV6.js → arrow-up-right-DFHi_8yG.js} +1 -1
  24. package/lib/public/assets/{auth-shell-DWHPXSnI.js → auth-shell-Bii1t4qc.js} +2 -2
  25. package/lib/public/assets/{builds-page-ClCNBUv_.js → builds-page-Bnv8Z9hi.js} +1 -1
  26. package/lib/public/assets/{button-xtAhYR-f.js → button-_Y78wXiP.js} +1 -1
  27. package/lib/public/assets/{calendar-CupwJ7Bm.js → calendar-D4MqSOc2.js} +1 -1
  28. package/lib/public/assets/{check-CyuEoKkd.js → check-uGHCXe-L.js} +1 -1
  29. package/lib/public/assets/{chevron-left-DgX-F9xK.js → chevron-left-Dy1OqpzB.js} +1 -1
  30. package/lib/public/assets/{chevron-right-BKJgJnU9.js → chevron-right-BUsldEli.js} +1 -1
  31. package/lib/public/assets/{circle-check-CZdg8mMa.js → circle-check-D3Qq0tKp.js} +1 -1
  32. package/lib/public/assets/{circle-x-CZH6t9z-.js → circle-x-B4IOD1DE.js} +1 -1
  33. package/lib/public/assets/{clock-Cd-t7xpe.js → clock-BfYfzvSg.js} +1 -1
  34. package/lib/public/assets/{copy-C6zerX3I.js → copy-DZwYE5Va.js} +1 -1
  35. package/lib/public/assets/{device-explorer-B1pI6b_M.js → device-explorer-CRG4Utt5.js} +3 -3
  36. package/lib/public/assets/device-explorer-D2AqrrLN.css +1 -0
  37. package/lib/public/assets/{download-x9WP7mcL.js → download-CCDYo_TN.js} +1 -1
  38. package/lib/public/assets/{forgot-password-B0TEH4N-.js → forgot-password-DuLCmVFb.js} +1 -1
  39. package/lib/public/assets/{funnel-Cb40MfFN.js → funnel-CtKNPqzb.js} +1 -1
  40. package/lib/public/assets/{index-BR1wTIPC.js → index-3uxl3o7K.js} +43 -43
  41. package/lib/public/assets/{index-BvH_ekRy.js → index-Zy1Op5C1.js} +1 -1
  42. package/lib/public/assets/{input-DejcCiTI.js → input-CzTWvUXZ.js} +1 -1
  43. package/lib/public/assets/{keycodes-Mavx5iGg.js → keycodes-CI-nYDFM.js} +1 -1
  44. package/lib/public/assets/{layers-BKqYLiHE.js → layers-YZZGFyTX.js} +1 -1
  45. package/lib/public/assets/{lock-LFsFhc17.js → lock-L8tzWgDo.js} +1 -1
  46. package/lib/public/assets/{login-x2pkzt-t.js → login-DBbX-fu8.js} +1 -1
  47. package/lib/public/assets/{maintenance-settings-CYafcoV-.js → maintenance-settings-AJwkOIsE.js} +1 -1
  48. package/lib/public/assets/{monitor-_7_uJ8d9.js → monitor-Di_66F2m.js} +1 -1
  49. package/lib/public/assets/{mouse-pointer-2-BfLuURKN.js → mouse-pointer-2-Cnab2zdg.js} +1 -1
  50. package/lib/public/assets/{network-Z_zBhpsD.js → network-DiSumY-1.js} +1 -1
  51. package/lib/public/assets/{overview-BY5uv08S.js → overview-CwE4gXl5.js} +1 -1
  52. package/lib/public/assets/{page-header-CP2UkykG.js → page-header-CfR4Sm9V.js} +1 -1
  53. package/lib/public/assets/{plus-BTD0dnQ0.js → plus-DOS8P9CQ.js} +1 -1
  54. package/lib/public/assets/{profile-page-BhJZuCS7.js → profile-page-BYpHwQIH.js} +1 -1
  55. package/lib/public/assets/{reset-password-CMN0SO81.js → reset-password-BRVJU_qV.js} +1 -1
  56. package/lib/public/assets/{runbook-page-CBpPX9_5.js → runbook-page-Bi3JvAFZ.js} +1 -1
  57. package/lib/public/assets/{select-Uc9E5pye.js → select-Cqx7pEou.js} +1 -1
  58. package/lib/public/assets/{selector-detail-page-BSyBnRuU.js → selector-detail-page-C0DsncKj.js} +1 -1
  59. package/lib/public/assets/{selector-health-DzqxNkzm.js → selector-health-CAhpdilF.js} +1 -1
  60. package/lib/public/assets/{selector-health-page-CyPLjoD1.js → selector-health-page-CrCVeW2m.js} +1 -1
  61. package/lib/public/assets/{session-detail-page-BZQZN1P-.js → session-detail-page-F_hwYqEt.js} +1 -1
  62. package/lib/public/assets/{settings-CkSWiZq8.js → settings-C-s5pypL.js} +1 -1
  63. package/lib/public/assets/{status-pill-outline-BFYni2qo.js → status-pill-outline-DY5uzv6A.js} +1 -1
  64. package/lib/public/assets/{tablet-BSd-xSZK.js → tablet-Dli7gwyg.js} +1 -1
  65. package/lib/public/assets/{teams-PpoEQz31.js → teams-ui4IhHNC.js} +1 -1
  66. package/lib/public/assets/{trash-2-BorJv5_u.js → trash-2-BV4hSmXJ.js} +1 -1
  67. package/lib/public/assets/{upload-C3i7sAZl.js → upload-DkGy_J-L.js} +1 -1
  68. package/lib/public/assets/{use-password-reset-mode-CeZQ3EsH.js → use-password-reset-mode-BFtMqQOh.js} +1 -1
  69. package/lib/public/assets/{users-Drd7ifJx.js → users-BU0nXIVe.js} +1 -1
  70. package/lib/public/assets/{users-CEJ3fC78.js → users-Nb_rzRmF.js} +1 -1
  71. package/lib/public/assets/{video-off-CNzn6bPE.js → video-off-CUNzD2kH.js} +1 -1
  72. package/lib/public/assets/{webhook-settings-BFqX6QXV.js → webhook-settings-Ca3UFXZe.js} +1 -1
  73. package/lib/public/assets/{zap-4yN8Jdfw.js → zap-B2bH2vKX.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 +11 -104
  82. package/lib/src/app/routers/grid.js +28 -8
  83. package/lib/src/app/routers/nodePhoneControl.js +301 -0
  84. package/lib/src/app/ws/h264StreamWs.js +43 -12
  85. package/lib/src/app/ws/logcatWs.js +48 -14
  86. package/lib/src/app/ws/nodeSocketRelay.js +167 -0
  87. package/lib/src/app/ws/upgradeRouter.js +215 -0
  88. package/lib/src/dashboard/event-manager.js +7 -6
  89. package/lib/src/data-service/device-service.js +152 -49
  90. package/lib/src/data-service/device-store.js +92 -10
  91. package/lib/src/data-service/deviceClaims.js +116 -0
  92. package/lib/src/data-service/deviceFieldOwners.js +32 -10
  93. package/lib/src/data-service/prisma-store.js +102 -31
  94. package/lib/src/device-managers/AndroidDeviceManager.js +40 -42
  95. package/lib/src/device-managers/HealthMonitorService.js +17 -2
  96. package/lib/src/device-managers/IOSDeviceManager.js +5 -1
  97. package/lib/src/device-managers/NodeDevices.js +7 -2
  98. package/lib/src/device-managers/android/AndroidH264StreamService.js +13 -5
  99. package/lib/src/device-managers/android/AndroidStreamService.js +9 -6
  100. package/lib/src/device-managers/android/previewHold.js +2 -2
  101. package/lib/src/device-managers/ios/IOSDiscoveryService.js +19 -8
  102. package/lib/src/device-managers/ios/IOSStreamService.js +162 -40
  103. package/lib/src/device-managers/localDeviceHosts.js +86 -0
  104. package/lib/src/device-managers/ownDeviceRow.js +38 -0
  105. package/lib/src/device-utils.js +78 -24
  106. package/lib/src/gateway/createHandoff.js +73 -0
  107. package/lib/src/gateway/defaultGateway.js +80 -0
  108. package/lib/src/gateway/forwardToNode.js +147 -0
  109. package/lib/src/gateway/hubSessionToken.js +356 -0
  110. package/lib/src/gateway/internalCall.js +90 -0
  111. package/lib/src/gateway/nodeSessionStatus.js +140 -0
  112. package/lib/src/gateway/nodeWebDriverUrl.js +139 -0
  113. package/lib/src/gateway/sessionCreate.js +136 -0
  114. package/lib/src/gateway/sessionGateway.js +201 -0
  115. package/lib/src/gateway/sessionLocator.js +100 -0
  116. package/lib/src/generated/client/edge.js +7 -4
  117. package/lib/src/generated/client/index-browser.js +4 -1
  118. package/lib/src/generated/client/index.d.ts +112 -1
  119. package/lib/src/generated/client/index.js +7 -4
  120. package/lib/src/generated/client/package.json +1 -1
  121. package/lib/src/generated/client/schema.prisma +9 -0
  122. package/lib/src/generated/client/wasm.js +4 -1
  123. package/lib/src/helpers/index.js +5 -1
  124. package/lib/src/logger.js +5 -0
  125. package/lib/src/middleware/authMiddleware.js +80 -0
  126. package/lib/src/middleware/commandAuth.js +31 -17
  127. package/lib/src/middleware/csrfMiddleware.js +3 -2
  128. package/lib/src/middleware/deviceAccessGuard.js +4 -1
  129. package/lib/src/middleware/sessionListingFilter.js +153 -0
  130. package/lib/src/middleware/sessionUpgradeGuard.js +203 -0
  131. package/lib/src/plugin.js +27 -1
  132. package/lib/src/prisma.js +2 -1
  133. package/lib/src/services/DeviceReconciler.js +5 -2
  134. package/lib/src/services/OrphanSweeper.js +12 -19
  135. package/lib/src/services/PortAllocator.js +75 -16
  136. package/lib/src/services/ServerManager.js +63 -30
  137. package/lib/src/services/SessionLifecycleService.js +335 -76
  138. package/lib/src/services/ShutdownCoordinator.js +7 -1
  139. package/lib/src/services/SocketServer.js +11 -2
  140. package/lib/src/services/device-access/LiveSessionOwners.js +53 -0
  141. package/lib/src/services/device-access/SessionOwnerResolver.js +74 -0
  142. package/lib/src/services/device-access/ticketActorAccess.js +3 -1
  143. package/lib/src/services/lease/LeaseOrphanSweeper.js +34 -6
  144. package/lib/src/services/lease/LeaseService.js +19 -5
  145. package/lib/src/services/session/nodeCreateCaps.js +55 -0
  146. package/lib/src/services/session/sessionCredentials.js +7 -59
  147. package/lib/src/sessions/CloudSession.js +12 -0
  148. package/lib/src/sessions/LocalSession.js +52 -26
  149. package/lib/src/sessions/RemoteSession.js +116 -9
  150. package/lib/src/sessions/SessionManager.js +49 -6
  151. package/lib/src/sessions/appiumUmbrella.js +32 -0
  152. package/lib/test/helpers/appium-umbrella.js +84 -0
  153. package/lib/test/helpers/fake-device-table.js +97 -0
  154. package/lib/test/helpers/loki-stores.js +41 -0
  155. package/lib/test/helpers/loopbackRequest.js +77 -0
  156. package/lib/test/helpers/own-node-id.js +29 -0
  157. package/lib/test/helpers/scratch-database.js +100 -0
  158. package/lib/test/helpers/scratch-port-leases.js +119 -0
  159. package/lib/test/helpers/test-container.js +35 -27
  160. package/lib/test/integration/androidDevices.spec.js +5 -4
  161. package/lib/test/integration/ios/01iOSSimulator.spec.js +6 -5
  162. package/lib/test/integration/ios/02iOSDevices.spec.js +3 -2
  163. package/lib/test/integration/prisma-device-store-add-devices.spec.js +14 -4
  164. package/lib/test/setup/scratch-default-database.js +32 -0
  165. package/lib/test/unit/AndroidDeviceManager.spec.js +56 -1
  166. package/lib/test/unit/DeviceModel.spec.js +9 -9
  167. package/lib/test/unit/HealingCascade.test.js +5 -1
  168. package/lib/test/unit/OrphanSweeper.test.js +17 -13
  169. package/lib/test/unit/SessionHeartbeatWrite.test.js +6 -7
  170. package/lib/test/unit/StreamTicketService.spec.js +4 -1
  171. package/lib/test/unit/TokenEndpoint.spec.js +17 -3
  172. package/lib/test/unit/aggregate-hotspots-session-scope.spec.js +5 -5
  173. package/lib/test/unit/android-h264-service.spec.js +24 -0
  174. package/lib/test/unit/android-stream-stall-eof.spec.js +10 -0
  175. package/lib/test/unit/apps-team-visibility.spec.js +16 -16
  176. package/lib/test/unit/auth-me-auth-disabled.test.js +7 -4
  177. package/lib/test/unit/authMiddleware.appTicket.spec.js +5 -5
  178. package/lib/test/unit/authMiddleware.bearer.spec.js +4 -1
  179. package/lib/test/unit/authMiddleware.mcpAudience.spec.js +4 -1
  180. package/lib/test/unit/authMiddleware.streamTicket.spec.js +19 -7
  181. package/lib/test/unit/authMiddleware.test.js +3 -0
  182. package/lib/test/unit/bug-report/route.spec.js +9 -9
  183. package/lib/test/unit/cliargs-admin-only.spec.js +3 -3
  184. package/lib/test/unit/clipboard-route.spec.js +22 -8
  185. package/lib/test/unit/command-auth-appium-server.spec.js +187 -32
  186. package/lib/test/unit/command-auth.spec.js +31 -31
  187. package/lib/test/unit/control-device-lookup.spec.js +9 -9
  188. package/lib/test/unit/control-forward-decision.spec.js +115 -0
  189. package/lib/test/unit/control-lookup-failure.spec.js +3 -3
  190. package/lib/test/unit/create-error-message.spec.js +43 -0
  191. package/lib/test/unit/create-handoff.spec.js +86 -0
  192. package/lib/test/unit/dashboard-events-team-scope.spec.js +6 -3
  193. package/lib/test/unit/device-access-guard.spec.js +66 -28
  194. package/lib/test/unit/device-service.spec.js +4 -0
  195. package/lib/test/unit/device-store-clear-at-boot.spec.js +48 -0
  196. package/lib/test/unit/device-sync-lost-update.spec.js +17 -89
  197. package/lib/test/unit/device-team-guard.spec.js +20 -20
  198. package/lib/test/unit/device-utils.spec.js +8 -0
  199. package/lib/test/unit/extract-team-cap.spec.js +65 -0
  200. package/lib/test/unit/grid-admin-only.spec.js +3 -3
  201. package/lib/test/unit/healing-tier-gate.spec.js +5 -1
  202. package/lib/test/unit/health-monitor-own-phones.spec.js +102 -0
  203. package/lib/test/unit/health-monitor-stream-lookup.spec.js +11 -1
  204. package/lib/test/unit/helpers.spec.js +27 -0
  205. package/lib/test/unit/hub-boot-sweep-recovery.spec.js +192 -0
  206. package/lib/test/unit/hub-create-once.spec.js +190 -0
  207. package/lib/test/unit/hub-create-session-umbrella.spec.js +160 -0
  208. package/lib/test/unit/hub-gateway-create-appium-server.spec.js +591 -0
  209. package/lib/test/unit/hub-node-control-forward.spec.js +608 -0
  210. package/lib/test/unit/hub-node-gateway-appium-server.spec.js +594 -0
  211. package/lib/test/unit/hub-node-race.spec.js +426 -0
  212. package/lib/test/unit/hub-node-same-host.spec.js +356 -0
  213. package/lib/test/unit/hub-register-removal.spec.js +165 -0
  214. package/lib/test/unit/hub-remote-session-heartbeat.spec.js +277 -0
  215. package/lib/test/unit/hub-routed-session-recovery.spec.js +151 -0
  216. package/lib/test/unit/hub-session-token.spec.js +265 -0
  217. package/lib/test/unit/insert-before-routes.spec.js +5 -5
  218. package/lib/test/unit/install-repository-app-team.spec.js +6 -2
  219. package/lib/test/unit/interceptor/MitmProxyHost.spec.js +23 -10
  220. package/lib/test/unit/internal-call.spec.js +181 -0
  221. package/lib/test/unit/ios-discovery-same-host.spec.js +193 -0
  222. package/lib/test/unit/ios-stream-appium-tunnel.spec.js +317 -0
  223. package/lib/test/unit/ios-stream-port-leases.spec.js +299 -0
  224. package/lib/test/unit/ios-wda-status-identity.spec.js +84 -0
  225. package/lib/test/unit/is-xenon-running.spec.js +69 -0
  226. package/lib/test/unit/lease/LeaseOrphanSweeper.spec.js +2 -2
  227. package/lib/test/unit/lease/LeaseService.spec.js +28 -1
  228. package/lib/test/unit/lease/lease-reap-held-phone.spec.js +154 -0
  229. package/lib/test/unit/lease/lease-sweeper-expiry.spec.js +107 -0
  230. package/lib/test/unit/lease/lease-team-visibility.spec.js +2 -2
  231. package/lib/test/unit/lease/lease-token-log-filter.spec.js +242 -64
  232. package/lib/test/unit/lease/lease-token-oracle.spec.js +20 -7
  233. package/lib/test/unit/lease/session-lease-proof.spec.js +8 -3
  234. package/lib/test/unit/logcat-ws.spec.js +12 -1
  235. package/lib/test/unit/loopback-request.spec.js +81 -0
  236. package/lib/test/unit/mark-node-busy-loki.spec.js +53 -0
  237. package/lib/test/unit/node-create-caps.spec.js +54 -0
  238. package/lib/test/unit/node-gateway-create-appium-server.spec.js +354 -0
  239. package/lib/test/unit/node-hub-session-owner.spec.js +223 -0
  240. package/lib/test/unit/node-phone-control.spec.js +131 -0
  241. package/lib/test/unit/node-socket-relay.spec.js +357 -0
  242. package/lib/test/unit/omni-vision.spec.js +7 -3
  243. package/lib/test/unit/plugin.spec.js +45 -4
  244. package/lib/test/unit/port-allocator-os-probe.spec.js +151 -0
  245. package/lib/test/unit/profile-router.test.js +11 -11
  246. package/lib/test/unit/queue-requester.spec.js +5 -0
  247. package/lib/test/unit/queue-team-scope-routes.spec.js +17 -16
  248. package/lib/test/unit/queue-visibility.spec.js +1 -1
  249. package/lib/test/unit/recording-downloads-visibility.spec.js +2 -2
  250. package/lib/test/unit/recording-instrumentation.spec.js +5 -0
  251. package/lib/test/unit/recording-source-ended.spec.js +5 -0
  252. package/lib/test/unit/recording-store.spec.js +9 -3
  253. package/lib/test/unit/recordings-library-routes.spec.js +51 -51
  254. package/lib/test/unit/recordings-write-visibility.spec.js +2 -2
  255. package/lib/test/unit/remote-session-create.spec.js +130 -0
  256. package/lib/test/unit/reservation-team-visibility.spec.js +5 -5
  257. package/lib/test/unit/reset-link.test.js +14 -14
  258. package/lib/test/unit/session-app-resolution.spec.js +14 -10
  259. package/lib/test/unit/session-assets.spec.js +9 -9
  260. package/lib/test/unit/session-attribution-wiring.spec.js +29 -2
  261. package/lib/test/unit/session-create-layer.spec.js +255 -0
  262. package/lib/test/unit/session-credential-strip.spec.js +25 -9
  263. package/lib/test/unit/session-credentials.spec.js +0 -47
  264. package/lib/test/unit/session-listing-auth.spec.js +345 -0
  265. package/lib/test/unit/session-locator.spec.js +250 -0
  266. package/lib/test/unit/session-node-calls.spec.js +174 -0
  267. package/lib/test/unit/session-owner-resolver.spec.js +107 -0
  268. package/lib/test/unit/session-recovery-routing.spec.js +118 -0
  269. package/lib/test/unit/session-upgrade-guard.spec.js +571 -0
  270. package/lib/test/unit/shutdown-drain-remote.spec.js +58 -0
  271. package/lib/test/unit/socketServer.bearer.spec.js +3 -1
  272. package/lib/test/unit/stream-leave-route.spec.js +19 -7
  273. package/lib/test/unit/stream-own-device-row.spec.js +164 -0
  274. package/lib/test/unit/stream-stop-ownership.spec.js +17 -5
  275. package/lib/test/unit/stream-stop-recording.spec.js +17 -5
  276. package/lib/test/unit/stream-ticket-identity.spec.js +26 -6
  277. package/lib/test/unit/stream-watchdog-timers.spec.js +46 -0
  278. package/lib/test/unit/upgrade-router.spec.js +446 -0
  279. package/lib/test/unit/users-router.test.js +11 -8
  280. package/lib/test/unit/verify-credential.spec.js +4 -1
  281. package/lib/test/unit/webdriver-info-route.spec.js +64 -0
  282. package/lib/test/unit/ws-upgrade-routing-appium-server.spec.js +500 -0
  283. package/lib/tsconfig.tsbuildinfo +1 -1
  284. package/package.json +3 -4
  285. package/prisma/migrations/20260929120000_device_claims/migration.sql +8 -0
  286. package/prisma/schema.prisma +9 -0
  287. package/lib/public/assets/BugReportButton-DKYsOdGV.js +0 -10
  288. package/lib/public/assets/device-explorer-DV8rWRxL.css +0 -1
  289. package/lib/src/proxy/wd-command-proxy.js +0 -221
package/lib/src/plugin.js CHANGED
@@ -60,9 +60,12 @@ const AICommandService_1 = require("./services/AICommandService");
60
60
  const TracingService_1 = require("./services/TracingService");
61
61
  const CommandInterceptor_1 = require("./interceptors/CommandInterceptor");
62
62
  const SessionLifecycleService_1 = require("./services/SessionLifecycleService");
63
+ const createHandoff_1 = require("./gateway/createHandoff");
63
64
  const ServerManager_1 = require("./services/ServerManager");
64
65
  const IPluginArgs_1 = require("./interfaces/IPluginArgs");
65
66
  const NodeDevices_1 = __importDefault(require("./device-managers/NodeDevices"));
67
+ const appiumUmbrella_1 = require("./sessions/appiumUmbrella");
68
+ const LiveSessionOwners_1 = require("./services/device-access/LiveSessionOwners");
66
69
  const config_1 = require("./config");
67
70
  const SessionManager_1 = require("./sessions/SessionManager");
68
71
  const event_manager_1 = require("./dashboard/event-manager");
@@ -94,6 +97,7 @@ class XenonPlugin extends base_plugin_1.default {
94
97
  return __awaiter(this, void 0, void 0, function* () {
95
98
  var _a;
96
99
  const sessionId = driver.sessionId;
100
+ typedi_1.Container.get(LiveSessionOwners_1.LiveSessionOwners).forget(sessionId);
97
101
  const deviceFilter = {
98
102
  session_id: sessionId ? sessionId : undefined,
99
103
  udid: driver.caps && driver.caps.udid ? driver.caps.udid : undefined,
@@ -105,6 +109,10 @@ class XenonPlugin extends base_plugin_1.default {
105
109
  hubToken: config_1.config.hubToken,
106
110
  }).unblockDevice(deviceFilter);
107
111
  }
112
+ else if (sessionId) {
113
+ // Keyed on the session: its phone may be another session's by now.
114
+ yield (0, device_service_1.releaseSessionDevices)(sessionId);
115
+ }
108
116
  else {
109
117
  yield (0, device_service_1.unblockDeviceMatchingFilter)(deviceFilter);
110
118
  }
@@ -168,7 +176,25 @@ class XenonPlugin extends base_plugin_1.default {
168
176
  if (caps && (!Array.isArray(caps.firstMatch) || caps.firstMatch.length === 0)) {
169
177
  caps.firstMatch = [{}];
170
178
  }
171
- return yield typedi_1.Container.get(SessionLifecycleService_1.SessionLifecycleService).createSession(next, driver, caps);
179
+ // The umbrella: where a node answers the hub's "does this session exist"
180
+ // without sending the session a command (gateway/nodeSessionStatus.ts).
181
+ typedi_1.Container.get(appiumUmbrella_1.AppiumUmbrella).note(driver);
182
+ const lifecycle = typedi_1.Container.get(SessionLifecycleService_1.SessionLifecycleService);
183
+ // The session gateway allocated this request's phone in front of Appium's
184
+ // route (gateway/sessionCreate.ts) and handed it here through the
185
+ // request's async context. It only ever hands over a phone this server
186
+ // drives: it creates a node's session itself and answers it, so Appium
187
+ // never has to hold a session it has no driver for.
188
+ const handoff = (0, createHandoff_1.currentCreateHandoff)();
189
+ if (handoff)
190
+ return yield lifecycle.completeLocalSession(handoff.take(), next, driver, caps);
191
+ // A create that did not come through the gateway (it could not be placed
192
+ // ahead of Appium's routes). A phone this server drives is still created
193
+ // here; one on another server is refused, since answering for Appium
194
+ // without calling next() is what broke its umbrella driver.
195
+ this.xenonLog.warn("createSession reached the plugin without the session gateway's allocation; allocating " +
196
+ 'here. Sessions on phones other servers drive need the session gateway, and are refused.');
197
+ return yield lifecycle.createSession(next, driver, caps, { localOnly: true });
172
198
  });
173
199
  }
174
200
  deleteSession(next, driver, sessionId) {
package/lib/src/prisma.js CHANGED
@@ -36,7 +36,8 @@ const MODEL_DELEGATES = new Set([
36
36
  'build', 'session', 'sessionLog', 'log', 'profiling', 'app', 'device',
37
37
  'pendingSession', 'cLIArgs', 'webhookConfig', 'webConfig', 'locatorEtalon',
38
38
  'lease', 'portLease', 'apiKey', 'selectorState', 'user', 'userSession',
39
- 'passwordResetToken', 'team', 'teamMember', 'eventLog', 'project',
39
+ 'passwordResetToken', 'team', 'teamMember', 'eventLog', 'project', 'recording', 'bookmark',
40
+ 'annotation',
40
41
  ]);
41
42
  /** Cache of plain-object wrappers, keyed by model name. */
42
43
  const _modelWrappers = new Map();
@@ -78,8 +78,11 @@ let DeviceReconciler = DeviceReconciler_1 = class DeviceReconciler {
78
78
  .join(', ')}`);
79
79
  for (const device of orphans) {
80
80
  try {
81
- yield (0, device_service_1.unblockDevice)(device.udid, device.host);
82
- this.orphansFreed++;
81
+ // Keyed on the session read above: if it ended meanwhile and the
82
+ // phone went to another session, that one keeps it.
83
+ if (yield (0, device_service_1.releaseSessionDevice)(device.udid, device.host, device.session_id)) {
84
+ this.orphansFreed++;
85
+ }
83
86
  }
84
87
  catch (err) {
85
88
  this.logger.error(`Failed to unblock ghost device ${device.udid}: ${err.message}`);
@@ -24,6 +24,7 @@ const prisma_1 = require("../prisma");
24
24
  const logger_1 = __importDefault(require("../logger"));
25
25
  const event_manager_1 = require("../dashboard/event-manager");
26
26
  const SessionStatus_1 = require("../types/SessionStatus");
27
+ const device_service_1 = require("../data-service/device-service");
27
28
  let OrphanSweeper = class OrphanSweeper {
28
29
  constructor() {
29
30
  this.log = logger_1.default.scope('OrphanSweeper');
@@ -60,25 +61,17 @@ let OrphanSweeper = class OrphanSweeper {
60
61
  this.log.info(`Sweeping ${stale.length} orphaned session(s)`);
61
62
  for (const s of stale) {
62
63
  try {
63
- yield prisma_1.prisma.$transaction([
64
- prisma_1.prisma.session.update({
65
- where: { id: s.id },
66
- data: {
67
- status: 'failed',
68
- failure_reason: 'Session heartbeat timeout',
69
- endTime: new Date(),
70
- },
71
- }),
72
- prisma_1.prisma.device.updateMany({
73
- where: { udid: s.device_udid },
74
- data: {
75
- busy: false,
76
- session_id: null,
77
- owningSessionId: null,
78
- lockedAt: null,
79
- },
80
- }),
81
- ]);
64
+ yield prisma_1.prisma.session.update({
65
+ where: { id: s.id },
66
+ data: {
67
+ status: 'failed',
68
+ failure_reason: 'Session heartbeat timeout',
69
+ endTime: new Date(),
70
+ },
71
+ });
72
+ // This session's claim only, not every row with its udid: the phone
73
+ // may be another session's by now (deviceClaims.ts).
74
+ yield (0, device_service_1.releaseSessionDevices)(s.id);
82
75
  // Event emit stays outside the transaction (side-effect with own error boundary)
83
76
  yield event_manager_1.DASHBORD_EVENT_MANAGER.onSessionStopped(s.id, SessionStatus_1.SessionStatus.FAILED, 'Session heartbeat timeout');
84
77
  }
@@ -32,6 +32,13 @@ const DEFAULT_RANGES = {
32
32
  system: [10100, 10199],
33
33
  proxy: [11100, 11199],
34
34
  };
35
+ /** Loopback and wildcard, in both families. See isOsFree. */
36
+ const PROBE_HOSTS = ['127.0.0.1', '::1', '0.0.0.0', '::'];
37
+ /**
38
+ * Bind errors meaning the host can't use that address at all (IPv6 disabled),
39
+ * not that something holds the port. Any other error still counts as in use.
40
+ */
41
+ const ADDRESS_UNAVAILABLE = new Set(['EADDRNOTAVAIL', 'EAFNOSUPPORT']);
35
42
  class PortRangeExhaustedError extends Error {
36
43
  constructor(purpose) {
37
44
  super(`Port range for purpose '${purpose}' is exhausted`);
@@ -166,9 +173,43 @@ let PortAllocator = class PortAllocator {
166
173
  .catch(() => undefined);
167
174
  });
168
175
  }
169
- release(port) {
176
+ /**
177
+ * Lease a port `udid` is already serving on. acquire() would refuse it,
178
+ * because its OS probe sees the listener, and that listener is the device's
179
+ * own: iOS attaching to the WDA an Appium session forwards is the case. The
180
+ * lease is what keeps the port from being handed to another device once the
181
+ * listener closes.
182
+ *
183
+ * Returns false, and leaves the row alone, when another udid holds an
184
+ * unexpired lease on the port.
185
+ */
186
+ claim(purpose_1, udid_1, port_1) {
187
+ return __awaiter(this, arguments, void 0, function* (purpose, udid, port, opts = {}) {
188
+ var _a;
189
+ const now = Date.now();
190
+ const ttlMs = (_a = opts.ttlMs) !== null && _a !== void 0 ? _a : 60 * 60 * 1000;
191
+ const held = yield prisma_1.prisma.portLease.findUnique({ where: { port } });
192
+ if (held && held.leasedToUdid !== udid && held.expiresAt >= now)
193
+ return false;
194
+ const lease = { purpose, leasedToUdid: udid, leasedAt: now, expiresAt: now + ttlMs };
195
+ yield prisma_1.prisma.portLease.upsert({
196
+ where: { port },
197
+ create: Object.assign({ port }, lease),
198
+ update: lease,
199
+ });
200
+ return true;
201
+ });
202
+ }
203
+ /**
204
+ * Release `port` if `udid` holds it. A port number alone doesn't say whose
205
+ * lease it is: a stale session releasing "its" port used to delete whatever
206
+ * lease held that number by then, including another device's live one.
207
+ */
208
+ release(port, udid) {
170
209
  return __awaiter(this, void 0, void 0, function* () {
171
- yield prisma_1.prisma.portLease.delete({ where: { port } }).catch(() => undefined);
210
+ yield prisma_1.prisma.portLease
211
+ .deleteMany({ where: { port, leasedToUdid: udid } })
212
+ .catch(() => undefined);
172
213
  });
173
214
  }
174
215
  /**
@@ -195,23 +236,41 @@ let PortAllocator = class PortAllocator {
195
236
  yield prisma_1.prisma.portLease.deleteMany({ where: { expiresAt: { lt: Date.now() } } });
196
237
  });
197
238
  }
239
+ /**
240
+ * Whether nothing listens on `port` on any local address.
241
+ *
242
+ * One bind can't tell. On macOS a bind to 127.0.0.1 succeeds beside a
243
+ * wildcard listener, and the more specific socket then takes every
244
+ * 127.0.0.1 connection. iproxy listens on the IPv6 wildcard, so a
245
+ * 127.0.0.1-only probe called an iPhone's 9100 free, the S9+'s MJPEG server
246
+ * bound 127.0.0.1:9100 over it, and the iPhone's recording showed the S9+.
247
+ *
248
+ * Each listener shape fails at least one of these binds (checked on macOS
249
+ * with Node's default SO_REUSEADDR): 127.0.0.1 → 127.0.0.1, ::1 → ::1,
250
+ * 0.0.0.0 → 0.0.0.0, dual-stack :: → 0.0.0.0 and ::, v6-only :: → ::.
251
+ */
198
252
  isOsFree(port) {
253
+ return __awaiter(this, void 0, void 0, function* () {
254
+ let probed = 0;
255
+ // One at a time: probes in flight together can fail on each other (a
256
+ // 0.0.0.0 bind fails beside a :: one).
257
+ for (const host of PROBE_HOSTS) {
258
+ const code = yield this.bindProbe(port, host);
259
+ if (code === null)
260
+ probed++;
261
+ else if (!ADDRESS_UNAVAILABLE.has(code))
262
+ return false;
263
+ }
264
+ return probed > 0;
265
+ });
266
+ }
267
+ /** Bind `host:port` and close it again. Resolves null on success, else the error code. */
268
+ bindProbe(port, host) {
199
269
  return new Promise((resolve) => {
200
270
  const server = net_1.default.createServer();
201
- let settled = false;
202
- const done = (ok) => {
203
- if (settled)
204
- return;
205
- settled = true;
206
- server.removeAllListeners();
207
- if (ok)
208
- server.close(() => resolve(true));
209
- else
210
- resolve(false);
211
- };
212
- server.once('error', () => done(false));
213
- server.once('listening', () => done(true));
214
- server.listen(port, '127.0.0.1');
271
+ server.once('error', (err) => { var _a; return resolve((_a = err.code) !== null && _a !== void 0 ? _a : 'UNKNOWN'); });
272
+ server.once('listening', () => server.close(() => resolve(null)));
273
+ server.listen(port, host);
215
274
  });
216
275
  }
217
276
  };
@@ -63,6 +63,8 @@ const networkAddresses_1 = require("../helpers/networkAddresses");
63
63
  const logger_1 = __importDefault(require("../logger"));
64
64
  const h264StreamWs_1 = require("../app/ws/h264StreamWs");
65
65
  const logcatWs_1 = require("../app/ws/logcatWs");
66
+ const nodeSocketRelay_1 = require("../app/ws/nodeSocketRelay");
67
+ const upgradeRouter_1 = require("../app/ws/upgradeRouter");
66
68
  const StreamTicketService_1 = require("./token/StreamTicketService");
67
69
  const AndroidH264StreamService_1 = __importDefault(require("../device-managers/android/AndroidH264StreamService"));
68
70
  const LogcatStreamService_1 = require("../device-managers/android/LogcatStreamService");
@@ -77,16 +79,16 @@ const config_service_1 = require("../data-service/config-service");
77
79
  const PluginContext_1 = require("../PluginContext");
78
80
  const device_store_1 = require("../data-service/device-store");
79
81
  const device_utils_1 = require("../device-utils");
82
+ const localDeviceHosts_1 = require("../device-managers/localDeviceHosts");
80
83
  const app_1 = require("../app");
81
- const wd_command_proxy_1 = require("../proxy/wd-command-proxy");
82
84
  const registerCommandAuth_1 = require("../app/registerCommandAuth");
85
+ const defaultGateway_1 = require("../gateway/defaultGateway");
83
86
  const appium_adb_1 = require("appium-adb");
84
87
  const ChromeDriverManager_1 = __importDefault(require("../device-managers/ChromeDriverManager"));
85
88
  const AndroidDeviceManager_1 = __importDefault(require("../device-managers/AndroidDeviceManager"));
86
89
  const IOSDeviceManager_1 = __importDefault(require("../device-managers/IOSDeviceManager"));
87
90
  const device_managers_1 = require("../device-managers");
88
91
  const pluginArgs_1 = require("../data-service/pluginArgs");
89
- const NodeDevices_1 = __importDefault(require("../device-managers/NodeDevices"));
90
92
  const config_1 = require("../config");
91
93
  const SocketServer_1 = require("./SocketServer");
92
94
  const SocketClient_1 = require("./SocketClient");
@@ -128,13 +130,17 @@ let ServerManager = ServerManager_1 = class ServerManager {
128
130
  }
129
131
  yield this.syncDatabaseAndAIConfig(pluginArgs);
130
132
  yield this.initializeCoreSubsystems(pluginArgs, cliArgs.port);
131
- this.registerRoutes(expressApp, cliArgs, pluginArgs);
133
+ this.registerRoutes(expressApp, httpServer, cliArgs, pluginArgs);
132
134
  yield this.bootEmulators(pluginArgs);
133
135
  this.registerDependenciesInContainer(pluginArgs, cliArgs, nodeId);
134
- yield this.setupHubOrNode(pluginArgs, cliArgs, httpServer, nodeId);
135
- yield this.setupMaintenanceCrons(pluginArgs);
136
+ // The hosts this server files its own phones under. Every "is this phone
137
+ // mine?" below compares against these exactly: a node on this machine
138
+ // shares the IP and differs only by port.
139
+ const localHosts = (0, localDeviceHosts_1.localDeviceHosts)(pluginArgs, cliArgs.port);
140
+ yield this.setupHubOrNode(pluginArgs, cliArgs, httpServer, localHosts);
141
+ yield this.setupMaintenanceCrons(pluginArgs, localHosts);
136
142
  // Live H.264 preview WebSocket (Android; feature-flagged in the frontend).
137
- // Only claims /stream/h264 — socket.io keeps its own upgrades. Ticket-auth'd.
143
+ // A route of the upgrade router: only /stream/h264 upgrades reach it. Ticket-auth'd.
138
144
  if (httpServer) {
139
145
  (0, h264StreamWs_1.attachH264Ws)(httpServer, {
140
146
  redeem: (ticket, udid) => typedi_1.Container.get(StreamTicketService_1.StreamTicketService).redeem(ticket, udid),
@@ -142,10 +148,12 @@ let ServerManager = ServerManager_1 = class ServerManager {
142
148
  // streaming.androidH264 config itself, so this WS auto-start honours a
143
149
  // { source: 'screenrecord' } rollback just like the REST stream/start.
144
150
  startStream: (udid) => typedi_1.Container.get(AndroidH264StreamService_1.default).start(udid),
151
+ // A node's phone is streamed by its node and relayed from here.
152
+ nodeSocket: (udid, actor) => (0, nodeSocketRelay_1.openNodeSocket)({ udid, actor, path: 'stream/h264' }),
145
153
  });
146
154
  // Continuous logcat WebSocket (Android; replaces the 3s dump-poll).
147
- // Only claims /logcat — socket.io and the h264 WS keep their own
148
- // upgrades. Ticket-auth'd like h264, plus an ownership check h264
155
+ // A route of the upgrade router: only /logcat upgrades reach it.
156
+ // Ticket-auth'd like h264, plus an ownership check h264
149
157
  // doesn't need: logcat routinely carries auth tokens and PII from
150
158
  // whatever app is under test, so it's an ownership-checked read, not
151
159
  // an open one — see docs/superpowers/specs/2026-08-09-logcat-stream-design.md
@@ -186,12 +194,22 @@ let ServerManager = ServerManager_1 = class ServerManager {
186
194
  // server-side level filter applies.
187
195
  return { mux: yield typedi_1.Container.get(LogcatStreamService_1.LogcatStreamService).start(udid) };
188
196
  }),
197
+ // A node's phone: its node streams the log, with the same filter.
198
+ nodeSocket: (udid, actor, filter) => {
199
+ var _a;
200
+ return (0, nodeSocketRelay_1.openNodeSocket)({
201
+ udid,
202
+ actor,
203
+ path: 'logcat',
204
+ query: new URLSearchParams(Object.assign(Object.assign({}, (((_a = filter.levels) === null || _a === void 0 ? void 0 : _a.length) ? { levels: filter.levels.join(',') } : {})), (filter.process ? { process: filter.process } : {}))),
205
+ });
206
+ },
189
207
  });
190
208
  }
191
209
  // Principal Cleaning: Attempt to recover remote sessions before marking as failed
192
210
  const { SessionManager } = yield Promise.resolve().then(() => __importStar(require('../sessions/SessionManager')));
193
211
  const sessionManager = typedi_1.Container.get(SessionManager);
194
- const recoveredCount = yield sessionManager.recoverActiveSessions(nodeId, plugin_1.XenonPlugin.nodeBasePath);
212
+ const recoveredCount = yield sessionManager.recoverActiveSessions(nodeId, plugin_1.XenonPlugin.nodeBasePath, (host) => (0, localDeviceHosts_1.isLocalDeviceHost)(localHosts, host));
195
213
  const recoveredSessionIds = sessionManager.getAllSessions().map((s) => s.getId());
196
214
  if (recoveredCount > 0) {
197
215
  logger_1.default.info(`🔄 Successfully recovered ${recoveredCount} remote sessions`);
@@ -232,9 +250,9 @@ let ServerManager = ServerManager_1 = class ServerManager {
232
250
  const { cleanupZombieSessions } = yield Promise.resolve().then(() => __importStar(require('../dashboard/services/session-service')));
233
251
  yield cleanupZombieSessions(recoveredSessionIds);
234
252
  // Initial device discovery poll to start managers and trackers
235
- yield (0, device_utils_1.updateDeviceList)(pluginArgs.bindHostOrIp, pluginArgs.hub, pluginArgs.tlsRejectUnauthorized);
253
+ yield (0, device_utils_1.updateDeviceList)(localHosts, pluginArgs.hub, pluginArgs.tlsRejectUnauthorized);
236
254
  // remove stale devices
237
- yield (0, device_utils_1.removeStaleDevices)(pluginArgs.bindHostOrIp, pluginArgs.tlsRejectUnauthorized);
255
+ yield (0, device_utils_1.removeStaleDevices)(localHosts, pluginArgs.tlsRejectUnauthorized);
238
256
  this.logger.info(`🚀 Xenon will be served at http://${pluginArgs.bindHostOrIp}:${cliArgs.port}/xenon with id ${nodeId}`);
239
257
  this.logger.info('You can provide the following URLs to access Xenon:');
240
258
  for (const url of (0, networkAddresses_1.listReachableBaseUrls)(cliArgs.port)) {
@@ -314,7 +332,8 @@ let ServerManager = ServerManager_1 = class ServerManager {
314
332
  yield (0, device_utils_1.initializeStorage)();
315
333
  const { runMigrations } = yield Promise.resolve().then(() => __importStar(require('../scripts/run-migrations')));
316
334
  yield runMigrations();
317
- yield device_store_1.DeviceStoreFactory.getStore().clearStorage();
335
+ // A hub keeps its nodes' phones: the sessions on them outlive its restart.
336
+ yield device_store_1.DeviceStoreFactory.getStore().clearStorage((0, localDeviceHosts_1.devicesClearedAtBoot)(pluginArgs, (0, localDeviceHosts_1.localDeviceHosts)(pluginArgs, port)));
318
337
  const { bootstrapIdentity } = yield Promise.resolve().then(() => __importStar(require('./identity/bootstrap')));
319
338
  yield bootstrapIdentity();
320
339
  typedi_1.Container.get(EmailService_1.EmailService).warnIfLogFallbackEnabled();
@@ -345,15 +364,33 @@ let ServerManager = ServerManager_1 = class ServerManager {
345
364
  typedi_1.Container.set(ARTIFACT_STORE, new FsArtifactStore(config_1.config.recordingsAssetsPath));
346
365
  });
347
366
  }
348
- registerRoutes(expressApp, cliArgs, pluginArgs) {
367
+ registerRoutes(expressApp, httpServer, cliArgs, pluginArgs) {
349
368
  expressApp.use('/xenon', (0, app_1.createRouter)(pluginArgs));
350
- // Per-command auth (XENON_REQUIRE_COMMAND_AUTH) goes in front of Appium's
351
- // routes, and is placed before the proxy middleware so that, where the
352
- // proxy is also spliced ahead of the routes, a hub checks a command before
353
- // forwarding it to a node. Throws when the setting is on and it cannot be
354
- // placed, rather than serve session commands unchecked.
355
- (0, registerCommandAuth_1.registerCommandAuth)(expressApp, cliArgs);
356
- (0, wd_command_proxy_1.registerProxyMiddlware)(expressApp, cliArgs);
369
+ // The session gateway goes in front of Appium's routes: Xenon's own
370
+ // /wd-internal calls (with the per-process secret), per-command auth
371
+ // (XENON_REQUIRE_COMMAND_AUTH), then, on a hub, forwarding of the sessions
372
+ // nodes run, so a hub checks a command before forwarding it and Appium's
373
+ // umbrella never sees a remote session. A node accepts the hub's session
374
+ // token in place of the client's credentials. With it go the filter on
375
+ // Appium's session listing and the guard on session WebSockets (BiDi,
376
+ // driver /ws/session/...), which upgrade outside the routes. One set of
377
+ // deps, so all three share a credential cache. Each throws when the
378
+ // setting is on and it cannot be installed, rather than serve sessions
379
+ // unchecked.
380
+ const commandAuth = (0, registerCommandAuth_1.commandAuthDeps)();
381
+ (0, registerCommandAuth_1.registerSessionGateway)(expressApp, cliArgs, commandAuth, (0, defaultGateway_1.gatewayOptionsFor)(pluginArgs, cliArgs, () => typedi_1.Container.get(PluginContext_1.PluginContext).nodeId));
382
+ (0, registerCommandAuth_1.registerSessionUpgradeGuard)(httpServer, expressApp, cliArgs, commandAuth);
383
+ // One owner per WebSocket upgrade (upgradeRouter.ts): Xenon's H.264,
384
+ // logcat and socket.io paths go to Xenon, everything else to Appium behind
385
+ // the session guard. Installed after the guard, so the guard never sees
386
+ // Xenon's sockets, and before socket.io and the H.264 / logcat sockets
387
+ // attach to it below. Without it, which side got an upgrade depended on
388
+ // the Node version, and each version broke one side.
389
+ if (httpServer) {
390
+ const router = (0, upgradeRouter_1.upgradeRouterFor)(httpServer);
391
+ this.logger.info("WebSocket upgrades: Xenon's own paths go to Xenon; every other path goes to " +
392
+ `${router.describeAppiumPath()}.`);
393
+ }
357
394
  }
358
395
  bootEmulators(pluginArgs) {
359
396
  return __awaiter(this, void 0, void 0, function* () {
@@ -384,22 +421,18 @@ let ServerManager = ServerManager_1 = class ServerManager {
384
421
  }
385
422
  (0, pluginArgs_1.addCLIArgs)(cliArgs);
386
423
  }
387
- setupHubOrNode(pluginArgs, cliArgs, httpServer, nodeId) {
424
+ setupHubOrNode(pluginArgs, cliArgs, httpServer, localHosts) {
388
425
  return __awaiter(this, void 0, void 0, function* () {
389
426
  const hubArgument = pluginArgs.hub;
390
427
  if (hubArgument !== undefined) {
391
428
  this.logger.info(`📡 I'm a node and my hub is ${hubArgument}`);
392
- yield (0, device_utils_1.setupCronUpdateDeviceList)(pluginArgs.bindHostOrIp, hubArgument, pluginArgs.sendNodeDevicesToHubIntervalMs, pluginArgs.tlsRejectUnauthorized);
429
+ yield (0, device_utils_1.setupCronUpdateDeviceList)(localHosts, hubArgument, pluginArgs.sendNodeDevicesToHubIntervalMs, pluginArgs.tlsRejectUnauthorized);
393
430
  // Handle graceful shutdown
394
431
  ['SIGINT', 'SIGTERM'].forEach((signal) => {
395
432
  process.once(signal, () => __awaiter(this, void 0, void 0, function* () {
396
433
  logger_1.default.info(`Received ${signal}, unregistering node from hub...`);
397
434
  try {
398
- yield new NodeDevices_1.default(hubArgument, {
399
- tlsRejectUnauthorized: pluginArgs.tlsRejectUnauthorized,
400
- hubAccessKey: config_1.config.hubAccessKey,
401
- hubToken: config_1.config.hubToken,
402
- }).unRegisterNode(pluginArgs.bindHostOrIp);
435
+ yield (0, device_utils_1.unregisterNodeFromHub)(hubArgument, localHosts, pluginArgs.tlsRejectUnauthorized, typedi_1.Container.get(PluginContext_1.PluginContext).nodeId);
403
436
  }
404
437
  catch (err) {
405
438
  logger_1.default.error(`Error during node unregistration: ${err}`);
@@ -417,7 +450,7 @@ let ServerManager = ServerManager_1 = class ServerManager {
417
450
  // Principal discovery: Background poll to prune stale/offline devices on the Hub itself
418
451
  (() => __awaiter(this, void 0, void 0, function* () {
419
452
  const { setupCronLocalDiscovery } = yield Promise.resolve().then(() => __importStar(require('../device-utils')));
420
- yield setupCronLocalDiscovery(pluginArgs.bindHostOrIp, pluginArgs.sendNodeDevicesToHubIntervalMs);
453
+ yield setupCronLocalDiscovery(localHosts, pluginArgs.sendNodeDevicesToHubIntervalMs);
421
454
  }))();
422
455
  const socketServer = typedi_1.Container.get(SocketServer_1.SocketServer);
423
456
  socketServer.initialize(httpServer);
@@ -426,12 +459,12 @@ let ServerManager = ServerManager_1 = class ServerManager {
426
459
  }
427
460
  });
428
461
  }
429
- setupMaintenanceCrons(pluginArgs) {
462
+ setupMaintenanceCrons(pluginArgs, localHosts) {
430
463
  return __awaiter(this, void 0, void 0, function* () {
431
464
  var _a;
432
465
  if (!((_a = pluginArgs.cloud) === null || _a === void 0 ? void 0 : _a.cloudName)) {
433
466
  // 1. Check for stale nodes
434
- yield (0, device_utils_1.setupCronCheckStaleDevices)(pluginArgs.checkStaleDevicesIntervalMs, pluginArgs.bindHostOrIp, pluginArgs.tlsRejectUnauthorized);
467
+ yield (0, device_utils_1.setupCronCheckStaleDevices)(pluginArgs.checkStaleDevicesIntervalMs, localHosts, pluginArgs.tlsRejectUnauthorized);
435
468
  // 2. Release blocked devices
436
469
  yield (0, device_utils_1.setupCronReleaseBlockedDevices)(pluginArgs.checkBlockedDevicesIntervalMs, pluginArgs.newCommandTimeoutSec);
437
470
  // 3. Clean up pending sessions