prismcast 1.13.0 → 1.14.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 (218) hide show
  1. package/dist/app.d.ts +88 -6
  2. package/dist/app.js +287 -154
  3. package/dist/app.js.map +1 -1
  4. package/dist/browser/cdp.d.ts +6 -17
  5. package/dist/browser/cdp.js +6 -57
  6. package/dist/browser/cdp.js.map +1 -1
  7. package/dist/browser/index.d.ts +106 -23
  8. package/dist/browser/index.js +292 -225
  9. package/dist/browser/index.js.map +1 -1
  10. package/dist/browser/precaching.d.ts +25 -11
  11. package/dist/browser/precaching.js +245 -97
  12. package/dist/browser/precaching.js.map +1 -1
  13. package/dist/browser/tabCapture.js +3 -10
  14. package/dist/browser/tabCapture.js.map +1 -1
  15. package/dist/browser/tuning/comcastPolymer.js +13 -14
  16. package/dist/browser/tuning/comcastPolymer.js.map +1 -1
  17. package/dist/browser/tuning/hbo.js +34 -17
  18. package/dist/browser/tuning/hbo.js.map +1 -1
  19. package/dist/browser/video.d.ts +1 -1
  20. package/dist/browser/video.js +3 -3
  21. package/dist/channels/index.js +5 -5
  22. package/dist/channels/index.js.map +1 -1
  23. package/dist/cliHelp.d.ts +11 -0
  24. package/dist/cliHelp.js +148 -0
  25. package/dist/cliHelp.js.map +1 -0
  26. package/dist/config/channelForm.js +5 -4
  27. package/dist/config/channelForm.js.map +1 -1
  28. package/dist/config/consistencyProbe.d.ts +2 -4
  29. package/dist/config/consistencyProbe.js +8 -51
  30. package/dist/config/consistencyProbe.js.map +1 -1
  31. package/dist/config/index.d.ts +107 -55
  32. package/dist/config/index.js +499 -267
  33. package/dist/config/index.js.map +1 -1
  34. package/dist/config/paths.d.ts +13 -8
  35. package/dist/config/paths.js +21 -25
  36. package/dist/config/paths.js.map +1 -1
  37. package/dist/config/persistence.d.ts +57 -11
  38. package/dist/config/persistence.js +127 -44
  39. package/dist/config/persistence.js.map +1 -1
  40. package/dist/config/reactivity.d.ts +60 -36
  41. package/dist/config/reactivity.js +89 -116
  42. package/dist/config/reactivity.js.map +1 -1
  43. package/dist/config/services.d.ts +61 -26
  44. package/dist/config/services.js +137 -68
  45. package/dist/config/services.js.map +1 -1
  46. package/dist/config/streamSettings.d.ts +24 -0
  47. package/dist/config/streamSettings.js +17 -0
  48. package/dist/config/streamSettings.js.map +1 -0
  49. package/dist/config/userChannels.d.ts +60 -52
  50. package/dist/config/userChannels.js +138 -188
  51. package/dist/config/userChannels.js.map +1 -1
  52. package/dist/config/userConfig.d.ts +224 -61
  53. package/dist/config/userConfig.js +351 -134
  54. package/dist/config/userConfig.js.map +1 -1
  55. package/dist/hdhr/discover.d.ts +7 -1
  56. package/dist/hdhr/discover.js +12 -6
  57. package/dist/hdhr/discover.js.map +1 -1
  58. package/dist/hdhr/index.d.ts +51 -10
  59. package/dist/hdhr/index.js +108 -112
  60. package/dist/hdhr/index.js.map +1 -1
  61. package/dist/hdhr/udp.d.ts +11 -4
  62. package/dist/hdhr/udp.js +25 -20
  63. package/dist/hdhr/udp.js.map +1 -1
  64. package/dist/index.js +16 -143
  65. package/dist/index.js.map +1 -1
  66. package/dist/native/index.d.ts +1 -1
  67. package/dist/native/index.js +4 -3
  68. package/dist/native/index.js.map +1 -1
  69. package/dist/native/proxy.d.ts +1 -1
  70. package/dist/native/proxy.js +36 -28
  71. package/dist/native/proxy.js.map +1 -1
  72. package/dist/routes/cdp.d.ts +7 -6
  73. package/dist/routes/cdp.js +9 -8
  74. package/dist/routes/cdp.js.map +1 -1
  75. package/dist/routes/components.d.ts +23 -5
  76. package/dist/routes/components.js +21 -4
  77. package/dist/routes/components.js.map +1 -1
  78. package/dist/routes/config/channels/endpoints/browse.js +39 -29
  79. package/dist/routes/config/channels/endpoints/browse.js.map +1 -1
  80. package/dist/routes/config/channels/endpoints/bulk.js +2 -6
  81. package/dist/routes/config/channels/endpoints/bulk.js.map +1 -1
  82. package/dist/routes/config/channels/endpoints/predefined.js +5 -15
  83. package/dist/routes/config/channels/endpoints/predefined.js.map +1 -1
  84. package/dist/routes/config/channels/endpoints/prefs.js +7 -6
  85. package/dist/routes/config/channels/endpoints/prefs.js.map +1 -1
  86. package/dist/routes/config/channels/endpoints/service.js +6 -5
  87. package/dist/routes/config/channels/endpoints/service.js.map +1 -1
  88. package/dist/routes/config/channels/endpoints/tags.js +4 -16
  89. package/dist/routes/config/channels/endpoints/tags.js.map +1 -1
  90. package/dist/routes/config/channels/table.d.ts +2 -1
  91. package/dist/routes/config/channels/table.js +11 -13
  92. package/dist/routes/config/channels/table.js.map +1 -1
  93. package/dist/routes/config/http/envelope.d.ts +6 -3
  94. package/dist/routes/config/http/envelope.js +18 -5
  95. package/dist/routes/config/http/envelope.js.map +1 -1
  96. package/dist/routes/config/index.d.ts +21 -13
  97. package/dist/routes/config/index.js +47 -34
  98. package/dist/routes/config/index.js.map +1 -1
  99. package/dist/routes/config/settings.d.ts +43 -6
  100. package/dist/routes/config/settings.js +225 -125
  101. package/dist/routes/config/settings.js.map +1 -1
  102. package/dist/routes/config/vocabulary.d.ts +24 -0
  103. package/dist/routes/config/vocabulary.js +33 -0
  104. package/dist/routes/config/vocabulary.js.map +1 -0
  105. package/dist/routes/debug.js +28 -13
  106. package/dist/routes/debug.js.map +1 -1
  107. package/dist/routes/root/content.js +33 -26
  108. package/dist/routes/root/content.js.map +1 -1
  109. package/dist/routes/root/scripts/channels.js +23 -11
  110. package/dist/routes/root/scripts/channels.js.map +1 -1
  111. package/dist/routes/root/scripts/config.d.ts +1 -1
  112. package/dist/routes/root/scripts/config.js +127 -79
  113. package/dist/routes/root/scripts/config.js.map +1 -1
  114. package/dist/routes/root/scripts/shared.js +34 -23
  115. package/dist/routes/root/scripts/shared.js.map +1 -1
  116. package/dist/routes/root/scripts/status.handlers.d.ts +8 -13
  117. package/dist/routes/root/scripts/status.handlers.js +35 -21
  118. package/dist/routes/root/scripts/status.handlers.js.map +1 -1
  119. package/dist/routes/root/styles.js +13 -16
  120. package/dist/routes/root/styles.js.map +1 -1
  121. package/dist/routes/services.js +12 -16
  122. package/dist/routes/services.js.map +1 -1
  123. package/dist/routes/theme.js +12 -12
  124. package/dist/routes/theme.js.map +1 -1
  125. package/dist/routes/ui.d.ts +4 -3
  126. package/dist/routes/ui.js +17 -13
  127. package/dist/routes/ui.js.map +1 -1
  128. package/dist/service/generators.js +12 -9
  129. package/dist/service/generators.js.map +1 -1
  130. package/dist/streaming/captureSession.d.ts +4 -4
  131. package/dist/streaming/captureSession.js +6 -7
  132. package/dist/streaming/captureSession.js.map +1 -1
  133. package/dist/streaming/codec.d.ts +12 -2
  134. package/dist/streaming/codec.js +40 -28
  135. package/dist/streaming/codec.js.map +1 -1
  136. package/dist/streaming/fmp4Segmenter.d.ts +14 -4
  137. package/dist/streaming/fmp4Segmenter.js +99 -79
  138. package/dist/streaming/fmp4Segmenter.js.map +1 -1
  139. package/dist/streaming/hls.d.ts +21 -14
  140. package/dist/streaming/hls.js +148 -122
  141. package/dist/streaming/hls.js.map +1 -1
  142. package/dist/streaming/hlsResume.d.ts +36 -13
  143. package/dist/streaming/hlsResume.js +68 -37
  144. package/dist/streaming/hlsResume.js.map +1 -1
  145. package/dist/streaming/monitor.d.ts +5 -3
  146. package/dist/streaming/monitor.js +47 -37
  147. package/dist/streaming/monitor.js.map +1 -1
  148. package/dist/streaming/mp4Parser.d.ts +29 -4
  149. package/dist/streaming/mp4Parser.js +49 -7
  150. package/dist/streaming/mp4Parser.js.map +1 -1
  151. package/dist/streaming/playlistBuilder.d.ts +1 -2
  152. package/dist/streaming/playlistBuilder.js +2 -3
  153. package/dist/streaming/playlistBuilder.js.map +1 -1
  154. package/dist/streaming/preroll.d.ts +43 -16
  155. package/dist/streaming/preroll.js +112 -62
  156. package/dist/streaming/preroll.js.map +1 -1
  157. package/dist/streaming/pretune.d.ts +2 -2
  158. package/dist/streaming/pretune.js +11 -7
  159. package/dist/streaming/pretune.js.map +1 -1
  160. package/dist/streaming/recovery.d.ts +29 -2
  161. package/dist/streaming/recovery.js +43 -4
  162. package/dist/streaming/recovery.js.map +1 -1
  163. package/dist/streaming/registry.d.ts +13 -10
  164. package/dist/streaming/registry.js +6 -5
  165. package/dist/streaming/registry.js.map +1 -1
  166. package/dist/streaming/setup.d.ts +29 -20
  167. package/dist/streaming/setup.js +181 -170
  168. package/dist/streaming/setup.js.map +1 -1
  169. package/dist/streaming/showInfo.d.ts +24 -12
  170. package/dist/streaming/showInfo.js +120 -113
  171. package/dist/streaming/showInfo.js.map +1 -1
  172. package/dist/streaming/statusEmitter.d.ts +15 -10
  173. package/dist/streaming/statusEmitter.js +24 -20
  174. package/dist/streaming/statusEmitter.js.map +1 -1
  175. package/dist/types/config.d.ts +24 -13
  176. package/dist/types/config.js +5 -1
  177. package/dist/types/config.js.map +1 -1
  178. package/dist/types/index.d.ts +3 -2
  179. package/dist/types/index.js +2 -1
  180. package/dist/types/index.js.map +1 -1
  181. package/dist/types/profiles.d.ts +2 -2
  182. package/dist/types/selection.d.ts +12 -10
  183. package/dist/types/streaming.d.ts +12 -5
  184. package/dist/types/streaming.js +12 -5
  185. package/dist/types/streaming.js.map +1 -1
  186. package/dist/utils/debugFilter.d.ts +3 -2
  187. package/dist/utils/debugFilter.js +7 -5
  188. package/dist/utils/debugFilter.js.map +1 -1
  189. package/dist/{hdhr → utils}/deviceId.d.ts +1 -1
  190. package/dist/{hdhr → utils}/deviceId.js +11 -29
  191. package/dist/utils/deviceId.js.map +1 -0
  192. package/dist/utils/errors.d.ts +7 -0
  193. package/dist/utils/errors.js +12 -3
  194. package/dist/utils/errors.js.map +1 -1
  195. package/dist/utils/fileLogger.d.ts +23 -12
  196. package/dist/utils/fileLogger.js +73 -41
  197. package/dist/utils/fileLogger.js.map +1 -1
  198. package/dist/utils/httpErrors.d.ts +13 -0
  199. package/dist/utils/httpErrors.js +22 -0
  200. package/dist/utils/httpErrors.js.map +1 -0
  201. package/dist/utils/index.d.ts +3 -0
  202. package/dist/utils/index.js +3 -0
  203. package/dist/utils/index.js.map +1 -1
  204. package/dist/utils/logger.d.ts +9 -8
  205. package/dist/utils/logger.js +64 -20
  206. package/dist/utils/logger.js.map +1 -1
  207. package/dist/utils/markup.js +8 -3
  208. package/dist/utils/markup.js.map +1 -1
  209. package/dist/utils/plainObject.d.ts +6 -0
  210. package/dist/utils/plainObject.js +18 -0
  211. package/dist/utils/plainObject.js.map +1 -0
  212. package/dist/utils/runtimeIdentity.context.js +1 -29
  213. package/dist/utils/runtimeIdentity.context.js.map +1 -1
  214. package/dist/utils/runtimeIdentity.d.ts +17 -6
  215. package/dist/utils/runtimeIdentity.js +50 -17
  216. package/dist/utils/runtimeIdentity.js.map +1 -1
  217. package/package.json +5 -5
  218. package/dist/hdhr/deviceId.js.map +0 -1
