buninu-browser 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/AGENTS.md +17 -0
  2. package/LICENSES/brotli.txt +19 -0
  3. package/README.md +41 -7
  4. package/THIRD_PARTY_NOTICES.md +26 -4
  5. package/buninu-browser.js +10 -2
  6. package/lib/audio/pcm.js +16 -0
  7. package/lib/audio/piano.js +109 -0
  8. package/lib/audio/pulse-client.js +413 -0
  9. package/lib/cdp-server.js +25 -6
  10. package/lib/document-types.js +308 -0
  11. package/lib/download-directory.js +28 -0
  12. package/lib/download.js +63 -0
  13. package/lib/headless-shell.js +108 -14
  14. package/lib/input/hit-test.js +57 -8
  15. package/lib/layout/text-layout.js +112 -27
  16. package/lib/network/network-service.js +7 -3
  17. package/lib/network/resolver.js +16 -0
  18. package/lib/paint/display-list.js +2 -2
  19. package/lib/render-tree/index.js +13 -0
  20. package/lib/renderer/bindings.js +541 -53
  21. package/lib/renderer/flags.js +8 -5
  22. package/lib/renderer/host.js +102 -6
  23. package/lib/renderer/main.js +35 -3
  24. package/lib/renderer/module-transform.js +30 -23
  25. package/lib/renderer/page-realm.js +226 -22
  26. package/lib/renderer/page-renderer.js +1293 -212
  27. package/lib/screenshot-cli.js +61 -0
  28. package/lib/style/computed-style.js +268 -20
  29. package/lib/user-agent.js +12 -0
  30. package/package.json +1 -1
  31. package/patches/happy-dom/0003-defer-script-events-to-renderer.patch +40 -0
  32. package/patches/happy-dom/0004-insert-adjacent-html-in-order.patch +19 -0
  33. package/patches/happy-dom/0005-invalidate-matches-on-structure-changes.patch +43 -0
  34. package/patches/happy-dom/README.md +12 -0
  35. package/patches/ses/0001-allow-regexp-own-tostring.patch +13 -0
  36. package/patches/ses/README.md +16 -0
  37. package/test/computed-style.test.js +52 -0
  38. package/test/document-types.test.js +111 -0
  39. package/test/download.test.js +192 -0
  40. package/test/fixtures/tone.mp3 +0 -0
  41. package/test/flex.test.js +11 -0
  42. package/test/happy-dom-matches.test.js +25 -0
  43. package/test/headless-shell.test.js +27 -0
  44. package/test/hit-test.test.js +37 -0
  45. package/test/page-scripts.test.js +388 -29
  46. package/test/piano.test.js +65 -0
  47. package/test/positioning.test.js +11 -0
  48. package/test/pulse-client.test.js +130 -0
  49. package/test/render-tree.test.js +14 -0
  50. package/test/resolver.test.js +12 -1
  51. package/test/screenshot-cli.test.js +19 -0
  52. package/test/text-layout.test.js +22 -0
  53. package/test/user-agent.test.js +42 -0
  54. package/vendor/happy-dom/packages/happy-dom/src/nodes/element/Element.ts +3 -6
  55. package/vendor/happy-dom/packages/happy-dom/src/nodes/html-script-element/HTMLScriptElement.ts +2 -20
  56. package/vendor/happy-dom/packages/happy-dom/src/query-selector/QuerySelector.ts +15 -3
  57. package/vendor/ses/dist/ses.mjs +2 -0
