@xenon-device-management/xenon 2.14.0 → 2.15.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 (259) hide show
  1. package/README.md +7 -4
  2. package/lib/package.json +5 -5
  3. package/lib/public/assets/{AnnotationOverlay-B_VNtKBH.js → AnnotationOverlay-Btq3dZRI.js} +1 -1
  4. package/lib/public/assets/{ApiKeyGate-BwuaMjkX.js → ApiKeyGate-C9OoAgg_.js} +1 -1
  5. package/lib/public/assets/DeviceMosaicView-DBC_xczB.js +2 -0
  6. package/lib/public/assets/{EmptyState-BSjhk_IV.js → EmptyState-DqWU2-vM.js} +1 -1
  7. package/lib/public/assets/{FieldGroup-Cb5QZsE1.js → FieldGroup-CveQ3xrA.js} +1 -1
  8. package/lib/public/assets/{FilterMenu-BAaSlWbW.js → FilterMenu-BfKDSfHz.js} +1 -1
  9. package/lib/public/assets/{Menu-JGGYrSQQ.js → Menu-CtdCybzA.js} +1 -1
  10. package/lib/public/assets/{Modal-CFCp3n9d.js → Modal-Bsvj1tm2.js} +1 -1
  11. package/lib/public/assets/{RecordingPage-CbJj_EBT.js → RecordingPage-CRofOiyx.js} +1 -1
  12. package/lib/public/assets/{RecordingsPage-BJeYnygR.js → RecordingsPage-jNQHb2IR.js} +1 -1
  13. package/lib/public/assets/{SegmentedControl-Bg42Atjy.js → SegmentedControl-C8HtTha-.js} +1 -1
  14. package/lib/public/assets/{SettingCard-Njz1pUIJ.js → SettingCard-D8K1LZNo.js} +1 -1
  15. package/lib/public/assets/{Table-BcTm5xV2.js → Table-zNTX92Pn.js} +1 -1
  16. package/lib/public/assets/{activity-DOVdsfMQ.js → activity-CJ8LwZq9.js} +1 -1
  17. package/lib/public/assets/ai-settings-CgxQZqrc.js +16 -0
  18. package/lib/public/assets/{api-keys-BKnNSTw9.js → api-keys-ECpo-zTo.js} +1 -1
  19. package/lib/public/assets/{apps-D0SC6uw6.js → apps-BjGymyf3.js} +1 -1
  20. package/lib/public/assets/{arrow-left-MMbTmgoE.js → arrow-left-BWfCbeHP.js} +1 -1
  21. package/lib/public/assets/{arrow-right-BMfnGmxW.js → arrow-right-DC7UGf_n.js} +1 -1
  22. package/lib/public/assets/{arrow-up-right-DuAftMWB.js → arrow-up-right-BOjwybDk.js} +1 -1
  23. package/lib/public/assets/{auth-shell-nZC2JNDO.js → auth-shell-CxMTo9aH.js} +2 -2
  24. package/lib/public/assets/{builds-page-hDCgTwkS.js → builds-page-DOCm4HkA.js} +1 -1
  25. package/lib/public/assets/{button-Dda4irn_.js → button-Cg08-EMT.js} +1 -1
  26. package/lib/public/assets/{calendar-BMr0Q62v.js → calendar-CA1ikLJs.js} +1 -1
  27. package/lib/public/assets/{check-CZ9iw9-e.js → check-BMTaH72W.js} +1 -1
  28. package/lib/public/assets/{chevron-right-BRYJJVC9.js → chevron-right-B2QOv9Nx.js} +1 -1
  29. package/lib/public/assets/{circle-check-CqAJjQ5O.js → circle-check-CYXBoUqT.js} +1 -1
  30. package/lib/public/assets/{circle-x-DlDJtnVz.js → circle-x-DTwnSAnl.js} +1 -1
  31. package/lib/public/assets/{clock-CpuDKysU.js → clock-C8_HViQP.js} +1 -1
  32. package/lib/public/assets/{copy-CAQydNFe.js → copy-Be7LNMXV.js} +1 -1
  33. package/lib/public/assets/derive-UnLRp4r3.js +1 -0
  34. package/lib/public/assets/device-explorer-BOqtfGXz.js +282 -0
  35. package/lib/public/assets/{download-C9XG6lLv.js → download-B56lwxWP.js} +1 -1
  36. package/lib/public/assets/forgot-password-CCU6bzLn.js +1 -0
  37. package/lib/public/assets/{h264Stream-VQBphYhg.js → h264Stream-C-OtmIuk.js} +6 -6
  38. package/lib/public/assets/index-B9KQ6LsY.js +15 -0
  39. package/lib/public/assets/{index-BbKEXUFH.js → index-BOxe43p1.js} +2 -2
  40. package/lib/public/assets/{index-DJGc1M2f.js → index-CfxsUslp.js} +1 -1
  41. package/lib/public/assets/{input-Cm0tmSoc.js → input-CZHFjbSg.js} +1 -1
  42. package/lib/public/assets/{line-chart-D2DyJCml.js → line-chart-Gl_Dz3X_.js} +1 -1
  43. package/lib/public/assets/{list-checks-UWf1GY99.js → list-checks-CCgjeyGY.js} +1 -1
  44. package/lib/public/assets/{lock-CCJv9m_I.js → lock-CkOwgDcx.js} +1 -1
  45. package/lib/public/assets/{login-f8vOVW-U.js → login-D5cc4w7M.js} +1 -1
  46. package/lib/public/assets/{maintenance-settings-C1PrkuAH.js → maintenance-settings-t7qr3fvX.js} +1 -1
  47. package/lib/public/assets/{monitor-B2jDgwm7.js → monitor-DEJixKQO.js} +1 -1
  48. package/lib/public/assets/{mouse-pointer-2-DLI1RtzU.js → mouse-pointer-2-CtOrk4M8.js} +1 -1
  49. package/lib/public/assets/{network-ebT86qoi.js → network-Dii9qdvk.js} +1 -1
  50. package/lib/public/assets/{overview-C0aykVrd.js → overview-rolAACrO.js} +1 -1
  51. package/lib/public/assets/{page-header-BjzgWz-T.js → page-header-COQZxJfR.js} +1 -1
  52. package/lib/public/assets/{play-DLG9t8FU.js → play-DrN1Se_R.js} +1 -1
  53. package/lib/public/assets/{plus-nBFSMu_X.js → plus-5aq-9uRx.js} +1 -1
  54. package/lib/public/assets/{profile-page-CSl0UYlv.js → profile-page-6i5kY6kF.js} +1 -1
  55. package/lib/public/assets/{reset-password-LeOuHqmB.js → reset-password-sp2IBrc7.js} +1 -1
  56. package/lib/public/assets/runbook-page-DJDjeopU.js +253 -0
  57. package/lib/public/assets/{select-88jHcl-Z.js → select-Bpyn7rWO.js} +1 -1
  58. package/lib/public/assets/{selector-detail-redirect-BbXKJjZM.js → selector-detail-redirect-BH74vmKv.js} +1 -1
  59. package/lib/public/assets/{selector-health-page-B4rA1Xkv.js → selector-health-page-Cw_g218H.js} +1 -1
  60. package/lib/public/assets/session-detail-page-AZJE6XXE.js +4 -0
  61. package/lib/public/assets/{settings-GF6ZMSQ6.js → settings-C3uyGhyc.js} +1 -1
  62. package/lib/public/assets/{settings-Dep_3kbA.css → settings-K8UHWMdI.css} +1 -1
  63. package/lib/public/assets/{stat-tile-BV98e_Sv.js → stat-tile-xVqAKIlI.js} +1 -1
  64. package/lib/public/assets/{tablet-BiAsnFDj.js → tablet-BxUScAeO.js} +1 -1
  65. package/lib/public/assets/{teams-B4-jrr-1.js → teams-B5ljGkY2.js} +1 -1
  66. package/lib/public/assets/{trash-2-C5RSuX0e.js → trash-2-B701i8sy.js} +1 -1
  67. package/lib/public/assets/{upload-BG7_rMXK.js → upload-C2gr44RS.js} +1 -1
  68. package/lib/public/assets/{use-builds-data-J-riQYyC.js → use-builds-data-NeQOFEoy.js} +1 -1
  69. package/lib/public/assets/{use-password-reset-mode-C8-eH-UG.js → use-password-reset-mode-DEzJq5ef.js} +1 -1
  70. package/lib/public/assets/{users-CFba5TSy.js → users-D_vv_yz4.js} +1 -1
  71. package/lib/public/assets/{users-Cp0beJm3.js → users-MY6eNe6s.js} +1 -1
  72. package/lib/public/assets/{video-off-DDsEISjV.js → video-off-OK1lNZQn.js} +1 -1
  73. package/lib/public/assets/{webhook-settings-BGu32vSB.js → webhook-settings-BU-C9lBf.js} +1 -1
  74. package/lib/public/assets/{zap-l4VaXzq8.js → zap-DO9ry1iz.js} +1 -1
  75. package/lib/public/index.html +1 -1
  76. package/lib/schema.json +5 -5
  77. package/lib/src/app/dashboardPluginLink.js +117 -0
  78. package/lib/src/app/index.js +13 -34
  79. package/lib/src/app/openapi/control.yaml +6 -4
  80. package/lib/src/app/openapi/grid.yaml +8 -2
  81. package/lib/src/app/openapi/identity.yaml +103 -31
  82. package/lib/src/app/openapi/platform.yaml +33 -15
  83. package/lib/src/app/openapi/sessions.yaml +10 -3
  84. package/lib/src/app/routers/apikeys.js +14 -2
  85. package/lib/src/app/routers/auth.js +29 -5
  86. package/lib/src/app/routers/control.js +13 -5
  87. package/lib/src/app/routers/dashboard.js +42 -27
  88. package/lib/src/app/routers/grid.js +5 -3
  89. package/lib/src/app/routers/profile.js +21 -0
  90. package/lib/src/app/routers/users.js +10 -5
  91. package/lib/src/app/swagger.js +2 -0
  92. package/lib/src/app/ws/h264StreamWs.js +10 -1
  93. package/lib/src/config.js +1 -0
  94. package/lib/src/dashboard/commands.js +5 -5
  95. package/lib/src/dashboard/event-manager.js +49 -61
  96. package/lib/src/dashboard/services/failure-analysis-service.js +136 -45
  97. package/lib/src/dashboard/sessionCommandSummary.js +39 -0
  98. package/lib/src/data-service/device-store.js +14 -1
  99. package/lib/src/data-service/prisma-store.js +19 -7
  100. package/lib/src/data-service/web-config-service.js +7 -0
  101. package/lib/src/device-managers/android/AndroidH264StreamService.js +69 -20
  102. package/lib/src/device-managers/android/H264Multiplexer.js +38 -4
  103. package/lib/src/device-utils.js +20 -2
  104. package/lib/src/gateway/defaultGateway.js +1 -1
  105. package/lib/src/gateway/forwardToNode.js +2 -0
  106. package/lib/src/gateway/healReport.js +87 -0
  107. package/lib/src/gateway/sessionGateway.js +12 -2
  108. package/lib/src/generated/client/edge.js +4 -3
  109. package/lib/src/generated/client/index-browser.js +1 -0
  110. package/lib/src/generated/client/index.d.ts +32 -0
  111. package/lib/src/generated/client/index.js +4 -3
  112. package/lib/src/generated/client/package.json +1 -1
  113. package/lib/src/generated/client/schema.prisma +3 -0
  114. package/lib/src/generated/client/wasm.js +1 -0
  115. package/lib/src/interceptors/CommandInterceptor.js +243 -181
  116. package/lib/src/middleware/authMiddleware.js +15 -3
  117. package/lib/src/middleware/commandCaller.js +3 -3
  118. package/lib/src/middleware/verifyCredential.js +58 -1
  119. package/lib/src/plugin.js +8 -3
  120. package/lib/src/services/AICommandService.js +9 -2
  121. package/lib/src/services/AIService.js +192 -42
  122. package/lib/src/services/EmailService.js +29 -0
  123. package/lib/src/services/OrphanSweeper.js +2 -0
  124. package/lib/src/services/SelectorVerificationJob.js +15 -4
  125. package/lib/src/services/ServerManager.js +11 -1
  126. package/lib/src/services/SessionLifecycleService.js +86 -26
  127. package/lib/src/services/SocketServer.js +19 -3
  128. package/lib/src/services/bug-report/video-slice.js +4 -1
  129. package/lib/src/services/device-access/leaseOverride.js +7 -5
  130. package/lib/src/services/healing/FuzzyXmlHealingProvider.js +51 -0
  131. package/lib/src/services/healing/HealEtalonService.js +3 -2
  132. package/lib/src/services/healing/HealingOrchestrator.js +55 -72
  133. package/lib/src/services/healing/OcrHealingProvider.js +41 -64
  134. package/lib/src/services/healing/ResilioTreeHealingProvider.js +41 -50
  135. package/lib/src/services/healing/VisualAiHealingProvider.js +1 -1
  136. package/lib/src/services/healing/fingerprintIdentity.js +126 -0
  137. package/lib/src/services/healing/resilioPath.js +274 -0
  138. package/lib/src/services/logcat/SessionDeviceLogs.js +271 -0
  139. package/lib/src/services/logcat/deviceLogBook.js +163 -0
  140. package/lib/src/services/ocr/ocrData.js +118 -0
  141. package/lib/src/services/ocr/vendor/LICENSE-tessdata.txt +202 -0
  142. package/lib/src/services/ocr/vendor/eng.traineddata.gz +0 -0
  143. package/lib/src/services/omni-vision/OmniVisionService.js +83 -56
  144. package/lib/src/services/passwordResetLink.js +50 -9
  145. package/lib/src/services/passwordResetMode.js +3 -1
  146. package/lib/src/services/publicUrl.js +45 -0
  147. package/lib/src/services/session/sessionIdentity.js +5 -3
  148. package/lib/src/services/session/shutdownReason.js +26 -0
  149. package/lib/src/services/settings/aiEngineSettings.js +219 -0
  150. package/lib/src/services/token/mintedLifetime.js +51 -0
  151. package/lib/src/sessions/SessionManager.js +8 -2
  152. package/lib/src/sessions/sessionMemory.js +25 -0
  153. package/lib/test/helpers/fake-ai-provider.js +188 -0
  154. package/lib/test/helpers/loki-stores.js +33 -0
  155. package/lib/test/helpers/page-sources.js +164 -0
  156. package/lib/test/helpers/scratch-database.js +57 -13
  157. package/lib/test/integration/audit-ingest.spec.js +10 -10
  158. package/lib/test/integration/auth-flow.spec.js +9 -14
  159. package/lib/test/integration/auth-rate-limit.spec.js +6 -3
  160. package/lib/test/integration/bug-report-route.spec.js +22 -2
  161. package/lib/test/integration/forgot-password-rate-limit.spec.js +6 -3
  162. package/lib/test/integration/forgot-password.spec.js +17 -18
  163. package/lib/test/integration/legacy-deprecation-headers.spec.js +5 -2
  164. package/lib/test/integration/migration.spec.js +6 -1
  165. package/lib/test/integration/node-pair-auth-rest.spec.js +5 -9
  166. package/lib/test/integration/ports-router.spec.js +4 -4
  167. package/lib/test/integration/prisma-device-store-add-devices.spec.js +2 -3
  168. package/lib/test/integration/prisma-device-store-team-filter.spec.js +2 -5
  169. package/lib/test/integration/profile-tokens.spec.js +10 -16
  170. package/lib/test/integration/recordings-router.spec.js +16 -16
  171. package/lib/test/integration/reset-revokes-sessions.spec.js +4 -9
  172. package/lib/test/integration/role-matrix.spec.js +4 -9
  173. package/lib/test/integration/sdk-leases-router.spec.js +9 -9
  174. package/lib/test/integration/sdk-version.spec.js +2 -2
  175. package/lib/test/integration/selector-health-endpoint.spec.js +5 -5
  176. package/lib/test/integration/team-membership-migration.spec.js +73 -12
  177. package/lib/test/integration/team-visibility-apps.spec.js +15 -22
  178. package/lib/test/integration/team-visibility-control.spec.js +96 -69
  179. package/lib/test/integration/team-visibility-dashboard.spec.js +7 -23
  180. package/lib/test/integration/team-visibility-grid.spec.js +16 -15
  181. package/lib/test/integration/team-visibility-leases.spec.js +8 -11
  182. package/lib/test/integration/team-visibility-recordings.spec.js +6 -19
  183. package/lib/test/integration/team-visibility-sessions.spec.js +6 -19
  184. package/lib/test/integration/users-flow.spec.js +7 -12
  185. package/lib/test/integration/users-lockout.spec.js +5 -3
  186. package/lib/test/unit/EmailService.test.js +66 -0
  187. package/lib/test/unit/ResilioTreeHealingProvider.test.js +46 -19
  188. package/lib/test/unit/ai-call-time-limit.spec.js +130 -0
  189. package/lib/test/unit/ai-engine-settings.spec.js +338 -0
  190. package/lib/test/unit/ai-provider-switch.spec.js +130 -0
  191. package/lib/test/unit/ai-rate-limit.spec.js +126 -0
  192. package/lib/test/unit/android-h264-viewers.spec.js +272 -0
  193. package/lib/test/unit/android-preview-hold.spec.js +3 -2
  194. package/lib/test/unit/apikeys-router.spec.js +84 -0
  195. package/lib/test/unit/authMiddleware.bearer.spec.js +9 -0
  196. package/lib/test/unit/authMiddleware.credentialLifetime.spec.js +149 -0
  197. package/lib/test/unit/bug-report/video-slice.spec.js +19 -0
  198. package/lib/test/unit/dashboard-events-device-logs.spec.js +207 -0
  199. package/lib/test/unit/dashboard-plugin-link.spec.js +213 -0
  200. package/lib/test/unit/device-log-book.spec.js +180 -0
  201. package/lib/test/unit/enable-dashboard-option.spec.js +57 -0
  202. package/lib/test/unit/event-log-session-data.spec.js +244 -0
  203. package/lib/test/unit/failure-analysis.spec.js +182 -0
  204. package/lib/test/unit/failure-categories.spec.js +128 -0
  205. package/lib/test/unit/fingerprint-identity.spec.js +433 -0
  206. package/lib/test/unit/h264-multiplexer.spec.js +46 -0
  207. package/lib/test/unit/heal-report.spec.js +71 -0
  208. package/lib/test/unit/healing-instrumentation.spec.js +6 -1
  209. package/lib/test/unit/healing-tier-gate.spec.js +73 -29
  210. package/lib/test/unit/healing-tiers-every-session.spec.js +219 -0
  211. package/lib/test/unit/hub-node-gateway-appium-server.spec.js +171 -0
  212. package/lib/test/unit/interceptor-visual-elements.spec.js +192 -0
  213. package/lib/test/unit/lease/lease-override.spec.js +23 -6
  214. package/lib/test/unit/lease/session-lease-proof.spec.js +16 -4
  215. package/lib/test/unit/ocr-healing-tier-real-ocr.spec.js +113 -0
  216. package/lib/test/unit/ocr-healing-tier.spec.js +122 -0
  217. package/lib/test/unit/ocr-offline.spec.js +213 -0
  218. package/lib/test/unit/omni-virtual-element-store.spec.js +301 -0
  219. package/lib/test/unit/omni-vision-ios-points.spec.js +0 -2
  220. package/lib/test/unit/omni-vision.spec.js +5 -7
  221. package/lib/test/unit/package-engines.spec.js +27 -0
  222. package/lib/test/unit/passwordResetMode.test.js +10 -2
  223. package/lib/test/unit/profile-router.test.js +122 -1
  224. package/lib/test/unit/queue-requester.spec.js +1 -1
  225. package/lib/test/unit/reset-link-address.spec.js +266 -0
  226. package/lib/test/unit/reset-link.test.js +10 -4
  227. package/lib/test/unit/resilio-path-healing.spec.js +270 -0
  228. package/lib/test/unit/selector-learning-every-session.spec.js +179 -0
  229. package/lib/test/unit/selector-verification-clean-build.spec.js +130 -0
  230. package/lib/test/unit/selector-verification-job.spec.js +13 -13
  231. package/lib/test/unit/self-healing-boot.spec.js +10 -5
  232. package/lib/test/unit/self-healing-setting.spec.js +7 -6
  233. package/lib/test/unit/session-app-resolution.spec.js +5 -1
  234. package/lib/test/unit/session-attribution-wiring.spec.js +41 -13
  235. package/lib/test/unit/session-create-credential-owner.spec.js +242 -0
  236. package/lib/test/unit/session-credential-strip.spec.js +1 -1
  237. package/lib/test/unit/session-device-logs.spec.js +347 -0
  238. package/lib/test/unit/session-end-failure-analysis.spec.js +133 -0
  239. package/lib/test/unit/session-log-indexes.spec.js +64 -3
  240. package/lib/test/unit/socketServer.sessionCookie.spec.js +2 -1
  241. package/lib/test/unit/stream-start-pipelines.spec.js +72 -18
  242. package/lib/test/unit/token-mint-limits.spec.js +165 -0
  243. package/lib/test/unit/unexpected-shutdown-reason.spec.js +163 -0
  244. package/lib/test/unit/verify-credential.spec.js +33 -0
  245. package/lib/test/unit/vision-assertion.spec.js +3 -2
  246. package/lib/test/unit/web-config-service.spec.js +19 -0
  247. package/lib/tsconfig.tsbuildinfo +1 -1
  248. package/package.json +5 -5
  249. package/prisma/migrations/20261004174413_locator_etalon_path/migration.sql +2 -0
  250. package/prisma/schema.prisma +3 -0
  251. package/schema.json +5 -5
  252. package/lib/public/assets/BugReportButton-BAt0_pzq.js +0 -15
  253. package/lib/public/assets/DeviceMosaicView-BYaQHhTf.js +0 -2
  254. package/lib/public/assets/ai-settings-CsuJroRf.js +0 -21
  255. package/lib/public/assets/device-explorer-CdO6_nrn.js +0 -282
  256. package/lib/public/assets/forgot-password-CGnkRgE9.js +0 -1
  257. package/lib/public/assets/runbook-page-CMMeQml9.js +0 -124
  258. package/lib/public/assets/session-detail-page-COL_RZxw.js +0 -4
  259. /package/lib/public/assets/{BugReportButton-CPBjfiv4.css → index-CPBjfiv4.css} +0 -0
