buninu-browser 0.5.1 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -6
- package/index.js +10 -6
- package/lib/audio/pcm.js +11 -0
- package/lib/audio/pulse-client.js +7 -2
- package/lib/cdp-server.js +94 -46
- package/lib/happy-dom/document-base.js +29 -1
- package/lib/happy-dom/mutation-observer.js +157 -0
- package/lib/happy-dom/parser.js +2 -0
- package/lib/headless-shell.js +49 -9
- package/lib/layout/repaint.js +89 -0
- package/lib/media/mp4-keyframes.js +144 -0
- package/lib/media/video-keyframe.js +29 -1
- package/lib/media/webm-snapshots.js +282 -0
- package/lib/network/cookie-jar.js +18 -2
- package/lib/paint/display-list.js +4 -3
- package/lib/paint/frame.js +12 -5
- package/lib/render-tree/index.js +3 -0
- package/lib/renderer/animation-frames.js +76 -0
- package/lib/renderer/bindings.js +2 -0
- package/lib/renderer/compositor-channel.js +95 -10
- package/lib/renderer/compositor.js +54 -18
- package/lib/renderer/document-cookies.js +87 -0
- package/lib/renderer/font-loading.js +60 -16
- package/lib/renderer/host.js +48 -19
- package/lib/renderer/idle-callbacks.js +109 -0
- package/lib/renderer/main.js +5 -1
- package/lib/renderer/media-source.js +4 -0
- package/lib/renderer/page-realm.js +159 -49
- package/lib/renderer/page-renderer.js +567 -197
- package/lib/renderer/resource-elements.js +88 -0
- package/lib/renderer/script-rewrite.js +18 -1
- package/lib/renderer/seccomp.js +6 -4
- package/lib/renderer/timers.js +71 -0
- package/lib/renderer/video-channel.js +94 -0
- package/lib/renderer/video-mask.js +115 -0
- package/lib/renderer/video-presentation.js +304 -39
- package/lib/renderer/video-worker.js +46 -0
- package/lib/renderer/workers.js +7 -17
- package/lib/style/computed-style.js +257 -61
- package/package.json +1 -1
- package/test/animation-frames.test.js +67 -0
- package/test/canvas-style-snapshot.test.js +18 -0
- package/test/cdp-stream.test.js +96 -3
- package/test/cdp-viewers.test.js +43 -0
- package/test/compositor-channel-ownership.test.js +194 -0
- package/test/compositor.test.js +27 -1
- package/test/computed-style-binding.test.js +49 -0
- package/test/computed-style-connectivity.test.js +13 -0
- package/test/computed-style.test.js +86 -0
- package/test/cookie-sync.test.js +82 -0
- package/test/document-base-cache.test.js +103 -0
- package/test/document-cookies.test.js +117 -0
- package/test/duplicate-style-keys.test.js +11 -0
- package/test/fixtures/animation-frames.js +56 -0
- package/test/fixtures/canvas-style-snapshot.js +81 -0
- package/test/fixtures/computed-style-connectivity.js +101 -0
- package/test/fixtures/computed-style-lazy.js +135 -0
- package/test/fixtures/duplicate-style-keys.js +97 -0
- package/test/fixtures/fake-renderer.js +12 -0
- package/test/fixtures/font-definition-invalidation.js +97 -0
- package/test/fixtures/font-metrics-invalidation.js +87 -0
- package/test/fixtures/full-style-identity.js +36 -0
- package/test/fixtures/hidden-style-engine.js +260 -0
- package/test/fixtures/hidden-style-reentry.js +232 -0
- package/test/fixtures/hidden-style-renderer.js +203 -0
- package/test/fixtures/layout-repaint.js +69 -0
- package/test/fixtures/media-presentation-continuation.js +141 -0
- package/test/fixtures/media-presentation-refill.js +113 -0
- package/test/fixtures/media-probe.js +148 -0
- package/test/fixtures/media-refill-cancel.js +79 -0
- package/test/fixtures/media-repaint.js +48 -0
- package/test/fixtures/media-resource-reselection.js +94 -0
- package/test/fixtures/media-source-attachment.js +136 -0
- package/test/fixtures/media-source-no-decoder.js +17 -0
- package/test/fixtures/media-track-refill.js +132 -0
- package/test/fixtures/media-webm-seek.js +51 -0
- package/test/fixtures/mutation-observer.js +201 -0
- package/test/fixtures/observer-invalidation.js +105 -0
- package/test/fixtures/prepaint-scheduling.js +73 -0
- package/test/fixtures/resource-discovery.js +32 -0
- package/test/fixtures/spare-renderer-scheduling.js +124 -0
- package/test/fixtures/style-propagation.js +26 -0
- package/test/fixtures/svg-node-reuse.js +83 -0
- package/test/fixtures/video-black-replay.js +140 -0
- package/test/fixtures/video-controls-invalidation.js +71 -0
- package/test/fixtures/viewport-bounds.js +148 -0
- package/test/font-loading.test.js +77 -0
- package/test/font-metrics-invalidation.test.js +60 -0
- package/test/full-style-identity.test.js +191 -0
- package/test/hidden-style-lazy.test.js +31 -0
- package/test/hidden-style-reentry.test.js +20 -0
- package/test/idle-callback.test.js +89 -0
- package/test/idle-scheduler.test.js +148 -0
- package/test/index.test.js +1 -0
- package/test/inline-style-cascade.test.js +63 -0
- package/test/layout-repaint-renderer.test.js +9 -0
- package/test/layout-repaint.test.js +222 -0
- package/test/media-presentation-continuation.test.js +14 -0
- package/test/media-presentation-refill.test.js +8 -0
- package/test/media-probe.test.js +16 -0
- package/test/media-repaint.test.js +8 -0
- package/test/media-source-attachment.test.js +34 -0
- package/test/media-track-refill.test.js +26 -0
- package/test/media-webm-seek.test.js +11 -0
- package/test/mp4-keyframes.test.js +36 -0
- package/test/mutation-observer.test.js +91 -0
- package/test/observer-invalidation.test.js +12 -0
- package/test/page-scripts.test.js +35 -0
- package/test/page-timers.test.js +67 -0
- package/test/paint-frame.test.js +18 -1
- package/test/pcm.test.js +63 -0
- package/test/prepaint-scheduling.test.js +13 -0
- package/test/pulse-client.test.js +165 -0
- package/test/raw-capture.test.js +34 -5
- package/test/renderer-host.test.js +51 -0
- package/test/resource-discovery.test.js +12 -0
- package/test/resource-elements.test.js +234 -0
- package/test/root-self-style.test.js +104 -0
- package/test/script-rewrite.test.js +35 -0
- package/test/seccomp.test.js +45 -0
- package/test/spare-renderer-scheduling.test.js +8 -0
- package/test/style-propagation.test.js +225 -0
- package/test/style-sheets.test.js +19 -0
- package/test/svg.test.js +45 -0
- package/test/timers.test.js +58 -0
- package/test/video-black-replay.test.js +34 -0
- package/test/video-capture.test.js +170 -0
- package/test/video-channel.test.js +253 -0
- package/test/video-continuation.test.js +57 -0
- package/test/video-controls-invalidation.test.js +12 -0
- package/test/video-keyframe.test.js +104 -4
- package/test/video-mask.test.js +94 -0
- package/test/video-presentation.test.js +239 -1
- package/test/video-timing-presentation.test.js +115 -0
- package/test/viewport-bounds.test.js +13 -0
- package/test/webm-snapshots.test.js +372 -0
package/README.md
CHANGED
|
@@ -96,9 +96,10 @@ the rest is passed on:
|
|
|
96
96
|
argument goes to casty)
|
|
97
97
|
- `--headless`: the engine alone, as
|
|
98
98
|
a CDP endpoint
|
|
99
|
-
- `--win32
|
|
100
|
-
|
|
101
|
-
|
|
99
|
+
- `--win32`: casty with a native Windows
|
|
100
|
+
window (Windows only)
|
|
101
|
+
- `--gtk`, `--appkit`, `--qt`: native
|
|
102
|
+
windowed frontends (reserved, not implemented yet)
|
|
102
103
|
- `--help`/`-h`, `--version`/`-V`,
|
|
103
104
|
`--readme`: this launcher's help,
|
|
104
105
|
version and backend details, and
|
|
@@ -291,9 +292,9 @@ borrows a contiguous raster view instead of rescaling; encoded output uses
|
|
|
291
292
|
the same crop. `Page.getLayoutMetrics` provides the viewport position so
|
|
292
293
|
Casty can follow document scrolling.
|
|
293
294
|
|
|
294
|
-
Casty also sends a private `clip` on `Page.startScreencast`. It takes precedence over fallback max limits and crops at native pixel density, without resizing. Width/height are CSS units; output pixels still follow device scale. This extension is not standard CDP.
|
|
295
|
+
Casty also sends a private `clip` on `Page.startScreencast`. It takes precedence over fallback max limits and crops at native pixel density, without resizing. Width/height are CSS units; output pixels still follow device scale. This extension is not standard CDP. A clip with both width and height equal to zero requests update notifications only: the server sends a fixed 1×1 PNG placeholder without rendering or encoding a preview. Ordinary captureScreenshot still rejects zero-sized clips.
|
|
295
296
|
|
|
296
|
-
Casty 也會在 `Page.startScreencast` 傳入私有 `clip`;優先於相容用的 max 限制,以原像素密度裁切、不縮放。寬高是 CSS 單位,實體像素仍隨 device scale 換算。此擴充不是標準 CDP
|
|
297
|
+
Casty 也會在 `Page.startScreencast` 傳入私有 `clip`;優先於相容用的 max 限制,以原像素密度裁切、不縮放。寬高是 CSS 單位,實體像素仍隨 device scale 換算。此擴充不是標準 CDP。寬高都為零時只發送更新通知:server 回傳固定 1×1 PNG 佔位資料,不渲染或編碼預覽;一般 captureScreenshot 仍拒絕零尺寸。
|
|
297
298
|
|
|
298
299
|
標準 CDP `clip` 也適用於 raw 截圖;小數 CSS 尺寸保留到最後才換算成實體像素,
|
|
299
300
|
避免字體大小改變後差一列而觸發 bunterm 縮放。`clip.scale: 1` 的全寬裁切
|
|
@@ -469,7 +470,13 @@ it to `buninu-browser.log` in the working directory, or set
|
|
|
469
470
|
|
|
470
471
|
- page errors and page `console` output;
|
|
471
472
|
- per-phase load timings;
|
|
472
|
-
- renderer crashes and watchdog kills.
|
|
473
|
+
- renderer crashes and explicitly enabled watchdog kills.
|
|
474
|
+
|
|
475
|
+
Renderer and video-worker startup have no deadline. Document navigation also
|
|
476
|
+
waits until completion, failure, or explicit close. The browser does not enable
|
|
477
|
+
the renderer method watchdog by default: slow initialization or page work must
|
|
478
|
+
not kill the renderer. Internal callers can opt into a method watchdog through
|
|
479
|
+
`rendererTimeout`; this does not impose a startup or navigation deadline.
|
|
473
480
|
|
|
474
481
|
Renderers cannot open files under seccomp, so the controller collects their
|
|
475
482
|
output and writes the log.
|
package/index.js
CHANGED
|
@@ -9,8 +9,9 @@
|
|
|
9
9
|
* --casty the terminal frontend (default): runs casty with
|
|
10
10
|
* CASTY_BROWSER set to this package's buninu-browser.js
|
|
11
11
|
* --headless the engine alone (buninu-browser.js, a CDP endpoint)
|
|
12
|
-
* --win32
|
|
13
|
-
*
|
|
12
|
+
* --win32 casty's native Windows frontend
|
|
13
|
+
* --gtk, --appkit, --qt
|
|
14
|
+
* reserved for native windowed frontends (not implemented)
|
|
14
15
|
*
|
|
15
16
|
* --help, -h / --version, -V / --readme, --docs
|
|
16
17
|
* this launcher's help, version and README
|
|
@@ -33,7 +34,7 @@ export * from "./buninu-browser.js";
|
|
|
33
34
|
|
|
34
35
|
const CASTY_PACKAGE = "@drxiaozhi/casty";
|
|
35
36
|
const ENGINE = join(import.meta.dir, "buninu-browser.js");
|
|
36
|
-
const RESERVED_FRONTENDS = new Set(["--
|
|
37
|
+
const RESERVED_FRONTENDS = new Set(["--gtk", "--appkit", "--qt"]);
|
|
37
38
|
const INFORMATION = new Map([
|
|
38
39
|
["--help", "help"], ["-h", "help"], ["-help", "help"],
|
|
39
40
|
["--version", "version"], ["-V", "version"], ["-version", "version"],
|
|
@@ -52,6 +53,7 @@ export function chooseFrontend(argv) {
|
|
|
52
53
|
return { frontend: first.slice(2), args: rest };
|
|
53
54
|
|
|
54
55
|
if (first === "--casty") return { frontend: "casty", args: rest };
|
|
56
|
+
if (first === "--win32") return { frontend: "win32", args: rest };
|
|
55
57
|
if (first === "--headless") return { frontend: "headless", args: rest };
|
|
56
58
|
if (RESERVED_FRONTENDS.has(first)) return { frontend: first.slice(2), args: rest, reserved: true };
|
|
57
59
|
if (INFORMATION.has(first)) return { frontend: INFORMATION.get(first), args: rest, information: true };
|
|
@@ -96,7 +98,9 @@ ${typeof waylandHelp === "undefined" ? "" : waylandHelp} --casty
|
|
|
96
98
|
--quickshell-wayland, --quickshell-headless, --quickshell-casty
|
|
97
99
|
Experimental Quickshell CLI (-p FILE_OR_DIRECTORY / -c CONFIG)
|
|
98
100
|
Use -- before backend arguments; each entry supports --help
|
|
99
|
-
--win32
|
|
101
|
+
--win32
|
|
102
|
+
Native Windows window driven by casty (Windows only)
|
|
103
|
+
--gtk, --appkit, --qt
|
|
100
104
|
Native windowed frontends (reserved, not implemented yet)
|
|
101
105
|
|
|
102
106
|
Browser identity:
|
|
@@ -129,7 +133,7 @@ function printVersion() {
|
|
|
129
133
|
console.log("Platform:", `${process.platform}-${process.arch}`);
|
|
130
134
|
console.log("Engine:", ENGINE);
|
|
131
135
|
console.log("casty launcher:", castyCommand([]).join(" "));
|
|
132
|
-
console.log("Frontends: casty (default), headless; reserved:
|
|
136
|
+
console.log("Frontends: casty (default), headless, win32; reserved: gtk, appkit, qt");
|
|
133
137
|
}
|
|
134
138
|
|
|
135
139
|
async function printReadme() {
|
|
@@ -176,6 +180,6 @@ if (import.meta.main) {
|
|
|
176
180
|
|
|
177
181
|
const code = frontend === "headless"
|
|
178
182
|
? await run([process.execPath, ENGINE, ...args])
|
|
179
|
-
: await run(castyCommand(args), { ...process.env, CASTY_BROWSER: ENGINE });
|
|
183
|
+
: await run(castyCommand(frontend === "win32" ? ["--win32", ...args] : args), { ...process.env, CASTY_BROWSER: ENGINE });
|
|
180
184
|
process.exit(code ?? 1);
|
|
181
185
|
}
|
package/lib/audio/pcm.js
CHANGED
|
@@ -1,6 +1,17 @@
|
|
|
1
|
+
// Typed-array stores use host byte order; the wire format is always s16le.
|
|
2
|
+
const LITTLE_ENDIAN = new Uint8Array(new Uint16Array([1]).buffer)[0] === 1;
|
|
3
|
+
|
|
1
4
|
/** Interleaved Float32 samples in [-1, 1] (as audio decoders produce) as s16le bytes. */
|
|
2
5
|
export function floatToS16(samples) {
|
|
3
6
|
const out = new Uint8Array(samples.length * 2);
|
|
7
|
+
if (LITTLE_ENDIAN) {
|
|
8
|
+
const view = new Int16Array(out.buffer);
|
|
9
|
+
for (let index = 0; index < samples.length; index++) {
|
|
10
|
+
const value = Math.max(-1, Math.min(1, samples[index]));
|
|
11
|
+
view[index] = Math.round(value < 0 ? value * 32768 : value * 32767);
|
|
12
|
+
}
|
|
13
|
+
return out;
|
|
14
|
+
}
|
|
4
15
|
const view = new DataView(out.buffer);
|
|
5
16
|
for (let index = 0; index < samples.length; index++) {
|
|
6
17
|
const value = Math.max(-1, Math.min(1, samples[index]));
|
|
@@ -339,6 +339,9 @@ export class PulseClient {
|
|
|
339
339
|
*/
|
|
340
340
|
const LEAD_SECONDS = 0.5;
|
|
341
341
|
const PACE_MS = 50;
|
|
342
|
+
// Video follows this measured clock. Sharing the 50 ms PCM timer would cap
|
|
343
|
+
// presentation at 20 FPS even when decoding and output can keep up.
|
|
344
|
+
const TIMING_MS = 16;
|
|
342
345
|
const MIN_REQUEST_SECONDS = 0.02;
|
|
343
346
|
const MIN_PACKET_SECONDS = 0.04;
|
|
344
347
|
const MAX_PACKET_SECONDS = 0.1;
|
|
@@ -355,6 +358,7 @@ class PlaybackStream {
|
|
|
355
358
|
#outstanding = 0;
|
|
356
359
|
#started = null;
|
|
357
360
|
#timer = null;
|
|
361
|
+
#timingTimer = null;
|
|
358
362
|
#leadSeconds;
|
|
359
363
|
#onTiming;
|
|
360
364
|
#onTimingError;
|
|
@@ -376,7 +380,8 @@ class PlaybackStream {
|
|
|
376
380
|
this.#producerBuffer = new PlaybackBuffer(this.#bytesPerSecond, this.#frameBytes, leadSeconds);
|
|
377
381
|
this.#onTiming = onTiming;
|
|
378
382
|
this.#onTimingError = onTimingError;
|
|
379
|
-
this.#timer = setInterval(() =>
|
|
383
|
+
this.#timer = setInterval(() => this.#pace(), PACE_MS);
|
|
384
|
+
if (onTiming) this.#timingTimer = setInterval(() => { void this.updateTiming(); }, TIMING_MS);
|
|
380
385
|
}
|
|
381
386
|
|
|
382
387
|
async updateTiming() {
|
|
@@ -393,7 +398,6 @@ class PlaybackStream {
|
|
|
393
398
|
this.#onTiming({ seconds: this.#playedBytes / this.#bytesPerSecond,
|
|
394
399
|
sentSeconds: this.#offset / this.#bytesPerSecond, sinkSeconds: timing.sinkSeconds, playing: timing.playing,
|
|
395
400
|
bufferedSeconds: this.#queuedBytes / this.#bytesPerSecond });
|
|
396
|
-
this.#pace();
|
|
397
401
|
} catch (error) {
|
|
398
402
|
if (!this.killed) this.#onTimingError?.(error);
|
|
399
403
|
} finally {
|
|
@@ -527,5 +531,6 @@ class PlaybackStream {
|
|
|
527
531
|
this.#queue = [];
|
|
528
532
|
this.#queuedBytes = 0;
|
|
529
533
|
clearInterval(this.#timer);
|
|
534
|
+
clearInterval(this.#timingTimer);
|
|
530
535
|
}
|
|
531
536
|
}
|
package/lib/cdp-server.js
CHANGED
|
@@ -40,11 +40,15 @@ export class CdpServer {
|
|
|
40
40
|
// Pages change on their own (loading stages, scripts, compositor
|
|
41
41
|
// scrolling): push a screencast frame whenever the context reports one,
|
|
42
42
|
// and send lifecycle events when the document really reaches them.
|
|
43
|
-
context.onFrame?.(() => {
|
|
44
|
-
|
|
45
|
-
|
|
43
|
+
context.onFrame?.((frame) => {
|
|
44
|
+
const offset = frame?.scrollOffset;
|
|
45
|
+
const scrollOffset = Number.isFinite(offset?.x) && Number.isFinite(offset?.y)
|
|
46
|
+
? { x: offset.x, y: offset.y } : null;
|
|
47
|
+
for (const target of [...state.targets.values(), state.implicitTarget]) {
|
|
48
|
+
if (!target) continue;
|
|
49
|
+
for (const stream of screencasts(target)) stream.scrollOffset = scrollOffset;
|
|
50
|
+
void pushScreencastFrame(context, state, target);
|
|
46
51
|
}
|
|
47
|
-
if (state.implicitTarget?.screencast) pushScreencastFrame(context, state, state.implicitTarget);
|
|
48
52
|
});
|
|
49
53
|
context.onLifecycle?.((name) => {
|
|
50
54
|
for (const target of state.targets.values()) emitLifecycle(target, name);
|
|
@@ -164,6 +168,8 @@ export class CdpServer {
|
|
|
164
168
|
const msg = JSON.parse(text);
|
|
165
169
|
id = msg.id;
|
|
166
170
|
sessionId = msg.sessionId;
|
|
171
|
+
if (sessionId && state.sessionOwners.get(sessionId) !== ws)
|
|
172
|
+
throw Error("No session owned by this connection");
|
|
167
173
|
let dispatchSessionId = sessionId;
|
|
168
174
|
if (!dispatchSessionId && ws.data?.targetId) {
|
|
169
175
|
dispatchSessionId = `page-${ws.data.targetId}`;
|
|
@@ -210,12 +216,17 @@ export class CdpServer {
|
|
|
210
216
|
},
|
|
211
217
|
close(ws) {
|
|
212
218
|
for (const target of state.targets.values()) {
|
|
213
|
-
|
|
219
|
+
stopScreencast(target, ws);
|
|
214
220
|
if (target.lifecycleEmit?.owner === ws) target.lifecycleEmit = null;
|
|
215
221
|
}
|
|
216
222
|
const implicit = state.implicitTarget;
|
|
217
|
-
if (implicit
|
|
223
|
+
if (implicit) stopScreencast(implicit, ws);
|
|
218
224
|
if (implicit?.lifecycleEmit?.owner === ws) implicit.lifecycleEmit = null;
|
|
225
|
+
for (const [id, owner] of state.sessionOwners) {
|
|
226
|
+
if (owner !== ws) continue;
|
|
227
|
+
state.sessionOwners.delete(id);
|
|
228
|
+
state.sessions.delete(id);
|
|
229
|
+
}
|
|
219
230
|
serverLog("[CdpServer] client disconnected");
|
|
220
231
|
},
|
|
221
232
|
},
|
|
@@ -279,6 +290,7 @@ function createCdpState() {
|
|
|
279
290
|
nextScreencastFrameId: 1,
|
|
280
291
|
targets: new Map(),
|
|
281
292
|
sessions: new Map(),
|
|
293
|
+
sessionOwners: new Map(),
|
|
282
294
|
objects: new Map(),
|
|
283
295
|
initialNavigations: new Map(),
|
|
284
296
|
implicitTarget: null,
|
|
@@ -320,6 +332,7 @@ async function dispatch(ctx, state, method, params, sessionId, emit) {
|
|
|
320
332
|
const target = state.targets.get(targetId);
|
|
321
333
|
target.attached = true;
|
|
322
334
|
state.sessions.set(attachedSessionId, targetId);
|
|
335
|
+
state.sessionOwners.set(attachedSessionId, emit.owner);
|
|
323
336
|
emit("Target.attachedToTarget", {
|
|
324
337
|
sessionId: attachedSessionId,
|
|
325
338
|
targetInfo: createTargetInfo(target),
|
|
@@ -336,6 +349,7 @@ async function dispatch(ctx, state, method, params, sessionId, emit) {
|
|
|
336
349
|
const sessionId = `session-${state.nextSessionId++}`;
|
|
337
350
|
target.attached = true;
|
|
338
351
|
state.sessions.set(sessionId, params.targetId);
|
|
352
|
+
state.sessionOwners.set(sessionId, emit.owner);
|
|
339
353
|
return { sessionId };
|
|
340
354
|
}
|
|
341
355
|
case "Target.getTargets":
|
|
@@ -369,6 +383,7 @@ async function dispatch(ctx, state, method, params, sessionId, emit) {
|
|
|
369
383
|
for (const [attachedSessionId, targetId] of state.sessions) {
|
|
370
384
|
if (targetId !== params.targetId) continue;
|
|
371
385
|
state.sessions.delete(attachedSessionId);
|
|
386
|
+
state.sessionOwners.delete(attachedSessionId);
|
|
372
387
|
emit("Target.detachedFromTarget", {
|
|
373
388
|
sessionId: attachedSessionId,
|
|
374
389
|
targetId,
|
|
@@ -378,9 +393,14 @@ async function dispatch(ctx, state, method, params, sessionId, emit) {
|
|
|
378
393
|
emit("Target.targetDestroyed", { targetId: params.targetId });
|
|
379
394
|
return { success: true };
|
|
380
395
|
}
|
|
381
|
-
case "Target.detachFromTarget":
|
|
396
|
+
case "Target.detachFromTarget": {
|
|
397
|
+
const target = state.targets.get(state.sessions.get(params.sessionId));
|
|
398
|
+
if (state.sessionOwners.get(params.sessionId) !== emit.owner) throw Error("No session owned by this connection");
|
|
399
|
+
if (target) stopScreencast(target, emit.owner, params.sessionId);
|
|
382
400
|
state.sessions.delete(params.sessionId);
|
|
401
|
+
state.sessionOwners.delete(params.sessionId);
|
|
383
402
|
return {};
|
|
403
|
+
}
|
|
384
404
|
case "Page.enable":
|
|
385
405
|
return {};
|
|
386
406
|
case "Page.getResourceContent": {
|
|
@@ -461,7 +481,7 @@ async function dispatch(ctx, state, method, params, sessionId, emit) {
|
|
|
461
481
|
// A javascript: URL runs in the current document: no new loader or history entry.
|
|
462
482
|
if (/^\s*javascript:/i.test(String(params.url))) {
|
|
463
483
|
await ctx.navigate?.(String(params.url).trim());
|
|
464
|
-
if (target.
|
|
484
|
+
if (target.screencasts.size) await pushScreencastFrame(ctx, state, target);
|
|
465
485
|
return { frameId: target.frameId, loaderId: target.loaderId };
|
|
466
486
|
}
|
|
467
487
|
// Events of the next document belong to its new loader.
|
|
@@ -470,7 +490,7 @@ async function dispatch(ctx, state, method, params, sessionId, emit) {
|
|
|
470
490
|
navigateTarget(state, target, params.url, emit, { addHistory: true });
|
|
471
491
|
target.title = await getContextTitle(ctx, target);
|
|
472
492
|
target.history[target.historyIndex].title = target.title;
|
|
473
|
-
if (target.
|
|
493
|
+
if (target.screencasts.size) {
|
|
474
494
|
await pushScreencastFrame(ctx, state, target);
|
|
475
495
|
}
|
|
476
496
|
state.initialNavigations.get(target.targetId)?.navigated.resolve();
|
|
@@ -523,22 +543,26 @@ async function dispatch(ctx, state, method, params, sessionId, emit) {
|
|
|
523
543
|
}
|
|
524
544
|
case "Page.startScreencast": {
|
|
525
545
|
const target = getSessionTarget(state, sessionId);
|
|
526
|
-
stopScreencast(target);
|
|
527
|
-
|
|
528
|
-
target.
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
546
|
+
stopScreencast(target, emit.owner, sessionId ?? null);
|
|
547
|
+
let sessions = target.screencasts.get(emit.owner);
|
|
548
|
+
if (!sessions) target.screencasts.set(emit.owner, sessions = new Map());
|
|
549
|
+
const stream = { params: { ...params }, emit, active: true, busy: false,
|
|
550
|
+
pending: false, scrollOffset: null, frames: new Set() };
|
|
551
|
+
sessions.set(sessionId ?? null, stream);
|
|
552
|
+
// Do not wait for a capture; each viewer owns its own backpressure.
|
|
553
|
+
void pushScreencastFrame(ctx, state, target, stream);
|
|
532
554
|
return {};
|
|
533
555
|
}
|
|
534
556
|
case "Page.screencastFrameAck": {
|
|
535
557
|
const target = getSessionTarget(state, sessionId);
|
|
536
|
-
|
|
558
|
+
const stream = target.screencasts.get(emit.owner)?.get(sessionId ?? null);
|
|
559
|
+
if (stream?.frames.delete(params.sessionId) && stream.pending)
|
|
560
|
+
void pushScreencastFrame(ctx, state, target, stream);
|
|
537
561
|
return {};
|
|
538
562
|
}
|
|
539
563
|
case "Page.stopScreencast": {
|
|
540
564
|
const target = getSessionTarget(state, sessionId);
|
|
541
|
-
stopScreencast(target);
|
|
565
|
+
stopScreencast(target, emit.owner, sessionId ?? null);
|
|
542
566
|
return {};
|
|
543
567
|
}
|
|
544
568
|
case "Page.reload": {
|
|
@@ -718,7 +742,7 @@ async function dispatch(ctx, state, method, params, sessionId, emit) {
|
|
|
718
742
|
navigateTarget(state,target,navigation.url,emit,{addHistory:true});
|
|
719
743
|
target.title=navigation.title ?? await getContextTitle(ctx,target);
|
|
720
744
|
target.history[target.historyIndex].title=target.title;
|
|
721
|
-
if(target.
|
|
745
|
+
if(target.screencasts.size)await pushScreencastFrame(ctx,state,target);
|
|
722
746
|
}
|
|
723
747
|
return {};
|
|
724
748
|
}
|
|
@@ -734,7 +758,7 @@ async function dispatch(ctx, state, method, params, sessionId, emit) {
|
|
|
734
758
|
navigateTarget(state, target, navigation.url, emit, { addHistory: true });
|
|
735
759
|
target.title = navigation.title ?? await getContextTitle(ctx, target);
|
|
736
760
|
target.history[target.historyIndex].title = target.title;
|
|
737
|
-
if (target.
|
|
761
|
+
if (target.screencasts.size) await pushScreencastFrame(ctx, state, target);
|
|
738
762
|
}
|
|
739
763
|
}
|
|
740
764
|
return {};
|
|
@@ -843,8 +867,7 @@ function createTarget(state, url = "about:blank", options = {}) {
|
|
|
843
867
|
height: options.height ?? 600,
|
|
844
868
|
deviceScaleFactor: 1,
|
|
845
869
|
},
|
|
846
|
-
|
|
847
|
-
screencastFrames: new Set(),
|
|
870
|
+
screencasts: new Map(),
|
|
848
871
|
};
|
|
849
872
|
state.targets.set(targetId, target);
|
|
850
873
|
state.initialNavigations.set(targetId, {
|
|
@@ -888,42 +911,67 @@ function emitLifecycle(target, name) {
|
|
|
888
911
|
// Bound the amount of image data queued to a slow CDP client. Two outstanding
|
|
889
912
|
// frames keep the pipe moving while acknowledging one; later updates coalesce.
|
|
890
913
|
const MAX_SCREENCAST_FRAMES = 2;
|
|
891
|
-
function
|
|
892
|
-
target.
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
914
|
+
function* screencasts(target) {
|
|
915
|
+
for (const sessions of target.screencasts.values()) yield* sessions.values();
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
// No owner means target destruction. An owner without a session means disconnect.
|
|
919
|
+
function stopScreencast(target, owner, sessionId) {
|
|
920
|
+
const owners = owner === undefined ? target.screencasts : new Map([[owner, target.screencasts.get(owner)]]);
|
|
921
|
+
for (const [key, sessions] of owners) {
|
|
922
|
+
if (!sessions) continue;
|
|
923
|
+
for (const [id, stream] of sessions) {
|
|
924
|
+
if (sessionId !== undefined && id !== sessionId) continue;
|
|
925
|
+
stream.active = false;
|
|
926
|
+
stream.pending = false;
|
|
927
|
+
stream.scrollOffset = null;
|
|
928
|
+
stream.frames.clear();
|
|
929
|
+
sessions.delete(id);
|
|
930
|
+
}
|
|
931
|
+
if (!sessions.size) target.screencasts.delete(key);
|
|
932
|
+
}
|
|
896
933
|
}
|
|
897
934
|
|
|
898
|
-
/** One capture at a time, with bounded unacknowledged frames. */
|
|
899
|
-
async function pushScreencastFrame(ctx, state, target) {
|
|
900
|
-
if (!
|
|
901
|
-
|
|
902
|
-
|
|
935
|
+
/** One capture at a time per viewer, with bounded unacknowledged frames. */
|
|
936
|
+
async function pushScreencastFrame(ctx, state, target, stream) {
|
|
937
|
+
if (!stream) {
|
|
938
|
+
await Promise.all([...screencasts(target)].map(s => pushScreencastFrame(ctx, state, target, s)));
|
|
939
|
+
return;
|
|
940
|
+
}
|
|
941
|
+
if (!stream.active) return;
|
|
942
|
+
if (stream.busy || stream.frames.size >= MAX_SCREENCAST_FRAMES) {
|
|
943
|
+
stream.pending = true;
|
|
903
944
|
return;
|
|
904
945
|
}
|
|
905
|
-
|
|
946
|
+
stream.busy = true;
|
|
906
947
|
try {
|
|
907
948
|
do {
|
|
908
|
-
|
|
909
|
-
await emitScreencastFrame(ctx, state, target);
|
|
910
|
-
} while (
|
|
911
|
-
&& target.screencastFrames.size < MAX_SCREENCAST_FRAMES);
|
|
949
|
+
stream.pending = false;
|
|
950
|
+
await emitScreencastFrame(ctx, state, target, stream);
|
|
951
|
+
} while (stream.pending && stream.active && stream.frames.size < MAX_SCREENCAST_FRAMES);
|
|
912
952
|
} catch (error) {
|
|
913
953
|
serverError(`[CdpServer] screencast frame failed: ${error?.message ?? error}`);
|
|
914
954
|
} finally {
|
|
915
|
-
|
|
955
|
+
stream.busy = false;
|
|
916
956
|
}
|
|
917
957
|
}
|
|
918
958
|
|
|
919
|
-
async function emitScreencastFrame(ctx, state, target) {
|
|
920
|
-
const
|
|
921
|
-
const
|
|
922
|
-
|
|
923
|
-
const
|
|
924
|
-
|
|
959
|
+
async function emitScreencastFrame(ctx, state, target, stream) {
|
|
960
|
+
const options = stream.params, emit = stream.emit;
|
|
961
|
+
const loaderId = target.loaderId;
|
|
962
|
+
// Pending updates replace this snapshot with the latest position.
|
|
963
|
+
const notifiedOffset = stream.scrollOffset;
|
|
964
|
+
stream.scrollOffset = null;
|
|
965
|
+
const zeroArea = options.clip?.width === 0 && options.clip?.height === 0;
|
|
966
|
+
const data = zeroArea
|
|
967
|
+
? "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Wl6SAAAAABJRU5ErkJggg=="
|
|
968
|
+
: await screenshotData(ctx, target, options);
|
|
969
|
+
if (!data || !stream.active || loaderId !== target.loaderId) return;
|
|
970
|
+
const scrollOffset = zeroArea && notifiedOffset ? notifiedOffset
|
|
971
|
+
: await ctx.scrollOffset?.() ?? { x: 0, y: 0 };
|
|
972
|
+
if (!stream.active || loaderId !== target.loaderId) return;
|
|
925
973
|
const sessionId = state.nextScreencastFrameId++;
|
|
926
|
-
|
|
974
|
+
stream.frames.add(sessionId);
|
|
927
975
|
emit("Page.screencastFrame", {
|
|
928
976
|
data,
|
|
929
977
|
metadata: {
|
|
@@ -950,6 +998,7 @@ function getPendingInitialNavigationTarget(state) {
|
|
|
950
998
|
|
|
951
999
|
function navigateTarget(state, target, url, emit, options = {}) {
|
|
952
1000
|
const deferLifecycle = typeof state.context?.onLifecycle === "function";
|
|
1001
|
+
for (const stream of screencasts(target)) stream.scrollOffset = null;
|
|
953
1002
|
target.lifecycleEmit = null;
|
|
954
1003
|
target.url = url;
|
|
955
1004
|
loadDocument(target, url);
|
|
@@ -1044,8 +1093,7 @@ function getImplicitTarget(state) {
|
|
|
1044
1093
|
|
|
1045
1094
|
state.implicitTarget = {
|
|
1046
1095
|
targetId: "implicit-target",
|
|
1047
|
-
|
|
1048
|
-
screencastFrames: new Set(),
|
|
1096
|
+
screencasts: new Map(),
|
|
1049
1097
|
frameId: "implicit-target",
|
|
1050
1098
|
loaderId: "implicit-loader",
|
|
1051
1099
|
type: "page",
|
|
@@ -1,12 +1,40 @@
|
|
|
1
1
|
import Node from "../../vendor/happy-dom/packages/happy-dom/src/nodes/node/Node.ts";
|
|
2
2
|
import Document from "../../vendor/happy-dom/packages/happy-dom/src/nodes/document/Document.ts";
|
|
3
|
+
import HTMLBaseElement from "../../vendor/happy-dom/packages/happy-dom/src/nodes/html-base-element/HTMLBaseElement.ts";
|
|
3
4
|
import HTMLHyperlinkElementUtility from "../../vendor/happy-dom/packages/happy-dom/src/nodes/html-hyperlink-element/HTMLHyperlinkElementUtility.ts";
|
|
5
|
+
import * as PropertySymbol from "../../vendor/happy-dom/packages/happy-dom/src/PropertySymbol.ts";
|
|
6
|
+
|
|
7
|
+
// Happy DOM reads baseURI on every attribute set, so the parser looked up
|
|
8
|
+
// `base[href]` once per attribute: a whole-document scan each time when there
|
|
9
|
+
// is no base. The first base[href] of each document is cached until one of
|
|
10
|
+
// that document's <base> elements is connected, disconnected or has an
|
|
11
|
+
// attribute changed. An adopted <base> disconnects from its old document
|
|
12
|
+
// first, so both documents are invalidated.
|
|
13
|
+
const baseCache = new WeakMap();
|
|
14
|
+
{
|
|
15
|
+
const prototype = HTMLBaseElement.prototype;
|
|
16
|
+
const inherited = Object.getPrototypeOf(prototype);
|
|
17
|
+
for (const symbol of [PropertySymbol.connectedToDocument, PropertySymbol.disconnectedFromDocument,
|
|
18
|
+
PropertySymbol.onSetAttribute, PropertySymbol.onRemoveAttribute]) {
|
|
19
|
+
prototype[symbol] = function(...args) {
|
|
20
|
+
baseCache.delete(this[PropertySymbol.ownerDocument]);
|
|
21
|
+
return inherited[symbol].apply(this, args);
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function firstBase(document) {
|
|
27
|
+
if (baseCache.has(document)) return baseCache.get(document);
|
|
28
|
+
const base = document.querySelector?.("base[href]") ?? null;
|
|
29
|
+
baseCache.set(document, base);
|
|
30
|
+
return base;
|
|
31
|
+
}
|
|
4
32
|
|
|
5
33
|
/** HTML document base URL, shared by the inert DOM and its page bindings. */
|
|
6
34
|
export function documentBaseURL(node) {
|
|
7
35
|
const document = node?.nodeType === 9 ? node : node?.ownerDocument;
|
|
8
36
|
const fallback = document?.URL ?? "about:blank";
|
|
9
|
-
const base = document
|
|
37
|
+
const base = document ? firstBase(document) : null;
|
|
10
38
|
if (!base) return fallback;
|
|
11
39
|
try {
|
|
12
40
|
const url = new URL(base.getAttribute("href"), fallback);
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import * as PropertySymbol from '../../vendor/happy-dom/packages/happy-dom/src/PropertySymbol.ts';
|
|
2
|
+
|
|
3
|
+
// One notification microtask for this renderer agent, with one ordered record
|
|
4
|
+
// queue per observer. Target registrations must not schedule their own delivery.
|
|
5
|
+
const enqueueMicrotask = globalThis.queueMicrotask;
|
|
6
|
+
const pending = new Set();
|
|
7
|
+
let queued = false;
|
|
8
|
+
let creationIndex = 0;
|
|
9
|
+
|
|
10
|
+
function activate(observer) {
|
|
11
|
+
pending.add(observer);
|
|
12
|
+
if (queued) return;
|
|
13
|
+
queued = true;
|
|
14
|
+
enqueueMicrotask(() => {
|
|
15
|
+
queued = false;
|
|
16
|
+
const observers = [...pending].sort((left, right) => left.order - right.order);
|
|
17
|
+
pending.clear();
|
|
18
|
+
for (const observer of observers) observer.deliver();
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function optionsOf(window, input = {}) {
|
|
23
|
+
const attributes = input.attributes === undefined
|
|
24
|
+
? input.attributeOldValue !== undefined || input.attributeFilter !== undefined
|
|
25
|
+
: Boolean(input.attributes);
|
|
26
|
+
const characterData = input.characterData === undefined
|
|
27
|
+
? input.characterDataOldValue !== undefined : Boolean(input.characterData);
|
|
28
|
+
const options = {
|
|
29
|
+
childList: Boolean(input.childList), attributes, characterData,
|
|
30
|
+
subtree: Boolean(input.subtree),
|
|
31
|
+
attributeOldValue: Boolean(input.attributeOldValue),
|
|
32
|
+
characterDataOldValue: Boolean(input.characterDataOldValue),
|
|
33
|
+
attributeFilter: input.attributeFilter === undefined ? null : Array.from(input.attributeFilter, String),
|
|
34
|
+
};
|
|
35
|
+
if ((!options.childList && !attributes && !characterData) ||
|
|
36
|
+
(!attributes && (options.attributeOldValue || options.attributeFilter !== null)) ||
|
|
37
|
+
(!characterData && options.characterDataOldValue)) {
|
|
38
|
+
throw new window.TypeError('Invalid MutationObserver options');
|
|
39
|
+
}
|
|
40
|
+
return options;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function installMutationObservers(window) {
|
|
44
|
+
window.MutationObserver = class MutationObserver {
|
|
45
|
+
#callback;
|
|
46
|
+
#registrations = new Map();
|
|
47
|
+
#records = [];
|
|
48
|
+
#seen = new WeakMap();
|
|
49
|
+
#destroyed = false;
|
|
50
|
+
#delivery;
|
|
51
|
+
|
|
52
|
+
constructor(callback) {
|
|
53
|
+
if (typeof callback !== 'function') throw new window.TypeError('MutationObserver callback must be a function');
|
|
54
|
+
this.#callback = callback;
|
|
55
|
+
this.#delivery = { order: creationIndex++, deliver: () => this.#deliver() };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
observe(target, input) {
|
|
59
|
+
if (this.#destroyed || window.closed) return;
|
|
60
|
+
if (!target || typeof target[PropertySymbol.observeMutations] !== 'function') {
|
|
61
|
+
throw new window.TypeError('MutationObserver.observe requires a Node');
|
|
62
|
+
}
|
|
63
|
+
const options = optionsOf(window, input);
|
|
64
|
+
const previous = this.#registrations.get(target);
|
|
65
|
+
if (previous) this.#remove(previous);
|
|
66
|
+
const registration = { target, options, transientRoots: new Set(), callback: null, listener: null };
|
|
67
|
+
registration.callback = (record) => this.#report(registration, record);
|
|
68
|
+
registration.listener = {
|
|
69
|
+
// Removals establish temporary registrations even if childList records
|
|
70
|
+
// were not requested. These survive until this observer is notified.
|
|
71
|
+
options: { ...options, childList: options.childList || options.subtree },
|
|
72
|
+
callback: new WeakRef(registration.callback),
|
|
73
|
+
};
|
|
74
|
+
this.#registrations.set(target, registration);
|
|
75
|
+
target[PropertySymbol.observeMutations](registration.listener);
|
|
76
|
+
if (!window[PropertySymbol.mutationObservers].includes(this)) {
|
|
77
|
+
window[PropertySymbol.mutationObservers].push(this);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
#clearTransient(registration) {
|
|
82
|
+
for (const root of registration.transientRoots) {
|
|
83
|
+
root[PropertySymbol.unobserveMutations](registration.listener);
|
|
84
|
+
}
|
|
85
|
+
registration.transientRoots.clear();
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
#remove(registration) {
|
|
89
|
+
this.#clearTransient(registration);
|
|
90
|
+
registration.target[PropertySymbol.unobserveMutations](registration.listener);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
#report(registration, record) {
|
|
94
|
+
const { options } = registration;
|
|
95
|
+
if (options.subtree && record.type === 'childList') {
|
|
96
|
+
for (const root of record.removedNodes) {
|
|
97
|
+
if (!registration.transientRoots.has(root)) {
|
|
98
|
+
registration.transientRoots.add(root);
|
|
99
|
+
root[PropertySymbol.observeMutations](registration.listener);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
if (record.removedNodes.length) activate(this.#delivery);
|
|
103
|
+
}
|
|
104
|
+
if (record.type === 'childList' && !options.childList) return;
|
|
105
|
+
const wantsOld = record.type === 'attributes' ? options.attributeOldValue
|
|
106
|
+
: record.type === 'characterData' && options.characterDataOldValue;
|
|
107
|
+
const existing = this.#seen.get(record);
|
|
108
|
+
if (existing) {
|
|
109
|
+
if (wantsOld) existing.oldValue = record.oldValue;
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
const copy = Object.assign(Object.create(Object.getPrototypeOf(record)), record, {
|
|
113
|
+
oldValue: wantsOld ? record.oldValue : null,
|
|
114
|
+
});
|
|
115
|
+
this.#seen.set(record, copy);
|
|
116
|
+
this.#records.push(copy);
|
|
117
|
+
activate(this.#delivery);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
#deliver() {
|
|
121
|
+
const records = this.takeRecords();
|
|
122
|
+
for (const registration of this.#registrations.values()) this.#clearTransient(registration);
|
|
123
|
+
if (!records.length || this.#destroyed || window.closed) return;
|
|
124
|
+
try {
|
|
125
|
+
this.#callback.call(this, records, this);
|
|
126
|
+
} catch (error) {
|
|
127
|
+
window[PropertySymbol.dispatchError](error);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
takeRecords() {
|
|
132
|
+
const records = this.#records;
|
|
133
|
+
this.#records = [];
|
|
134
|
+
this.#seen = new WeakMap();
|
|
135
|
+
return records;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
disconnect() {
|
|
139
|
+
for (const registration of this.#registrations.values()) this.#remove(registration);
|
|
140
|
+
this.#registrations.clear();
|
|
141
|
+
this.takeRecords();
|
|
142
|
+
// Window destruction iterates this array. Do not splice it during that
|
|
143
|
+
// iteration, otherwise the next observer would survive teardown.
|
|
144
|
+
if (!window.closed) {
|
|
145
|
+
const observers = window[PropertySymbol.mutationObservers];
|
|
146
|
+
const index = observers.indexOf(this);
|
|
147
|
+
if (index >= 0) observers.splice(index, 1);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
[PropertySymbol.destroy]() {
|
|
152
|
+
this.#destroyed = true;
|
|
153
|
+
this.disconnect();
|
|
154
|
+
pending.delete(this.#delivery);
|
|
155
|
+
}
|
|
156
|
+
};
|
|
157
|
+
}
|
package/lib/happy-dom/parser.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { makeLinksInert } from './inert-link.js';
|
|
2
|
+
import { installMutationObservers } from './mutation-observer.js';
|
|
2
3
|
import HTMLParser from "../../vendor/happy-dom/packages/happy-dom/src/html-parser/HTMLParser.ts";
|
|
3
4
|
import Window from "../../vendor/happy-dom/packages/happy-dom/src/window/Window.ts";
|
|
4
5
|
import * as PropertySymbol from "../../vendor/happy-dom/packages/happy-dom/src/PropertySymbol.ts";
|
|
@@ -41,6 +42,7 @@ export function createInertWindow(url = "about:blank") {
|
|
|
41
42
|
navigation: { disableFallbackToSetURL: true },
|
|
42
43
|
},
|
|
43
44
|
});
|
|
45
|
+
installMutationObservers(window);
|
|
44
46
|
makeLinksInert(window);
|
|
45
47
|
return window;
|
|
46
48
|
}
|