prismcast 1.12.0 → 1.13.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 (631) hide show
  1. package/dist/app.d.ts +41 -1
  2. package/dist/app.js +164 -76
  3. package/dist/app.js.map +1 -1
  4. package/dist/browser/blockedPage.d.ts +0 -1
  5. package/dist/browser/browserSupervisor.d.ts +34 -5
  6. package/dist/browser/browserSupervisor.js +39 -12
  7. package/dist/browser/browserSupervisor.js.map +1 -1
  8. package/dist/browser/cdp.d.ts +58 -6
  9. package/dist/browser/cdp.js +147 -9
  10. package/dist/browser/cdp.js.map +1 -1
  11. package/dist/browser/channelSelection.d.ts +18 -6
  12. package/dist/browser/channelSelection.js +47 -36
  13. package/dist/browser/channelSelection.js.map +1 -1
  14. package/dist/browser/consent.d.ts +33 -4
  15. package/dist/browser/consent.js +87 -50
  16. package/dist/browser/consent.js.map +1 -1
  17. package/dist/browser/display.d.ts +0 -1
  18. package/dist/browser/hlsPlaylistObserver.d.ts +3 -2
  19. package/dist/browser/hlsPlaylistObserver.js +11 -6
  20. package/dist/browser/hlsPlaylistObserver.js.map +1 -1
  21. package/dist/browser/index.d.ts +273 -59
  22. package/dist/browser/index.js +768 -255
  23. package/dist/browser/index.js.map +1 -1
  24. package/dist/browser/launchGovernor.d.ts +0 -1
  25. package/dist/browser/login.d.ts +11 -10
  26. package/dist/browser/login.js +42 -33
  27. package/dist/browser/login.js.map +1 -1
  28. package/dist/browser/manifestInterceptor.d.ts +14 -5
  29. package/dist/browser/manifestInterceptor.js +26 -13
  30. package/dist/browser/manifestInterceptor.js.map +1 -1
  31. package/dist/browser/navigation.d.ts +28 -0
  32. package/dist/browser/navigation.js +57 -0
  33. package/dist/browser/navigation.js.map +1 -0
  34. package/dist/browser/pageStaleness.d.ts +1 -2
  35. package/dist/browser/pageStaleness.js +9 -9
  36. package/dist/browser/pageStaleness.js.map +1 -1
  37. package/dist/browser/precaching.d.ts +27 -13
  38. package/dist/browser/precaching.js +219 -73
  39. package/dist/browser/precaching.js.map +1 -1
  40. package/dist/browser/tabCapture.d.ts +97 -0
  41. package/dist/browser/tabCapture.js +314 -0
  42. package/dist/browser/tabCapture.js.map +1 -0
  43. package/dist/browser/tabNetworkObserver.d.ts +0 -1
  44. package/dist/browser/tabSelection.d.ts +117 -0
  45. package/dist/browser/tabSelection.js +547 -0
  46. package/dist/browser/tabSelection.js.map +1 -0
  47. package/dist/browser/tuning/cache.d.ts +0 -1
  48. package/dist/browser/tuning/comcastPolymer.d.ts +0 -1
  49. package/dist/browser/tuning/comcastPolymer.js +18 -19
  50. package/dist/browser/tuning/comcastPolymer.js.map +1 -1
  51. package/dist/browser/tuning/cox.d.ts +0 -1
  52. package/dist/browser/tuning/directv.d.ts +0 -1
  53. package/dist/browser/tuning/directv.js +12 -13
  54. package/dist/browser/tuning/directv.js.map +1 -1
  55. package/dist/browser/tuning/fox.d.ts +0 -1
  56. package/dist/browser/tuning/fox.js +2 -2
  57. package/dist/browser/tuning/fox.js.map +1 -1
  58. package/dist/browser/tuning/gridSearch.d.ts +0 -1
  59. package/dist/browser/tuning/hbo.d.ts +0 -1
  60. package/dist/browser/tuning/hbo.js +2 -2
  61. package/dist/browser/tuning/hbo.js.map +1 -1
  62. package/dist/browser/tuning/hulu.d.ts +14 -2
  63. package/dist/browser/tuning/hulu.js +150 -24
  64. package/dist/browser/tuning/hulu.js.map +1 -1
  65. package/dist/browser/tuning/shared.d.ts +1 -2
  66. package/dist/browser/tuning/shared.js +1 -1
  67. package/dist/browser/tuning/sling.d.ts +0 -1
  68. package/dist/browser/tuning/sling.js +17 -22
  69. package/dist/browser/tuning/sling.js.map +1 -1
  70. package/dist/browser/tuning/spectrum.d.ts +0 -1
  71. package/dist/browser/tuning/thumbnailRow.d.ts +0 -1
  72. package/dist/browser/tuning/tileClick.d.ts +0 -1
  73. package/dist/browser/tuning/xfinity.d.ts +0 -1
  74. package/dist/browser/tuning/youtubeTv.d.ts +0 -1
  75. package/dist/browser/tuning/youtubeTv.js +1 -2
  76. package/dist/browser/tuning/youtubeTv.js.map +1 -1
  77. package/dist/browser/video.d.ts +55 -33
  78. package/dist/browser/video.js +213 -169
  79. package/dist/browser/video.js.map +1 -1
  80. package/dist/browser/windowSync.d.ts +45 -0
  81. package/dist/browser/windowSync.js +153 -0
  82. package/dist/browser/windowSync.js.map +1 -0
  83. package/dist/channels/index.d.ts +7 -1
  84. package/dist/channels/index.js +24 -8
  85. package/dist/channels/index.js.map +1 -1
  86. package/dist/config/channelForm.d.ts +0 -1
  87. package/dist/config/channelIdentity.d.ts +27 -0
  88. package/dist/config/channelIdentity.js +72 -0
  89. package/dist/config/channelIdentity.js.map +1 -0
  90. package/dist/config/channelSort.d.ts +25 -0
  91. package/dist/config/channelSort.js +101 -0
  92. package/dist/config/channelSort.js.map +1 -0
  93. package/dist/config/consistencyProbe.d.ts +0 -1
  94. package/dist/config/consistencyProbe.js +1 -1
  95. package/dist/config/consistencyProbe.js.map +1 -1
  96. package/dist/config/health.d.ts +3 -2
  97. package/dist/config/health.js +41 -31
  98. package/dist/config/health.js.map +1 -1
  99. package/dist/config/index.d.ts +12 -1
  100. package/dist/config/index.js +77 -31
  101. package/dist/config/index.js.map +1 -1
  102. package/dist/config/paths.d.ts +17 -2
  103. package/dist/config/paths.js +21 -1
  104. package/dist/config/paths.js.map +1 -1
  105. package/dist/config/persistence.context.d.ts +0 -1
  106. package/dist/config/persistence.d.ts +8 -4
  107. package/dist/config/persistence.js +143 -82
  108. package/dist/config/persistence.js.map +1 -1
  109. package/dist/config/presets.d.ts +0 -1
  110. package/dist/config/profiles.d.ts +9 -4
  111. package/dist/config/profiles.js +39 -6
  112. package/dist/config/profiles.js.map +1 -1
  113. package/dist/config/providerLineups.d.ts +0 -1
  114. package/dist/config/providerLineups.js +0 -3
  115. package/dist/config/providerLineups.js.map +1 -1
  116. package/dist/config/reactivity.d.ts +0 -1
  117. package/dist/config/servicePacks.d.ts +0 -1
  118. package/dist/config/services.d.ts +1 -47
  119. package/dist/config/services.js +4 -135
  120. package/dist/config/services.js.map +1 -1
  121. package/dist/config/sites.d.ts +0 -9
  122. package/dist/config/sites.js +82 -98
  123. package/dist/config/sites.js.map +1 -1
  124. package/dist/config/userChannels.d.ts +22 -29
  125. package/dist/config/userChannels.js +47 -65
  126. package/dist/config/userChannels.js.map +1 -1
  127. package/dist/config/userConfig.d.ts +19 -4
  128. package/dist/config/userConfig.js +123 -25
  129. package/dist/config/userConfig.js.map +1 -1
  130. package/dist/config/userProfiles.d.ts +2 -3
  131. package/dist/config/userProfiles.js +16 -13
  132. package/dist/config/userProfiles.js.map +1 -1
  133. package/dist/hdhr/channelMap.d.ts +0 -1
  134. package/dist/hdhr/deviceId.d.ts +0 -1
  135. package/dist/hdhr/discover.d.ts +0 -1
  136. package/dist/hdhr/getHandlers.d.ts +0 -1
  137. package/dist/hdhr/identity.d.ts +0 -1
  138. package/dist/hdhr/index.d.ts +0 -1
  139. package/dist/hdhr/protocol.d.ts +1 -2
  140. package/dist/hdhr/protocol.js +1 -1
  141. package/dist/hdhr/tunerState.d.ts +0 -1
  142. package/dist/hdhr/udp.d.ts +0 -1
  143. package/dist/hdhr/udp.js +16 -4
  144. package/dist/hdhr/udp.js.map +1 -1
  145. package/dist/identity.d.ts +0 -1
  146. package/dist/index.d.ts +0 -1
  147. package/dist/index.js +10 -3
  148. package/dist/index.js.map +1 -1
  149. package/dist/native/codecInference.d.ts +5 -4
  150. package/dist/native/codecInference.js +12 -7
  151. package/dist/native/codecInference.js.map +1 -1
  152. package/dist/native/decrypt.d.ts +10 -3
  153. package/dist/native/decrypt.js +9 -5
  154. package/dist/native/decrypt.js.map +1 -1
  155. package/dist/native/index.d.ts +26 -24
  156. package/dist/native/index.js +26 -20
  157. package/dist/native/index.js.map +1 -1
  158. package/dist/native/probe.d.ts +7 -10
  159. package/dist/native/probe.js +41 -37
  160. package/dist/native/probe.js.map +1 -1
  161. package/dist/native/proxy.d.ts +3 -4
  162. package/dist/native/proxy.js +91 -69
  163. package/dist/native/proxy.js.map +1 -1
  164. package/dist/native/tokenExpiry.d.ts +0 -1
  165. package/dist/routes/assets.d.ts +0 -1
  166. package/dist/routes/auth.d.ts +0 -1
  167. package/dist/routes/auth.js +1 -1
  168. package/dist/routes/auth.js.map +1 -1
  169. package/dist/routes/cdp.d.ts +11 -5
  170. package/dist/routes/cdp.js +91 -63
  171. package/dist/routes/cdp.js.map +1 -1
  172. package/dist/routes/channels.d.ts +0 -1
  173. package/dist/routes/clientActions.d.ts +0 -1
  174. package/dist/routes/components.d.ts +0 -1
  175. package/dist/routes/config/channels/endpoints/browse.d.ts +0 -1
  176. package/dist/routes/config/channels/endpoints/bulk.d.ts +0 -1
  177. package/dist/routes/config/channels/endpoints/bulk.js +3 -3
  178. package/dist/routes/config/channels/endpoints/bulk.js.map +1 -1
  179. package/dist/routes/config/channels/endpoints/crud.d.ts +0 -1
  180. package/dist/routes/config/channels/endpoints/crud.js +4 -3
  181. package/dist/routes/config/channels/endpoints/crud.js.map +1 -1
  182. package/dist/routes/config/channels/endpoints/importExport.d.ts +0 -1
  183. package/dist/routes/config/channels/endpoints/importExport.js +7 -3
  184. package/dist/routes/config/channels/endpoints/importExport.js.map +1 -1
  185. package/dist/routes/config/channels/endpoints/predefined.d.ts +0 -1
  186. package/dist/routes/config/channels/endpoints/prefs.d.ts +0 -1
  187. package/dist/routes/config/channels/endpoints/prefs.js +1 -1
  188. package/dist/routes/config/channels/endpoints/prefs.js.map +1 -1
  189. package/dist/routes/config/channels/endpoints/service.d.ts +0 -1
  190. package/dist/routes/config/channels/endpoints/service.js +8 -5
  191. package/dist/routes/config/channels/endpoints/service.js.map +1 -1
  192. package/dist/routes/config/channels/endpoints/tags.d.ts +0 -1
  193. package/dist/routes/config/channels/endpoints/tags.js +3 -3
  194. package/dist/routes/config/channels/endpoints/tags.js.map +1 -1
  195. package/dist/routes/config/channels/healthBridge.d.ts +4 -1
  196. package/dist/routes/config/channels/healthBridge.js +9 -5
  197. package/dist/routes/config/channels/healthBridge.js.map +1 -1
  198. package/dist/routes/config/channels/http/handler.d.ts +0 -1
  199. package/dist/routes/config/channels/http/playlistHint.d.ts +0 -1
  200. package/dist/routes/config/channels/http/serviceWarning.d.ts +0 -1
  201. package/dist/routes/config/channels/http/serviceWarning.js +1 -1
  202. package/dist/routes/config/channels/http/serviceWarning.js.map +1 -1
  203. package/dist/routes/config/channels/index.d.ts +0 -1
  204. package/dist/routes/config/channels/setup.d.ts +0 -1
  205. package/dist/routes/config/channels/table.d.ts +0 -1
  206. package/dist/routes/config/channels/table.js +66 -134
  207. package/dist/routes/config/channels/table.js.map +1 -1
  208. package/dist/routes/config/http/envelope.d.ts +1 -2
  209. package/dist/routes/config/index.d.ts +5 -5
  210. package/dist/routes/config/index.js +7 -5
  211. package/dist/routes/config/index.js.map +1 -1
  212. package/dist/routes/config/services.d.ts +0 -1
  213. package/dist/routes/config/services.js +17 -19
  214. package/dist/routes/config/services.js.map +1 -1
  215. package/dist/routes/config/settings.d.ts +6 -15
  216. package/dist/routes/config/settings.js +16 -35
  217. package/dist/routes/config/settings.js.map +1 -1
  218. package/dist/routes/debug.d.ts +0 -1
  219. package/dist/routes/debug.js +1 -1
  220. package/dist/routes/debug.js.map +1 -1
  221. package/dist/routes/health.d.ts +16 -8
  222. package/dist/routes/health.js +27 -12
  223. package/dist/routes/health.js.map +1 -1
  224. package/dist/routes/hls.d.ts +0 -1
  225. package/dist/routes/icons.d.ts +0 -1
  226. package/dist/routes/index.d.ts +0 -1
  227. package/dist/routes/logs.d.ts +3 -2
  228. package/dist/routes/logs.js +54 -14
  229. package/dist/routes/logs.js.map +1 -1
  230. package/dist/routes/mpegts.d.ts +0 -1
  231. package/dist/routes/play.d.ts +0 -1
  232. package/dist/routes/playlist.d.ts +0 -1
  233. package/dist/routes/playlist.js +2 -1
  234. package/dist/routes/playlist.js.map +1 -1
  235. package/dist/routes/root/content.d.ts +0 -1
  236. package/dist/routes/root/content.js +32 -11
  237. package/dist/routes/root/content.js.map +1 -1
  238. package/dist/routes/root/index.d.ts +10 -1
  239. package/dist/routes/root/index.js +17 -7
  240. package/dist/routes/root/index.js.map +1 -1
  241. package/dist/routes/root/scripts/channels.d.ts +0 -1
  242. package/dist/routes/root/scripts/channels.js +10 -20
  243. package/dist/routes/root/scripts/channels.js.map +1 -1
  244. package/dist/routes/root/scripts/clientEscape.d.ts +0 -1
  245. package/dist/routes/root/scripts/clientEscape.js +1 -1
  246. package/dist/routes/root/scripts/clientUrl.d.ts +0 -1
  247. package/dist/routes/root/scripts/config.d.ts +0 -1
  248. package/dist/routes/root/scripts/config.js +8 -29
  249. package/dist/routes/root/scripts/config.js.map +1 -1
  250. package/dist/routes/root/scripts/index.d.ts +0 -1
  251. package/dist/routes/root/scripts/shared.d.ts +0 -1
  252. package/dist/routes/root/scripts/shared.js +16 -13
  253. package/dist/routes/root/scripts/shared.js.map +1 -1
  254. package/dist/routes/root/scripts/status.d.ts +0 -1
  255. package/dist/routes/root/scripts/status.handlers.d.ts +3 -1
  256. package/dist/routes/root/scripts/status.handlers.js +52 -26
  257. package/dist/routes/root/scripts/status.handlers.js.map +1 -1
  258. package/dist/routes/root/styles.d.ts +0 -1
  259. package/dist/routes/root/styles.js +10 -18
  260. package/dist/routes/root/styles.js.map +1 -1
  261. package/dist/routes/services.d.ts +0 -1
  262. package/dist/routes/services.js +1 -1
  263. package/dist/routes/services.js.map +1 -1
  264. package/dist/routes/sse.d.ts +3 -2
  265. package/dist/routes/sse.js +7 -3
  266. package/dist/routes/sse.js.map +1 -1
  267. package/dist/routes/streams.d.ts +4 -2
  268. package/dist/routes/streams.js +8 -5
  269. package/dist/routes/streams.js.map +1 -1
  270. package/dist/routes/theme.d.ts +0 -1
  271. package/dist/routes/theme.js +3 -3
  272. package/dist/routes/theme.js.map +1 -1
  273. package/dist/routes/ui.d.ts +0 -1
  274. package/dist/routes/ui.js +5 -5
  275. package/dist/routes/ui.js.map +1 -1
  276. package/dist/routes/upgrade.d.ts +0 -1
  277. package/dist/routes/upgrade.js +2 -1
  278. package/dist/routes/upgrade.js.map +1 -1
  279. package/dist/service/commands.context.d.ts +0 -1
  280. package/dist/service/commands.context.js +7 -1
  281. package/dist/service/commands.context.js.map +1 -1
  282. package/dist/service/commands.d.ts +0 -1
  283. package/dist/service/generators.context.d.ts +0 -1
  284. package/dist/service/generators.d.ts +0 -1
  285. package/dist/service/index.d.ts +0 -1
  286. package/dist/streaming/captureLock.d.ts +1 -2
  287. package/dist/streaming/captureLock.js +8 -7
  288. package/dist/streaming/captureLock.js.map +1 -1
  289. package/dist/streaming/captureSession.d.ts +0 -1
  290. package/dist/streaming/clients.d.ts +4 -3
  291. package/dist/streaming/clients.js +5 -4
  292. package/dist/streaming/clients.js.map +1 -1
  293. package/dist/streaming/codec.d.ts +0 -1
  294. package/dist/streaming/fmp4Segmenter.d.ts +11 -6
  295. package/dist/streaming/fmp4Segmenter.js +39 -17
  296. package/dist/streaming/fmp4Segmenter.js.map +1 -1
  297. package/dist/streaming/hls.d.ts +96 -3
  298. package/dist/streaming/hls.js +282 -171
  299. package/dist/streaming/hls.js.map +1 -1
  300. package/dist/streaming/hlsResume.d.ts +8 -5
  301. package/dist/streaming/hlsResume.js +10 -8
  302. package/dist/streaming/hlsResume.js.map +1 -1
  303. package/dist/streaming/hlsSegments.d.ts +11 -8
  304. package/dist/streaming/hlsSegments.js +38 -18
  305. package/dist/streaming/hlsSegments.js.map +1 -1
  306. package/dist/streaming/lifecycle.d.ts +5 -4
  307. package/dist/streaming/lifecycle.js +43 -13
  308. package/dist/streaming/lifecycle.js.map +1 -1
  309. package/dist/streaming/monitor.d.ts +14 -3
  310. package/dist/streaming/monitor.js +447 -234
  311. package/dist/streaming/monitor.js.map +1 -1
  312. package/dist/streaming/mp4Parser.d.ts +0 -1
  313. package/dist/streaming/mpegts.d.ts +11 -1
  314. package/dist/streaming/mpegts.js +92 -28
  315. package/dist/streaming/mpegts.js.map +1 -1
  316. package/dist/streaming/playlistBuilder.d.ts +0 -1
  317. package/dist/streaming/preroll.d.ts +34 -15
  318. package/dist/streaming/preroll.js +32 -30
  319. package/dist/streaming/preroll.js.map +1 -1
  320. package/dist/streaming/pretune.d.ts +2 -3
  321. package/dist/streaming/pretune.js +45 -45
  322. package/dist/streaming/pretune.js.map +1 -1
  323. package/dist/streaming/pretuneTimers.d.ts +16 -12
  324. package/dist/streaming/pretuneTimers.js +27 -37
  325. package/dist/streaming/pretuneTimers.js.map +1 -1
  326. package/dist/streaming/recovery.d.ts +97 -9
  327. package/dist/streaming/recovery.js +96 -18
  328. package/dist/streaming/recovery.js.map +1 -1
  329. package/dist/streaming/registry.d.ts +102 -15
  330. package/dist/streaming/registry.js +90 -5
  331. package/dist/streaming/registry.js.map +1 -1
  332. package/dist/streaming/setup.d.ts +90 -32
  333. package/dist/streaming/setup.js +379 -248
  334. package/dist/streaming/setup.js.map +1 -1
  335. package/dist/streaming/showInfo.d.ts +8 -4
  336. package/dist/streaming/showInfo.js +54 -45
  337. package/dist/streaming/showInfo.js.map +1 -1
  338. package/dist/streaming/statusEmitter.d.ts +5 -3
  339. package/dist/streaming/statusEmitter.js +20 -8
  340. package/dist/streaming/statusEmitter.js.map +1 -1
  341. package/dist/types/channels.d.ts +0 -1
  342. package/dist/types/channels.js +0 -4
  343. package/dist/types/channels.js.map +1 -1
  344. package/dist/types/config.d.ts +0 -1
  345. package/dist/types/index.d.ts +4 -4
  346. package/dist/types/index.js +2 -1
  347. package/dist/types/index.js.map +1 -1
  348. package/dist/types/profiles.d.ts +51 -9
  349. package/dist/types/profiles.js +35 -1
  350. package/dist/types/profiles.js.map +1 -1
  351. package/dist/types/selection.d.ts +10 -1
  352. package/dist/types/shared.d.ts +0 -1
  353. package/dist/types/streaming.d.ts +8 -1
  354. package/dist/types/streaming.js +7 -0
  355. package/dist/types/streaming.js.map +1 -1
  356. package/dist/upgrade/commands.context.d.ts +0 -1
  357. package/dist/upgrade/commands.d.ts +0 -1
  358. package/dist/upgrade/commands.js +1 -1
  359. package/dist/upgrade/commands.js.map +1 -1
  360. package/dist/upgrade/detection.context.d.ts +0 -1
  361. package/dist/upgrade/detection.d.ts +1 -2
  362. package/dist/upgrade/index.d.ts +0 -1
  363. package/dist/upgrade/lifecycle.context.d.ts +2 -1
  364. package/dist/upgrade/lifecycle.context.js +9 -4
  365. package/dist/upgrade/lifecycle.context.js.map +1 -1
  366. package/dist/upgrade/lifecycle.d.ts +0 -1
  367. package/dist/upgrade/pathHandle.d.ts +0 -1
  368. package/dist/utils/bootSession.context.d.ts +0 -1
  369. package/dist/utils/bootSession.context.js +3 -2
  370. package/dist/utils/bootSession.context.js.map +1 -1
  371. package/dist/utils/bootSession.d.ts +0 -1
  372. package/dist/utils/chromeFetch.d.ts +0 -1
  373. package/dist/utils/cliOutput.d.ts +0 -1
  374. package/dist/utils/debugFilter.d.ts +0 -1
  375. package/dist/utils/debugFilter.js +5 -3
  376. package/dist/utils/debugFilter.js.map +1 -1
  377. package/dist/utils/delay.d.ts +65 -13
  378. package/dist/utils/delay.js +96 -23
  379. package/dist/utils/delay.js.map +1 -1
  380. package/dist/utils/errors.d.ts +0 -11
  381. package/dist/utils/errors.js +3 -14
  382. package/dist/utils/errors.js.map +1 -1
  383. package/dist/utils/evaluate.d.ts +11 -3
  384. package/dist/utils/evaluate.js +4 -3
  385. package/dist/utils/evaluate.js.map +1 -1
  386. package/dist/utils/ffmpeg.context.d.ts +0 -1
  387. package/dist/utils/ffmpeg.d.ts +1 -1
  388. package/dist/utils/ffmpeg.js +2 -1
  389. package/dist/utils/ffmpeg.js.map +1 -1
  390. package/dist/utils/fileLogger.d.ts +26 -7
  391. package/dist/utils/fileLogger.js +241 -102
  392. package/dist/utils/fileLogger.js.map +1 -1
  393. package/dist/utils/format.d.ts +36 -4
  394. package/dist/utils/format.js +66 -12
  395. package/dist/utils/format.js.map +1 -1
  396. package/dist/utils/index.d.ts +1 -2
  397. package/dist/utils/index.js +1 -1
  398. package/dist/utils/index.js.map +1 -1
  399. package/dist/utils/logEmitter.d.ts +0 -1
  400. package/dist/utils/logger.d.ts +0 -1
  401. package/dist/utils/m3u.d.ts +0 -1
  402. package/dist/utils/markup.d.ts +0 -1
  403. package/dist/utils/memo.d.ts +0 -1
  404. package/dist/utils/morganStream.d.ts +2 -3
  405. package/dist/utils/morganStream.js +6 -8
  406. package/dist/utils/morganStream.js.map +1 -1
  407. package/dist/utils/network.d.ts +0 -1
  408. package/dist/utils/never.d.ts +8 -0
  409. package/dist/utils/never.js +20 -0
  410. package/dist/utils/never.js.map +1 -0
  411. package/dist/utils/pid.d.ts +0 -1
  412. package/dist/utils/platform.d.ts +0 -1
  413. package/dist/utils/processInspector.context.d.ts +32 -2
  414. package/dist/utils/processInspector.context.js +117 -1
  415. package/dist/utils/processInspector.context.js.map +1 -1
  416. package/dist/utils/processInspector.d.ts +0 -32
  417. package/dist/utils/processInspector.js +10 -117
  418. package/dist/utils/processInspector.js.map +1 -1
  419. package/dist/utils/retry.d.ts +5 -4
  420. package/dist/utils/retry.js +43 -33
  421. package/dist/utils/retry.js.map +1 -1
  422. package/dist/utils/runtimeIdentity.context.d.ts +0 -1
  423. package/dist/utils/runtimeIdentity.context.js +3 -1
  424. package/dist/utils/runtimeIdentity.context.js.map +1 -1
  425. package/dist/utils/runtimeIdentity.d.ts +1 -1
  426. package/dist/utils/runtimeIdentity.js +1 -1
  427. package/dist/utils/runtimeIdentity.js.map +1 -1
  428. package/dist/utils/sanitize.d.ts +8 -1
  429. package/dist/utils/sanitize.js +19 -0
  430. package/dist/utils/sanitize.js.map +1 -1
  431. package/dist/utils/streamContext.d.ts +0 -1
  432. package/dist/utils/timing.d.ts +3 -4
  433. package/dist/utils/timing.js +4 -4
  434. package/dist/utils/timing.js.map +1 -1
  435. package/dist/utils/version.d.ts +14 -8
  436. package/dist/utils/version.js +35 -24
  437. package/dist/utils/version.js.map +1 -1
  438. package/package.json +7 -7
  439. package/dist/app.d.ts.map +0 -1
  440. package/dist/browser/blockedPage.d.ts.map +0 -1
  441. package/dist/browser/browserSupervisor.d.ts.map +0 -1
  442. package/dist/browser/cdp.d.ts.map +0 -1
  443. package/dist/browser/channelSelection.d.ts.map +0 -1
  444. package/dist/browser/consent.d.ts.map +0 -1
  445. package/dist/browser/display.d.ts.map +0 -1
  446. package/dist/browser/hlsPlaylistObserver.d.ts.map +0 -1
  447. package/dist/browser/index.d.ts.map +0 -1
  448. package/dist/browser/launchGovernor.d.ts.map +0 -1
  449. package/dist/browser/login.d.ts.map +0 -1
  450. package/dist/browser/manifestInterceptor.d.ts.map +0 -1
  451. package/dist/browser/pageStaleness.d.ts.map +0 -1
  452. package/dist/browser/precaching.d.ts.map +0 -1
  453. package/dist/browser/tabNetworkObserver.d.ts.map +0 -1
  454. package/dist/browser/tuning/cache.d.ts.map +0 -1
  455. package/dist/browser/tuning/comcastPolymer.d.ts.map +0 -1
  456. package/dist/browser/tuning/cox.d.ts.map +0 -1
  457. package/dist/browser/tuning/directv.d.ts.map +0 -1
  458. package/dist/browser/tuning/fox.d.ts.map +0 -1
  459. package/dist/browser/tuning/gridSearch.d.ts.map +0 -1
  460. package/dist/browser/tuning/hbo.d.ts.map +0 -1
  461. package/dist/browser/tuning/hulu.d.ts.map +0 -1
  462. package/dist/browser/tuning/shared.d.ts.map +0 -1
  463. package/dist/browser/tuning/sling.d.ts.map +0 -1
  464. package/dist/browser/tuning/spectrum.d.ts.map +0 -1
  465. package/dist/browser/tuning/thumbnailRow.d.ts.map +0 -1
  466. package/dist/browser/tuning/tileClick.d.ts.map +0 -1
  467. package/dist/browser/tuning/xfinity.d.ts.map +0 -1
  468. package/dist/browser/tuning/youtubeTv.d.ts.map +0 -1
  469. package/dist/browser/video.d.ts.map +0 -1
  470. package/dist/channels/index.d.ts.map +0 -1
  471. package/dist/config/channelForm.d.ts.map +0 -1
  472. package/dist/config/consistencyProbe.d.ts.map +0 -1
  473. package/dist/config/health.d.ts.map +0 -1
  474. package/dist/config/index.d.ts.map +0 -1
  475. package/dist/config/paths.d.ts.map +0 -1
  476. package/dist/config/persistence.context.d.ts.map +0 -1
  477. package/dist/config/persistence.d.ts.map +0 -1
  478. package/dist/config/presets.d.ts.map +0 -1
  479. package/dist/config/profiles.d.ts.map +0 -1
  480. package/dist/config/providerLineups.d.ts.map +0 -1
  481. package/dist/config/reactivity.d.ts.map +0 -1
  482. package/dist/config/servicePacks.d.ts.map +0 -1
  483. package/dist/config/services.d.ts.map +0 -1
  484. package/dist/config/sites.d.ts.map +0 -1
  485. package/dist/config/userChannels.d.ts.map +0 -1
  486. package/dist/config/userConfig.d.ts.map +0 -1
  487. package/dist/config/userProfiles.d.ts.map +0 -1
  488. package/dist/hdhr/channelMap.d.ts.map +0 -1
  489. package/dist/hdhr/deviceId.d.ts.map +0 -1
  490. package/dist/hdhr/discover.d.ts.map +0 -1
  491. package/dist/hdhr/getHandlers.d.ts.map +0 -1
  492. package/dist/hdhr/identity.d.ts.map +0 -1
  493. package/dist/hdhr/index.d.ts.map +0 -1
  494. package/dist/hdhr/protocol.d.ts.map +0 -1
  495. package/dist/hdhr/tunerState.d.ts.map +0 -1
  496. package/dist/hdhr/udp.d.ts.map +0 -1
  497. package/dist/identity.d.ts.map +0 -1
  498. package/dist/index.d.ts.map +0 -1
  499. package/dist/native/codecInference.d.ts.map +0 -1
  500. package/dist/native/decrypt.d.ts.map +0 -1
  501. package/dist/native/index.d.ts.map +0 -1
  502. package/dist/native/probe.d.ts.map +0 -1
  503. package/dist/native/proxy.d.ts.map +0 -1
  504. package/dist/native/tokenExpiry.d.ts.map +0 -1
  505. package/dist/routes/assets.d.ts.map +0 -1
  506. package/dist/routes/auth.d.ts.map +0 -1
  507. package/dist/routes/cdp.d.ts.map +0 -1
  508. package/dist/routes/channels.d.ts.map +0 -1
  509. package/dist/routes/clientActions.d.ts.map +0 -1
  510. package/dist/routes/components.d.ts.map +0 -1
  511. package/dist/routes/config/channels/endpoints/browse.d.ts.map +0 -1
  512. package/dist/routes/config/channels/endpoints/bulk.d.ts.map +0 -1
  513. package/dist/routes/config/channels/endpoints/crud.d.ts.map +0 -1
  514. package/dist/routes/config/channels/endpoints/importExport.d.ts.map +0 -1
  515. package/dist/routes/config/channels/endpoints/predefined.d.ts.map +0 -1
  516. package/dist/routes/config/channels/endpoints/prefs.d.ts.map +0 -1
  517. package/dist/routes/config/channels/endpoints/service.d.ts.map +0 -1
  518. package/dist/routes/config/channels/endpoints/tags.d.ts.map +0 -1
  519. package/dist/routes/config/channels/healthBridge.d.ts.map +0 -1
  520. package/dist/routes/config/channels/http/handler.d.ts.map +0 -1
  521. package/dist/routes/config/channels/http/playlistHint.d.ts.map +0 -1
  522. package/dist/routes/config/channels/http/serviceWarning.d.ts.map +0 -1
  523. package/dist/routes/config/channels/index.d.ts.map +0 -1
  524. package/dist/routes/config/channels/setup.d.ts.map +0 -1
  525. package/dist/routes/config/channels/table.d.ts.map +0 -1
  526. package/dist/routes/config/http/envelope.d.ts.map +0 -1
  527. package/dist/routes/config/index.d.ts.map +0 -1
  528. package/dist/routes/config/services.d.ts.map +0 -1
  529. package/dist/routes/config/settings.d.ts.map +0 -1
  530. package/dist/routes/debug.d.ts.map +0 -1
  531. package/dist/routes/health.d.ts.map +0 -1
  532. package/dist/routes/hls.d.ts.map +0 -1
  533. package/dist/routes/icons.d.ts.map +0 -1
  534. package/dist/routes/index.d.ts.map +0 -1
  535. package/dist/routes/logs.d.ts.map +0 -1
  536. package/dist/routes/mpegts.d.ts.map +0 -1
  537. package/dist/routes/play.d.ts.map +0 -1
  538. package/dist/routes/playlist.d.ts.map +0 -1
  539. package/dist/routes/root/content.d.ts.map +0 -1
  540. package/dist/routes/root/index.d.ts.map +0 -1
  541. package/dist/routes/root/scripts/channels.d.ts.map +0 -1
  542. package/dist/routes/root/scripts/clientEscape.d.ts.map +0 -1
  543. package/dist/routes/root/scripts/clientUrl.d.ts.map +0 -1
  544. package/dist/routes/root/scripts/config.d.ts.map +0 -1
  545. package/dist/routes/root/scripts/index.d.ts.map +0 -1
  546. package/dist/routes/root/scripts/shared.d.ts.map +0 -1
  547. package/dist/routes/root/scripts/status.d.ts.map +0 -1
  548. package/dist/routes/root/scripts/status.handlers.d.ts.map +0 -1
  549. package/dist/routes/root/styles.d.ts.map +0 -1
  550. package/dist/routes/services.d.ts.map +0 -1
  551. package/dist/routes/sse.d.ts.map +0 -1
  552. package/dist/routes/streams.d.ts.map +0 -1
  553. package/dist/routes/theme.d.ts.map +0 -1
  554. package/dist/routes/ui.d.ts.map +0 -1
  555. package/dist/routes/upgrade.d.ts.map +0 -1
  556. package/dist/service/commands.context.d.ts.map +0 -1
  557. package/dist/service/commands.d.ts.map +0 -1
  558. package/dist/service/generators.context.d.ts.map +0 -1
  559. package/dist/service/generators.d.ts.map +0 -1
  560. package/dist/service/index.d.ts.map +0 -1
  561. package/dist/streaming/captureLock.d.ts.map +0 -1
  562. package/dist/streaming/captureSession.d.ts.map +0 -1
  563. package/dist/streaming/clients.d.ts.map +0 -1
  564. package/dist/streaming/codec.d.ts.map +0 -1
  565. package/dist/streaming/fmp4Segmenter.d.ts.map +0 -1
  566. package/dist/streaming/hls.d.ts.map +0 -1
  567. package/dist/streaming/hlsResume.d.ts.map +0 -1
  568. package/dist/streaming/hlsSegments.d.ts.map +0 -1
  569. package/dist/streaming/lifecycle.d.ts.map +0 -1
  570. package/dist/streaming/monitor.d.ts.map +0 -1
  571. package/dist/streaming/mp4Parser.d.ts.map +0 -1
  572. package/dist/streaming/mpegts.d.ts.map +0 -1
  573. package/dist/streaming/playlistBuilder.d.ts.map +0 -1
  574. package/dist/streaming/preroll.d.ts.map +0 -1
  575. package/dist/streaming/pretune.d.ts.map +0 -1
  576. package/dist/streaming/pretuneTimers.d.ts.map +0 -1
  577. package/dist/streaming/recovery.d.ts.map +0 -1
  578. package/dist/streaming/registry.d.ts.map +0 -1
  579. package/dist/streaming/setup.d.ts.map +0 -1
  580. package/dist/streaming/showInfo.d.ts.map +0 -1
  581. package/dist/streaming/statusEmitter.d.ts.map +0 -1
  582. package/dist/types/channels.d.ts.map +0 -1
  583. package/dist/types/config.d.ts.map +0 -1
  584. package/dist/types/index.d.ts.map +0 -1
  585. package/dist/types/profiles.d.ts.map +0 -1
  586. package/dist/types/selection.d.ts.map +0 -1
  587. package/dist/types/shared.d.ts.map +0 -1
  588. package/dist/types/streaming.d.ts.map +0 -1
  589. package/dist/upgrade/commands.context.d.ts.map +0 -1
  590. package/dist/upgrade/commands.d.ts.map +0 -1
  591. package/dist/upgrade/detection.context.d.ts.map +0 -1
  592. package/dist/upgrade/detection.d.ts.map +0 -1
  593. package/dist/upgrade/index.d.ts.map +0 -1
  594. package/dist/upgrade/lifecycle.context.d.ts.map +0 -1
  595. package/dist/upgrade/lifecycle.d.ts.map +0 -1
  596. package/dist/upgrade/pathHandle.d.ts.map +0 -1
  597. package/dist/utils/bootSession.context.d.ts.map +0 -1
  598. package/dist/utils/bootSession.d.ts.map +0 -1
  599. package/dist/utils/chromeFetch.d.ts.map +0 -1
  600. package/dist/utils/cliOutput.d.ts.map +0 -1
  601. package/dist/utils/clock.d.ts +0 -15
  602. package/dist/utils/clock.d.ts.map +0 -1
  603. package/dist/utils/clock.js +0 -31
  604. package/dist/utils/clock.js.map +0 -1
  605. package/dist/utils/debugFilter.d.ts.map +0 -1
  606. package/dist/utils/delay.d.ts.map +0 -1
  607. package/dist/utils/errors.d.ts.map +0 -1
  608. package/dist/utils/evaluate.d.ts.map +0 -1
  609. package/dist/utils/ffmpeg.context.d.ts.map +0 -1
  610. package/dist/utils/ffmpeg.d.ts.map +0 -1
  611. package/dist/utils/fileLogger.d.ts.map +0 -1
  612. package/dist/utils/format.d.ts.map +0 -1
  613. package/dist/utils/index.d.ts.map +0 -1
  614. package/dist/utils/logEmitter.d.ts.map +0 -1
  615. package/dist/utils/logger.d.ts.map +0 -1
  616. package/dist/utils/m3u.d.ts.map +0 -1
  617. package/dist/utils/markup.d.ts.map +0 -1
  618. package/dist/utils/memo.d.ts.map +0 -1
  619. package/dist/utils/morganStream.d.ts.map +0 -1
  620. package/dist/utils/network.d.ts.map +0 -1
  621. package/dist/utils/pid.d.ts.map +0 -1
  622. package/dist/utils/platform.d.ts.map +0 -1
  623. package/dist/utils/processInspector.context.d.ts.map +0 -1
  624. package/dist/utils/processInspector.d.ts.map +0 -1
  625. package/dist/utils/retry.d.ts.map +0 -1
  626. package/dist/utils/runtimeIdentity.context.d.ts.map +0 -1
  627. package/dist/utils/runtimeIdentity.d.ts.map +0 -1
  628. package/dist/utils/sanitize.d.ts.map +0 -1
  629. package/dist/utils/streamContext.d.ts.map +0 -1
  630. package/dist/utils/timing.d.ts.map +0 -1
  631. package/dist/utils/version.d.ts.map +0 -1
