buninu-browser 0.2.3 → 0.3.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 +86 -1
- package/lib/cdp-server.js +47 -14
- package/lib/clipboard.js +22 -0
- package/lib/happy-dom/document-base.js +33 -0
- package/lib/happy-dom/parser.js +15 -6
- package/lib/happy-dom/selector-matcher.js +2 -2
- package/lib/happy-dom/selector-pseudo-adapter.js +19 -0
- package/lib/happy-dom/svg-attribute-adapter.js +50 -0
- package/lib/headless-shell.js +40 -11
- package/lib/input/hit-test.js +67 -19
- package/lib/layout/text-layout.js +213 -91
- package/lib/media/byte-pipe.js +2 -2
- package/lib/media/position-audio.js +10 -1
- package/lib/media/source-snapshots.js +126 -0
- package/lib/media/video-keyframe.js +17 -4
- package/lib/media/webm-stream.js +106 -0
- package/lib/network/network-service.js +268 -120
- package/lib/paint/display-list.js +8 -28
- package/lib/paint/frame-cache.js +25 -0
- package/lib/paint/frame.js +77 -16
- package/lib/paint/paragraph-text.js +24 -0
- package/lib/paint/raster-readback.js +41 -0
- package/lib/paint/raw-frames.js +47 -0
- package/lib/paint/rounded-rect.js +82 -0
- package/lib/paint/shared-frame-pool.js +105 -0
- package/lib/render-tree/index.js +38 -6
- package/lib/render-tree/svg.js +15 -7
- package/lib/renderer/bindings.js +233 -80
- package/lib/renderer/broadcast-channel.js +76 -0
- package/lib/renderer/canvas-2d.js +257 -14
- package/lib/renderer/canvas-text.js +120 -0
- package/lib/renderer/compositor-channel.js +103 -0
- package/lib/renderer/compositor.js +34 -40
- package/lib/renderer/custom-element-reactions.js +25 -1
- package/lib/renderer/flags.js +1 -1
- package/lib/renderer/font-loading.js +227 -0
- package/lib/renderer/host.js +133 -98
- package/lib/renderer/image-resources.js +65 -0
- package/lib/renderer/interfaces.js +28 -0
- package/lib/renderer/layout-observers.js +236 -0
- package/lib/renderer/main.js +52 -5
- package/lib/renderer/media-source.js +126 -55
- package/lib/renderer/page-realm.js +287 -99
- package/lib/renderer/page-renderer.js +663 -239
- package/lib/renderer/prepared-script-cache.js +50 -0
- package/lib/renderer/rendered-text.js +108 -0
- package/lib/renderer/rewrite-worker.js +4 -3
- package/lib/renderer/script-rewrite.js +13 -2
- package/lib/renderer/scroll-into-view.js +28 -0
- package/lib/renderer/slot-assignment.js +60 -0
- package/lib/renderer/video-presentation.js +23 -4
- package/lib/renderer/workers.js +2 -2
- package/lib/style/computed-style.js +611 -104
- package/lib/style/selector-traits.js +49 -5
- package/package.json +1 -1
- package/test/broadcast-channel.test.js +26 -0
- package/test/canvas-2d.test.js +83 -2
- package/test/canvas-paint.test.js +9 -0
- package/test/canvas-text.test.js +41 -0
- package/test/cdp-stream.test.js +56 -0
- package/test/clipboard.test.js +35 -0
- package/test/computed-style-binding.test.js +151 -0
- package/test/computed-style.test.js +258 -1
- package/test/display-list.test.js +12 -0
- package/test/document-base.test.js +87 -0
- package/test/dom-platform.test.js +461 -0
- package/test/fetch-lifecycle.test.js +161 -0
- package/test/fixtures/browser-raw-demo.html +36 -0
- package/test/fixtures/canvas-paint.js +117 -0
- package/test/fixtures/canvas-text.js +42 -0
- package/test/fixtures/image-lifetime.js +90 -0
- package/test/fixtures/media-seek-snapshot.js +77 -0
- package/test/fixtures/mse-audio-chirp.webm +0 -0
- package/test/fixtures/mse-switch-large.mp4 +0 -0
- package/test/fixtures/mse-switch-small.mp4 +0 -0
- package/test/fixtures/raw-capture-benchmark.json +76 -0
- package/test/fixtures/renderer-memory.js +110 -0
- package/test/fixtures/summary-style.js +28 -0
- package/test/font-loading.test.js +97 -0
- package/test/grid.test.js +58 -0
- package/test/hit-test.test.js +21 -1
- package/test/image-lifetime.test.js +12 -0
- package/test/image-resources.test.js +94 -0
- package/test/local-media.test.js +84 -0
- package/test/media-seek-snapshot.test.js +11 -0
- package/test/media-source-config.test.js +98 -0
- package/test/media-source-webm-accounting.test.js +98 -0
- package/test/media-source-webm.test.js +108 -0
- package/test/navigation.test.js +1 -0
- package/test/network-service.test.js +388 -0
- package/test/page-scripts.test.js +1 -1
- package/test/paint-frame.test.js +103 -0
- package/test/paint-paragraph-cache.test.js +87 -0
- package/test/position-audio.test.js +24 -0
- package/test/positioning.test.js +22 -0
- package/test/prepared-script-cache.test.js +45 -0
- package/test/pulse-client.test.js +10 -3
- package/test/raster-readback.test.js +40 -0
- package/test/raw-capture.test.js +152 -0
- package/test/render-tree.test.js +28 -0
- package/test/renderer-memory.test.js +11 -0
- package/test/selector-traits.test.js +24 -0
- package/test/shared-frame-pool.test.js +63 -0
- package/test/style-sheets.test.js +51 -0
- package/test/svg.test.js +29 -0
- package/test/text-layout.test.js +106 -0
- package/test/video-keyframe.test.js +61 -0
- package/test/video-presentation.test.js +69 -0
- package/test/window-resize.test.js +36 -0
- package/tools/bench-raw-capture.js +32 -0
- package/tools/browser-lab.js +78 -0
- package/tools/browser-soak.js +75 -0
- package/tools/network-reference.js +49 -0
- package/tools/process-memory.js +38 -0
- package/tools/script-benchmark.js +25 -0
- package/ai-debug.log +0 -5537
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-document layout observers. A layout/scroll notification schedules one
|
|
3
|
+
* measurement pass for all observers; page callbacks run only after every
|
|
4
|
+
* target has been measured, avoiding a layout flush for each lazy image.
|
|
5
|
+
* Geometry comes from the renderer, never from Happy DOM's zero-size boxes.
|
|
6
|
+
*/
|
|
7
|
+
export function createLayoutObservers({ nodeOf, boundsOf, computedStyle, viewport, schedule, cancel, call, now }) {
|
|
8
|
+
const intersections = new Set();
|
|
9
|
+
const resizes = new Set();
|
|
10
|
+
let pending = null;
|
|
11
|
+
let measuring = false;
|
|
12
|
+
let disposed = false;
|
|
13
|
+
let lastGeometry = null;
|
|
14
|
+
|
|
15
|
+
const queue = () => {
|
|
16
|
+
if (disposed || measuring || pending !== null || (!intersections.size && !resizes.size)) return;
|
|
17
|
+
pending = schedule(flush);
|
|
18
|
+
};
|
|
19
|
+
function flush() {
|
|
20
|
+
pending = null;
|
|
21
|
+
if (disposed) return;
|
|
22
|
+
measuring = true;
|
|
23
|
+
const deliveries = [];
|
|
24
|
+
// Multiple observers commonly watch the same ancestor or target.
|
|
25
|
+
const bounds = new Map();
|
|
26
|
+
const styles = new Map();
|
|
27
|
+
const rectOf = (node) => {
|
|
28
|
+
if (!bounds.has(node)) bounds.set(node, node?.isConnected ? boundsOf(node) : null);
|
|
29
|
+
return bounds.get(node);
|
|
30
|
+
};
|
|
31
|
+
const styleOf = (node) => {
|
|
32
|
+
if (!styles.has(node)) styles.set(node, computedStyle(node));
|
|
33
|
+
return styles.get(node);
|
|
34
|
+
};
|
|
35
|
+
try {
|
|
36
|
+
for (const observer of resizes) {
|
|
37
|
+
const state = resizeStates.get(observer);
|
|
38
|
+
const entries = [];
|
|
39
|
+
for (const [node, target] of state.targets) {
|
|
40
|
+
const rect = rectOf(node);
|
|
41
|
+
const style = rect ? styleOf(node) : null;
|
|
42
|
+
const border = style?.borderWidths ?? ZERO_EDGES;
|
|
43
|
+
const padding = style?.padding ?? ZERO_EDGES;
|
|
44
|
+
const width = Math.max(0, (rect?.width ?? 0) - sum(border, 1, 3) - sum(padding, 1, 3));
|
|
45
|
+
const height = Math.max(0, (rect?.height ?? 0) - sum(border, 0, 2) - sum(padding, 0, 2));
|
|
46
|
+
const scale = viewport().devicePixelRatio || 1;
|
|
47
|
+
const entry = {
|
|
48
|
+
target: target.wrapper,
|
|
49
|
+
contentRect: rectangle({ left: number(padding[3]), top: number(padding[0]), width, height }),
|
|
50
|
+
borderBoxSize: [size(rect?.width ?? 0, rect?.height ?? 0)],
|
|
51
|
+
contentBoxSize: [size(width, height)],
|
|
52
|
+
devicePixelContentBoxSize: [size(Math.round(width * scale), Math.round(height * scale))],
|
|
53
|
+
};
|
|
54
|
+
const selected = target.box === "border-box" ? entry.borderBoxSize[0]
|
|
55
|
+
: target.box === "device-pixel-content-box" ? entry.devicePixelContentBoxSize[0] : entry.contentBoxSize[0];
|
|
56
|
+
if (!target.last || selected.inlineSize !== target.last.inlineSize || selected.blockSize !== target.last.blockSize) {
|
|
57
|
+
target.last = selected;
|
|
58
|
+
entries.push(entry);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
if (entries.length) deliveries.push([state.callback, observer, entries]);
|
|
62
|
+
}
|
|
63
|
+
for (const observer of intersections) {
|
|
64
|
+
const state = intersectionStates.get(observer);
|
|
65
|
+
const rootNode = nodeOf(state.root);
|
|
66
|
+
const screen = viewport();
|
|
67
|
+
const rootRect = rootNode?.nodeType === 1 ? rectOf(rootNode) : { left: 0, top: 0, width: screen.width, height: screen.height };
|
|
68
|
+
const rootBounds = rootRect ? marginRect(rectangle(rootRect), state.margin) : rectangle(ZERO_RECT);
|
|
69
|
+
for (const [node, target] of state.targets) {
|
|
70
|
+
const raw = rectOf(node);
|
|
71
|
+
const boundingClientRect = rectangle(raw ?? ZERO_RECT);
|
|
72
|
+
let intersection = raw && rootRect && (rootNode?.nodeType !== 1 || rootNode.contains(node))
|
|
73
|
+
? boundingClientRect : null;
|
|
74
|
+
// Clip through scrolling/overflow ancestors before the root.
|
|
75
|
+
for (let ancestor = node.parentElement; intersection && ancestor && ancestor !== rootNode; ancestor = ancestor.parentElement) {
|
|
76
|
+
const style = styleOf(ancestor);
|
|
77
|
+
if (style?.overflowX === "clip" || style?.overflowY === "clip") {
|
|
78
|
+
const clip = rectOf(ancestor);
|
|
79
|
+
intersection = clip ? intersect(intersection, rectangle(clip), style.overflowX === "clip", style.overflowY === "clip") : null;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
if (intersection) intersection = intersect(intersection, rootBounds);
|
|
83
|
+
const isIntersecting = intersection !== null;
|
|
84
|
+
const area = boundingClientRect.width * boundingClientRect.height;
|
|
85
|
+
const ratio = area ? (intersection ? intersection.width * intersection.height / area : 0) : (isIntersecting ? 1 : 0);
|
|
86
|
+
const thresholdIndex = state.thresholds.findIndex((threshold) => threshold > ratio);
|
|
87
|
+
const index = thresholdIndex === -1 ? state.thresholds.length : thresholdIndex;
|
|
88
|
+
if (target.index !== index || target.intersecting !== isIntersecting) {
|
|
89
|
+
target.index = index;
|
|
90
|
+
target.intersecting = isIntersecting;
|
|
91
|
+
state.records.push({ target: target.wrapper, time: now(), isIntersecting, intersectionRatio: ratio,
|
|
92
|
+
boundingClientRect, intersectionRect: intersection ?? rectangle(ZERO_RECT), rootBounds });
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
if (state.records.length) deliveries.push([state.callback, observer, state.records.splice(0)]);
|
|
96
|
+
}
|
|
97
|
+
} finally {
|
|
98
|
+
measuring = false;
|
|
99
|
+
}
|
|
100
|
+
for (const [callback, observer, entries] of deliveries) call(callback, observer, [entries, observer]);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const element = (target) => {
|
|
104
|
+
const node = nodeOf(target);
|
|
105
|
+
if (node?.nodeType !== 1) throw new TypeError("The target must be an Element.");
|
|
106
|
+
return node;
|
|
107
|
+
};
|
|
108
|
+
const intersectionStates = new WeakMap();
|
|
109
|
+
class IntersectionObserver {
|
|
110
|
+
constructor(callback, options = {}) {
|
|
111
|
+
if (typeof callback !== "function") throw new TypeError("IntersectionObserver callback must be a function.");
|
|
112
|
+
const root = options?.root ?? null;
|
|
113
|
+
const rootNode = root === null ? null : nodeOf(root);
|
|
114
|
+
if (root !== null && rootNode?.nodeType !== 1 && rootNode?.nodeType !== 9) {
|
|
115
|
+
throw new TypeError("IntersectionObserver root must be an Element or Document.");
|
|
116
|
+
}
|
|
117
|
+
const margin = parseMargin(options?.rootMargin ?? "0px");
|
|
118
|
+
const raw = options?.threshold ?? 0;
|
|
119
|
+
const thresholds = (typeof raw === "object" && raw !== null ? Array.from(raw, Number) : [Number(raw)]).sort((a, b) => a - b);
|
|
120
|
+
if (thresholds.some((value) => !Number.isFinite(value) || value < 0 || value > 1)) {
|
|
121
|
+
throw new RangeError("IntersectionObserver thresholds must be between 0 and 1.");
|
|
122
|
+
}
|
|
123
|
+
if (!thresholds.length) thresholds.push(0);
|
|
124
|
+
intersectionStates.set(this, { callback, root, margin, thresholds: Object.freeze(thresholds), targets: new Map(), records: [] });
|
|
125
|
+
}
|
|
126
|
+
get root() { return intersectionState(this).root; }
|
|
127
|
+
get rootMargin() { return intersectionState(this).margin.map(({ value, unit }) => `${value}${unit}`).join(" "); }
|
|
128
|
+
get thresholds() { return intersectionState(this).thresholds; }
|
|
129
|
+
observe(target) {
|
|
130
|
+
const state = intersectionState(this);
|
|
131
|
+
const node = element(target);
|
|
132
|
+
if (state.targets.has(node)) return;
|
|
133
|
+
state.targets.set(node, { wrapper: target, index: -1, intersecting: false });
|
|
134
|
+
intersections.add(this);
|
|
135
|
+
queue();
|
|
136
|
+
}
|
|
137
|
+
unobserve(target) {
|
|
138
|
+
const state = intersectionState(this);
|
|
139
|
+
state.targets.delete(element(target));
|
|
140
|
+
if (!state.targets.size) intersections.delete(this);
|
|
141
|
+
}
|
|
142
|
+
disconnect() {
|
|
143
|
+
intersectionState(this).targets.clear();
|
|
144
|
+
intersections.delete(this);
|
|
145
|
+
}
|
|
146
|
+
takeRecords() { return intersectionState(this).records.splice(0); }
|
|
147
|
+
}
|
|
148
|
+
const intersectionState = (observer) => {
|
|
149
|
+
const state = intersectionStates.get(observer);
|
|
150
|
+
if (!state) throw new TypeError("Illegal invocation");
|
|
151
|
+
return state;
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
const resizeStates = new WeakMap();
|
|
155
|
+
class ResizeObserver {
|
|
156
|
+
constructor(callback) {
|
|
157
|
+
if (typeof callback !== "function") throw new TypeError("ResizeObserver callback must be a function.");
|
|
158
|
+
resizeStates.set(this, { callback, targets: new Map() });
|
|
159
|
+
}
|
|
160
|
+
observe(target, options = {}) {
|
|
161
|
+
const state = resizeState(this);
|
|
162
|
+
const node = element(target);
|
|
163
|
+
const box = String(options?.box ?? "content-box");
|
|
164
|
+
if (!["content-box", "border-box", "device-pixel-content-box"].includes(box)) throw new TypeError("Invalid ResizeObserver box option.");
|
|
165
|
+
if (state.targets.get(node)?.box === box) return;
|
|
166
|
+
state.targets.set(node, { wrapper: target, box, last: size(0, 0) });
|
|
167
|
+
resizes.add(this);
|
|
168
|
+
queue();
|
|
169
|
+
}
|
|
170
|
+
unobserve(target) {
|
|
171
|
+
const state = resizeState(this);
|
|
172
|
+
state.targets.delete(element(target));
|
|
173
|
+
if (!state.targets.size) resizes.delete(this);
|
|
174
|
+
}
|
|
175
|
+
disconnect() {
|
|
176
|
+
resizeState(this).targets.clear();
|
|
177
|
+
resizes.delete(this);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
const resizeState = (observer) => {
|
|
181
|
+
const state = resizeStates.get(observer);
|
|
182
|
+
if (!state) throw new TypeError("Illegal invocation");
|
|
183
|
+
return state;
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
return {
|
|
187
|
+
IntersectionObserver, ResizeObserver,
|
|
188
|
+
update(...geometry) {
|
|
189
|
+
// Video frames and screenshots can repaint without changing layout.
|
|
190
|
+
// They must not cause an O(targets × ancestors) observer pass each time.
|
|
191
|
+
if (geometry.length && lastGeometry && geometry.every((value, index) => value === lastGeometry[index])) return;
|
|
192
|
+
lastGeometry = geometry;
|
|
193
|
+
queue();
|
|
194
|
+
},
|
|
195
|
+
dispose() {
|
|
196
|
+
disposed = true;
|
|
197
|
+
if (pending !== null) cancel(pending);
|
|
198
|
+
pending = null;
|
|
199
|
+
intersections.clear();
|
|
200
|
+
resizes.clear();
|
|
201
|
+
},
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const ZERO_RECT = Object.freeze({ left: 0, top: 0, width: 0, height: 0 });
|
|
206
|
+
const ZERO_EDGES = Object.freeze([0, 0, 0, 0]);
|
|
207
|
+
const number = (value) => typeof value === "number" && Number.isFinite(value) ? value : 0;
|
|
208
|
+
const sum = (edges, a, b) => number(edges[a]) + number(edges[b]);
|
|
209
|
+
const size = (inlineSize, blockSize) => ({ inlineSize, blockSize });
|
|
210
|
+
function rectangle({ left, top, width, height }) {
|
|
211
|
+
return { x: left, y: top, left, top, width, height, right: left + width, bottom: top + height,
|
|
212
|
+
toJSON() { return { x: this.x, y: this.y, width: this.width, height: this.height,
|
|
213
|
+
top: this.top, right: this.right, bottom: this.bottom, left: this.left }; } };
|
|
214
|
+
}
|
|
215
|
+
function parseMargin(value) {
|
|
216
|
+
const tokens = String(value).trim().split(/\s+/);
|
|
217
|
+
if (!tokens.length || tokens.length > 4) throw new DOMException("Invalid rootMargin.", "SyntaxError");
|
|
218
|
+
const parts = tokens.map((token) => {
|
|
219
|
+
const match = /^([+-]?(?:\d+\.?\d*|\.\d+))(px|%)$/.exec(token);
|
|
220
|
+
if (!match) throw new DOMException("rootMargin values must be pixels or percent.", "SyntaxError");
|
|
221
|
+
return { value: Number(match[1]), unit: match[2] };
|
|
222
|
+
});
|
|
223
|
+
const [top, right = top, bottom = top, left = right] = parts;
|
|
224
|
+
return [top, right, bottom, left];
|
|
225
|
+
}
|
|
226
|
+
function marginRect(rect, margin) {
|
|
227
|
+
const [top, right, bottom, left] = margin.map(({ value, unit }) => unit === "%" ? value * rect.width / 100 : value);
|
|
228
|
+
return rectangle({ left: rect.left - left, top: rect.top - top, width: rect.width + left + right, height: rect.height + top + bottom });
|
|
229
|
+
}
|
|
230
|
+
function intersect(a, b, x = true, y = true) {
|
|
231
|
+
const left = x ? Math.max(a.left, b.left) : a.left;
|
|
232
|
+
const right = x ? Math.min(a.right, b.right) : a.right;
|
|
233
|
+
const top = y ? Math.max(a.top, b.top) : a.top;
|
|
234
|
+
const bottom = y ? Math.min(a.bottom, b.bottom) : a.bottom;
|
|
235
|
+
return left <= right && top <= bottom ? rectangle({ left, top, width: right - left, height: bottom - top }) : null;
|
|
236
|
+
}
|
package/lib/renderer/main.js
CHANGED
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
* closed with sentBytes: the PCM that reached the output before audio-close)
|
|
29
29
|
*
|
|
30
30
|
* With --compositor, a compositor Worker (compositor.js) answers screenshots
|
|
31
|
-
* and scrolling on
|
|
31
|
+
* and scrolling on a dedicated socket while this thread is busy; frames go to it with
|
|
32
32
|
* postMessage.
|
|
33
33
|
*
|
|
34
34
|
* Startup order matters for the sandbox:
|
|
@@ -46,6 +46,7 @@ import { RENDERER_LOG_FLAG } from "../log.js";
|
|
|
46
46
|
import wasmpeg from "../../vendor/wasmpeg/src/js/index.js";
|
|
47
47
|
import { createBytePipe } from "../media/byte-pipe.js";
|
|
48
48
|
import { notifyRejectionHandled, notifyUnhandledRejection } from "./rejections.js";
|
|
49
|
+
import { SharedFramePool } from "../paint/shared-frame-pool.js";
|
|
49
50
|
|
|
50
51
|
const DANGEROUSLY_ALLOW_HOST_JS = "--dangerously-allow-host-js";
|
|
51
52
|
|
|
@@ -90,6 +91,7 @@ function fetchResource(url, kind) {
|
|
|
90
91
|
function pageFetch(request) {
|
|
91
92
|
let fetchId;
|
|
92
93
|
let streamController;
|
|
94
|
+
const completed = Promise.withResolvers();
|
|
93
95
|
const body = new ReadableStream({
|
|
94
96
|
start(controller) { streamController = controller; },
|
|
95
97
|
cancel() { promise.abort(); },
|
|
@@ -97,11 +99,17 @@ function pageFetch(request) {
|
|
|
97
99
|
const promise = new Promise((resolve, reject) => {
|
|
98
100
|
const id = nextFetchId++;
|
|
99
101
|
fetchId = id;
|
|
100
|
-
pendingFetches.set(id, { resolve, reject, page: true, body, streamController, head: false });
|
|
102
|
+
pendingFetches.set(id, { resolve, reject, page: true, body, streamController, head: false, finish: completed.resolve });
|
|
101
103
|
process.send({ type: "page-fetch", id, request });
|
|
102
104
|
});
|
|
103
|
-
promise.
|
|
104
|
-
|
|
105
|
+
promise.finished = completed.promise;
|
|
106
|
+
promise.abort = (reason = new DOMException("The operation was aborted.", "AbortError")) => {
|
|
107
|
+
const pending = pendingFetches.get(fetchId);
|
|
108
|
+
if (!pending) return;
|
|
109
|
+
pendingFetches.delete(fetchId);
|
|
110
|
+
if (pending.head) { try { streamController.error(reason); } catch {} }
|
|
111
|
+
else pending.reject(reason);
|
|
112
|
+
completed.resolve();
|
|
105
113
|
process.send({ type: "page-fetch-abort", id: fetchId });
|
|
106
114
|
};
|
|
107
115
|
return promise;
|
|
@@ -145,6 +153,7 @@ const audio = {
|
|
|
145
153
|
};
|
|
146
154
|
|
|
147
155
|
// Workers load their module from disk, so this happens before seccomp.
|
|
156
|
+
const frameBuffers = new SharedFramePool();
|
|
148
157
|
const compositor = process.argv.includes(COMPOSITOR_FLAG) ? await startCompositor() : null;
|
|
149
158
|
// Started before the sandbox closes: the worker loads its module from disk.
|
|
150
159
|
const rewriteScript = await startScriptRewriter();
|
|
@@ -163,13 +172,23 @@ function compositorCaughtUp() {
|
|
|
163
172
|
|
|
164
173
|
async function startCompositor() {
|
|
165
174
|
const worker = new Worker(new URL("./compositor.js", import.meta.url).href);
|
|
175
|
+
const endpoint = JSON.parse(process.argv.find(arg=>arg.startsWith('--compositor-endpoint=')).slice('--compositor-endpoint='.length));
|
|
176
|
+
worker.postMessage({type:'connect',endpoint});
|
|
166
177
|
await new Promise((resolve, reject) => {
|
|
167
178
|
worker.addEventListener("message", resolve, { once: true });
|
|
168
179
|
worker.addEventListener("error", reject, { once: true });
|
|
169
180
|
});
|
|
181
|
+
worker.addEventListener("error", (event) => {
|
|
182
|
+
frameBuffers.clear();
|
|
183
|
+
report(`buninu-browser: compositor worker failed: ${String(event?.message ?? "unknown error").slice(0, 500)}`);
|
|
184
|
+
// Its dedicated controller channel and outstanding frame leases cannot
|
|
185
|
+
// recover in place. Normal renderer exit rejects pending controller calls.
|
|
186
|
+
process.exit(1);
|
|
187
|
+
});
|
|
170
188
|
worker.addEventListener("message", ({ data }) => {
|
|
171
189
|
if (data?.type === "scroll") renderer.applyCompositorScroll(data.x, data.y);
|
|
172
190
|
else if (data?.type === "video-report") renderer.videoReport(data);
|
|
191
|
+
else if (data?.type === "frame-release" && Number.isInteger(data.serial)) frameBuffers.release(data.serial);
|
|
173
192
|
else if (data?.type === "frame-ack" && Number.isInteger(data.serial)) {
|
|
174
193
|
framesAcknowledged = Math.max(framesAcknowledged, data.serial);
|
|
175
194
|
frameWaiters = frameWaiters.filter((waiter) => {
|
|
@@ -220,19 +239,36 @@ async function startScriptRewriter() {
|
|
|
220
239
|
});
|
|
221
240
|
}
|
|
222
241
|
|
|
242
|
+
let nextClipboardId=1;
|
|
243
|
+
const clipboardWrites=new Map();
|
|
244
|
+
function clipboardWrite(text) {
|
|
245
|
+
const id=nextClipboardId++;
|
|
246
|
+
return new Promise((resolve,reject)=>{
|
|
247
|
+
const timer=setTimeout(()=>{clipboardWrites.delete(id);reject(Error('Clipboard timed out'));},5000);
|
|
248
|
+
clipboardWrites.set(id,{resolve,reject,timer});
|
|
249
|
+
process.send({type:'clipboard-write',id,text});
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
|
|
223
253
|
const renderer = await createPageRenderer({
|
|
224
254
|
fetchResource,
|
|
225
255
|
rewriteScript,
|
|
226
256
|
requestNavigation: (url, download = null, fragmentFrom = null, post = null) => process.send({ type: "navigate", url, download, fragmentFrom, post }),
|
|
227
257
|
requestDownload: ({ url, name, data }) => process.send({ type: "download-data", url, name, data }),
|
|
228
258
|
pageFetch,
|
|
259
|
+
clipboardWrite,
|
|
229
260
|
webSockets,
|
|
230
261
|
audio,
|
|
231
262
|
setCookie: (value) => process.send({ type: "cookie", value }),
|
|
232
263
|
reportError: (message) => report(`buninu-browser: page error: ${logging ? message : String(message).split("\n")[0]}`),
|
|
233
264
|
consoleMessage: logging ? (level, text) => report(`page console.${level}: ${text}`) : null,
|
|
234
265
|
trace: logging ? (text) => report(`timing: ${text}`) : null,
|
|
235
|
-
|
|
266
|
+
allocateFramePixels: compositor ? bytes => frameBuffers.acquire(bytes) : null,
|
|
267
|
+
onFrame: compositor ? (frame) => {
|
|
268
|
+
const serial = ++framesSent;
|
|
269
|
+
frameBuffers.publish(serial, frame.tile.pixels);
|
|
270
|
+
compositor.postMessage({ type: "frame", serial, ...frame });
|
|
271
|
+
} : null,
|
|
236
272
|
onVideo: compositor ? (message) => compositor.postMessage(message) : null,
|
|
237
273
|
// Whoever waits for load (a screenshot, say) sees the frame painted at load.
|
|
238
274
|
onLifecycle: (name, generation) => {
|
|
@@ -301,6 +337,13 @@ async function warmUp() {
|
|
|
301
337
|
}
|
|
302
338
|
|
|
303
339
|
process.on("message", async (message) => {
|
|
340
|
+
if(message?.type==='clipboard-result') {
|
|
341
|
+
const pending=clipboardWrites.get(message.id);if(!pending)return;
|
|
342
|
+
clipboardWrites.delete(message.id);clearTimeout(pending.timer);
|
|
343
|
+
if(message.error)pending.reject(Error(String(message.error)));else pending.resolve();
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
|
|
304
347
|
if (message?.type === "page-fetch-head") {
|
|
305
348
|
const pending = pendingFetches.get(message.id);
|
|
306
349
|
if (!pending?.page || pending.head) return;
|
|
@@ -317,6 +360,7 @@ process.on("message", async (message) => {
|
|
|
317
360
|
if (!pending?.page) return;
|
|
318
361
|
pendingFetches.delete(message.id);
|
|
319
362
|
pending.streamController.close();
|
|
363
|
+
pending.finish?.();
|
|
320
364
|
return;
|
|
321
365
|
}
|
|
322
366
|
if (message?.type === "page-fetch-error") {
|
|
@@ -326,6 +370,7 @@ process.on("message", async (message) => {
|
|
|
326
370
|
const error = new Error(message.error || "fetch failed");
|
|
327
371
|
if (pending.head) pending.streamController.error(error);
|
|
328
372
|
else pending.reject(error);
|
|
373
|
+
pending.finish?.();
|
|
329
374
|
return;
|
|
330
375
|
}
|
|
331
376
|
if (message?.type === "fetch-chunk") {
|
|
@@ -357,6 +402,7 @@ process.on("message", async (message) => {
|
|
|
357
402
|
const error = new Error(message.error);
|
|
358
403
|
pending.pipe?.fail(error);
|
|
359
404
|
if (!pending.head) pending.reject(error);
|
|
405
|
+
pending.finish?.();
|
|
360
406
|
return;
|
|
361
407
|
}
|
|
362
408
|
const result = message.result ?? {};
|
|
@@ -374,6 +420,7 @@ process.on("message", async (message) => {
|
|
|
374
420
|
return;
|
|
375
421
|
}
|
|
376
422
|
pending.resolve(result);
|
|
423
|
+
pending.finish?.();
|
|
377
424
|
return;
|
|
378
425
|
}
|
|
379
426
|
if (message?.type === "audio-event") {
|
|
@@ -5,14 +5,16 @@
|
|
|
5
5
|
* The event order follows the Media Source spec: appendBuffer returns with
|
|
6
6
|
* updating set, and a later task runs updatestart, the append, then update
|
|
7
7
|
* and updateend. sourceopen fires when an element attaches, not at
|
|
8
|
-
* construction. remove() and abort()
|
|
9
|
-
*
|
|
10
|
-
*
|
|
8
|
+
* construction. remove() and abort() retain original fragment history for
|
|
9
|
+
* decoder reopens and backward seeks. MP4 initialization changes have
|
|
10
|
+
* separate snapshots selected by media time, so configurations do not mix.
|
|
11
11
|
*
|
|
12
12
|
* Codec claims stay inside what this build can demux and decode. Types the
|
|
13
13
|
* engine cannot play are rejected so a page picks another one.
|
|
14
14
|
*/
|
|
15
15
|
import { createBytePipe } from "../media/byte-pipe.js";
|
|
16
|
+
import { createSourceSnapshots } from "../media/source-snapshots.js";
|
|
17
|
+
import { createWebMStream, ebmlHeader } from "../media/webm-stream.js";
|
|
16
18
|
|
|
17
19
|
const BRAND = Symbol("MediaSource");
|
|
18
20
|
const BUFFER_BRAND = Symbol("SourceBuffer");
|
|
@@ -140,53 +142,55 @@ function ebmlUint(bytes, offset, length) {
|
|
|
140
142
|
}
|
|
141
143
|
|
|
142
144
|
/** Actual buffered end of WebM clusters (nanosecond TimecodeScale, millisecond default). */
|
|
143
|
-
function inspectWebMAppend(bytes, state) {
|
|
144
|
-
for (let offset = 0; offset + 4 < bytes.byteLength; offset++) {
|
|
145
|
-
if (bytes[offset] !== 0x2a || bytes[offset + 1] !== 0xd7 || bytes[offset + 2] !== 0xb1) continue;
|
|
146
|
-
const size = ebmlVint(bytes, offset + 3);
|
|
147
|
-
const value = size && ebmlUint(bytes, offset + 3 + size.length, size.value);
|
|
148
|
-
if (value) state.scale = value;
|
|
149
|
-
}
|
|
150
|
-
const scale = state.scale || 1_000_000;
|
|
145
|
+
function inspectWebMAppend(bytes, state, clusterOrigin = null) {
|
|
151
146
|
let latest = null;
|
|
152
147
|
let first = null;
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
if (timeAt + 2 <= end) {
|
|
179
|
-
let relative = (bytes[timeAt] << 8) | bytes[timeAt + 1];
|
|
180
|
-
if (relative & 0x8000) relative -= 0x10000;
|
|
181
|
-
// Opus packets are normally 20 ms; this small tail keeps buffered
|
|
182
|
-
// from ending at the start timestamp of its final packet.
|
|
183
|
-
latest = Math.max(latest ?? 0, clusterTime + relative + 20);
|
|
148
|
+
const tracks = new Map();
|
|
149
|
+
const inspectCluster = (from, limit, origin = 0) => {
|
|
150
|
+
let clusterTime = origin;
|
|
151
|
+
const visit = (start, stop) => {
|
|
152
|
+
for (let cursor = start; cursor < stop;) {
|
|
153
|
+
const element = ebmlHeader(bytes, cursor);
|
|
154
|
+
if (!element) break;
|
|
155
|
+
const data = cursor + element.length, end = Math.min(stop, data + element.size);
|
|
156
|
+
if (element.id === 0xe7) {
|
|
157
|
+
clusterTime = ebmlUint(bytes, data, element.size) ?? clusterTime;
|
|
158
|
+
latest = Math.max(latest ?? 0, clusterTime);
|
|
159
|
+
} else if (element.id === 0xa0) visit(data, end); // BlockGroup
|
|
160
|
+
else if (element.id === 0xa3 || element.id === 0xa1) {
|
|
161
|
+
const track = ebmlVint(bytes, data);
|
|
162
|
+
const timeAt = track ? data + track.length : end;
|
|
163
|
+
if (timeAt + 2 <= end) {
|
|
164
|
+
let relative = (bytes[timeAt] << 8) | bytes[timeAt + 1];
|
|
165
|
+
if (relative & 0x8000) relative -= 0x10000;
|
|
166
|
+
first = Math.min(first ?? clusterTime + relative, clusterTime + relative);
|
|
167
|
+
// Opus packets are normally 20 ms; include the final packet.
|
|
168
|
+
latest = Math.max(latest ?? 0, clusterTime + relative + 20);
|
|
169
|
+
const range = tracks.get(track.value);
|
|
170
|
+
tracks.set(track.value, [Math.min(range?.[0] ?? Infinity, clusterTime + relative),
|
|
171
|
+
Math.max(range?.[1] ?? 0, clusterTime + relative + 20)]);
|
|
172
|
+
}
|
|
184
173
|
}
|
|
174
|
+
cursor = end;
|
|
185
175
|
}
|
|
186
|
-
|
|
176
|
+
};
|
|
177
|
+
visit(from, limit);
|
|
178
|
+
};
|
|
179
|
+
const visit = (from, limit) => {
|
|
180
|
+
for (let offset = from; offset < limit;) {
|
|
181
|
+
const element = ebmlHeader(bytes, offset);
|
|
182
|
+
if (!element) break;
|
|
183
|
+
const data = offset + element.length, end = Math.min(limit, data + element.size);
|
|
184
|
+
if (element.id === 0x18538067 || element.id === 0x1549a966) visit(data, end);
|
|
185
|
+
else if (element.id === 0x2ad7b1) state.scale = ebmlUint(bytes, data, element.size) || state.scale;
|
|
186
|
+
else if (element.id === 0x1f43b675) inspectCluster(data, end);
|
|
187
|
+
offset = end;
|
|
187
188
|
}
|
|
188
|
-
|
|
189
|
-
|
|
189
|
+
};
|
|
190
|
+
if (clusterOrigin != null) inspectCluster(0, bytes.length, clusterOrigin);
|
|
191
|
+
else visit(0, bytes.length);
|
|
192
|
+
const scale = state.scale || 1_000_000;
|
|
193
|
+
state.appendTracks = new Map([...tracks].map(([track, [start, end]]) => [track, [start * scale / 1e9, end * scale / 1e9]]));
|
|
190
194
|
state.appendStart = first == null ? null : first * scale / 1e9;
|
|
191
195
|
return latest == null ? null : latest * scale / 1e9;
|
|
192
196
|
}
|
|
@@ -422,7 +426,12 @@ export function createMediaSourceScope({ origin, schedule, DOMException, onAppen
|
|
|
422
426
|
Object.defineProperty(this, BUFFER_BRAND, { value: true });
|
|
423
427
|
this._source = source;
|
|
424
428
|
this._mime = String(mime);
|
|
425
|
-
this.
|
|
429
|
+
this._snapshots = /^(video|audio)\/mp4\b/i.test(this._mime) ? createSourceSnapshots(inspectMp4Append) : null;
|
|
430
|
+
this._pipe = createBytePipe({ retainBytes: !this._snapshots });
|
|
431
|
+
if (this._snapshots) {
|
|
432
|
+
this._pipe.snapshotAt = time => this._snapshots.at(time);
|
|
433
|
+
this._pipe.bytes = () => this._snapshots.at(0).bytes;
|
|
434
|
+
}
|
|
426
435
|
this._updating = false;
|
|
427
436
|
this._operation = 0;
|
|
428
437
|
this._removed = false;
|
|
@@ -433,6 +442,8 @@ export function createMediaSourceScope({ origin, schedule, DOMException, onAppen
|
|
|
433
442
|
this._accountedBytes = 0;
|
|
434
443
|
this._mp4 = {};
|
|
435
444
|
this._webm = {};
|
|
445
|
+
this._webmCovered = new Map();
|
|
446
|
+
this._webmStream = /^(video|audio)\/webm\b/i.test(this._mime) ? createWebMStream() : null;
|
|
436
447
|
this._hasSegmentTimeline = false;
|
|
437
448
|
this._hasMediaSegment = false;
|
|
438
449
|
this._appendWindowStart = 0;
|
|
@@ -504,16 +515,12 @@ export function createMediaSourceScope({ origin, schedule, DOMException, onAppen
|
|
|
504
515
|
const bytes = copyBytes(data);
|
|
505
516
|
const inspected = /^video\/mp4|^audio\/mp4/i.test(this._mime) ? this._mp4
|
|
506
517
|
: /^video\/webm|^audio\/webm/i.test(this._mime) ? this._webm : null;
|
|
507
|
-
|
|
508
|
-
: inspected === this._webm ? inspectWebMAppend(bytes, this._webm) : null;
|
|
518
|
+
let segmentEnd = inspected === this._mp4 ? inspectMp4Append(bytes, this._mp4) : null;
|
|
509
519
|
// Media the buffer already holds, sent again (players retry a segment
|
|
510
520
|
// on a slow network): the concatenation would play it twice, so it is
|
|
511
521
|
// left out. Its events fire as for any append.
|
|
512
522
|
const start = inspected && !inspected.appendHasHeader ? inspected.appendStart : null;
|
|
513
|
-
const duplicate = start != null && segmentEnd != null && covers(this._covered, start, segmentEnd);
|
|
514
|
-
const hasWebMCluster = /^video\/webm|^audio\/webm/i.test(this._mime)
|
|
515
|
-
&& bytes.indexOf(0x1f) >= 0 && bytes.some((value, index) => value === 0x1f
|
|
516
|
-
&& bytes[index + 1] === 0x43 && bytes[index + 2] === 0xb6 && bytes[index + 3] === 0x75);
|
|
523
|
+
const duplicate = !this._snapshots && start != null && segmentEnd != null && covers(this._covered, start, segmentEnd);
|
|
517
524
|
if (appended + bytes.byteLength > appendQuota) {
|
|
518
525
|
throw new DOMException("The SourceBuffer is full.", "QuotaExceededError");
|
|
519
526
|
}
|
|
@@ -525,13 +532,15 @@ export function createMediaSourceScope({ origin, schedule, DOMException, onAppen
|
|
|
525
532
|
schedule(() => {
|
|
526
533
|
if (this._removed || operation !== this._operation) return;
|
|
527
534
|
emitNow(this, "updatestart");
|
|
528
|
-
if (
|
|
535
|
+
if (this._webmStream) {
|
|
536
|
+
segmentEnd = this._appendWebM(this._webmStream.append(bytes), bytes.length);
|
|
537
|
+
} else if (!duplicate && (!this._snapshots || this._snapshots.append(bytes))) {
|
|
529
538
|
this._pipe.append(bytes);
|
|
530
539
|
appended += bytes.byteLength;
|
|
531
540
|
this._accountedBytes += bytes.byteLength;
|
|
532
541
|
if (start != null && segmentEnd != null) addCovered(this._covered, start, segmentEnd);
|
|
533
542
|
}
|
|
534
|
-
if (this._mp4.hasMediaSegment
|
|
543
|
+
if (this._mp4.hasMediaSegment) this._hasMediaSegment = true;
|
|
535
544
|
if (segmentEnd != null) {
|
|
536
545
|
if (!this._hasSegmentTimeline) this._end = segmentEnd;
|
|
537
546
|
else this._end = Math.max(this._end, segmentEnd);
|
|
@@ -548,10 +557,66 @@ export function createMediaSourceScope({ origin, schedule, DOMException, onAppen
|
|
|
548
557
|
});
|
|
549
558
|
}
|
|
550
559
|
|
|
560
|
+
/** Complete finite Clusters, or complete Blocks from an unknown-size
|
|
561
|
+
* live Cluster, enter the decoder and contribute buffered ranges.
|
|
562
|
+
* Retries may be split differently or share an append with new media;
|
|
563
|
+
* comparing complete elements handles both cases.
|
|
564
|
+
*/
|
|
565
|
+
_appendWebM(elements, incoming = 0) {
|
|
566
|
+
let retained = incoming, latest = null;
|
|
567
|
+
for (const { id, bytes, cluster, closedCluster } of elements) {
|
|
568
|
+
if (closedCluster) {
|
|
569
|
+
if (!closedCluster.accepted) retained -= closedCluster.prefix.reduce((sum, part) => sum + part.length, 0);
|
|
570
|
+
continue;
|
|
571
|
+
}
|
|
572
|
+
const end = inspectWebMAppend(bytes, this._webm, cluster?.time);
|
|
573
|
+
const start = this._webm.appendStart;
|
|
574
|
+
if (id === 0x1f43b675) {
|
|
575
|
+
this._hasMediaSegment = true;
|
|
576
|
+
if (end != null) latest = Math.max(latest ?? end, end);
|
|
577
|
+
if (start != null && end != null) {
|
|
578
|
+
// Different tracks can carry samples at the same timestamp.
|
|
579
|
+
// In particular, incremental unknown-size Clusters expose one
|
|
580
|
+
// Block at a time; an audio Block must not discard a video Block.
|
|
581
|
+
const tracks = this._webm.appendTracks;
|
|
582
|
+
if (tracks.size && [...tracks].every(([track, range]) => covers(this._webmCovered.get(track) ?? [], ...range))) {
|
|
583
|
+
retained -= bytes.length; continue;
|
|
584
|
+
}
|
|
585
|
+
for (const [track, range] of tracks) {
|
|
586
|
+
if (!this._webmCovered.has(track)) this._webmCovered.set(track, []);
|
|
587
|
+
addCovered(this._webmCovered.get(track), ...range);
|
|
588
|
+
}
|
|
589
|
+
addCovered(this._covered, start, end);
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
if (cluster && this._webmCluster !== cluster.token) {
|
|
593
|
+
for (const prefix of cluster.prefix) this._pipe.append(prefix);
|
|
594
|
+
this._webmCluster = cluster.token;
|
|
595
|
+
cluster.token.accepted = true;
|
|
596
|
+
}
|
|
597
|
+
this._pipe.append(bytes);
|
|
598
|
+
}
|
|
599
|
+
// A retry can span remove(), which may already have released the
|
|
600
|
+
// quota charged for its first pieces. Refund only this buffer's
|
|
601
|
+
// remaining charge, never another SourceBuffer's bytes.
|
|
602
|
+
retained = Math.max(-this._accountedBytes, retained);
|
|
603
|
+
appended += retained;
|
|
604
|
+
this._accountedBytes += retained;
|
|
605
|
+
return latest;
|
|
606
|
+
}
|
|
607
|
+
|
|
551
608
|
abort() {
|
|
552
609
|
if (this._source.readyState !== "open") {
|
|
553
610
|
throw new DOMException("MediaSource is not open.", "InvalidStateError");
|
|
554
611
|
}
|
|
612
|
+
// Reset parser input even between append operations: the next append
|
|
613
|
+
// may start a new initialization instead of completing this element.
|
|
614
|
+
if (this._webmStream) {
|
|
615
|
+
const released = Math.min(this._accountedBytes, this._webmStream.pendingSize);
|
|
616
|
+
this._webmStream.reset();
|
|
617
|
+
this._accountedBytes -= released;
|
|
618
|
+
appended -= released;
|
|
619
|
+
}
|
|
555
620
|
if (!this._updating) return;
|
|
556
621
|
this._operation += 1;
|
|
557
622
|
this._updating = false;
|
|
@@ -683,7 +748,13 @@ export function createMediaSourceScope({ origin, schedule, DOMException, onAppen
|
|
|
683
748
|
}
|
|
684
749
|
for (const buffer of this._buffers) {
|
|
685
750
|
if (error) buffer._pipe.fail(new Error(error));
|
|
686
|
-
else
|
|
751
|
+
else {
|
|
752
|
+
if (buffer._webmStream) {
|
|
753
|
+
const end = buffer._appendWebM(buffer._webmStream.finish());
|
|
754
|
+
if (end != null) { buffer._end = Math.max(buffer._end, end); buffer._hasSegmentTimeline = true; }
|
|
755
|
+
}
|
|
756
|
+
buffer._pipe.finish();
|
|
757
|
+
}
|
|
687
758
|
}
|
|
688
759
|
if (!Number.isFinite(this._duration)) {
|
|
689
760
|
const end = Math.max(0, ...this._buffers.map((buffer) => buffer._end));
|