@@ -3,10 +3,10 @@ import { TimerRegistry, systemClock } from "homebridge-plugin-utils";
3
3
  import { clearLoginState, isLoginModeActive, setLoginDeps } from "./login.js";
4
4
  import { getAllStreams, getStreamCount, hasActiveCaptureStreams, hasEstablishedStreams, isCaptureIdentity } from "../streaming/registry.js";
5
5
  import { getCachedTabId, installStrayOpenTabReaper, onTabActivation } from "./tabSelection.js";
6
- import { getChromeDataDir, getDataDir, getExtensionDir } from "../config/paths.js";
6
+ import { getChromeDataDir, getDataDir } from "../config/paths.js";
7
7
  import { getExtensionPage, launch } from "puppeteer-stream";
8
8
  import { getGpuCapabilities, setGpuCapabilities } from "./display.js";
9
- import { minimizeWindow, readWindowPlacement, reaffirmCaptureSurface, unminimizeWindow, withCDPSession } from "./cdp.js";
9
+ import { minimizeWindow, readWindowPlacement, unminimizeWindow, withCDPSession } from "./cdp.js";
10
10
  import { CONFIG } from "../config/index.js";
11
11
  import { EXTENSION_READY_EXPRESSION } from "./tabCapture.js";
12
12
  import { clearChannelSelectionCaches } from "./channelSelection.js";
@@ -18,21 +18,22 @@ import fs from "node:fs";
18
18
  import { getPresetViewport } from "../config/presets.js";
19
19
  import path from "node:path";
20
20
  import { launch as puppeteerLaunch } from "puppeteer-core";
21
+ import { registerConfigChangeHandler } from "../config/reactivity.js";
21
22
  import { startPrecaching } from "./precaching.js";
22
23
  const { promises: fsPromises } = fs;
23
24
  /* Global variables maintain the application's runtime state across all operations. We minimize global state where possible, but some values must be shared across
24
25
  * the application lifecycle:
25
26
  *
26
- * - supervisor: The browser capture-readiness supervisor. It is the single source of truth for the shared Chrome instance and its lifecycle (absent / launching /
27
- * ready / degraded / trialing), so all streaming sessions use one Chrome process via supervisor.acquire(). It holds one discriminated-union lifecycle state that
28
- * captures the browser reference, its launch timestamp, and whether a launch is in flight, and routes every relaunch through one loop-safe governor.
27
+ * - supervisor: The browser capture-readiness supervisor. It is the single source of truth for the shared Chrome instance and its lifecycle (every phase from
28
+ * absent to closing), so all streaming sessions use one Chrome process via supervisor.acquire(). It holds one discriminated-union lifecycle state that captures
29
+ * the browser reference, its launch timestamp, and whether a launch is in flight, and routes every relaunch through one loop-safe governor.
29
30
  *
30
31
  * - currentChromeVersion: The one piece of per-browser metadata the adapter holds directly, captured when the browser becomes ready and surfaced by the
31
32
  * health endpoint.
32
33
  *
33
34
  * Stream tracking and ID generation live in streaming/registry.ts for unified stream management across all output types (HLS, MPEG-TS, etc.). Filesystem path
34
- * resolution for persistent data (the Chrome profile and the streaming extension files) is centralized in config/paths.ts, whose getters create the data directory on
35
- * startup if it does not exist; it is resolved on demand rather than held as module state here.
35
+ * resolution for persistent data (the Chrome profile and the streaming extension files) is centralized in config/paths.ts and resolved on demand rather than held
36
+ * as module state here; ensureDataDirectory() below creates the data directory at startup.
36
37
  */
37
38
  // The Chrome version string (e.g., "Chrome/144.0.7559.110") captured when the browser becomes capture-ready in launchReadyBrowser. Cleared when the browser
38
39
  // disconnects or is closed. This is the one piece of per-browser metadata the adapter holds directly; the browser instance, its launch timestamp, and the
@@ -56,8 +57,8 @@ const KILL_WAIT_MS = 2000;
56
57
  const BROWSER_TEARDOWN_DRAIN_BOUND_MS = TERM_WAIT_MS + KILL_WAIT_MS;