@@ -1,22 +1,24 @@
1
- import { LOG, boundedWait, evaluateWithAbort, formatError, isProcessRunning, listProcesses, realClock, startTimer } from "../utils/index.js";
2
- import { clearLoginState, isLoginModeActive, setBrowserAccessors } from "./login.js";
3
- import { getAllStreams, getStreamCount } from "../streaming/registry.js";
1
+ import { LOG, boundedWait, evaluateWithAbort, formatError, isProcessRunning, listProcesses, setChromeUserAgent, startTimer } from "../utils/index.js";
2
+ import { TimerRegistry, systemClock } from "homebridge-plugin-utils";
3
+ import { clearLoginState, isLoginModeActive, setLoginDeps } from "./login.js";
4
+ import { getAllStreams, getStreamCount, hasActiveCaptureStreams, hasEstablishedStreams, isCaptureIdentity } from "../streaming/registry.js";
5
+ import { getCachedTabId, installStrayOpenTabReaper, onTabActivation } from "./tabSelection.js";
4
6
  import { getChromeDataDir, getDataDir, getExtensionDir } from "../config/paths.js";
5
- import { getExtensionPage, getStream, launch } from "puppeteer-stream";
7
+ import { getExtensionPage, launch } from "puppeteer-stream";
6
8
  import { getGpuCapabilities, setGpuCapabilities } from "./display.js";
