@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
@@ -76,6 +76,7 @@ const config_1 = require("../../config");
76
76
  const device_service_1 = require("../../data-service/device-service");
77
77
  const iOSTracker_1 = require("../iOSTracker");
78
78
  const networkAddresses_1 = require("../../helpers/networkAddresses");
79
+ const localDeviceHosts_1 = require("../localDeviceHosts");
79
80
  /**
80
81
  * Should this simulator be given wda/mjpeg ports the moment we notice it?
81
82
  *
@@ -152,9 +153,13 @@ let IOSDiscoveryService = class IOSDiscoveryService {
152
153
  console.log('[POISON PILL] REAL fetchLocalIOSDevices CALLED IN TEST MODE!');
153
154
  }
154
155
  const devices = yield this.getConnectedDevices();
156
+ // This server's own row for a phone: the exact host getDeviceInfo files
157
+ // it under. A node on this machine sees the same iPhone and shares the
158
+ // IP, so its row for the udid must not be taken for ours.
159
+ const ownHost = (0, localDeviceHosts_1.iosRealDeviceHost)(this.pluginArgs, this.hostPort);
155
160
  const deviceProcessingPromises = devices.map((udid) => __awaiter(this, void 0, void 0, function* () {
156
161
  try {
157
- const existingDevice = existingDeviceDetails.find((device) => device.udid === udid);
162
+ const existingDevice = existingDeviceDetails.find((device) => device.udid === udid && device.host === ownHost);
158
163
  if (existingDevice) {
159
164
  const networkIp = yield this.fetchRealDeviceNetworkIp(udid);
160
165
  return Object.assign(Object.assign({}, existingDevice), { ip: networkIp || (0, networkAddresses_1.sanitizeDeviceNetworkIp)(existingDevice.ip) || '' });
@@ -176,10 +181,10 @@ let IOSDiscoveryService = class IOSDiscoveryService {
176
181
  getDeviceInfo(udid) {
177
182
  return __awaiter(this, void 0, void 0, function* () {
178
183
  const store = device_store_1.DeviceStoreFactory.getStore();
179
- const storeDevice = yield store.findDevice({ udid });
180
- const host = this.pluginArgs.remoteMachineProxyIP
181
- ? String(this.pluginArgs.remoteMachineProxyIP)
182
- : `http://${this.pluginArgs.bindHostOrIp}:${this.hostPort}`;
184
+ const host = (0, localDeviceHosts_1.iosRealDeviceHost)(this.pluginArgs, this.hostPort);
185
+ // Our own row only (udid AND host): it is spread over the result below,
186
+ // and another server's row would hand us its host, ports and busy state.
187
+ const storeDevice = yield store.findDevice({ udid, host });
183
188
  // A real device is going to want these, so ask now — but not at the cost
184
189
  // of the whole discovery pass. See tryAcquire: an exhausted range used to
185
190
  // throw out of here and take the device list with it.
@@ -257,9 +262,11 @@ let IOSDiscoveryService = class IOSDiscoveryService {
257
262
  }
258
263
  const store = device_store_1.DeviceStoreFactory.getStore();
259
264
  const nodeLanIp = this.resolveNodeLanIp();
265
+ // A node on this Mac lists the same simulators; only our own row's ports.
266
+ const simHost = (0, localDeviceHosts_1.iosSimulatorHost)(this.pluginArgs, this.hostPort);
260
267
  return yield Promise.all(simulators.map((d) => __awaiter(this, void 0, void 0, function* () {
261
268
  var _a;
262
- const storeDevice = yield store.findDevice({ udid: d.udid });
269
+ const storeDevice = yield store.findDevice({ udid: d.udid, host: simHost });
263
270
  const willRunWda = simulatorNeedsPortNow(d.state);
264
271
  return Object.assign(Object.assign(Object.assign({}, d), (0, appleIdentity_1.simulatorIdentity)(d.name)), { wdaLocalPort: (storeDevice === null || storeDevice === void 0 ? void 0 : storeDevice.wdaLocalPort) ||
265
272
  (willRunWda
@@ -267,7 +274,7 @@ let IOSDiscoveryService = class IOSDiscoveryService {
267
274
  : undefined), mjpegServerPort: (storeDevice === null || storeDevice === void 0 ? void 0 : storeDevice.mjpegServerPort) ||
268
275
  (willRunWda
269
276
  ? yield typedi_1.Container.get(PortAllocator_1.PortAllocator).tryAcquire('mjpeg', d.udid)
270
- : undefined), busy: false, realDevice: false, platform: (((_a = d.name) === null || _a === void 0 ? void 0 : _a.toLowerCase().includes('tv')) ? 'tvos' : 'ios'), deviceType: 'simulator', host: `http://${this.pluginArgs.bindHostOrIp}:${this.hostPort}`, ip: nodeLanIp, totalUtilizationTimeMilliSec: yield (0, device_utils_1.getUtilizationTime)(d.udid), sessionStartTime: 0 });
277
+ : undefined), busy: false, realDevice: false, platform: (((_a = d.name) === null || _a === void 0 ? void 0 : _a.toLowerCase().includes('tv')) ? 'tvos' : 'ios'), deviceType: 'simulator', host: simHost, ip: nodeLanIp, totalUtilizationTimeMilliSec: yield (0, device_utils_1.getUtilizationTime)(d.udid), sessionStartTime: 0 });
271
278
  })));
272
279
  });
273
280
  }
@@ -323,7 +330,11 @@ let IOSDiscoveryService = class IOSDiscoveryService {
323
330
  }
324
331
  }));
325
332
  tracker.on('detached', (udid) => __awaiter(this, void 0, void 0, function* () {
326
- const deviceRemoved = [{ udid, host: this.pluginArgs.bindHostOrIp }];
333
+ // The host 'attached' filed it under (getDeviceInfo), exact: a bare IP
334
+ // is matched as a substring and takes other servers' rows with it.
335
+ const deviceRemoved = [
336
+ { udid, host: (0, localDeviceHosts_1.iosRealDeviceHost)(this.pluginArgs, this.hostPort), nodeId: this.nodeId },
337
+ ];
327
338
  if (this.pluginArgs.hub) {
328
339
  yield new NodeDevices_1.default(this.pluginArgs.hub, {
329
340
  tlsRejectUnauthorized: this.pluginArgs.tlsRejectUnauthorized,
@@ -60,8 +60,9 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
60
60
  return (mod && mod.__esModule) ? mod : { "default": mod };
61
61
  };
62
62
  Object.defineProperty(exports, "__esModule", { value: true });
63
- exports.IOS_IDLE_STOP_MS = void 0;
63
+ exports.StreamRestartRefused = exports.IOS_IDLE_STOP_MS = void 0;
64
64
  exports.shouldStopIdleIosStream = shouldStopIdleIosStream;
65
+ exports.heldByAppiumSession = heldByAppiumSession;
65
66
  const typedi_1 = require("typedi");
66
67
  const ProcessRegistry_1 = require("../../services/ProcessRegistry");
67
68
  const child_process_1 = require("child_process");
@@ -77,6 +78,7 @@ const helpers_1 = require("../../helpers");
77
78
  const singleFlight_1 = require("../../helpers/singleFlight");
78
79
  const PortAllocator_1 = require("../../services/PortAllocator");
79
80
  const device_store_1 = require("../../data-service/device-store");
81
+ const ownDeviceRow_1 = require("../ownDeviceRow");
80
82
  const iosStreamDiagnostics_1 = require("./iosStreamDiagnostics");
81
83
  const tunnelProcess_1 = require("./tunnelProcess");
82
84
  const device_service_1 = require("../../data-service/device-service");
@@ -98,6 +100,25 @@ function shouldStopIdleIosStream(use) {
98
100
  return false;
99
101
  return !use.sessionId || (0, manualLock_1.isManualLock)(use.sessionId);
100
102
  }
103
+ /**
104
+ * Whether a live Appium session holds the phone: busy under a session id that
105
+ * isn't a live-preview hold. A claim still waiting for its session id isn't
106
+ * one yet.
107
+ */
108
+ function heldByAppiumSession(device) {
109
+ return !!(device === null || device === void 0 ? void 0 : device.busy) && !!device.session_id && !(0, manualLock_1.isManualLock)(device.session_id);
110
+ }
111
+ /**
112
+ * The ports go-ios's tunnel process listens on: its tunnel-info API
113
+ * (`GO_IOS_AGENT_PORT`, default 60105) and the first phone's userspace tunnel
114
+ * (API port + 1). On iOS 17+ every go-ios command that talks to the phone,
115
+ * `runwda` included, goes through that process.
116
+ */
117
+ const GO_IOS_AGENT_PORTS = [60105, 60106];
118
+ /** A start refused a restart because an Appium session holds the phone. */
119
+ class StreamRestartRefused extends Error {
120
+ }
121
+ exports.StreamRestartRefused = StreamRestartRefused;
101
122
  let IOSStreamService = class IOSStreamService {
102
123
  constructor() {
103
124
  this.sessions = new Map();
@@ -117,7 +138,9 @@ let IOSStreamService = class IOSStreamService {
117
138
  startWatchdog() {
118
139
  // Every minute, and cheap: an unwatched preview's hold was held for good,
119
140
  // because the only idle check ran hourly and kept every busy device.
120
- setInterval(() => void this.sweepIdle(), 60000);
141
+ // unref()ed: the server's listener keeps the process alive; these only
142
+ // need to fire while it runs.
143
+ setInterval(() => void this.sweepIdle(), 60000).unref();
121
144
  // Hourly: port leases and the stream health check, which probes WDA.
122
145
  setInterval(() => __awaiter(this, void 0, void 0, function* () {
123
146
  for (const [udid, session] of this.sessions.entries()) {
@@ -146,7 +169,7 @@ let IOSStreamService = class IOSStreamService {
146
169
  logger_1.default.warn(`Stream watchdog detected failure for ${udid}. Attempting autonomous healing...`);
147
170
  try {
148
171
  // Get device state to check for Session Shield
149
- const device = yield device_store_1.DeviceStoreFactory.getStore().findDevice({ udid });
172
+ const device = yield (0, ownDeviceRow_1.findOwnDevice)(udid);
150
173
  if (device && device.busy) {
151
174
  logger_1.default.info(`🛡️ [Watchdog] Skipping heal for ${udid} as it has an active session.`);
152
175
  continue;
@@ -166,7 +189,7 @@ let IOSStreamService = class IOSStreamService {
166
189
  }
167
190
  }
168
191
  }
169
- }), 3600000); // 1hr interval for background stability
192
+ }), 3600000).unref(); // 1hr interval for background stability
170
193
  }
171
194
  /**
172
195
  * Stop streams nobody has watched for 10 minutes, which releases an
@@ -184,7 +207,7 @@ let IOSStreamService = class IOSStreamService {
184
207
  if (session.viewerCount > 0 || idleMs <= exports.IOS_IDLE_STOP_MS)
185
208
  continue;
186
209
  try {
187
- const device = yield device_store_1.DeviceStoreFactory.getStore().findDevice({ udid });
210
+ const device = yield (0, ownDeviceRow_1.findOwnDevice)(udid);
188
211
  const recording = yield typedi_2.Container.get(recording_store_1.RecordingStore).isRecording(udid);
189
212
  const stop = shouldStopIdleIosStream({
190
213
  idleMs,
@@ -329,20 +352,28 @@ let IOSStreamService = class IOSStreamService {
329
352
  });
330
353
  }
331
354
  /**
332
- * Kill any orphan tunnel processes that might be left from previous runs
333
- * This is critical for preventing 'address already in use' errors
355
+ * Kill go-ios tunnel processes left over from earlier runs, so a new tunnel
356
+ * can bind the agent's ports ('address already in use' otherwise).
357
+ *
358
+ * Never while an Appium session holds the phone. That session may be
359
+ * driving a WDA go-ios launched (iOSCapabilities points a session at the
360
+ * stream's WDA whenever a stream runs), and on iOS 17+ that WDA reaches the
361
+ * phone through the tunnel listening on these ports. Nothing here can tell
362
+ * that tunnel from an orphan, so the sweep waits for the next stop or start
363
+ * after the session ends; a restart reaps every go-ios process at boot.
334
364
  */
335
365
  cleanupOrphanTunnels(udid) {
336
366
  return __awaiter(this, void 0, void 0, function* () {
367
+ if (yield this.appiumSessionMayUse(udid, 'go-ios tunnels'))
368
+ return;
337
369
  logger_1.default.debug(`Cleaning up orphan tunnels for ${udid}...`);
338
370
  // Reap the tunnel process *group* for this udid so the self-forking go-ios
339
371
  // agent children (whose argv carries no udid, so a udid-scoped pkill can't
340
372
  // see them) die with their parent instead of orphaning. Group-scoped, so a
341
373
  // second device's tunnel is left untouched. See ./tunnelProcess.
342
374
  yield (0, tunnelProcess_1.reapTunnelsForUdid)(udid, execPromise);
343
- // Check common go-ios agent ports (60105, 60106) and kill if bound
344
- const agentPorts = [60105, 60106];
345
- for (const port of agentPorts) {
375
+ // Whatever still listens on the go-ios agent's ports.
376
+ for (const port of GO_IOS_AGENT_PORTS) {
346
377
  try {
347
378
  const { stdout } = yield execPromise(`lsof -ti :${port}`);
348
379
  const pids = stdout.trim().split('\n');
@@ -361,14 +392,65 @@ let IOSStreamService = class IOSStreamService {
361
392
  yield new Promise((resolve) => setTimeout(resolve, 500));
362
393
  });
363
394
  }
395
+ /**
396
+ * Whether an Appium session may be using what the caller is about to kill:
397
+ * one holds the phone, or the phone's row can't be read, in which case
398
+ * nothing is known and nothing is killed.
399
+ */
400
+ appiumSessionMayUse(udid, what) {
401
+ return __awaiter(this, void 0, void 0, function* () {
402
+ var _a;
403
+ try {
404
+ const device = yield (0, ownDeviceRow_1.findOwnDevice)(udid);
405
+ if (!heldByAppiumSession(device))
406
+ return false;
407
+ logger_1.default.info(`[${udid}] Leaving ${what} alone: Appium session ${device === null || device === void 0 ? void 0 : device.session_id} holds the device and may be using them`);
408
+ }
409
+ catch (e) {
410
+ logger_1.default.warn(`[${udid}] Leaving ${what} alone: could not read the device: ${(_a = e === null || e === void 0 ? void 0 : e.message) !== null && _a !== void 0 ? _a : e}`);
411
+ }
412
+ return true;
413
+ });
414
+ }
415
+ /**
416
+ * Throw StreamRestartRefused when an Appium session holds the phone, as the
417
+ * watchdog's heal already skips a busy device. A restart would kill the WDA
418
+ * and go-ios tunnel the session may be driving, or, on a stream attached to
419
+ * the session's own WDA, launch a second WDA over it. A WDA that is only
420
+ * slow to answer /status (a long command) would be killed for nothing. The
421
+ * stream is left as it is; it can restart once the session ends. A row that
422
+ * can't be read refuses too.
423
+ */
424
+ refuseRestartUnderAppiumSession(udid) {
425
+ return __awaiter(this, void 0, void 0, function* () {
426
+ var _a;
427
+ let device;
428
+ try {
429
+ device = yield (0, ownDeviceRow_1.findOwnDevice)(udid);
430
+ }
431
+ catch (e) {
432
+ throw new StreamRestartRefused(`Stream for ${udid} is not answering, and the device can't be read to check for an Appium session (${(_a = e === null || e === void 0 ? void 0 : e.message) !== null && _a !== void 0 ? _a : e}): not restarting it.`);
433
+ }
434
+ if (!heldByAppiumSession(device))
435
+ return;
436
+ throw new StreamRestartRefused(`Stream for ${udid} is not answering, but Appium session ${device === null || device === void 0 ? void 0 : device.session_id} holds the device: not restarting the WDA and go-ios tunnel it may be driving. The stream can restart once the session ends.`);
437
+ });
438
+ }
364
439
  /**
365
440
  * Check if WDA is already running and responding
366
441
  * Principal Resilience: Retries transient connection errors (ECONNRESET) up to 2 times
367
442
  * with exponential backoff, as these often indicate WDA is restarting or tunnel is reconnecting.
443
+ *
444
+ * It says whether *a* WDA answers on the port, never whose. WDA names no
445
+ * phone: /status carries os, ios.ip, build and device (the form factor),
446
+ * and /wda/device/info's `uuid` is identifierForVendor, not the UDID. So it
447
+ * takes no udid. A caller that must know the phone relies on the port
448
+ * being one it already knows belongs to it: its own stream's leased port,
449
+ * or the Device row's port while an Appium session holds that phone.
368
450
  */
369
- isWDARunning(wdaPort_1, udid_1) {
370
- return __awaiter(this, arguments, void 0, function* (wdaPort, udid, retries = 2) {
371
- var _a, _b, _c, _d, _e, _f, _g;
451
+ isWDARunning(wdaPort_1) {
452
+ return __awaiter(this, arguments, void 0, function* (wdaPort, retries = 2) {
453
+ var _a, _b, _c, _d;
372
454
  const axios = (yield Promise.resolve().then(() => __importStar(require('axios')))).default;
373
455
  const host = '127.0.0.1'; // Force IPv4 for local tunnels
374
456
  const maxRetries = retries;
@@ -380,15 +462,9 @@ let IOSStreamService = class IOSStreamService {
380
462
  httpAgent: new http_1.default.Agent({ keepAlive: false }),
381
463
  validateStatus: (status) => status === 200,
382
464
  });
383
- // Principal Resilience: Verify the UDID if provided to ensure we're talking to the right device
384
- const remoteUdid = (_c = (_b = (_a = response.data) === null || _a === void 0 ? void 0 : _a.value) === null || _b === void 0 ? void 0 : _b.ios) === null || _c === void 0 ? void 0 : _c.udid;
385
- if (udid && remoteUdid && remoteUdid !== udid) {
386
- logger_1.default.warn(`[WDA] Port ${wdaPort} is used by a DIFFERENT device: ${remoteUdid} (expected ${udid})`);
387
- return false;
388
- }
389
- const isReady = ((_e = (_d = response.data) === null || _d === void 0 ? void 0 : _d.value) === null || _e === void 0 ? void 0 : _e.ready) === true;
465
+ const isReady = ((_b = (_a = response.data) === null || _a === void 0 ? void 0 : _a.value) === null || _b === void 0 ? void 0 : _b.ready) === true;
390
466
  if (!isReady) {
391
- logger_1.default.debug(`[WDA] Port ${wdaPort} active but not ready. Status: ${JSON.stringify((_f = response.data) === null || _f === void 0 ? void 0 : _f.value)}`);
467
+ logger_1.default.debug(`[WDA] Port ${wdaPort} active but not ready. Status: ${JSON.stringify((_c = response.data) === null || _c === void 0 ? void 0 : _c.value)}`);
392
468
  }
393
469
  return isReady;
394
470
  }
@@ -400,7 +476,7 @@ let IOSStreamService = class IOSStreamService {
400
476
  (error.response && error.response.status >= 500);
401
477
  if (isTransientError && attempt < maxRetries) {
402
478
  const backoffMs = Math.min(500 * Math.pow(2, attempt), 2000); // 500ms, 1000ms, 2000ms max
403
- logger_1.default.debug(`[WDA] Port ${wdaPort} transient error (${error.code || ((_g = error.response) === null || _g === void 0 ? void 0 : _g.status)}), retrying in ${backoffMs}ms (attempt ${attempt + 1}/${maxRetries + 1})`);
479
+ logger_1.default.debug(`[WDA] Port ${wdaPort} transient error (${error.code || ((_d = error.response) === null || _d === void 0 ? void 0 : _d.status)}), retrying in ${backoffMs}ms (attempt ${attempt + 1}/${maxRetries + 1})`);
404
480
  yield new Promise((resolve) => setTimeout(resolve, backoffMs));
405
481
  continue;
406
482
  }
@@ -443,7 +519,7 @@ let IOSStreamService = class IOSStreamService {
443
519
  }
444
520
  if (!isWdaIproxyAlive || !isMjpegIproxyAlive) {
445
521
  logger_1.default.warn(`🛡️ [${udid}] [Watchdog] Tunnel processes are dead. Attempting tunnel-only recovery...`);
446
- const device = yield device_store_1.DeviceStoreFactory.getStore().findDevice({ udid });
522
+ const device = yield (0, ownDeviceRow_1.findOwnDevice)(udid);
447
523
  if (device && device.ip) {
448
524
  // Double check if WDA is alive via network IP
449
525
  const isWdaAccessibleViaNetwork = yield this.isWDARunningOnHost(device.ip, 8100);
@@ -459,7 +535,7 @@ let IOSStreamService = class IOSStreamService {
459
535
  const isRespondingViaTunnel = yield this.isWDARunning(session.wdaPort);
460
536
  if (!isRespondingViaTunnel) {
461
537
  logger_1.default.warn(`🛡️ [${udid}] [Watchdog] WDA tunnel on port ${session.wdaPort} is unresponsive. checking network...`);
462
- const device = yield device_store_1.DeviceStoreFactory.getStore().findDevice({ udid });
538
+ const device = yield (0, ownDeviceRow_1.findOwnDevice)(udid);
463
539
  if (device && device.ip) {
464
540
  const isWdaAccessibleViaNetwork = yield this.isWDARunningOnHost(device.ip, 8100);
465
541
  if (isWdaAccessibleViaNetwork) {
@@ -543,6 +619,8 @@ let IOSStreamService = class IOSStreamService {
543
619
  logger_1.default.debug(`[${udid}] Stream already running and healthy, reusing existing session`);
544
620
  return { wdaPort: existingSession.wdaPort, mjpegPort: existingSession.mjpegPort };
545
621
  }
622
+ // Never under a live Appium session (see refuseRestartUnderAppiumSession).
623
+ yield this.refuseRestartUnderAppiumSession(udid);
546
624
  // Stream exists but unhealthy - check cooldown before recovery
547
625
  if (!this.canAttemptRecovery(udid)) {
548
626
  const lastAttempt = this.recoveryCooldowns.get(udid);
@@ -578,6 +656,7 @@ let IOSStreamService = class IOSStreamService {
578
656
  logger_1.default.debug(`[${udid}] Stream already running and healthy, reusing existing session`);
579
657
  return { wdaPort: existingSession.wdaPort, mjpegPort: existingSession.mjpegPort };
580
658
  }
659
+ yield this.refuseRestartUnderAppiumSession(udid);
581
660
  // Stream exists but unhealthy - check cooldown before recovery
582
661
  if (!this.canAttemptRecovery(udid)) {
583
662
  const lastAttempt = this.recoveryCooldowns.get(udid);
@@ -588,10 +667,17 @@ let IOSStreamService = class IOSStreamService {
588
667
  logger_1.default.info(`Existing session for ${udid} not responding, restarting...`);
589
668
  this.markRecoveryAttempt(udid);
590
669
  }
591
- const device = yield device_store_1.DeviceStoreFactory.getStore().findDevice({ udid });
670
+ const device = yield (0, ownDeviceRow_1.findOwnDevice)(udid);
592
671
  if (!device)
593
672
  throw new Error(`Device ${udid} not found`);
594
- // Always resolve stream ports through the PortAllocator — never a value
673
+ // Tear down this udid's previous session BEFORE acquiring ports, never
674
+ // after. acquire() hands a udid back its own existing leases, so a
675
+ // failed start's session names the very port numbers this start is
676
+ // about to get. Stopped after acquiring, its release() deleted this
677
+ // start's leases: the stream ran on unleased ports, and the allocator
678
+ // gave its 9100 to the S9+. A no-op when there is no previous session.
679
+ yield this.stopStream(udid);
680
+ // Resolve stream ports through the PortAllocator — never a value
595
681
  // persisted on the Device row. A stale persisted mjpegServerPort (e.g.
596
682
  // 9100) bypassed the allocator and could collide with another device's
597
683
  // live lease on the same port (the Android stream leases 9100 too),
@@ -599,13 +685,29 @@ let IOSStreamService = class IOSStreamService {
599
685
  // guarantee a non-colliding port; the lease is refreshed by the watchdog
600
686
  // and released in stopStream().
601
687
  const portAllocator = typedi_2.Container.get(PortAllocator_1.PortAllocator);
602
- const wdaPort = yield portAllocator.acquire('wda', udid, { ttlMs: this.STREAM_PORT_TTL_MS });
603
- const mjpegPort = yield portAllocator.acquire('mjpeg', udid, {
604
- ttlMs: this.STREAM_PORT_TTL_MS,
605
- });
606
- // Principal Discovery: Check if WDA is already up (e.g. from an active Appium session)
607
- // If yes, and it belongs to our device, we simply attach to it instead of killing it.
608
- const alreadyUp = yield this.isWDARunning(wdaPort, udid);
688
+ const ttl = { ttlMs: this.STREAM_PORT_TTL_MS };
689
+ // Principal Discovery: attach to the WDA an Appium session runs on this
690
+ // device instead of launching a second one over it. The one exception
691
+ // to the rule above: that session's forwarder listens on the Device
692
+ // row's wdaLocalPort (iOSCapabilities hands it to Appium), and acquire()
693
+ // can't find it, because it refuses any port with a live listener, this
694
+ // device's own included. Only while an Appium session holds the device:
695
+ // WDA names no phone (see isWDARunning), so that session is the only
696
+ // evidence the port is this phone's. Outside one, an answer on a stale
697
+ // row's port may be another phone's WDA.
698
+ const appiumWdaPort = heldByAppiumSession(device) ? device.wdaLocalPort : undefined;
699
+ const alreadyUp = !!appiumWdaPort && (yield this.isWDARunning(appiumWdaPort));
700
+ let wdaPort;
701
+ if (appiumWdaPort && alreadyUp) {
702
+ wdaPort = appiumWdaPort;
703
+ if (!(yield portAllocator.claim('wda', udid, wdaPort, ttl))) {
704
+ logger_1.default.warn(`[${udid}] WDA port ${wdaPort} is leased to another device; attaching unleased`);
705
+ }
706
+ }
707
+ else {
708
+ wdaPort = yield portAllocator.acquire('wda', udid, ttl);
709
+ }
710
+ const mjpegPort = yield portAllocator.acquire('mjpeg', udid, ttl);
609
711
  if (alreadyUp) {
610
712
  logger_1.default.info(`[${udid}] WDA already responding on port ${wdaPort}. Attaching to existing tunnel...`);
611
713
  // WDA reachable on 127.0.0.1:wdaPort means the WDA iproxy is live, but the
@@ -653,7 +755,7 @@ let IOSStreamService = class IOSStreamService {
653
755
  return { wdaPort, mjpegPort };
654
756
  }
655
757
  // Perform aggressive cleanup of any existing processes for THIS device/ports
656
- yield this.stopStream(udid);
758
+ // (the previous session was already stopped, before the ports were acquired)
657
759
  yield this.killStaleProcesses(udid, wdaPort, mjpegPort);
658
760
  const session = {
659
761
  udid,
@@ -788,7 +890,7 @@ let IOSStreamService = class IOSStreamService {
788
890
  logger_1.default.warn(`Failed to get screen size for ${udid}: ${e}`);
789
891
  }
790
892
  // Update device info in store
791
- const device = yield device_store_1.DeviceStoreFactory.getStore().findDevice({ udid });
893
+ const device = yield (0, ownDeviceRow_1.findOwnDevice)(udid);
792
894
  if (device) {
793
895
  const updateData = {
794
896
  wdaLocalPort: wdaPort,
@@ -836,6 +938,9 @@ let IOSStreamService = class IOSStreamService {
836
938
  throw new Error(`WDA failed to start within ${timeout / 1000}s. Check logs.`);
837
939
  }
838
940
  catch (error) {
941
+ // Not a failed start: the running stream is left exactly as it was.
942
+ if (error instanceof StreamRestartRefused)
943
+ throw error;
839
944
  const session = this.sessions.get(udid);
840
945
  if (session) {
841
946
  session.status = 'error';
@@ -908,12 +1013,29 @@ let IOSStreamService = class IOSStreamService {
908
1013
  yield new Promise((resolve) => setTimeout(resolve, 500));
909
1014
  });
910
1015
  }
911
- stopStream(udid) {
912
- return __awaiter(this, void 0, void 0, function* () {
1016
+ /**
1017
+ * Stop this udid's stream: its processes, a live-preview hold, the go-ios
1018
+ * orphan sweep (see cleanupOrphanTunnels) and its port leases.
1019
+ *
1020
+ * `forViewer` means the caller is only done watching (stream/stop,
1021
+ * stream/leave). While an Appium session holds the phone and this stream
1022
+ * launched its own WDA, nothing is stopped: a session allocated while the
1023
+ * stream ran drives that WDA, through this stream's forwarder and go-ios
1024
+ * tunnel. The session's teardown stops it (EventManager
1025
+ * stopIdleStreamForDevice), or the idle watchdog once the session has
1026
+ * ended. A restart never passes it: the WDA already failed its health check.
1027
+ */
1028
+ stopStream(udid_1) {
1029
+ return __awaiter(this, arguments, void 0, function* (udid, opts = {}) {
913
1030
  var _a, _b;
914
1031
  const session = this.sessions.get(udid);
915
1032
  if (!session)
916
1033
  return;
1034
+ if (opts.forViewer &&
1035
+ session.wdaProcess &&
1036
+ (yield this.appiumSessionMayUse(udid, "the stream's WDA and go-ios tunnel"))) {
1037
+ return;
1038
+ }
917
1039
  // Kill sidecar processes. The go-ios tunnel is detached (its own process
918
1040
  // group), so reap the whole group — a plain p.kill() would leave the
919
1041
  // self-forking agent children behind to respawn. See ./tunnelProcess.
@@ -931,7 +1053,7 @@ let IOSStreamService = class IOSStreamService {
931
1053
  // The lock could belong to an Appium automation session (session_id is a real UUID).
932
1054
  // We should only unblock if it's a manual control lock (session_id starts with 'manual_').
933
1055
  try {
934
- const device = yield device_store_1.DeviceStoreFactory.getStore().findDevice({ udid });
1056
+ const device = yield (0, ownDeviceRow_1.findOwnDevice)(udid);
935
1057
  if (device && ((_b = device.session_id) === null || _b === void 0 ? void 0 : _b.startsWith('manual_'))) {
936
1058
  logger_1.default.info(`Stream Stop: Releasing manual control lock for ${udid}`);
937
1059
  yield (0, device_service_1.unblockDevice)(udid, device.host);
@@ -949,8 +1071,8 @@ let IOSStreamService = class IOSStreamService {
949
1071
  // devices — they were held for the lifetime of this stream.
950
1072
  try {
951
1073
  const portAllocator = typedi_2.Container.get(PortAllocator_1.PortAllocator);
952
- yield portAllocator.release(session.wdaPort);
953
- yield portAllocator.release(session.mjpegPort);
1074
+ yield portAllocator.release(session.wdaPort, udid);
1075
+ yield portAllocator.release(session.mjpegPort, udid);
954
1076
  }
955
1077
  catch (e) {
956
1078
  logger_1.default.warn(`Failed to release port leases for ${udid}: ${e}`);
@@ -0,0 +1,86 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.serverOrigin = serverOrigin;
4
+ exports.parseAdbRemote = parseAdbRemote;
5
+ exports.androidDeviceHost = androidDeviceHost;
6
+ exports.iosRealDeviceHost = iosRealDeviceHost;
7
+ exports.iosSimulatorHost = iosSimulatorHost;
8
+ exports.localDeviceHosts = localDeviceHosts;
9
+ exports.isLocalDeviceHost = isLocalDeviceHost;
10
+ exports.isOwnDevice = isOwnDevice;
11
+ exports.devicesClearedAtBoot = devicesClearedAtBoot;
12
+ /** This server's origin, `http://<bindHostOrIp>:<port>`. */
13
+ function serverOrigin(args, port) {
14
+ return `http://${args.bindHostOrIp}:${port}`;
15
+ }
16
+ /** An `adbRemote` entry, `host[:port]`, with adb's default port 5037. */
17
+ function parseAdbRemote(value) {
18
+ const [adbHost, adbPort] = value.split(':');
19
+ return { adbHost, adbPort: parseInt(adbPort) || 5037 };
20
+ }
21
+ /**
22
+ * An Android phone: a phone on a remote adb server is filed under that
23
+ * server; otherwise under remoteMachineProxyIP with this server's port;
24
+ * otherwise under this server.
25
+ */
26
+ function androidDeviceHost(args, port, adb) {
27
+ if ((adb === null || adb === void 0 ? void 0 : adb.adbHost) != null)
28
+ return `http://${adb.adbHost}:${adb.adbPort}`;
29
+ if (args.remoteMachineProxyIP !== undefined)
30
+ return `http://${args.remoteMachineProxyIP}:${port}`;
31
+ return serverOrigin(args, port);
32
+ }
33
+ /** A real iOS device: remoteMachineProxyIP as given (no port added), otherwise this server. */
34
+ function iosRealDeviceHost(args, port) {
35
+ return args.remoteMachineProxyIP ? String(args.remoteMachineProxyIP) : serverOrigin(args, port);
36
+ }
37
+ /** An iOS simulator: always this server. */
38
+ function iosSimulatorHost(args, port) {
39
+ return serverOrigin(args, port);
40
+ }
41
+ function localDeviceHosts(args, port) {
42
+ var _a;
43
+ return {
44
+ origin: serverOrigin(args, port),
45
+ hosts: new Set([
46
+ serverOrigin(args, port),
47
+ androidDeviceHost(args, port),
48
+ ...((_a = args.adbRemote) !== null && _a !== void 0 ? _a : []).map((value) => androidDeviceHost(args, port, parseAdbRemote(value))),
49
+ iosRealDeviceHost(args, port),
50
+ iosSimulatorHost(args, port),
51
+ ]),
52
+ };
53
+ }
54
+ /** Whether a row's host is one this server writes: exact, never by IP or prefix. */
55
+ function isLocalDeviceHost(local, host) {
56
+ return host != null && local.hosts.has(host);
57
+ }
58
+ /**
59
+ * Whether a phone's row is this server's own, for stale cleanup. A row names
60
+ * the server process that found it (`nodeId`, new on every boot; a node's
61
+ * report carries the node's), so that decides: another server's phone is
62
+ * never pruned as this one's, even under a host this server also files
63
+ * phones under (a shared remoteMachineProxyIP or adb server), and is
64
+ * host-checked as the other server's. A row with no nodeId falls back to its
65
+ * host.
66
+ */
67
+ function isOwnDevice(local, nodeId, device) {
68
+ if (device.nodeId)
69
+ return device.nodeId === nodeId;
70
+ return isLocalDeviceHost(local, device.host);
71
+ }
72
+ /**
73
+ * Which phones a server forgets when it starts: the hosts to clear, or
74
+ * undefined for every phone.
75
+ *
76
+ * A hub keeps its nodes' phones. Their sessions keep running on the nodes
77
+ * while the hub restarts, and the hub finds where to route them from those
78
+ * rows (SessionManager.recoverActiveSessions, gateway/sessionLocator.ts). A
79
+ * node that died meanwhile is pruned by removeStaleDevices, and a live one's
80
+ * next report refreshes its rows. A hub's own phones are cleared as before:
81
+ * discovery adds back the attached ones, and their sessions died with it.
82
+ * A node clears everything, as it always has.
83
+ */
84
+ function devicesClearedAtBoot(args, local) {
85
+ return args.hub === undefined ? [...local.hosts] : undefined;
86
+ }
@@ -0,0 +1,38 @@
1
+ "use strict";
2
+ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
3
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
4
+ return new (P || (P = Promise))(function (resolve, reject) {
5
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
6
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
7
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
8
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
9
+ });
10
+ };
11
+ Object.defineProperty(exports, "__esModule", { value: true });
12
+ exports.findOwnDevice = findOwnDevice;
13
+ const typedi_1 = require("typedi");
14
+ const device_store_1 = require("../data-service/device-store");
15
+ const PluginContext_1 = require("../PluginContext");
16
+ const localDeviceHosts_1 = require("./localDeviceHosts");
17
+ /**
18
+ * This server's own Device row for a phone it drives: the udid under one of
19
+ * the hosts its discovery files phones under (localDeviceHosts), never
20
+ * another server's row. A hub's table also holds its nodes' rows, and a node
21
+ * on the same machine sees the same phones (an iPhone, emulator-5554), so a
22
+ * udid alone may name the node's row, with the node's holds and sessions.
23
+ *
24
+ * One lookup per own host, usually just one. Null when none of them has the
25
+ * phone.
26
+ */
27
+ function findOwnDevice(udid) {
28
+ return __awaiter(this, void 0, void 0, function* () {
29
+ const ctx = typedi_1.Container.get(PluginContext_1.PluginContext);
30
+ const store = device_store_1.DeviceStoreFactory.getStore();
31
+ for (const host of (0, localDeviceHosts_1.localDeviceHosts)(ctx.pluginArgs, ctx.port).hosts) {
32
+ const device = yield store.findDevice({ udid, host });
33
+ if (device)
34
+ return device;
35
+ }
36
+ return null;
37
+ });
38
+ }