@@ -93,27 +93,34 @@ let AndroidH264StreamService = class AndroidH264StreamService {
93
93
  startWatchdog() {
94
94
  // unref()ed below: the server's listener keeps the process alive; this only
95
95
  // needs to fire while it runs.
96
- setInterval(() => {
97
- const now = Date.now();
98
- for (const [udid, s] of this.sessions.entries()) {
99
- if (s.status !== 'running')
100
- continue;
101
- if (s.mux.clientCount > 0) {
102
- s.emptyAt = undefined;
103
- }
104
- else if (s.emptyAt === undefined) {
105
- s.emptyAt = now;
106
- }
107
- else if (now - s.emptyAt > IDLE_TIMEOUT_MS) {
108
- logger_1.default.info(`[${udid}] Stopping idle H.264 stream (no viewers for ${IDLE_TIMEOUT_MS}ms)`);
109
- // The stream went, but the live-preview hold stayed: with H.264
110
- // preview, a tab closed without releasing kept the device busy for
111
- // good. stop() itself must not release (a recording starting calls
112
- // it); an idle stop does, if nothing else uses the device.
113
- void this.stop(udid).then(() => (0, previewHold_1.releaseIdlePreviewHold)(udid));
114
- }
96
+ setInterval(() => this.sweep(Date.now()), 60000).unref();
97
+ }
98
+ /** One look at every capture: ends those nobody has watched for long enough. */
99
+ sweep(now) {
100
+ for (const [udid, s] of this.sessions.entries()) {
101
+ if (s.status !== 'running')
102
+ continue;
103
+ if (s.mux.clientCount > 0) {
104
+ s.emptyAt = undefined;
105
+ }
106
+ else if (s.endWhenUnwatched && !this.startPromises.has(udid)) {
107
+ // A page asked for MJPEG while this capture was starting for a viewer
108
+ // who then never came (endWhenUnwatched leaves a start alone).
109
+ logger_1.default.info(`[${udid}] Stopping the unwatched H.264 stream: the phone is shown as MJPEG`);
110
+ void this.stop(udid);
115
111
  }
116
- }, 60000).unref();
112
+ else if (s.emptyAt === undefined) {
113
+ s.emptyAt = now;
114
+ }
115
+ else if (now - s.emptyAt > IDLE_TIMEOUT_MS) {
116
+ logger_1.default.info(`[${udid}] Stopping idle H.264 stream (no viewers for ${IDLE_TIMEOUT_MS}ms)`);
117
+ // The stream went, but the live-preview hold stayed: with H.264
118
+ // preview, a tab closed without releasing kept the device busy for
119
+ // good. stop() itself must not release (a recording starting calls
120
+ // it); an idle stop does, if nothing else uses the device.
121
+ void this.stop(udid).then(() => (0, previewHold_1.releaseIdlePreviewHold)(udid));
122
+ }
123
+ }
117
124
  }
118
125
  getMultiplexer(udid) {
119
126
  var _a;
@@ -131,6 +138,7 @@ let AndroidH264StreamService = class AndroidH264StreamService {
131
138
  var _a, _b;
132
139
  const mux = new H264Multiplexer_1.H264Multiplexer();
133
140
  const session = { status: 'running', mux };
141
+ mux.onEmpty(() => this.endIfUnwatched(udid, session));
134
142
  this.sessions.set(udid, session);
135
143
  try {
136
144
  let resolveConfig = () => undefined;
@@ -178,6 +186,44 @@ let AndroidH264StreamService = class AndroidH264StreamService {
178
186
  }
179
187
  });
180
188
  }
189
+ /**
190
+ * A page that showed this phone's H.264 preview now shows MJPEG (its player
191
+ * failed, or it cannot play H.264), and the MJPEG capture is starting for
192
+ * it. One capture per phone where possible, but never at the expense of a
193
+ * viewer still playing H.264: another tile, tab or admin may be. So the
194
+ * capture ends now if nobody watches it, else the moment its last viewer
195
+ * leaves, never after the idle wait. A viewer who joins meanwhile is served
196
+ * and ends it in turn. A capture still starting is left to the viewer it is
197
+ * starting for, and the watchdog ends it if that viewer never comes.
198
+ */
199
+ endWhenUnwatched(udid) {
200
+ return __awaiter(this, void 0, void 0, function* () {
201
+ const session = this.sessions.get(udid);
202
+ if (!session || session.status !== 'running')
203
+ return;
204
+ session.endWhenUnwatched = true;
205
+ const viewers = session.mux.clientCount;
206
+ if (viewers > 0) {
207
+ logger_1.default.info(`[${udid}] H.264 stream kept beside MJPEG for its ${viewers} viewer(s); ` +
208
+ 'it ends when the last one leaves');
209
+ return;
210
+ }
211
+ if (this.startPromises.has(udid)) {
212
+ logger_1.default.info(`[${udid}] H.264 stream still starting: kept for the viewer it is starting for`);
213
+ return;
214
+ }
215
+ logger_1.default.info(`[${udid}] Stopping the unwatched H.264 stream: the phone is shown as MJPEG`);
216
+ yield this.stop(udid);
217
+ });
218
+ }
219
+ /** The last viewer left `session`: end it if a page asked (endWhenUnwatched). */
220
+ endIfUnwatched(udid, session) {
221
+ // A capture replaced since (a stop and a new start) is not this one.
222
+ if (this.sessions.get(udid) !== session || !session.endWhenUnwatched)
223
+ return;
224
+ logger_1.default.info(`[${udid}] Stopping the H.264 stream: its last viewer left, the phone is shown as MJPEG`);
225
+ void this.stop(udid);
226
+ }
181
227
  stop(udid) {
182
228
  return __awaiter(this, void 0, void 0, function* () {
183
229
  var _a;
@@ -192,6 +238,9 @@ let AndroidH264StreamService = class AndroidH264StreamService {
192
238
  /* best-effort */
193
239
  }
194
240
  this.sessions.delete(udid);
241
+ // Viewers still on it (a recording starting, a stream/stop) are told, so
242
+ // their players fall back to MJPEG instead of freezing on the last frame.
243
+ session.mux.close();
195
244
  logger_1.default.info(`[${udid}] H.264 stream terminated.`);
196
245
  });
197
246
  }
@@ -9,6 +9,7 @@ class H264Multiplexer {
9
9
  constructor() {
10
10
  this.clients = new Set();
11
11
  this.gop = []; // current GOP: [keyframe, ...deltas-since]
12
+ this.closed = false;
12
13
  }
13
14
  setConfig(p) {
14
15
  this.config = p;
@@ -16,9 +17,21 @@ class H264Multiplexer {
16
17
  get clientCount() {
17
18
  return this.clients.size;
18
19
  }
19
- /** Register a client sink. Returns a remover. */
20
- addClient(send) {
21
- const c = { send, started: false };
20
+ /** Called each time the last client leaves. */
21
+ onEmpty(listener) {
22
+ this.emptyListener = listener;
23
+ }
24
+ /**
25
+ * Register a client sink, and how to end it when the stream does (see
26
+ * `close`). Returns a remover (safe to call twice).
27
+ */
28
+ addClient(send, end) {
29
+ const c = { send, started: false, end };
30
+ if (this.closed) {
31
+ // Joined a stream already stopped: end it now rather than starve it.
32
+ end === null || end === void 0 ? void 0 : end();
33
+ return () => undefined;
34
+ }
22
35
  this.clients.add(c);
23
36
  if (this.config)
24
37
  send(this.config);
@@ -29,9 +42,30 @@ class H264Multiplexer {
29
42
  c.started = true;
30
43
  }
31
44
  return () => {
32
- this.clients.delete(c);
45
+ var _a;
46
+ if (this.clients.delete(c) && this.clients.size === 0)
47
+ (_a = this.emptyListener) === null || _a === void 0 ? void 0 : _a.call(this);
33
48
  };
34
49
  }
50
+ /**
51
+ * The capture stopped: end every client (its socket closes, so its player
52
+ * falls back to MJPEG instead of freezing on its last frame). Not an
53
+ * `onEmpty`: nobody left, the stream did.
54
+ */
55
+ close() {
56
+ var _a;
57
+ this.closed = true;
58
+ const ending = [...this.clients];
59
+ this.clients.clear();
60
+ for (const c of ending) {
61
+ try {
62
+ (_a = c.end) === null || _a === void 0 ? void 0 : _a.call(c);
63
+ }
64
+ catch (_b) {
65
+ /* one client's end must not keep the others open */
66
+ }
67
+ }
68
+ }
35
69
  /** Feed one upstream packet; fans out per the join semantics above. */
36
70
  push(p) {
37
71
  if (p.type === 'config') {
@@ -648,7 +648,7 @@ function unblockCandidateDevices() {
648
648
  }
649
649
  function releaseBlockedDevices(newCommandTimeout) {
650
650
  return __awaiter(this, void 0, void 0, function* () {
651
- var _a, _b;
651
+ var _a, _b, _c;
652
652
  const busyDevices = yield unblockCandidateDevices();
653
653
  // A phone an SDK lease holds is the lease's to time out (its heartbeats and
654
654
  // expiry, LeaseOrphanSweeper), not idle until a session runs on it. A lease
@@ -698,6 +698,7 @@ function releaseBlockedDevices(newCommandTimeout) {
698
698
  logger_1.default.info(`Unblocking device ${device.udid} at host ${device.host} because it has been idle for ${timeSinceLastCmdExecuted} seconds`);
699
699
  // Before anything below can release the phone (onSessionStopped too).
700
700
  yield restoreIdleSessionNetwork((_a = device.claimSessionId) !== null && _a !== void 0 ? _a : device.session_id);
701
+ yield forgetIdleSessionMemory((_b = device.claimSessionId) !== null && _b !== void 0 ? _b : device.session_id);
701
702
  // Principal Protection: If this device has an active dashboard session, stop it properly
702
703
  if (device.session_id) {
703
704
  const sessionId = device.session_id;
@@ -713,7 +714,7 @@ function releaseBlockedDevices(newCommandTimeout) {
713
714
  }
714
715
  // Keyed on the session read here, so a release that loses the race to
715
716
  // the session's own end (and the phone's next claim) frees nothing.
716
- const claimant = (_b = device.claimSessionId) !== null && _b !== void 0 ? _b : device.session_id;
717
+ const claimant = (_c = device.claimSessionId) !== null && _c !== void 0 ? _c : device.session_id;
717
718
  yield (claimant
718
719
  ? (0, device_service_1.releaseSessionDevice)(device.udid, device.host, claimant)
719
720
  : (0, device_service_1.unblockDevice)(device.udid, device.host)).catch((err) => logger_1.default.error(`Unable to release ${device.udid}: ${err}`));
@@ -739,6 +740,23 @@ function restoreIdleSessionNetwork(sessionId) {
739
740
  }
740
741
  });
741
742
  }
743
+ /**
744
+ * The idle session's virtual elements and autowait settings
745
+ * (sessionMemory.ts). Imported lazily, as above. Never throws.
746
+ */
747
+ function forgetIdleSessionMemory(sessionId) {
748
+ return __awaiter(this, void 0, void 0, function* () {
749
+ if (!sessionId)
750
+ return;
751
+ try {
752
+ const { forgetSessionMemory } = yield Promise.resolve().then(() => __importStar(require('./sessions/sessionMemory')));
753
+ forgetSessionMemory(sessionId);
754
+ }
755
+ catch (err) {
756
+ logger_1.default.warn(`Could not forget idle session ${sessionId}'s elements: ${err}`);
757
+ }
758
+ });
759
+ }
742
760
  function setupCronReleaseBlockedDevices(intervalMs, newCommandTimeoutSec) {
743
761
  return __awaiter(this, void 0, void 0, function* () {
744
762
  if (cronTimerToReleaseBlockedDevices) {
@@ -34,7 +34,7 @@ const createWith = (hubGrants) => ({
34
34
  });
35
35
  const dashboardHooks = {
36
36
  before: (sessionId, command, req, res) => event_manager_1.DASHBORD_EVENT_MANAGER.beforeSessionCommand(sessionId, command, req, res),
37
- after: (sessionId, command, req, res, body) => event_manager_1.DASHBORD_EVENT_MANAGER.afterSessionCommand(sessionId, command, null, req, res, body),
37
+ after: (sessionId, command, req, res, body, heal) => event_manager_1.DASHBORD_EVENT_MANAGER.afterSessionCommand(sessionId, command, null, req, res, body, heal),
38
38
  };
39
39
  /** A hub: sessions other servers run are forwarded to them. */
40
40
  function hubGatewayOptions(config) {
@@ -15,6 +15,7 @@ const https_proxy_agent_1 = require("https-proxy-agent");
15
15
  const http_proxy_agent_1 = require("http-proxy-agent");
16
16
  const internalCall_1 = require("./internalCall");
17
17
  const hubSessionToken_1 = require("./hubSessionToken");
18
+ const healReport_1 = require("./healReport");
18
19
  /**
19
20
  * Sending a session command on to the node that runs the session, and handing
20
21
  * the node's answer back to the client unchanged.
@@ -44,6 +45,7 @@ const NEVER_FORWARDED = new Set([
44
45
  'x-xenon-token',
45
46
  internalCall_1.INTERNAL_CALL_HEADER,
46
47
  hubSessionToken_1.HUB_TOKEN_HEADER,
48
+ healReport_1.HEAL_REPORT_HEADER,
47
49
  ]);
48
50
  function forwardedRequestHeaders(incoming) {
49
51
  const out = {};
@@ -0,0 +1,87 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.HEAL_REPORT_HEADER = void 0;
4
+ exports.runReportingHeals = runReportingHeals;
5
+ exports.reportHeal = reportHeal;
6
+ exports.takeHealReport = takeHealReport;
7
+ const async_hooks_1 = require("async_hooks");
8
+ /**
9
+ * A heal on a node's phone, on its way back to the hub.
10
+ *
11
+ * A session the hub created runs its commands on the node, so the node's
12
+ * CommandInterceptor heals its finds. The node keeps no record of such a
13
+ * session; the hub does. So the node puts the heal on its answer to the
14
+ * forwarded command, as the `x-xenon-heal` header, and the hub takes it off
15
+ * before relaying the answer and records it with the command, as it records a
16
+ * local session's heal.
17
+ *
18
+ * The node's session gateway runs a command that came from a hub inside
19
+ * `runReportingHeals`, so the interceptor, deep inside Appium's route, finds
20
+ * the answer to put it on. Anywhere else `reportHeal` does nothing, and the
21
+ * server records the heal itself.
22
+ */
23
+ exports.HEAL_REPORT_HEADER = 'x-xenon-heal';
24
+ /**
25
+ * A header longer than this isn't sent: the hub's HTTP parser refuses an
26
+ * answer whose headers pass 16 KB, and a refused answer would fail the
27
+ * command. A heal of selectors that long goes unrecorded instead.
28
+ */
29
+ const MAX_HEADER_LENGTH = 8 * 1024;
30
+ const answers = new async_hooks_1.AsyncLocalStorage();
31
+ /** Run the rest of a command that came from a hub with its answer at hand. */
32
+ function runReportingHeals(res, fn) {
33
+ const answer = { res };
34
+ const forget = () => {
35
+ answer.res = null;
36
+ };
37
+ res.once('finish', forget);
38
+ res.once('close', forget);
39
+ return answers.run(answer, fn);
40
+ }
41
+ /**
42
+ * Put `heal` on the answer to the hub's command this code runs for.
43
+ * `not-from-hub` when the command didn't come from a hub (or the answer has
44
+ * gone): the caller records the heal itself.
45
+ */
46
+ function reportHeal(heal) {
47
+ var _a;
48
+ const res = (_a = answers.getStore()) === null || _a === void 0 ? void 0 : _a.res;
49
+ if (!res || res.headersSent)
50
+ return 'not-from-hub';
51
+ const encoded = Buffer.from(JSON.stringify(heal), 'utf8').toString('base64url');
52
+ if (encoded.length > MAX_HEADER_LENGTH)
53
+ return 'too-long';
54
+ res.setHeader(exports.HEAL_REPORT_HEADER, encoded);
55
+ return 'reported';
56
+ }
57
+ /**
58
+ * The heal a node put on its answer, taken off the headers so it isn't
59
+ * relayed to the client. Undefined when there is none, or it isn't one.
60
+ */
61
+ function takeHealReport(headers) {
62
+ const raw = headers[exports.HEAL_REPORT_HEADER];
63
+ delete headers[exports.HEAL_REPORT_HEADER];
64
+ if (typeof raw !== 'string' || !raw)
65
+ return undefined;
66
+ try {
67
+ const heal = JSON.parse(Buffer.from(raw, 'base64url').toString('utf8'));
68
+ if (!heal ||
69
+ typeof heal.originalSelector !== 'string' ||
70
+ typeof heal.healedSelector !== 'string' ||
71
+ typeof heal.confidence !== 'number') {
72
+ return undefined;
73
+ }
74
+ const optional = (v) => (typeof v === 'string' ? v : undefined);
75
+ return {
76
+ originalSelector: heal.originalSelector,
77
+ originalStrategy: optional(heal.originalStrategy),
78
+ healedSelector: heal.healedSelector,
79
+ healedStrategy: optional(heal.healedStrategy),
80
+ confidence: heal.confidence,
81
+ tier: typeof heal.tier === 'number' ? heal.tier : undefined,
82
+ };
83
+ }
84
+ catch (_a) {
85
+ return undefined;
86
+ }
87
+ }
@@ -15,6 +15,7 @@ exports.createHubRouting = createHubRouting;
15
15
  const commandAuth_1 = require("../middleware/commandAuth");
16
16
  const internalCall_1 = require("./internalCall");
17
17
  const hubSessionToken_1 = require("./hubSessionToken");
18
+ const healReport_1 = require("./healReport");
18
19
  const forwardToNode_1 = require("./forwardToNode");
19
20
  /** Where a session runs could not be determined (a database error). */
20
21
  exports.ROUTING_UNAVAILABLE_STATUS = 503;
@@ -62,7 +63,14 @@ function createSessionGatewayLayer(deps) {
62
63
  const latency = (_b = (_a = deps.latencyOf) === null || _a === void 0 ? void 0 : _a.call(deps, sessionId)) !== null && _b !== void 0 ? _b : 0;
63
64
  if (latency > 0)
64
65
  yield new Promise((resolve) => setTimeout(resolve, latency));
65
- next();
66
+ // On a node, a heal of the hub's command goes back to the hub on the answer
67
+ // (healReport.ts). The header proves nothing elsewhere: a hub or a
68
+ // standalone server checks no hub token, so a client could send one there
69
+ // to keep its heals out of the record.
70
+ if (fromHub && deps.hubTokens)
71
+ (0, healReport_1.runReportingHeals)(res, next);
72
+ else
73
+ next();
66
74
  });
67
75
  }
68
76
  return function xenonSessionGateway(req, res, next) {
@@ -176,8 +184,10 @@ function createHubRouting(deps) {
176
184
  abort.abort();
177
185
  });
178
186
  let captured;
187
+ let heal;
179
188
  try {
180
189
  const upstream = yield (0, forwardToNode_1.sendToNode)(yield nodeRequest(req, sessionId, location, abort.signal));
190
+ heal = (0, healReport_1.takeHealReport)(upstream.headers);
181
191
  captured = yield (0, forwardToNode_1.relayAnswer)(upstream, res, !!hooks);
182
192
  }
183
193
  catch (error) {
@@ -192,7 +202,7 @@ function createHubRouting(deps) {
192
202
  }
193
203
  if (hooks && captured !== undefined) {
194
204
  yield hooks
195
- .after(sessionId, command, req, res, captured)
205
+ .after(sessionId, command, req, res, captured, heal)
196
206
  .catch((error) => logger.warn(`Dashboard log failed for session ${sessionId}: ${(0, commandAuth_1.summarize)(error)}`));
197
207
  }
198
208
  });