7
- import { minimizeWindow, unminimizeWindow, withCDPSession } from "./cdp.js";
9
+ import { minimizeWindow, readWindowPlacement, reaffirmCaptureSurface, unminimizeWindow, withCDPSession } from "./cdp.js";
8
10
  import { CONFIG } from "../config/index.js";
11
+ import { EXTENSION_READY_EXPRESSION } from "./tabCapture.js";
9
12
  import { clearChannelSelectionCaches } from "./channelSelection.js";
10
13
  import { createBrowserSupervisor } from "./browserSupervisor.js";
14
+ import { createWindowVisibilitySync } from "./windowSync.js";
11
15
  import { emitSystemStatusChanged } from "../streaming/statusEmitter.js";
12
16
  import { evaluateStalePages } from "./pageStaleness.js";
13
17
  import fs from "node:fs";
14
18
  import { getPresetViewport } from "../config/presets.js";
15
19
  import path from "node:path";
16
20
  import { launch as puppeteerLaunch } from "puppeteer-core";
17
- import { setChromeUserAgent } from "../utils/index.js";
18
21
  import { startPrecaching } from "./precaching.js";
19
- import { terminateStream } from "../streaming/lifecycle.js";
20
22
  const { promises: fsPromises } = fs;
21
23
  /* Global variables maintain the application's runtime state across all operations. We minimize global state where possible, but some values must be shared across
22
24
  * the application lifecycle:
@@ -69,17 +71,19 @@ function buildRelaunchPolicy() {
69
71
  }
70
72
  /* The one browser capture-readiness supervisor for the process lifetime. It owns the lifecycle state (absent/launching/ready/degraded/trialing) that unifies the
71
73
  * browser reference, launch promise, and launch timestamp, and routes every relaunch through one loop-safe governor. The adapter injects the impure
72
- * ports: launchReadyBrowser (spawn Chrome and run the readiness gate), closeBrowserInstance (teardown), realClock.now (time), buildRelaunchPolicy (live config
74
+ * ports: launchReadyBrowser (spawn Chrome and run the readiness gate), closeBrowserInstance (teardown), systemClock.now (time), buildRelaunchPolicy (live config
73
75
  * bounds), and onSupervisorStateChange (the loud degraded alarm and the recovery notice). All browser access flows through it: getCurrentBrowser is acquire(); the
74
76
  * non-launching reads derive from current() and currentLaunchTime(). The injected ports are hoisted function declarations, so referencing them here is safe even
75
77
  * though they are defined further down the module.
76
78
  */
77
- const supervisor = createBrowserSupervisor({ close: closeBrowserInstance, launch: launchReadyBrowser, now: realClock.now, onStateChange: onSupervisorStateChange,
78
- policy: buildRelaunchPolicy });
79
+ const supervisor = createBrowserSupervisor({ close: closeBrowserInstance, launch: launchReadyBrowser, now: () => systemClock.now(),
80
+ onStateChange: onSupervisorStateChange, policy: buildRelaunchPolicy });
79
81
  /**
80
82
  * Observes supervisor lifecycle transitions purely for operator-visible signals; it never affects the transition (the supervisor treats it as best-effort, so a
81
- * throwing logger cannot corrupt the lifecycle). It raises the loud degraded alarm when the relaunch governor trips, an info notice when capture readiness is
82
- * restored after a degraded period, and emits the SSE system status whenever a ready browser is published.
83
+ * throwing logger cannot corrupt the lifecycle). It raises the loud degraded alarm when the relaunch governor trips, the capture-impairment alarm when a published
84
+ * browser is marked as unable to start captures, an info notice when capture readiness is restored after a degraded period, and emits the SSE system status
85
+ * whenever a ready browser is published. It reports rather than acts: the caller holding a verdict is what drives any recovery from it, because a restart begun
86
+ * from inside this observer would re-enter the supervisor while the transition that notified it is still on the stack.
83
87
  * @param next - The state being entered.
84
88
  * @param previous - The state being left.
85
89
  */
@@ -87,7 +91,7 @@ function onSupervisorStateChange(next, previous) {
87
91
  // The governor just tripped: relaunches are paused while the browser's capture system cools down. We log loudly at ERROR with the cooldown horizon so the
88
92
  // condition is never invisible - the enforceable form of "it is impossible to be silently un-tunable."
89
93
  if ((next.kind === "degraded") && (previous.kind !== "degraded")) {
90
- const cooldownMinutes = Math.max(1, Math.round((next.until - realClock.now()) / 60000));
94
+ const cooldownMinutes = Math.max(1, Math.round((next.until - systemClock.now()) / 60000));
91
95
  LOG.error("The browser capture system has degraded and the relaunch governor has tripped: %s Relaunches are paused for approximately %d minute(s) while it " +
92
96
  "cools down; new stream requests will receive a 503 back-off until it recovers.", next.reason, cooldownMinutes);
93
97
  }
@@ -96,29 +100,54 @@ function onSupervisorStateChange(next, previous) {
96
100
  // silent; only a recovery from trialing/degraded is worth an operator notice.
97
101
  LOG.info("The browser capture system has recovered and is serving captures again.");
98
102
  }
103
+ else if ((next.kind === "ready") && (next.impairment !== null) && ((previous.kind !== "ready") || (previous.impairment === null))) {
104
+ // The published browser has been marked as unable to start captures. The transition carrying the mark happens once per instance, so the alarm fires once per
105
+ // instance too, and it says what an operator watching a stalled tune needs: the running captures are unaffected, new requests back off, and the cure arrives
106
+ // on its own as soon as nothing on this browser holds a page.
107
+ LOG.error("The browser can no longer start captures (%s). Its running captures continue, new stream requests receive a 503 back-off, and it will relaunch as " +
108
+ "soon as nothing holds a page on it.", next.impairment.reason);
109
+ }
99
110
  // The browser's connectivity is part of the SSE system status, so emit when a ready browser is published. Readiness-loss emits are owned by handleBrowserDisconnect
100
111
  // (genuine disconnect) and the shutdown path; emitSystemStatusChanged dedupes, so a redundant emit is a cheap no-op.
101
112
  if (next.kind === "ready") {
102
113
  void emitCurrentSystemStatus();
103
114
  }
104
115
  }
105
- /* The capture-readiness probe (capability tier of the launch gate). Null until streaming/setup.ts injects the real getStream probe at module load, which the import
106
- * order guarantees runs before any launch: app.ts imports the streaming layer, whose module bodies evaluate during import resolution, before startServer's warm-up.
107
- * launchReadyBrowser refuses to publish a browser if it is somehow still null (see the call site), rather than serving an unverified one.
116
+ /* The capture-readiness probe (capability tier of the launch gate). Null until streaming/setup.ts injects the real capture probe at module load, which the import
117
+ * order places ahead of any launch: index.ts value-imports app.ts, app.ts value-imports streaming/hls.ts, and hls.ts value-imports streaming/setup.ts, so the
118
+ * injection has run by the time index.ts's own body calls startServer. launchReadyBrowser refuses to publish a browser if it is somehow still null (see the call
119
+ * site), rather than serving an unverified one.
108
120
  */
109
121
  let captureProbe = null;
110
122
  /**
111
123
  * Injects the capture-readiness probe used as the capability tier of the launch gate. Called once from streaming/setup.ts at module load (which always precedes any
112
124
  * launch, since the streaming layer is imported during server startup). Separating the wiring from the call keeps browser/index.ts free of a streaming-layer import.
113
- * @param probe - The probe to run against a freshly-launched browser; it resolves when the browser can capture and rejects otherwise (or exits the process for the
114
- * unrecoverable stale-mutex case).
125
+ * @param probe - The probe to run against a freshly-launched browser; it resolves when the browser can capture and rejects when it cannot.
115
126
  */
116
127
  export function setCaptureProbe(probe) {
117
128
  captureProbe = probe;
118
129
  }
119
- // The stale page cleanup interval handle, stored so we can clear it during graceful shutdown. The interval periodically checks for browser pages that are not
120
- // associated with active streams and closes them to prevent resource exhaustion.
121
- let stalePageCleanupInterval = null;
130
+ /* The authoritative stream terminator. Null until streaming/lifecycle.ts injects terminateStream at module load, which the import order places ahead of any
131
+ * browser launch: index.ts value-imports app.ts, app.ts value-imports streaming/lifecycle.ts, and a module body finishes before the body of anything that
132
+ * imports it, so the injection has run by the time index.ts's own body calls startServer - the earliest moment a launch, and so a disconnect, can happen. A
133
+ * still-null terminator at readiness loss is reported at ERROR and the teardown carries on (see the call site), because losing the rest of the teardown on top
134
+ * of the stream cleanup would make a recoverable situation worse.
135
+ */
136
+ let streamTerminator = null;
137
+ /**
138
+ * Injects the authoritative stream terminator used when browser readiness is lost. Called once from streaming/lifecycle.ts at module load. Separating the wiring
139
+ * from the call keeps browser/index.ts free of a streaming-layer import.
140
+ * @param terminator - The function that tears down one stream and all of the resources it owns.
141
+ */
142
+ export function setStreamTerminator(terminator) {
143
+ streamTerminator = terminator;
144
+ }
145
+ // The identity of the periodic sweep on the stale-page owner's registry.
146
+ const STALE_PAGE_SWEEP_KEY = "sweep";
147
+ /* The stale-page owner's timers: the periodic sweep that closes browser pages no active stream is using, so a long session cannot exhaust resources. The registry
148
+ * is built on the clock the start receives and disposed by the stop, so the sweep can never outlive the owner. Null until started, and again once stopped.
149
+ */
150
+ let stalePageTimers = null;
122
151
  /* Opportunistic browser restart state. Chrome accumulates memory pressure, GPU process issues, and general flakiness over multi-hour sessions with continuous
123
152
  * media playback. We proactively restart Chrome after it has been running for BROWSER_MAX_AGE, waiting for a quiet period with zero active streams before
124
153
  * executing the restart. After the restart, a fresh browser is launched immediately so it is ready for the next stream request.
@@ -129,11 +158,23 @@ const BROWSER_MAX_AGE = 6 * 60 * 60 * 1000;
129
158
  const BROWSER_RESTART_QUIET_PERIOD = 5 * 60 * 1000;
130
159
  // How often to check whether the browser qualifies for a restart (30 seconds).
131
160
  const BROWSER_RESTART_CHECK_INTERVAL = 30000;
132
- // Timer handle for the quiet period countdown. When set, the browser has exceeded BROWSER_MAX_AGE and we are waiting for BROWSER_RESTART_QUIET_PERIOD to
133
- // elapse with zero active streams. Cancelled if a stream starts during the quiet period.
134
- let restartQuietTimer = null;
135
- // Interval handle for the periodic restart eligibility check.
136
- let restartCheckInterval = null;
161
+ // The identity of the periodic eligibility check on the restart owner's registry.
162
+ const RESTART_CHECK_KEY = "check";
163
+ // The identity of the quiet-period countdown on the restart owner's registry.
164
+ const RESTART_QUIET_KEY = "quiet";
165
+ /* The restart owner's timers, keyed on one registry built on the clock its start receives: the periodic eligibility check, and - while the browser has exceeded
166
+ * BROWSER_MAX_AGE and we are waiting for BROWSER_RESTART_QUIET_PERIOD to elapse with zero active streams - the quiet-period countdown, which a stream starting
167
+ * during the wait cancels. Holding both on one registry is what makes the stop drain the countdown along with the check. Null until the check is started, and
168
+ * again once it is stopped.
169
+ */
170
+ let restartTimers = null;
171
+ /**
172
+ * Cancels the scheduled-restart quiet period if one is pending, so the countdown it was running cannot fire against a browser it no longer describes. Reads the
173
+ * module binding rather than a handed-in registry because executeBrowserRestart calls it as well, which also makes it a no-op once the owner has been stopped.
174
+ */
175
+ function cancelRestartQuietTimer() {
176
+ restartTimers?.clear(RESTART_QUIET_KEY);
177
+ }
137
178
  // Flag indicating that the browser is being closed intentionally via closeBrowser(). When true, the disconnect handler skips error logging and stream termination