57
58
  /**
58
59
  * Builds the browser relaunch governor's policy from live configuration. The supervisor's policy port is a getter, so this is read fresh at each governor decision -
59
- * an operator's change to the recovery.relaunch* settings takes effect without reconstructing the supervisor (and a deferred config reload that restarts the server
60
- * applies it too). The scalar tolerances come from CONFIG.recovery (conservative, biased eager-for-the-first-failure: the first failures cost no cooldown; only
60
+ * an operator's change to the recovery.relaunch* settings takes effect at the next governor decision without reconstructing the supervisor. The scalar
61
+ * tolerances come from CONFIG.recovery (conservative, biased eager-for-the-first-failure: the first failures cost no cooldown; only
61
62
  * repeated failures within the window trip the escalating cooldown); the cooldown ladder is the fixed escalation shape above.
62
63
  * @returns The current launch governor policy.
63
64
  */
@@ -69,12 +70,12 @@ function buildRelaunchPolicy() {
69
70
  healthHoldMs: CONFIG.recovery.relaunchHealthHold
70
71
  };
71
72
  }
72
- /* The one browser capture-readiness supervisor for the process lifetime. It owns the lifecycle state (absent/launching/ready/degraded/trialing) that unifies the
73
- * browser reference, launch promise, and launch timestamp, and routes every relaunch through one loop-safe governor. The adapter injects the impure
74
- * ports: launchReadyBrowser (spawn Chrome and run the readiness gate), closeBrowserInstance (teardown), systemClock.now (time), buildRelaunchPolicy (live config
75
- * bounds), and onSupervisorStateChange (the loud degraded alarm and the recovery notice). All browser access flows through it: getCurrentBrowser is acquire(); the
76
- * non-launching reads derive from current() and currentLaunchTime(). The injected ports are hoisted function declarations, so referencing them here is safe even
77
- * though they are defined further down the module.
73
+ /* The one browser capture-readiness supervisor for the process lifetime. It owns the lifecycle state (one discriminated union covering every phase from absent to
74
+ * closing) that unifies the browser reference, launch promise, and launch timestamp, and routes every relaunch through one loop-safe governor. The adapter
75
+ * injects the impure ports: launchReadyBrowser (spawn Chrome and run the readiness gate), closeBrowserInstance (teardown), systemClock.now (time),
76
+ * buildRelaunchPolicy (live config bounds), and onSupervisorStateChange (the loud degraded alarm and the recovery notice). All browser access flows through it:
77
+ * getCurrentBrowser is acquire(); the non-launching reads derive from current() and currentLaunchTime(). The injected ports are hoisted function declarations, so
78
+ * referencing them here is safe even though they are defined further down the module.
78
79
  */
79
80
  const supervisor = createBrowserSupervisor({ close: closeBrowserInstance, launch: launchReadyBrowser, now: () => systemClock.now(),
80
81
  onStateChange: onSupervisorStateChange, policy: buildRelaunchPolicy });
