buninu-browser 0.5.0 → 0.6.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 +9 -3
- package/lib/audio/pcm.js +11 -0
- package/lib/audio/pulse-client.js +7 -2
- package/lib/cdp-server.js +31 -7
- package/lib/happy-dom/document-base.js +29 -1
- package/lib/happy-dom/inert-link.js +26 -0
- package/lib/happy-dom/mutation-observer.js +157 -0
- package/lib/happy-dom/node-iterator-removal.js +36 -0
- package/lib/happy-dom/node-replacement.js +60 -0
- package/lib/happy-dom/parser.js +6 -1
- package/lib/headless-shell.js +49 -9
- package/lib/layout/repaint.js +89 -0
- package/lib/layout/text-layout.js +4 -2
- 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 +107 -3
- 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 +577 -201
- 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/compare-node-iterator.js +42 -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/fractional-text-scale.js +38 -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/node-iterator-cases.js +59 -0
- package/test/fixtures/node-iterator-chromium.json +315 -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/fractional-text-scale.test.js +6 -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/inert-link.test.js +37 -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/node-iterator.test.js +81 -0
- package/test/observer-invalidation.test.js +12 -0
- package/test/page-scripts.test.js +151 -1
- 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/text-layout.test.js +15 -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/test/window-resize.test.js +29 -0
package/README.md
CHANGED
|
@@ -291,9 +291,9 @@ borrows a contiguous raster view instead of rescaling; encoded output uses
|
|
|
291
291
|
the same crop. `Page.getLayoutMetrics` provides the viewport position so
|
|
292
292
|
Casty can follow document scrolling.
|
|
293
293
|
|
|
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.
|
|
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. 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
295
|
|
|
296
|
-
Casty 也會在 `Page.startScreencast` 傳入私有 `clip`;優先於相容用的 max 限制,以原像素密度裁切、不縮放。寬高是 CSS 單位,實體像素仍隨 device scale 換算。此擴充不是標準 CDP
|
|
296
|
+
Casty 也會在 `Page.startScreencast` 傳入私有 `clip`;優先於相容用的 max 限制,以原像素密度裁切、不縮放。寬高是 CSS 單位,實體像素仍隨 device scale 換算。此擴充不是標準 CDP。寬高都為零時只發送更新通知:server 回傳固定 1×1 PNG 佔位資料,不渲染或編碼預覽;一般 captureScreenshot 仍拒絕零尺寸。
|
|
297
297
|
|
|
298
298
|
標準 CDP `clip` 也適用於 raw 截圖;小數 CSS 尺寸保留到最後才換算成實體像素,
|
|
299
299
|
避免字體大小改變後差一列而觸發 bunterm 縮放。`clip.scale: 1` 的全寬裁切
|
|
@@ -469,7 +469,13 @@ it to `buninu-browser.log` in the working directory, or set
|
|
|
469
469
|
|
|
470
470
|
- page errors and page `console` output;
|
|
471
471
|
- per-phase load timings;
|
|
472
|
-
- renderer crashes and watchdog kills.
|
|
472
|
+
- renderer crashes and explicitly enabled watchdog kills.
|
|
473
|
+
|
|
474
|
+
Renderer and video-worker startup have no deadline. Document navigation also
|
|
475
|
+
waits until completion, failure, or explicit close. The browser does not enable
|
|
476
|
+
the renderer method watchdog by default: slow initialization or page work must
|
|
477
|
+
not kill the renderer. Internal callers can opt into a method watchdog through
|
|
478
|
+
`rendererTimeout`; this does not impose a startup or navigation deadline.
|
|
473
479
|
|
|
474
480
|
Renderers cannot open files under seccomp, so the controller collects their
|
|
475
481
|
output and writes the log.
|
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,20 @@ 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?.(() => {
|
|
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;
|
|
44
47
|
for (const target of state.targets.values()) {
|
|
45
|
-
if (target.screencast && target.screencastEmit)
|
|
48
|
+
if (target.screencast && target.screencastEmit) {
|
|
49
|
+
target.screencastScrollOffset = scrollOffset;
|
|
50
|
+
pushScreencastFrame(context, state, target);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
if (state.implicitTarget?.screencast) {
|
|
54
|
+
state.implicitTarget.screencastScrollOffset = scrollOffset;
|
|
55
|
+
pushScreencastFrame(context, state, state.implicitTarget);
|
|
46
56
|
}
|
|
47
|
-
if (state.implicitTarget?.screencast) pushScreencastFrame(context, state, state.implicitTarget);
|
|
48
57
|
});
|
|
49
58
|
context.onLifecycle?.((name) => {
|
|
50
59
|
for (const target of state.targets.values()) emitLifecycle(target, name);
|
|
@@ -844,6 +853,7 @@ function createTarget(state, url = "about:blank", options = {}) {
|
|
|
844
853
|
deviceScaleFactor: 1,
|
|
845
854
|
},
|
|
846
855
|
screencast: null,
|
|
856
|
+
screencastScrollOffset: null,
|
|
847
857
|
screencastFrames: new Set(),
|
|
848
858
|
};
|
|
849
859
|
state.targets.set(targetId, target);
|
|
@@ -892,6 +902,7 @@ function stopScreencast(target) {
|
|
|
892
902
|
target.screencast = null;
|
|
893
903
|
target.screencastEmit = null;
|
|
894
904
|
target.screencastPending = false;
|
|
905
|
+
target.screencastScrollOffset = null;
|
|
895
906
|
target.screencastFrames.clear();
|
|
896
907
|
}
|
|
897
908
|
|
|
@@ -918,10 +929,21 @@ async function pushScreencastFrame(ctx, state, target) {
|
|
|
918
929
|
|
|
919
930
|
async function emitScreencastFrame(ctx, state, target) {
|
|
920
931
|
const stream = target.screencast, emit = target.screencastEmit;
|
|
921
|
-
const
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
932
|
+
const loaderId = target.loaderId;
|
|
933
|
+
// This belongs to one invalidation, not a lasting cache. Pending updates
|
|
934
|
+
// replace it with the latest position; start/restart without one still query.
|
|
935
|
+
const notifiedOffset = target.screencastScrollOffset;
|
|
936
|
+
target.screencastScrollOffset = null;
|
|
937
|
+
// Private zero-area screencast: signal invalidation without rendering pixels.
|
|
938
|
+
// captureScreenshot keeps its ordinary clip validation.
|
|
939
|
+
const zeroArea = stream.clip?.width === 0 && stream.clip?.height === 0;
|
|
940
|
+
const data = zeroArea
|
|
941
|
+
? "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Wl6SAAAAABJRU5ErkJggg=="
|
|
942
|
+
: await screenshotData(ctx, target, stream);
|
|
943
|
+
if (!data || stream !== target.screencast || emit !== target.screencastEmit || loaderId !== target.loaderId) return;
|
|
944
|
+
const scrollOffset = zeroArea && notifiedOffset ? notifiedOffset
|
|
945
|
+
: await ctx.scrollOffset?.() ?? { x: 0, y: 0 };
|
|
946
|
+
if (stream !== target.screencast || emit !== target.screencastEmit || loaderId !== target.loaderId) return;
|
|
925
947
|
const sessionId = state.nextScreencastFrameId++;
|
|
926
948
|
target.screencastFrames.add(sessionId);
|
|
927
949
|
emit("Page.screencastFrame", {
|
|
@@ -950,6 +972,7 @@ function getPendingInitialNavigationTarget(state) {
|
|
|
950
972
|
|
|
951
973
|
function navigateTarget(state, target, url, emit, options = {}) {
|
|
952
974
|
const deferLifecycle = typeof state.context?.onLifecycle === "function";
|
|
975
|
+
target.screencastScrollOffset = null;
|
|
953
976
|
target.lifecycleEmit = null;
|
|
954
977
|
target.url = url;
|
|
955
978
|
loadDocument(target, url);
|
|
@@ -1045,6 +1068,7 @@ function getImplicitTarget(state) {
|
|
|
1045
1068
|
state.implicitTarget = {
|
|
1046
1069
|
targetId: "implicit-target",
|
|
1047
1070
|
screencast: null,
|
|
1071
|
+
screencastScrollOffset: null,
|
|
1048
1072
|
screencastFrames: new Set(),
|
|
1049
1073
|
frameId: "implicit-target",
|
|
1050
1074
|
loaderId: "implicit-loader",
|
|
@@ -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,26 @@
|
|
|
1
|
+
import * as PropertySymbol from '../../vendor/happy-dom/packages/happy-dom/src/PropertySymbol.ts';
|
|
2
|
+
|
|
3
|
+
// Happy DOM shares HTMLLinkElement.prototype between windows. Install one
|
|
4
|
+
// dispatch hook, but bypass its loader only for windows owned by our renderer.
|
|
5
|
+
const inertWindows = new WeakSet();
|
|
6
|
+
let installed = false;
|
|
7
|
+
export function makeLinksInert(window) {
|
|
8
|
+
inertWindows.add(window);
|
|
9
|
+
if (installed) return;
|
|
10
|
+
installed = true;
|
|
11
|
+
const base = window.HTMLElement.prototype;
|
|
12
|
+
const prototype = window.HTMLLinkElement.prototype;
|
|
13
|
+
const connected = prototype[PropertySymbol.connectedToDocument];
|
|
14
|
+
const setAttribute = prototype[PropertySymbol.onSetAttribute];
|
|
15
|
+
prototype[PropertySymbol.connectedToDocument] = function() {
|
|
16
|
+
if (!inertWindows.has(this[PropertySymbol.window])) return connected.call(this);
|
|
17
|
+
// The renderer owns load/error dispatch; the disabled parser loader must
|
|
18
|
+
// not emit a premature error when a dynamic stylesheet is connected.
|
|
19
|
+
base[PropertySymbol.connectedToDocument].call(this);
|
|
20
|
+
this.ownerDocument[PropertySymbol.clearComputedStyleCache]();
|
|
21
|
+
};
|
|
22
|
+
prototype[PropertySymbol.onSetAttribute] = function(attribute, replacedAttribute) {
|
|
23
|
+
if (!inertWindows.has(this[PropertySymbol.window])) return setAttribute.call(this, attribute, replacedAttribute);
|
|
24
|
+
base[PropertySymbol.onSetAttribute].call(this, attribute, replacedAttribute);
|
|
25
|
+
};
|
|
26
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import Node from '../../vendor/happy-dom/packages/happy-dom/src/nodes/node/Node.ts';
|
|
2
|
+
import * as PropertySymbol from '../../vendor/happy-dom/packages/happy-dom/src/PropertySymbol.ts';
|
|
3
|
+
|
|
4
|
+
// NodeIterator's pre-removing steps must run synchronously, including removals
|
|
5
|
+
// by innerHTML, normalize and reparenting. All reach Happy DOM's internal hook.
|
|
6
|
+
const roots = new WeakMap();
|
|
7
|
+
const cleanup = new FinalizationRegistry(({ entries, ref }) => entries.delete(ref));
|
|
8
|
+
let installed = false;
|
|
9
|
+
export function observeIteratorRemoval(root, iterator) {
|
|
10
|
+
let entries = roots.get(root);
|
|
11
|
+
if (!entries) roots.set(root, entries = new Set());
|
|
12
|
+
const ref = new WeakRef(iterator);
|
|
13
|
+
entries.add(ref);
|
|
14
|
+
cleanup.register(iterator, { entries, ref });
|
|
15
|
+
if (installed) return;
|
|
16
|
+
installed = true;
|
|
17
|
+
const remove = Node.prototype[PropertySymbol.removeChild];
|
|
18
|
+
Node.prototype[PropertySymbol.removeChild] = function(node) {
|
|
19
|
+
node = node?.[PropertySymbol.proxy] ?? node;
|
|
20
|
+
// select/form methods are bound to the raw target, while parentNode and
|
|
21
|
+
// iterator roots expose its proxy. Compare and walk the public identity.
|
|
22
|
+
const parent = this[PropertySymbol.proxy] ?? this;
|
|
23
|
+
if (node?.parentNode === parent) {
|
|
24
|
+
for (let ancestor = parent; ancestor; ancestor = ancestor.parentNode) {
|
|
25
|
+
const entries = roots.get(ancestor);
|
|
26
|
+
if (!entries) continue;
|
|
27
|
+
for (const ref of entries) {
|
|
28
|
+
const iterator = ref.deref();
|
|
29
|
+
if (iterator) iterator.preRemove(node);
|
|
30
|
+
else entries.delete(ref);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return remove.call(this, node);
|
|
35
|
+
};
|
|
36
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/** DOM replace: validate first, detach the incoming node, remove the outgoing
|
|
2
|
+
* node, then insert. Happy DOM's insert-then-remove order skips replacements
|
|
3
|
+
* in live NodeIterators. Keep this on the renderer bindings' mutation path.
|
|
4
|
+
* https://dom.spec.whatwg.org/#concept-node-replace */
|
|
5
|
+
function validate(parent, node, child) {
|
|
6
|
+
const fail = (name) => { throw new DOMException('Invalid node replacement', name); };
|
|
7
|
+
if (![1,9,11].includes(parent.nodeType)) fail('HierarchyRequestError');
|
|
8
|
+
for (let ancestor = parent; ancestor; ancestor = ancestor.parentNode ?? ancestor.host) {
|
|
9
|
+
if (ancestor === node) fail('HierarchyRequestError');
|
|
10
|
+
}
|
|
11
|
+
if (child.parentNode !== parent) fail('NotFoundError');
|
|
12
|
+
if (![1,3,4,7,8,10,11].includes(node.nodeType)) fail('HierarchyRequestError');
|
|
13
|
+
if (node.nodeType === 10 && parent.nodeType !== 9) fail('HierarchyRequestError');
|
|
14
|
+
if (parent.nodeType !== 9) return;
|
|
15
|
+
// Validate the final document, allowing the outgoing child and an incoming
|
|
16
|
+
// sibling to be replaced/moved without counting either twice.
|
|
17
|
+
const added = node.nodeType === 11 ? [...node.childNodes] : [node];
|
|
18
|
+
const final = [];
|
|
19
|
+
for (const current of parent.childNodes) {
|
|
20
|
+
if (current === child) final.push(...added);
|
|
21
|
+
else if (current !== node) final.push(current);
|
|
22
|
+
}
|
|
23
|
+
let element = false, doctype = false;
|
|
24
|
+
for (const current of final) {
|
|
25
|
+
if (current.nodeType === 3 || current.nodeType === 4) fail('HierarchyRequestError');
|
|
26
|
+
if (current.nodeType === 1) {
|
|
27
|
+
if (element) fail('HierarchyRequestError');
|
|
28
|
+
element = true;
|
|
29
|
+
} else if (current.nodeType === 10) {
|
|
30
|
+
if (doctype || element) fail('HierarchyRequestError');
|
|
31
|
+
doctype = true;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function replaceNode(parent, node, child) {
|
|
37
|
+
validate(parent, node, child);
|
|
38
|
+
let reference = child.nextSibling;
|
|
39
|
+
if (reference === node) reference = node.nextSibling;
|
|
40
|
+
if (node.parentNode) node.parentNode.removeChild(node);
|
|
41
|
+
if (child.parentNode) child.parentNode.removeChild(child);
|
|
42
|
+
parent.insertBefore(node, reference);
|
|
43
|
+
return child;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function replaceWithNodes(child, nodes) {
|
|
47
|
+
const parent = child.parentNode;
|
|
48
|
+
if (!parent) return;
|
|
49
|
+
let reference = child.nextSibling;
|
|
50
|
+
while (reference && nodes.includes(reference)) reference = reference.nextSibling;
|
|
51
|
+
// DOM's convert-nodes algorithm moves multiple arguments into a fragment
|
|
52
|
+
// first, including the receiver if it occurs among the arguments.
|
|
53
|
+
let node = nodes[0];
|
|
54
|
+
if (nodes.length !== 1) {
|
|
55
|
+
node = child.ownerDocument.createDocumentFragment();
|
|
56
|
+
for (const item of nodes) node.appendChild(item);
|
|
57
|
+
}
|
|
58
|
+
if (child.parentNode === parent) replaceNode(parent, node, child);
|
|
59
|
+
else parent.insertBefore(node, reference);
|
|
60
|
+
}
|
package/lib/happy-dom/parser.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { makeLinksInert } from './inert-link.js';
|
|
2
|
+
import { installMutationObservers } from './mutation-observer.js';
|
|
1
3
|
import HTMLParser from "../../vendor/happy-dom/packages/happy-dom/src/html-parser/HTMLParser.ts";
|
|
2
4
|
import Window from "../../vendor/happy-dom/packages/happy-dom/src/window/Window.ts";
|
|
3
5
|
import * as PropertySymbol from "../../vendor/happy-dom/packages/happy-dom/src/PropertySymbol.ts";
|
|
@@ -26,7 +28,7 @@ export function parseHTMLDocument(html, url = "about:blank", referrer = "") {
|
|
|
26
28
|
|
|
27
29
|
/** An isolated parser context: no script execution, resource loads or page custom elements. */
|
|
28
30
|
export function createInertWindow(url = "about:blank") {
|
|
29
|
-
|
|
31
|
+
const window = new Window({
|
|
30
32
|
url,
|
|
31
33
|
settings: {
|
|
32
34
|
disableJavaScriptEvaluation: true,
|
|
@@ -40,6 +42,9 @@ export function createInertWindow(url = "about:blank") {
|
|
|
40
42
|
navigation: { disableFallbackToSetURL: true },
|
|
41
43
|
},
|
|
42
44
|
});
|
|
45
|
+
installMutationObservers(window);
|
|
46
|
+
makeLinksInert(window);
|
|
47
|
+
return window;
|
|
43
48
|
}
|
|
44
49
|
|
|
45
50
|
/**
|
package/lib/headless-shell.js
CHANGED
|
@@ -90,7 +90,7 @@ export async function runHeadlessShell(args = Bun.argv.slice(2)) {
|
|
|
90
90
|
* WebKit identity defaults on; webkit: false selects Chromium. Mobile takes priority.
|
|
91
91
|
*/
|
|
92
92
|
export async function createBrowser({
|
|
93
|
-
allowHostJs = false, spareRenderer = true, network = new NetworkService(), rendererTimeout =
|
|
93
|
+
allowHostJs = false, spareRenderer = true, network = new NetworkService(), rendererTimeout = 0, mobile = false,
|
|
94
94
|
waitForRenderer = true, webkit = true,
|
|
95
95
|
clipboard = null,
|
|
96
96
|
} = {}) {
|
|
@@ -105,6 +105,7 @@ export async function createBrowser({
|
|
|
105
105
|
let current = null;
|
|
106
106
|
let currentKey = null;
|
|
107
107
|
let spare = null;
|
|
108
|
+
let sparePreparation = null;
|
|
108
109
|
let closed = false;
|
|
109
110
|
// Settles when the first renderer shows about:blank (assigned below).
|
|
110
111
|
let rendererReady = null;
|
|
@@ -195,8 +196,8 @@ export async function createBrowser({
|
|
|
195
196
|
if (host !== current) return;
|
|
196
197
|
void download({ url, body: data, attributeName: name, totalBytes: data.byteLength });
|
|
197
198
|
},
|
|
198
|
-
onFrame: () => {
|
|
199
|
-
if (host === current) notify(frameListeners);
|
|
199
|
+
onFrame: (frame) => {
|
|
200
|
+
if (host === current) notify(frameListeners, frame);
|
|
200
201
|
},
|
|
201
202
|
onLifecycle: (name, generation) => {
|
|
202
203
|
let generations = lifecycleSeen.get(host);
|
|
@@ -207,7 +208,11 @@ export async function createBrowser({
|
|
|
207
208
|
generations.set(generation, names);
|
|
208
209
|
if (names.has(name)) return;
|
|
209
210
|
names.add(name);
|
|
210
|
-
if (committed.host === host && committed.generation === generation)
|
|
211
|
+
if (committed.host === host && committed.generation === generation) {
|
|
212
|
+
if (name === "load" && sparePreparation?.host === host && sparePreparation.generation === generation)
|
|
213
|
+
armSparePreparation(sparePreparation, 1_000);
|
|
214
|
+
notify(lifecycleListeners, name);
|
|
215
|
+
}
|
|
211
216
|
},
|
|
212
217
|
});
|
|
213
218
|
return host;
|
|
@@ -232,12 +237,45 @@ export async function createBrowser({
|
|
|
232
237
|
|
|
233
238
|
function prepareSpare() {
|
|
234
239
|
if (!spareRenderer || closed || spare) return;
|
|
235
|
-
|
|
236
|
-
spare
|
|
237
|
-
|
|
240
|
+
const preparing = createHost();
|
|
241
|
+
spare = preparing;
|
|
242
|
+
preparing.start().catch(() => {
|
|
243
|
+
// An acquired spare may fail after its successor has been scheduled.
|
|
244
|
+
// Only this host owns its slot; never clear a newer spare's reference.
|
|
245
|
+
if (spare === preparing) {
|
|
246
|
+
spare = null;
|
|
247
|
+
preparing.close();
|
|
248
|
+
}
|
|
238
249
|
});
|
|
239
250
|
}
|
|
240
251
|
|
|
252
|
+
function cancelSparePreparation() {
|
|
253
|
+
if (sparePreparation) clearTimeout(sparePreparation.timer);
|
|
254
|
+
sparePreparation = null;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function armSparePreparation(request, delay) {
|
|
258
|
+
clearTimeout(request.timer);
|
|
259
|
+
request.timer = setTimeout(() => {
|
|
260
|
+
if (sparePreparation !== request) return;
|
|
261
|
+
sparePreparation = null;
|
|
262
|
+
if (current === request.host && committed.host === request.host
|
|
263
|
+
&& committed.generation === request.generation) prepareSpare();
|
|
264
|
+
}, delay);
|
|
265
|
+
request.timer.unref?.();
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function scheduleSparePreparation(host, generation) {
|
|
269
|
+
cancelSparePreparation();
|
|
270
|
+
if (!spareRenderer || closed || spare || host !== current) return;
|
|
271
|
+
const request = { host, generation, timer: null };
|
|
272
|
+
sparePreparation = request;
|
|
273
|
+
// Delay after load, rather than competing with the new page's initial
|
|
274
|
+
// scripts/layout. This is not CPU-idle detection. A bounded fallback
|
|
275
|
+
// still warms a spare for pages whose load event never arrives.
|
|
276
|
+
armSparePreparation(request, reachedLifecycle(host, generation).has("load") ? 1_000 : 30_000);
|
|
277
|
+
}
|
|
278
|
+
|
|
241
279
|
/** The renderer for a URL's origin, swapping processes on an origin change. */
|
|
242
280
|
async function rendererFor(url) {
|
|
243
281
|
let key = originKey(url);
|
|
@@ -264,7 +302,6 @@ export async function createBrowser({
|
|
|
264
302
|
current = next;
|
|
265
303
|
currentKey = key;
|
|
266
304
|
previous?.close();
|
|
267
|
-
prepareSpare();
|
|
268
305
|
return current;
|
|
269
306
|
}
|
|
270
307
|
|
|
@@ -345,12 +382,14 @@ export async function createBrowser({
|
|
|
345
382
|
selectRenderer = url => rendererFor(url, { qmlHost });
|
|
346
383
|
// qml handle end
|
|
347
384
|
|
|
385
|
+
cancelSparePreparation();
|
|
348
386
|
const host = invoke ? current : await selectRenderer(document.url);
|
|
349
387
|
let generation;
|
|
350
388
|
try {
|
|
351
389
|
generation = await (invoke ?? ((method, ...params) => host.call(method, ...params)))("loadDocument", document);
|
|
352
390
|
} finally { documentReady.get(host)?.resolve(); }
|
|
353
391
|
committed = { host, generation };
|
|
392
|
+
scheduleSparePreparation(host, generation);
|
|
354
393
|
for (const name of reachedLifecycle(host, generation)) notify(lifecycleListeners, name);
|
|
355
394
|
writeLog("navigation", `${document.url}: first paint in ${Math.round(performance.now() - fetched)}ms`);
|
|
356
395
|
}
|
|
@@ -502,7 +541,7 @@ export async function createBrowser({
|
|
|
502
541
|
press: (key, options) => call("press", key, options),
|
|
503
542
|
type: (text) => call("type", text),
|
|
504
543
|
scrollOffset: () => composite("scrollOffset"),
|
|
505
|
-
/** Calls `listener()`
|
|
544
|
+
/** Calls `listener(frame?)` when the viewport changes, with its measured scroll offset when available. */
|
|
506
545
|
onFrame(listener) {
|
|
507
546
|
frameListeners.add(listener);
|
|
508
547
|
return () => frameListeners.delete(listener);
|
|
@@ -573,6 +612,7 @@ export async function createBrowser({
|
|
|
573
612
|
},
|
|
574
613
|
close() {
|
|
575
614
|
closed = true;
|
|
615
|
+
cancelSparePreparation();
|
|
576
616
|
current?.close();
|
|
577
617
|
spare?.close();
|
|
578
618
|
current = null;
|