138
179
  // since these are handled by the shutdown code path. This prevents false "unexpected disconnect" errors during graceful shutdown.
139
180
  let gracefulShutdownInProgress = false;
@@ -166,23 +207,205 @@ const managedPageIds = new Set();
166
207
  // Map from page ID to timestamp when a page was first observed as potentially stale (not associated with an active stream). Pages must remain in this state for
167
208
  // the configured grace period before being closed. This prevents race conditions where pages are briefly untracked during initialization or cleanup transitions.
168
209
  const potentiallyStalePages = new Map();
169
- /* Set of IDs for pages that stream setup created and whose ownership the stream registry does not yet record. Setup writes the registry's page reference only once
170
- * it completes, which on a slow tune is long enough for the cleanup walk to see the page as unowned and close it out from under the setup driving it; membership
171
- * here exempts the page from staleness for exactly that window. An entry leaves the set when unregisterManagedPage releases the page as setup tears it down, when
172
- * the cleanup walk sees the registry record the ownership the mark stood in for, and when clearPageTracking wipes the collections at the end of a browser session.
210
+ /* Set of IDs for pages an operation owns for its whole duration while nothing the cleanup walk reads records that ownership. Stream setup is one such owner: it
211
+ * writes the registry's page reference only once it completes, which on a slow tune is long enough for the walk to see the page as unowned and close it out from
212
+ * under the setup driving it. A channel discovery walk is another: its page is never a stream page, so no registry entry will ever speak for it, and the walk's
213
+ * own cleanup is what releases it. Membership here exempts the page from staleness for exactly the window its owner holds it. An entry leaves the set when
214
+ * unregisterManagedPage releases the page as its owner finishes, when the cleanup walk sees the registry record the ownership the mark stood in for, and when
215
+ * clearPageTracking wipes the collections at the end of a browser session.
173
216
  */
174
- const inFlightSetupPageIds = new Set();
217
+ const inFlightPageIds = new Set();
218
+ /* The pages that live in a window of their own rather than the shared one. A CDP window command acts on the window of the page whose session carries it, so
219
+ * which page a command travels on decides which window moves...and a page in its own window would move that one. Membership here is what keeps such a page
220
+ * out of every site that reaches for a convenient page to command the shared window through.
221
+ *
222
+ * Keyed on the page and never cleared. The fact is intrinsic to how the page was created rather than a phase of its life, an entry dies with the page that
223
+ * holds it, and the picker below already declines a closed page - so unregistration, teardown order, and clearPageTracking have nothing to maintain here.
224
+ */
225
+ const ownWindowPages = new WeakSet();
226
+ /**
227
+ * Reports whether a page may carry a CDP command aimed at the shared window: it exists, it is still open, and it does not live in a window of its own. The
228
+ * parameter accepts the absent case so every caller narrows through this one test rather than guarding beside it.
229
+ * @param page - The candidate page, which may be absent.
230
+ * @returns True when the page can carry a shared-window command.
231
+ */
232
+ export function isCarrierPage(page) {
233
+ return !!page && !page.isClosed() && !ownWindowPages.has(page);
234
+ }
235
+ /**
236
+ * Picks the page a shared-window command should travel on from the pages a browser has open, taking the first that qualifies.
237
+ * @param pages - The candidate pages, in the order the browser reports them.
238
+ * @returns The page to carry the command, or null when none of them qualifies.
239
+ */
240
+ export function pickCarrierPage(pages) {
241
+ return pages.find(isCarrierPage) ?? null;
242
+ }
243
+ /* Which window Chrome's own tables call the shared one, keyed on the browser that owns it. The value belongs to CDP's window table and is never comparable with
244
+ * the capture extension's chrome.windows ids: those are two independent tables, and this is the only place a value from the CDP side is held.
245
+ *
246
+ * Keyed on the browser and never cleared, for the reason the own-window mark above is: the identity is a fact about how that browser was created, an entry dies
247
+ * with the browser holding it, and a relaunch records its own. An id that outlived its browser is exactly what would place a capture tab in the wrong window
248
+ * while every check agreed, so there is deliberately no way to write one a live browser did not report.
249
+ */
250
+ const sharedWindowIds = new WeakMap();
251
+ /* The window each page was found in, cached for the page's life in the tabIds idiom of tabSelection.ts. The lookup below attaches a CDP session the shared helper
252
+ * does not detach, so reading a candidate fresh on every tune would leave a session behind on each launch-era page every time. A page a user drags into another
253
+ * window keeps the window it was first read in; a stale entry costs a carrier that has since moved out of the shared window, which the placement confirmation
254
+ * catches and answers with the plain create rather than with a tab in the wrong place.
255
+ */
256
+ const pageWindowIds = new WeakMap();
257
+ // The URL prefix the capture extension's own pages carry. The resolver reads it to prefer a plain-origin carrier, since an open evaluated on the extension's
258
+ // options page is territory nothing has measured.
259
+ const EXTENSION_PAGE_PREFIX = "chrome-extension://";
260
+ /**
261
+ * Reads the window a page sits in, answering from the cache once the page has been read.
262
+ * @param page - The page to locate.
263
+ * @returns The window's CDP id, or undefined when the window cannot be read at all - a closed page, a target that yields no window, or a CDP failure.
264
+ */
265
+ async function readPageWindow(page) {
266
+ const cached = pageWindowIds.get(page);
267
+ if (cached !== undefined) {
268
+ return cached;
269
+ }
270
+ const windowId = await withCDPSession(page, async (_session, id) => id);
271
+ if (windowId !== undefined) {
272
+ pageWindowIds.set(page, windowId);
273
+ }
274
+ return windowId;
275
+ }
276
+ /**
277
+ * Records which window the shared one is, reading it from a page that sits in it.
278
+ *
279
+ * This is the one way an identity is written. The launch path calls it once the launch-era pages exist, so every browser this process publishes carries the
280
+ * answer before anything asks for a capture tab, and a probe running against a browser of its own calls the same function rather than reaching for the map.
281
+ * @param browser - The browser whose shared window is being recorded.
282
+ * @param page - A page that sits in that window.
283
+ */
284
+ export async function noteSharedWindow(browser, page) {
285
+ const windowId = await readPageWindow(page);
286
+ if (windowId === undefined) {
287
+ LOG.debug("browser:lifecycle", "The shared browser window could not be identified, so capture tabs will open wherever Chrome places them.");
288
+ return;
289
+ }
290
+ sharedWindowIds.set(browser, windowId);
291
+ }
292
+ /**
293
+ * Picks the page an opener-anchored tab should be opened from: one the browser has open, living in the shared window rather than a window of its own, and
294
+ * confirmed to sit in the window this process recorded.
295
+ *
296
+ * A plain-origin page is preferred over the capture extension's own options page, which is the carrier of last resort. The options page is recognized by the URL
297
+ * prefix its own pages carry rather than through the library's resolver, whose wait for a missing extension would spend thirty seconds inside a caller's turn.
298
+ * When no identity was ever recorded the confirmation has nothing to test against, and the first qualifying page is the answer: an anchor to some window of the
299
+ * browser's is still better than letting Chrome choose one.
300
+ * @param browser - The browser to resolve a carrier in.
301
+ * @returns The page to evaluate the open on, or null when none of the browser's pages qualifies.
302
+ */
303
+ export async function resolveSharedWindowCarrier(browser) {
304
+ const sharedWindowId = sharedWindowIds.get(browser);
305
+ const candidates = (await browser.pages()).filter(isCarrierPage);
306
+ for (const page of [...candidates.filter((candidate) => !candidate.url().startsWith(EXTENSION_PAGE_PREFIX)),
307
+ ...candidates.filter((candidate) => candidate.url().startsWith(EXTENSION_PAGE_PREFIX))]) {
308
+ if (sharedWindowId === undefined) {
309
+ return page;
310
+ }
311
+ // eslint-disable-next-line no-await-in-loop -- A candidate is only asked for its window once the candidates ahead of it have failed to qualify.
312
+ if ((await readPageWindow(page)) === sharedWindowId) {
313
+ return page;
314
+ }
315
+ }
316
+ return null;
317
+ }
318
+ /**
319
+ * Reports whether a page ended up in the shared window.
320
+ *
321
+ * Advisory when no identity was ever recorded: there is nothing to contradict the placement with, so the answer is true and the caller proceeds. A window that
322
+ * cannot be READ is the opposite answer, though, and deliberately so - the shared lookup reports undefined for a closed page, an empty response, and a CDP
323
+ * failure alike, and reading any of those as a confirmation would confirm a wrong-window tab in exactly the case this check exists for.
324
+ * @param page - The page to test.
325
+ * @returns True when the page sits in the recorded window, or when no window was ever recorded.
326
+ */
327
+ export async function confirmSharedWindowPlacement(page) {
328
+ const sharedWindowId = sharedWindowIds.get(page.browser());
329
+ if (sharedWindowId === undefined) {
330
+ return true;
331
+ }
332
+ return (await readPageWindow(page)) === sharedWindowId;
333
+ }
175
334
  // Login mode management. State and functions live in login.ts; re-exported here so existing consumers don't need import path changes. clearLoginState,
176
- // isLoginModeActive, and setBrowserAccessors are imported above; the first two for internal use, setBrowserAccessors for one-time initialization below.
335
+ // isLoginModeActive, and setLoginDeps are imported above; the first two for internal use, setLoginDeps for one-time initialization below.
177
336
  export { clearLoginState, isLoginModeActive };
178
337
  export { endLoginMode, getLoginPage, getLoginStatus, setLoginModeEndObserver, startLoginMode } from "./login.js";
179
338
  // Re-export the supervisor's acquire() rejection classes through the browser surface so the stream-setup layer can map them to a 503 back-off without reaching into
180
- // the supervisor module directly. Both signal a transient "retry me" condition: BrowserUnavailableError while the relaunch governor is cooling, BrowserSupersededError
181
- // when a launch was abandoned mid-flight by a readiness-loss.
182
- export { BrowserSupersededError, BrowserUnavailableError } from "./browserSupervisor.js";
183
- // Inject browser accessors into the login module. This breaks the circular dependency (login needs getBrowserInstance/minimizeBrowserWindow, index needs login
184
- // functions) using the same setter/getter pattern as setChromeUserAgent in chromeFetch.ts. Function declarations are hoisted, so both accessors are available here.
185
- setBrowserAccessors({ getBrowserInstance, minimizeBrowserWindow });
339
+ // the supervisor module directly. Each signals a transient "retry me" condition: BrowserUnavailableError while the relaunch governor is cooling,
340
+ // BrowserSupersededError when a launch was abandoned mid-flight by a readiness-loss, and BrowserCaptureImpairedError when the published browser can no longer start
341
+ // captures and is waiting for its streams to end. The purpose and impairment types cross the same boundary, so a caller declares what it needs the browser for and
342
+ // reads the mark without importing the supervisor.
343
+ export { BrowserCaptureImpairedError, BrowserSupersededError, BrowserUnavailableError } from "./browserSupervisor.js";
344
+ /* The one window-presentation executor for the process lifetime. Its collaborators all live in this module: the registry predicate that says whether capture is
345
+ * reading the compositor, login mode's own flag, the shutdown gate, the two CDP primitives, and a page resolver built on the supervisor.
346
+ *
347
+ * The resolver prefers the page its caller handed over, borrows any page the browser already has open when there is none, and creates a temporary page only when
348
+ * the browser has nothing open at all. A temporary page is the only resolution that carries a dispose, so the executor releases what the resolver created and
349
+ * leaves a borrowed page alone. Both the preference and the borrow go through the shared carrier rule, because a command travels on a page's own session and
350
+ * so acts on that page's window: a page living in a window of its own would move that window instead of the shared one, whether it arrived as the caller's
351
+ * preference or as the first page the browser happened to report.
352
+ *
353
+ * The instance stays private to this module and every caller reaches it through the exported function just below. That split is what makes the symbol safe to
354
+ * reference at module-evaluation time: a function declaration is hoisted, so the setLoginDeps call further down - and any sibling module whose own body
355
+ * evaluates while this module is still evaluating, precaching.ts among them - resolves it whatever the declaration order turns out to be. A bare exported const
356
+ * would leave those readers in the temporal dead zone, which no placement inside this file can fix for a reader in another file.
357
+ */
358
+ const windowVisibilitySync = createWindowVisibilitySync({
359
+ hasActiveCaptureStreams,
360
+ isLoginModeActive,
361
+ isShuttingDown: isGracefulShutdown,
362
+ minimize: minimizeWindow,
363
+ resolvePage: async (preferred) => {
364
+ const browser = supervisor.current();
365
+ if (!browser?.connected) {
366
+ return null;
367
+ }
368
+ if (isCarrierPage(preferred)) {
369
+ return { dispose: null, page: preferred };
370
+ }
371
+ const borrowed = pickCarrierPage(await browser.pages());
372
+ if (borrowed) {
373
+ return { dispose: null, page: borrowed };
374
+ }
375
+ // A page that exists only to carry a CDP command never needs its tab selected.
376
+ const temporary = await browser.newPage({ background: true });
377
+ // Registered so stale page cleanup recognizes the page as ours for the moment it exists.
378
+ registerManagedPage(temporary);
379
+ return {
380
+ dispose: async () => {
381
+ unregisterManagedPage(temporary);
382
+ try {
383
+ await temporary.close();
384
+ }
385
+ catch (error) {
386
+ // The browser may already have taken the page with it. Nothing downstream depends on the close succeeding.
387
+ LOG.debug("browser:lifecycle", "Could not close the temporary window-sync page: %s.", formatError(error));
388
+ }
389
+ },
390
+ page: temporary
391
+ };
392
+ },
393
+ unminimize: unminimizeWindow
394
+ });
395
+ /**
396
+ * Brings the shared browser window into agreement with the visibility policy. This is the one entry point every layer uses - streaming, recovery, login,
397
+ * precaching, and startup all call it and none of them decide window state themselves.
398
+ * @param page - The page whose CDP session should carry the command, when the caller has one in hand. The executor falls back to any open page, or a temporary
399
+ * one, when it is omitted or the page has closed.
400
+ * @returns A promise that resolves once a pass that began at or after this call has completed.
401
+ */
402
+ export async function syncWindowVisibility(page) {
403
+ return windowVisibilitySync(page);
404
+ }
405
+ // Inject login's dependency set. This breaks the circular dependency (login needs getBrowserInstance and the window sync, index needs login functions) using the
406
+ // same setter/getter pattern as setChromeUserAgent in chromeFetch.ts. Both accessors are hoisted function declarations, so this call reads them at module
407
+ // evaluation time regardless of where they sit in the file. No clock is supplied, so login runs on the system clock the port defaults to.
408
+ setLoginDeps({ getBrowserInstance, syncWindowVisibility });
186
409
  /**
187
410
  * Computes the current system status and emits it to SSE subscribers. Called when browser state changes significantly or when streams are added/removed.
188
411
  */