@@ -121,7 +122,8 @@ function onSupervisorStateChange(next, previous) {
121
122
  let captureProbe = null;
122
123
  /**
123
124
  * 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
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.
125
+ * launch, since the streaming layer is imported during server startup). Separating the wiring from the call keeps browser/index.ts from importing
126
+ * streaming/setup.ts, which already imports this module, so the dependency stays one-directional.
125
127
  * @param probe - The probe to run against a freshly-launched browser; it resolves when the browser can capture and rejects when it cannot.
126
128
  */
127
129
  export function setCaptureProbe(probe) {
@@ -136,7 +138,7 @@ export function setCaptureProbe(probe) {
136
138
  let streamTerminator = null;
137
139
  /**
138
140
  * 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.
141
+ * from the call keeps browser/index.ts from importing streaming/lifecycle.ts, which already imports this module, so the dependency stays one-directional.
140
142
  * @param terminator - The function that tears down one stream and all of the resources it owns.
141
143
  */
142
144
  export function setStreamTerminator(terminator) {
@@ -144,10 +146,8 @@ export function setStreamTerminator(terminator) {
144
146
  }
145
147
  // The identity of the periodic sweep on the stale-page owner's registry.
146
148
  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;
149
+ // The running stale-page sweep. Null until started, and again once stopped.
150
+ let stalePageSweep = null;
151
151
  /* Opportunistic browser restart state. Chrome accumulates memory pressure, GPU process issues, and general flakiness over multi-hour sessions with continuous
152
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
153
153
  * executing the restart. After the restart, a fresh browser is launched immediately so it is ready for the next stream request.
@@ -175,8 +175,9 @@ let restartTimers = null;
175
175
  function cancelRestartQuietTimer() {
176
176
  restartTimers?.clear(RESTART_QUIET_KEY);
177
177
  }
178
- // Flag indicating that the browser is being closed intentionally via closeBrowser(). When true, the disconnect handler skips error logging and stream termination
179
- // since these are handled by the shutdown code path. This prevents false "unexpected disconnect" errors during graceful shutdown.
178
+ // The process-wide graceful-shutdown state. app.ts shutdown() sets it early, and closeBrowser() sets it as a fallback for direct calls. While it is set, the
179
+ // disconnect path stays quiet (no error log and no status emit) and the restart paths stand down; stream termination still runs. This prevents false
180
+ // "unexpected disconnect" errors during graceful shutdown.
180
181
  let gracefulShutdownInProgress = false;
181
182
  /**
182
183
  * Returns true if graceful shutdown is in progress.
@@ -241,7 +242,7 @@ export function pickCarrierPage(pages) {
241
242
  return pages.find(isCarrierPage) ?? null;
242
243
  }
243
244
  /* 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
+ * the capture extension's chrome.windows ids: those are two independent tables, and this is the only place the shared window's identity is recorded.
245
246
  *
246
247
  * 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
248
  * 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
@@ -277,7 +278,7 @@ async function readPageWindow(page) {
277
278
  * Records which window the shared one is, reading it from a page that sits in it.
278
279
  *
279
280
  * 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
+ * answer before anything asks for a capture tab.
281
282
  * @param browser - The browser whose shared window is being recorded.
282
283
  * @param page - A page that sits in that window.
283
284
  */
@@ -341,6 +342,16 @@ export { endLoginMode, getLoginPage, getLoginStatus, setLoginModeEndObserver, st
341
342
  // 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
343
  // reads the mark without importing the supervisor.
343
344
  export { BrowserCaptureImpairedError, BrowserSupersededError, BrowserUnavailableError } from "./browserSupervisor.js";
345
+ /**
346
+ * Thrown when the browser launched but its capture system failed a launch gate: the capture extension never became ready, or the capture probe found no
347
+ * working capture across its attempts. setupStream answers it as a capture-infrastructure failure by its type, so its message is free to state what happened.
348
+ */
349
+ export class CaptureLaunchError extends Error {
350
+ constructor(message, options) {
351
+ super(message, options);
352
+ this.name = "CaptureLaunchError";
353
+ }
354
+ }
344
355
  /* 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
356
  * reading the compositor, login mode's own flag, the shutdown gate, the two CDP primitives, and a page resolver built on the supervisor.
346
357
  *
@@ -406,6 +417,20 @@ export async function syncWindowVisibility(page) {
406
417
  // same setter/getter pattern as setChromeUserAgent in chromeFetch.ts. Both accessors are hoisted function declarations, so this call reads them at module
407
418
  // 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
419
  setLoginDeps({ getBrowserInstance, syncWindowVisibility });
420
+ /**
421
+ * Composes the system status the SSE bus carries. A browser reads as connected only when one is published and its connection is live, and the memory readings
422
+ * carry only the figures the status reports.
423
+ * @param facts - The browser and capture state and the process and registry readings, read at the moment the status is composed.
424
+ * @returns The system status.
425
+ */
426
+ export function composeSystemStatus(facts) {
427
+ return {
428
+ browser: { captureImpaired: facts.captureImpaired, connected: facts.browser?.connected ?? false, pageCount: facts.pageCount },
429
+ memory: { heapUsed: facts.memory.heapUsed, rss: facts.memory.rss },
430
+ streams: { active: facts.streams.active, limit: facts.streams.limit },
431
+ uptime: facts.uptime
432
+ };
433
+ }
409
434
  /**
410
435
  * Computes the current system status and emits it to SSE subscribers. Called when browser state changes significantly or when streams are added/removed.
411
436
  */
@@ -423,36 +448,20 @@ export async function emitCurrentSystemStatus() {
423
448
  catch (_error) {
424
449
  // Ignore errors getting page count.
425
450
  }
426
- const memUsage = process.memoryUsage();
427
- const status = {
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,
435
- connected: !!browser && browser.connected,
436
- pageCount
437
- },
438
- memory: {
439
- heapUsed: memUsage.heapUsed,
440
- rss: memUsage.rss
441
- },
442
- streams: {
443
- active: getStreamCount(),
444
- limit: CONFIG.streaming.maxConcurrentStreams
445
- },
446
- uptime: process.uptime()
447
- };
448
- emitSystemStatusChanged(status);
451
+ /* The capture-impairment mark is read here, at compose time, beside the connectivity the composer reads, rather than as a snapshot taken before the page-count
452
+ * await. This function is called un-awaited from many sites, so two calls can be in flight around the instant a mark lands; a pre-mark snapshot settling after
453
+ * the mark's own emit would broadcast false over the cached true and the dedupe would honor it. Reading here makes every emit describe the state at the moment
454
+ * consumers receive it, so the later of two racing emits is also the truer one.
455
+ */
456
+ emitSystemStatusChanged(composeSystemStatus({ browser, captureImpaired: supervisor.captureImpairment() !== null, memory: process.memoryUsage(), pageCount,
457
+ streams: { active: getStreamCount(), limit: CONFIG.streaming.maxConcurrentStreams }, uptime: process.uptime() }));
449
458
  }
450
459
  /**
451
460
  * Registers a page as managed by PrismCast. This should be called immediately after creating a page via browser.newPage(). Registered pages are tracked for stale
452
461
  * page cleanup, while unregistered pages (manually opened, site popups, etc.) are left alone.
453
462
  *
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
455
- * issues with Page object reference identity.
463
+ * Each registered page receives a unique ID that persists for the page's lifetime, read back through the Page reference by getManagedPageId. The staleness map,
464
+ * the in-flight set and the stale page cleanup's decision core all work on this stable string key rather than on Page objects.
456
465
  * @param page - The Puppeteer Page to register.
457
466
  * @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
467
  * 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
@@ -508,11 +517,7 @@ function clearPageTracking() {
508
517
  }
509
518
  /**
510
519
  * Ensures the data directory exists, creating it if necessary. This should be called during application startup before any operations that depend on the data
511
- * directory (like browser launch or extension preparation).
512
- *
513
- * The data directory stores:
514
- * - Chrome profile data (cookies, local storage, session state)
515
- * - Extension files (when running as a packaged executable)
520
+ * directory, browser launch above all, because the directory holds the Chrome profile data (cookies, local storage, session state).
516
521
  */
517
522
  export async function ensureDataDirectory() {
518
523
  try {
@@ -524,7 +529,7 @@ export async function ensureDataDirectory() {
524
529
  throw error;
525
530
  }
526
531
  // Purge on-disk artifacts retired in earlier releases. The data directory is the right boundary for this work - it runs once per startup, after the directory
527
- // exists, before any subsequent step reads it. Future retirements add a single line to purgeLegacyArtifacts; the call site here does not change.
532
+ // exists, before any subsequent step reads it. Future retirements add an entry to RETIRED_ARTIFACTS; neither purgeLegacyArtifacts nor this call site changes.
528
533
  await purgeLegacyArtifacts();
529
534
  }
530
535
  const RETIRED_ARTIFACTS = [
@@ -577,6 +582,38 @@ async function purgeLegacyArtifacts() {
577
582
  function syncSleep(ms) {
578
583
  Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
579
584
  }
585
+ /**
586
+ * Reports whether a process's command line runs Chrome on the given profile directory: the first stage of the kill filter and of the holder test, so they agree
587
+ * on which processes are on the profile.
588
+ * @param commandLine - The process's command line as the OS reports it.
589
+ * @param profileDir - The Chrome user-data-dir to match against.
590
+ * @returns True when the command line carries the profile flag for exactly that directory.
591
+ */
592
+ function usesProfile(commandLine, profileDir) {
593
+ const target = "--user-data-dir=" + profileDir;
594
+ // Chrome puppeteer launches with --user-data-dir=<path> (equals form, no quotes). We also verify the character following the path is whitespace or
595
+ // end-of-string, otherwise "/x/y" would match "--user-data-dir=/x/yz".
596
+ const idx = commandLine.indexOf(target);
597
+ if (idx === -1) {
598
+ return false;
599
+ }
600
+ const after = commandLine.charAt(idx + target.length);
601
+ return (after === "") || (after === " ") || (after === "\t");
602
+ }
603
+ /**
604
+ * Reports whether a Chrome process on our profile is ours to terminate: the ownership stage of the kill filter, and the stage the holder test reads to decide
605
+ * which Chrome the sweep spares, so the sweep never keeps the files of a Chrome it ends nor removes those of one it spares. The parent-child relationship in the
606
+ * OS process table is the ownership proof, so no in-memory flag is consulted.
607
+ * @param entry - The process-table row of a Chrome process on our profile.
608
+ * @param ownPid - The current process's PID.
609
+ * @param isProcessAlive - Predicate that returns true when the given PID is currently a live process.
610
+ * @returns True when we spawned the process or its parent is no longer alive.
611
+ */
612
+ function isOwnedChrome(entry, ownPid, isProcessAlive) {
613
+ // Ours if we spawned it (ppid is us) or if its parent is no longer alive (orphaned from a previous PrismCast that died). A live unrelated parent owns it, not
614
+ // us.
615
+ return (entry.ppid === ownPid) || !isProcessAlive(entry.ppid);
616
+ }
580
617
  /**
581
618
  * Identifies Chrome processes that this PrismCast instance is responsible for terminating. The filter has two stages: command-line discovery (anything using
582
619
  * our profile directory) followed by ownership verification (we spawned it, or its parent is no longer alive). The ownership stage is structural - we do not
@@ -592,22 +629,25 @@ function syncSleep(ms) {
592
629
  * @returns The PIDs to terminate, in process-table order.
593
630
  */
594
631
  export function findChromeProcessesUsingProfile(processes, profileDir, ownPid, isProcessAlive) {
595
- const target = "--user-data-dir=" + profileDir;
596
- return processes.filter((p) => {
597
- // Stage 1: command-line match. Chrome puppeteer launches with --user-data-dir=<path> (equals form, no quotes). We also verify the character following
598
- // the path is whitespace or end-of-string, otherwise "/x/y" would match "--user-data-dir=/x/yz".
599
- const idx = p.commandLine.indexOf(target);
600
- if (idx === -1) {
601
- return false;
602
- }
603
- const after = p.commandLine.charAt(idx + target.length);
604
- if ((after !== "") && (after !== " ") && (after !== "\t")) {
605
- return false;
606
- }
607
- // Stage 2: ownership verification. Kill if we spawned it (ppid is us) or if its parent is no longer alive (orphaned from a previous PrismCast that died).
608
- // Skip if its parent is a live unrelated PID - that process owns it, not us.
609
- return (p.ppid === ownPid) || !isProcessAlive(p.ppid);
610
- }).map((p) => p.pid);
632
+ return processes.filter((p) => usesProfile(p.commandLine, profileDir) && isOwnedChrome(p, ownPid, isProcessAlive)).map((p) => p.pid);
633
+ }
634
+ /**
635
+ * Finds a live Chrome outside this process that holds our profile directory. A Chrome holds the profile when it roots a tree of processes on the profile and the
636
+ * kill filter's ownership stage spares it, its parent alive and not this process. The test reads the root because Chrome's helpers carry the profile flag with
637
+ * the Chrome main as their parent, so a helper's parent, the main, is a live process that is not this one, and every helper of this process's own Chrome would
638
+ * otherwise read as a holder. It reads the ownership stage rather than a liveness test of its own so it stays in step with the kill filter: the sweep never keeps
639
+ * the files of a Chrome it ends nor removes those of one it spares. A helper whose parent lacks the profile flag reads as a root, so that error keeps the lock files
640
+ * rather than removing them, and the next sweep with no Chrome alive removes them.
641
+ * @param processes - The current process table snapshot: the sweep's scan from before its kill loop, or the teardown's scan once its Chrome has exited.
642
+ * @param profileDir - The Chrome user-data-dir to match against.
643
+ * @param ownPid - The current process's PID (a Chrome whose parent is us is ours, never a holder).
644
+ * @param isProcessAlive - Predicate that returns true when the given PID is currently a live process. Injected so tests do not depend on real PIDs.
645
+ * @returns The PID of the first holder in process-table order, or null when no live Chrome outside this process holds the profile.
646
+ */
647
+ export function findProfileHolder(processes, profileDir, ownPid, isProcessAlive) {
648
+ const onProfile = processes.filter((p) => usesProfile(p.commandLine, profileDir));
649
+ const profilePids = new Set(onProfile.map((p) => p.pid));
650
+ return onProfile.find((p) => !profilePids.has(p.ppid) && !isOwnedChrome(p, ownPid, isProcessAlive))?.pid ?? null;
611
651
  }
612
652
  /**
613
653
  * Ensures a clean slate for browser launch by terminating any stale Chrome processes and removing orphaned profile lock files. Chrome locks its profile
@@ -620,8 +660,9 @@ export function findChromeProcessesUsingProfile(processes, profileDir, ownPid, i
620
660
  * process exit handler: Chrome may be running normally (e.g., after a capture probe timeout) and an immediate SIGKILL would corrupt its profile databases,
621
661
  * poisoning the Docker volume for subsequent container restarts.
622
662
  *
623
- * The ownership filter makes this function safe to call from any context, including a rejected-duplicate startup's exit handler: a duplicate that never
624
- * spawned Chrome will find nothing matching its ownership criteria and signal nothing.
663
+ * The ownership filter and the holder test together make this function safe to call from any context, including a rejected-duplicate startup's exit handler: a
664
+ * duplicate that never spawned Chrome finds nothing matching its ownership criteria, signals nothing, and leaves the profile's lock files to the live Chrome
665
+ * that holds them.
625
666
  *
626
667
  * Called at startup before launching the browser and from the process exit handler as a crash recovery fallback. Safe to call when no stale processes or files
627
668
  * exist - the discovery and lock-file cleanup are both no-ops in the empty case.
@@ -629,7 +670,10 @@ export function findChromeProcessesUsingProfile(processes, profileDir, ownPid, i
629
670
  export function killStaleChrome() {
630
671
  const profileDir = getChromeDataDir(CONFIG);
631
672
  const POLL_INTERVAL_MS = 200;
632
- const pidsToKill = findChromeProcessesUsingProfile(listProcesses(), profileDir, process.pid, isProcessRunning);
673
+ // The kill filter and the lock-file removal's holder test read the one scan taken before the kill loop, because a scan after it would read the helpers of each
674
+ // Chrome the loop ends, adopted by a live process once their main exits, as live holders.
675
+ const processes = listProcesses();
676
+ const pidsToKill = findChromeProcessesUsingProfile(processes, profileDir, process.pid, isProcessRunning);
633
677
  for (const pid of pidsToKill) {
634
678
  try {
635
679
  // Send SIGTERM first to give Chrome a chance to flush its profile databases (LevelDB, extension state, session storage) before exiting. This is critical
@@ -652,15 +696,13 @@ export function killStaleChrome() {
652
696
  }
653
697
  }
654
698
  catch (error) {
655
- // ESRCH means the process does not exist - expected when there are no stale processes from a clean shutdown, or in Docker where the PID belongs to a
656
- // previous container's PID namespace.
699
+ // ESRCH means the process exited between the process-table scan above and the signal, which is a benign race.
657
700
  if (error.code !== "ESRCH") {
658
701
  LOG.warn("Failed to signal Chrome process %d: %s.", pid, formatError(error));
659
702
  }
660
703
  }
661
704
  }
662
- // Remove stale lock and port files left behind by an unclean Chrome exit.
663
- cleanStaleProfileFiles(profileDir);
705
+ cleanStaleProfileFiles(processes, profileDir);
664
706
  }
665
707
  /**
666
708
  * 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
@@ -682,12 +724,23 @@ function waitForChromeExit(pid, timeoutMs, pollIntervalMs) {
682
724
  return !isProcessRunning(pid);
683
725
  }
684
726
  /**
685
- * Removes stale Chrome profile lock files and the DevTools port file. Chrome writes these while running and removes them on clean shutdown, but an unclean exit
686
- * (container kill, SIGKILL, crash) leaves them behind. Stale lock files prevent Chrome from acquiring the profile, and a stale DevToolsActivePort can confuse the
687
- * Puppeteer connection.
727
+ * Removes the profile's lock files and the DevTools port file unless a live Chrome outside this process holds the profile. Chrome writes these while running and
728
+ * removes them on clean shutdown, but an unclean exit (container kill, SIGKILL, crash) leaves them behind. Stale lock files prevent Chrome from acquiring the
729
+ * profile, and a stale DevToolsActivePort can confuse the Puppeteer connection. This is the one removal of those files, so each caller, the stale-Chrome sweep and
730
+ * the teardown primitive, reaches them through the holder test, and none removes them from under a live holder.
731
+ * @param processes - The process table the holder test reads: the sweep's scan from before its kill loop, or the teardown's scan once its Chrome has exited.
688
732
  * @param profileDir - The Chrome user data directory path.
689
733
  */
690
- function cleanStaleProfileFiles(profileDir) {
734
+ export function cleanStaleProfileFiles(processes, profileDir) {
735
+ const holder = findProfileHolder(processes, profileDir, process.pid, isProcessRunning);
736
+ /* The lock and port files belong to whichever Chrome holds the profile, so they are stale only when no live Chrome outside this process holds it. Removing
737
+ * them from under a live holder, such as the instance a refused second start found, would let the next Chrome launched on the profile start beside the
738
+ * running one instead of meeting its singleton.
739
+ */
740
+ if (holder !== null) {
741
+ LOG.debug("browser:lifecycle", "Kept the profile lock files of live Chrome process %d.", holder);
742
+ return;
743
+ }
691
744
  // Chrome's profile lock mechanism uses three symlinks: SingletonLock (hostname-PID pair), SingletonCookie (numeric verification token), and SingletonSocket
692
745
  // (path to the IPC socket). All three must be removed for Chrome to acquire a fresh lock. DevToolsActivePort contains the debugging port from the previous
693
746
  // session and is irrelevant when launching a new browser instance.
@@ -893,9 +946,9 @@ export function buildLaunchOptions() {
893
946
  executablePath: getExecutablePath(),
894
947
  /* 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
948
  * 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.
949
+ * outright. Each of them races the shutdown handler app.ts installs for the same signal and preempts closeBrowserInstance's SIGTERM ladder below, so Chrome
950
+ * gets no shutdown at all and forfeits whatever it had pending: a window placement inside its save debounce, its session state. With the listeners off, every
951
+ * one of these signals reaches PrismCast's own handlers, the graceful path runs, and Chrome exits through the ladder with its pending writes committed.
899
952
  */
900
953
  handleSIGHUP: false,
901
954
  handleSIGINT: false,
@@ -929,8 +982,8 @@ export function buildLaunchOptions() {
929
982
  "--enable-blink-features=IdleDetection",
930
983
  "--mute-audio"
931
984
  ],
932
- // Use pipe mode for browser communication instead of WebSocket. Pipe mode is faster and more reliable, especially under load. It uses stdin/stdout for
933
- // the DevTools Protocol connection rather than a network socket.
985
+ // Use pipe mode for browser communication instead of WebSocket. Pipe mode is faster and more reliable, especially under load. It runs the DevTools Protocol
986
+ // over a dedicated pair of pipes (file descriptors 3 and 4) rather than a network socket.
934
987
  pipe: true,
935
988
  // Persistent user data directory for Chrome profile. This directory stores cookies, local storage, and other session data. By persisting this across
936
989
  // restarts, sites remember login state and don't require re-authentication.
@@ -981,6 +1034,32 @@ export async function emulateCaptureSurface(page) {
981
1034
  export async function emulateLayoutSurface(page) {
982
1035
  return declareSurface(page, NATIVE_DENSITY);
983
1036
  }
1037
+ /**
1038
+ * Re-issues a capture page's own standing device-metrics override. Chrome composes the capture of a selected tab from the window's fitted presentation rather than
1039
+ * from the emulated surface, and re-sending the page's standing override is what moves the composition back to the emulated surface, while a capture already
1040
+ * composing that surface is left exactly as it was. Callable at any time, from anywhere, at any frequency, because the values sent are the ones Puppeteer has
1041
+ * already declared on this page - nothing about the page's emulation changes.
1042
+ *
1043
+ * The re-issue goes through the page's own emulation session, the one Puppeteer keeps on the page's primary target, because the override is only as durable as
1044
+ * the session that holds it and Chrome restores the window's view size when a session that declared one detaches.
1045
+ * @param page - The page to re-affirm. A page carrying no explicitly declared density is left alone.
1046
+ * @throws Whatever the viewport setter rejects with. Each trigger site decides whether that matters to it.
1047
+ */
1048
+ export async function reaffirmCaptureSurface(page) {
1049
+ /* An explicitly declared, positive density is precisely the mark of a capture page, which is what makes this function safe to fire at any page from any trigger.
1050
+ * A page PrismCast has not emulated carries no viewport at all and falls out on the first test; a page emulated for layout declares the display's own density
1051
+ * through Chrome's disable value of 0, which the positive test excludes.
1052
+ */
1053
+ const viewport = page.viewport();
1054
+ if (!viewport) {
1055
+ return;
1056
+ }
1057
+ const deviceScaleFactor = viewport.deviceScaleFactor;
1058
+ if ((typeof deviceScaleFactor !== "number") || !(deviceScaleFactor > 0)) {
1059
+ return;
1060
+ }
1061
+ await page.setViewport(viewport);
1062
+ }
984
1063
  /**
985
1064
  * 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
1065
  * 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
@@ -1164,27 +1243,6 @@ export function healActivatedCaptureTab(tabId, deps = { getAllStreams, getCached
1164
1243
  onTabActivation((tabId) => {
1165
1244
  healActivatedCaptureTab(tabId);
1166
1245
  });
1167
- /**
1168
- * Custom launch function that modifies Chrome arguments when running as a packaged executable. The packaged version cannot load extensions from node_modules
1169
- * (which is bundled inside the executable), so we point the extension paths to our extracted extension files in the data directory.
1170
- * @param opts - The launch options to modify.
1171
- * @returns The launched browser instance.
1172
- */
1173
- async function launchWithCustomArgs(opts) {
1174
- // When running as a packaged executable (process.pkg is set by the pkg bundler), we need to replace the extension paths. puppeteer-stream points
1175
- // opts.enableExtensions at its own node_modules-relative extension directory, which puppeteer-core installs via a CDP browser.installExtension() call after
1176
- // Chrome starts, rather than via --load-extension/--disable-extensions-except CLI flags - so that path still resolves inside node_modules, which does not
1177
- // exist at that location in the packaged executable. We route around it with Chrome's own native unpacked-extension flags, pointing them at our extracted
1178
- // extension files instead.
1179
- if (process.pkg) {
1180
- const extensionPath = getExtensionDir(CONFIG);
1181
- // Remove any existing extension arguments and add our own pointing to the extracted extension.
1182
- opts.args = (opts.args ?? [])
1183
- .filter((arg) => !arg.startsWith("--load-extension=") && !arg.startsWith("--disable-extensions-except="))
1184
- .concat(["--disable-extensions-except=" + extensionPath, "--load-extension=" + extensionPath]);
1185
- }
1186
- return puppeteerLaunch(opts);
1187
- }
1188
1246
  /**
1189
1247
  * Formats the GPU capabilities into a human-readable suffix for the "Chrome ready" log line. The renderer string is already cleaned (ANGLE wrapper and Metal
1190
1248
  * prefix stripped) at detection time, so this function uses it directly and appends hardware-accelerated codec names in brackets when available.
@@ -1249,7 +1307,8 @@ async function detectBrowserCapabilities(browser) {
1249
1307
  return ext ? String(gl.getParameter(ext.UNMASKED_RENDERER_WEBGL)) : String(gl.getParameter(gl.RENDERER));
1250
1308
  });
1251
1309
  // Extract the meaningful GPU name. ANGLE wraps the actual GPU identity: "ANGLE (Vendor, GPU Name, API Version)". The GPU name is the second
1252
- // comma-separated field. For non-ANGLE renderers, use the device string from CDP.
1310
+ // comma-separated field. A non-ANGLE renderer string is used as is, and the device string from CDP is used only when WebGL reports the masked
1311
+ // "WebKit WebGL" or nothing at all.
1253
1312
  let renderer = webglRenderer;
1254
1313
  const anglePart = /^ANGLE \([^,]+, ([^,]+)/.exec(webglRenderer)?.[1];
1255
1314
  if (anglePart) {
@@ -1373,7 +1432,7 @@ function relinquishBrowserReadiness(streamTerminationReason) {
1373
1432
  */
1374
1433
  function handleBrowserDisconnect() {
1375
1434
  // Announce the unexpected disconnect before tearing down (the message says streams will be terminated, which relinquish then does). Suppressed during a full
1376
- // server shutdown, where closeBrowser() set the flag and the disconnect is intentional.
1435
+ // server shutdown, where app.ts shutdown() (or closeBrowser() as a fallback) set the flag and the disconnect is intentional.
1377
1436
  if (!gracefulShutdownInProgress) {
1378
1437
  LOG.error("Browser disconnected unexpectedly. All active streams will be terminated.");
1379
1438
  }
@@ -1434,23 +1493,36 @@ async function launchReadyBrowser() {
1434
1493
  // Seed the profile's extension developer-mode flag before Chrome reads the profile. Chrome loads the unpacked capture extension only when the flag is set, and
1435
1494
  // this is the one function every launch passes through, so it is also the only place that runs before the first-ever launch on a fresh install.
1436
1495
  seedProfilePreferences(getChromeDataDir(CONFIG));
1437
- // The launch function from puppeteer-stream wraps standard Puppeteer launch to inject the streaming extension. We pass our custom launch function that handles
1438
- // packaged-executable extension paths. This happens on first stream request, after a browser crash, during server warmup, or during a governed relaunch.
1439
- const browser = await launch({ launch: launchWithCustomArgs }, buildLaunchOptions());
1496
+ // The launch function from puppeteer-stream wraps a standard Puppeteer launch to inject the streaming extension. We hand it puppeteer-core's own launch as its
1497
+ // launch port, so the browser comes from the puppeteer-core this module depends on rather than whichever copy puppeteer-stream resolves for itself. This happens
1498
+ // on first stream request, after a browser crash, during server warmup, or during a governed relaunch.
1499
+ const browser = await launch({ launch: puppeteerLaunch }, buildLaunchOptions());
1440
1500
  try {
1441
1501
  LOG.debug("timing:browser", "Chrome process spawned. (+%sms)", browserElapsed());
1442
- // Readiness gate, handshake tier (cheap, on-suspicion). Poll for the puppeteer-stream extension to finish initializing - it injects a START_RECORDING function
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
1444
- // extension loads (typically 200-500ms). On failure this THROWS rather than warning-and-proceeding: an unregistered extension means chrome.tabs is undefined and
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
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
1447
- // 500 the client would not back off from - an unregistered extension is a capture-infrastructure fault, and a fresh relaunch usually clears it.
1502
+ // The init timeout is live, so it is read once per launch: the error below then names the bound the wait applied, and a save made during a handshake applies
1503
+ // at the next launch.
1504
+ const initTimeout = CONFIG.browser.initTimeout;
1505
+ /* Readiness gate, handshake tier (cheap, on-suspicion). Poll for the puppeteer-stream extension to finish initializing - it injects a START_RECORDING function
1506
+ * 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
1507
+ * the extension loads (typically 200-500ms). On failure this THROWS rather than warning-and-proceeding: an unregistered extension means chrome.tabs is undefined
1508
+ * and every capture acquisition would hang, so the instance is not capture-ready and must not be published.
1509
+ *
1510
+ * Each step throws a CaptureLaunchError whose message states what that step saw, with the underlying error as its cause. The type, not the wording, is what
1511
+ * earns the 503 back-off at the setup layer (the same answer the capability-tier probe failure gets), rather than a 500 the client would not back off from - an
1512
+ * unregistered extension is a capture-infrastructure fault, and a fresh relaunch usually clears it.
1513
+ */
1514
+ let extensionPage;
1448
1515
  try {
1449
- const extensionPage = await getExtensionPage(browser);
1450
- await extensionPage.waitForFunction(EXTENSION_READY_EXPRESSION, { timeout: CONFIG.browser.initTimeout });
1516
+ extensionPage = await getExtensionPage(browser);
1451
1517
  }
1452
- catch (handshakeError) {
1453
- throw new Error("The capture extension handshake timed out after " + String(CONFIG.browser.initTimeout) + " ms.", { cause: handshakeError });
1518
+ catch (error) {
1519
+ throw new CaptureLaunchError("The capture extension page did not open.", { cause: error });
1520
+ }
1521
+ try {
1522
+ await extensionPage.waitForFunction(EXTENSION_READY_EXPRESSION, { timeout: initTimeout });
1523
+ }
1524
+ catch (error) {
1525
+ throw new CaptureLaunchError("The capture extension did not become ready within " + String(initTimeout) + " ms.", { cause: error });
1454
1526
  }
1455
1527
  LOG.debug("timing:browser", "Extension initialized. (+%sms)", browserElapsed());
1456
1528
  /* 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
@@ -1578,8 +1650,9 @@ export async function getBrowserPages() {
1578
1650
  *
1579
1651
  * Chrome termination uses Puppeteer's ChildProcess handle and its `exit` event for detection:
1580
1652
  *
1581
- * - browser.close() sends CDP Browser.close and waits for WebSocket teardown, which hangs 3-5 seconds even after Chrome exits.
1582
- * - browser.disconnect() drops the WebSocket instantly but orphans Chrome as a Node child process, creating a zombie that process.kill(pid, 0) cannot detect.
1653
+ * - browser.close() first runs puppeteer-stream's pass that closes every page but the extension's capture page and queries the extension's tabs, then sends CDP
1654
+ * Browser.close and waits for Chrome's process to exit, with no bound of its own on that wait.
1655
+ * - browser.disconnect() drops the DevTools connection instantly but orphans Chrome as a Node child process, creating a zombie that process.kill(pid, 0) cannot detect.
1583
1656
  * - Synchronous polling (Atomics.wait) blocks the event loop, preventing Node from processing SIGCHLD to reap the child - Chrome becomes a zombie regardless
1584
1657
  * of how SIGTERM was sent.
1585
1658
  *
@@ -1605,25 +1678,27 @@ async function closeBrowserInstance(browser) {
1605
1678
  // Wait for Chrome to exit after SIGTERM, with a bound. If Chrome doesn't exit in time, escalate to SIGKILL.
1606
1679
  const exitedAfterTerm = await boundedWait(exitPromise, TERM_WAIT_MS);
1607
1680
  if (!exitedAfterTerm) {
1608
- // SIGTERM didn't work within the bound. Escalate to SIGKILL. Orphaned Chrome processes (from a crashed parent or previous container) may not
1609
- // respond to SIGTERM.
1681
+ // SIGTERM didn't work within the bound. Escalate to SIGKILL. A wedged or hung Chrome can fail to exit on SIGTERM within the bound.
1610
1682
  LOG.debug("browser:lifecycle", "Chrome did not exit after SIGTERM. Escalating to SIGKILL.");
1611
1683
  chromeProcess.kill("SIGKILL");
1612
1684
  // The same exit promise serves the second wait: if it already resolved, this returns its value immediately.
1613
1685
  await boundedWait(exitPromise, KILL_WAIT_MS);
1614
1686
  }
1615
1687
  }
1616
- // Disconnect the Puppeteer WebSocket after Chrome has exited. This cleans up Puppeteer's internal state (event listeners, pending CDP calls) without waiting for
1617
- // the WebSocket close handshake to complete on a dead connection. We catch the rejection: disconnect() on a connection whose underlying transport already died of
1688
+ // Disconnect Puppeteer's DevTools connection (the CDP pipe) after Chrome has exited. This cleans up Puppeteer's internal state (event listeners, pending CDP
1689
+ // calls) without waiting for an orderly close on a dead connection. We catch the rejection: disconnect() on a connection whose underlying transport already died of
1618
1690
  // an unclean Chrome exit can reject, and an unhandled rejection on this fire-and-forget call would crash the process during an otherwise-successful teardown.
1619
1691
  if (browser.connected) {
1620
1692
  browser.disconnect().catch((error) => {
1621
1693
  LOG.debug("browser:lifecycle", "Ignoring browser disconnect error during teardown: %s.", formatError(error));
1622
1694
  });
1623
1695
  }
1624
- // Remove stale Chrome profile lock files left behind by the disconnected browser. No per-PID state to clear here - killStaleChrome on the next launch will
1625
- // discover any leftover Chrome via the OS process table.
1626
- cleanStaleProfileFiles(getChromeDataDir(CONFIG));
1696
+ /* The lock files go through the holder test on a scan taken here, once this process's Chrome has exited, because only a scan after the exit can see a Chrome
1697
+ * another instance launched on a shared profile in its place. This process's own Chrome never reads as that holder: a main still running after the bounded
1698
+ * waits is ours by the ownership test, and its helpers are not roots. A helper still shutting down after its main exited has been adopted by a live process
1699
+ * and reads as a holder, so that teardown keeps the files, the adopted-orphan limit the sweep shares, and the next startup sweep with no Chrome alive removes them.
1700
+ */
1701
+ cleanStaleProfileFiles(listProcesses(), getChromeDataDir(CONFIG));
1627
1702
  }
1628
1703
  /**
1629
1704
  * Closes the browser and cleans up resources during full server shutdown. After this call the supervisor reports absent and any subsequent stream request launches
@@ -1659,8 +1734,8 @@ export async function closeBrowser() {
1659
1734
  * 1. Only managed pages: We only consider pages that PrismCast created (tracked in managedPageIds). Pages opened manually by the user for debugging, or pages opened
1660
1735
  * by streaming sites (OAuth popups, etc.) are left alone.
1661
1736
  *
1662
- * 2. Target ID comparison: We use target IDs (strings) instead of Page object references for comparison. Puppeteer may return different wrapper objects for the
1663
- * same underlying page, making reference comparison unreliable.
1737
+ * 2. Managed page IDs: Each managed page is assigned a string ID ("page-" plus a counter) by registerManagedPage and read back through the Page reference by
1738
+ * getManagedPageId. The staleness map, the in-flight set and the decision core all work on these stable string keys rather than on Page objects.
1664
1739
  *
1665
1740
  * 3. Grace period: Pages must be observed as potentially stale for a configurable grace period before being closed. This handles race conditions where pages are
1666
1741
  * briefly untracked during stream initialization or cleanup.
@@ -1756,32 +1831,62 @@ export async function cleanupStalePages(now) {
1756
1831
  }
1757
1832
  }
1758
1833
  catch (error) {
1759
- // Cleanup failure is not critical - log a warning and try again next interval.
1834
+ // Cleanup failure is not critical - log it at debug level and let the next interval retry.
1760
1835
  LOG.debug("browser:lifecycle", "Stale page cleanup failed: %s.", formatError(error));
1761
1836
  }
1762
1837
  }
1838
+ /**
1839
+ * Arms the stale-page sweep's interval on the owner's registry: cleanupStalePages at the owner's clock reading, every interval. Arming under the sweep's key
1840
+ * replaces an interval already armed there, so a re-arm leaves one sweep, on the new cadence from the moment it is armed.
1841
+ * @param sweep - The running stale-page owner.
1842
+ * @param interval - Milliseconds between sweeps.
1843
+ */
1844
+ function armStalePageSweep(sweep, interval) {
1845
+ sweep.timers.setInterval(STALE_PAGE_SWEEP_KEY, () => { void cleanupStalePages(sweep.clock.now()); }, interval);
1846
+ }
1763
1847
  /**
1764
1848
  * 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.
1849
+ * stopStalePageCleanup() is called (typically during graceful shutdown), and a saved interval re-arms it through applyStalePageCleanupChanges(). A second start
1850
+ * while one is running changes nothing.
1766
1851
  * @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
1852
  * source. Defaults to the system clock.
1768
1853
  */
1769
1854
  export function startStalePageCleanup(clock = systemClock) {
1770
- if (stalePageTimers) {
1855
+ if (stalePageSweep) {
1771
1856
  return;
1772
1857
  }
1773
- const timers = new TimerRegistry({ clock });
1774
- timers.setInterval(STALE_PAGE_SWEEP_KEY, () => { void cleanupStalePages(clock.now()); }, CONFIG.recovery.stalePageCleanupInterval);
1775
- stalePageTimers = timers;
1858
+ const sweep = { clock, timers: new TimerRegistry({ clock }) };
1859
+ armStalePageSweep(sweep, CONFIG.recovery.stalePageCleanupInterval);
1860
+ stalePageSweep = sweep;
1776
1861
  }
1777
1862
  /**
1778
1863
  * 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
1864
  * browser and streams. Disposing the registry drains the interval, and a stop with nothing running is a no-op.
1780
1865
  */
1781
1866
  export function stopStalePageCleanup() {
1782
- stalePageTimers?.dispose();
1783
- stalePageTimers = null;
1867
+ stalePageSweep?.timers.dispose();
1868
+ stalePageSweep = null;
1869
+ }
1870
+ /**
1871
+ * Re-arms the running stale-page sweep at a saved cleanup interval. The re-arm restarts the cadence from the save, so the next sweep runs one full interval after
1872
+ * it, and the grace period needs nothing here, because each sweep already reads it when it runs. The handler reads the running sweep and never creates one, so a
1873
+ * save before the boot's start or after the stop arms nothing, and a save can never resurrect a stopped sweep. The interval comes from the candidate, because
1874
+ * the reconcile commits CONFIG only after its handlers run. Re-arming cannot fail, so the handler refuses nothing.
1875
+ * @param _changes - The change to the cleanup interval; the candidate carries the interval, so the handler reads that instead.
1876
+ * @param next - The candidate running configuration.
1877
+ * @returns No rejections.
1878
+ */
1879
+ export async function applyStalePageCleanupChanges(_changes, next) {
1880
+ if (stalePageSweep) {
1881
+ const interval = next.recovery.stalePageCleanupInterval;
1882
+ armStalePageSweep(stalePageSweep, interval);
1883
+ LOG.debug("browser:lifecycle", "Stale page cleanup re-armed at %d ms.", interval);
1884
+ }
1885
+ return [];
1784
1886
  }
1887
+ // Module-load side effect: register the handler once per process, as every config-change handler registers, so it is in place before the first save can reach
1888
+ // the reconcile.
1889
+ registerConfigChangeHandler("recovery.stalePageCleanupInterval", applyStalePageCleanupChanges);
1785
1890
  /**
1786
1891
  * 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
1892
  * the decision is stated once here rather than spelled out at each guard.
@@ -1814,6 +1919,23 @@ export function isBrowserIdleForRestart(cause, facts) {
1814
1919
  function readRestartFacts() {
1815
1920
  return { establishedStreams: hasEstablishedStreams(), inFlightPages: inFlightPageIds.size, streamCount: getStreamCount() };
1816
1921
  }
1922
+ /**
1923
+ * Decides what a restart check's tick does about the maintenance quiet period. A browser younger than BROWSER_MAX_AGE needs no maintenance, so the tick leaves
1924
+ * the countdown as it stands. An old browser with streams registered waits for them: a countdown already running is cancelled, so a stream that starts during
1925
+ * the quiet period resets it, and with no countdown running there is nothing to do. An old browser with an empty registry starts the countdown, unless one is
1926
+ * already running.
1927
+ * @param facts - The browser's age, the registry's size and the countdown's state, read at the tick.
1928
+ * @returns The action the tick takes.
1929
+ */
1930
+ export function decideRestartQuietPeriod(facts) {
1931
+ if (facts.age < BROWSER_MAX_AGE) {
1932
+ return "skip";
1933
+ }
1934
+ if (facts.streamCount > 0) {
1935
+ return facts.quietPending ? "cancel" : "skip";
1936
+ }
1937
+ return facts.quietPending ? "skip" : "arm";
1938
+ }
1817
1939
  /**
1818
1940
  * 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
1941
  * 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
@@ -1839,18 +1961,17 @@ export async function restartBrowserIfImpairedAndIdle() {
1839
1961
  /**
1840
1962
  * 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
1963
  * 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.
1964
+ * tick drives the supervisor's health-gated governor reset and carries out what decideRestartQuietPeriod decides about the maintenance quiet period.
1844
1965
  * @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
1966
  * nullable module binding and needs no null branch of its own.
1967
+ * @param clock - The clock the check was started with, which the browser's age is measured on.
1846
1968
  */
1847
- function checkBrowserRestart(timers) {
1969
+ function checkBrowserRestart(timers, clock) {
1848
1970
  // Skip if the server is shutting down or login mode is active.
1849
1971
  if (gracefulShutdownInProgress || isLoginModeActive()) {
1850
1972
  return;
1851
1973
  }
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
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.
1974
+ // 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."
1854
1975
  const browser = supervisor.current();
1855
1976
  const launchTime = supervisor.currentLaunchTime();
1856
1977
  if (!browser?.connected || (launchTime === null)) {
@@ -1870,31 +1991,32 @@ function checkBrowserRestart(timers) {
1870
1991
  if (supervisor.noteSustainedHealth()) {
1871
1992
  LOG.info("Browser capture readiness has been sustained; the relaunch governor has reset to its normal state.");
1872
1993
  }
1873
- // Skip if the browser has not exceeded the maximum age.
1874
- const age = systemClock.now() - launchTime;
1875
- if (age < BROWSER_MAX_AGE) {
1876
- return;
1877
- }
1878
- // If there are active streams, cancel any pending quiet timer and return. Streams that start during the quiet period reset the countdown.
1879
- if (getStreamCount() > 0) {
1880
- if (timers.has(RESTART_QUIET_KEY)) {
1994
+ /* The age is measured on the clock this check was started with. The supervisor stamps the launch time on the system clock, which is the clock production
1995
+ * starts the check with, so in production the launch time and the reading it is subtracted from come from the same source.
1996
+ */
1997
+ const action = decideRestartQuietPeriod({ age: clock.now() - launchTime, quietPending: timers.has(RESTART_QUIET_KEY), streamCount: getStreamCount() });
1998
+ switch (action) {
1999
+ case "arm": {
2000
+ 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));
2001
+ timers.setTimeout(RESTART_QUIET_KEY, () => {
2002
+ void executeBrowserRestart("maintenance");
2003
+ }, BROWSER_RESTART_QUIET_PERIOD);
2004
+ break;
2005
+ }
2006
+ case "cancel": {
1881
2007
  LOG.debug("browser:lifecycle", "Browser restart quiet period cancelled - streams are active.");
2008
+ cancelRestartQuietTimer();
2009
+ break;
2010
+ }
2011
+ case "skip": {
2012
+ break;
1882
2013
  }
1883
- cancelRestartQuietTimer();
1884
- return;
1885
- }
1886
- // No active streams and the browser is old enough. Start the quiet timer if one is not already running.
1887
- if (!timers.has(RESTART_QUIET_KEY)) {
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));
1889
- timers.setTimeout(RESTART_QUIET_KEY, () => {
1890
- void executeBrowserRestart("maintenance");
1891
- }, BROWSER_RESTART_QUIET_PERIOD);
1892
2014
  }
1893
2015
  }
1894
2016
  /**
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.
2017
+ * 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. The guard's
2018
+ * idleness test and the log line that announces the restart both depend on the cause; the teardown and relaunch sequence is shared by every cause.
2019
+ * @param cause - Why the restart is running, for the guard's idleness test and the announcement.
1898
2020
  */
1899
2021
  async function executeBrowserRestart(cause) {
1900
2022
  // 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
@@ -1960,14 +2082,14 @@ async function executeBrowserRestart(cause) {
1960
2082
  /**
1961
2083
  * Starts the periodic browser restart eligibility check. This should be called once during server startup, after the browser is initialized. The check runs
1962
2084
  * 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.
2085
+ * @param clock - The clock the check's interval and the quiet period it arms both run on, and the browser's age is measured on. Defaults to the system clock.
1964
2086
  */
1965
2087
  export function startBrowserRestartChecking(clock = systemClock) {
1966
2088
  if (restartTimers) {
1967
2089
  return;
1968
2090
  }
1969
2091
  const timers = new TimerRegistry({ clock });
1970
- timers.setInterval(RESTART_CHECK_KEY, () => { checkBrowserRestart(timers); }, BROWSER_RESTART_CHECK_INTERVAL);
2092
+ timers.setInterval(RESTART_CHECK_KEY, () => { checkBrowserRestart(timers, clock); }, BROWSER_RESTART_CHECK_INTERVAL);
1971
2093
  restartTimers = timers;
1972
2094
  }
1973
2095
  /**
@@ -1978,61 +2100,6 @@ export function stopBrowserRestartChecking() {
1978
2100
  restartTimers?.dispose();
1979
2101
  restartTimers = null;
1980
2102
  }
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
1982
- * included in the bundle, but Chrome cannot load extensions from within the packaged binary - it needs actual files on the filesystem.
1983
- *
1984
- * To solve this, we extract the puppeteer-stream extension files to the application's data directory during startup. This happens only when process.pkg is
1985
- * defined (indicating we're running as a packaged executable).
1986
- *
1987
- * The extracted files are:
1988
- * - background.js: The extension's service worker that handles media capture
1989
- * - manifest.json: The extension manifest declaring permissions and capabilities
1990
- * - options.html/options.js: Extension options page (not used by our automation, but required by the manifest)
1991
- */
1992
- /**
1993
- * Extracts the Puppeteer Stream extension files when running as a packaged executable. This copies the extension files from within the packaged binary to the
1994
- * filesystem where Chrome can load them.
1995
- *
1996
- * When running from source (not packaged), this function does nothing - puppeteer-stream can load the extension directly from node_modules.
1997
- * @throws If extension extraction fails.
1998
- */
1999
- export async function prepareExtension() {
2000
- // Only needed when running as a packaged executable.
2001
- if (!process.pkg) {
2002
- return;
2003
- }
2004
- try {
2005
- // The extension files are extracted to the extension directory within the data directory (ensured to exist before this function is called).
2006
- const out = getExtensionDir(CONFIG);
2007
- // Create the extension directory if it doesn't exist.
2008
- try {
2009
- await fsPromises.mkdir(out, { recursive: true });
2010
- }
2011
- catch (error) {
2012
- LOG.error("Failed to create extension directory: %s.", formatError(error));
2013
- throw error;
2014
- }
2015
- // The extension files that need to be extracted. These are the files from puppeteer-stream's extension directory.
2016
- const files = ["background.js", "manifest.json", "options.html", "options.js"];
2017
- for (const file of files) {
2018
- try {
2019
- // Copy each file from the packaged location (relative to the executable) to the data directory. The source path assumes the executable is in the
2020
- // same directory as node_modules (which is how pkg packages the application).
2021
- // eslint-disable-next-line no-await-in-loop
2022
- await fsPromises.copyFile(path.join(path.dirname(process.execPath), "node_modules", "puppeteer-stream", "extension", file), path.join(out, file));
2023
- }
2024
- catch (error) {
2025
- LOG.error("Failed to copy extension file %s: %s.", file, formatError(error));
2026
- throw error;
2027
- }
2028
- }
2029
- LOG.debug("browser:lifecycle", "Extension files prepared successfully.");
2030
- }
2031
- catch (error) {
2032
- LOG.error("Extension preparation failed: %s.", formatError(error));
2033
- throw error;
2034
- }
2035
- }
2036
2103
  /* Re-export the capture acquisition for the streaming module. The browser directory owns every conversation with puppeteer-stream - index.ts launches it and
2037
2104
  * 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
2105
  * asks it for a browser.