prismcast 1.12.0 → 1.12.1
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.
- package/dist/app.d.ts +41 -0
- package/dist/app.d.ts.map +1 -1
- package/dist/app.js +156 -69
- package/dist/app.js.map +1 -1
- package/dist/browser/browserSupervisor.d.ts +30 -1
- package/dist/browser/browserSupervisor.d.ts.map +1 -1
- package/dist/browser/browserSupervisor.js +39 -12
- package/dist/browser/browserSupervisor.js.map +1 -1
- package/dist/browser/cdp.d.ts +58 -5
- package/dist/browser/cdp.d.ts.map +1 -1
- package/dist/browser/cdp.js +146 -9
- package/dist/browser/cdp.js.map +1 -1
- package/dist/browser/channelSelection.d.ts +2 -2
- package/dist/browser/channelSelection.d.ts.map +1 -1
- package/dist/browser/channelSelection.js +5 -5
- package/dist/browser/channelSelection.js.map +1 -1
- package/dist/browser/consent.d.ts +16 -0
- package/dist/browser/consent.d.ts.map +1 -1
- package/dist/browser/consent.js +67 -15
- package/dist/browser/consent.js.map +1 -1
- package/dist/browser/hlsPlaylistObserver.js +2 -2
- package/dist/browser/hlsPlaylistObserver.js.map +1 -1
- package/dist/browser/index.d.ts +212 -48
- package/dist/browser/index.d.ts.map +1 -1
- package/dist/browser/index.js +612 -199
- package/dist/browser/index.js.map +1 -1
- package/dist/browser/login.d.ts +4 -4
- package/dist/browser/login.d.ts.map +1 -1
- package/dist/browser/login.js +19 -19
- package/dist/browser/login.js.map +1 -1
- package/dist/browser/manifestInterceptor.js +1 -1
- package/dist/browser/pageStaleness.d.ts +1 -1
- package/dist/browser/pageStaleness.d.ts.map +1 -1
- package/dist/browser/pageStaleness.js +9 -9
- package/dist/browser/pageStaleness.js.map +1 -1
- package/dist/browser/precaching.d.ts +23 -11
- package/dist/browser/precaching.d.ts.map +1 -1
- package/dist/browser/precaching.js +188 -49
- package/dist/browser/precaching.js.map +1 -1
- package/dist/browser/tabCapture.d.ts +100 -0
- package/dist/browser/tabCapture.d.ts.map +1 -0
- package/dist/browser/tabCapture.js +320 -0
- package/dist/browser/tabCapture.js.map +1 -0
- package/dist/browser/tabSelection.d.ts +117 -0
- package/dist/browser/tabSelection.d.ts.map +1 -0
- package/dist/browser/tabSelection.js +546 -0
- package/dist/browser/tabSelection.js.map +1 -0
- package/dist/browser/tuning/comcastPolymer.js +2 -2
- package/dist/browser/tuning/comcastPolymer.js.map +1 -1
- package/dist/browser/tuning/directv.js +1 -1
- package/dist/browser/tuning/directv.js.map +1 -1
- package/dist/browser/tuning/hbo.js +2 -2
- package/dist/browser/tuning/hbo.js.map +1 -1
- package/dist/browser/tuning/hulu.d.ts.map +1 -1
- package/dist/browser/tuning/hulu.js +9 -6
- package/dist/browser/tuning/hulu.js.map +1 -1
- package/dist/browser/tuning/shared.d.ts +1 -1
- package/dist/browser/tuning/shared.js +1 -1
- package/dist/browser/tuning/youtubeTv.d.ts.map +1 -1
- package/dist/browser/tuning/youtubeTv.js +1 -2
- package/dist/browser/tuning/youtubeTv.js.map +1 -1
- package/dist/browser/video.d.ts +33 -13
- package/dist/browser/video.d.ts.map +1 -1
- package/dist/browser/video.js +113 -52
- package/dist/browser/video.js.map +1 -1
- package/dist/browser/windowSync.d.ts +46 -0
- package/dist/browser/windowSync.d.ts.map +1 -0
- package/dist/browser/windowSync.js +153 -0
- package/dist/browser/windowSync.js.map +1 -0
- package/dist/channels/index.d.ts +7 -0
- package/dist/channels/index.d.ts.map +1 -1
- package/dist/channels/index.js +24 -8
- package/dist/channels/index.js.map +1 -1
- package/dist/config/consistencyProbe.js +1 -1
- package/dist/config/consistencyProbe.js.map +1 -1
- package/dist/config/health.d.ts.map +1 -1
- package/dist/config/health.js +0 -3
- package/dist/config/health.js.map +1 -1
- package/dist/config/index.d.ts +12 -0
- package/dist/config/index.d.ts.map +1 -1
- package/dist/config/index.js +77 -31
- package/dist/config/index.js.map +1 -1
- package/dist/config/paths.d.ts +17 -1
- package/dist/config/paths.d.ts.map +1 -1
- package/dist/config/paths.js +21 -1
- package/dist/config/paths.js.map +1 -1
- package/dist/config/persistence.d.ts +8 -3
- package/dist/config/persistence.d.ts.map +1 -1
- package/dist/config/persistence.js +143 -82
- package/dist/config/persistence.js.map +1 -1
- package/dist/config/profiles.d.ts.map +1 -1
- package/dist/config/profiles.js +2 -3
- package/dist/config/profiles.js.map +1 -1
- package/dist/config/providerLineups.d.ts.map +1 -1
- package/dist/config/providerLineups.js +0 -3
- package/dist/config/providerLineups.js.map +1 -1
- package/dist/config/services.js +1 -1
- package/dist/config/services.js.map +1 -1
- package/dist/config/sites.d.ts.map +1 -1
- package/dist/config/sites.js +77 -58
- package/dist/config/sites.js.map +1 -1
- package/dist/config/userChannels.d.ts +0 -1
- package/dist/config/userChannels.d.ts.map +1 -1
- package/dist/config/userChannels.js +1 -5
- package/dist/config/userChannels.js.map +1 -1
- package/dist/config/userConfig.d.ts +15 -3
- package/dist/config/userConfig.d.ts.map +1 -1
- package/dist/config/userConfig.js +119 -25
- package/dist/config/userConfig.js.map +1 -1
- package/dist/config/userProfiles.d.ts.map +1 -1
- package/dist/config/userProfiles.js +10 -11
- package/dist/config/userProfiles.js.map +1 -1
- package/dist/hdhr/protocol.d.ts +1 -1
- package/dist/hdhr/protocol.js +1 -1
- package/dist/hdhr/udp.d.ts.map +1 -1
- package/dist/hdhr/udp.js +16 -4
- package/dist/hdhr/udp.js.map +1 -1
- package/dist/index.js +10 -3
- package/dist/index.js.map +1 -1
- package/dist/native/index.d.ts +1 -1
- package/dist/native/index.js +3 -3
- package/dist/native/probe.js +1 -1
- package/dist/native/proxy.d.ts +1 -1
- package/dist/native/proxy.js +5 -5
- package/dist/native/proxy.js.map +1 -1
- package/dist/routes/cdp.js +1 -1
- package/dist/routes/config/channels/endpoints/bulk.js +2 -2
- package/dist/routes/config/channels/endpoints/bulk.js.map +1 -1
- package/dist/routes/config/channels/endpoints/crud.js +1 -1
- package/dist/routes/config/channels/endpoints/crud.js.map +1 -1
- package/dist/routes/config/channels/endpoints/tags.js +3 -3
- package/dist/routes/config/channels/endpoints/tags.js.map +1 -1
- package/dist/routes/config/channels/healthBridge.d.ts +4 -0
- package/dist/routes/config/channels/healthBridge.d.ts.map +1 -1
- package/dist/routes/config/channels/healthBridge.js +9 -5
- package/dist/routes/config/channels/healthBridge.js.map +1 -1
- package/dist/routes/config/channels/table.d.ts.map +1 -1
- package/dist/routes/config/channels/table.js +64 -133
- package/dist/routes/config/channels/table.js.map +1 -1
- package/dist/routes/config/http/envelope.d.ts +1 -1
- package/dist/routes/config/index.d.ts +5 -4
- package/dist/routes/config/index.d.ts.map +1 -1
- package/dist/routes/config/index.js +5 -4
- package/dist/routes/config/index.js.map +1 -1
- package/dist/routes/config/services.d.ts.map +1 -1
- package/dist/routes/config/services.js +13 -16
- package/dist/routes/config/services.js.map +1 -1
- package/dist/routes/config/settings.d.ts +6 -14
- package/dist/routes/config/settings.d.ts.map +1 -1
- package/dist/routes/config/settings.js +14 -34
- package/dist/routes/config/settings.js.map +1 -1
- package/dist/routes/debug.js +1 -1
- package/dist/routes/debug.js.map +1 -1
- package/dist/routes/health.d.ts +15 -6
- package/dist/routes/health.d.ts.map +1 -1
- package/dist/routes/health.js +22 -10
- package/dist/routes/health.js.map +1 -1
- package/dist/routes/logs.d.ts.map +1 -1
- package/dist/routes/logs.js +50 -12
- package/dist/routes/logs.js.map +1 -1
- package/dist/routes/root/content.d.ts.map +1 -1
- package/dist/routes/root/content.js +32 -11
- package/dist/routes/root/content.js.map +1 -1
- package/dist/routes/root/index.d.ts +10 -0
- package/dist/routes/root/index.d.ts.map +1 -1
- package/dist/routes/root/index.js +17 -7
- package/dist/routes/root/index.js.map +1 -1
- package/dist/routes/root/scripts/channels.d.ts.map +1 -1
- package/dist/routes/root/scripts/channels.js +9 -19
- package/dist/routes/root/scripts/channels.js.map +1 -1
- package/dist/routes/root/scripts/clientEscape.js +1 -1
- package/dist/routes/root/scripts/config.js +3 -3
- package/dist/routes/root/scripts/config.js.map +1 -1
- package/dist/routes/root/scripts/shared.d.ts.map +1 -1
- package/dist/routes/root/scripts/shared.js +16 -13
- package/dist/routes/root/scripts/shared.js.map +1 -1
- package/dist/routes/root/scripts/status.handlers.d.ts +3 -0
- package/dist/routes/root/scripts/status.handlers.d.ts.map +1 -1
- package/dist/routes/root/scripts/status.handlers.js +49 -24
- package/dist/routes/root/scripts/status.handlers.js.map +1 -1
- package/dist/routes/root/styles.d.ts.map +1 -1
- package/dist/routes/root/styles.js +10 -18
- package/dist/routes/root/styles.js.map +1 -1
- package/dist/routes/theme.js +3 -3
- package/dist/routes/theme.js.map +1 -1
- package/dist/routes/ui.js +5 -5
- package/dist/routes/ui.js.map +1 -1
- package/dist/streaming/captureLock.js +2 -2
- package/dist/streaming/captureLock.js.map +1 -1
- package/dist/streaming/fmp4Segmenter.d.ts +9 -5
- package/dist/streaming/fmp4Segmenter.d.ts.map +1 -1
- package/dist/streaming/fmp4Segmenter.js +28 -7
- package/dist/streaming/fmp4Segmenter.js.map +1 -1
- package/dist/streaming/hls.d.ts +62 -2
- package/dist/streaming/hls.d.ts.map +1 -1
- package/dist/streaming/hls.js +161 -117
- package/dist/streaming/hls.js.map +1 -1
- package/dist/streaming/hlsSegments.d.ts +2 -1
- package/dist/streaming/hlsSegments.d.ts.map +1 -1
- package/dist/streaming/hlsSegments.js +19 -4
- package/dist/streaming/hlsSegments.js.map +1 -1
- package/dist/streaming/lifecycle.d.ts.map +1 -1
- package/dist/streaming/lifecycle.js +25 -7
- package/dist/streaming/lifecycle.js.map +1 -1
- package/dist/streaming/monitor.d.ts +11 -1
- package/dist/streaming/monitor.d.ts.map +1 -1
- package/dist/streaming/monitor.js +386 -179
- package/dist/streaming/monitor.js.map +1 -1
- package/dist/streaming/mpegts.d.ts +11 -0
- package/dist/streaming/mpegts.d.ts.map +1 -1
- package/dist/streaming/mpegts.js +69 -21
- package/dist/streaming/mpegts.js.map +1 -1
- package/dist/streaming/preroll.js +3 -3
- package/dist/streaming/preroll.js.map +1 -1
- package/dist/streaming/recovery.d.ts +90 -4
- package/dist/streaming/recovery.d.ts.map +1 -1
- package/dist/streaming/recovery.js +86 -11
- package/dist/streaming/recovery.js.map +1 -1
- package/dist/streaming/registry.d.ts +79 -10
- package/dist/streaming/registry.d.ts.map +1 -1
- package/dist/streaming/registry.js +73 -2
- package/dist/streaming/registry.js.map +1 -1
- package/dist/streaming/setup.d.ts +56 -30
- package/dist/streaming/setup.d.ts.map +1 -1
- package/dist/streaming/setup.js +290 -224
- package/dist/streaming/setup.js.map +1 -1
- package/dist/streaming/statusEmitter.d.ts +4 -1
- package/dist/streaming/statusEmitter.d.ts.map +1 -1
- package/dist/streaming/statusEmitter.js +19 -7
- package/dist/streaming/statusEmitter.js.map +1 -1
- package/dist/types/channels.d.ts.map +1 -1
- package/dist/types/channels.js +0 -4
- package/dist/types/channels.js.map +1 -1
- package/dist/types/index.d.ts +3 -2
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/index.js +2 -1
- package/dist/types/index.js.map +1 -1
- package/dist/types/profiles.d.ts +50 -8
- package/dist/types/profiles.d.ts.map +1 -1
- package/dist/types/profiles.js +35 -1
- package/dist/types/profiles.js.map +1 -1
- package/dist/types/streaming.d.ts +8 -0
- package/dist/types/streaming.d.ts.map +1 -1
- package/dist/types/streaming.js +7 -0
- package/dist/types/streaming.js.map +1 -1
- package/dist/upgrade/commands.js +1 -1
- package/dist/upgrade/commands.js.map +1 -1
- package/dist/upgrade/detection.d.ts +1 -1
- package/dist/utils/bootSession.context.js +1 -1
- package/dist/utils/bootSession.context.js.map +1 -1
- package/dist/utils/debugFilter.d.ts.map +1 -1
- package/dist/utils/debugFilter.js +5 -3
- package/dist/utils/debugFilter.js.map +1 -1
- package/dist/utils/delay.d.ts +31 -1
- package/dist/utils/delay.d.ts.map +1 -1
- package/dist/utils/delay.js +39 -3
- package/dist/utils/delay.js.map +1 -1
- package/dist/utils/errors.d.ts +0 -10
- package/dist/utils/errors.d.ts.map +1 -1
- package/dist/utils/errors.js +3 -14
- package/dist/utils/errors.js.map +1 -1
- package/dist/utils/ffmpeg.d.ts +1 -0
- package/dist/utils/ffmpeg.d.ts.map +1 -1
- package/dist/utils/ffmpeg.js +2 -1
- package/dist/utils/ffmpeg.js.map +1 -1
- package/dist/utils/fileLogger.d.ts +23 -5
- package/dist/utils/fileLogger.d.ts.map +1 -1
- package/dist/utils/fileLogger.js +230 -99
- package/dist/utils/fileLogger.js.map +1 -1
- package/dist/utils/format.d.ts +24 -1
- package/dist/utils/format.d.ts.map +1 -1
- package/dist/utils/format.js +37 -1
- package/dist/utils/format.js.map +1 -1
- package/dist/utils/index.d.ts +1 -0
- package/dist/utils/index.d.ts.map +1 -1
- package/dist/utils/index.js +1 -0
- package/dist/utils/index.js.map +1 -1
- package/dist/utils/morganStream.d.ts +2 -2
- package/dist/utils/morganStream.d.ts.map +1 -1
- package/dist/utils/morganStream.js +6 -8
- package/dist/utils/morganStream.js.map +1 -1
- package/dist/utils/never.d.ts +9 -0
- package/dist/utils/never.d.ts.map +1 -0
- package/dist/utils/never.js +20 -0
- package/dist/utils/never.js.map +1 -0
- package/dist/utils/sanitize.d.ts +8 -0
- package/dist/utils/sanitize.d.ts.map +1 -1
- package/dist/utils/sanitize.js +19 -0
- package/dist/utils/sanitize.js.map +1 -1
- package/dist/utils/version.d.ts +9 -4
- package/dist/utils/version.d.ts.map +1 -1
- package/dist/utils/version.js.map +1 -1
- package/package.json +5 -5
package/dist/browser/index.js
CHANGED
|
@@ -1,20 +1,22 @@
|
|
|
1
|
-
import { LOG, boundedWait, evaluateWithAbort, formatError, isProcessRunning, listProcesses, realClock, startTimer } from "../utils/index.js";
|
|
1
|
+
import { LOG, boundedWait, evaluateWithAbort, formatError, isProcessRunning, listProcesses, realClock, setChromeUserAgent, startTimer } from "../utils/index.js";
|
|
2
2
|
import { clearLoginState, isLoginModeActive, setBrowserAccessors } from "./login.js";
|
|
3
|
-
import { getAllStreams, getStreamCount } from "../streaming/registry.js";
|
|
3
|
+
import { getAllStreams, getStreamCount, hasActiveCaptureStreams, isCaptureIdentity } from "../streaming/registry.js";
|
|
4
|
+
import { getCachedTabId, installStrayOpenTabReaper, onTabActivation } from "./tabSelection.js";
|
|
4
5
|
import { getChromeDataDir, getDataDir, getExtensionDir } from "../config/paths.js";
|
|
5
|
-
import { getExtensionPage,
|
|
6
|
+
import { getExtensionPage, launch } from "puppeteer-stream";
|
|
6
7
|
import { getGpuCapabilities, setGpuCapabilities } from "./display.js";
|
|
7
|
-
import { minimizeWindow, unminimizeWindow, withCDPSession } from "./cdp.js";
|
|
8
|
+
import { minimizeWindow, readWindowPlacement, reaffirmCaptureSurface, unminimizeWindow, withCDPSession } from "./cdp.js";
|
|
8
9
|
import { CONFIG } from "../config/index.js";
|
|
10
|
+
import { EXTENSION_READY_EXPRESSION } from "./tabCapture.js";
|
|
9
11
|
import { clearChannelSelectionCaches } from "./channelSelection.js";
|
|
10
12
|
import { createBrowserSupervisor } from "./browserSupervisor.js";
|
|
13
|
+
import { createWindowVisibilitySync } from "./windowSync.js";
|
|
11
14
|
import { emitSystemStatusChanged } from "../streaming/statusEmitter.js";
|
|
12
15
|
import { evaluateStalePages } from "./pageStaleness.js";
|
|
13
16
|
import fs from "node:fs";
|
|
14
17
|
import { getPresetViewport } from "../config/presets.js";
|
|
15
18
|
import path from "node:path";
|
|
16
19
|
import { launch as puppeteerLaunch } from "puppeteer-core";
|
|
17
|
-
import { setChromeUserAgent } from "../utils/index.js";
|
|
18
20
|
import { startPrecaching } from "./precaching.js";
|
|
19
21
|
import { terminateStream } from "../streaming/lifecycle.js";
|
|
20
22
|
const { promises: fsPromises } = fs;
|
|
@@ -78,8 +80,10 @@ const supervisor = createBrowserSupervisor({ close: closeBrowserInstance, launch
|
|
|
78
80
|
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,
|
|
82
|
-
* restored after a degraded period, and emits the SSE system status
|
|
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
|
*/
|
|
@@ -96,13 +100,20 @@ 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 once the registry empties.
|
|
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 no stream is active.", 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
|
|
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
|
|
106
117
|
* 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
118
|
* launchReadyBrowser refuses to publish a browser if it is somehow still null (see the call site), rather than serving an unverified one.
|
|
108
119
|
*/
|
|
@@ -110,8 +121,7 @@ let captureProbe = null;
|
|
|
110
121
|
/**
|
|
111
122
|
* 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
123
|
* 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
|
|
114
|
-
* unrecoverable stale-mutex case).
|
|
124
|
+
* @param probe - The probe to run against a freshly-launched browser; it resolves when the browser can capture and rejects when it cannot.
|
|
115
125
|
*/
|
|
116
126
|
export function setCaptureProbe(probe) {
|
|
117
127
|
captureProbe = probe;
|
|
@@ -132,6 +142,15 @@ const BROWSER_RESTART_CHECK_INTERVAL = 30000;
|
|
|
132
142
|
// 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
143
|
// elapse with zero active streams. Cancelled if a stream starts during the quiet period.
|
|
134
144
|
let restartQuietTimer = null;
|
|
145
|
+
/**
|
|
146
|
+
* 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.
|
|
147
|
+
*/
|
|
148
|
+
function cancelRestartQuietTimer() {
|
|
149
|
+
if (restartQuietTimer) {
|
|
150
|
+
clearTimeout(restartQuietTimer);
|
|
151
|
+
restartQuietTimer = null;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
135
154
|
// Interval handle for the periodic restart eligibility check.
|
|
136
155
|
let restartCheckInterval = null;
|
|
137
156
|
// Flag indicating that the browser is being closed intentionally via closeBrowser(). When true, the disconnect handler skips error logging and stream termination
|
|
@@ -166,23 +185,205 @@ const managedPageIds = new Set();
|
|
|
166
185
|
// 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
186
|
// the configured grace period before being closed. This prevents race conditions where pages are briefly untracked during initialization or cleanup transitions.
|
|
168
187
|
const potentiallyStalePages = new Map();
|
|
169
|
-
/* Set of IDs for pages
|
|
170
|
-
* it completes, which on a slow tune is long enough for the
|
|
171
|
-
*
|
|
172
|
-
*
|
|
188
|
+
/* 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
|
|
189
|
+
* 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
|
|
190
|
+
* 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
|
|
191
|
+
* 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
|
|
192
|
+
* 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
|
|
193
|
+
* clearPageTracking wipes the collections at the end of a browser session.
|
|
194
|
+
*/
|
|
195
|
+
const inFlightPageIds = new Set();
|
|
196
|
+
/* 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
|
|
197
|
+
* 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
|
|
198
|
+
* out of every site that reaches for a convenient page to command the shared window through.
|
|
199
|
+
*
|
|
200
|
+
* 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
|
|
201
|
+
* holds it, and the picker below already declines a closed page - so unregistration, teardown order, and clearPageTracking have nothing to maintain here.
|
|
202
|
+
*/
|
|
203
|
+
const ownWindowPages = new WeakSet();
|
|
204
|
+
/**
|
|
205
|
+
* 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
|
|
206
|
+
* parameter accepts the absent case so every caller narrows through this one test rather than guarding beside it.
|
|
207
|
+
* @param page - The candidate page, which may be absent.
|
|
208
|
+
* @returns True when the page can carry a shared-window command.
|
|
209
|
+
*/
|
|
210
|
+
export function isCarrierPage(page) {
|
|
211
|
+
return !!page && !page.isClosed() && !ownWindowPages.has(page);
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* Picks the page a shared-window command should travel on from the pages a browser has open, taking the first that qualifies.
|
|
215
|
+
* @param pages - The candidate pages, in the order the browser reports them.
|
|
216
|
+
* @returns The page to carry the command, or null when none of them qualifies.
|
|
173
217
|
*/
|
|
174
|
-
|
|
218
|
+
export function pickCarrierPage(pages) {
|
|
219
|
+
return pages.find(isCarrierPage) ?? null;
|
|
220
|
+
}
|
|
221
|
+
/* 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
|
|
222
|
+
* 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.
|
|
223
|
+
*
|
|
224
|
+
* 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
|
|
225
|
+
* 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
|
|
226
|
+
* while every check agreed, so there is deliberately no way to write one a live browser did not report.
|
|
227
|
+
*/
|
|
228
|
+
const sharedWindowIds = new WeakMap();
|
|
229
|
+
/* 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
|
|
230
|
+
* 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
|
|
231
|
+
* 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
|
|
232
|
+
* catches and answers with the plain create rather than with a tab in the wrong place.
|
|
233
|
+
*/
|
|
234
|
+
const pageWindowIds = new WeakMap();
|
|
235
|
+
// 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
|
|
236
|
+
// options page is territory nothing has measured.
|
|
237
|
+
const EXTENSION_PAGE_PREFIX = "chrome-extension://";
|
|
238
|
+
/**
|
|
239
|
+
* Reads the window a page sits in, answering from the cache once the page has been read.
|
|
240
|
+
* @param page - The page to locate.
|
|
241
|
+
* @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.
|
|
242
|
+
*/
|
|
243
|
+
async function readPageWindow(page) {
|
|
244
|
+
const cached = pageWindowIds.get(page);
|
|
245
|
+
if (cached !== undefined) {
|
|
246
|
+
return cached;
|
|
247
|
+
}
|
|
248
|
+
const windowId = await withCDPSession(page, async (_session, id) => id);
|
|
249
|
+
if (windowId !== undefined) {
|
|
250
|
+
pageWindowIds.set(page, windowId);
|
|
251
|
+
}
|
|
252
|
+
return windowId;
|
|
253
|
+
}
|
|
254
|
+
/**
|
|
255
|
+
* Records which window the shared one is, reading it from a page that sits in it.
|
|
256
|
+
*
|
|
257
|
+
* 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
|
|
258
|
+
* 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.
|
|
259
|
+
* @param browser - The browser whose shared window is being recorded.
|
|
260
|
+
* @param page - A page that sits in that window.
|
|
261
|
+
*/
|
|
262
|
+
export async function noteSharedWindow(browser, page) {
|
|
263
|
+
const windowId = await readPageWindow(page);
|
|
264
|
+
if (windowId === undefined) {
|
|
265
|
+
LOG.debug("browser:lifecycle", "The shared browser window could not be identified, so capture tabs will open wherever Chrome places them.");
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
sharedWindowIds.set(browser, windowId);
|
|
269
|
+
}
|
|
270
|
+
/**
|
|
271
|
+
* 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
|
|
272
|
+
* confirmed to sit in the window this process recorded.
|
|
273
|
+
*
|
|
274
|
+
* 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
|
|
275
|
+
* 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.
|
|
276
|
+
* 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
|
|
277
|
+
* browser's is still better than letting Chrome choose one.
|
|
278
|
+
* @param browser - The browser to resolve a carrier in.
|
|
279
|
+
* @returns The page to evaluate the open on, or null when none of the browser's pages qualifies.
|
|
280
|
+
*/
|
|
281
|
+
export async function resolveSharedWindowCarrier(browser) {
|
|
282
|
+
const sharedWindowId = sharedWindowIds.get(browser);
|
|
283
|
+
const candidates = (await browser.pages()).filter(isCarrierPage);
|
|
284
|
+
for (const page of [...candidates.filter((candidate) => !candidate.url().startsWith(EXTENSION_PAGE_PREFIX)),
|
|
285
|
+
...candidates.filter((candidate) => candidate.url().startsWith(EXTENSION_PAGE_PREFIX))]) {
|
|
286
|
+
if (sharedWindowId === undefined) {
|
|
287
|
+
return page;
|
|
288
|
+
}
|
|
289
|
+
// 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.
|
|
290
|
+
if ((await readPageWindow(page)) === sharedWindowId) {
|
|
291
|
+
return page;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
return null;
|
|
295
|
+
}
|
|
296
|
+
/**
|
|
297
|
+
* Reports whether a page ended up in the shared window.
|
|
298
|
+
*
|
|
299
|
+
* 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
|
|
300
|
+
* 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
|
|
301
|
+
* failure alike, and reading any of those as a confirmation would confirm a wrong-window tab in exactly the case this check exists for.
|
|
302
|
+
* @param page - The page to test.
|
|
303
|
+
* @returns True when the page sits in the recorded window, or when no window was ever recorded.
|
|
304
|
+
*/
|
|
305
|
+
export async function confirmSharedWindowPlacement(page) {
|
|
306
|
+
const sharedWindowId = sharedWindowIds.get(page.browser());
|
|
307
|
+
if (sharedWindowId === undefined) {
|
|
308
|
+
return true;
|
|
309
|
+
}
|
|
310
|
+
return (await readPageWindow(page)) === sharedWindowId;
|
|
311
|
+
}
|
|
175
312
|
// Login mode management. State and functions live in login.ts; re-exported here so existing consumers don't need import path changes. clearLoginState,
|
|
176
313
|
// isLoginModeActive, and setBrowserAccessors are imported above; the first two for internal use, setBrowserAccessors for one-time initialization below.
|
|
177
314
|
export { clearLoginState, isLoginModeActive };
|
|
178
315
|
export { endLoginMode, getLoginPage, getLoginStatus, setLoginModeEndObserver, startLoginMode } from "./login.js";
|
|
179
316
|
// 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.
|
|
181
|
-
// when a launch was abandoned mid-flight by a readiness-loss
|
|
182
|
-
|
|
183
|
-
//
|
|
184
|
-
|
|
185
|
-
|
|
317
|
+
// the supervisor module directly. Each signals a transient "retry me" condition: BrowserUnavailableError while the relaunch governor is cooling,
|
|
318
|
+
// BrowserSupersededError when a launch was abandoned mid-flight by a readiness-loss, and BrowserCaptureImpairedError when the published browser can no longer start
|
|
319
|
+
// 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
|
|
320
|
+
// reads the mark without importing the supervisor.
|
|
321
|
+
export { BrowserCaptureImpairedError, BrowserSupersededError, BrowserUnavailableError } from "./browserSupervisor.js";
|
|
322
|
+
/* The one window-presentation executor for the process lifetime. Its collaborators all live in this module: the registry predicate that says whether capture is
|
|
323
|
+
* reading the compositor, login mode's own flag, the shutdown gate, the two CDP primitives, and a page resolver built on the supervisor.
|
|
324
|
+
*
|
|
325
|
+
* 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
|
|
326
|
+
* 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
|
|
327
|
+
* 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
|
|
328
|
+
* 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
|
|
329
|
+
* preference or as the first page the browser happened to report.
|
|
330
|
+
*
|
|
331
|
+
* 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
|
|
332
|
+
* reference at module-evaluation time: a function declaration is hoisted, so the setBrowserAccessors call further down - and any sibling module whose own body
|
|
333
|
+
* 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
|
|
334
|
+
* would leave those readers in the temporal dead zone, which no placement inside this file can fix for a reader in another file.
|
|
335
|
+
*/
|
|
336
|
+
const windowVisibilitySync = createWindowVisibilitySync({
|
|
337
|
+
hasActiveCaptureStreams,
|
|
338
|
+
isLoginModeActive,
|
|
339
|
+
isShuttingDown: isGracefulShutdown,
|
|
340
|
+
minimize: minimizeWindow,
|
|
341
|
+
resolvePage: async (preferred) => {
|
|
342
|
+
const browser = supervisor.current();
|
|
343
|
+
if (!browser?.connected) {
|
|
344
|
+
return null;
|
|
345
|
+
}
|
|
346
|
+
if (isCarrierPage(preferred)) {
|
|
347
|
+
return { dispose: null, page: preferred };
|
|
348
|
+
}
|
|
349
|
+
const borrowed = pickCarrierPage(await browser.pages());
|
|
350
|
+
if (borrowed) {
|
|
351
|
+
return { dispose: null, page: borrowed };
|
|
352
|
+
}
|
|
353
|
+
// A page that exists only to carry a CDP command never needs its tab selected.
|
|
354
|
+
const temporary = await browser.newPage({ background: true });
|
|
355
|
+
// Registered so stale page cleanup recognizes the page as ours for the moment it exists.
|
|
356
|
+
registerManagedPage(temporary);
|
|
357
|
+
return {
|
|
358
|
+
dispose: async () => {
|
|
359
|
+
unregisterManagedPage(temporary);
|
|
360
|
+
try {
|
|
361
|
+
await temporary.close();
|
|
362
|
+
}
|
|
363
|
+
catch (error) {
|
|
364
|
+
// The browser may already have taken the page with it. Nothing downstream depends on the close succeeding.
|
|
365
|
+
LOG.debug("browser:lifecycle", "Could not close the temporary window-sync page: %s.", formatError(error));
|
|
366
|
+
}
|
|
367
|
+
},
|
|
368
|
+
page: temporary
|
|
369
|
+
};
|
|
370
|
+
},
|
|
371
|
+
unminimize: unminimizeWindow
|
|
372
|
+
});
|
|
373
|
+
/**
|
|
374
|
+
* Brings the shared browser window into agreement with the visibility policy. This is the one entry point every layer uses - streaming, recovery, login,
|
|
375
|
+
* precaching, and startup all call it and none of them decide window state themselves.
|
|
376
|
+
* @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
|
|
377
|
+
* one, when it is omitted or the page has closed.
|
|
378
|
+
* @returns A promise that resolves once a pass that began at or after this call has completed.
|
|
379
|
+
*/
|
|
380
|
+
export async function syncWindowVisibility(page) {
|
|
381
|
+
return windowVisibilitySync(page);
|
|
382
|
+
}
|
|
383
|
+
// Inject browser accessors into the login module. This breaks the circular dependency (login needs getBrowserInstance and the window sync, index needs login
|
|
384
|
+
// functions) using the same setter/getter pattern as setChromeUserAgent in chromeFetch.ts. Both accessors are hoisted function declarations, so this call reads
|
|
385
|
+
// them at module evaluation time regardless of where they sit in the file.
|
|
386
|
+
setBrowserAccessors({ getBrowserInstance, syncWindowVisibility });
|
|
186
387
|
/**
|
|
187
388
|
* Computes the current system status and emits it to SSE subscribers. Called when browser state changes significantly or when streams are added/removed.
|
|
188
389
|
*/
|
|
@@ -203,6 +404,12 @@ export async function emitCurrentSystemStatus() {
|
|
|
203
404
|
const memUsage = process.memoryUsage();
|
|
204
405
|
const status = {
|
|
205
406
|
browser: {
|
|
407
|
+
/* 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
|
|
408
|
+
* 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
|
|
409
|
+
* 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
|
|
410
|
+
* later of two racing emits is also the truer one.
|
|
411
|
+
*/
|
|
412
|
+
captureImpaired: supervisor.captureImpairment() !== null,
|
|
206
413
|
connected: !!browser && browser.connected,
|
|
207
414
|
pageCount
|
|
208
415
|
},
|
|
@@ -225,8 +432,9 @@ export async function emitCurrentSystemStatus() {
|
|
|
225
432
|
* 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
433
|
* issues with Page object reference identity.
|
|
227
434
|
* @param page - The Puppeteer Page to register.
|
|
228
|
-
* @param options - Registration options. Set
|
|
229
|
-
*
|
|
435
|
+
* @param options - Registration options. Set inFlight when an operation owns the page for its duration but nothing the stale page cleanup reads records that
|
|
436
|
+
* 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
|
|
437
|
+
* never closes a page out from under the operation driving it.
|
|
230
438
|
*/
|
|
231
439
|
export function registerManagedPage(page, options = {}) {
|
|
232
440
|
// Generate a unique ID for this page.
|
|
@@ -235,9 +443,9 @@ export function registerManagedPage(page, options = {}) {
|
|
|
235
443
|
pageToId.set(page, pageId);
|
|
236
444
|
// Track the ID as managed.
|
|
237
445
|
managedPageIds.add(pageId);
|
|
238
|
-
// Exempt the page from staleness for as long as
|
|
239
|
-
if (options.
|
|
240
|
-
|
|
446
|
+
// Exempt the page from staleness for as long as the operation that owns it is in flight.
|
|
447
|
+
if (options.inFlight) {
|
|
448
|
+
inFlightPageIds.add(pageId);
|
|
241
449
|
}
|
|
242
450
|
}
|
|
243
451
|
/**
|
|
@@ -251,8 +459,8 @@ export function unregisterManagedPage(page) {
|
|
|
251
459
|
managedPageIds.delete(pageId);
|
|
252
460
|
// Also remove from potentially stale tracking since we're intentionally closing it.
|
|
253
461
|
potentiallyStalePages.delete(pageId);
|
|
254
|
-
// Release any in-flight
|
|
255
|
-
|
|
462
|
+
// Release any in-flight mark. The page is leaving our management, so the exemption it carried has nothing left to protect.
|
|
463
|
+
inFlightPageIds.delete(pageId);
|
|
256
464
|
// Note: We don't delete from pageToId because WeakMap handles cleanup automatically when the Page is garbage collected.
|
|
257
465
|
}
|
|
258
466
|
}
|
|
@@ -274,7 +482,7 @@ function getManagedPageId(page) {
|
|
|
274
482
|
function clearPageTracking() {
|
|
275
483
|
managedPageIds.clear();
|
|
276
484
|
potentiallyStalePages.clear();
|
|
277
|
-
|
|
485
|
+
inFlightPageIds.clear();
|
|
278
486
|
}
|
|
279
487
|
/**
|
|
280
488
|
* Ensures the data directory exists, creating it if necessary. This should be called during application startup before any operations that depend on the data
|
|
@@ -580,6 +788,9 @@ export function getExecutablePath() {
|
|
|
580
788
|
* the full rationale.
|
|
581
789
|
*/
|
|
582
790
|
const PUPPETEER_STREAM_EXTENSION_ID = "jjndjgheafjngoipoacpjgeicjeomjli";
|
|
791
|
+
// 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
|
|
792
|
+
// sentinel from reading as a literal density of zero.
|
|
793
|
+
const NATIVE_DENSITY = 0;
|
|
583
794
|
/**
|
|
584
795
|
* Assembles the configuration options for launching Chrome with Puppeteer. These options are critical for reliable streaming:
|
|
585
796
|
*
|
|
@@ -590,7 +801,6 @@ const PUPPETEER_STREAM_EXTENSION_ID = "jjndjgheafjngoipoacpjgeicjeomjli";
|
|
|
590
801
|
* @returns Puppeteer launch options.
|
|
591
802
|
*/
|
|
592
803
|
export function buildLaunchOptions() {
|
|
593
|
-
const viewport = getPresetViewport(CONFIG);
|
|
594
804
|
return {
|
|
595
805
|
/* Chrome command-line arguments. Each flag serves a specific purpose for reliable streaming:
|
|
596
806
|
*
|
|
@@ -602,7 +812,7 @@ export function buildLaunchOptions() {
|
|
|
602
812
|
* is a defensive duplicate: if a future puppeteer-stream release drops the flag again in favor of granting activeTab via a synthetic keystroke,
|
|
603
813
|
* CDP-synthesized keystrokes do not satisfy chrome.commands under automation (the renderer sees the event but the browser-process accelerator dispatcher
|
|
604
814
|
* 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
|
|
815
|
+
* whether this duplicate is still warranted whenever the locked puppeteer-stream version changes.
|
|
606
816
|
*
|
|
607
817
|
* --autoplay-policy=no-user-gesture-required: Allows video and audio to play without requiring a user click first. Essential for automated streaming
|
|
608
818
|
* since we cannot simulate genuine user interaction for autoplay policy purposes.
|
|
@@ -645,18 +855,30 @@ export function buildLaunchOptions() {
|
|
|
645
855
|
"--hide-scrollbars",
|
|
646
856
|
"--no-first-run"
|
|
647
857
|
],
|
|
648
|
-
/*
|
|
649
|
-
*
|
|
650
|
-
*
|
|
651
|
-
*
|
|
652
|
-
*
|
|
653
|
-
*
|
|
858
|
+
/* 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
|
|
859
|
+
* 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
|
|
860
|
+
* 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
|
|
861
|
+
* 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
|
|
862
|
+
* 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
|
|
863
|
+
* with no default the window's size and placement are Chrome's and the user's.
|
|
864
|
+
*
|
|
865
|
+
* 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
|
|
866
|
+
* than none.
|
|
654
867
|
*/
|
|
655
|
-
defaultViewport:
|
|
868
|
+
defaultViewport: null,
|
|
656
869
|
// Path to the Chrome executable, either from environment variable or autodetected.
|
|
657
870
|
executablePath: getExecutablePath(),
|
|
658
|
-
|
|
659
|
-
|
|
871
|
+
/* PrismCast owns process signals, so the launcher's own listeners are off. Left on, the puppeteer launcher installs handlers for SIGHUP, SIGINT, and SIGTERM
|
|
872
|
+
* 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
|
|
873
|
+
* 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
|
|
874
|
+
* 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
|
|
875
|
+
* own handler, the graceful path runs, and Chrome exits through the ladder with its pending writes committed.
|
|
876
|
+
*/
|
|
877
|
+
handleSIGHUP: false,
|
|
878
|
+
handleSIGINT: false,
|
|
879
|
+
handleSIGTERM: false,
|
|
880
|
+
// Run Chrome in headed (visible) mode, not headless. The puppeteer-stream extension captures the compositor's output for a real window, which a headless
|
|
881
|
+
// browser does not have. The window is on screen while any capture stream runs or a login session is active, and minimized otherwise.
|
|
660
882
|
headless: false,
|
|
661
883
|
/* Prevent Puppeteer from adding certain default arguments that would interfere with streaming:
|
|
662
884
|
*
|
|
@@ -692,6 +914,212 @@ export function buildLaunchOptions() {
|
|
|
692
914
|
userDataDir: getChromeDataDir(CONFIG)
|
|
693
915
|
};
|
|
694
916
|
}
|
|
917
|
+
/**
|
|
918
|
+
* 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
|
|
919
|
+
* built here, so the preset reaches a page's emulation through exactly one read and one command.
|
|
920
|
+
* @param page - The page to declare the surface on.
|
|
921
|
+
* @param deviceScaleFactor - The pixel density to declare. NATIVE_DENSITY leaves the display's own density in effect.
|
|
922
|
+
* @returns The dimensions declared on the page.
|
|
923
|
+
*/
|
|
924
|
+
async function declareSurface(page, deviceScaleFactor) {
|
|
925
|
+
const viewport = getPresetViewport(CONFIG);
|
|
926
|
+
await page.setViewport({ deviceScaleFactor, height: viewport.height, width: viewport.width });
|
|
927
|
+
return { height: viewport.height, width: viewport.width };
|
|
928
|
+
}
|
|
929
|
+
/**
|
|
930
|
+
* 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
|
|
931
|
+
* actually has. Chrome composes an active tab's capture from the window presentation unless the page's device-metrics override declares a density explicitly
|
|
932
|
+
* 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 -
|
|
933
|
+
* 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
|
|
934
|
+
* emulated rather than reading the preset a second time.
|
|
935
|
+
* @param page - The page to emulate the capture surface on.
|
|
936
|
+
* @returns The dimensions declared on the page.
|
|
937
|
+
*/
|
|
938
|
+
export async function emulateCaptureSurface(page) {
|
|
939
|
+
const reportedDensity = await evaluateWithAbort(page, () => window.devicePixelRatio);
|
|
940
|
+
// 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
|
|
941
|
+
// unscaled display, which is the safest thing to declare when the page cannot say.
|
|
942
|
+
const densityIsUsable = Number.isFinite(reportedDensity) && (reportedDensity > 0);
|
|
943
|
+
const deviceScaleFactor = densityIsUsable ? reportedDensity : 1;
|
|
944
|
+
if (!densityIsUsable) {
|
|
945
|
+
LOG.warn("The capture page reported a pixel density of %s. Emulating the capture surface at a density of %d instead.", reportedDensity, deviceScaleFactor);
|
|
946
|
+
}
|
|
947
|
+
const surface = await declareSurface(page, deviceScaleFactor);
|
|
948
|
+
LOG.debug("browser:lifecycle", "Emulated the capture surface at %dx%d with a pixel density of %s.", surface.width, surface.height, deviceScaleFactor);
|
|
949
|
+
return surface;
|
|
950
|
+
}
|
|
951
|
+
/**
|
|
952
|
+
* 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
|
|
953
|
+
* 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
|
|
954
|
+
* validated on. The density stays the display's, because nothing reads this page's pixels.
|
|
955
|
+
* @param page - The page to emulate the layout surface on.
|
|
956
|
+
* @returns The dimensions declared on the page.
|
|
957
|
+
*/
|
|
958
|
+
export async function emulateLayoutSurface(page) {
|
|
959
|
+
return declareSurface(page, NATIVE_DENSITY);
|
|
960
|
+
}
|
|
961
|
+
/**
|
|
962
|
+
* 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
|
|
963
|
+
* 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
|
|
964
|
+
* 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
|
|
965
|
+
* 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.
|
|
966
|
+
* @param placement - The placement of the window to mirror.
|
|
967
|
+
* @returns The bounds to create the new window with.
|
|
968
|
+
*/
|
|
969
|
+
export function mirrorPlacement(placement) {
|
|
970
|
+
const { height, left, top, width } = placement;
|
|
971
|
+
return (placement.windowState === "maximized") ? { height, left, top, width, windowState: "maximized" } : { height, left, top, width };
|
|
972
|
+
}
|
|
973
|
+
/**
|
|
974
|
+
* Opens a page for a channel guide discovery walk in a browser window of its own, and marks it as belonging to that window.
|
|
975
|
+
*
|
|
976
|
+
* A document renders only while it is the active tab of a window that is not minimized, and the shared window rests minimized whenever nothing is capturing and
|
|
977
|
+
* no sign-in holds it on screen, with its selected tab belonging to the user. A guide walk needs its page to render - an observer-driven channel rail fills its
|
|
978
|
+
* tiles from rendering updates, and a virtualized grid re-renders as the walk scrolls it - yet it is never captured, so it has no claim on the shared window's
|
|
979
|
+
* presentation and no business moving the user's selection. Its own window resolves both: the page is the active tab there from the moment it exists.
|
|
980
|
+
*
|
|
981
|
+
* The window is created in the background, so Chrome shows it inactive and moves no focus (measured 2026-08-31), and at the shared window's own placement, so
|
|
982
|
+
* the window placement Chrome persists for the profile never changes - readWindowPlacement carries the reasoning. The caller declares the layout surface on
|
|
983
|
+
* the page and owns its registration, and closing the page closes the window with it.
|
|
984
|
+
* @param browser - The browser to open the window in.
|
|
985
|
+
* @returns The page, as the active tab of its own window.
|
|
986
|
+
*/
|
|
987
|
+
export async function createDiscoveryPage(browser) {
|
|
988
|
+
const carrier = pickCarrierPage(await browser.pages());
|
|
989
|
+
const placement = carrier ? await readWindowPlacement(carrier) : null;
|
|
990
|
+
// 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.
|
|
991
|
+
const page = await browser.newPage({ background: true, type: "window", windowBounds: placement ? mirrorPlacement(placement) : undefined });
|
|
992
|
+
ownWindowPages.add(page);
|
|
993
|
+
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");
|
|
994
|
+
return page;
|
|
995
|
+
}
|
|
996
|
+
/* 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
|
|
997
|
+
* 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
|
|
998
|
+
* 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
|
|
999
|
+
* 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
|
|
1000
|
+
* 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
|
|
1001
|
+
* monitor's periodic re-affirmation remains the backstop bounding anything the whole ladder misses.
|
|
1002
|
+
*/
|
|
1003
|
+
const ACTIVATION_REAFFIRM_LADDER_MS = [250, 750, 1500, 3000];
|
|
1004
|
+
/* 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
|
|
1005
|
+
* 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
|
|
1006
|
+
* binding invokes, so the two triggers share one generation counter and an activation arriving through either route supersedes an in-flight ladder rather than
|
|
1007
|
+
* running a second one beside it.
|
|
1008
|
+
*/
|
|
1009
|
+
const activationHeals = new WeakMap();
|
|
1010
|
+
/**
|
|
1011
|
+
* 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
|
|
1012
|
+
* 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
|
|
1013
|
+
* reach the far side of the switch, so later shots have to.
|
|
1014
|
+
*
|
|
1015
|
+
* Invocations supersede rather than accumulate. A generation counter makes the newest invocation the owner of the rung schedule: the duplicate focus events a
|
|
1016
|
+
* single activation fires collapse into one schedule a few milliseconds newer than the first, while an activation that genuinely arrives later - a second
|
|
1017
|
+
* 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.
|
|
1018
|
+
* 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.
|
|
1019
|
+
*
|
|
1020
|
+
* 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
|
|
1021
|
+
* 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
|
|
1022
|
+
* invocation is itself the invalidation - and the schedule holds no timers, only awaited sleeps.
|
|
1023
|
+
*
|
|
1024
|
+
* 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
|
|
1025
|
+
* stream is closing - and the periodic re-affirmation corrects anything a lost re-issue leaves behind.
|
|
1026
|
+
* @param page - The capture page this callback re-affirms.
|
|
1027
|
+
* @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
|
|
1028
|
+
* it was built for, what schedule it keeps, and what it does with a rejection, is the whole of its contract.
|
|
1029
|
+
* @param clock - The time source the ladder's waits run on. Defaults to realClock.
|
|
1030
|
+
* @returns The callback, which never rejects. Its promise settles once the ladder is spent, which the page-side listener does not wait on.
|
|
1031
|
+
*/
|
|
1032
|
+
export function makeFocusReaffirmCallback(page, reaffirm, clock = realClock) {
|
|
1033
|
+
// 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
|
|
1034
|
+
// a higher one.
|
|
1035
|
+
let generation = 0;
|
|
1036
|
+
// 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.
|
|
1037
|
+
const fireShot = async (offsetMs) => {
|
|
1038
|
+
try {
|
|
1039
|
+
await reaffirm(page);
|
|
1040
|
+
}
|
|
1041
|
+
catch (error) {
|
|
1042
|
+
LOG.debug("browser:lifecycle", "Could not re-affirm the capture surface after a tab activation (+%sms): %s.", offsetMs, formatError(error));
|
|
1043
|
+
}
|
|
1044
|
+
};
|
|
1045
|
+
return async () => {
|
|
1046
|
+
const mine = ++generation;
|
|
1047
|
+
await fireShot(0);
|
|
1048
|
+
let previousOffsetMs = 0;
|
|
1049
|
+
for (const offsetMs of ACTIVATION_REAFFIRM_LADDER_MS) {
|
|
1050
|
+
// 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.
|
|
1051
|
+
await clock.sleep(offsetMs - previousOffsetMs);
|
|
1052
|
+
// 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.
|
|
1053
|
+
if (generation !== mine) {
|
|
1054
|
+
return;
|
|
1055
|
+
}
|
|
1056
|
+
// 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.
|
|
1057
|
+
await fireShot(offsetMs);
|
|
1058
|
+
previousOffsetMs = offsetMs;
|
|
1059
|
+
}
|
|
1060
|
+
};
|
|
1061
|
+
}
|
|
1062
|
+
// The production collaborators.
|
|
1063
|
+
const defaultActivationHealDeps = { clock: realClock, reaffirm: reaffirmCaptureSurface };
|
|
1064
|
+
/**
|
|
1065
|
+
* 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
|
|
1066
|
+
* 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
|
|
1067
|
+
* the emulated surface about a second later.
|
|
1068
|
+
*
|
|
1069
|
+
* 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
|
|
1070
|
+
* arrives with a user's click focusing the browser window. The enrollment in activationHeals is fired by the selection primitive's activation report, which
|
|
1071
|
+
* 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
|
|
1072
|
+
* 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).
|
|
1073
|
+
* Both triggers hold the same callback instance, which is what lets them share its generation counter instead of running two schedules against one page.
|
|
1074
|
+
*
|
|
1075
|
+
* The listener is registered through evaluateOnNewDocument so it survives the page's navigations, exactly as the shared video-selector helper does, and the
|
|
1076
|
+
* binding survives them on Puppeteer's own terms.
|
|
1077
|
+
* @param page - The capture page to install the heal on.
|
|
1078
|
+
* @param deps - The clock and the re-issue the ladder runs on. Defaults to the production pair.
|
|
1079
|
+
*/
|
|
1080
|
+
export async function installActivationHeal(page, deps = defaultActivationHealDeps) {
|
|
1081
|
+
const callback = makeFocusReaffirmCallback(page, deps.reaffirm, deps.clock);
|
|
1082
|
+
// 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
|
|
1083
|
+
// down with it, and the entry goes when the page does.
|
|
1084
|
+
activationHeals.set(page, callback);
|
|
1085
|
+
await page.exposeFunction("__prismcastReaffirmSurface", callback);
|
|
1086
|
+
await page.evaluateOnNewDocument(() => {
|
|
1087
|
+
window.addEventListener("focus", () => {
|
|
1088
|
+
void window.__prismcastReaffirmSurface?.();
|
|
1089
|
+
});
|
|
1090
|
+
});
|
|
1091
|
+
}
|
|
1092
|
+
/**
|
|
1093
|
+
* Heals the capture page showing in a tab the selection primitive has just activated.
|
|
1094
|
+
*
|
|
1095
|
+
* 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
|
|
1096
|
+
* 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
|
|
1097
|
+
* no capture at all, a page registered before its setup reached the install point - and every one of them is a quiet no-op.
|
|
1098
|
+
* @param tabId - The tab the selection primitive activated.
|
|
1099
|
+
* @param deps - The registry read and the page-to-tab lookup the match runs through. Defaults to the production pair.
|
|
1100
|
+
*/
|
|
1101
|
+
export function healActivatedCaptureTab(tabId, deps = { getAllStreams, getCachedTabId }) {
|
|
1102
|
+
for (const entry of deps.getAllStreams()) {
|
|
1103
|
+
// 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
|
|
1104
|
+
// 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
|
|
1105
|
+
// of the lookup that needs a page to ask about.
|
|
1106
|
+
if (!isCaptureIdentity(entry) || (entry.page === null)) {
|
|
1107
|
+
continue;
|
|
1108
|
+
}
|
|
1109
|
+
if (deps.getCachedTabId(entry.page) !== tabId) {
|
|
1110
|
+
continue;
|
|
1111
|
+
}
|
|
1112
|
+
// 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.
|
|
1113
|
+
void activationHeals.get(entry.page)?.();
|
|
1114
|
+
}
|
|
1115
|
+
}
|
|
1116
|
+
/* The extension-side trigger, subscribed once for the life of the process. Every activation PrismCast performs goes through the selection primitive and is
|
|
1117
|
+
* 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
|
|
1118
|
+
* primitive does not perform, a user's click that focuses the window. A browser relaunch changes neither module, so nothing here is re-subscribed.
|
|
1119
|
+
*/
|
|
1120
|
+
onTabActivation((tabId) => {
|
|
1121
|
+
healActivatedCaptureTab(tabId);
|
|
1122
|
+
});
|
|
695
1123
|
/**
|
|
696
1124
|
* Custom launch function that modifies Chrome arguments when running as a packaged executable. The packaged version cannot load extensions from node_modules
|
|
697
1125
|
* (which is bundled inside the executable), so we point the extension paths to our extracted extension files in the data directory.
|
|
@@ -736,28 +1164,8 @@ function formatGpuSuffix(gpu) {
|
|
|
736
1164
|
return " (software rendering)";
|
|
737
1165
|
}
|
|
738
1166
|
/**
|
|
739
|
-
*
|
|
740
|
-
*
|
|
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.
|
|
1167
|
+
* Probes the browser's GPU hardware-encoding capabilities. It queries CDP SystemInfo.getInfo for renderer identity and H.264/HEVC/AV1 hardware encoding support,
|
|
1168
|
+
* falls back to a MediaRecorder capability probe where the CDP data is incomplete, and caches the result for codec selection to read.
|
|
761
1169
|
*
|
|
762
1170
|
* The probe runs against an existing page where one is open, and a temporary page otherwise.
|
|
763
1171
|
* @param browser - The browser instance to use for detection.
|
|
@@ -765,17 +1173,16 @@ export function getUndersizedDisplayBounds(bounds, captureSurface) {
|
|
|
765
1173
|
async function detectBrowserCapabilities(browser) {
|
|
766
1174
|
let tempPage = null;
|
|
767
1175
|
try {
|
|
768
|
-
// Try to use an existing page first to avoid window activation issues on macOS.
|
|
769
|
-
|
|
770
|
-
let targetPage =
|
|
1176
|
+
// 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
|
|
1177
|
+
// 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.
|
|
1178
|
+
let targetPage = pickCarrierPage(await browser.pages());
|
|
771
1179
|
if (!targetPage) {
|
|
772
|
-
|
|
1180
|
+
// A probe page never needs its tab selected either.
|
|
1181
|
+
tempPage = await browser.newPage({ background: true });
|
|
773
1182
|
targetPage = tempPage;
|
|
774
1183
|
}
|
|
775
1184
|
/* 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
|
|
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.
|
|
1185
|
+
* restart the window can come up minimized, and the GPU probe wants an environment representative of the one capture runs in.
|
|
779
1186
|
*/
|
|
780
1187
|
await unminimizeWindow(targetPage);
|
|
781
1188
|
// 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 +1257,6 @@ async function detectBrowserCapabilities(browser) {
|
|
|
850
1257
|
catch (gpuError) {
|
|
851
1258
|
LOG.debug("browser:lifecycle", "GPU detection failed: %s.", String(gpuError));
|
|
852
1259
|
}
|
|
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
1260
|
}
|
|
867
1261
|
catch (error) {
|
|
868
1262
|
LOG.warn("Browser capability detection failed: %s. Hardware encoding capabilities are unknown for this session.", formatError(error));
|
|
@@ -881,10 +1275,11 @@ async function detectBrowserCapabilities(browser) {
|
|
|
881
1275
|
}
|
|
882
1276
|
/**
|
|
883
1277
|
* 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"
|
|
1278
|
+
* 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
1279
|
* 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
1280
|
* 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.
|
|
1281
|
+
* 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
|
|
1282
|
+
* takes the far gentler noteBrowserCaptureImpaired path instead, which keeps its running captures.
|
|
888
1283
|
* @param streamTerminationReason - The reason recorded against each terminated stream, for the stream-end logs.
|
|
889
1284
|
*/
|
|
890
1285
|
function relinquishBrowserReadiness(streamTerminationReason) {
|
|
@@ -895,10 +1290,7 @@ function relinquishBrowserReadiness(streamTerminationReason) {
|
|
|
895
1290
|
currentChromeVersion = null;
|
|
896
1291
|
setChromeUserAgent(null);
|
|
897
1292
|
// Cancel any pending scheduled-restart quiet timer since the browser this readiness applied to is gone.
|
|
898
|
-
|
|
899
|
-
clearTimeout(restartQuietTimer);
|
|
900
|
-
restartQuietTimer = null;
|
|
901
|
-
}
|
|
1293
|
+
cancelRestartQuietTimer();
|
|
902
1294
|
// Clear all channel selection caches. Cached state (guide row positions, discovered page URLs) belongs to the old browser session.
|
|
903
1295
|
clearChannelSelectionCaches();
|
|
904
1296
|
// 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
|
|
@@ -933,43 +1325,40 @@ function handleBrowserDisconnect() {
|
|
|
933
1325
|
relinquishBrowserReadiness("browser disconnect");
|
|
934
1326
|
}
|
|
935
1327
|
/**
|
|
936
|
-
*
|
|
937
|
-
* single recovery action
|
|
938
|
-
*
|
|
939
|
-
*
|
|
940
|
-
* browser cannot capture. noteReadinessLost stays internal: callers signal intent through this function, never the supervisor directly.
|
|
1328
|
+
* Records that a still-connected browser can no longer start captures - a mid-life capture death that no "disconnected" event would surface - and schedules the
|
|
1329
|
+
* 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
|
|
1330
|
+
* running captures continue untouched, new stream requests are refused at acquire() with a 503 back-off, the recovery ladder stops offering tab replacement, and
|
|
1331
|
+
* the relaunch waits for the registry to empty. Exported for the streaming layer to call once its probe or its wedge has produced the verdict.
|
|
941
1332
|
*
|
|
942
|
-
* The
|
|
943
|
-
*
|
|
944
|
-
*
|
|
945
|
-
* @param browser - The specific browser instance the caller verified as unable to
|
|
946
|
-
* @param reason - A short description of
|
|
947
|
-
*/
|
|
948
|
-
export
|
|
949
|
-
//
|
|
950
|
-
//
|
|
951
|
-
if (supervisor.
|
|
1333
|
+
* The restart trigger uses this function's return value rather than the supervisor's transition observer, deliberately. The observer runs inside transition(), so a
|
|
1334
|
+
* restart begun there would call noteReadinessLost re-entrantly while the marking transition's notification is still on the stack. The observer stays a reporter -
|
|
1335
|
+
* the alarm and the status emit - and the caller that holds the verdict acts on it once the transition has completed.
|
|
1336
|
+
* @param browser - The specific browser instance the caller verified as unable to start captures.
|
|
1337
|
+
* @param reason - A short description of the evidence behind the verdict, carried in the alarm log and the impairment record.
|
|
1338
|
+
*/
|
|
1339
|
+
export function noteBrowserCaptureImpaired(browser, reason) {
|
|
1340
|
+
// 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
|
|
1341
|
+
// already carries a mark whose alarm and status emit have already fired. Either way there is no new state to act on.
|
|
1342
|
+
if (!supervisor.noteCaptureImpaired(browser, reason)) {
|
|
952
1343
|
return;
|
|
953
1344
|
}
|
|
954
|
-
|
|
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;
|
|
1345
|
+
restartBrowserIfImpairedAndIdle();
|
|
961
1346
|
}
|
|
962
1347
|
/**
|
|
963
1348
|
* 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
1349
|
* 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
1350
|
* lazily launches when absent, or - while the relaunch governor is cooling after repeated failures - rejects fast with a BrowserUnavailableError WITHOUT spawning
|
|
966
1351
|
* Chrome (the loop bound). The launch it drives runs the readiness gate, so a returned browser is verified capture-ready, not merely connected.
|
|
1352
|
+
*
|
|
1353
|
+
* 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
|
|
1354
|
+
* operation the browser can no longer perform, while a "page" caller - precaching, the startup warm-up, the relaunch itself - is served as usual.
|
|
1355
|
+
* @param purpose - Whether the caller goes on to start a capture or only needs a page to open.
|
|
967
1356
|
* @returns The capture-ready browser instance.
|
|
968
|
-
* @throws BrowserUnavailableError while the governor is cooling,
|
|
969
|
-
* launch error when a launch attempt fails.
|
|
1357
|
+
* @throws BrowserUnavailableError while the governor is cooling, BrowserCaptureImpairedError when a capture purpose meets a browser that can no longer start
|
|
1358
|
+
* captures, BrowserSupersededError if an in-flight launch was abandoned by a readiness-loss, or the underlying launch error when a launch attempt fails.
|
|
970
1359
|
*/
|
|
971
|
-
export async function getCurrentBrowser() {
|
|
972
|
-
return supervisor.acquire();
|
|
1360
|
+
export async function getCurrentBrowser(purpose) {
|
|
1361
|
+
return supervisor.acquire(purpose);
|
|
973
1362
|
}
|
|
974
1363
|
/**
|
|
975
1364
|
* The supervisor's `launch` port: spawns Chrome, runs the readiness gate, performs post-launch initialization (display detection, version/UA capture, precaching),
|
|
@@ -993,21 +1382,31 @@ async function launchReadyBrowser() {
|
|
|
993
1382
|
// Readiness gate, handshake tier (cheap, on-suspicion). Poll for the puppeteer-stream extension to finish initializing - it injects a START_RECORDING function
|
|
994
1383
|
// 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
1384
|
// 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
|
|
1385
|
+
// 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
1386
|
// 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
1387
|
// 500 the client would not back off from - an unregistered extension is a capture-infrastructure fault, and a fresh relaunch usually clears it.
|
|
999
1388
|
try {
|
|
1000
1389
|
const extensionPage = await getExtensionPage(browser);
|
|
1001
|
-
await extensionPage.waitForFunction(
|
|
1390
|
+
await extensionPage.waitForFunction(EXTENSION_READY_EXPRESSION, { timeout: CONFIG.browser.initTimeout });
|
|
1002
1391
|
}
|
|
1003
1392
|
catch (handshakeError) {
|
|
1004
1393
|
throw new Error("The capture extension handshake timed out after " + String(CONFIG.browser.initTimeout) + " ms.", { cause: handshakeError });
|
|
1005
1394
|
}
|
|
1006
1395
|
LOG.debug("timing:browser", "Extension initialized. (+%sms)", browserElapsed());
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1396
|
+
/* 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
|
|
1397
|
+
* 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
|
|
1398
|
+
* 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
|
|
1399
|
+
* closing it costs nothing.
|
|
1400
|
+
*
|
|
1401
|
+
* 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,
|
|
1402
|
+
* the capability detection, or any stream - is what lets every later capture tab be anchored to the window rather than placed by Chrome.
|
|
1403
|
+
*/
|
|
1404
|
+
const restingTab = await browser.newPage();
|
|
1405
|
+
await noteSharedWindow(browser, restingTab);
|
|
1406
|
+
// Readiness gate, capability tier (the authoritative arbiter). Run the injected capture probe - a real capture acquisition against a throwaway page on THIS
|
|
1407
|
+
// instance - so "ready" means "really captured," not merely "the extension handshake responded." This predicate must run at every (re)launch:
|
|
1408
|
+
// it exercises the exact acquisition path that fails when the extension is unregistered. A probe failure throws, so the supervisor counts the launch failure
|
|
1409
|
+
// and the browser is never published.
|
|
1011
1410
|
//
|
|
1012
1411
|
// 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
1412
|
// we reject the launch rather than publish a handshake-only browser: serving an unverified browser would be the "proceed and hope" path this design
|
|
@@ -1034,6 +1433,11 @@ async function launchReadyBrowser() {
|
|
|
1034
1433
|
// Start background precaching of selected service channel lineups. Fire-and-forget - the setTimeout inside startPrecaching() defers the work until after this
|
|
1035
1434
|
// launch settles and the supervisor has published the ready browser, so its getCurrentBrowser() resolves immediately rather than re-entering this launch.
|
|
1036
1435
|
startPrecaching();
|
|
1436
|
+
/* 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
|
|
1437
|
+
* 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
|
|
1438
|
+
* publication, for the reason its own comment gives.
|
|
1439
|
+
*/
|
|
1440
|
+
installStrayOpenTabReaper(browser);
|
|
1037
1441
|
// 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
1442
|
// 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
1443
|
// 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 +1473,15 @@ export function getChromeVersion() {
|
|
|
1069
1473
|
export function getBrowserInstance() {
|
|
1070
1474
|
return supervisor.current();
|
|
1071
1475
|
}
|
|
1476
|
+
/**
|
|
1477
|
+
* Returns the impairment recorded on the published browser, or null when nothing is published or the published browser can still start captures. Like
|
|
1478
|
+
* getBrowserInstance this does not launch, so callers that only need to know whether captures can be started - the recovery ladder, the status composition, the
|
|
1479
|
+
* health endpoint - read it without touching the lifecycle.
|
|
1480
|
+
* @returns The impairment record, or null.
|
|
1481
|
+
*/
|
|
1482
|
+
export function getCaptureImpairment() {
|
|
1483
|
+
return supervisor.captureImpairment();
|
|
1484
|
+
}
|
|
1072
1485
|
/**
|
|
1073
1486
|
* Checks if the browser is currently connected and usable. This is a synchronous check that can be used before attempting browser operations.
|
|
1074
1487
|
* @returns True if the browser is connected and ready for use, false otherwise.
|
|
@@ -1077,54 +1490,6 @@ export function isBrowserConnected() {
|
|
|
1077
1490
|
const browser = supervisor.current();
|
|
1078
1491
|
return !!browser && browser.connected;
|
|
1079
1492
|
}
|
|
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
1493
|
/**
|
|
1129
1494
|
* Gets all open browser pages (tabs). This is used by the health check endpoint to report page count.
|
|
1130
1495
|
* @returns Array of pages, or empty array if the browser is not connected.
|
|
@@ -1241,8 +1606,8 @@ export async function closeBrowser() {
|
|
|
1241
1606
|
*
|
|
1242
1607
|
* 4. Minimum page preservation: We always keep at least one page open to prevent Chrome from exiting.
|
|
1243
1608
|
*
|
|
1244
|
-
* 5. In-flight
|
|
1245
|
-
*
|
|
1609
|
+
* 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
|
|
1610
|
+
* 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
1611
|
*
|
|
1247
1612
|
* 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
1613
|
* I/O shell around that decision: it reads Chrome's page list, applies the decision to the tracking collections, and performs the closes.
|
|
@@ -1253,7 +1618,7 @@ export async function closeBrowser() {
|
|
|
1253
1618
|
*
|
|
1254
1619
|
* The cleanup uses a multi-stage filtering process:
|
|
1255
1620
|
* 1. Only consider pages we created (in managedPageIds)
|
|
1256
|
-
* 2. Exclude pages associated with active streams, and pages
|
|
1621
|
+
* 2. Exclude pages associated with active streams, and pages an operation still holds in flight
|
|
1257
1622
|
* 3. Apply a grace period before closing (to handle race conditions)
|
|
1258
1623
|
* 4. Preserve at least one page to keep the browser alive
|
|
1259
1624
|
*/
|
|
@@ -1292,7 +1657,7 @@ export async function cleanupStalePages() {
|
|
|
1292
1657
|
}
|
|
1293
1658
|
}
|
|
1294
1659
|
// 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,
|
|
1660
|
+
const actions = evaluateStalePages({ activePageIds, gracePeriodMs: CONFIG.recovery.stalePageGracePeriod, inFlightPageIds, now, pageIds,
|
|
1296
1661
|
staleFirstSeen: potentiallyStalePages });
|
|
1297
1662
|
// 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
1663
|
for (const pageId of actions.forgetTrackedIds) {
|
|
@@ -1302,7 +1667,7 @@ export async function cleanupStalePages() {
|
|
|
1302
1667
|
potentiallyStalePages.set(pageId, now);
|
|
1303
1668
|
}
|
|
1304
1669
|
for (const pageId of actions.clearInFlightIds) {
|
|
1305
|
-
|
|
1670
|
+
inFlightPageIds.delete(pageId);
|
|
1306
1671
|
}
|
|
1307
1672
|
let closedCount = 0;
|
|
1308
1673
|
for (const pageId of actions.closeIds) {
|
|
@@ -1350,15 +1715,39 @@ export function stopStalePageCleanup() {
|
|
|
1350
1715
|
stalePageCleanupInterval = null;
|
|
1351
1716
|
}
|
|
1352
1717
|
}
|
|
1353
|
-
/*
|
|
1354
|
-
*
|
|
1355
|
-
*
|
|
1718
|
+
/* Browser restart functions. One routine performs the restart; what differs is the cause that reaches it.
|
|
1719
|
+
*
|
|
1720
|
+
* The maintenance cause is opportunistic and age-driven: the check runs on a 30-second interval and, when the browser exceeds BROWSER_MAX_AGE with zero active
|
|
1721
|
+
* streams, starts a quiet period timer. The quiet timer is cancelled if a stream starts, ensuring active viewers are never disrupted. When the timer expires, the
|
|
1722
|
+
* browser is closed and immediately re-launched.
|
|
1723
|
+
*
|
|
1724
|
+
* The impairment cause is a repair rather than hygiene: a browser that can no longer start captures is unusable for new tunes no matter how young it is, so age and
|
|
1725
|
+
* the quiet period do not apply to it. It relaunches the moment the registry empties, which the mark itself, every stream end, and the periodic tick each check
|
|
1726
|
+
* for.
|
|
1356
1727
|
*/
|
|
1357
1728
|
/**
|
|
1358
|
-
*
|
|
1359
|
-
*
|
|
1360
|
-
*
|
|
1361
|
-
*
|
|
1729
|
+
* 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,
|
|
1730
|
+
* 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
|
|
1731
|
+
* 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
|
|
1732
|
+
* captures are still worth finishing, so the earliest safe moment is exactly the moment the last of them ends.
|
|
1733
|
+
*/
|
|
1734
|
+
export function restartBrowserIfImpairedAndIdle() {
|
|
1735
|
+
if (supervisor.captureImpairment() === null) {
|
|
1736
|
+
return;
|
|
1737
|
+
}
|
|
1738
|
+
// 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
|
|
1739
|
+
// mark - whether or not the registry is idle yet - rather than being left to fire against the browser the relaunch will have replaced.
|
|
1740
|
+
cancelRestartQuietTimer();
|
|
1741
|
+
if (getStreamCount() > 0) {
|
|
1742
|
+
return;
|
|
1743
|
+
}
|
|
1744
|
+
void executeBrowserRestart("impairment");
|
|
1745
|
+
}
|
|
1746
|
+
/**
|
|
1747
|
+
* Checks whether the browser qualifies for a restart. Called periodically by the restart check interval. The check skips when any of these conditions hold:
|
|
1748
|
+
* 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
|
|
1749
|
+
* 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
|
|
1750
|
+
* active streams exist (streams started during the quiet period reset the countdown), and otherwise start a quiet timer if one is not already running.
|
|
1362
1751
|
*/
|
|
1363
1752
|
function checkBrowserRestart() {
|
|
1364
1753
|
// Skip if the server is shutting down or login mode is active.
|
|
@@ -1372,6 +1761,15 @@ function checkBrowserRestart() {
|
|
|
1372
1761
|
if (!browser?.connected || (launchTime === null)) {
|
|
1373
1762
|
return;
|
|
1374
1763
|
}
|
|
1764
|
+
/* 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
|
|
1765
|
+
* 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
|
|
1766
|
+
* 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
|
|
1767
|
+
* all. The quiet period this tick would otherwise cancel below is retired inside the call, so the early return leaves no timer unobserved.
|
|
1768
|
+
*/
|
|
1769
|
+
if (supervisor.captureImpairment() !== null) {
|
|
1770
|
+
restartBrowserIfImpairedAndIdle();
|
|
1771
|
+
return;
|
|
1772
|
+
}
|
|
1375
1773
|
// 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
1774
|
// hold, this resets the relaunch governor to its normal state and returns true, so we log the recovery exactly once.
|
|
1377
1775
|
if (supervisor.noteSustainedHealth()) {
|
|
@@ -1386,26 +1784,28 @@ function checkBrowserRestart() {
|
|
|
1386
1784
|
if (getStreamCount() > 0) {
|
|
1387
1785
|
if (restartQuietTimer) {
|
|
1388
1786
|
LOG.debug("browser:lifecycle", "Browser restart quiet period cancelled - streams are active.");
|
|
1389
|
-
clearTimeout(restartQuietTimer);
|
|
1390
|
-
restartQuietTimer = null;
|
|
1391
1787
|
}
|
|
1788
|
+
cancelRestartQuietTimer();
|
|
1392
1789
|
return;
|
|
1393
1790
|
}
|
|
1394
1791
|
// No active streams and the browser is old enough. Start the quiet timer if one is not already running.
|
|
1395
1792
|
if (!restartQuietTimer) {
|
|
1396
1793
|
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
1794
|
restartQuietTimer = setTimeout(() => {
|
|
1398
|
-
void executeBrowserRestart();
|
|
1795
|
+
void executeBrowserRestart("maintenance");
|
|
1399
1796
|
}, BROWSER_RESTART_QUIET_PERIOD);
|
|
1400
1797
|
}
|
|
1401
1798
|
}
|
|
1402
1799
|
/**
|
|
1403
|
-
* Executes
|
|
1404
|
-
*
|
|
1800
|
+
* 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
|
|
1801
|
+
* that whole sequence; only the log line that announces the restart differs, since only the cause knows why the browser is being replaced.
|
|
1802
|
+
* @param cause - Why the restart is running, for the announcement.
|
|
1405
1803
|
*/
|
|
1406
|
-
async function executeBrowserRestart() {
|
|
1407
|
-
//
|
|
1408
|
-
|
|
1804
|
+
async function executeBrowserRestart(cause) {
|
|
1805
|
+
// 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
|
|
1806
|
+
// 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
|
|
1807
|
+
// restart the fresh instance a second time.
|
|
1808
|
+
cancelRestartQuietTimer();
|
|
1409
1809
|
// 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
1810
|
// was activated, or the browser disconnected on its own). Reading current()/currentLaunchTime() together keeps the ready-state check and the age source consistent.
|
|
1411
1811
|
const browser = supervisor.current();
|
|
@@ -1417,7 +1817,12 @@ async function executeBrowserRestart() {
|
|
|
1417
1817
|
const age = realClock.now() - launchTime;
|
|
1418
1818
|
const hours = Math.floor(age / 3600000);
|
|
1419
1819
|
const minutes = Math.floor((age % 3600000) / 60000);
|
|
1420
|
-
|
|
1820
|
+
if (cause === "impairment") {
|
|
1821
|
+
LOG.info("Restarting the browser because it can no longer start captures (uptime: %sh %sm).", hours, minutes);
|
|
1822
|
+
}
|
|
1823
|
+
else {
|
|
1824
|
+
LOG.info("Restarting browser for scheduled maintenance (uptime: %sh %sm).", hours, minutes);
|
|
1825
|
+
}
|
|
1421
1826
|
try {
|
|
1422
1827
|
// 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
1828
|
// server is not shutting down): closeBrowserInstance removes the disconnect listener, which is what makes this intentional teardown quiet. acquire() publishes
|
|
@@ -1430,6 +1835,12 @@ async function executeBrowserRestart() {
|
|
|
1430
1835
|
const teardown = closeBrowserInstance(browser);
|
|
1431
1836
|
supervisor.noteTeardownBegun(teardown, BROWSER_TEARDOWN_DRAIN_BOUND_MS);
|
|
1432
1837
|
await teardown;
|
|
1838
|
+
/* The relaunch ends the browser session the channel-selection caches describe (guide row positions, discovered page URLs, watch URLs), exactly as the
|
|
1839
|
+
* 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,
|
|
1840
|
+
* because a page of the retiring session can still deliver a response event while Chrome drains - the Comcast channelmap listener writes its caches from
|
|
1841
|
+
* exactly such an event - and a clear issued ahead of the drain would be undone by it.
|
|
1842
|
+
*/
|
|
1843
|
+
clearChannelSelectionCaches();
|
|
1433
1844
|
// 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
1845
|
// 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
1846
|
// 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 +1848,12 @@ async function executeBrowserRestart() {
|
|
|
1437
1848
|
LOG.debug("browser:lifecycle", "Browser restart relaunch declined because shutdown began while the previous instance was closing.");
|
|
1438
1849
|
return;
|
|
1439
1850
|
}
|
|
1440
|
-
// Launch a fresh browser instance so it is ready for the next stream request.
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1851
|
+
// 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
|
|
1852
|
+
// may be refusing captures, so it acquires for a page.
|
|
1853
|
+
await getCurrentBrowser("page");
|
|
1854
|
+
// 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
|
|
1855
|
+
// arriving while the relaunch is in flight triggers its own sync during establishment.
|
|
1856
|
+
await syncWindowVisibility();
|
|
1444
1857
|
LOG.info("Browser restart complete. Fresh instance is ready.");
|
|
1445
1858
|
}
|
|
1446
1859
|
catch (error) {
|
|
@@ -1463,10 +1876,7 @@ export function stopBrowserRestartChecking() {
|
|
|
1463
1876
|
clearInterval(restartCheckInterval);
|
|
1464
1877
|
restartCheckInterval = null;
|
|
1465
1878
|
}
|
|
1466
|
-
|
|
1467
|
-
clearTimeout(restartQuietTimer);
|
|
1468
|
-
restartQuietTimer = null;
|
|
1469
|
-
}
|
|
1879
|
+
cancelRestartQuietTimer();
|
|
1470
1880
|
}
|
|
1471
1881
|
/* 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
1882
|
* included in the bundle, but Chrome cannot load extensions from within the packaged binary - it needs actual files on the filesystem.
|
|
@@ -1523,6 +1933,9 @@ export async function prepareExtension() {
|
|
|
1523
1933
|
throw error;
|
|
1524
1934
|
}
|
|
1525
1935
|
}
|
|
1526
|
-
|
|
1527
|
-
|
|
1936
|
+
/* Re-export the capture acquisition for the streaming module. The browser directory owns every conversation with puppeteer-stream - index.ts launches it and
|
|
1937
|
+
* 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
|
|
1938
|
+
* asks it for a browser.
|
|
1939
|
+
*/
|
|
1940
|
+
export { acquireCaptureStream } from "./tabCapture.js";
|
|
1528
1941
|
//# sourceMappingURL=index.js.map
|