@@ -203,6 +426,12 @@ export async function emitCurrentSystemStatus() {
203
426
  const memUsage = process.memoryUsage();
204
427
  const status = {
205
428
  browser: {
429
+ /* Read at compose time, beside the connectivity read below rather than as a snapshot taken before the page-count await. This function is called un-awaited
430
+ * from many sites, so two calls can be in flight around the instant a mark lands; a pre-mark snapshot settling after the mark's own emit would broadcast
431
+ * false over the cached true and the dedupe would honor it. Reading here makes every emit describe the state at the moment consumers receive it, so the
432
+ * later of two racing emits is also the truer one.
433
+ */
434
+ captureImpaired: supervisor.captureImpairment() !== null,
206
435
  connected: !!browser && browser.connected,
207
436
  pageCount
208
437
  },
@@ -225,8 +454,9 @@ export async function emitCurrentSystemStatus() {
225
454
  * Each registered page receives a unique ID that persists for the page's lifetime. This ID is used for comparison and staleness tracking, avoiding potential
226
455
  * issues with Page object reference identity.
227
456
  * @param page - The Puppeteer Page to register.
228
- * @param options - Registration options. Set inFlightSetup when stream setup owns the page but has not yet recorded that ownership in the stream registry, so the
229
- * stale page cleanup never closes a page whose stream is still being established.
457
+ * @param options - Registration options. Set inFlight when an operation owns the page for its duration but nothing the stale page cleanup reads records that
458
+ * ownership - a stream setup that has not yet written its page into the registry, or a discovery walk whose page belongs to no stream at all - so the cleanup
459
+ * never closes a page out from under the operation driving it.
230
460
  */
231
461
  export function registerManagedPage(page, options = {}) {
232
462
  // Generate a unique ID for this page.
@@ -235,9 +465,9 @@ export function registerManagedPage(page, options = {}) {
235
465
  pageToId.set(page, pageId);
236
466
  // Track the ID as managed.
237
467
  managedPageIds.add(pageId);
238
- // Exempt the page from staleness for as long as its stream setup is in flight.
239
- if (options.inFlightSetup) {
240
- inFlightSetupPageIds.add(pageId);
468
+ // Exempt the page from staleness for as long as the operation that owns it is in flight.
469
+ if (options.inFlight) {
470
+ inFlightPageIds.add(pageId);
241
471
  }
242
472
  }
243
473
  /**
@@ -251,8 +481,8 @@ export function unregisterManagedPage(page) {
251
481
  managedPageIds.delete(pageId);
252
482
  // Also remove from potentially stale tracking since we're intentionally closing it.
253
483
  potentiallyStalePages.delete(pageId);
254
- // Release any in-flight setup mark. The page is leaving our management, so the exemption it carried has nothing left to protect.
255
- inFlightSetupPageIds.delete(pageId);
484
+ // Release any in-flight mark. The page is leaving our management, so the exemption it carried has nothing left to protect.
485
+ inFlightPageIds.delete(pageId);
256
486
  // Note: We don't delete from pageToId because WeakMap handles cleanup automatically when the Page is garbage collected.
257
487
  }
258
488
  }
@@ -274,7 +504,7 @@ function getManagedPageId(page) {
274
504
  function clearPageTracking() {
275
505
  managedPageIds.clear();
276
506
  potentiallyStalePages.clear();
277
- inFlightSetupPageIds.clear();
507
+ inFlightPageIds.clear();
278
508
  }
279
509
  /**
280
510
  * Ensures the data directory exists, creating it if necessary. This should be called during application startup before any operations that depend on the data
@@ -434,15 +664,16 @@ export function killStaleChrome() {
434
664
  }
435
665
  /**
436
666
  * Polls until the Chrome process with the given PID has exited, or the timeout expires. Uses process.kill(pid, 0) to check process existence - throws ESRCH
437
- * when the process is gone. Between polls, sleeps synchronously using Atomics.wait() for cross-platform compatibility.
667
+ * when the process is gone. The wait runs inside the synchronous exit handler, so it reads the port for its instants and sleeps synchronously between them
668
+ * using Atomics.wait() for cross-platform compatibility.
438
669
  * @param pid - The Chrome process ID to wait for.
439
670
  * @param timeoutMs - Maximum time to wait in milliseconds.
440
671
  * @param pollIntervalMs - Time between existence checks in milliseconds.
441
672
  * @returns True if the process exited within the timeout, false otherwise.
442
673
  */
443
674
  function waitForChromeExit(pid, timeoutMs, pollIntervalMs) {
444
- const deadline = Date.now() + timeoutMs;
445
- while (Date.now() < deadline) {
675
+ const deadline = systemClock.now() + timeoutMs;
676
+ while (systemClock.now() < deadline) {
446
677
  if (!isProcessRunning(pid)) {
447
678
  return true;
448
679
  }
@@ -580,6 +811,9 @@ export function getExecutablePath() {
580
811
  * the full rationale.
581
812
  */
582
813
  const PUPPETEER_STREAM_EXTENSION_ID = "jjndjgheafjngoipoacpjgeicjeomjli";
814
+ // Chrome's documented value for disabling the device-scale-factor part of a metrics override, leaving the display's own density in effect. Naming it keeps the
815
+ // sentinel from reading as a literal density of zero.
816
+ const NATIVE_DENSITY = 0;
583
817
  /**
584
818
  * Assembles the configuration options for launching Chrome with Puppeteer. These options are critical for reliable streaming:
585
819
  *
@@ -590,7 +824,6 @@ const PUPPETEER_STREAM_EXTENSION_ID = "jjndjgheafjngoipoacpjgeicjeomjli";
590
824
  * @returns Puppeteer launch options.
591
825
  */
592
826
  export function buildLaunchOptions() {
593
- const viewport = getPresetViewport(CONFIG);
594
827
  return {
595
828
  /* Chrome command-line arguments. Each flag serves a specific purpose for reliable streaming:
596
829
  *
@@ -602,7 +835,7 @@ export function buildLaunchOptions() {
602
835
  * is a defensive duplicate: if a future puppeteer-stream release drops the flag again in favor of granting activeTab via a synthetic keystroke,
603
836
  * CDP-synthesized keystrokes do not satisfy chrome.commands under automation (the renderer sees the event but the browser-process accelerator dispatcher
604
837
  * does not), so capture would be denied at the API level (see github.com/Flam3rboy/puppeteer-stream/issues/206) without this fallback in place. Confirm
605
- * whether this duplicate is still warranted whenever the pinned puppeteer-stream version changes.
838
+ * whether this duplicate is still warranted whenever the locked puppeteer-stream version changes.
606
839
  *
607
840
  * --autoplay-policy=no-user-gesture-required: Allows video and audio to play without requiring a user click first. Essential for automated streaming
608
841
  * since we cannot simulate genuine user interaction for autoplay policy purposes.
@@ -645,18 +878,30 @@ export function buildLaunchOptions() {
645
878
  "--hide-scrollbars",
646
879
  "--no-first-run"
647
880
  ],
648
- /* The configured quality preset is the capture surface. Puppeteer holds this viewport for the browser's lifetime and applies it as a device-metrics override
649
- * to every page the browser creates - capture pages, probe pages, discovery pages, and the capture extension's own page - so no individual page-creation site
650
- * has to ask for it. Tab capture reads the compositor's emulated surface rather than the OS window, which is what lets capture run at the preset's resolution
651
- * whatever size the display is or the window happens to be. A device scale factor of 1 keeps a 1:1 render-to-encode mapping...the surface is rastered at
652
- * exactly the pixel count the encoder consumes, so nothing is scaled on the way out. puppeteer-stream reads this same option and derives Chrome's window and
653
- * ozone screen dimension flags from it, so the preset's dimensions enter the launch in exactly one place.
881
+ /* No launch-wide viewport, stated as an explicit null because an omitted option is not the same thing: Puppeteer reads an absent defaultViewport as its own
882
+ * 800x600 default and applies that override to every page it creates, while a null leaves each page carrying no override at all. Two consequences follow, and
883
+ * both are the point. A page renders at whatever surface PrismCast declares on it and nothing else - the capture surface on a capture page, the preset-sized
884
+ * layout on the discovery and capture-probe pages - so no page inherits an emulation it never asked for. And puppeteer-stream derives Chrome's --window-size
885
+ * and --ozone-override-screen-size flags from a sized default, where a --window-size flag stops Chrome restoring the placement it persisted in the profile, so
886
+ * with no default the window's size and placement are Chrome's and the user's.
887
+ *
888
+ * The return type names the null so the key cannot be dropped as dead configuration without a compile error, since its absence is silently a viewport rather
889
+ * than none.
654
890
  */
655
- defaultViewport: { deviceScaleFactor: 1, height: viewport.height, width: viewport.width },
891
+ defaultViewport: null,
656
892
  // Path to the Chrome executable, either from environment variable or autodetected.
657
893
  executablePath: getExecutablePath(),
658
- // Run Chrome in headed (visible) mode, not headless. The puppeteer-stream extension requires a visible browser window to capture the screen. We minimize
659
- // the window after launch to reduce GPU usage while still allowing capture.
894
+ /* PrismCast owns process signals, so the launcher's own listeners are off. Left on, the puppeteer launcher installs handlers for SIGHUP, SIGINT, and SIGTERM
895
+ * on the node process and, on SIGTERM or SIGHUP, ends Chrome's entire process group with SIGKILL - and on SIGINT it kills the group and then exits the process
896
+ * outright. Either way it races the shutdown handlers in app.ts and preempts closeBrowserInstance's SIGTERM ladder below, so Chrome gets no shutdown at all
897
+ * and forfeits whatever it had pending: a window placement inside its save debounce, its session state. With the listeners off, a signal reaches PrismCast's
898
+ * own handler, the graceful path runs, and Chrome exits through the ladder with its pending writes committed.
899
+ */
900
+ handleSIGHUP: false,
901
+ handleSIGINT: false,
902
+ handleSIGTERM: false,
903
+ // Run Chrome in headed (visible) mode, not headless. The puppeteer-stream extension captures the compositor's output for a real window, which a headless
904
+ // browser does not have. The window is on screen while any capture stream runs or a login session is active, and minimized otherwise.
660
905
  headless: false,
661
906
  /* Prevent Puppeteer from adding certain default arguments that would interfere with streaming:
662
907
  *
@@ -692,6 +937,233 @@ export function buildLaunchOptions() {
692
937
  userDataDir: getChromeDataDir(CONFIG)
693
938
  };
694
939
  }
940
+ /**
941
+ * Declares a device-metrics override on a page: the configured quality preset's dimensions at the density the caller names. Every surface this module declares is
942
+ * built here, so the preset reaches a page's emulation through exactly one read and one command.
943
+ * @param page - The page to declare the surface on.
944
+ * @param deviceScaleFactor - The pixel density to declare. NATIVE_DENSITY leaves the display's own density in effect.
945
+ * @returns The dimensions declared on the page.
946
+ */
947
+ async function declareSurface(page, deviceScaleFactor) {
948
+ const viewport = getPresetViewport(CONFIG);
949
+ await page.setViewport({ deviceScaleFactor, height: viewport.height, width: viewport.width });
950
+ return { height: viewport.height, width: viewport.width };
951
+ }
952
+ /**
953
+ * Emulates the capture surface on a page that is about to be captured: the configured quality preset's dimensions, declared at the pixel density the display
954
+ * actually has. Chrome composes an active tab's capture from the window presentation unless the page's device-metrics override declares a density explicitly
955
+ * and that density matches the display's real one, so the density is read from the page itself - a page carrying no declared density reports the display's own -
956
+ * and declared back before capture acquires the page. The declared dimensions are returned so the caller holds its capture constraints to the surface that was
957
+ * emulated rather than reading the preset a second time.
958
+ * @param page - The page to emulate the capture surface on.
959
+ * @returns The dimensions declared on the page.
960
+ */
961
+ export async function emulateCaptureSurface(page) {
962
+ const reportedDensity = await evaluateWithAbort(page, () => window.devicePixelRatio);
963
+ // A reading that is not a positive finite number is no measurement at all, and the compositor still needs an explicit density...1 is the density of an
964
+ // unscaled display, which is the safest thing to declare when the page cannot say.
965
+ const densityIsUsable = Number.isFinite(reportedDensity) && (reportedDensity > 0);
966
+ const deviceScaleFactor = densityIsUsable ? reportedDensity : 1;
967
+ if (!densityIsUsable) {
968
+ LOG.warn("The capture page reported a pixel density of %s. Emulating the capture surface at a density of %d instead.", reportedDensity, deviceScaleFactor);
969
+ }
970
+ const surface = await declareSurface(page, deviceScaleFactor);
971
+ LOG.debug("browser:lifecycle", "Emulated the capture surface at %dx%d with a pixel density of %s.", surface.width, surface.height, deviceScaleFactor);
972
+ return surface;
973
+ }
974
+ /**
975
+ * Emulates the layout surface on a page that is laid out but never captured: the configured quality preset's dimensions at the display's own density. The guide
976
+ * walks were written against that layout and the launch-gate capture probe acquires against it, so declaring it is what keeps both on the surface they were
977
+ * validated on. The density stays the display's, because nothing reads this page's pixels.
978
+ * @param page - The page to emulate the layout surface on.
979
+ * @returns The dimensions declared on the page.
980
+ */
981
+ export async function emulateLayoutSurface(page) {
982
+ return declareSurface(page, NATIVE_DENSITY);
983
+ }
984
+ /**
985
+ * Derives the creation bounds for a window that should open exactly where an existing window rests. The four numbers always travel, so the new window lands on
986
+ * the same frame. The state travels only when it is "maximized", because that is the one state Chrome saves alongside the frame and a maximized window mirrored
987
+ * as a normal one would drop it. Every other state is left off deliberately: a fullscreen window's frame is its whole screen and a normal window at that frame
988
+ * is the right stand-in for it, and a minimized window reports the frame it will return to, which is a placement worth inheriting without the minimization.
989
+ * @param placement - The placement of the window to mirror.
990
+ * @returns The bounds to create the new window with.
991
+ */
992
+ export function mirrorPlacement(placement) {
993
+ const { height, left, top, width } = placement;
994
+ return (placement.windowState === "maximized") ? { height, left, top, width, windowState: "maximized" } : { height, left, top, width };
995
+ }
996
+ /**
997
+ * Opens a page for a channel guide discovery walk in a browser window of its own, and marks it as belonging to that window.
998
+ *
999
+ * A document renders only while Chrome presents it: the active tab of a window the desktop is showing, or a page Chrome counts as captured. The shared window
1000
+ * rests minimized whenever nothing is capturing and no sign-in holds it on screen, with its selected tab belonging to the user. A guide walk needs its page to
1001
+ * render - an observer-driven channel rail fills its tiles from rendering updates, a virtualized grid re-renders as the walk scrolls it, and every wait the walk
1002
+ * makes polls on the page's animation frames - yet it is never captured, so it has no claim on the shared window's presentation and no business moving the
1003
+ * user's selection. A window of its own resolves the tab half: the page is that window's active tab from the moment it exists. The window is created in the
1004
+ * background, so Chrome shows it inactive and moves no focus (measured 2026-08-31), and such a window's document is one Chrome does not present on its own: it
1005
+ * reports itself hidden and unfocused and delivers no animation frame beyond a document load's first ones (measured 2026-09-13). Focus emulation resolves the
1006
+ * presentation half, for the page's whole life - Puppeteer's own emulateFocusedPage carries the mechanism, on the page's own session.
1007
+ *
1008
+ * The window opens at the shared window's own placement, so the window placement Chrome persists for the profile never changes - readWindowPlacement carries
1009
+ * the reasoning. The caller declares the layout surface on the page and owns its registration, and closing the page closes the window with it.
1010
+ * @param browser - The browser to open the window in.
1011
+ * @returns The page, as the active tab of its own window.
1012
+ */
1013
+ export async function createDiscoveryPage(browser) {
1014
+ const carrier = pickCarrierPage(await browser.pages());
1015
+ const placement = carrier ? await readWindowPlacement(carrier) : null;
1016
+ // The mark is set with nothing awaited between the creation and it, so no page read can observe this page as a candidate to carry a shared-window command.
1017
+ const page = await browser.newPage({ background: true, type: "window", windowBounds: placement ? mirrorPlacement(placement) : undefined });
1018
+ ownWindowPages.add(page);
1019
+ /* The page is presented from before its first load, so every wait a walk makes - a visible selector, a condition polled from inside the page - runs as it
1020
+ * would in a window the desktop is showing. Chrome counts a focus-emulated page as captured, which is what presents its document, and Puppeteer keeps the
1021
+ * state on the page's own session for as long as the page lives (measured 2026-09-13). The mark above stays first: nothing is awaited between the creation
1022
+ * and it. A page that refuses the emulation is one no walk can use, and nothing else holds it yet - the caller never receives it and the managed-page sweep
1023
+ * never sees it - so the creator closes it, which closes the window with it, before the failure propagates.
1024
+ */
1025
+ try {
1026
+ await page.emulateFocusedPage(true);
1027
+ }
1028
+ catch (error) {
1029
+ try {
1030
+ await page.close();
1031
+ }
1032
+ catch {
1033
+ // The page is already gone, which is the state the close was asking for.
1034
+ }
1035
+ throw error;
1036
+ }
1037
+ LOG.debug("browser:lifecycle", "Opened the discovery page in a window of its own, %s.", placement ? "mirroring the shared window's placement" : "with no shared window to read a placement from");
1038
+ return page;
1039
+ }
1040
+ /* The follow-up re-issue schedule a tab activation runs, expressed as offsets in milliseconds from the activation's own invocation rather than as gaps between
1041
+ * shots. Chrome switches an activated tab's capture to the window's fitted presentation only after the focus event has fired, so the immediate re-issue that
1042
+ * event triggers lands too early to stick (measured 2026-08-30: a selected capture tab's recording stayed fitted for upwards of forty seconds, until the
1043
+ * monitor's periodic re-affirmation healed it completely with the tab still selected). These offsets bracket the switch so one shot lands just past it, the last
1044
+ * rung allowing for platforms slower than the one measured. Every shot beyond the one that sticks is a no-op by reaffirmCaptureSurface's own contract, and the
1045
+ * monitor's periodic re-affirmation remains the backstop bounding anything the whole ladder misses.
1046
+ */
1047
+ const ACTIVATION_REAFFIRM_LADDER_MS = [250, 750, 1500, 3000];
1048
+ /* The heal enrolled for each capture page, which is the trigger the selection primitive's activation report fires. Keying on the page rather than on a tab id is
1049
+ * what makes the enrollment expire on its own: an entry lives exactly as long as the page it heals. The callback held here is the very instance that page's focus
1050
+ * binding invokes, so the two triggers share one generation counter and an activation arriving through either route supersedes an in-flight ladder rather than
1051
+ * running a second one beside it.
1052
+ */
1053
+ const activationHeals = new WeakMap();
1054
+ /**
1055
+ * Builds the callback the page's focus binding invokes: an immediate re-issue, then a ladder of follow-ups at ACTIVATION_REAFFIRM_LADDER_MS's offsets from this
1056
+ * invocation. The ladder is what makes the heal land, because the compositor's switch outlasts the focus event announcing it - the immediate shot alone cannot
1057
+ * reach the far side of the switch, so later shots have to.
1058
+ *
1059
+ * Invocations supersede rather than accumulate. A generation counter makes the newest invocation the owner of the rung schedule: the duplicate focus events a
1060
+ * single activation fires collapse into one schedule a few milliseconds newer than the first, while an activation that genuinely arrives later - a second
1061
+ * stream's tab give-back landing on this page, another click - gets a full bracket timed from its own moment instead of inheriting the tail of an in-flight one.
1062
+ * The counter lives in this closure, so a callback supersedes only the ladders it started itself and two streams' pages cannot reach each other's schedules.
1063
+ *
1064
+ * Nothing cancels a superseded ladder, because nothing has to: it returns at its next generation check, a shot against a page mid-teardown rejects into the
1065
+ * swallow below, and a shot that never settles leaves its ladder awaiting a promise nobody holds. The counter needs no reset for the same reason - a newer
1066
+ * invocation is itself the invalidation - and the schedule holds no timers, only awaited sleeps.
1067
+ *
1068
+ * A rejection is swallowed into a debug line because a focus event races page teardown by nature - the tab a user just selected can be the one a terminating
1069
+ * stream is closing - and the periodic re-affirmation corrects anything a lost re-issue leaves behind.
1070
+ * @param page - The capture page this callback re-affirms.
1071
+ * @param reaffirm - The re-issue to invoke. A parameter rather than a direct call so the callback's behavior can be driven on its own: what it does with the page
1072
+ * it was built for, what schedule it keeps, and what it does with a rejection, is the whole of its contract.
1073
+ * @param clock - The time source the ladder's waits run on. Defaults to the system clock.
1074
+ * @returns The callback, which never rejects. Its promise settles once the ladder is spent, which the page-side listener does not wait on.
1075
+ */
1076
+ export function makeFocusReaffirmCallback(page, reaffirm, clock = systemClock) {
1077
+ // The generation this closure hands out. It only ever increments: an invocation claims the next one and owns the rung schedule until a later invocation claims
1078
+ // a higher one.
1079
+ let generation = 0;
1080
+ // One rung's re-issue. The offset it was scheduled at is carried on the debug line, so a swallowed failure says which shot spoke; the immediate shot reports zero.
1081
+ const fireShot = async (offsetMs) => {
1082
+ try {
1083
+ await reaffirm(page);
1084
+ }
1085
+ catch (error) {
1086
+ LOG.debug("browser:lifecycle", "Could not re-affirm the capture surface after a tab activation (+%sms): %s.", offsetMs, formatError(error));
1087
+ }
1088
+ };
1089
+ return async () => {
1090
+ const mine = ++generation;
1091
+ await fireShot(0);
1092
+ let previousOffsetMs = 0;
1093
+ for (const offsetMs of ACTIVATION_REAFFIRM_LADDER_MS) {
1094
+ // eslint-disable-next-line no-await-in-loop -- The pacing is the point: each rung waits out the distance left to its own offset before anything else runs.
1095
+ await clock.delay(offsetMs - previousOffsetMs);
1096
+ // A newer activation owns the schedule, and it carries its own rungs timed from its own moment, so this ladder has nothing left to contribute.
1097
+ if (generation !== mine) {
1098
+ return;
1099
+ }
1100
+ // eslint-disable-next-line no-await-in-loop -- Sequential by design: a rung's re-issue settles before the wait toward the next rung begins.
1101
+ await fireShot(offsetMs);
1102
+ previousOffsetMs = offsetMs;
1103
+ }
1104
+ };
1105
+ }
1106
+ // The production collaborators.
1107
+ const defaultActivationHealDeps = { clock: systemClock, reaffirm: reaffirmCaptureSurface };
1108
+ /**
1109
+ * Installs the tab-activation heal on a capture page. Chrome composes the capture of a selected tab from the window's fitted presentation, so the instant a
1110
+ * capture tab becomes the selected one that capture starts recording a clipped view of itself; the re-issue ladder this installs moves the composition back to
1111
+ * the emulated surface about a second later.
1112
+ *
1113
+ * The heal has two triggers and one ladder between them. The exposed binding is fired by a focus listener inside the page, which covers the activation that
1114
+ * arrives with a user's click focusing the browser window. The enrollment in activationHeals is fired by the selection primitive's activation report, which
1115
+ * covers every activation PrismCast performs for itself - the give-back returning a user to a capture tab, the selection step, a re-assert - and a captured page
1116
+ * hears none of those: capture holds it visible and the extension's update moves no operating-system focus, so no page event fires at all (measured 2026-08-30).
1117
+ * Both triggers hold the same callback instance, which is what lets them share its generation counter instead of running two schedules against one page.
1118
+ *
1119
+ * The listener is registered through evaluateOnNewDocument so it survives the page's navigations, exactly as the shared video-selector helper does, and the
1120
+ * binding survives them on Puppeteer's own terms.
1121
+ * @param page - The capture page to install the heal on.
1122
+ * @param deps - The clock and the re-issue the ladder runs on. Defaults to the production pair.
1123
+ */
1124
+ export async function installActivationHeal(page, deps = defaultActivationHealDeps) {
1125
+ const callback = makeFocusReaffirmCallback(page, deps.reaffirm, deps.clock);
1126
+ // Enrolled ahead of the binding, so the report-side trigger is live from the first moment either trigger could be. An exposeFunction that fails takes the page
1127
+ // down with it, and the entry goes when the page does.
1128
+ activationHeals.set(page, callback);
1129
+ await page.exposeFunction("__prismcastReaffirmSurface", callback);
1130
+ await page.evaluateOnNewDocument(() => {
1131
+ window.addEventListener("focus", () => {
1132
+ void window.__prismcastReaffirmSurface?.();
1133
+ });
1134
+ });
1135
+ }
1136
+ /**
1137
+ * Heals the capture page showing in a tab the selection primitive has just activated.
1138
+ *
1139
+ * The report names a tab and what needs healing is a page, so the match walks the registry for a capture-mode stream whose page was identified as that tab. A
1140
+ * report matching nothing is the ordinary case rather than a failure - a give-back returning the selection to the user's own tab, a native stream that composes
1141
+ * no capture at all, a page registered before its setup reached the install point - and every one of them is a quiet no-op.
1142
+ * @param tabId - The tab the selection primitive activated.
1143
+ * @param deps - The registry read and the page-to-tab lookup the match runs through. Defaults to the production pair.
1144
+ */
1145
+ export function healActivatedCaptureTab(tabId, deps = { getAllStreams, getCachedTabId }) {
1146
+ for (const entry of deps.getAllStreams()) {
1147
+ // The mode is read through the registry's own published predicate rather than off the entry, keeping this layer's knowledge of a stream's shape to what the
1148
+ // registry chooses to publish. A stream's page is nullable, because an entry is registered before its setup has produced one, so the narrowing comes ahead
1149
+ // of the lookup that needs a page to ask about.
1150
+ if (!isCaptureIdentity(entry) || (entry.page === null)) {
1151
+ continue;
1152
+ }
1153
+ if (deps.getCachedTabId(entry.page) !== tabId) {
1154
+ continue;
1155
+ }
1156
+ // A page whose install point has not run yet has nothing enrolled to fire, and the monitor's periodic re-affirmation is what covers it in the meantime.
1157
+ void activationHeals.get(entry.page)?.();
1158
+ }
1159
+ }
1160
+ /* The extension-side trigger, subscribed once for the life of the process. Every activation PrismCast performs goes through the selection primitive and is
1161
+ * reported by it, which is the only way an activation of a captured page can be known at all; the page's own focus listener covers the one activation the
1162
+ * primitive does not perform, a user's click that focuses the window. A browser relaunch changes neither module, so nothing here is re-subscribed.
1163
+ */
1164
+ onTabActivation((tabId) => {
1165
+ healActivatedCaptureTab(tabId);
1166
+ });
695
1167
  /**
696
1168
  * Custom launch function that modifies Chrome arguments when running as a packaged executable. The packaged version cannot load extensions from node_modules
697
1169
  * (which is bundled inside the executable), so we point the extension paths to our extracted extension files in the data directory.
@@ -736,28 +1208,8 @@ function formatGpuSuffix(gpu) {
736
1208
  return " (software rendering)";
737
1209
  }
738
1210
  /**
739
- * Decides whether the window bounds the browser granted prove the display cannot show a whole captured frame, returning those bounds when they do and null
740
- * otherwise. Bounds the browser could not report, and bounds read while the window carried a state other than normal, describe a transient condition rather than
741
- * the display...treating either as evidence would let a CDP hiccup or an unfinished window transition pose as a small display, so both yield null. Beyond that,
742
- * either dimension falling below the capture surface is enough: a frame that does not fit the window in one axis is already a frame the screen cannot show whole.
743
- * @param bounds - The window bounds the browser reported, or undefined when the read did not complete.
744
- * @param captureSurface - The dimensions every page renders at, derived from the configured quality preset.
745
- * @returns The granted window dimensions when the display falls short of the capture surface, null otherwise.
746
- */
747
- export function getUndersizedDisplayBounds(bounds, captureSurface) {
748
- if ((bounds?.windowState !== "normal") || (bounds.width === undefined) || (bounds.height === undefined)) {
749
- return null;
750
- }
751
- if ((bounds.width >= captureSurface.width) && (bounds.height >= captureSurface.height)) {
752
- return null;
753
- }
754
- return { height: bounds.height, width: bounds.width };
755
- }
756
- /**
757
- * Probes the browser's GPU hardware-encoding capabilities and reports how the display compares to the configured capture surface. The GPU half queries CDP
758
- * SystemInfo.getInfo for renderer identity and H.264/HEVC/AV1 hardware encoding support, falling back to a MediaRecorder capability probe where the CDP data is
759
- * incomplete, and caches the result for codec selection to read. The display half is advisory: every page renders at the configured preset, so a display too
760
- * small to show a whole frame changes nothing about capture and is worth stating once.
1211
+ * Probes the browser's GPU hardware-encoding capabilities. It queries CDP SystemInfo.getInfo for renderer identity and H.264/HEVC/AV1 hardware encoding support,
1212
+ * falls back to a MediaRecorder capability probe where the CDP data is incomplete, and caches the result for codec selection to read.
761
1213
  *
762
1214
  * The probe runs against an existing page where one is open, and a temporary page otherwise.
763
1215
  * @param browser - The browser instance to use for detection.
@@ -765,17 +1217,16 @@ export function getUndersizedDisplayBounds(bounds, captureSurface) {
765
1217
  async function detectBrowserCapabilities(browser) {
766
1218
  let tempPage = null;
767
1219
  try {
768
- // Try to use an existing page first to avoid window activation issues on macOS.
769
- const existingPages = await browser.pages();
770
- let targetPage = existingPages.find((p) => !p.isClosed()) ?? null;
1220
+ // Try to use an existing page first to avoid window activation issues on macOS. The pick goes through the shared carrier rule, because the restore below
1221
+ // acts on the window of whichever page carries it and a page living in its own window would restore that one instead of the shared one.
1222
+ let targetPage = pickCarrierPage(await browser.pages());
771
1223
  if (!targetPage) {
772
- tempPage = await browser.newPage();
1224
+ // A probe page never needs its tab selected either.
1225
+ tempPage = await browser.newPage({ background: true });
773
1226
  targetPage = tempPage;
774
1227
  }
775
1228
  /* Restore the window to its normal state before probing. Chrome restores window state from the persistent user data directory, so after a scheduled browser
776
- * restart the window can come up minimized, and a minimized window reports its bounds as the shrunken presentation rather than the size the OS granted it -
777
- * which would make the display advisory below fire against a size no display ever imposed. It also keeps the GPU probe's environment representative of the
778
- * one capture runs in.
1229
+ * restart the window can come up minimized, and the GPU probe wants an environment representative of the one capture runs in.
779
1230
  */
780
1231
  await unminimizeWindow(targetPage);
781
1232
  // Detect GPU capabilities via CDP SystemInfo.getInfo. This is the authoritative source for GPU identity and hardware encoding capabilities - it runs at the
@@ -850,19 +1301,6 @@ async function detectBrowserCapabilities(browser) {
850
1301
  catch (gpuError) {
851
1302
  LOG.debug("browser:lifecycle", "GPU detection failed: %s.", String(gpuError));
852
1303
  }
853
- /* Report a display that cannot show a whole frame. The bounds come from the browser rather than from the page, because an emulated page reports the emulated
854
- * size for screen.* and window.* alike, which would make the comparison circular - Browser.getWindowBounds is window state the page's emulation cannot reach.
855
- */
856
- const bounds = await withCDPSession(targetPage, async (session, windowId) => {
857
- const result = await session.send("Browser.getWindowBounds", { windowId });
858
- return result.bounds;
859
- });
860
- const captureSurface = getPresetViewport(CONFIG);
861
- const granted = getUndersizedDisplayBounds(bounds, captureSurface);
862
- if (granted) {
863
- LOG.info("The display is smaller than the configured %s\u00d7%s capture surface (the browser window was granted %s\u00d7%s). Capture is unaffected: every " +
864
- "page renders at the configured preset regardless of display or window size.", captureSurface.width, captureSurface.height, granted.width, granted.height);
865
- }
866
1304
  }
867
1305
  catch (error) {
868
1306
  LOG.warn("Browser capability detection failed: %s. Hardware encoding capabilities are unknown for this session.", formatError(error));
@@ -881,10 +1319,11 @@ async function detectBrowserCapabilities(browser) {
881
1319
  }
882
1320
  /**
883
1321
  * Relinquishes the current browser's capture readiness and tears down everything that depended on it. This is the single source of truth for "the published browser
884
- * is no longer usable" - shared by the disconnect handler (the browser crashed) and by invalidateBrowser (the browser is alive but capture-dead). It drops the
1322
+ * is no longer usable", and the disconnect handler is its one caller: the browser is gone, so nothing that depended on it can be salvaged. It drops the
885
1323
  * supervisor's readiness first (which supersedes any launch in flight, clears the governor's health anchor, and transitions the lifecycle to absent so the next
886
1324
  * request relaunches through the gate and governor), clears the adapter-held metadata and caches, ends login mode, terminates every active stream (they were
887
- * capturing on the now-unusable browser), and emits status. Callers log the specific cause; the alive-but-dead caller additionally closes the Chrome instance.
1325
+ * capturing on the now-unusable browser), and emits status. The caller logs the specific cause. A browser that is still connected but can no longer start captures
1326
+ * takes the far gentler noteBrowserCaptureImpaired path instead, which keeps its running captures.
888
1327
  * @param streamTerminationReason - The reason recorded against each terminated stream, for the stream-end logs.
889
1328
  */
890
1329
  function relinquishBrowserReadiness(streamTerminationReason) {
@@ -895,10 +1334,7 @@ function relinquishBrowserReadiness(streamTerminationReason) {
895
1334
  currentChromeVersion = null;
896
1335
  setChromeUserAgent(null);
897
1336
  // Cancel any pending scheduled-restart quiet timer since the browser this readiness applied to is gone.
898
- if (restartQuietTimer) {
899
- clearTimeout(restartQuietTimer);
900
- restartQuietTimer = null;
901
- }
1337
+ cancelRestartQuietTimer();
902
1338
  // Clear all channel selection caches. Cached state (guide row positions, discovered page URLs) belongs to the old browser session.
903
1339
  clearChannelSelectionCaches();
904
1340
  // End login mode if it was active. We use clearLoginState() rather than endLoginMode() because the browser may already be gone and we do not want to attempt any
@@ -906,10 +1342,21 @@ function relinquishBrowserReadiness(streamTerminationReason) {
906
1342
  if (clearLoginState() && !gracefulShutdownInProgress) {
907
1343
  LOG.info("Login mode ended due to browser readiness loss.");
908
1344
  }
909
- // Terminate every active stream using the authoritative terminateStream for consistent cleanup. Kept even during graceful shutdown as a defensive measure -
910
- // terminateStream() is safe to call more than once, so if streams were already terminated by the caller, this harmlessly iterates an empty array.
911
- for (const streamInfo of getAllStreams()) {
912
- terminateStream(streamInfo.id, streamInfo.info.storeKey, streamTerminationReason);
1345
+ /* Terminate every active stream through the streaming layer's authoritative terminator, so a readiness loss cleans up exactly the way every other termination
1346
+ * path does. Kept even during graceful shutdown as a defensive measure - termination is safe to call more than once, so streams the caller already tore down
1347
+ * leave this a harmless pass over an empty array.
1348
+ */
1349
+ const activeStreams = getAllStreams();
1350
+ if (streamTerminator) {
1351
+ for (const streamInfo of activeStreams) {
1352
+ streamTerminator(streamInfo.id, streamInfo.info.storeKey, streamTerminationReason);
1353
+ }
1354
+ }
1355
+ else {
1356
+ /* An unwired terminator means the streaming layer never loaded, so nothing here can run the streams' cleanup sequence. Report it and keep going: the page
1357
+ * tracking and status emission below are still worth doing, and abandoning them would compound a wiring failure with a half-finished teardown.
1358
+ */
1359
+ LOG.error("Stream cleanup was skipped on browser readiness loss because no stream terminator is wired. Active streams left untouched: %s.", activeStreams.length);
913
1360
  }
914
1361
  // The session those streams captured on is over, so whatever page tracking survived their termination belongs to a browser that is gone.
915
1362
  clearPageTracking();
@@ -933,43 +1380,45 @@ function handleBrowserDisconnect() {
933
1380
  relinquishBrowserReadiness("browser disconnect");
934
1381
  }
935
1382
  /**
936
- * Invalidates a specific browser that is still connected but can no longer capture - a mid-life capture death that no "disconnected" event would surface. This is the
937
- * single recovery action generalized to the alive-but-incapable case: relinquish readiness and terminate the browser's streams (exactly as a disconnect does),
938
- * then additionally close the still-running Chrome so a leaked process is not left behind. The lifecycle is already absent after relinquish, so the next request
939
- * relaunches a fresh, gate-verified browser through the supervisor. Exported for the streaming layer's passive mid-life detector to call once its probe confirms the
940
- * browser cannot capture. noteReadinessLost stays internal: callers signal intent through this function, never the supervisor directly.
1383
+ * Records that a still-connected browser can no longer start captures - a mid-life capture death that no "disconnected" event would surface - and runs the
1384
+ * relaunch that cures it. This is the single recovery action for a browser that is alive and still serving: the mark lives on the supervisor's ready state, so its
1385
+ * running captures continue untouched, new stream requests are refused at acquire() with a 503 back-off, the recovery ladder stops offering tab replacement, and
1386
+ * the relaunch waits until nothing depends on the browser. Exported for the streaming layer to call once its probe or its wedge has produced the verdict.
1387
+ *
1388
+ * The returned promise settles once the relaunch this mark triggered has settled, which is what a caller that means to use the fresh browser awaits: the teardown
1389
+ * holds the supervisor in its draining state, where acquire() rejects rather than joins, so acquiring any earlier draws that rejection instead of the new
1390
+ * instance. A caller with nothing waiting on the outcome voids it and the relaunch proceeds on its own.
941
1391
  *
942
- * The caller passes the exact instance it verified, and we invalidate only if it is still the published browser. The detector's probe runs in the background for
943
- * seconds, during which the verified browser could have disconnected and been replaced by a fresh relaunch; without this identity guard we would tear down that
944
- * new, healthy browser on the strength of a probe against the old, dead one.
945
- * @param browser - The specific browser instance the caller verified as unable to capture.
946
- * @param reason - A short description of why the browser is being invalidated, for the alarm log.
947
- */
948
- export async function invalidateBrowser(browser, reason) {
949
- // Only invalidate if this is still the published browser. If it was already superseded (a disconnect plus relaunch raced the caller's probe), there is nothing to
950
- // do - the readiness loss was handled, and tearing down the current browser would wrongly disrupt a healthy, freshly-relaunched one.
951
- if (supervisor.current() !== browser) {
1392
+ * The restart trigger uses this function's return value rather than the supervisor's transition observer, deliberately. The observer runs inside transition(), so a
1393
+ * restart begun there would call noteReadinessLost re-entrantly while the marking transition's notification is still on the stack. The observer stays a reporter -
1394
+ * the alarm and the status emit - and the caller that holds the verdict acts on it once the transition has completed.
1395
+ * @param browser - The specific browser instance the caller verified as unable to start captures.
1396
+ * @param reason - A short description of the evidence behind the verdict, carried in the alarm log and the impairment record.
1397
+ * @returns A promise settling once the relaunch this mark triggered has settled, or at once when the verdict landed on nothing.
1398
+ */
1399
+ export async function noteBrowserCaptureImpaired(browser, reason) {
1400
+ // A false answer means the verdict landed on nothing: the instance was superseded by a disconnect and relaunch while the caller was confirming it, or the browser
1401
+ // already carries a mark whose alarm and status emit have already fired. Either way there is no new state to act on.
1402
+ if (!supervisor.noteCaptureImpaired(browser, reason)) {
952
1403
  return;
953
1404
  }
954
- LOG.error("The browser is connected but can no longer capture (%s). Invalidating it for a governed relaunch.", reason);
955
- relinquishBrowserReadiness("capture system failure");
956
- // Readiness was relinquished first, because that is what supersedes an in-flight launch; publishing the teardown synchronously, before any await, then keeps the
957
- // launch window shut for the whole drain so nothing spawns a second Chrome against the profile lock this one still holds.
958
- const teardown = closeBrowserInstance(browser);
959
- supervisor.noteTeardownBegun(teardown, BROWSER_TEARDOWN_DRAIN_BOUND_MS);
960
- await teardown;
1405
+ await restartBrowserIfImpairedAndIdle();
961
1406
  }
962
1407
  /**
963
1408
  * Provides access to the capture-ready browser, launching one if needed. This is the single gated entry point for all browser access: it delegates to the
964
1409
  * supervisor's acquire(), which returns the ready browser, joins an in-flight launch (single-flight, so concurrent callers never contend on Chrome's profile lock),
965
1410
  * lazily launches when absent, or - while the relaunch governor is cooling after repeated failures - rejects fast with a BrowserUnavailableError WITHOUT spawning
966
1411
  * Chrome (the loop bound). The launch it drives runs the readiness gate, so a returned browser is verified capture-ready, not merely connected.
1412
+ *
1413
+ * The purpose is what the caller intends to do with the browser, and it is the gate on a marked one: a "capture" caller is refused, because that is precisely the
1414
+ * operation the browser can no longer perform, while a "page" caller - precaching, the startup warm-up, the relaunch itself - is served as usual.
1415
+ * @param purpose - Whether the caller goes on to start a capture or only needs a page to open.
967
1416
  * @returns The capture-ready browser instance.
968
- * @throws BrowserUnavailableError while the governor is cooling, BrowserSupersededError if an in-flight launch was abandoned by a readiness-loss, or the underlying
969
- * launch error when a launch attempt fails.
1417
+ * @throws BrowserUnavailableError while the governor is cooling, BrowserCaptureImpairedError when a capture purpose meets a browser that can no longer start
1418
+ * captures, BrowserSupersededError if an in-flight launch was abandoned by a readiness-loss, or the underlying launch error when a launch attempt fails.
970
1419
  */
971
- export async function getCurrentBrowser() {
972
- return supervisor.acquire();
1420
+ export async function getCurrentBrowser(purpose) {
1421
+ return supervisor.acquire(purpose);
973
1422
  }
974
1423
  /**
975
1424
  * The supervisor's `launch` port: spawns Chrome, runs the readiness gate, performs post-launch initialization (display detection, version/UA capture, precaching),
@@ -993,21 +1442,31 @@ async function launchReadyBrowser() {
993
1442
  // Readiness gate, handshake tier (cheap, on-suspicion). Poll for the puppeteer-stream extension to finish initializing - it injects a START_RECORDING function
994
1443
  // into its options page context, so its presence is the extension's own readiness signal. We poll rather than fixed-delay so the browser is ready as soon as the
995
1444
  // extension loads (typically 200-500ms). On failure this THROWS rather than warning-and-proceeding: an unregistered extension means chrome.tabs is undefined and
996
- // every getStream() would hang, so the instance is not capture-ready and must not be published. We reclassify the raw waitForFunction timeout into a
1445
+ // every capture acquisition would hang, so the instance is not capture-ready and must not be published. We reclassify the raw waitForFunction timeout into a
997
1446
  // capture-infrastructure error carrying "timed out" so the setup layer maps it to a 503 back-off (the same as the capability-tier probe failure), rather than a
998
1447
  // 500 the client would not back off from - an unregistered extension is a capture-infrastructure fault, and a fresh relaunch usually clears it.
999
1448
  try {
1000
1449
  const extensionPage = await getExtensionPage(browser);
1001
- await extensionPage.waitForFunction("typeof START_RECORDING === 'function'", { timeout: CONFIG.browser.initTimeout });
1450
+ await extensionPage.waitForFunction(EXTENSION_READY_EXPRESSION, { timeout: CONFIG.browser.initTimeout });
1002
1451
  }
1003
1452
  catch (handshakeError) {
1004
1453
  throw new Error("The capture extension handshake timed out after " + String(CONFIG.browser.initTimeout) + " ms.", { cause: handshakeError });
1005
1454
  }
1006
1455
  LOG.debug("timing:browser", "Extension initialized. (+%sms)", browserElapsed());
1007
- // Readiness gate, capability tier (the authoritative arbiter). Run the injected capture probe - a real getStream against a throwaway page on THIS instance - so
1008
- // "ready" means "really captured," not merely "the extension handshake responded." This predicate must run at every (re)launch:
1009
- // it exercises the exact getStream path that hangs when the extension is unregistered. A probe failure throws, so the supervisor counts the launch failure and the
1010
- // browser is never published; the unrecoverable stale-mutex case exits the process from inside the probe, since a Chrome restart cannot fix a leaked module mutex.
1456
+ /* The tab the window rests on. It is created once and selected by construction because nothing asks otherwise, so whatever the window shows when nothing has
1457
+ * asked for a tab is a blank page rather than the capture extension's own options page - which the library opens selected at launch. It is left unmanaged, as
1458
+ * stale page cleanup judges only the pages stream setup and discovery create; it carries no correctness duty, so it needs no re-validation, and a user
1459
+ * closing it costs nothing.
1460
+ *
1461
+ * It is also the page the shared window's identity is read from. This tab is in that window by construction, and reading it here - before the capture probe,
1462
+ * the capability detection, or any stream - is what lets every later capture tab be anchored to the window rather than placed by Chrome.
1463
+ */
1464
+ const restingTab = await browser.newPage();
1465
+ await noteSharedWindow(browser, restingTab);
1466
+ // Readiness gate, capability tier (the authoritative arbiter). Run the injected capture probe - a real capture acquisition against a throwaway page on THIS
1467
+ // instance - so "ready" means "really captured," not merely "the extension handshake responded." This predicate must run at every (re)launch:
1468
+ // it exercises the exact acquisition path that fails when the extension is unregistered. A probe failure throws, so the supervisor counts the launch failure
1469
+ // and the browser is never published.
1011
1470
  //
1012
1471
  // If the probe is not wired (the injection point left unset by a refactor - impossible in the normal import order, which always wires it before any launch),
1013
1472
  // we reject the launch rather than publish a handshake-only browser: serving an unverified browser would be the "proceed and hope" path this design
@@ -1031,9 +1490,15 @@ async function launchReadyBrowser() {
1031
1490
  const gpuSuffix = gpu ? formatGpuSuffix(gpu) : "";
1032
1491
  LOG.info("Chrome ready: %s%s.", chromeVersion, gpuSuffix);
1033
1492
  LOG.debug("timing:browser", "Browser ready. Total: %sms.", browserElapsed());
1034
- // Start background precaching of selected service channel lineups. Fire-and-forget - the setTimeout inside startPrecaching() defers the work until after this
1035
- // launch settles and the supervisor has published the ready browser, so its getCurrentBrowser() resolves immediately rather than re-entering this launch.
1493
+ // Start background precaching of selected service channel lineups. Fire-and-forget - the delay startPrecaching() arms on its own clock defers the work until
1494
+ // after this launch settles and the supervisor has published the ready browser, so its getCurrentBrowser() resolves immediately rather than re-entering this
1495
+ // launch.
1036
1496
  startPrecaching();
1497
+ /* The reaper belongs to the browser instance the supervisor is about to publish: puppeteer drops a closed browser's listeners along with the instance, and
1498
+ * a relaunch comes back through this same path and installs its own. It goes in ahead of the disconnect handler so that handler stays the last step before
1499
+ * publication, for the reason its own comment gives.
1500
+ */
1501
+ installStrayOpenTabReaper(browser);
1037
1502
  // Arm the disconnect handler only now, as the very last step before the supervisor publishes this browser as ready. It is deliberately NOT armed earlier: during
1038
1503
  // the gate/init window above, a Chrome crash surfaces as a thrown init step (CDP and waitForFunction reject on a dead browser), which the supervisor counts as a
1039
1504
  // launch failure and feeds to the governor - keeping the relaunch loop bounded even if Chrome dies repeatedly during init. Arming the handler earlier would let
@@ -1069,6 +1534,15 @@ export function getChromeVersion() {
1069
1534
  export function getBrowserInstance() {
1070
1535
  return supervisor.current();
1071
1536
  }
1537
+ /**
1538
+ * Returns the impairment recorded on the published browser, or null when nothing is published or the published browser can still start captures. Like
1539
+ * getBrowserInstance this does not launch, so callers that only need to know whether captures can be started - the recovery ladder, the status composition, the
1540
+ * health endpoint - read it without touching the lifecycle.
1541
+ * @returns The impairment record, or null.
1542
+ */
1543
+ export function getCaptureImpairment() {
1544
+ return supervisor.captureImpairment();
1545
+ }
1072
1546
  /**
1073
1547
  * Checks if the browser is currently connected and usable. This is a synchronous check that can be used before attempting browser operations.
1074
1548
  * @returns True if the browser is connected and ready for use, false otherwise.
@@ -1077,54 +1551,6 @@ export function isBrowserConnected() {
1077
1551
  const browser = supervisor.current();
1078
1552
  return !!browser && browser.connected;
1079
1553
  }
1080
- /**
1081
- * Minimizes the browser window. The window's presentation state is all this touches - the surface pages render at is emulated from the configured preset and is
1082
- * unaffected by whether the window is on screen.
1083
- *
1084
- * To avoid issues with creating temporary pages (which can cause the window to restore on macOS), we prefer using an existing page if one is available. Only if
1085
- * no pages exist do we create a temporary page.
1086
- */
1087
- export async function minimizeBrowserWindow() {
1088
- // Guard against calling this when no browser is running.
1089
- const browser = supervisor.current();
1090
- if (!browser?.connected) {
1091
- return;
1092
- }
1093
- let tempPage = null;
1094
- try {
1095
- // Try to use an existing page first. Creating a new page can cause the window to restore/activate on macOS, which defeats the purpose of minimizing.
1096
- const existingPages = await browser.pages();
1097
- let targetPage = existingPages.find((p) => !p.isClosed()) ?? null;
1098
- // If no existing pages, we must create a temporary one. This is less ideal but necessary to get a CDP session target.
1099
- if (!targetPage) {
1100
- tempPage = await browser.newPage();
1101
- // Register the temp page so stale cleanup knows it's ours.
1102
- registerManagedPage(tempPage);
1103
- targetPage = tempPage;
1104
- }
1105
- // Delegate to the CDP helper for the window-state change, so every minimize in the codebase issues the same command sequence.
1106
- await minimizeWindow(targetPage);
1107
- // Clean up the temporary page if we created one.
1108
- if (tempPage) {
1109
- unregisterManagedPage(tempPage);
1110
- await tempPage.close();
1111
- }
1112
- }
1113
- catch (error) {
1114
- // If we created a temp page, make sure to unregister it even on error.
1115
- if (tempPage) {
1116
- unregisterManagedPage(tempPage);
1117
- try {
1118
- await tempPage.close();
1119
- }
1120
- catch (_closeError) {
1121
- // Ignore close errors during error handling.
1122
- }
1123
- }
1124
- // Minimizing is not critical - log a warning but don't fail the operation.
1125
- LOG.debug("browser:lifecycle", "Could not minimize the browser window: %s.", formatError(error));
1126
- }
1127
- }
1128
1554
  /**
1129
1555
  * Gets all open browser pages (tabs). This is used by the health check endpoint to report page count.
1130
1556
  * @returns Array of pages, or empty array if the browser is not connected.
@@ -1241,8 +1667,8 @@ export async function closeBrowser() {
1241
1667
  *
1242
1668
  * 4. Minimum page preservation: We always keep at least one page open to prevent Chrome from exiting.
1243
1669
  *
1244
- * 5. In-flight setup exemption: Pages whose stream setup is still running (tracked in inFlightSetupPageIds) are never considered stale. The registry records a
1245
- * stream's page only once setup completes, so without this a slow tune would have its own page closed out from under it.
1670
+ * 5. In-flight exemption: Pages an operation still holds (tracked in inFlightPageIds) are never considered stale. The registry records a stream's page only once
1671
+ * setup completes, and a discovery page reaches the registry at no point at all, so without this a slow tune or a running walk would lose its own page.
1246
1672
  *
1247
1673
  * The safeguards are expressed as rules in browser/pageStaleness.ts, which decides from a snapshot what to close, track, forget, and unmark. This function is the
1248
1674
  * I/O shell around that decision: it reads Chrome's page list, applies the decision to the tracking collections, and performs the closes.
@@ -1253,11 +1679,13 @@ export async function closeBrowser() {
1253
1679
  *
1254
1680
  * The cleanup uses a multi-stage filtering process:
1255
1681
  * 1. Only consider pages we created (in managedPageIds)
1256
- * 2. Exclude pages associated with active streams, and pages whose stream setup is still in flight
1682
+ * 2. Exclude pages associated with active streams, and pages an operation still holds in flight
1257
1683
  * 3. Apply a grace period before closing (to handle race conditions)
1258
1684
  * 4. Preserve at least one page to keep the browser alive
1685
+ * @param now - The instant the sweep judges staleness against, read from the interval's own clock so the staleness clocks this sweep starts and the ones it
1686
+ * later reads are stamped on one time source.
1259
1687
  */
1260
- export async function cleanupStalePages() {
1688
+ export async function cleanupStalePages(now) {
1261
1689
  // Guard against calling this when no ready browser is running.
1262
1690
  const browser = supervisor.current();
1263
1691
  if (!browser?.connected) {
@@ -1279,7 +1707,6 @@ export async function cleanupStalePages() {
1279
1707
  }
1280
1708
  }
1281
1709
  }
1282
- const now = Date.now();
1283
1710
  // Project the browser's pages into the shape the decision core reads: the managed ids in the browser's own order, with undefined standing in for pages we
1284
1711
  // did not create, plus a lookup back to the Page objects so the ids it returns can be resolved to something closable.
1285
1712
  const idToPage = new Map();
@@ -1292,7 +1719,7 @@ export async function cleanupStalePages() {
1292
1719
  }
1293
1720
  }
1294
1721
  // The staleness judgment - clocks, exemptions, the dead-entry sweep, and the preserve-one budget - belongs to the pure core; this function only carries it out.
1295
- const actions = evaluateStalePages({ activePageIds, gracePeriodMs: CONFIG.recovery.stalePageGracePeriod, inFlightSetupPageIds, now, pageIds,
1722
+ const actions = evaluateStalePages({ activePageIds, gracePeriodMs: CONFIG.recovery.stalePageGracePeriod, inFlightPageIds, now, pageIds,
1296
1723
  staleFirstSeen: potentiallyStalePages });
1297
1724
  // Bring the tracking collections in line with the decision before any close runs, so a close that fails cannot leave the bookkeeping half-applied.
1298
1725
  for (const pageId of actions.forgetTrackedIds) {
@@ -1302,7 +1729,7 @@ export async function cleanupStalePages() {
1302
1729
  potentiallyStalePages.set(pageId, now);
1303
1730
  }
1304
1731
  for (const pageId of actions.clearInFlightIds) {
1305
- inFlightSetupPageIds.delete(pageId);
1732
+ inFlightPageIds.delete(pageId);
1306
1733
  }
1307
1734
  let closedCount = 0;
1308
1735
  for (const pageId of actions.closeIds) {
@@ -1334,90 +1761,165 @@ export async function cleanupStalePages() {
1334
1761
  }
1335
1762
  }
1336
1763
  /**
1337
- * Starts the periodic stale page cleanup interval. This should be called once during server startup, after the browser is initialized. The interval runs
1338
- * indefinitely until stopStalePageCleanup() is called (typically during graceful shutdown).
1764
+ * Starts the periodic stale page cleanup. This should be called once during server startup, after the browser is initialized. The sweep runs indefinitely until
1765
+ * stopStalePageCleanup() is called (typically during graceful shutdown). A second start while one is running changes nothing.
1766
+ * @param clock - The clock the sweep's interval arms on and whose reading each sweep judges staleness against, so the cadence and the judgment share one time
1767
+ * source. Defaults to the system clock.
1339
1768
  */
1340
- export function startStalePageCleanup() {
1341
- stalePageCleanupInterval = setInterval(() => { void cleanupStalePages(); }, CONFIG.recovery.stalePageCleanupInterval);
1769
+ export function startStalePageCleanup(clock = systemClock) {
1770
+ if (stalePageTimers) {
1771
+ return;
1772
+ }
1773
+ const timers = new TimerRegistry({ clock });
1774
+ timers.setInterval(STALE_PAGE_SWEEP_KEY, () => { void cleanupStalePages(clock.now()); }, CONFIG.recovery.stalePageCleanupInterval);
1775
+ stalePageTimers = timers;
1342
1776
  }
1343
1777
  /**
1344
- * Stops the stale page cleanup interval. This should be called during graceful shutdown to prevent the cleanup from running after we've started shutting down
1345
- * the browser and streams.
1778
+ * Stops the periodic stale page cleanup. This should be called during graceful shutdown to prevent the sweep from running after we've started shutting down the
1779
+ * browser and streams. Disposing the registry drains the interval, and a stop with nothing running is a no-op.
1346
1780
  */
1347
1781
  export function stopStalePageCleanup() {
1348
- if (stalePageCleanupInterval) {
1349
- clearInterval(stalePageCleanupInterval);
1350
- stalePageCleanupInterval = null;
1782
+ stalePageTimers?.dispose();
1783
+ stalePageTimers = null;
1784
+ }
1785
+ /**
1786
+ * Decides whether the browser may be torn down for the given cause, from facts read at the call. Each cause asks a different question of the same registry, so
1787
+ * the decision is stated once here rather than spelled out at each guard.
1788
+ *
1789
+ * Maintenance is opportunistic housekeeping, so it waits for an empty registry outright: a pending entry is a tune in progress, and replacing the browser under
1790
+ * one would fail it for nothing better than a fresher instance. Impairment is a repair the tunes themselves are waiting on, so it asks the narrower question of
1791
+ * what a teardown would actually destroy - a stream established on this browser, whose entry holds its page, or a page an operation still holds in flight. A tune
1792
+ * refused a capture start holds neither while it waits for the relaunch, so the very pending entry that maintenance would defer to is not a reason to leave an
1793
+ * unusable browser in place. A tune that has acquired the browser but not yet opened its page falls outside every dependency source for the same reason and
1794
+ * deliberately so: it holds nothing a relaunch would destroy, neither a page nor a capture, and its own capture start would meet the impaired browser in any
1795
+ * case, so the relaunch may run under it.
1796
+ * @param cause - Why the restart wants to run.
1797
+ * @param facts - The registry facts read at the call.
1798
+ * @returns True when the browser may be torn down for this cause.
1799
+ */
1800
+ export function isBrowserIdleForRestart(cause, facts) {
1801
+ switch (cause) {
1802
+ case "impairment": {
1803
+ return !facts.establishedStreams && (facts.inFlightPages === 0);
1804
+ }
1805
+ case "maintenance": {
1806
+ return facts.streamCount === 0;
1807
+ }
1351
1808
  }
1352
1809
  }
1353
- /* Opportunistic browser restart functions. The check runs on a 30-second interval and, when the browser exceeds BROWSER_MAX_AGE with zero active streams, starts
1354
- * a quiet period timer. The quiet timer is cancelled if a stream starts, ensuring active viewers are never disrupted. When the timer expires, the browser is
1355
- * closed and immediately re-launched.
1810
+ /**
1811
+ * Reads the registry facts the idleness decision is made from, at the moment of the call.
1812
+ * @returns The facts.
1813
+ */
1814
+ function readRestartFacts() {
1815
+ return { establishedStreams: hasEstablishedStreams(), inFlightPages: inFlightPageIds.size, streamCount: getStreamCount() };
1816
+ }
1817
+ /**
1818
+ * Relaunches a browser that can no longer start captures, as soon as nothing depends on it. Every trigger routes here - the mark itself, each stream termination,
1819
+ * and the periodic restart check as the backstop for a moment when the other two could not act (login mode above all) - so the decision lives in one place rather
1820
+ * than being re-derived by each. Idleness rather than age is the condition, because the mark makes the browser useless for new tunes immediately while its running
1821
+ * captures are still worth finishing, so the earliest safe moment is exactly the moment the last thing depending on this browser lets go of it.
1822
+ *
1823
+ * The returned promise settles once the relaunch has settled, for the caller that goes on to acquire the fresh browser; a trigger with nothing waiting on the
1824
+ * outcome voids it. An early return resolves at once, because there is nothing for such a caller to wait for.
1825
+ * @returns A promise settling once the relaunch has settled, or at once when no relaunch runs.
1356
1826
  */
1827
+ export async function restartBrowserIfImpairedAndIdle() {
1828
+ if (supervisor.captureImpairment() === null) {
1829
+ return;
1830
+ }
1831
+ // A marked browser's restart belongs to this path, so a maintenance quiet period pending from before the mark is retired the first time any trigger observes the
1832
+ // mark - whether or not the browser is idle yet - rather than being left to fire against the browser the relaunch will have replaced.
1833
+ cancelRestartQuietTimer();
1834
+ if (!isBrowserIdleForRestart("impairment", readRestartFacts())) {
1835
+ return;
1836
+ }
1837
+ await executeBrowserRestart("impairment");
1838
+ }
1357
1839
  /**
1358
- * Checks whether the browser qualifies for an opportunistic restart. Called periodically by the restart check interval. The check skips when any of these
1359
- * conditions hold: graceful shutdown in progress, login mode active, browser not ready, browser age below threshold. On every eligible tick it also drives the
1360
- * supervisor's health-gated governor reset. If active streams exist, any pending quiet timer is cancelled (streams started during the quiet period reset the
1361
- * countdown). Otherwise a quiet timer is started if one is not already running.
1840
+ * Checks whether the browser qualifies for a restart. Called periodically by the restart check interval. The check skips when any of these conditions hold:
1841
+ * graceful shutdown in progress, login mode active, browser not ready. A marked browser is handed to the impairment path and the tick ends there. Otherwise the
1842
+ * tick drives the supervisor's health-gated governor reset and applies the maintenance rules: skip below the age threshold, cancel any pending quiet timer while
1843
+ * active streams exist (streams started during the quiet period reset the countdown), and otherwise start a quiet timer if one is not already running.
1844
+ * @param timers - The restart owner's registry, whose quiet period this check arms and reads. Handed in by the interval closure, so the check never reads the
1845
+ * nullable module binding and needs no null branch of its own.
1362
1846
  */
1363
- function checkBrowserRestart() {
1847
+ function checkBrowserRestart(timers) {
1364
1848
  // Skip if the server is shutting down or login mode is active.
1365
1849
  if (gracefulShutdownInProgress || isLoginModeActive()) {
1366
1850
  return;
1367
1851
  }
1368
1852
  // Read the ready browser and its launch time from the supervisor. Both are non-null only in the ready state, so a single guard covers "no ready browser." The
1369
- // launch time is on the supervisor's clock (realClock.now), so age must be measured against the same clock - not Date.now() - or the units would not match.
1853
+ // launch time is read from the supervisor's clock (systemClock.now), so age is measured against that same clock rather than against any other time source.
1370
1854
  const browser = supervisor.current();
1371
1855
  const launchTime = supervisor.currentLaunchTime();
1372
1856
  if (!browser?.connected || (launchTime === null)) {
1373
1857
  return;
1374
1858
  }
1859
+ /* A marked browser waits for idleness rather than for age, so the maintenance rules below have nothing to say about it. It is not evidence of sustained health
1860
+ * either - the governor's reset waits for the fresh browser - so this tick neither feeds the health hold nor logs the recovery notice for it. The stream-end
1861
+ * trigger normally restarts it well before this tick runs; the tick is the backstop for a trigger that could not act, a login session active at the time above
1862
+ * all. The quiet period this tick would otherwise cancel below is retired inside the call, so the early return leaves no timer unobserved.
1863
+ */
1864
+ if (supervisor.captureImpairment() !== null) {
1865
+ void restartBrowserIfImpairedAndIdle();
1866
+ return;
1867
+ }
1375
1868
  // Health-gated governor reset. On every eligible tick, tell the supervisor the browser is still ready; once it has been continuously ready for the policy's
1376
1869
  // hold, this resets the relaunch governor to its normal state and returns true, so we log the recovery exactly once.
1377
1870
  if (supervisor.noteSustainedHealth()) {
1378
1871
  LOG.info("Browser capture readiness has been sustained; the relaunch governor has reset to its normal state.");
1379
1872
  }
1380
1873
  // Skip if the browser has not exceeded the maximum age.
1381
- const age = realClock.now() - launchTime;
1874
+ const age = systemClock.now() - launchTime;
1382
1875
  if (age < BROWSER_MAX_AGE) {
1383
1876
  return;
1384
1877
  }
1385
1878
  // If there are active streams, cancel any pending quiet timer and return. Streams that start during the quiet period reset the countdown.
1386
1879
  if (getStreamCount() > 0) {
1387
- if (restartQuietTimer) {
1880
+ if (timers.has(RESTART_QUIET_KEY)) {
1388
1881
  LOG.debug("browser:lifecycle", "Browser restart quiet period cancelled - streams are active.");
1389
- clearTimeout(restartQuietTimer);
1390
- restartQuietTimer = null;
1391
1882
  }
1883
+ cancelRestartQuietTimer();
1392
1884
  return;
1393
1885
  }
1394
1886
  // No active streams and the browser is old enough. Start the quiet timer if one is not already running.
1395
- if (!restartQuietTimer) {
1887
+ if (!timers.has(RESTART_QUIET_KEY)) {
1396
1888
  LOG.debug("browser:lifecycle", "Browser uptime exceeds threshold. Quiet period started - restart will proceed if no streams start within %s minutes.", Math.round(BROWSER_RESTART_QUIET_PERIOD / 60000));
1397
- restartQuietTimer = setTimeout(() => {
1398
- void executeBrowserRestart();
1889
+ timers.setTimeout(RESTART_QUIET_KEY, () => {
1890
+ void executeBrowserRestart("maintenance");
1399
1891
  }, BROWSER_RESTART_QUIET_PERIOD);
1400
1892
  }
1401
1893
  }
1402
1894
  /**
1403
- * Executes the opportunistic browser restart after the quiet period has elapsed. Performs a final guard check before proceeding, then closes the browser and
1404
- * immediately re-launches a fresh instance.
1895
+ * Executes a browser restart: a final guard check, then the current instance is retired and torn down and a fresh one is launched in its place. Every cause shares
1896
+ * that whole sequence; only the log line that announces the restart differs, since only the cause knows why the browser is being replaced.
1897
+ * @param cause - Why the restart is running, for the announcement.
1405
1898
  */
1406
- async function executeBrowserRestart() {
1407
- // Clear the timer handle.
1408
- restartQuietTimer = null;
1899
+ async function executeBrowserRestart(cause) {
1900
+ // Retire any pending quiet period. For a maintenance restart this is the timer that just fired, and clearing a fired handle costs nothing. For an impairment
1901
+ // restart it may be a countdown started before the mark, which has to go: the browser it was measured against is about to be replaced, and letting it fire would
1902
+ // restart the fresh instance a second time.
1903
+ cancelRestartQuietTimer();
1409
1904
  // Final guard: re-check all preconditions. Conditions may have changed during the quiet period (e.g., a stream started just before the timer fired, login mode
1410
- // was activated, or the browser disconnected on its own). Reading current()/currentLaunchTime() together keeps the ready-state check and the age source consistent.
1905
+ // was activated, or the browser disconnected on its own). Reading current()/currentLaunchTime() together keeps the ready-state check and the age source
1906
+ // consistent. The idleness question is asked for this restart's own cause, because what a maintenance sweep must defer to and what a repair must defer to are
1907
+ // not the same set of streams.
1411
1908
  const browser = supervisor.current();
1412
1909
  const launchTime = supervisor.currentLaunchTime();
1413
- if (gracefulShutdownInProgress || isLoginModeActive() || (getStreamCount() > 0) || !browser?.connected || (launchTime === null)) {
1910
+ if (gracefulShutdownInProgress || isLoginModeActive() || !isBrowserIdleForRestart(cause, readRestartFacts()) || !browser?.connected || (launchTime === null)) {
1414
1911
  LOG.debug("browser:lifecycle", "Browser restart aborted - preconditions no longer met.");
1415
1912
  return;
1416
1913
  }
1417
- const age = realClock.now() - launchTime;
1914
+ const age = systemClock.now() - launchTime;
1418
1915
  const hours = Math.floor(age / 3600000);
1419
1916
  const minutes = Math.floor((age % 3600000) / 60000);
1420
- LOG.info("Restarting browser for scheduled maintenance (uptime: %sh %sm).", hours, minutes);
1917
+ if (cause === "impairment") {
1918
+ LOG.info("Restarting the browser because it can no longer start captures (uptime: %sh %sm).", hours, minutes);
1919
+ }
1920
+ else {
1921
+ LOG.info("Restarting browser for scheduled maintenance (uptime: %sh %sm).", hours, minutes);
1922
+ }
1421
1923
  try {
1422
1924
  // Retire the current instance from the lifecycle, tear it down, then acquire a fresh one through the supervisor. We do NOT touch the graceful-shutdown flag (the
1423
1925
  // server is not shutting down): closeBrowserInstance removes the disconnect listener, which is what makes this intentional teardown quiet. acquire() publishes
@@ -1430,6 +1932,12 @@ async function executeBrowserRestart() {
1430
1932
  const teardown = closeBrowserInstance(browser);
1431
1933
  supervisor.noteTeardownBegun(teardown, BROWSER_TEARDOWN_DRAIN_BOUND_MS);
1432
1934
  await teardown;
1935
+ /* The relaunch ends the browser session the channel-selection caches describe (guide row positions, discovered page URLs, watch URLs), exactly as the
1936
+ * disconnect path ends it, so they are cleared here for the same reason. The clear waits for the process to be gone rather than running beside the teardown,
1937
+ * because a page of the retiring session can still deliver a response event while Chrome drains - the Comcast channelmap listener writes its caches from
1938
+ * exactly such an event - and a clear issued ahead of the drain would be undone by it.
1939
+ */
1940
+ clearChannelSelectionCaches();
1433
1941
  // The preconditions were checked before the teardown, but a shutdown can begin during the seconds it takes, and relaunching then would spawn Chrome into a
1434
1942
  // dying process. Re-check on the far side of the await, for the same reason the guard above re-checks on the far side of the quiet period.
1435
1943
  // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- the shutdown path sets this while the teardown is awaited; TS cannot see that.
@@ -1437,10 +1945,12 @@ async function executeBrowserRestart() {
1437
1945
  LOG.debug("browser:lifecycle", "Browser restart relaunch declined because shutdown began while the previous instance was closing.");
1438
1946
  return;
1439
1947
  }
1440
- // Launch a fresh browser instance so it is ready for the next stream request.
1441
- await getCurrentBrowser();
1442
- // Minimize the new window to reduce GPU usage and desktop clutter.
1443
- await minimizeBrowserWindow();
1948
+ // Launch a fresh browser instance so it is ready for the next stream request. The relaunch needs nothing but the browser itself, and the instance it replaces
1949
+ // may be refusing captures, so it acquires for a page.
1950
+ await getCurrentBrowser("page");
1951
+ // Bring the fresh window into agreement with the policy. A scheduled restart only runs against an idle registry, so this settles it minimized; a stream
1952
+ // arriving while the relaunch is in flight triggers its own sync during establishment.
1953
+ await syncWindowVisibility();
1444
1954
  LOG.info("Browser restart complete. Fresh instance is ready.");
1445
1955
  }
1446
1956
  catch (error) {
@@ -1448,25 +1958,25 @@ async function executeBrowserRestart() {
1448
1958
  }
1449
1959
  }
1450
1960
  /**
1451
- * Starts the periodic browser restart eligibility check. This should be called once during server startup, after the browser is initialized. The interval runs
1452
- * indefinitely until stopBrowserRestartChecking() is called (typically during graceful shutdown).
1961
+ * Starts the periodic browser restart eligibility check. This should be called once during server startup, after the browser is initialized. The check runs
1962
+ * indefinitely until stopBrowserRestartChecking() is called (typically during graceful shutdown). A second start while one is running changes nothing.
1963
+ * @param clock - The clock the check's interval and the quiet period it arms both run on. Defaults to the system clock.
1453
1964
  */
1454
- export function startBrowserRestartChecking() {
1455
- restartCheckInterval = setInterval(checkBrowserRestart, BROWSER_RESTART_CHECK_INTERVAL);
1965
+ export function startBrowserRestartChecking(clock = systemClock) {
1966
+ if (restartTimers) {
1967
+ return;
1968
+ }
1969
+ const timers = new TimerRegistry({ clock });
1970
+ timers.setInterval(RESTART_CHECK_KEY, () => { checkBrowserRestart(timers); }, BROWSER_RESTART_CHECK_INTERVAL);
1971
+ restartTimers = timers;
1456
1972
  }
1457
1973
  /**
1458
- * Stops the browser restart checking interval and cancels any pending quiet timer. This should be called during graceful shutdown to prevent a restart from
1459
- * racing with server shutdown.
1974
+ * Stops the periodic browser restart eligibility check. This should be called during graceful shutdown to prevent a restart from racing with server shutdown.
1975
+ * Disposing the registry drains the check and any pending quiet period together, and a stop with nothing running is a no-op.
1460
1976
  */
1461
1977
  export function stopBrowserRestartChecking() {
1462
- if (restartCheckInterval) {
1463
- clearInterval(restartCheckInterval);
1464
- restartCheckInterval = null;
1465
- }
1466
- if (restartQuietTimer) {
1467
- clearTimeout(restartQuietTimer);
1468
- restartQuietTimer = null;
1469
- }
1978
+ restartTimers?.dispose();
1979
+ restartTimers = null;
1470
1980
  }
1471
1981
  /* When running as a packaged executable (created by the `pkg` tool), the application is bundled into a single binary. Node modules like puppeteer-stream are
1472
1982
  * included in the bundle, but Chrome cannot load extensions from within the packaged binary - it needs actual files on the filesystem.
@@ -1523,6 +2033,9 @@ export async function prepareExtension() {
1523
2033
  throw error;
1524
2034
  }
1525
2035
  }
1526
- // Re-export getStream from puppeteer-stream for use by the streaming module. This keeps all puppeteer-stream imports centralized in the browser module.
1527
- export { getStream };
2036
+ /* Re-export the capture acquisition for the streaming module. The browser directory owns every conversation with puppeteer-stream - index.ts launches it and
2037
+ * tabCapture.ts speaks its extension's protocol - so no other layer imports the library, and the streaming layer asks this module for a capture the same way it
2038
+ * asks it for a browser.
2039
+ */
2040
+ export { acquireCaptureStream } from "./tabCapture.js";
1528
2041
  //# sourceMappingURL=index.js.map