1import { FAVICON_DATA_URL_MAX_LENGTH } from "@t3tools/contracts";
2
3export const MAX_FAVICON_RESPONSE_BYTES = 100_000;
4export const MAX_FAVICON_CANDIDATES = 8;
5const MAX_FAVICON_HTTP_URL_LENGTH = 2_048;
6
7const MAX_FAVICON_CANDIDATE_INPUT_UNITS = 262_144;
8const MIN_FAVICON_CANDIDATE_INPUT_UNITS = 256;
9const MAX_FAVICON_SOURCE_PIXELS = 1_048_576;
10const MAX_FAVICON_INLINE_URL_LENGTH = Math.ceil((MAX_FAVICON_RESPONSE_BYTES * 4) / 3) + 128;
11const FAVICON_CAPTURE_TIMEOUT_MS = 5_000;
12const FAVICON_RASTER_WORLD_ID = 1001;
13const FAVICON_RASTER_TIMEOUT_MS = 1_000;
14const PNG_SIGNATURE = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
15
16interface RasterizationGate {
17 generation: number;
18 launchAllowed?: Promise<void>;
19}
20
21const rasterizationGates = new WeakMap<Electron.WebContents, RasterizationGate>();
22
23async function waitForRasterLaunch(previous: Promise<void>, signal: AbortSignal): Promise<void> {
24 if (signal.aborted) return;
25 await new Promise<void>((resolve) => {
26 const finish = () => {
27 signal.removeEventListener("abort", finish);
28 resolve();
29 };
30 signal.addEventListener("abort", finish, { once: true });
31 void previous.then(finish);
32 });
33}
34
35export type FaviconCaptureResult =
36 | { readonly kind: "captured"; readonly dataUrl: string }
37 | { readonly kind: "none" }
38 | { readonly kind: "timed-out" };
39
40type RasterizationResult =
41 | { readonly kind: "completed"; readonly value: unknown }
42 | { readonly kind: "timed-out" };
43
44export function safeHttpOrigin(url: string): string | null {
45 try {
46 const parsed = new URL(url);
47 return parsed.protocol === "http:" || parsed.protocol === "https:" ? parsed.origin : null;
48 } catch {
49 return null;
50 }
51}
52
53export function selectFaviconCandidates(candidates: ReadonlyArray<string>): ReadonlyArray<string> {
54 const selected: string[] = [];
55 const seen = new Set<string>();
56 let inputUnits = 0;
57 for (const candidate of candidates) {
58 // Charge a minimum per entry so a large array of tiny malformed values is bounded too.
59 inputUnits += Math.max(MIN_FAVICON_CANDIDATE_INPUT_UNITS, candidate.length);
60 if (inputUnits > MAX_FAVICON_CANDIDATE_INPUT_UNITS) break;
61 if (!isSupportedFaviconUrl(candidate) || seen.has(candidate)) continue;
62 seen.add(candidate);
63 selected.push(candidate);
64 if (selected.length === MAX_FAVICON_CANDIDATES) break;
65 }
66 return selected;
67}
68
69export async function captureFavicon(input: {
70 readonly webContents: Electron.WebContents;
71 readonly pageUrl: string;
72 readonly candidates: ReadonlyArray<string>;
73 readonly signal: AbortSignal;
74}): Promise<FaviconCaptureResult> {
75 const pageOrigin = safeHttpOrigin(input.pageUrl);
76 if (!pageOrigin) return { kind: "none" };
77 const captureTimeout = AbortSignal.timeout(FAVICON_CAPTURE_TIMEOUT_MS);
78 const captureSignal = AbortSignal.any([input.signal, captureTimeout]);
79
80 for (const candidate of selectFaviconCandidates(input.candidates)) {
81 if (captureSignal.aborted) {
82 return input.signal.aborted ? { kind: "none" } : { kind: "timed-out" };
83 }
84 const captured = await captureCandidate({
85 webContents: input.webContents,
86 pageOrigin,
87 candidate,
88 signal: captureSignal,
89 });
90 if (captureSignal.aborted) {
91 return input.signal.aborted ? { kind: "none" } : { kind: "timed-out" };
92 }
93 if (captured.kind === "captured" || captured.kind === "timed-out") return captured;
94 }
95
96 return { kind: "none" };
97}
98
99async function captureCandidate(input: {
100 readonly webContents: Electron.WebContents;
101 readonly pageOrigin: string;
102 readonly candidate: string;
103 readonly signal: AbortSignal;
104}): Promise<FaviconCaptureResult> {
105 try {
106 const inline = parseInlineFavicon(input.candidate);
107 if (inline) {
108 return await normalizeFaviconBuffer(
109 input.webContents,
110 inline.mime,
111 inline.buffer,
112 input.signal,
113 );
114 }
115
116 const candidateOrigin = safeHttpOrigin(input.candidate);
117 if (!candidateOrigin) return { kind: "none" };
118 const response = await input.webContents.session.fetch(input.candidate, {
119 credentials: candidateOrigin === input.pageOrigin ? "include" : "omit",
120 redirect: "error",
121 signal: input.signal,
122 });
123 if (!response.ok) {
124 await response.body?.cancel();
125 return { kind: "none" };
126 }
127 const buffer = await readFaviconResponse(response, input.signal);
128 if (!buffer || input.signal.aborted) return { kind: "none" };
129 const mime = response.headers.get("content-type")?.split(";", 1)[0] ?? null;
130 return await normalizeFaviconBuffer(input.webContents, mime, buffer, input.signal);
131 } catch {
132 return { kind: "none" };
133 }
134}
135
136async function readFaviconResponse(
137 response: Response,
138 signal: AbortSignal,
139): Promise<Buffer | null> {
140 const contentLength = Number(response.headers.get("content-length"));
141 if (Number.isFinite(contentLength) && contentLength > MAX_FAVICON_RESPONSE_BYTES) {
142 await response.body?.cancel();
143 return null;
144 }
145 if (!response.body) {
146 const buffer = Buffer.from(await response.arrayBuffer());
147 return buffer.byteLength <= MAX_FAVICON_RESPONSE_BYTES ? buffer : null;
148 }
149
150 const reader = response.body.getReader();
151 const cancelForAbort = () => {
152 void reader.cancel(signal.reason).catch(() => undefined);
153 };
154 signal.addEventListener("abort", cancelForAbort, { once: true });
155 if (signal.aborted) cancelForAbort();
156 const chunks: Buffer[] = [];
157 let byteLength = 0;
158 try {
159 while (true) {
160 const next = await reader.read();
161 if (next.done) return Buffer.concat(chunks, byteLength);
162 byteLength += next.value.byteLength;
163 if (byteLength > MAX_FAVICON_RESPONSE_BYTES) {
164 await reader.cancel();
165 return null;
166 }
167 chunks.push(Buffer.from(next.value));
168 }
169 } finally {
170 signal.removeEventListener("abort", cancelForAbort);
171 reader.releaseLock();
172 }
173}
174
175function isSupportedFaviconUrl(url: string): boolean {
176 if (url.length > MAX_FAVICON_INLINE_URL_LENGTH) return false;
177 if (/^data:/i.test(url)) return /^data:image\/[a-z0-9.+-]+(?:;[^,]*)?,/i.test(url);
178 try {
179 const protocol = new URL(url).protocol;
180 return (
181 (protocol === "http:" || protocol === "https:") && url.length <= MAX_FAVICON_HTTP_URL_LENGTH
182 );
183 } catch {
184 return false;
185 }
186}
187
188function decodeInlineFaviconPayload(payload: string): Buffer | null {
189 const decoded = Buffer.allocUnsafe(Buffer.byteLength(payload));
190 let inputOffset = 0;
191 let outputOffset = 0;
192 while (inputOffset < payload.length) {
193 const escapeOffset = payload.indexOf("%", inputOffset);
194 const literalEnd = escapeOffset === -1 ? payload.length : escapeOffset;
195 outputOffset += decoded.write(payload.slice(inputOffset, literalEnd), outputOffset, "utf8");
196 if (escapeOffset === -1) break;
197 const hex = payload.slice(escapeOffset + 1, escapeOffset + 3);
198 if (!/^[0-9a-f]{2}$/i.test(hex)) return null;
199 decoded[outputOffset] = Number.parseInt(hex, 16);
200 outputOffset += 1;
201 inputOffset = escapeOffset + 3;
202 }
203 return decoded.subarray(0, outputOffset);
204}
205
206function parseInlineFavicon(
207 url: string,
208): { readonly buffer: Buffer; readonly mime: string } | null {
209 if (url.length > MAX_FAVICON_INLINE_URL_LENGTH) return null;
210 const match = /^data:(image\/[a-z0-9.+-]+)((?:;[^,]*)?),(.*)$/is.exec(url);
211 if (!match) return null;
212 const mime = match[1]?.toLowerCase();
213 const parameters = match[2]
214 ?.split(";")
215 .filter(Boolean)
216 .map((parameter) => parameter.toLowerCase());
217 const payload = match[3];
218 if (!mime || !parameters || !payload) return null;
219 const base64 = parameters.at(-1) === "base64";
220 if (parameters.includes("base64") && !base64) return null;
221
222 let buffer: Buffer;
223 try {
224 if (base64) {
225 if (!/^[a-z0-9+/]*={0,2}$/i.test(payload) || payload.length % 4 === 1) return null;
226 buffer = Buffer.from(payload, "base64");
227 if (buffer.toString("base64").replace(/=+$/, "") !== payload.replace(/=+$/, "")) {
228 return null;
229 }
230 } else {
231 const decoded = decodeInlineFaviconPayload(payload);
232 if (!decoded) return null;
233 buffer = decoded;
234 }
235 } catch {
236 return null;
237 }
238
239 return buffer.byteLength > 0 && buffer.byteLength <= MAX_FAVICON_RESPONSE_BYTES
240 ? { buffer, mime }
241 : null;
242}
243
244interface ImageDimensions {
245 readonly width: number;
246 readonly height: number;
247}
248
249function safeDimensions(
250 dimensions: ImageDimensions | null,
251 maxPixels = MAX_FAVICON_SOURCE_PIXELS,
252): dimensions is ImageDimensions {
253 return (
254 dimensions !== null &&
255 Number.isSafeInteger(dimensions.width) &&
256 Number.isSafeInteger(dimensions.height) &&
257 dimensions.width > 0 &&
258 dimensions.height > 0 &&
259 dimensions.width * dimensions.height <= maxPixels
260 );
261}
262
263function pngDimensions(buffer: Buffer): ImageDimensions | null {
264 if (!buffer.subarray(0, PNG_SIGNATURE.length).equals(PNG_SIGNATURE) || buffer.byteLength < 24) {
265 return null;
266 }
267 return { width: buffer.readUInt32BE(16), height: buffer.readUInt32BE(20) };
268}
269
270function skipGifSubBlocks(buffer: Buffer, startOffset: number): number | null {
271 let offset = startOffset;
272 while (offset < buffer.byteLength) {
273 const blockLength = buffer[offset]!;
274 offset += 1;
275 if (blockLength === 0) return offset;
276 if (offset + blockLength > buffer.byteLength) return null;
277 offset += blockLength;
278 }
279 return null;
280}
281
282function gifDimensions(buffer: Buffer, maxPixels: number): ImageDimensions | null {
283 if (buffer.byteLength < 13 || !/^GIF8[79]a$/u.test(buffer.subarray(0, 6).toString("ascii"))) {
284 return null;
285 }
286 const logicalWidth = buffer.readUInt16LE(6);
287 const logicalHeight = buffer.readUInt16LE(8);
288 if (!safeDimensions({ width: logicalWidth, height: logicalHeight }, maxPixels)) return null;
289 const packed = buffer[10]!;
290 let offset = 13 + ((packed & 0x80) === 0 ? 0 : 3 * 2 ** ((packed & 0x07) + 1));
291 if (offset > buffer.byteLength) return null;
292 let width = logicalWidth;
293 let height = logicalHeight;
294 let frameCount = 0;
295 let framePixels = 0;
296 while (offset < buffer.byteLength) {
297 const marker = buffer[offset];
298 if (marker === 0x3b) return frameCount > 0 ? { width, height } : null;
299 if (marker === 0x2c) {
300 if (offset + 10 > buffer.byteLength) return null;
301 const left = buffer.readUInt16LE(offset + 1);
302 const top = buffer.readUInt16LE(offset + 3);
303 const frameWidth = buffer.readUInt16LE(offset + 5);
304 const frameHeight = buffer.readUInt16LE(offset + 7);
305 if (frameWidth === 0 || frameHeight === 0) return null;
306 framePixels += frameWidth * frameHeight;
307 if (framePixels > maxPixels) return null;
308 width = Math.max(width, left + frameWidth);
309 height = Math.max(height, top + frameHeight);
310 if (!safeDimensions({ width, height }, maxPixels)) return null;
311 const framePacked = buffer[offset + 9]!;
312 offset += 10;
313 if ((framePacked & 0x80) !== 0) {
314 offset += 3 * 2 ** ((framePacked & 0x07) + 1);
315 }
316 if (offset >= buffer.byteLength) return null;
317 const minimumCodeSize = buffer[offset]!;
318 if (minimumCodeSize < 2 || minimumCodeSize > 8) return null;
319 offset += 1;
320 const nextOffset = skipGifSubBlocks(buffer, offset);
321 if (nextOffset === null) return null;
322 offset = nextOffset;
323 frameCount += 1;
324 continue;
325 }
326 if (marker !== 0x21 || offset + 2 > buffer.byteLength) return null;
327 const nextOffset = skipGifSubBlocks(buffer, offset + 2);
328 if (nextOffset === null) return null;
329 offset = nextOffset;
330 }
331 return null;
332}
333
334interface JpegExifMetadata {
335 readonly complete: boolean;
336 readonly orientation: number | null;
337}
338
339function jpegExifMetadata(segment: Buffer): JpegExifMetadata | null {
340 if (segment.byteLength <= 6 || segment.subarray(0, 5).toString("binary") !== "Exif\0") {
341 return null;
342 }
343 const metadataWithoutOrientation = (): JpegExifMetadata => ({
344 complete: true,
345 orientation: null,
346 });
347 if (segment.byteLength < 14) return metadataWithoutOrientation();
348 const tiffOffset = 6;
349 const littleEndian = segment[tiffOffset] === 0x49 && segment[tiffOffset + 1] === 0x49;
350 const bigEndian = segment[tiffOffset] === 0x4d && segment[tiffOffset + 1] === 0x4d;
351 if (!littleEndian && !bigEndian) return metadataWithoutOrientation();
352 const readUInt16 = (offset: number): number | null => {
353 if (offset < 0 || offset + 2 > segment.byteLength) return null;
354 return littleEndian ? segment.readUInt16LE(offset) : segment.readUInt16BE(offset);
355 };
356 const readUInt32 = (offset: number): number | null => {
357 if (offset < 0 || offset + 4 > segment.byteLength) return null;
358 return littleEndian ? segment.readUInt32LE(offset) : segment.readUInt32BE(offset);
359 };
360 const relativeIfdOffset = readUInt32(tiffOffset + 4);
361 if (relativeIfdOffset === null) return metadataWithoutOrientation();
362 // Keep untrusted metadata parsing linear even when IFD pointers overlap.
363 let remainingIfdEntryVisits = Math.ceil(segment.byteLength / 12);
364 const budgetExhausted = Symbol("ifd-entry-budget-exhausted");
365 type IfdOrientation = number | null | typeof budgetExhausted;
366 const subIfdOrientationByOffset = new Map<number, number | null>();
367 const readIfdOrientation = (ifdOffset: number, isRoot: boolean): IfdOrientation => {
368 if (!isRoot && subIfdOrientationByOffset.has(ifdOffset)) {
369 return subIfdOrientationByOffset.get(ifdOffset) ?? null;
370 }
371 const entryCount = readUInt16(ifdOffset);
372 if (entryCount === null) return null;
373 let result: IfdOrientation = null;
374 for (let index = 0; index < entryCount; index += 1) {
375 if (remainingIfdEntryVisits === 0) return budgetExhausted;
376 remainingIfdEntryVisits -= 1;
377 const entryOffset = ifdOffset + 2 + index * 12;
378 if (entryOffset + 12 > segment.byteLength) break;
379 const tag = readUInt16(entryOffset);
380 const type = readUInt16(entryOffset + 2);
381 const count = readUInt32(entryOffset + 4);
382 if (tag === 0x0112 && type === 3 && count === 1) {
383 const orientation = readUInt16(entryOffset + 8);
384 if (orientation !== null && orientation >= 1 && orientation <= 8) {
385 result = orientation;
386 break;
387 }
388 } else if (isRoot && tag === 0x8769 && type === 4 && count === 1) {
389 const relativeSubIfdOffset = readUInt32(entryOffset + 8);
390 if (relativeSubIfdOffset !== null) {
391 const orientation = readIfdOrientation(tiffOffset + relativeSubIfdOffset, false);
392 if (orientation === budgetExhausted) return budgetExhausted;
393 if (orientation !== null) {
394 result = orientation;
395 break;
396 }
397 }
398 }
399 }
400 if (!isRoot) subIfdOrientationByOffset.set(ifdOffset, result);
401 return result;
402 };
403 const orientation = readIfdOrientation(tiffOffset + relativeIfdOffset, true);
404 return orientation === budgetExhausted
405 ? { complete: false, orientation: null }
406 : { complete: true, orientation };
407}
408
409function jpegDimensions(buffer: Buffer): ImageDimensions | null {
410 if (buffer.byteLength < 4 || buffer[0] !== 0xff || buffer[1] !== 0xd8) return null;
411 const startOfFrameMarkers = new Set([
412 0xc0, 0xc1, 0xc2, 0xc3, 0xc5, 0xc6, 0xc7, 0xc9, 0xca, 0xcb, 0xcd, 0xce, 0xcf,
413 ]);
414 let offset = 2;
415 let dimensions: ImageDimensions | null = null;
416 let exifMetadata: JpegExifMetadata | null = null;
417 while (offset + 3 < buffer.byteLength) {
418 if (buffer[offset] !== 0xff) {
419 offset += 1;
420 continue;
421 }
422 while (buffer[offset] === 0xff) offset += 1;
423 const marker = buffer[offset];
424 offset += 1;
425 if (marker === undefined || marker === 0xd9 || marker === 0xda) break;
426 if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd8)) continue;
427 if (offset + 1 >= buffer.byteLength) return null;
428 const length = buffer.readUInt16BE(offset);
429 if (length < 2 || offset + length > buffer.byteLength) return null;
430 if (marker === 0xe1 && exifMetadata === null) {
431 exifMetadata = jpegExifMetadata(buffer.subarray(offset + 2, offset + length));
432 }
433 if (startOfFrameMarkers.has(marker)) {
434 if (length < 7) return null;
435 if (dimensions !== null) return null;
436 dimensions = {
437 height: buffer.readUInt16BE(offset + 3),
438 width: buffer.readUInt16BE(offset + 5),
439 };
440 }
441 offset += length;
442 }
443 if (!dimensions) return null;
444 if (exifMetadata?.complete === false) return null;
445 const orientation = exifMetadata?.orientation;
446 return orientation !== undefined && orientation !== null && orientation >= 5 && orientation <= 8
447 ? { width: dimensions.height, height: dimensions.width }
448 : dimensions;
449}
450
451function webpDimensions(buffer: Buffer): ImageDimensions | null {
452 if (
453 buffer.byteLength < 30 ||
454 buffer.subarray(0, 4).toString("ascii") !== "RIFF" ||
455 buffer.subarray(8, 12).toString("ascii") !== "WEBP"
456 ) {
457 return null;
458 }
459 const kind = buffer.subarray(12, 16).toString("ascii");
460 if (kind === "VP8X") {
461 return {
462 width: 1 + buffer.readUIntLE(24, 3),
463 height: 1 + buffer.readUIntLE(27, 3),
464 };
465 }
466 if (kind === "VP8 " && buffer.subarray(23, 26).equals(Buffer.from([0x9d, 0x01, 0x2a]))) {
467 return {
468 width: buffer.readUInt16LE(26) & 0x3fff,
469 height: buffer.readUInt16LE(28) & 0x3fff,
470 };
471 }
472 if (kind === "VP8L" && buffer[20] === 0x2f) {
473 return {
474 width: 1 + buffer[21]! + ((buffer[22]! & 0x3f) << 8),
475 height: 1 + (buffer[22]! >> 6) + (buffer[23]! << 2) + ((buffer[24]! & 0x0f) << 10),
476 };
477 }
478 return null;
479}
480
481function dibDimensions(buffer: Buffer): ImageDimensions | null {
482 if (buffer.byteLength < 12) return null;
483 const headerSize = buffer.readUInt32LE(0);
484 if (headerSize === 12) {
485 return {
486 width: buffer.readUInt16LE(4),
487 height: buffer.readUInt16LE(6),
488 };
489 }
490 if (headerSize < 40 || buffer.byteLength < 12) return null;
491 return {
492 width: Math.abs(buffer.readInt32LE(4)),
493 height: Math.abs(buffer.readInt32LE(8)),
494 };
495}
496
497function icoDimensions(buffer: Buffer, maxPixels: number): ImageDimensions | null {
498 if (
499 buffer.byteLength < 22 ||
500 buffer.readUInt16LE(0) !== 0 ||
501 (buffer.readUInt16LE(2) !== 1 && buffer.readUInt16LE(2) !== 2)
502 ) {
503 return null;
504 }
505 const count = buffer.readUInt16LE(4);
506 if (count === 0 || count > 256 || buffer.byteLength < 6 + count * 16) return null;
507 let width = 0;
508 let height = 0;
509 for (let index = 0; index < count; index += 1) {
510 const offset = 6 + index * 16;
511 width = Math.max(width, buffer[offset] === 0 ? 256 : buffer[offset]!);
512 height = Math.max(height, buffer[offset + 1] === 0 ? 256 : buffer[offset + 1]!);
513 if (!safeDimensions({ width, height }, maxPixels)) return null;
514 const byteLength = buffer.readUInt32LE(offset + 8);
515 const imageOffset = buffer.readUInt32LE(offset + 12);
516 if (
517 byteLength === 0 ||
518 imageOffset < 6 + count * 16 ||
519 imageOffset > buffer.byteLength ||
520 byteLength > buffer.byteLength - imageOffset
521 )
522 return null;
523 const embedded = buffer.subarray(imageOffset, imageOffset + byteLength);
524 const embeddedDimensions = pngDimensions(embedded) ?? dibDimensions(embedded);
525 if (!safeDimensions(embeddedDimensions, maxPixels)) return null;
526 }
527 return { width, height };
528}
529
530export function sourceDimensions(
531 buffer: Buffer,
532 maxPixels = MAX_FAVICON_SOURCE_PIXELS,
533): ImageDimensions | null {
534 return (
535 pngDimensions(buffer) ??
536 gifDimensions(buffer, maxPixels) ??
537 jpegDimensions(buffer) ??
538 webpDimensions(buffer) ??
539 icoDimensions(buffer, maxPixels)
540 );
541}
542
543async function normalizeFaviconBuffer(
544 webContents: Electron.WebContents,
545 mime: string | null,
546 buffer: Buffer,
547 signal: AbortSignal,
548): Promise<FaviconCaptureResult> {
549 const declaredMime = mime?.trim().toLowerCase() || null;
550 const normalizedMime =
551 declaredMime === "application/x-icon"
552 ? "image/x-icon"
553 : declaredMime === "application/octet-stream" || declaredMime === "binary/octet-stream"
554 ? null
555 : declaredMime;
556 const dimensions = sourceDimensions(buffer);
557 if (
558 (normalizedMime !== null && !/^image\/[a-z0-9.+-]+$/i.test(normalizedMime)) ||
559 normalizedMime === "image/svg+xml" ||
560 buffer.byteLength > MAX_FAVICON_RESPONSE_BYTES ||
561 !safeDimensions(dimensions)
562 ) {
563 return { kind: "none" };
564 }
565
566 const rasterized = await rasterizeFavicon(
567 webContents,
568 normalizedMime,
569 buffer,
570 dimensions,
571 signal,
572 );
573 if (rasterized.kind === "timed-out") return rasterized;
574 return typeof rasterized.value === "string" &&
575 rasterized.value.startsWith("data:image/png;base64,") &&
576 rasterized.value.length <= FAVICON_DATA_URL_MAX_LENGTH
577 ? { kind: "captured", dataUrl: rasterized.value }
578 : { kind: "none" };
579}
580
581async function rasterizeFavicon(
582 webContents: Electron.WebContents,
583 mime: string | null,
584 buffer: Buffer,
585 dimensions: ImageDimensions,
586 signal: AbortSignal,
587): Promise<RasterizationResult> {
588 const gate = rasterizationGates.get(webContents) ?? { generation: 0 };
589 rasterizationGates.set(webContents, gate);
590 const generation = ++gate.generation;
591 const previousLaunchAllowed = gate.launchAllowed;
592 if (previousLaunchAllowed) {
593 await waitForRasterLaunch(previousLaunchAllowed, signal);
594 }
595 if (signal.aborted || generation !== gate.generation) {
596 return { kind: "completed", value: null };
597 }
598
599 const payload = buffer.toString("base64");
600 const blobType = mime ?? "";
601 const scale = Math.min(32 / dimensions.width, 32 / dimensions.height);
602 const decodeWidth = Math.max(1, Math.round(dimensions.width * scale));
603 const decodeHeight = Math.max(1, Math.round(dimensions.height * scale));
604 const drawX = (32 - decodeWidth) / 2;
605 const drawY = (32 - decodeHeight) / 2;
606 const code = `
607 (() => {
608 const rasterize = async () => {
609 try {
610 const source = Uint8Array.from(atob("${payload}"), (char) => char.charCodeAt(0));
611 const bitmap = await createImageBitmap(new Blob([source], { type: "${blobType}" }), {
612 resizeWidth: ${decodeWidth},
613 resizeHeight: ${decodeHeight},
614 resizeQuality: "high",
615 });
616 try {
617 if (bitmap.width <= 0 || bitmap.height <= 0 || bitmap.width * bitmap.height > ${MAX_FAVICON_SOURCE_PIXELS}) {
618 return null;
619 }
620 const canvas = new OffscreenCanvas(32, 32);
621 const context = canvas.getContext("2d");
622 if (!context) return null;
623 context.drawImage(bitmap, ${drawX}, ${drawY}, ${decodeWidth}, ${decodeHeight});
624 const blob = await canvas.convertToBlob({ type: "image/png" });
625 const output = new Uint8Array(await blob.arrayBuffer());
626 let binary = "";
627 for (const byte of output) binary += String.fromCharCode(byte);
628 return "data:image/png;base64," + btoa(binary);
629 } finally {
630 bitmap.close();
631 }
632 } catch {
633 return null;
634 }
635 };
636 return rasterize();
637 })()
638 `;
639
640 const execution = webContents.executeJavaScriptInIsolatedWorld(FAVICON_RASTER_WORLD_ID, [
641 { code },
642 ]);
643
644 const result = new Promise<RasterizationResult>((resolve, reject) => {
645 // Electron cannot cancel isolated-world execution. This timeout ends only
646 // the logical attempt; renderer work may finish after a newer attempt starts.
647 const timeout = AbortSignal.timeout(FAVICON_RASTER_TIMEOUT_MS);
648 let settled = false;
649 const finish = (complete: () => void) => {
650 if (settled) return;
651 settled = true;
652 timeout.removeEventListener("abort", onTimeout);
653 signal.removeEventListener("abort", onAbort);
654 complete();
655 };
656 const onTimeout = () => {
657 finish(() => resolve({ kind: "timed-out" }));
658 };
659 const onAbort = () => {
660 finish(() => resolve({ kind: "completed", value: null }));
661 };
662 timeout.addEventListener("abort", onTimeout, { once: true });
663 signal.addEventListener("abort", onAbort, { once: true });
664 void execution.then(
665 (value) => {
666 finish(() => resolve({ kind: "completed", value }));
667 },
668 (cause: unknown) => {
669 finish(() => reject(cause));
670 },
671 );
672 if (signal.aborted) onAbort();
673 });
674 // The logical timeout does not cancel Electron's renderer work. Keep the
675 // gate closed until that physical execution actually settles.
676 const launchAllowed = execution.then(
677 () => undefined,
678 () => undefined,
679 );
680 gate.launchAllowed = launchAllowed;
681 void launchAllowed.then(() => {
682 if (gate.launchAllowed === launchAllowed) delete gate.launchAllowed;
683 });
684 return await result;
685}