@@ -0,0 +1,308 @@
1
+ /**
2
+ * What a top-level response looks like in the viewport. HTML is shown as
3
+ * itself; anything else gets a small generated HTML document, as browsers do
4
+ * for "standalone" resources (HTML §7.4.4-§7.4.7):
5
+ *
6
+ * - images: the image alone on a dark background, centered and fitted to the
7
+ * width (SVG inline, other formats as a data: URL so they are not fetched
8
+ * twice);
9
+ * - video and audio: a <video> or <audio> element for the URL on the same
10
+ * background, with the file's name and type under it;
11
+ * - the formats bunmsh's `serve` previews, handled the way it does: Markdown
12
+ * rendered with Bun.markdown.html, and JSON, JSON5, JSONC, JSON Lines, YAML,
13
+ * TOML and XML parsed with Bun's parsers and printed as highlighted JSON,
14
+ * under a "Pretty-print" checkbox that is on by default and, unticked,
15
+ * shows the text as sent;
16
+ * - everything else, whatever its bytes: the text in a <pre>. There is no
17
+ * "cannot be shown" page (see AGENTS.md).
18
+ *
19
+ * Large files would stall layout (on a phone, 1 MB of text takes seconds and
20
+ * 4 MB never finished): past DISPLAY_LIMIT only the start of the text is
21
+ * shown, and past PRETTY_LIMIT the preview is skipped for the text, each
22
+ * with a bar at the top that says so and points to casty's `download`.
23
+ *
24
+ * The bytes kept for downloads are the response's own, not this HTML. Text is
25
+ * decoded with the Content-Type charset, UTF-8 when there is none.
26
+ */
27
+
28
+ const HTML_TYPES = new Set(["text/html", "application/xhtml+xml"]);
29
+ // Measured on a phone: 256 KiB of text lays out in about 2 s; a 32 KiB
30
+ // preview (highlighted JSON, many spans) in about 2.5 s.
31
+ export const DISPLAY_LIMIT = 256 * 1024;
32
+ export const PRETTY_LIMIT = 32 * 1024;
33
+
34
+ // From bunmsh serve.js (MIT, same author): the formats it previews, by file
35
+ // extension, with its parser names, pretty() and highlightJson().
36
+ const PARSERS = new Map([
37
+ ["json", { name: "JSON.parse", parse: JSON.parse }],
38
+ ["json5", { name: "Bun.JSON5.parse", parse: Bun.JSON5.parse }],
39
+ ["jsonc", { name: "Bun.JSONC.parse", parse: Bun.JSONC.parse }],
40
+ ["jsonl", { name: "Bun.JSONL.parse", parse: Bun.JSONL.parse }],
41
+ ["ndjson", { name: "Bun.JSONL.parse", parse: Bun.JSONL.parse }],
42
+ ["yaml", { name: "Bun.YAML.parse", parse: Bun.YAML.parse }],
43
+ ["yml", { name: "Bun.YAML.parse", parse: Bun.YAML.parse }],
44
+ ["toml", { name: "Bun.TOML.parse", parse: Bun.TOML.parse }],
45
+ ]);
46
+ if (typeof Bun.XML?.parse === "function") {
47
+ PARSERS.set("xml", { name: "Bun.XML.parse", parse: Bun.XML.parse });
48
+ }
49
+ const MARKDOWN = "Bun.markdown.html";
50
+
51
+ // A URL without one of those extensions: its Content-Type names the format.
52
+ const EXTENSION_BY_TYPE = new Map([
53
+ ["text/markdown", "md"], ["text/x-markdown", "md"],
54
+ ["application/json", "json"], ["text/json", "json"], ["application/json5", "json5"], ["application/jsonc", "jsonc"],
55
+ ["application/jsonl", "jsonl"], ["application/x-ndjson", "ndjson"], ["application/jsonlines", "jsonl"],
56
+ ["text/yaml", "yaml"], ["application/yaml", "yaml"], ["application/x-yaml", "yaml"], ["text/x-yaml", "yaml"],
57
+ ["application/toml", "toml"], ["text/x-toml", "toml"],
58
+ ["application/xml", "xml"], ["text/xml", "xml"],
59
+ ]);
60
+
61
+ /** "md", a PARSERS key, or null: what bunmsh serve would preview this as. */
62
+ function previewKind(name, type) {
63
+ const byName = extension(name);
64
+ if (byName === "md" || PARSERS.has(byName)) return byName;
65
+ const byType = EXTENSION_BY_TYPE.get(type) ?? (type.endsWith("+json") ? "json" : type.endsWith("+xml") ? "xml" : null);
66
+ return byType === "md" || PARSERS.has(byType) ? byType : null;
67
+ }
68
+
69
+ /**
70
+ * @param {{ url: string, contentType?: string, body: Uint8Array }} response
71
+ * @returns {{ source: string, contentType: string }}
72
+ */
73
+ export function displayDocument({ url, contentType = "", body }) {
74
+ const { type, charset } = parseContentType(contentType);
75
+ const name = fileName(url);
76
+ if (HTML_TYPES.has(type) || (type === "" && looksLikeHtml(body))) {
77
+ return { source: decode(body, charset), contentType: contentType || "text/html" };
78
+ }
79
+ if (type === "image/svg+xml") return html(name, IMAGE_STYLE, `<div class="image">${stripProlog(decode(body, charset))}</div>`);
80
+ if (type.startsWith("image/")) {
81
+ const src = `data:${type};base64,${Buffer.from(body).toString("base64")}`;
82
+ return html(name, IMAGE_STYLE, `<div class="image"><img src="${src}" alt="${Bun.escapeHTML(name)}"></div>`);
83
+ }
84
+ const media = mediaKind(type, name);
85
+ if (media) return mediaDocument(url, name, type || media, media, body.byteLength);
86
+ const kind = previewKind(name, type);
87
+ if (kind && body.byteLength <= PRETTY_LIMIT) return previewDocument(url, name, kind, decode(body, charset));
88
+ // Everything else is shown as text, whatever the bytes are: no file is unviewable.
89
+ const note = kind
90
+ ? `Too large to pretty-print quickly (${formatSize(body.byteLength)})${body.byteLength > DISPLAY_LIMIT ? `; showing the first ${formatSize(DISPLAY_LIMIT)}` : ""}.`
91
+ : body.byteLength > DISPLAY_LIMIT ? `Large file (${formatSize(body.byteLength)}); showing the first ${formatSize(DISPLAY_LIMIT)}.` : null;
92
+ const shown = body.byteLength > DISPLAY_LIMIT ? body.subarray(0, DISPLAY_LIMIT) : body;
93
+ const bar = `<div class="bar"><span>${note ? `${note} The whole file: casty's <code>download</code> (Ctrl+E).` : ""}</span>${downloadButton(url)}</div>`;
94
+ return html(name, TEXT_STYLE, `${bar}<pre>${Bun.escapeHTML(decode(shown, charset))}</pre>`);
95
+ }
96
+
97
+ /** Built-in audio diagnostics page; CSS keys work without SVG or page scripts. */
98
+ export function audioDemoDocument() {
99
+ const whites = [["A4", 69], ["B4", 71], ["C5", 72], ["D5", 74],
100
+ ["E5", 76], ["F5", 77], ["G5", 79], ["A5", 81]]
101
+ .map(([name, note]) => `<button type="button" class="white-key" data-piano-note="${note}"><span>${name}</span></button>`).join("");
102
+ const blacks = [["A♯4", 70, "8.75"], ["C♯5", 73, "33.75"], ["D♯5", 75, "46.25"],
103
+ ["F♯5", 78, "71.25"], ["G♯5", 80, "83.75"]]
104
+ .map(([name, note, left]) => `<button type="button" class="black-key" data-piano-note="${note}" style="left:${left}%"><span>${name}</span></button>`).join("");
105
+ const style = `:root { color-scheme: dark }
106
+ body { margin: 0; min-height: 100vh; background: #151820; color: #f4f5f7; font: 15px sans-serif }
107
+ .demo { max-width: 560px; margin: 0 auto; padding: 42px 18px 24px; text-align: center }
108
+ h1 { margin: 0 0 8px; font-size: 24px }
109
+ .note { color: #abb1c0; font-size: 13px; line-height: 1.5 }
110
+ .keyboard { display: flex; position: relative; width: 100%; max-width: 420px; height: 150px; margin: 32px auto 20px; box-shadow: 0 8px 22px #0008 }
111
+ .white-key { flex: 1; position: relative; box-sizing: border-box; padding: 0; border: 1px solid #6f7683; border-radius: 0 0 6px 6px; background: #f5f3ed; color: #2c3039; cursor: pointer; font: inherit }
112
+ .white-key span { position: absolute; bottom: 9px; left: 0; right: 0; font-size: 12px }
113
+ .black-key { position: absolute; z-index: 1; top: 0; width: 8.6%; height: 92px; padding: 0; border: 0; border-radius: 0 0 5px 5px; background: #262b35; box-shadow: 0 4px 5px #0008; color: #e8e8e8; cursor: pointer; font: inherit }
114
+ .black-key span { position: absolute; bottom: 8px; left: 0; right: 0; font-size: 10px }
115
+ input { box-sizing: border-box; width: 100%; max-width: 420px; padding: 10px 12px; border: 1px solid #858da0; border-radius: 7px; background: #222735; color: white; font: 15px sans-serif }
116
+ input:focus { outline: 2px solid #90b4ff; outline-offset: 2px }`;
117
+ return html("Audio demo", style, `<main class="demo"><h1>Audio demo</h1>`
118
+ + `<p class="note">Tap a key, or click the field and play with Space, J, K and the LazyI keyboard bindings.</p>`
119
+ + `<div class="keyboard" aria-label="Piano keyboard">${whites}${blacks}</div>`
120
+ + `<input id="buninu-piano-input" type="text" autocomplete="off" placeholder="Play Piano · 彈鋼琴 · ピアノを弾く" aria-label="Play Piano">`
121
+ + `<p class="note">Space = A4 · J = B4 · K = C♯5</p></main>`);
122
+ }
123
+
124
+ function formatSize(bytes) {
125
+ return bytes >= 1024 * 1024 ? `${(bytes / 1024 / 1024).toFixed(1)} MB` : `${Math.round(bytes / 1024)} KB`;
126
+ }
127
+
128
+ // Media the renderer's decoder (wasmpeg's FFmpeg) handles: by type, or by
129
+ // extension when the server's type says nothing.
130
+ const VIDEO_TYPES = new Set(["application/ogg", "application/mp4", "application/x-matroska", "application/mxf"]);
131
+ const VIDEO_EXTENSIONS = new Set(["mp4", "m4v", "mov", "webm", "mkv", "avi", "ogv", "ogg", "ts", "mts", "m2ts",
132
+ "mpg", "mpeg", "3gp", "3g2", "wmv", "flv", "mxf", "y4m"]);
133
+ const AUDIO_EXTENSIONS = new Set(["mp3", "wav", "flac", "oga", "opus", "m4a", "aac", "wma", "aiff", "aif", "amr", "ac3", "mka"]);
134
+
135
+ /** "video", "audio" or null. */
136
+ function mediaKind(type, name) {
137
+ if (type.startsWith("video/") || VIDEO_TYPES.has(type)) return "video";
138
+ if (type.startsWith("audio/")) return "audio";
139
+ if (!["", "application/octet-stream", "binary/octet-stream"].includes(type)) return null;
140
+ const ext = extension(name);
141
+ return VIDEO_EXTENSIONS.has(ext) ? "video" : AUDIO_EXTENSIONS.has(ext) ? "audio" : null;
142
+ }
143
+
144
+ /** A media document (HTML §7.4.6): the element alone, playing the URL itself. */
145
+ function mediaDocument(url, name, type, kind, size) {
146
+ const src = Bun.escapeHTML(url);
147
+ const about = `${Bun.escapeHTML(name)} (${Bun.escapeHTML(type)}, ${size} bytes)`;
148
+ const element = kind === "video"
149
+ ? `<video controls autoplay src="${src}"></video><p class="note">${about}. Click the video to play.</p>`
150
+ : `<audio controls src="${src}"></audio><p class="note">${about}. Click to play; `
151
+ + "sound goes to a PulseAudio server on 127.0.0.1:4713, such as jspulse.</p>";
152
+ const piano = kind === "audio" && (type === "audio/mpeg" || extension(name) === "mp3")
153
+ ? `<label class="piano">鋼琴 <input id="buninu-piano-input" type="text" autocomplete="off" placeholder="Space J K · F1–F12" aria-label="鋼琴鍵盤"></label>`
154
+ : "";
155
+ return html(name, IMAGE_STYLE, `${piano}<div class="image">${element}</div>`);
156
+ }
157
+
158
+ /** bunmsh serve's preview (its previewResponse), under a Pretty-print checkbox over the text as sent. */
159
+ function previewDocument(url, name, kind, source) {
160
+ const raw = (hidden) => `<pre id="raw"${hidden ? " hidden" : ""}>${Bun.escapeHTML(source)}</pre>`;
161
+ const parser = PARSERS.get(kind);
162
+ const previewer = kind === "md" ? MARKDOWN : parser.name;
163
+ let formatted;
164
+ try {
165
+ formatted = kind === "md"
166
+ ? `<div id="formatted">${Bun.markdown.html(source, { headings: true })}</div>`
167
+ : `<pre id="formatted"><code>${highlightJson(pretty(parser.parse(source)))}</code></pre>`;
168
+ } catch (error) {
169
+ return html(`Cannot preview ${name}`, TEXT_STYLE, `<div class="bar"><span></span>${downloadButton(url)}</div><h1>Cannot preview ${Bun.escapeHTML(name)}</h1>`
170
+ + `<pre>${Bun.escapeHTML(error?.stack ?? String(error))}</pre>${raw(false)}`);
171
+ }
172
+ return html(`${name} — ${previewer}`, TEXT_STYLE, `<div class="bar"><span><label><input type="checkbox" id="pretty-print" checked> Pretty-print</label>`
173
+ + ` <span class="note">(${previewer})</span></span>${downloadButton(url)}</div>${formatted}${raw(true)}`
174
+ + `<script>(() => {
175
+ const box = document.getElementById("pretty-print");
176
+ const show = () => {
177
+ document.getElementById("formatted").hidden = !box.checked;
178
+ document.getElementById("raw").hidden = box.checked;
179
+ };
180
+ box.addEventListener("change", show);
181
+ })();</script>`);
182
+ }
183
+
184
+ function downloadButton(url) {
185
+ return `<a class="download" href="${Bun.escapeHTML(url)}" download>Download</a>`;
186
+ }
187
+
188
+ // Centered in the viewport both ways, as browsers show a standalone image.
189
+ const IMAGE_STYLE = `body { margin: 0; background: #0e0e0e }
190
+ .image { min-height: 100vh; display: flex; align-items: center; justify-content: center }
191
+ .image { flex-direction: column }
192
+ .image img, .image svg { max-width: 100%; height: auto; background: white }
193
+ .image video { max-width: 100% }
194
+ .image .note { color: #bbb; font-family: sans-serif; font-size: 13px }
195
+ .piano { position: fixed; z-index: 2; top: 8px; left: 8px; display: flex; align-items: center; gap: 5px; color: #eee; font: 13px sans-serif }
196
+ .piano input { width: 128px; padding: 3px 5px; font: 13px sans-serif }`;
197
+ // The link style and highlight colors are bunmsh serve's.
198
+ const TEXT_STYLE = `:root { color-scheme: light dark }
199
+ body { margin: 8px }
200
+ a { color: inherit; text-decoration: none }
201
+ pre { margin: 0; white-space: pre-wrap; overflow-wrap: anywhere; font-family: monospace }
202
+ code { font: inherit }
203
+ .bar { display: flex; justify-content: space-between; align-items: center; gap: 16px; font-family: sans-serif; margin-bottom: 8px }
204
+ .download { margin-left: auto; padding: 4px 9px; border: 1px solid currentColor; border-radius: 4px; white-space: nowrap }
205
+ .note { font-family: sans-serif; color: #666 }
206
+ .json-statement { color: #b0005a }
207
+ .json-string { color: #6b5d00 }
208
+ .json-special { color: #087f5b }
209
+ .json-number, .json-constant { color: #5f3dc4 }
210
+ @media (prefers-color-scheme: dark) {
211
+ .json-statement { color: #f92672 }
212
+ .json-string { color: #e6db74 }
213
+ .json-special { color: #a6e22e }
214
+ .json-number, .json-constant { color: #ae81ff }
215
+ }`;
216
+
217
+ function html(title, style, body) {
218
+ return {
219
+ source: `<!doctype html><html><head><meta charset="utf-8"><title>${Bun.escapeHTML(title)}</title>`
220
+ + `<style>${style}</style></head><body>${body}</body></html>`,
221
+ contentType: "text/html",
222
+ };
223
+ }
224
+
225
+ // bunmsh serve.js pretty(): BigInts (TOML integers) keep their digits as "123n".
226
+ const pretty = (value) => {
227
+ try {
228
+ return JSON.stringify(value, (_key, item) =>
229
+ typeof item === "bigint" ? `${item}n` : item, 2) ?? String(value);
230
+ } catch {
231
+ return Bun.inspect(value, { colors: false, depth: Infinity });
232
+ }
233
+ };
234
+
235
+ // bunmsh serve.js highlightJson(): statement keys, strings and escapes,
236
+ // numbers, and true/false/null constants of the JSON pretty() emits.
237
+ export const highlightJson = (source) => {
238
+ const input = String(source);
239
+ const token = /"(?:\\(?:u[\da-fA-F]{4}|.)|[^"\\])*"|-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?|\b(?:true|false|null)\b/g;
240
+ let output = "", cursor = 0, match;
241
+ const stringHtml = (value) => {
242
+ let html = "", offset = 0;
243
+ for (const escape of value.matchAll(/\\(?:u[\da-fA-F]{4}|.)/g)) {
244
+ html += Bun.escapeHTML(value.slice(offset, escape.index));
245
+ html += `<span class="json-special">${Bun.escapeHTML(escape[0])}</span>`;
246
+ offset = escape.index + escape[0].length;
247
+ }
248
+ return html + Bun.escapeHTML(value.slice(offset));
249
+ };
250
+ while ((match = token.exec(input))) {
251
+ output += Bun.escapeHTML(input.slice(cursor, match.index));
252
+ const value = match[0];
253
+ let end = token.lastIndex;
254
+ if (value.startsWith('"')) {
255
+ const propertyEnd = /^\s*:/.exec(input.slice(end));
256
+ if (propertyEnd) {
257
+ end += propertyEnd[0].length;
258
+ output += `<span class="json-statement">${Bun.escapeHTML(input.slice(match.index, end))}</span>`;
259
+ } else {
260
+ output += `<span class="json-string">${stringHtml(value)}</span>`;
261
+ }
262
+ } else if (value === "true" || value === "false" || value === "null") {
263
+ output += `<span class="json-constant">${value}</span>`;
264
+ } else {
265
+ output += `<span class="json-number">${value}</span>`;
266
+ }
267
+ cursor = end;
268
+ token.lastIndex = end;
269
+ }
270
+ return output + Bun.escapeHTML(input.slice(cursor));
271
+ };
272
+
273
+ export function parseContentType(value) {
274
+ const [type = "", ...parameters] = String(value).split(";");
275
+ const charset = parameters.map((part) => part.trim().match(/^charset=["']?([^"';]+)/i)?.[1]).find(Boolean) ?? null;
276
+ return { type: type.trim().toLowerCase(), charset };
277
+ }
278
+
279
+ function decode(body, charset) {
280
+ try {
281
+ return new TextDecoder(charset ?? "utf-8").decode(body);
282
+ } catch {
283
+ return new TextDecoder().decode(body);
284
+ }
285
+ }
286
+
287
+ function fileName(url) {
288
+ try {
289
+ const segment = new URL(url).pathname.split("/").filter(Boolean).pop() ?? "";
290
+ return decodeURIComponent(segment) || new URL(url).hostname || url;
291
+ } catch {
292
+ return String(url);
293
+ }
294
+ }
295
+
296
+ function extension(name) {
297
+ const dot = name.lastIndexOf(".");
298
+ return dot > 0 ? name.slice(dot + 1).toLowerCase() : "";
299
+ }
300
+
301
+ function stripProlog(svg) {
302
+ return svg.replace(/^\s*<\?xml[^>]*>\s*/i, "").replace(/<!DOCTYPE[^>]*>\s*/i, "");
303
+ }
304
+
305
+ function looksLikeHtml(body) {
306
+ const head = new TextDecoder().decode(body.subarray(0, 512)).trimStart().toLowerCase();
307
+ return head.startsWith("<!doctype html") || head.startsWith("<html") || head.startsWith("<head") || head.startsWith("<body") || head === "";
308
+ }
@@ -0,0 +1,28 @@
1
+ import { homedir } from "node:os";
2
+ import { join, win32 } from "node:path";
3
+
4
+ // Windows can move the Downloads known folder away from %USERPROFILE%. Query
5
+ // its registered location through PowerShell so non-ASCII paths stay UTF-8.
6
+ const WINDOWS_DOWNLOADS = [
7
+ "$OutputEncoding = [Console]::OutputEncoding = [Text.UTF8Encoding]::new($false)",
8
+ "$key = [Microsoft.Win32.Registry]::CurrentUser.OpenSubKey('Software\\Microsoft\\Windows\\CurrentVersion\\Explorer\\User Shell Folders')",
9
+ "if ($key) { $path = $key.GetValue('{374DE290-123F-4565-9164-39C4925E467B}'); if ($path) { [Console]::Write([Environment]::ExpandEnvironmentVariables($path)) } }",
10
+ ].join("; ");
11
+
12
+ function windowsDownloads() {
13
+ try {
14
+ const result = Bun.spawnSync({ cmd: ["powershell.exe", "-NoProfile", "-NonInteractive", "-Command", WINDOWS_DOWNLOADS],
15
+ stdout: "pipe", stderr: "ignore" });
16
+ return result.exitCode === 0 ? new TextDecoder().decode(result.stdout).trim() : null;
17
+ } catch {
18
+ return null;
19
+ }
20
+ }
21
+
22
+ export function downloadsDirectory({ platform = process.platform, home = homedir(), queryWindows = windowsDownloads } = {}) {
23
+ if (platform === "win32") {
24
+ const known = queryWindows();
25
+ return known && win32.isAbsolute(known) ? known : win32.join(home, "Downloads");
26
+ }
27
+ return join(home, "Downloads");
28
+ }
@@ -0,0 +1,63 @@
1
+ import { mkdir, open, unlink } from "node:fs/promises";
2
+ import { extname, join } from "node:path";
3
+ import { Readable, Transform } from "node:stream";
4
+ import { pipeline } from "node:stream/promises";
5
+ import { downloadsDirectory } from "./download-directory.js";
6
+
7
+ export const DEFAULT_DOWNLOAD_DIR = downloadsDirectory();
8
+
9
+ export function isAttachment(value) {
10
+ return /^\s*attachment\s*(?:;|$)/i.test(value ?? "");
11
+ }
12
+
13
+ export function suggestedFilename(url, disposition = "", attributeName = "") {
14
+ const encoded = /(?:^|;)\s*filename\*\s*=\s*(?:UTF-8''|utf-8'[^']*')([^;]+)/i.exec(disposition)?.[1];
15
+ const plain = /(?:^|;)\s*filename\s*=\s*(?:"([^"]*)"|([^;]*))/i.exec(disposition);
16
+ let name = "";
17
+ if (encoded) { try { name = decodeURIComponent(encoded.trim().replace(/^"|"$/g, "")); } catch {} }
18
+ if (!name) name = plain?.[1] ?? plain?.[2]?.trim() ?? attributeName;
19
+ if (!name) {
20
+ const target = new URL(url);
21
+ const segment = target.pathname.split("/").filter(Boolean).at(-1);
22
+ try { name = decodeURIComponent(segment ?? ""); } catch { name = segment ?? ""; }
23
+ if (!name) name = target.hostname || "download";
24
+ }
25
+ name = name.split(/[\\/]/).at(-1).replace(/[<>:"|?*\x00-\x1f\x7f]/g, "_").replace(/^\.+/, "_").replace(/[. ]+$/, "").slice(0, 200);
26
+ return name || "download";
27
+ }
28
+
29
+ /** Save a response stream or already held bytes without replacing an existing file. */
30
+ export async function saveDownload({ url, disposition = "", attributeName = "", filename = null, body, dir = DEFAULT_DOWNLOAD_DIR }) {
31
+ await mkdir(dir, { recursive: true });
32
+ const name = filename ?? suggestedFilename(url, disposition, attributeName);
33
+ const extension = extname(name);
34
+ const stem = name.slice(0, name.length - extension.length);
35
+ let file;
36
+ let path;
37
+ for (let number = 1; ; number++) {
38
+ path = join(dir, number === 1 ? name : `${stem} (${number})${extension}`);
39
+ try { file = await open(path, "wx"); break; }
40
+ catch (error) { if (error.code !== "EEXIST") throw error; }
41
+ }
42
+ try {
43
+ let bytes = 0;
44
+ if (body instanceof Uint8Array) {
45
+ bytes = body.byteLength;
46
+ await file.writeFile(body);
47
+ } else if (body) {
48
+ const count = new Transform({ transform(chunk, _encoding, done) {
49
+ bytes += chunk.byteLength;
50
+ done(null, chunk);
51
+ } });
52
+ await pipeline(Readable.fromWeb(body), count, file.createWriteStream());
53
+ }
54
+ else await file.close();
55
+ return { path, name: path.slice(dir.length + 1), bytes };
56
+ } catch (error) {
57
+ await file.close().catch(() => {});
58
+ await unlink(path).catch(() => {});
59
+ throw error;
60
+ } finally {
61
+ await file.close().catch(() => {});
62
+ }
63
+ }
@@ -11,10 +11,13 @@
11
11
  * the renderer sandbox, for every page.
12
12
  */
13
13
  import { CdpServer } from "./cdp-server.js";
14
+ import { DEFAULT_DOWNLOAD_DIR, isAttachment, saveDownload, suggestedFilename } from "./download.js";
15
+ import { audioDemoDocument, displayDocument } from "./document-types.js";
14
16
  import { installLog, logFileFromEnv, writeLog } from "./log.js";
15
17
  import { NetworkService } from "./network/network-service.js";
16
18
  import { RendererHost } from "./renderer/host.js";
17
19
  import { MOBILE_FLAG, MOBILE_LAYOUT_WIDTH, MOBILE_USER_AGENT } from "./renderer/flags.js";
20
+ import { DESKTOP_USER_AGENT } from "./user-agent.js";
18
21
 
19
22
  /** Opt-in phone presentation: mobile user agent and touch media features. */
20
23
  export const MOBILE = "--mobile";
@@ -70,10 +73,12 @@ export async function createBrowser({
70
73
  allowHostJs = false, spareRenderer = true, network = new NetworkService(), rendererTimeout = 30_000, mobile = false,
71
74
  } = {}) {
72
75
  // --mobile fixes the user agent for page requests and navigator.userAgent.
73
- if (mobile) network.userAgent = MOBILE_USER_AGENT;
76
+ network.userAgent = mobile ? MOBILE_USER_AGENT : DESKTOP_USER_AGENT;
74
77
  const history = [{ url: "about:blank", x: 0, y: 0 }];
75
78
  let historyIndex = 0;
76
79
  let currentUrl = "about:blank";
80
+ // The current document's response as fetched (raw bytes), for downloads.
81
+ let resource = null;
77
82
  let current = null;
78
83
  let currentKey = null;
79
84
  let spare = null;
@@ -84,6 +89,8 @@ export async function createBrowser({
84
89
  // Listeners for frame and lifecycle notifications (the CDP server).
85
90
  const frameListeners = new Set();
86
91
  const lifecycleListeners = new Set();
92
+ const downloadListeners = new Set();
93
+ let downloadBehavior = { behavior: "allow", downloadPath: DEFAULT_DOWNLOAD_DIR, eventsEnabled: false };
87
94
  // The committed document: its renderer and load generation. Lifecycle
88
95
  // messages can arrive before loadDocument returns, so each host records
89
96
  // what every generation has reached.
@@ -104,6 +111,30 @@ export async function createBrowser({
104
111
  }
105
112
  }
106
113
 
114
+ async function download({ url, body, disposition = "", attributeName = "", totalBytes = 0 }) {
115
+ const guid = crypto.randomUUID();
116
+ const filename = suggestedFilename(url, disposition, attributeName);
117
+ if (downloadBehavior.eventsEnabled) notify(downloadListeners, "willBegin", { guid, url, suggestedFilename: filename });
118
+ if (downloadBehavior.behavior === "deny") {
119
+ if (downloadBehavior.eventsEnabled) notify(downloadListeners, "progress", { guid, state: "canceled", receivedBytes: 0, totalBytes });
120
+ return { downloaded: true };
121
+ }
122
+ try {
123
+ if (downloadBehavior.eventsEnabled) notify(downloadListeners, "progress", { guid, state: "inProgress", receivedBytes: 0, totalBytes });
124
+ const saved = await saveDownload({ url, body, disposition, attributeName,
125
+ filename: downloadBehavior.behavior === "allowAndName" ? guid : null, dir: downloadBehavior.downloadPath });
126
+ if (downloadBehavior.eventsEnabled) notify(downloadListeners, "progress", {
127
+ guid, state: "completed", receivedBytes: saved.bytes, totalBytes: totalBytes || saved.bytes, filePath: saved.path,
128
+ });
129
+ writeLog("download", `${url} -> ${saved.path}`);
130
+ return { downloaded: true, path: saved.path };
131
+ } catch (error) {
132
+ if (downloadBehavior.eventsEnabled) notify(downloadListeners, "progress", { guid, state: "canceled", receivedBytes: 0, totalBytes });
133
+ console.error(`buninu-browser: download failed: ${error?.message ?? error}`);
134
+ return { downloaded: true, error };
135
+ }
136
+ }
137
+
107
138
  function createHost() {
108
139
  const host = new RendererHost({
109
140
  network,
@@ -115,9 +146,17 @@ export async function createBrowser({
115
146
  await load(currentUrl, invoke);
116
147
  },
117
148
  // Only the renderer showing the current page may navigate it.
118
- onNavigate: (url) => {
149
+ onNavigate: (url, downloadName, fragmentFrom) => {
150
+ if (host !== current) return;
151
+ if (fragmentFrom) {
152
+ recordFragment(url, fragmentFrom);
153
+ return;
154
+ }
155
+ navigate(url, { downloadName }).catch((error) => console.error(`buninu-browser: navigation failed: ${error.message}`));
156
+ },
157
+ onDownload: ({ url, name, data }) => {
119
158
  if (host !== current) return;
120
- navigate(url).catch((error) => console.error(`buninu-browser: navigation failed: ${error.message}`));
159
+ void download({ url, body: data, attributeName: name, totalBytes: data.byteLength });
121
160
  },
122
161
  onFrame: () => {
123
162
  if (host === current) notify(frameListeners);
@@ -184,22 +223,36 @@ export async function createBrowser({
184
223
  * final origin. `invoke` pins the renderer (used when restoring a
185
224
  * restarted renderer).
186
225
  */
187
- async function load(url, invoke = null) {
226
+ async function load(url, invoke = null, downloadName = null) {
188
227
  const started = performance.now();
189
- currentUrl = url;
228
+ if (downloadName !== null && resource && withoutHash(url) === withoutHash(resource.url)) {
229
+ return download({ url: resource.url, body: resource.body, attributeName: downloadName, totalBytes: resource.body.byteLength });
230
+ }
190
231
  let document;
191
232
  if (url === "about:blank") {
192
233
  document = { url };
234
+ } else if (url === "about:audio") {
235
+ document = { url, ...audioDemoDocument() };
193
236
  } else {
194
237
  try {
195
238
  const target = new URL(url);
196
239
  const response = ["http:", "https:"].includes(target.protocol)
197
- ? await network.navigate(target.href)
240
+ ? await network.navigate(target.href, { onResponse: (raw, finalUrl) => {
241
+ if (!raw.ok || (downloadName === null && !isAttachment(raw.headers.get("content-disposition")))) return null;
242
+ return download({ url: finalUrl, body: raw.body, disposition: raw.headers.get("content-disposition") ?? "",
243
+ attributeName: downloadName ?? "", totalBytes: Number(raw.headers.get("content-length")) || 0 });
244
+ } })
198
245
  : await localDocument(target);
246
+ if (response.downloaded) return response;
247
+ if (downloadName !== null) return download({ url: response.url, body: response.body,
248
+ attributeName: downloadName, totalBytes: response.body.byteLength });
249
+ resource = { url: response.url, contentType: response.contentType ?? "", body: response.body };
250
+ // Non-HTML responses (images, JSON, TOML, text...) are shown through a generated page.
251
+ const shown = displayDocument({ url: response.url, contentType: response.contentType, body: response.body });
199
252
  document = {
200
253
  url: response.url,
201
- source: new TextDecoder().decode(response.body),
202
- contentType: response.contentType || "text/html",
254
+ source: shown.source,
255
+ contentType: shown.contentType,
203
256
  status: response.status,
204
257
  statusText: response.statusText,
205
258
  cookie: network.documentCookie(response.url),
@@ -208,6 +261,7 @@ export async function createBrowser({
208
261
  document = { url, error: String(error) };
209
262
  }
210
263
  }
264
+ if (document.url === "about:blank" || document.url === "about:audio" || document.error) resource = null;
211
265
  currentUrl = document.url;
212
266
  const fetched = performance.now();
213
267
  writeLog("navigation", `${url}: ${document.error ?? `HTTP ${document.status ?? 200}`}`
@@ -228,7 +282,7 @@ export async function createBrowser({
228
282
  Object.assign(history[historyIndex], { x: offset.x, y: offset.y });
229
283
  }
230
284
 
231
- async function navigate(url) {
285
+ async function navigate(url, { downloadName = null } = {}) {
232
286
  const resolved = new URL(url, currentUrl).href;
233
287
  // A javascript: URL runs in the current document; history does not change.
234
288
  if (resolved.startsWith("javascript:")) {
@@ -236,10 +290,11 @@ export async function createBrowser({
236
290
  return;
237
291
  }
238
292
  await rememberScroll();
239
- if (withoutHash(resolved) === withoutHash(currentUrl)) {
293
+ if (downloadName === null && withoutHash(resolved) === withoutHash(currentUrl)) {
240
294
  currentUrl = resolved;
241
295
  } else {
242
- await load(resolved);
296
+ const result = await load(resolved, null, downloadName);
297
+ if (result?.downloaded) return null;
243
298
  }
244
299
  await call("showFragment", resolved);
245
300
  history.splice(historyIndex + 1);
@@ -247,6 +302,16 @@ export async function createBrowser({
247
302
  historyIndex = history.length - 1;
248
303
  }
249
304
 
305
+ /** The renderer already navigated to a fragment of its document; only history changes. */
306
+ function recordFragment(url, from) {
307
+ if (withoutHash(url) !== withoutHash(currentUrl)) return;
308
+ Object.assign(history[historyIndex], { x: from.x, y: from.y });
309
+ currentUrl = url;
310
+ history.splice(historyIndex + 1);
311
+ history.push({ url, x: 0, y: 0 });
312
+ historyIndex = history.length - 1;
313
+ }
314
+
250
315
  async function traverseHistory(index) {
251
316
  if (index < 0 || index >= history.length || index === historyIndex) return;
252
317
  await rememberScroll();
@@ -258,6 +323,14 @@ export async function createBrowser({
258
323
 
259
324
  const context = {
260
325
  navigate,
326
+ setDownloadBehavior({ behavior = "allow", downloadPath = DEFAULT_DOWNLOAD_DIR, eventsEnabled = false } = {}) {
327
+ if (!["allow", "allowAndName", "default", "deny"].includes(behavior)) throw new TypeError(`Unsupported download behavior ${behavior}`);
328
+ downloadBehavior = { behavior, downloadPath, eventsEnabled: Boolean(eventsEnabled) };
329
+ },
330
+ onDownload(listener) {
331
+ downloadListeners.add(listener);
332
+ return () => downloadListeners.delete(listener);
333
+ },
261
334
  reload: () => load(currentUrl),
262
335
  goBack: () => traverseHistory(historyIndex - 1),
263
336
  goForward: () => traverseHistory(historyIndex + 1),
@@ -277,6 +350,22 @@ export async function createBrowser({
277
350
  quality: Number.isFinite(options.quality) ? options.quality : 80,
278
351
  }),
279
352
  title: () => call("title"),
353
+ /**
354
+ * The document's response body as it arrived (CDP Page.getResourceContent),
355
+ * for the current document's URL with or without its fragment; else null.
356
+ */
357
+ resourceContent(url = currentUrl) {
358
+ if (!resource) return null;
359
+ let wanted;
360
+ try {
361
+ wanted = withoutHash(String(url));
362
+ } catch {
363
+ return null;
364
+ }
365
+ return wanted === withoutHash(resource.url) || wanted === withoutHash(currentUrl)
366
+ ? { url: resource.url, contentType: resource.contentType, body: resource.body }
367
+ : null;
368
+ },
280
369
  scroll: (deltaX, deltaY) => composite("scroll", deltaX / pageScale(), deltaY / pageScale()),
281
370
  press: (key, options) => call("press", key, options),
282
371
  type: (text) => call("type", text),
@@ -296,10 +385,15 @@ export async function createBrowser({
296
385
  return [...reachedLifecycle(committed.host, committed.generation)];
297
386
  },
298
387
  async click(x, y) {
299
- const href = await call("click", x / pageScale(), y / pageScale());
388
+ const clicked = await call("click", x / pageScale(), y / pageScale());
389
+ const href = typeof clicked === "string" ? clicked : clicked?.url;
300
390
  if (typeof href !== "string") return null;
301
- await navigate(href);
302
- return { url: href, title: await call("title") };
391
+ if (clicked.fragmentFrom) {
392
+ recordFragment(href, clicked.fragmentFrom);
393
+ return { url: href, title: await call("title") };
394
+ }
395
+ const result = await navigate(href, { downloadName: typeof clicked === "object" ? clicked.download ?? null : null });
396
+ return result === null ? null : { url: href, title: await call("title") };
303
397
  },
304
398
  evaluate: (expression) => call("evaluate", String(expression)),
305
399
  cdp() {