authorgravatar for chris@xenforo.comChris Deeming <chris@xenforo.com> 2026-08-14 03:02:09+01:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-13 22:02:09-04:00
log710fd0eeb3b4c4146c28fa0fc558ac4c236a8067
tree709b56f498bb0e319881fe74114cee5951548efa
parente15f655ba423e4b0e50a5692cc06eb9421bff7df
signature Signed by PGP key B5690EEEBB952194

feat(desktop): add favicons to the Browser panel (#5644)


28 files changed, 4189 insertions(+), 71 deletions(-)

apps/desktop/src/preview/FaviconCapture.test.ts created+999
...@@ -0,0 +1,999 @@
1import { describe, expect, it, vi } from "vite-plus/test";
2
3import {
4 MAX_FAVICON_CANDIDATES,
5 MAX_FAVICON_RESPONSE_BYTES,
6 captureFavicon,
7 selectFaviconCandidates,
8} from "./FaviconCapture.ts";
9
10const PNG = "data:image/png;base64,cG5n";
11const SOURCE_PNG = Buffer.alloc(24);
12Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]).copy(SOURCE_PNG);
13SOURCE_PNG.writeUInt32BE(1, 16);
14SOURCE_PNG.writeUInt32BE(1, 20);
15const SOURCE_PNG_URL = `data:image/png;base64,${SOURCE_PNG.toString("base64")}`;
16
17function sourceGif(
18 width: number,
19 height: number,
20 frameWidth = width,
21 frameHeight = height,
22 additionalFrames: ReadonlyArray<{
23 readonly left?: number;
24 readonly top?: number;
25 readonly width: number;
26 readonly height: number;
27 }> = [],
28): Buffer {
29 const frames = [{ width: frameWidth, height: frameHeight }, ...additionalFrames];
30 const buffer = Buffer.alloc(13 + frames.length * 12 + 1);
31 buffer.write("GIF89a", 0, "ascii");
32 buffer.writeUInt16LE(width, 6);
33 buffer.writeUInt16LE(height, 8);
34 let offset = 13;
35 for (const frame of frames) {
36 buffer[offset] = 0x2c;
37 buffer.writeUInt16LE(frame.left ?? 0, offset + 1);
38 buffer.writeUInt16LE(frame.top ?? 0, offset + 3);
39 buffer.writeUInt16LE(frame.width, offset + 5);
40 buffer.writeUInt16LE(frame.height, offset + 7);
41 offset += 10;
42 buffer[offset] = 2;
43 buffer[offset + 1] = 0;
44 offset += 2;
45 }
46 buffer[offset] = 0x3b;
47 return buffer;
48}
49
50function sourceJpeg(
51 width: number,
52 height: number,
53 orientations: number | ReadonlyArray<number> = [],
54): Buffer {
55 const frame = Buffer.from([
56 0xff,
57 0xd8,
58 0xff,
59 0xc0,
60 0x00,
61 0x07,
62 0x08,
63 height >>> 8,
64 height & 0xff,
65 width >>> 8,
66 width & 0xff,
67 ]);
68 const app1Segments = (typeof orientations === "number" ? [orientations] : orientations).map(
69 (orientation) => sourceJpegExifSegment([orientation]),
70 );
71 return Buffer.concat([frame.subarray(0, 2), ...app1Segments, frame.subarray(2)]);
72}
73
74function sourceJpegApp1Segment(payload: Buffer): Buffer {
75 const app1 = Buffer.alloc(4 + payload.byteLength);
76 app1[0] = 0xff;
77 app1[1] = 0xe1;
78 app1.writeUInt16BE(payload.byteLength + 2, 2);
79 payload.copy(app1, 4);
80 return app1;
81}
82
83function sourceJpegExifSegment(
84 orientations: ReadonlyArray<number>,
85 options?: {
86 readonly byteOrder?: "II" | "MM";
87 readonly magic?: number;
88 readonly padding?: number;
89 },
90): Buffer {
91 const exif = Buffer.alloc(20 + orientations.length * 12);
92 exif.write("Exif\0\0", 0, "binary");
93 exif[5] = options?.padding ?? 0;
94 const littleEndian = options?.byteOrder !== "MM";
95 exif.write(littleEndian ? "II" : "MM", 6, "ascii");
96 const writeUInt16 = (value: number, offset: number) =>
97 littleEndian ? exif.writeUInt16LE(value, offset) : exif.writeUInt16BE(value, offset);
98 const writeUInt32 = (value: number, offset: number) =>
99 littleEndian ? exif.writeUInt32LE(value, offset) : exif.writeUInt32BE(value, offset);
100 writeUInt16(options?.magic ?? 42, 8);
101 writeUInt32(8, 10);
102 writeUInt16(orientations.length, 14);
103 orientations.forEach((orientation, index) => {
104 const entryOffset = 16 + index * 12;
105 writeUInt16(0x0112, entryOffset);
106 writeUInt16(3, entryOffset + 2);
107 writeUInt32(1, entryOffset + 4);
108 writeUInt16(orientation, entryOffset + 8);
109 });
110 return sourceJpegApp1Segment(exif);
111}
112
113function sourceJpegWithApp1Segments(
114 width: number,
115 height: number,
116 segments: ReadonlyArray<Buffer>,
117): Buffer {
118 const frame = sourceJpeg(width, height);
119 return Buffer.concat([frame.subarray(0, 2), ...segments, frame.subarray(2)]);
120}
121
122function sourceJpegWithOrientationEntries(
123 width: number,
124 height: number,
125 orientations: ReadonlyArray<number>,
126): Buffer {
127 return sourceJpegWithApp1Segments(width, height, [sourceJpegExifSegment(orientations)]);
128}
129
130function sourceJpegWithEndianAlias(alias: number, byteOrder: "II" | "MM"): Buffer {
131 const exif = sourceJpegExifSegment([6], { byteOrder });
132 exif[10] = alias;
133 exif[11] = alias;
134 return sourceJpegWithApp1Segments(64, 32, [exif]);
135}
136
137function sourceJpegExifWithSubIfd(options: {
138 readonly rootOrientation?: number;
139 readonly subIfdFirst?: boolean;
140 readonly subIfdOrientation: number;
141}): Buffer {
142 const rootEntries = options.rootOrientation === undefined ? 1 : 2;
143 const rootIfdOffset = 14;
144 const subIfdOffset = rootIfdOffset + 2 + rootEntries * 12 + 4;
145 const exif = Buffer.alloc(subIfdOffset + 2 + 12 + 4);
146 exif.write("Exif\0\0", 0, "binary");
147 exif.write("II", 6, "ascii");
148 exif.writeUInt16LE(42, 8);
149 exif.writeUInt32LE(8, 10);
150 exif.writeUInt16LE(rootEntries, rootIfdOffset);
151
152 const writeOrientation = (offset: number, orientation: number) => {
153 exif.writeUInt16LE(0x0112, offset);
154 exif.writeUInt16LE(3, offset + 2);
155 exif.writeUInt32LE(1, offset + 4);
156 exif.writeUInt16LE(orientation, offset + 8);
157 };
158 const writeSubIfdPointer = (offset: number) => {
159 exif.writeUInt16LE(0x8769, offset);
160 exif.writeUInt16LE(4, offset + 2);
161 exif.writeUInt32LE(1, offset + 4);
162 exif.writeUInt32LE(subIfdOffset - 6, offset + 8);
163 };
164
165 const firstRootEntryOffset = rootIfdOffset + 2;
166 if (options.rootOrientation === undefined) {
167 writeSubIfdPointer(firstRootEntryOffset);
168 } else if (options.subIfdFirst) {
169 writeSubIfdPointer(firstRootEntryOffset);
170 writeOrientation(firstRootEntryOffset + 12, options.rootOrientation);
171 } else {
172 writeOrientation(firstRootEntryOffset, options.rootOrientation);
173 writeSubIfdPointer(firstRootEntryOffset + 12);
174 }
175
176 exif.writeUInt16LE(1, subIfdOffset);
177 writeOrientation(subIfdOffset + 2, options.subIfdOrientation);
178 return sourceJpegApp1Segment(exif);
179}
180
181function sourceJpegExifWithSubIfdPointers(options: {
182 readonly pointerCount: number;
183 readonly subIfdEntries: number;
184}): Buffer {
185 const { pointerCount, subIfdEntries } = options;
186 const rootIfdOffset = 14;
187 const rootEntries = pointerCount + 1;
188 const subIfdOffset = rootIfdOffset + 2 + rootEntries * 12 + 4;
189 const exif = Buffer.alloc(subIfdOffset + 2 + subIfdEntries * 12 + 4);
190 exif.write("Exif\0\0", 0, "binary");
191 exif.write("II", 6, "ascii");
192 exif.writeUInt16LE(42, 8);
193 exif.writeUInt32LE(8, 10);
194 exif.writeUInt16LE(rootEntries, rootIfdOffset);
195 for (let index = 0; index < pointerCount; index += 1) {
196 const entryOffset = rootIfdOffset + 2 + index * 12;
197 exif.writeUInt16LE(0x8769, entryOffset);
198 exif.writeUInt16LE(4, entryOffset + 2);
199 exif.writeUInt32LE(1, entryOffset + 4);
200 exif.writeUInt32LE(subIfdOffset - 6, entryOffset + 8);
201 }
202 const orientationOffset = rootIfdOffset + 2 + pointerCount * 12;
203 exif.writeUInt16LE(0x0112, orientationOffset);
204 exif.writeUInt16LE(3, orientationOffset + 2);
205 exif.writeUInt32LE(1, orientationOffset + 4);
206 exif.writeUInt16LE(6, orientationOffset + 8);
207
208 exif.writeUInt16LE(subIfdEntries, subIfdOffset);
209 for (let index = 0; index < subIfdEntries; index += 1) {
210 const entryOffset = subIfdOffset + 2 + index * 12;
211 exif.writeUInt16LE(1, entryOffset);
212 exif.writeUInt16LE(3, entryOffset + 2);
213 exif.writeUInt32LE(1, entryOffset + 4);
214 }
215 return sourceJpegApp1Segment(exif);
216}
217
218function sourceJpegExifWithOverlappingSubIfds(pointerCount: number, subIfdEntries: number): Buffer {
219 const rootIfdOffset = 14;
220 const rootEntries = pointerCount + 1;
221 const subIfdOffset = rootIfdOffset + 2 + rootEntries * 12 + 4;
222 const exif = Buffer.alloc(subIfdOffset + pointerCount * 2 + 2 + subIfdEntries * 12);
223 exif.write("Exif\0\0", 0, "binary");
224 exif.write("II", 6, "ascii");
225 exif.writeUInt32LE(8, 10);
226 exif.writeUInt16LE(rootEntries, rootIfdOffset);
227 for (let index = 0; index < pointerCount; index += 1) {
228 const entryOffset = rootIfdOffset + 2 + index * 12;
229 exif.writeUInt16LE(0x8769, entryOffset);
230 exif.writeUInt16LE(4, entryOffset + 2);
231 exif.writeUInt32LE(1, entryOffset + 4);
232 exif.writeUInt32LE(subIfdOffset + index * 2 - 6, entryOffset + 8);
233 exif.writeUInt16LE(subIfdEntries, subIfdOffset + index * 2);
234 }
235 const orientationOffset = rootIfdOffset + 2 + pointerCount * 12;
236 exif.writeUInt16LE(0x0112, orientationOffset);
237 exif.writeUInt16LE(3, orientationOffset + 2);
238 exif.writeUInt32LE(1, orientationOffset + 4);
239 exif.writeUInt16LE(6, orientationOffset + 8);
240 return sourceJpegApp1Segment(exif);
241}
242
243function sourceWebp(width: number, height: number): Buffer {
244 const buffer = Buffer.alloc(30);
245 buffer.write("RIFF", 0, "ascii");
246 buffer.write("WEBP", 8, "ascii");
247 buffer.write("VP8X", 12, "ascii");
248 buffer.writeUIntLE(width - 1, 24, 3);
249 buffer.writeUIntLE(height - 1, 27, 3);
250 return buffer;
251}
252
253function sourceIco(embedded: Buffer): Buffer {
254 const buffer = Buffer.alloc(22 + embedded.byteLength);
255 buffer.writeUInt16LE(1, 2);
256 buffer.writeUInt16LE(1, 4);
257 buffer.writeUInt32LE(embedded.byteLength, 14);
258 buffer.writeUInt32LE(22, 18);
259 embedded.copy(buffer, 22);
260 return buffer;
261}
262
263function makeUnsafePng(): Buffer {
264 const buffer = Buffer.from(SOURCE_PNG);
265 buffer.writeUInt32BE(4096, 16);
266 buffer.writeUInt32BE(4096, 20);
267 return buffer;
268}
269
270function sourcePng(width: number, height: number): Buffer {
271 const buffer = Buffer.from(SOURCE_PNG);
272 buffer.writeUInt32BE(width, 16);
273 buffer.writeUInt32BE(height, 20);
274 return buffer;
275}
276
277function makeUnsafeDib(): Buffer {
278 const buffer = Buffer.alloc(40);
279 buffer.writeUInt32LE(40, 0);
280 buffer.writeInt32LE(4096, 4);
281 buffer.writeInt32LE(4096, 8);
282 return buffer;
283}
284
285function makeWebContents(options?: {
286 readonly fetch?: (url: string, init?: RequestInit) => Promise<Response>;
287 readonly rasterize?: (code: string) => Promise<unknown>;
288}) {
289 const fetch = vi.fn(
290 options?.fetch ??
291 (async () =>
292 new Response(new Uint8Array(SOURCE_PNG), {
293 headers: { "content-type": "image/png" },
294 })),
295 );
296 const executeJavaScriptInIsolatedWorld = vi.fn(
297 async (_worldId: number, scripts: ReadonlyArray<{ readonly code: string }>) =>
298 options?.rasterize ? options.rasterize(scripts[0]?.code ?? "") : PNG,
299 );
300 return {
301 webContents: {
302 session: { fetch },
303 executeJavaScriptInIsolatedWorld,
304 } as never,
305 executeJavaScriptInIsolatedWorld,
306 fetch,
307 };
308}
309
310const JPEG_LANDSCAPE_LAYOUT = {
311 draw: "context.drawImage(bitmap, 0, 8, 32, 16)",
312 resizeHeight: 16,
313 resizeWidth: 32,
314} as const;
315const JPEG_PORTRAIT_LAYOUT = {
316 draw: "context.drawImage(bitmap, 8, 0, 16, 32)",
317 resizeHeight: 32,
318 resizeWidth: 16,
319} as const;
320
321async function expectJpegLayout(
322 source: Buffer,
323 layout: typeof JPEG_LANDSCAPE_LAYOUT | typeof JPEG_PORTRAIT_LAYOUT,
324): Promise<void> {
325 const { webContents } = makeWebContents({
326 rasterize: async (code) => {
327 expect(code).toContain(`resizeWidth: ${layout.resizeWidth}`);
328 expect(code).toContain(`resizeHeight: ${layout.resizeHeight}`);
329 expect(code).toContain(layout.draw);
330 return PNG;
331 },
332 });
333
334 expect(
335 await captureFavicon({
336 webContents,
337 pageUrl: "https://example.com/page",
338 candidates: [`data:image/jpeg;base64,${source.toString("base64")}`],
339 signal: new AbortController().signal,
340 }),
341 ).toEqual({ kind: "captured", dataUrl: PNG });
342}
343
344describe("selectFaviconCandidates", () => {
345 it("filters and deduplicates before applying the candidate cap", () => {
346 const valid = Array.from(
347 { length: MAX_FAVICON_CANDIDATES + 2 },
348 (_, index) => `https://example.com/favicon-${index}.png`,
349 );
350 expect(
351 selectFaviconCandidates([
352 ...Array.from({ length: 64 }, () => "javascript:alert(1)"),
353 valid[0]!,
354 valid[0]!,
355 ...valid.slice(1),
356 ]),
357 ).toEqual(valid.slice(0, MAX_FAVICON_CANDIDATES));
358 });
359
360 it("bounds raw candidate scanning independently of the usable-candidate cap", () => {
361 const oversizedInvalid = `javascript:${"x".repeat(2_048)}`;
362 expect(
363 selectFaviconCandidates([
364 ...Array.from({ length: 128 }, () => oversizedInvalid),
365 "https://example.com/too-late.png",
366 ]),
367 ).toEqual([]);
368 });
369});
370
371describe("captureFavicon", () => {
372 it.each([
373 {
374 label: "same-origin",
375 pageUrl: "https://example.com/page",
376 faviconUrl: "https://example.com/favicon.png",
377 credentials: "include",
378 },
379 {
380 label: "cross-origin",
381 pageUrl: "https://example.com/page",
382 faviconUrl: "https://cdn.example.net/favicon.png",
383 credentials: "omit",
384 },
385 ])("uses the explicit credential policy for $label requests", async (testCase) => {
386 const { webContents, fetch } = makeWebContents();
387 const result = await captureFavicon({
388 webContents,
389 pageUrl: testCase.pageUrl,
390 candidates: [testCase.faviconUrl],
391 signal: new AbortController().signal,
392 });
393
394 expect(result).toEqual({ kind: "captured", dataUrl: PNG });
395 expect(fetch).toHaveBeenCalledWith(
396 testCase.faviconUrl,
397 expect.objectContaining({ credentials: testCase.credentials, redirect: "error" }),
398 );
399 });
400
401 it("decodes base64 and percent-encoded inline images without fetching", async () => {
402 const { webContents, fetch, executeJavaScriptInIsolatedWorld } = makeWebContents();
403 const percentEncodedPng = [...SOURCE_PNG]
404 .map((byte) => `%${byte.toString(16).padStart(2, "0")}`)
405 .join("");
406
407 for (const candidate of [SOURCE_PNG_URL, `data:image/png,${percentEncodedPng}`]) {
408 expect(
409 await captureFavicon({
410 webContents,
411 pageUrl: "https://example.com/page",
412 candidates: [candidate],
413 signal: new AbortController().signal,
414 }),
415 ).toEqual({ kind: "captured", dataUrl: PNG });
416 }
417
418 expect(fetch).not.toHaveBeenCalled();
419 expect(executeJavaScriptInIsolatedWorld).toHaveBeenCalledTimes(2);
420 });
421
422 it("tries the next candidate after an ordinary rejection", async () => {
423 const { webContents, fetch } = makeWebContents({
424 fetch: async (url) =>
425 url.endsWith("first.png")
426 ? new Response(null, { status: 404 })
427 : new Response(new Uint8Array(SOURCE_PNG), {
428 headers: { "content-type": "image/png" },
429 }),
430 });
431
432 expect(
433 await captureFavicon({
434 webContents,
435 pageUrl: "https://example.com/page",
436 candidates: ["https://example.com/first.png", "https://example.com/second.png"],
437 signal: new AbortController().signal,
438 }),
439 ).toEqual({ kind: "captured", dataUrl: PNG });
440 expect(fetch).toHaveBeenCalledTimes(2);
441 });
442
443 it("cancels a rejected response body before trying the next candidate", async () => {
444 const cancel = vi.fn();
445 const body = new ReadableStream<Uint8Array>({
446 start(controller) {
447 controller.enqueue(new Uint8Array(1));
448 },
449 cancel,
450 });
451 const { webContents, fetch } = makeWebContents({
452 fetch: async (url) =>
453 url.endsWith("first.png")
454 ? new Response(body, { status: 404 })
455 : new Response(new Uint8Array(SOURCE_PNG), {
456 headers: { "content-type": "image/png" },
457 }),
458 });
459
460 expect(
461 await captureFavicon({
462 webContents,
463 pageUrl: "https://example.com/page",
464 candidates: ["https://example.com/first.png", "https://example.com/second.png"],
465 signal: new AbortController().signal,
466 }),
467 ).toEqual({ kind: "captured", dataUrl: PNG });
468 expect(cancel).toHaveBeenCalledOnce();
469 expect(fetch).toHaveBeenCalledTimes(2);
470 });
471
472 it("stops a pending fetch when its capture is aborted", async () => {
473 const controller = new AbortController();
474 const { webContents } = makeWebContents({
475 fetch: (_url, init) =>
476 new Promise<Response>((_resolve, reject) => {
477 init?.signal?.addEventListener("abort", () => reject(init.signal?.reason), {
478 once: true,
479 });
480 }),
481 });
482 const capture = captureFavicon({
483 webContents,
484 pageUrl: "https://example.com/page",
485 candidates: ["https://example.com/favicon.png"],
486 signal: controller.signal,
487 });
488 controller.abort();
489 expect(await capture).toEqual({ kind: "none" });
490 });
491
492 it("ends candidate fallback when the overall capture deadline expires", async () => {
493 const timeoutController = new AbortController();
494 const timeout = vi.spyOn(AbortSignal, "timeout").mockReturnValue(timeoutController.signal);
495 const { webContents, fetch } = makeWebContents({
496 fetch: (url, init) => {
497 if (url.endsWith("first.png")) return Promise.resolve(new Response(null, { status: 404 }));
498 return new Promise<Response>((_resolve, reject) => {
499 init?.signal?.addEventListener("abort", () => reject(init.signal?.reason), {
500 once: true,
501 });
502 });
503 },
504 });
505 try {
506 const capture = captureFavicon({
507 webContents,
508 pageUrl: "https://example.com/page",
509 candidates: [
510 "https://example.com/first.png",
511 "https://example.com/second.png",
512 "https://example.com/third.png",
513 ],
514 signal: new AbortController().signal,
515 });
516 await vi.waitFor(() => expect(fetch).toHaveBeenCalledTimes(2));
517 timeoutController.abort(new DOMException("capture timed out", "TimeoutError"));
518
519 expect(await capture).toEqual({ kind: "timed-out" });
520 expect(fetch).toHaveBeenCalledTimes(2);
521 expect(timeout).toHaveBeenCalledOnce();
522 } finally {
523 timeout.mockRestore();
524 }
525 });
526
527 it("does not publish a rasterization that completes after the capture deadline", async () => {
528 const captureTimeoutController = new AbortController();
529 const rasterTimeoutController = new AbortController();
530 const timeout = vi
531 .spyOn(AbortSignal, "timeout")
532 .mockImplementation((milliseconds) =>
533 milliseconds === 5_000 ? captureTimeoutController.signal : rasterTimeoutController.signal,
534 );
535 let resolveRasterization!: (value: unknown) => void;
536 const { webContents, executeJavaScriptInIsolatedWorld } = makeWebContents({
537 rasterize: () =>
538 new Promise((resolve) => {
539 resolveRasterization = resolve;
540 }),
541 });
542 try {
543 const capture = captureFavicon({
544 webContents,
545 pageUrl: "https://example.com/page",
546 candidates: [SOURCE_PNG_URL],
547 signal: new AbortController().signal,
548 });
549 await vi.waitFor(() => expect(executeJavaScriptInIsolatedWorld).toHaveBeenCalledOnce());
550 captureTimeoutController.abort(new DOMException("capture timed out", "TimeoutError"));
551
552 expect(await capture).toEqual({ kind: "timed-out" });
553 resolveRasterization(PNG);
554 } finally {
555 timeout.mockRestore();
556 }
557 });
558
559 it("cancels a stalled response body when the capture deadline expires", async () => {
560 const timeoutController = new AbortController();
561 const timeout = vi.spyOn(AbortSignal, "timeout").mockReturnValue(timeoutController.signal);
562 const cancel = vi.fn();
563 const { webContents } = makeWebContents({
564 fetch: async () =>
565 new Response(
566 new ReadableStream<Uint8Array>({
567 cancel,
568 }),
569 { headers: { "content-type": "image/png" } },
570 ),
571 });
572 try {
573 const capture = captureFavicon({
574 webContents,
575 pageUrl: "https://example.com/page",
576 candidates: ["https://example.com/favicon.png"],
577 signal: new AbortController().signal,
578 });
579 timeoutController.abort(new DOMException("capture timed out", "TimeoutError"));
580
581 expect(await capture).toEqual({ kind: "timed-out" });
582 expect(cancel).toHaveBeenCalledOnce();
583 } finally {
584 timeout.mockRestore();
585 }
586 });
587
588 it("rejects and cancels an oversized streamed response", async () => {
589 const cancel = vi.fn();
590 const body = new ReadableStream<Uint8Array>({
591 start(controller) {
592 controller.enqueue(new Uint8Array(MAX_FAVICON_RESPONSE_BYTES));
593 controller.enqueue(new Uint8Array(1));
594 },
595 cancel,
596 });
597 const { webContents, executeJavaScriptInIsolatedWorld } = makeWebContents({
598 fetch: async () => new Response(body, { headers: { "content-type": "image/png" } }),
599 });
600
601 expect(
602 await captureFavicon({
603 webContents,
604 pageUrl: "https://example.com/page",
605 candidates: ["https://example.com/favicon.png"],
606 signal: new AbortController().signal,
607 }),
608 ).toEqual({ kind: "none" });
609 expect(cancel).toHaveBeenCalledOnce();
610 expect(executeJavaScriptInIsolatedWorld).not.toHaveBeenCalled();
611 });
612
613 it("retains bounded compatibility with common favicon formats", async () => {
614 const { webContents, executeJavaScriptInIsolatedWorld } = makeWebContents();
615 for (const [mime, buffer] of [
616 ["image/gif", sourceGif(32, 32)],
617 ["image/jpeg", sourceJpeg(32, 32)],
618 ["image/webp", sourceWebp(32, 32)],
619 ["image/x-icon", sourceIco(SOURCE_PNG)],
620 ] as const) {
621 expect(
622 await captureFavicon({
623 webContents,
624 pageUrl: "https://example.com/page",
625 candidates: [`data:${mime};base64,${buffer.toString("base64")}`],
626 signal: new AbortController().signal,
627 }),
628 ).toEqual({ kind: "captured", dataUrl: PNG });
629 }
630 expect(executeJavaScriptInIsolatedWorld).toHaveBeenCalledTimes(4);
631 });
632
633 it.each([
634 {
635 label: "landscape",
636 source: sourcePng(64, 32),
637 resizeWidth: 32,
638 resizeHeight: 16,
639 draw: "context.drawImage(bitmap, 0, 8, 32, 16)",
640 },
641 {
642 label: "portrait",
643 source: sourcePng(32, 64),
644 resizeWidth: 16,
645 resizeHeight: 32,
646 draw: "context.drawImage(bitmap, 8, 0, 16, 32)",
647 },
648 ])("preserves $label aspect ratio within the 32x32 output", async (testCase) => {
649 const { webContents } = makeWebContents({
650 rasterize: async (code) => {
651 expect(code).toContain(`resizeWidth: ${testCase.resizeWidth}`);
652 expect(code).toContain(`resizeHeight: ${testCase.resizeHeight}`);
653 expect(code).toContain('resizeQuality: "high"');
654 expect(code).toContain(testCase.draw);
655 return PNG;
656 },
657 });
658
659 expect(
660 await captureFavicon({
661 webContents,
662 pageUrl: "https://example.com/page",
663 candidates: [`data:image/png;base64,${testCase.source.toString("base64")}`],
664 signal: new AbortController().signal,
665 }),
666 ).toEqual({ kind: "captured", dataUrl: PNG });
667 });
668
669 it.each([
670 ...[1, 2, 3, 4].map((orientation) => ({
671 label: `keeps stored dimensions for orientation ${orientation}`,
672 layout: JPEG_LANDSCAPE_LAYOUT,
673 source: sourceJpeg(64, 32, orientation),
674 })),
675 ...[5, 6, 7, 8].map((orientation) => ({
676 label: `uses display dimensions for orientation ${orientation}`,
677 layout: JPEG_PORTRAIT_LAYOUT,
678 source: sourceJpeg(64, 32, orientation),
679 })),
680 {
681 label: "uses the first separate EXIF segment when it is transposed",
682 layout: JPEG_PORTRAIT_LAYOUT,
683 source: sourceJpeg(64, 32, [6, 1]),
684 },
685 {
686 label: "uses the first separate EXIF segment when it is untransposed",
687 layout: JPEG_LANDSCAPE_LAYOUT,
688 source: sourceJpeg(64, 32, [1, 6]),
689 },
690 {
691 label: "does not consult a later EXIF segment after an invalid orientation",
692 layout: JPEG_LANDSCAPE_LAYOUT,
693 source: sourceJpeg(64, 32, [9, 6]),
694 },
695 {
696 label: "uses a later valid orientation in the same IFD",
697 layout: JPEG_PORTRAIT_LAYOUT,
698 source: sourceJpegWithOrientationEntries(64, 32, [9, 6]),
699 },
700 {
701 label: "skips a non-EXIF APP1 segment",
702 layout: JPEG_PORTRAIT_LAYOUT,
703 source: sourceJpegWithApp1Segments(64, 32, [
704 sourceJpegApp1Segment(Buffer.from("not-exif")),
705 sourceJpegExifSegment([6]),
706 ]),
707 },
708 {
709 label: "skips an empty EXIF APP1 segment",
710 layout: JPEG_PORTRAIT_LAYOUT,
711 source: sourceJpegWithApp1Segments(64, 32, [
712 sourceJpegApp1Segment(Buffer.from("Exif\0\0", "binary")),
713 sourceJpegExifSegment([6]),
714 ]),
715 },
716 {
717 label: "stops after a malformed qualifying EXIF APP1 segment",
718 layout: JPEG_LANDSCAPE_LAYOUT,
719 source: sourceJpegWithApp1Segments(64, 32, [
720 sourceJpegApp1Segment(Buffer.from("Exif\0\0broken", "binary")),
721 sourceJpegExifSegment([6]),
722 ]),
723 },
724 {
725 label: "ignores the EXIF padding byte",
726 layout: JPEG_PORTRAIT_LAYOUT,
727 source: sourceJpegWithApp1Segments(64, 32, [sourceJpegExifSegment([6], { padding: 0xff })]),
728 },
729 {
730 label: "reads big-endian EXIF",
731 layout: JPEG_PORTRAIT_LAYOUT,
732 source: sourceJpegWithApp1Segments(64, 32, [sourceJpegExifSegment([6], { byteOrder: "MM" })]),
733 },
734 {
735 label: "matches Chromium for a nonstandard TIFF magic field",
736 layout: JPEG_PORTRAIT_LAYOUT,
737 source: sourceJpegWithApp1Segments(64, 32, [sourceJpegExifSegment([6], { magic: 0 })]),
738 },
739 {
740 label: "rejects a high-bit little-endian alias",
741 layout: JPEG_LANDSCAPE_LAYOUT,
742 source: sourceJpegWithEndianAlias(0xc9, "II"),
743 },
744 {
745 label: "rejects a high-bit big-endian alias",
746 layout: JPEG_LANDSCAPE_LAYOUT,
747 source: sourceJpegWithEndianAlias(0xcd, "MM"),
748 },
749 {
750 label: "reads an orientation from a SubIFD",
751 layout: JPEG_PORTRAIT_LAYOUT,
752 source: sourceJpegWithApp1Segments(64, 32, [
753 sourceJpegExifWithSubIfd({ subIfdOrientation: 6 }),
754 ]),
755 },
756 {
757 label: "uses a SubIFD orientation before a later root orientation",
758 layout: JPEG_PORTRAIT_LAYOUT,
759 source: sourceJpegWithApp1Segments(64, 32, [
760 sourceJpegExifWithSubIfd({
761 rootOrientation: 1,
762 subIfdFirst: true,
763 subIfdOrientation: 6,
764 }),
765 ]),
766 },
767 {
768 label: "uses a root orientation before a later SubIFD orientation",
769 layout: JPEG_LANDSCAPE_LAYOUT,
770 source: sourceJpegWithApp1Segments(64, 32, [
771 sourceJpegExifWithSubIfd({ rootOrientation: 1, subIfdOrientation: 6 }),
772 ]),
773 },
774 {
775 label: "memoizes repeated aliases to the same SubIFD",
776 layout: JPEG_PORTRAIT_LAYOUT,
777 source: sourceJpegWithApp1Segments(64, 32, [
778 sourceJpegExifWithSubIfdPointers({ pointerCount: 32, subIfdEntries: 32 }),
779 ]),
780 },
781 ])("matches Chromium JPEG layout: $label", async ({ source, layout }) => {
782 await expectJpegLayout(source, layout);
783 });
784
785 it("rejects JPEG metadata when distinct SubIFDs exhaust the linear work budget", async () => {
786 const source = sourceJpegWithApp1Segments(64, 32, [
787 sourceJpegExifWithOverlappingSubIfds(32, 32),
788 ]);
789 const { webContents, executeJavaScriptInIsolatedWorld } = makeWebContents();
790
791 expect(
792 await captureFavicon({
793 webContents,
794 pageUrl: "https://example.com/page",
795 candidates: [`data:image/jpeg;base64,${source.toString("base64")}`],
796 signal: new AbortController().signal,
797 }),
798 ).toEqual({ kind: "none" });
799 expect(executeJavaScriptInIsolatedWorld).not.toHaveBeenCalled();
800 });
801
802 it("rejects JPEGs with multiple frame headers before rasterization", async () => {
803 const buffer = Buffer.concat([sourceJpeg(4096, 4096), sourceJpeg(1, 1).subarray(2)]);
804 const { webContents, executeJavaScriptInIsolatedWorld } = makeWebContents();
805
806 expect(
807 await captureFavicon({
808 webContents,
809 pageUrl: "https://example.com/page",
810 candidates: [`data:image/jpeg;base64,${buffer.toString("base64")}`],
811 signal: new AbortController().signal,
812 }),
813 ).toEqual({ kind: "none" });
814 expect(executeJavaScriptInIsolatedWorld).not.toHaveBeenCalled();
815 });
816
817 it("rejects an unsafe PNG size before rasterization", async () => {
818 const buffer = makeUnsafePng();
819 const { webContents, executeJavaScriptInIsolatedWorld } = makeWebContents({
820 fetch: async () =>
821 new Response(new Uint8Array(buffer), {
822 headers: { "content-type": "image/png" },
823 }),
824 });
825
826 expect(
827 await captureFavicon({
828 webContents,
829 pageUrl: "https://example.com/page",
830 candidates: ["https://example.com/favicon.png"],
831 signal: new AbortController().signal,
832 }),
833 ).toEqual({ kind: "none" });
834 expect(executeJavaScriptInIsolatedWorld).not.toHaveBeenCalled();
835 });
836
837 it.each([
838 ["GIF", "image/gif", sourceGif(4096, 4096)],
839 ["GIF frame", "image/gif", sourceGif(1, 1, 4096, 4096)],
840 ["GIF later frame", "image/gif", sourceGif(1, 1, 1, 1, [{ width: 4096, height: 4096 }])],
841 [
842 "GIF cumulative frames",
843 "image/gif",
844 sourceGif(
845 64,
846 64,
847 64,
848 64,
849 Array.from({ length: 256 }, () => ({ width: 64, height: 64 })),
850 ),
851 ],
852 ["JPEG", "image/jpeg", sourceJpeg(4096, 4096)],
853 ["WebP", "image/webp", sourceWebp(4096, 4096)],
854 ["ICO with PNG", "image/x-icon", sourceIco(makeUnsafePng())],
855 ["ICO with DIB", "image/x-icon", sourceIco(makeUnsafeDib())],
856 ["SVG", "image/svg+xml", Buffer.from('<svg width="1" height="1"/>')],
857 [
858 "SVG with embedded bitmap",
859 "image/svg+xml",
860 Buffer.from(
861 `<svg width="1" height="1"><image href="data:image/png;base64,${makeUnsafePng().toString("base64")}"/></svg>`,
862 ),
863 ],
864 [
865 "ICO invalid payload span",
866 "image/x-icon",
867 (() => {
868 const buffer = Buffer.alloc(22);
869 buffer.writeUInt16LE(1, 2);
870 buffer.writeUInt16LE(1, 4);
871 buffer.writeUInt32LE(100, 14);
872 buffer.writeUInt32LE(22, 18);
873 return buffer;
874 })(),
875 ],
876 ])("rejects unsafe or unsupported %s before rasterization", async (_label, mime, buffer) => {
877 const { webContents, executeJavaScriptInIsolatedWorld } = makeWebContents();
878 const candidate = `data:${mime};base64,${buffer.toString("base64")}`;
879 expect(
880 await captureFavicon({
881 webContents,
882 pageUrl: "https://example.com/page",
883 candidates: [candidate],
884 signal: new AbortController().signal,
885 }),
886 ).toEqual({ kind: "none" });
887 expect(executeJavaScriptInIsolatedWorld).not.toHaveBeenCalled();
888 });
889
890 it("ignores output that is not a bounded PNG data URL", async () => {
891 const { webContents } = makeWebContents({
892 rasterize: async () => "data:image/svg+xml;base64,c3Zn",
893 });
894
895 expect(
896 await captureFavicon({
897 webContents,
898 pageUrl: "https://example.com/page",
899 candidates: [SOURCE_PNG_URL],
900 signal: new AbortController().signal,
901 }),
902 ).toEqual({ kind: "none" });
903 });
904
905 it("waits for physical rasterization settlement after a logical timeout", async () => {
906 vi.useFakeTimers();
907 try {
908 let resolveOld!: (value: unknown) => void;
909 let executions = 0;
910 const { webContents } = makeWebContents({
911 rasterize: () => {
912 executions += 1;
913 return executions === 1
914 ? new Promise((resolve) => {
915 resolveOld = resolve;
916 })
917 : Promise.resolve(PNG);
918 },
919 });
920 const input = {
921 webContents,
922 pageUrl: "https://example.com/page",
923 candidates: [SOURCE_PNG_URL],
924 signal: new AbortController().signal,
925 };
926 const timedOut = captureFavicon(input);
927 await vi.advanceTimersByTimeAsync(1_001);
928 expect(await timedOut).toEqual({ kind: "timed-out" });
929
930 const newer = captureFavicon(input);
931 await Promise.resolve();
932 expect(executions).toBe(1);
933 resolveOld(PNG);
934 expect(await newer).toEqual({ kind: "captured", dataUrl: PNG });
935 expect(executions).toBe(2);
936 } finally {
937 vi.useRealTimers();
938 }
939 });
940
941 it("ends candidate fallback after a rasterization timeout", async () => {
942 vi.useFakeTimers();
943 try {
944 let resolveRasterization!: (value: unknown) => void;
945 const { webContents, fetch, executeJavaScriptInIsolatedWorld } = makeWebContents({
946 rasterize: () =>
947 new Promise((resolve) => {
948 resolveRasterization = resolve;
949 }),
950 });
951 const capture = captureFavicon({
952 webContents,
953 pageUrl: "https://example.com/page",
954 candidates: ["https://example.com/first.png", "https://example.com/second.png"],
955 signal: new AbortController().signal,
956 });
957
958 await vi.advanceTimersByTimeAsync(1_001);
959
960 expect(await capture).toEqual({ kind: "timed-out" });
961 expect(fetch).toHaveBeenCalledOnce();
962 expect(executeJavaScriptInIsolatedWorld).toHaveBeenCalledOnce();
963 resolveRasterization(PNG);
964 } finally {
965 vi.useRealTimers();
966 }
967 });
968
969 it("coalesces queued rasterizations so only the latest pending capture launches", async () => {
970 let resolveFirst!: (value: unknown) => void;
971 let executions = 0;
972 const { webContents } = makeWebContents({
973 rasterize: () => {
974 executions += 1;
975 return executions === 1
976 ? new Promise((resolve) => {
977 resolveFirst = resolve;
978 })
979 : Promise.resolve(PNG);
980 },
981 });
982 const input = {
983 webContents,
984 pageUrl: "https://example.com/page",
985 candidates: [SOURCE_PNG_URL],
986 signal: new AbortController().signal,
987 };
988 const first = captureFavicon(input);
989 const superseded = captureFavicon(input);
990 const newest = captureFavicon(input);
991
992 expect(executions).toBe(1);
993 resolveFirst(PNG);
994 expect(await first).toEqual({ kind: "captured", dataUrl: PNG });
995 expect(await superseded).toEqual({ kind: "none" });
996 expect(await newest).toEqual({ kind: "captured", dataUrl: PNG });
997 expect(executions).toBe(2);
998 });
999});
apps/desktop/src/preview/FaviconCapture.ts created+679
...@@ -0,0 +1,679 @@
1import { FAVICON_DATA_URL_MAX_LENGTH } from "@t3tools/contracts";
2
3export const MAX_FAVICON_RESPONSE_BYTES = 100_000;
4export const MAX_FAVICON_CANDIDATES = 8;
5export const 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(dimensions: ImageDimensions | null): dimensions is ImageDimensions {
250 return (
251 dimensions !== null &&
252 Number.isSafeInteger(dimensions.width) &&
253 Number.isSafeInteger(dimensions.height) &&
254 dimensions.width > 0 &&
255 dimensions.height > 0 &&
256 dimensions.width * dimensions.height <= MAX_FAVICON_SOURCE_PIXELS
257 );
258}
259
260function pngDimensions(buffer: Buffer): ImageDimensions | null {
261 if (!buffer.subarray(0, PNG_SIGNATURE.length).equals(PNG_SIGNATURE) || buffer.byteLength < 24) {
262 return null;
263 }
264 return { width: buffer.readUInt32BE(16), height: buffer.readUInt32BE(20) };
265}
266
267function skipGifSubBlocks(buffer: Buffer, startOffset: number): number | null {
268 let offset = startOffset;
269 while (offset < buffer.byteLength) {
270 const blockLength = buffer[offset]!;
271 offset += 1;
272 if (blockLength === 0) return offset;
273 if (offset + blockLength > buffer.byteLength) return null;
274 offset += blockLength;
275 }
276 return null;
277}
278
279function gifDimensions(buffer: Buffer): ImageDimensions | null {
280 if (buffer.byteLength < 13 || !/^GIF8[79]a$/u.test(buffer.subarray(0, 6).toString("ascii"))) {
281 return null;
282 }
283 const logicalWidth = buffer.readUInt16LE(6);
284 const logicalHeight = buffer.readUInt16LE(8);
285 if (!safeDimensions({ width: logicalWidth, height: logicalHeight })) return null;
286 const packed = buffer[10]!;
287 let offset = 13 + ((packed & 0x80) === 0 ? 0 : 3 * 2 ** ((packed & 0x07) + 1));
288 if (offset > buffer.byteLength) return null;
289 let width = logicalWidth;
290 let height = logicalHeight;
291 let frameCount = 0;
292 let framePixels = 0;
293 while (offset < buffer.byteLength) {
294 const marker = buffer[offset];
295 if (marker === 0x3b) return frameCount > 0 ? { width, height } : null;
296 if (marker === 0x2c) {
297 if (offset + 10 > buffer.byteLength) return null;
298 const left = buffer.readUInt16LE(offset + 1);
299 const top = buffer.readUInt16LE(offset + 3);
300 const frameWidth = buffer.readUInt16LE(offset + 5);
301 const frameHeight = buffer.readUInt16LE(offset + 7);
302 if (frameWidth === 0 || frameHeight === 0) return null;
303 framePixels += frameWidth * frameHeight;
304 if (framePixels > MAX_FAVICON_SOURCE_PIXELS) return null;
305 width = Math.max(width, left + frameWidth);
306 height = Math.max(height, top + frameHeight);
307 if (!safeDimensions({ width, height })) return null;
308 const framePacked = buffer[offset + 9]!;
309 offset += 10;
310 if ((framePacked & 0x80) !== 0) {
311 offset += 3 * 2 ** ((framePacked & 0x07) + 1);
312 }
313 if (offset >= buffer.byteLength) return null;
314 const minimumCodeSize = buffer[offset]!;
315 if (minimumCodeSize < 2 || minimumCodeSize > 8) return null;
316 offset += 1;
317 const nextOffset = skipGifSubBlocks(buffer, offset);
318 if (nextOffset === null) return null;
319 offset = nextOffset;
320 frameCount += 1;
321 continue;
322 }
323 if (marker !== 0x21 || offset + 2 > buffer.byteLength) return null;
324 const nextOffset = skipGifSubBlocks(buffer, offset + 2);
325 if (nextOffset === null) return null;
326 offset = nextOffset;
327 }
328 return null;
329}
330
331interface JpegExifMetadata {
332 readonly complete: boolean;
333 readonly orientation: number | null;
334}
335
336function jpegExifMetadata(segment: Buffer): JpegExifMetadata | null {
337 if (segment.byteLength <= 6 || segment.subarray(0, 5).toString("binary") !== "Exif\0") {
338 return null;
339 }
340 const metadataWithoutOrientation = (): JpegExifMetadata => ({
341 complete: true,
342 orientation: null,
343 });
344 if (segment.byteLength < 14) return metadataWithoutOrientation();
345 const tiffOffset = 6;
346 const littleEndian = segment[tiffOffset] === 0x49 && segment[tiffOffset + 1] === 0x49;
347 const bigEndian = segment[tiffOffset] === 0x4d && segment[tiffOffset + 1] === 0x4d;
348 if (!littleEndian && !bigEndian) return metadataWithoutOrientation();
349 const readUInt16 = (offset: number): number | null => {
350 if (offset < 0 || offset + 2 > segment.byteLength) return null;
351 return littleEndian ? segment.readUInt16LE(offset) : segment.readUInt16BE(offset);
352 };
353 const readUInt32 = (offset: number): number | null => {
354 if (offset < 0 || offset + 4 > segment.byteLength) return null;
355 return littleEndian ? segment.readUInt32LE(offset) : segment.readUInt32BE(offset);
356 };
357 const relativeIfdOffset = readUInt32(tiffOffset + 4);
358 if (relativeIfdOffset === null) return metadataWithoutOrientation();
359 // Keep untrusted metadata parsing linear even when IFD pointers overlap.
360 let remainingIfdEntryVisits = Math.ceil(segment.byteLength / 12);
361 const budgetExhausted = Symbol("ifd-entry-budget-exhausted");
362 type IfdOrientation = number | null | typeof budgetExhausted;
363 const subIfdOrientationByOffset = new Map<number, number | null>();
364 const readIfdOrientation = (ifdOffset: number, isRoot: boolean): IfdOrientation => {
365 if (!isRoot && subIfdOrientationByOffset.has(ifdOffset)) {
366 return subIfdOrientationByOffset.get(ifdOffset) ?? null;
367 }
368 const entryCount = readUInt16(ifdOffset);
369 if (entryCount === null) return null;
370 let result: IfdOrientation = null;
371 for (let index = 0; index < entryCount; index += 1) {
372 if (remainingIfdEntryVisits === 0) return budgetExhausted;
373 remainingIfdEntryVisits -= 1;
374 const entryOffset = ifdOffset + 2 + index * 12;
375 if (entryOffset + 12 > segment.byteLength) break;
376 const tag = readUInt16(entryOffset);
377 const type = readUInt16(entryOffset + 2);
378 const count = readUInt32(entryOffset + 4);
379 if (tag === 0x0112 && type === 3 && count === 1) {
380 const orientation = readUInt16(entryOffset + 8);
381 if (orientation !== null && orientation >= 1 && orientation <= 8) {
382 result = orientation;
383 break;
384 }
385 } else if (isRoot && tag === 0x8769 && type === 4 && count === 1) {
386 const relativeSubIfdOffset = readUInt32(entryOffset + 8);
387 if (relativeSubIfdOffset !== null) {
388 const orientation = readIfdOrientation(tiffOffset + relativeSubIfdOffset, false);
389 if (orientation === budgetExhausted) return budgetExhausted;
390 if (orientation !== null) {
391 result = orientation;
392 break;
393 }
394 }
395 }
396 }
397 if (!isRoot) subIfdOrientationByOffset.set(ifdOffset, result);
398 return result;
399 };
400 const orientation = readIfdOrientation(tiffOffset + relativeIfdOffset, true);
401 return orientation === budgetExhausted
402 ? { complete: false, orientation: null }
403 : { complete: true, orientation };
404}
405
406function jpegDimensions(buffer: Buffer): ImageDimensions | null {
407 if (buffer.byteLength < 4 || buffer[0] !== 0xff || buffer[1] !== 0xd8) return null;
408 const startOfFrameMarkers = new Set([
409 0xc0, 0xc1, 0xc2, 0xc3, 0xc5, 0xc6, 0xc7, 0xc9, 0xca, 0xcb, 0xcd, 0xce, 0xcf,
410 ]);
411 let offset = 2;
412 let dimensions: ImageDimensions | null = null;
413 let exifMetadata: JpegExifMetadata | null = null;
414 while (offset + 3 < buffer.byteLength) {
415 if (buffer[offset] !== 0xff) {
416 offset += 1;
417 continue;
418 }
419 while (buffer[offset] === 0xff) offset += 1;
420 const marker = buffer[offset];
421 offset += 1;
422 if (marker === undefined || marker === 0xd9 || marker === 0xda) break;
423 if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd8)) continue;
424 if (offset + 1 >= buffer.byteLength) return null;
425 const length = buffer.readUInt16BE(offset);
426 if (length < 2 || offset + length > buffer.byteLength) return null;
427 if (marker === 0xe1 && exifMetadata === null) {
428 exifMetadata = jpegExifMetadata(buffer.subarray(offset + 2, offset + length));
429 }
430 if (startOfFrameMarkers.has(marker)) {
431 if (length < 7) return null;
432 if (dimensions !== null) return null;
433 dimensions = {
434 height: buffer.readUInt16BE(offset + 3),
435 width: buffer.readUInt16BE(offset + 5),
436 };
437 }
438 offset += length;
439 }
440 if (!dimensions) return null;
441 if (exifMetadata?.complete === false) return null;
442 const orientation = exifMetadata?.orientation;
443 return orientation !== undefined && orientation !== null && orientation >= 5 && orientation <= 8
444 ? { width: dimensions.height, height: dimensions.width }
445 : dimensions;
446}
447
448function webpDimensions(buffer: Buffer): ImageDimensions | null {
449 if (
450 buffer.byteLength < 30 ||
451 buffer.subarray(0, 4).toString("ascii") !== "RIFF" ||
452 buffer.subarray(8, 12).toString("ascii") !== "WEBP"
453 ) {
454 return null;
455 }
456 const kind = buffer.subarray(12, 16).toString("ascii");
457 if (kind === "VP8X") {
458 return {
459 width: 1 + buffer.readUIntLE(24, 3),
460 height: 1 + buffer.readUIntLE(27, 3),
461 };
462 }
463 if (kind === "VP8 " && buffer.subarray(23, 26).equals(Buffer.from([0x9d, 0x01, 0x2a]))) {
464 return {
465 width: buffer.readUInt16LE(26) & 0x3fff,
466 height: buffer.readUInt16LE(28) & 0x3fff,
467 };
468 }
469 if (kind === "VP8L" && buffer[20] === 0x2f) {
470 return {
471 width: 1 + buffer[21]! + ((buffer[22]! & 0x3f) << 8),
472 height: 1 + (buffer[22]! >> 6) + (buffer[23]! << 2) + ((buffer[24]! & 0x0f) << 10),
473 };
474 }
475 return null;
476}
477
478function dibDimensions(buffer: Buffer): ImageDimensions | null {
479 if (buffer.byteLength < 12) return null;
480 const headerSize = buffer.readUInt32LE(0);
481 if (headerSize === 12) {
482 return {
483 width: buffer.readUInt16LE(4),
484 height: buffer.readUInt16LE(6),
485 };
486 }
487 if (headerSize < 40 || buffer.byteLength < 12) return null;
488 return {
489 width: Math.abs(buffer.readInt32LE(4)),
490 height: Math.abs(buffer.readInt32LE(8)),
491 };
492}
493
494function icoDimensions(buffer: Buffer): ImageDimensions | null {
495 if (
496 buffer.byteLength < 22 ||
497 buffer.readUInt16LE(0) !== 0 ||
498 (buffer.readUInt16LE(2) !== 1 && buffer.readUInt16LE(2) !== 2)
499 ) {
500 return null;
501 }
502 const count = buffer.readUInt16LE(4);
503 if (count === 0 || count > 256 || buffer.byteLength < 6 + count * 16) return null;
504 let width = 0;
505 let height = 0;
506 for (let index = 0; index < count; index += 1) {
507 const offset = 6 + index * 16;
508 width = Math.max(width, buffer[offset] === 0 ? 256 : buffer[offset]!);
509 height = Math.max(height, buffer[offset + 1] === 0 ? 256 : buffer[offset + 1]!);
510 if (!safeDimensions({ width, height })) return null;
511 const byteLength = buffer.readUInt32LE(offset + 8);
512 const imageOffset = buffer.readUInt32LE(offset + 12);
513 if (
514 byteLength === 0 ||
515 imageOffset < 6 + count * 16 ||
516 imageOffset > buffer.byteLength ||
517 byteLength > buffer.byteLength - imageOffset
518 )
519 return null;
520 const embedded = buffer.subarray(imageOffset, imageOffset + byteLength);
521 const embeddedDimensions = pngDimensions(embedded) ?? dibDimensions(embedded);
522 if (!safeDimensions(embeddedDimensions)) return null;
523 }
524 return { width, height };
525}
526
527function sourceDimensions(buffer: Buffer): ImageDimensions | null {
528 return (
529 pngDimensions(buffer) ??
530 gifDimensions(buffer) ??
531 jpegDimensions(buffer) ??
532 webpDimensions(buffer) ??
533 icoDimensions(buffer)
534 );
535}
536
537async function normalizeFaviconBuffer(
538 webContents: Electron.WebContents,
539 mime: string | null,
540 buffer: Buffer,
541 signal: AbortSignal,
542): Promise<FaviconCaptureResult> {
543 const declaredMime = mime?.trim().toLowerCase() || null;
544 const normalizedMime =
545 declaredMime === "application/x-icon"
546 ? "image/x-icon"
547 : declaredMime === "application/octet-stream" || declaredMime === "binary/octet-stream"
548 ? null
549 : declaredMime;
550 const dimensions = sourceDimensions(buffer);
551 if (
552 (normalizedMime !== null && !/^image\/[a-z0-9.+-]+$/i.test(normalizedMime)) ||
553 normalizedMime === "image/svg+xml" ||
554 buffer.byteLength > MAX_FAVICON_RESPONSE_BYTES ||
555 !safeDimensions(dimensions)
556 ) {
557 return { kind: "none" };
558 }
559
560 const rasterized = await rasterizeFavicon(
561 webContents,
562 normalizedMime,
563 buffer,
564 dimensions,
565 signal,
566 );
567 if (rasterized.kind === "timed-out") return rasterized;
568 return typeof rasterized.value === "string" &&
569 rasterized.value.startsWith("data:image/png;base64,") &&
570 rasterized.value.length <= FAVICON_DATA_URL_MAX_LENGTH
571 ? { kind: "captured", dataUrl: rasterized.value }
572 : { kind: "none" };
573}
574
575async function rasterizeFavicon(
576 webContents: Electron.WebContents,
577 mime: string | null,
578 buffer: Buffer,
579 dimensions: ImageDimensions,
580 signal: AbortSignal,
581): Promise<RasterizationResult> {
582 const gate = rasterizationGates.get(webContents) ?? { generation: 0 };
583 rasterizationGates.set(webContents, gate);
584 const generation = ++gate.generation;
585 const previousLaunchAllowed = gate.launchAllowed;
586 if (previousLaunchAllowed) {
587 await waitForRasterLaunch(previousLaunchAllowed, signal);
588 }
589 if (signal.aborted || generation !== gate.generation) {
590 return { kind: "completed", value: null };
591 }
592
593 const payload = buffer.toString("base64");
594 const blobType = mime ?? "";
595 const scale = Math.min(32 / dimensions.width, 32 / dimensions.height);
596 const decodeWidth = Math.max(1, Math.round(dimensions.width * scale));
597 const decodeHeight = Math.max(1, Math.round(dimensions.height * scale));
598 const drawX = (32 - decodeWidth) / 2;
599 const drawY = (32 - decodeHeight) / 2;
600 const code = `
601 (() => {
602 const rasterize = async () => {
603 try {
604 const source = Uint8Array.from(atob("${payload}"), (char) => char.charCodeAt(0));
605 const bitmap = await createImageBitmap(new Blob([source], { type: "${blobType}" }), {
606 resizeWidth: ${decodeWidth},
607 resizeHeight: ${decodeHeight},
608 resizeQuality: "high",
609 });
610 try {
611 if (bitmap.width <= 0 || bitmap.height <= 0 || bitmap.width * bitmap.height > ${MAX_FAVICON_SOURCE_PIXELS}) {
612 return null;
613 }
614 const canvas = new OffscreenCanvas(32, 32);
615 const context = canvas.getContext("2d");
616 if (!context) return null;
617 context.drawImage(bitmap, ${drawX}, ${drawY}, ${decodeWidth}, ${decodeHeight});
618 const blob = await canvas.convertToBlob({ type: "image/png" });
619 const output = new Uint8Array(await blob.arrayBuffer());
620 let binary = "";
621 for (const byte of output) binary += String.fromCharCode(byte);
622 return "data:image/png;base64," + btoa(binary);
623 } finally {
624 bitmap.close();
625 }
626 } catch {
627 return null;
628 }
629 };
630 return rasterize();
631 })()
632 `;
633
634 const execution = webContents.executeJavaScriptInIsolatedWorld(FAVICON_RASTER_WORLD_ID, [
635 { code },
636 ]);
637
638 const result = new Promise<RasterizationResult>((resolve, reject) => {
639 // Electron cannot cancel isolated-world execution. This timeout ends only
640 // the logical attempt; renderer work may finish after a newer attempt starts.
641 const timeout = AbortSignal.timeout(FAVICON_RASTER_TIMEOUT_MS);
642 let settled = false;
643 const finish = (complete: () => void) => {
644 if (settled) return;
645 settled = true;
646 timeout.removeEventListener("abort", onTimeout);
647 signal.removeEventListener("abort", onAbort);
648 complete();
649 };
650 const onTimeout = () => {
651 finish(() => resolve({ kind: "timed-out" }));
652 };
653 const onAbort = () => {
654 finish(() => resolve({ kind: "completed", value: null }));
655 };
656 timeout.addEventListener("abort", onTimeout, { once: true });
657 signal.addEventListener("abort", onAbort, { once: true });
658 void execution.then(
659 (value) => {
660 finish(() => resolve({ kind: "completed", value }));
661 },
662 (cause: unknown) => {
663 finish(() => reject(cause));
664 },
665 );
666 if (signal.aborted) onAbort();
667 });
668 // The logical timeout does not cancel Electron's renderer work. Keep the
669 // gate closed until that physical execution actually settles.
670 const launchAllowed = execution.then(
671 () => undefined,
672 () => undefined,
673 );
674 gate.launchAllowed = launchAllowed;
675 void launchAllowed.then(() => {
676 if (gate.launchAllowed === launchAllowed) delete gate.launchAllowed;
677 });
678 return await result;
679}
apps/desktop/src/preview/Manager.test.ts+604
...@@ -186,6 +186,102 @@ const makeTestPreviewWebContents = (...@@ -186,6 +186,102 @@ const makeTestPreviewWebContents = (
186 capturePage,186 capturePage,
187 }) as never;187 }) as never;
188188
189const TEST_FAVICON = "data:image/png;base64,cG5n";
190
191const makeSourcePng = (width = 1, height = 1): Buffer => {
192 const buffer = Buffer.alloc(24);
193 Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]).copy(buffer);
194 buffer.writeUInt32BE(width, 16);
195 buffer.writeUInt32BE(height, 20);
196 return buffer;
197};
198
199const makeFaviconWebContents = (options?: {
200 readonly fetch?: (url: string, init?: RequestInit) => Promise<Response>;
201 readonly id?: number;
202 readonly rasterize?: (code: string) => Promise<unknown>;
203 readonly url?: string;
204}) => {
205 const sourcePng = makeSourcePng();
206 const listeners = new Map<string, (...args: never[]) => void>();
207 let currentUrl = options?.url ?? "http://localhost:3200/";
208 let destroyed = false;
209 let loading = false;
210 const fetch = vi.fn(
211 options?.fetch ??
212 (async () =>
213 new Response(new Uint8Array(sourcePng), {
214 headers: { "content-type": "image/png" },
215 })),
216 );
217 const executeJavaScriptInIsolatedWorld = vi.fn(
218 async (_worldId: number, scripts: ReadonlyArray<{ readonly code: string }>) =>
219 options?.rasterize ? options.rasterize(scripts[0]?.code ?? "") : TEST_FAVICON,
220 );
221 const reload = vi.fn();
222 const loadURL = vi.fn(async (url: string) => {
223 currentUrl = url;
224 });
225 const off = vi.fn();
226 const debuggerOff = vi.fn();
227 const webContents = {
228 id: options?.id ?? 42,
229 isDestroyed: () => destroyed,
230 getType: () => "webview",
231 getURL: () => currentUrl,
232 getTitle: () => "Preview",
233 isLoading: () => loading,
234 isDevToolsOpened: () => false,
235 getZoomFactor: () => 1,
236 setZoomFactor: vi.fn(),
237 reload,
238 reloadIgnoringCache: vi.fn(),
239 loadURL,
240 on: vi.fn((event: string, listener: (...args: never[]) => void) => {
241 listeners.set(event, listener);
242 }),
243 off,
244 ipc: { on: vi.fn(), off: vi.fn() },
245 send: webviewSend,
246 session: { fetch },
247 navigationHistory: { canGoBack: () => false, canGoForward: () => false },
248 setWindowOpenHandler: vi.fn(),
249 executeJavaScriptInIsolatedWorld,
250 debugger: {
251 isAttached: () => false,
252 attach: vi.fn(),
253 sendCommand: vi.fn(async () => undefined),
254 on: vi.fn(),
255 off: debuggerOff,
256 },
257 };
258 return {
259 executeJavaScriptInIsolatedWorld,
260 fetch,
261 debuggerOff,
262 listeners,
263 loadURL,
264 off,
265 reload,
266 setDestroyed: (value: boolean) => {
267 destroyed = value;
268 },
269 setLoading: (value: boolean) => {
270 loading = value;
271 },
272 setUrl: (url: string) => {
273 currentUrl = url;
274 },
275 webContents: webContents as never,
276 };
277};
278
279const settle = function* (until: () => boolean) {
280 for (let attempt = 0; attempt < 30 && !until(); attempt++) {
281 yield* Effect.promise(() => Promise.resolve());
282 }
283};
284
189const makeTestPictureInPictureWindow = (loadURL: () => Promise<void> = async () => undefined) => {285const makeTestPictureInPictureWindow = (loadURL: () => Promise<void> = async () => undefined) => {
190 const listeners = new Map<string, () => void>();286 const listeners = new Map<string, () => void>();
191 const send = vi.fn();287 const send = vi.fn();
...@@ -257,6 +353,32 @@ describe("PreviewManager", () => {...@@ -257,6 +353,32 @@ describe("PreviewManager", () => {
257 ),353 ),
258 );354 );
259355
356 effectIt.effect("rejects a destroyed webview during registration", () =>
357 withManager((manager) =>
358 Effect.gen(function* () {
359 const getType = vi.fn(() => "webview" as const);
360 fromId.mockReturnValue({
361 id: 42,
362 isDestroyed: () => true,
363 getType,
364 } as never);
365 yield* manager.createTab("tab_destroyed_registration");
366
367 const exit = yield* Effect.exit(manager.registerWebview("tab_destroyed_registration", 42));
368
369 expect(Exit.isFailure(exit)).toBe(true);
370 if (Exit.isFailure(exit)) {
371 expect(Option.getOrThrow(Cause.findErrorOption(exit.cause))).toMatchObject({
372 _tag: "PreviewWebContentsNotFoundError",
373 tabId: "tab_destroyed_registration",
374 webContentsId: 42,
375 });
376 }
377 expect(getType).not.toHaveBeenCalled();
378 }),
379 ),
380 );
381
260 effectIt.effect("isolates failed state listeners and continues delivery", () => {382 effectIt.effect("isolates failed state listeners and continues delivery", () => {
261 const loggedErrors: Array<unknown> = [];383 const loggedErrors: Array<unknown> = [];
262 const logger = Logger.make(({ message }) => {384 const logger = Logger.make(({ message }) => {
...@@ -375,6 +497,488 @@ describe("PreviewManager", () => {...@@ -375,6 +497,488 @@ describe("PreviewManager", () => {
375 ),497 ),
376 );498 );
377499
500 effectIt.effect("detaches a destroyed webview instead of navigating it", () =>
501 withManager((manager) =>
502 Effect.gen(function* () {
503 const preview = makeFaviconWebContents();
504 fromId.mockReturnValue(preview.webContents);
505 const states: PreviewManager.PreviewTabState[] = [];
506 yield* manager.subscribeStateChanges((_tabId, state) =>
507 Effect.sync(() => {
508 states.push(state);
509 }),
510 );
511 yield* manager.createTab("tab_destroyed_navigation");
512 yield* manager.registerWebview("tab_destroyed_navigation", 42);
513 yield* manager.setColorScheme("tab_destroyed_navigation", "dark");
514 preview.setDestroyed(true);
515
516 yield* manager.navigate("tab_destroyed_navigation", "https://example.com/");
517
518 expect(preview.loadURL).not.toHaveBeenCalled();
519 expect(preview.reload).not.toHaveBeenCalled();
520 expect(preview.off).toHaveBeenCalled();
521 expect(preview.debuggerOff).toHaveBeenCalled();
522 expect(states.at(-1)).toMatchObject({
523 webContentsId: null,
524 navStatus: { kind: "Loading", url: "https://example.com/" },
525 });
526 }),
527 ),
528 );
529
530 effectIt.effect("does not let destroyed-webview cleanup detach a same-id replacement", () =>
531 withManager((manager) =>
532 Effect.gen(function* () {
533 const previous = makeFaviconWebContents();
534 const replacement = makeFaviconWebContents({ url: "https://example.com/" });
535 let current = previous.webContents;
536 let startReplacementRegistration: () => void = () => void 0;
537 const replacementReady = new Promise<void>((resolve) => {
538 startReplacementRegistration = resolve;
539 });
540 fromId.mockImplementation(() => current);
541 yield* manager.createTab("tab_destroyed_replacement_race");
542 yield* manager.registerWebview("tab_destroyed_replacement_race", 42);
543 yield* manager.setColorScheme("tab_destroyed_replacement_race", "dark");
544 const replacementRegistration = yield* Effect.promise(() => replacementReady).pipe(
545 Effect.flatMap(() => manager.registerWebview("tab_destroyed_replacement_race", 42)),
546 Effect.forkChild({ startImmediately: true }),
547 );
548 previous.setDestroyed(true);
549 previous.debuggerOff.mockImplementationOnce(() => {
550 current = replacement.webContents;
551 startReplacementRegistration();
552 });
553 const states: PreviewManager.PreviewTabState[] = [];
554 yield* manager.subscribeStateChanges((_tabId, state) =>
555 Effect.sync(() => {
556 states.push(state);
557 }),
558 );
559
560 yield* manager.navigate("tab_destroyed_replacement_race", "https://example.com/");
561 const registrationExit = yield* Fiber.await(replacementRegistration);
562
563 expect(Exit.isSuccess(registrationExit)).toBe(true);
564 expect(previous.off).toHaveBeenCalled();
565 expect(replacement.off).not.toHaveBeenCalled();
566 expect(states.at(-1)).toMatchObject({
567 webContentsId: 42,
568 navStatus: { kind: "Loading", url: "https://example.com/" },
569 });
570 }),
571 ),
572 );
573
574 effectIt.effect("publishes a canonical favicon origin while the page is loading", () =>
575 withManager((manager) =>
576 Effect.gen(function* () {
577 const preview = makeFaviconWebContents({
578 url: `http://localhost:3200/${"x".repeat(3_000)}`,
579 });
580 preview.setLoading(true);
581 fromId.mockReturnValue(preview.webContents);
582 const states: PreviewManager.PreviewTabState[] = [];
583 yield* manager.subscribeStateChanges((_tabId, state) =>
584 Effect.sync(() => {
585 states.push(state);
586 }),
587 );
588 yield* manager.createTab("tab_favicon_loading");
589 yield* manager.registerWebview("tab_favicon_loading", 42);
590
591 preview.listeners.get("page-favicon-updated")?.(
592 {} as never,
593 ["http://localhost:3200/favicon.png"] as never,
594 );
595 yield* settle(() => states.at(-1)?.favicon !== undefined);
596
597 expect(states.at(-1)?.favicon).toMatchObject({
598 dataUrl: TEST_FAVICON,
599 pageUrl: "http://localhost:3200",
600 });
601 expect(states.at(-1)?.favicon?.capturedAt).toEqual(expect.any(Number));
602 }),
603 ),
604 );
605
606 effectIt.effect("shares an identical in-flight event and lets a changed event win", () =>
607 withManager((manager) =>
608 Effect.gen(function* () {
609 let resolveFirst!: (response: Response) => void;
610 const firstResponse = new Promise<Response>((resolve) => {
611 resolveFirst = resolve;
612 });
613 const preview = makeFaviconWebContents({
614 fetch: (url) =>
615 url.endsWith("first.png")
616 ? firstResponse
617 : Promise.resolve(
618 new Response(new Uint8Array(makeSourcePng()), {
619 headers: { "content-type": "image/png" },
620 }),
621 ),
622 });
623 fromId.mockReturnValue(preview.webContents);
624 const states: PreviewManager.PreviewTabState[] = [];
625 yield* manager.subscribeStateChanges((_tabId, state) =>
626 Effect.sync(() => {
627 states.push(state);
628 }),
629 );
630 yield* manager.createTab("tab_favicon_latest");
631 yield* manager.registerWebview("tab_favicon_latest", 42);
632
633 const faviconUpdated = preview.listeners.get("page-favicon-updated")!;
634 faviconUpdated({} as never, ["http://localhost:3200/first.png"] as never);
635 faviconUpdated({} as never, ["http://localhost:3200/first.png"] as never);
636 yield* settle(() => preview.fetch.mock.calls.length === 1);
637 faviconUpdated({} as never, ["http://localhost:3200/second.png"] as never);
638 yield* settle(() => states.at(-1)?.favicon !== undefined);
639 resolveFirst(
640 new Response(new Uint8Array(makeSourcePng()), {
641 headers: { "content-type": "image/png" },
642 }),
643 );
644 yield* settle(() => false);
645
646 expect(preview.fetch).toHaveBeenCalledTimes(2);
647 expect(states.filter((state) => state.favicon !== undefined)).toHaveLength(1);
648 }),
649 ),
650 );
651
652 effectIt.effect("allows an identical retry after an undecodable capture", () =>
653 withManager((manager) =>
654 Effect.gen(function* () {
655 let rasterizations = 0;
656 const preview = makeFaviconWebContents({
657 rasterize: async () => (++rasterizations === 1 ? null : TEST_FAVICON),
658 });
659 fromId.mockReturnValue(preview.webContents);
660 const states: PreviewManager.PreviewTabState[] = [];
661 yield* manager.subscribeStateChanges((_tabId, state) =>
662 Effect.sync(() => {
663 states.push(state);
664 }),
665 );
666 yield* manager.createTab("tab_favicon_retry");
667 yield* manager.registerWebview("tab_favicon_retry", 42);
668 const faviconUpdated = preview.listeners.get("page-favicon-updated")!;
669
670 faviconUpdated({} as never, ["http://localhost:3200/favicon.png"] as never);
671 yield* settle(() => rasterizations === 1);
672 yield* settle(() => false);
673 faviconUpdated({} as never, ["http://localhost:3200/favicon.png"] as never);
674 yield* settle(() => states.at(-1)?.favicon !== undefined);
675
676 expect(rasterizations).toBe(2);
677 expect(states.at(-1)?.favicon?.dataUrl).toBe(TEST_FAVICON);
678 }),
679 ),
680 );
681
682 effectIt.effect("does not publish a capture invalidated by navigation", () =>
683 withManager((manager) =>
684 Effect.gen(function* () {
685 let resolveFetch!: (response: Response) => void;
686 const preview = makeFaviconWebContents({
687 fetch: () =>
688 new Promise<Response>((resolve) => {
689 resolveFetch = resolve;
690 }),
691 });
692 fromId.mockReturnValue(preview.webContents);
693 const states: PreviewManager.PreviewTabState[] = [];
694 yield* manager.subscribeStateChanges((_tabId, state) =>
695 Effect.sync(() => {
696 states.push(state);
697 }),
698 );
699 yield* manager.createTab("tab_favicon_navigation");
700 yield* manager.registerWebview("tab_favicon_navigation", 42);
701 preview.listeners.get("page-favicon-updated")?.(
702 {} as never,
703 ["http://localhost:3200/favicon.png"] as never,
704 );
705 yield* settle(() => preview.fetch.mock.calls.length === 1);
706 preview.listeners.get("did-start-navigation")?.({
707 isMainFrame: true,
708 isSameDocument: false,
709 } as never);
710 preview.setUrl("https://example.com/");
711 resolveFetch(
712 new Response(new Uint8Array(makeSourcePng()), {
713 headers: { "content-type": "image/png" },
714 }),
715 );
716 yield* settle(() => false);
717
718 expect(states.some((state) => state.favicon !== undefined)).toBe(false);
719 }),
720 ),
721 );
722
723 effectIt.effect("retains a favicon when reloading the current URL without a new event", () =>
724 withManager((manager) =>
725 Effect.gen(function* () {
726 const preview = makeFaviconWebContents();
727 fromId.mockReturnValue(preview.webContents);
728 const states: PreviewManager.PreviewTabState[] = [];
729 yield* manager.subscribeStateChanges((_tabId, state) =>
730 Effect.sync(() => {
731 states.push(state);
732 }),
733 );
734 yield* manager.createTab("tab_favicon_reload");
735 yield* manager.registerWebview("tab_favicon_reload", 42);
736 preview.listeners.get("page-favicon-updated")?.(
737 {} as never,
738 ["http://localhost:3200/favicon.png"] as never,
739 );
740 yield* settle(() => states.at(-1)?.favicon !== undefined);
741
742 yield* manager.navigate("tab_favicon_reload", "http://localhost:3200/");
743
744 expect(preview.reload).toHaveBeenCalledOnce();
745 expect(states.at(-1)?.favicon?.dataUrl).toBe(TEST_FAVICON);
746 }),
747 ),
748 );
749
750 effectIt.effect("clears a published favicon after a confirmed cross-origin navigation", () =>
751 withManager((manager) =>
752 Effect.gen(function* () {
753 const preview = makeFaviconWebContents();
754 fromId.mockReturnValue(preview.webContents);
755 const states: PreviewManager.PreviewTabState[] = [];
756 yield* manager.subscribeStateChanges((_tabId, state) =>
757 Effect.sync(() => {
758 states.push(state);
759 }),
760 );
761 yield* manager.createTab("tab_favicon_origin");
762 yield* manager.registerWebview("tab_favicon_origin", 42);
763 preview.listeners.get("page-favicon-updated")?.(
764 {} as never,
765 ["http://localhost:3200/favicon.png"] as never,
766 );
767 yield* settle(() => states.at(-1)?.favicon !== undefined);
768
769 preview.setUrl("https://example.com/");
770 preview.listeners.get("did-navigate")?.({} as never);
771 yield* settle(() => states.at(-1)?.navStatus.kind === "Success");
772
773 expect(states.at(-1)?.favicon).toBeUndefined();
774 }),
775 ),
776 );
777
778 effectIt.effect(
779 "retains the previous document icon across a failed cross-origin navigation",
780 () =>
781 withManager((manager) =>
782 Effect.gen(function* () {
783 const preview = makeFaviconWebContents();
784 fromId.mockReturnValue(preview.webContents);
785 const states: PreviewManager.PreviewTabState[] = [];
786 yield* manager.subscribeStateChanges((_tabId, state) =>
787 Effect.sync(() => {
788 states.push(state);
789 }),
790 );
791 yield* manager.createTab("tab_favicon_failed_origin");
792 yield* manager.registerWebview("tab_favicon_failed_origin", 42);
793 preview.listeners.get("page-favicon-updated")?.(
794 {} as never,
795 ["http://localhost:3200/favicon.png"] as never,
796 );
797 yield* settle(() => states.at(-1)?.favicon !== undefined);
798
799 preview.listeners.get("did-fail-load")?.(
800 {} as never,
801 -105 as never,
802 "Name not resolved" as never,
803 "https://unreachable.example/" as never,
804 true as never,
805 );
806 yield* settle(() => states.at(-1)?.navStatus.kind === "LoadFailed");
807 expect(states.at(-1)?.favicon?.dataUrl).toBe(TEST_FAVICON);
808
809 preview.listeners.get("did-navigate")?.({} as never);
810 yield* settle(() => states.at(-1)?.navStatus.kind === "Success");
811 expect(states.at(-1)?.favicon?.dataUrl).toBe(TEST_FAVICON);
812 }),
813 ),
814 );
815
816 effectIt.effect("does not resurrect an icon after a confirmed about:blank document", () =>
817 withManager((manager) =>
818 Effect.gen(function* () {
819 const preview = makeFaviconWebContents();
820 fromId.mockReturnValue(preview.webContents);
821 const states: PreviewManager.PreviewTabState[] = [];
822 yield* manager.subscribeStateChanges((_tabId, state) =>
823 Effect.sync(() => {
824 states.push(state);
825 }),
826 );
827 yield* manager.createTab("tab_favicon_blank");
828 yield* manager.registerWebview("tab_favicon_blank", 42);
829 preview.listeners.get("page-favicon-updated")?.(
830 {} as never,
831 ["http://localhost:3200/favicon.png"] as never,
832 );
833 yield* settle(() => states.at(-1)?.favicon !== undefined);
834
835 preview.setUrl("about:blank");
836 preview.listeners.get("did-navigate")?.({} as never);
837 yield* settle(() => states.at(-1)?.navStatus.kind === "Idle");
838 expect(states.at(-1)?.favicon).toBeUndefined();
839
840 preview.setUrl("http://localhost:3200/");
841 preview.listeners.get("did-navigate")?.({} as never);
842 yield* settle(() => states.at(-1)?.navStatus.kind === "Success");
843 expect(states.at(-1)?.favicon).toBeUndefined();
844 }),
845 ),
846 );
847
848 effectIt.effect("clears a published favicon when a replacement webview attaches", () =>
849 withManager((manager) =>
850 Effect.gen(function* () {
851 const initial = makeFaviconWebContents({ id: 42 });
852 const replacement = makeFaviconWebContents({ id: 43 });
853 fromId.mockImplementation((id?: number) => {
854 if (id === 42) return initial.webContents;
855 if (id === 43) return replacement.webContents;
856 return null;
857 });
858 const states: PreviewManager.PreviewTabState[] = [];
859 yield* manager.subscribeStateChanges((_tabId, state) =>
860 Effect.sync(() => {
861 states.push(state);
862 }),
863 );
864 yield* manager.createTab("tab_favicon_replace");
865 yield* manager.registerWebview("tab_favicon_replace", 42);
866 initial.listeners.get("page-favicon-updated")?.(
867 {} as never,
868 ["http://localhost:3200/favicon.png"] as never,
869 );
870 yield* settle(() => states.at(-1)?.favicon !== undefined);
871
872 yield* manager.registerWebview("tab_favicon_replace", 43);
873
874 expect(states.at(-1)?.webContentsId).toBe(43);
875 expect(states.at(-1)?.favicon).toBeUndefined();
876 }),
877 ),
878 );
879
880 effectIt.effect("ignores an old capture that completes after webview replacement", () =>
881 withManager((manager) =>
882 Effect.gen(function* () {
883 let resolveFetch!: (response: Response) => void;
884 const initial = makeFaviconWebContents({
885 id: 42,
886 fetch: () =>
887 new Promise<Response>((resolve) => {
888 resolveFetch = resolve;
889 }),
890 });
891 const replacement = makeFaviconWebContents({ id: 43 });
892 fromId.mockImplementation((id?: number) =>
893 id === 42 ? initial.webContents : id === 43 ? replacement.webContents : null,
894 );
895 const states: PreviewManager.PreviewTabState[] = [];
896 yield* manager.subscribeStateChanges((_tabId, state) =>
897 Effect.sync(() => {
898 states.push(state);
899 }),
900 );
901 yield* manager.createTab("tab_favicon_late_replace");
902 yield* manager.registerWebview("tab_favicon_late_replace", 42);
903 initial.listeners.get("page-favicon-updated")?.(
904 {} as never,
905 ["http://localhost:3200/favicon.png"] as never,
906 );
907 yield* settle(() => initial.fetch.mock.calls.length === 1);
908
909 yield* manager.registerWebview("tab_favicon_late_replace", 43);
910 resolveFetch(
911 new Response(new Uint8Array(makeSourcePng()), {
912 headers: { "content-type": "image/png" },
913 }),
914 );
915 yield* settle(() => false);
916
917 expect(states.at(-1)?.webContentsId).toBe(43);
918 expect(
919 states.some((state) => state.webContentsId === 43 && state.favicon !== undefined),
920 ).toBe(false);
921 }),
922 ),
923 );
924
925 effectIt.effect("treats a reused WebContents id as a new attachment", () =>
926 withManager((manager) =>
927 Effect.gen(function* () {
928 const initial = makeFaviconWebContents({ id: 42 });
929 const replacement = makeFaviconWebContents({ id: 42 });
930 let active = initial.webContents;
931 fromId.mockImplementation(() => active);
932 const states: PreviewManager.PreviewTabState[] = [];
933 yield* manager.subscribeStateChanges((_tabId, state) =>
934 Effect.sync(() => {
935 states.push(state);
936 }),
937 );
938 yield* manager.createTab("tab_favicon_reused_id");
939 yield* manager.registerWebview("tab_favicon_reused_id", 42);
940 initial.listeners.get("page-favicon-updated")?.(
941 {} as never,
942 ["http://localhost:3200/favicon.png"] as never,
943 );
944 yield* settle(() => states.at(-1)?.favicon !== undefined);
945
946 active = replacement.webContents;
947 yield* manager.registerWebview("tab_favicon_reused_id", 42);
948
949 expect(states.at(-1)?.favicon).toBeUndefined();
950 expect(initial.off).toHaveBeenCalled();
951 expect(replacement.listeners.has("page-favicon-updated")).toBe(true);
952 }),
953 ),
954 );
955
956 effectIt.effect("preserves a favicon when the active attachment registers again", () =>
957 withManager((manager) =>
958 Effect.gen(function* () {
959 const preview = makeFaviconWebContents();
960 fromId.mockReturnValue(preview.webContents);
961 const states: PreviewManager.PreviewTabState[] = [];
962 yield* manager.subscribeStateChanges((_tabId, state) =>
963 Effect.sync(() => {
964 states.push(state);
965 }),
966 );
967 yield* manager.createTab("tab_favicon_reregister");
968 yield* manager.registerWebview("tab_favicon_reregister", 42);
969 preview.listeners.get("page-favicon-updated")?.(
970 {} as never,
971 ["http://localhost:3200/favicon.png"] as never,
972 );
973 yield* settle(() => states.at(-1)?.favicon !== undefined);
974
975 yield* manager.registerWebview("tab_favicon_reregister", 42);
976
977 expect(states.at(-1)?.favicon?.dataUrl).toBe(TEST_FAVICON);
978 }),
979 ),
980 );
981
378 effectIt.effect("mirrors Electron's effective zoom across registration and navigation", () =>982 effectIt.effect("mirrors Electron's effective zoom across registration and navigation", () =>
379 withManager((manager) =>983 withManager((manager) =>
380 Effect.gen(function* () {984 Effect.gen(function* () {
apps/desktop/src/preview/Manager.ts+204-21
...@@ -8,6 +8,7 @@...@@ -8,6 +8,7 @@
8import type {8import type {
9 DesktopPreviewAnnotationTheme,9 DesktopPreviewAnnotationTheme,
10 DesktopPreviewColorScheme,10 DesktopPreviewColorScheme,
11 DesktopPreviewFavicon,
11 DesktopPreviewPointerEvent,12 DesktopPreviewPointerEvent,
12 PreviewAnnotationPayload,13 PreviewAnnotationPayload,
13 PreviewAnnotationRect,14 PreviewAnnotationRect,
...@@ -62,6 +63,7 @@ import {...@@ -62,6 +63,7 @@ import {
62import { isPreviewAnnotationPayload } from "./PickedElementPayload.ts";63import { isPreviewAnnotationPayload } from "./PickedElementPayload.ts";
63import { playwrightInjectedRuntimeInstallExpression } from "./PlaywrightInjectedRuntime.ts";64import { playwrightInjectedRuntimeInstallExpression } from "./PlaywrightInjectedRuntime.ts";
64import { makePreviewAutomationKeySequence } from "./PreviewKeyboard.ts";65import { makePreviewAutomationKeySequence } from "./PreviewKeyboard.ts";
66import { captureFavicon, safeHttpOrigin, selectFaviconCandidates } from "./FaviconCapture.ts";
6567
66export type PreviewNavStatus =68export type PreviewNavStatus =
67 | { kind: "Idle" }69 | { kind: "Idle" }
...@@ -85,6 +87,7 @@ export interface PreviewTabState {...@@ -85,6 +87,7 @@ export interface PreviewTabState {
85 pictureInPicture: boolean;87 pictureInPicture: boolean;
86 colorScheme: DesktopPreviewColorScheme;88 colorScheme: DesktopPreviewColorScheme;
87 controller: "human" | "agent" | "none";89 controller: "human" | "agent" | "none";
90 favicon?: DesktopPreviewFavicon;
88 updatedAt: string;91 updatedAt: string;
89}92}
9093
...@@ -346,7 +349,10 @@ type PreviewInputSignal =...@@ -346,7 +349,10 @@ type PreviewInputSignal =
346 | { readonly kind: "key"; readonly key: string; readonly code: string };349 | { readonly kind: "key"; readonly key: string; readonly code: string };
347350
348interface ManagedListeners {351interface ManagedListeners {
352 readonly attachmentId: symbol;
353 readonly cancelFaviconCapture: () => void;
349 readonly scope: Scope.Closeable;354 readonly scope: Scope.Closeable;
355 readonly webContents: Electron.WebContents;
350}356}
351357
352type FrameCaptureConsumer = "picture-in-picture" | "recording";358type FrameCaptureConsumer = "picture-in-picture" | "recording";
...@@ -613,6 +619,15 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -613,6 +619,15 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
613 );619 );
614 });620 });
615621
622 const emitIfCurrent = Effect.fn("PreviewManager.emitIfCurrent")(function* (
623 tabId: string,
624 state: PreviewTabState,
625 ) {
626 if ((yield* SynchronizedRef.get(tabsRef)).get(tabId) === state) {
627 yield* emit(tabId, state);
628 }
629 });
630
616 const update = Effect.fn("PreviewManager.update")(function* (631 const update = Effect.fn("PreviewManager.update")(function* (
617 tabId: string,632 tabId: string,
618 patch: Partial<PreviewTabState>,633 patch: Partial<PreviewTabState>,
...@@ -1204,7 +1219,10 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -1204,7 +1219,10 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
1204 copy.delete(webContentsId);1219 copy.delete(webContentsId);
1205 }),1220 }),
1206 ]);1221 ]);
1207 if (managed) yield* Scope.close(managed.scope, Exit.void).pipe(Effect.ignore);1222 if (managed) {
1223 managed.cancelFaviconCapture();
1224 yield* Scope.close(managed.scope, Exit.void).pipe(Effect.ignore);
1225 }
1208 });1226 });
12091227
1210 const isAppShortcut = (input: Electron.Input): boolean =>1228 const isAppShortcut = (input: Electron.Input): boolean =>
...@@ -1268,8 +1286,23 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -1268,8 +1286,23 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
1268 wc: Electron.WebContents,1286 wc: Electron.WebContents,
1269 ) {1287 ) {
1270 const scope = yield* Scope.fork(parentScope, "sequential");1288 const scope = yield* Scope.fork(parentScope, "sequential");
1289 const attachmentId = Symbol();
1290 let documentId = 0;
1291 let nextRequestId = 0;
1292 let activeCapture: {
1293 readonly controller: AbortController;
1294 readonly documentId: number;
1295 readonly eventKey: string;
1296 readonly requestId: number;
1297 } | null = null;
1298 const cancelFaviconCapture = () => {
1299 documentId += 1;
1300 activeCapture?.controller.abort();
1301 activeCapture = null;
1302 };
1271 const syncState = Effect.fn("PreviewManager.syncWebContentsState")(function* (1303 const syncState = Effect.fn("PreviewManager.syncWebContentsState")(function* (
1272 preserveLoadFailure: boolean,1304 preserveLoadFailure: boolean,
1305 confirmedNavigation = false,
1273 ) {1306 ) {
1274 if (wc.isDestroyed()) return;1307 if (wc.isDestroyed()) return;
1275 const zoomFactor = yield* attempt(1308 const zoomFactor = yield* attempt(
...@@ -1282,7 +1315,9 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -1282,7 +1315,9 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
1282 const updatedAt = yield* currentIso;1315 const updatedAt = yield* currentIso;
1283 const next = yield* SynchronizedRef.modify(tabsRef, (tabs) => {1316 const next = yield* SynchronizedRef.modify(tabsRef, (tabs) => {
1284 const current = tabs.get(tabId);1317 const current = tabs.get(tabId);
1285 if (!current) return [Option.none<PreviewTabState>(), tabs] as const;1318 if (!current || current.webContentsId !== wc.id || webContents.fromId(wc.id) !== wc) {
1319 return [Option.none<PreviewTabState>(), tabs] as const;
1320 }
1286 // Electron emits did-stop-loading after did-fail-load. At that point the1321 // Electron emits did-stop-loading after did-fail-load. At that point the
1287 // failed guest is no longer "loading", but it has not successfully1322 // failed guest is no longer "loading", but it has not successfully
1288 // navigated anywhere. Keep the failure until a new load actually starts.1323 // navigated anywhere. Keep the failure until a new load actually starts.
...@@ -1292,8 +1327,14 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -1292,8 +1327,14 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
1292 computedNavStatus.kind === "Success"1327 computedNavStatus.kind === "Success"
1293 ? current.navStatus1328 ? current.navStatus
1294 : computedNavStatus;1329 : computedNavStatus;
1330 const clearFavicon =
1331 confirmedNavigation &&
1332 current.favicon !== undefined &&
1333 safeHttpOrigin(current.favicon.pageUrl) !==
1334 safeHttpOrigin(navStatus.kind === "Idle" ? wc.getURL() : navStatus.url);
1335 const { favicon: _favicon, ...currentWithoutFavicon } = current;
1295 const state: PreviewTabState = {1336 const state: PreviewTabState = {
1296 ...current,1337 ...(clearFavicon ? currentWithoutFavicon : current),
1297 navStatus,1338 navStatus,
1298 canGoBack,1339 canGoBack,
1299 canGoForward,1340 canGoForward,
...@@ -1307,10 +1348,109 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -1307,10 +1348,109 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
1307 }),1348 }),
1308 ] as const;1349 ] as const;
1309 });1350 });
1310 if (Option.isSome(next)) yield* emit(tabId, next.value);1351 if (Option.isSome(next)) yield* emitIfCurrent(tabId, next.value);
1311 });1352 });
1312 const sync = () => runFork(syncState(true));1353 const sync = () => runFork(syncState(true));
1313 const syncNavigation = () => runFork(syncState(false));1354 const syncNavigation = () => runFork(syncState(false, true));
1355 const syncInPageNavigation = () => runFork(syncState(false));
1356 const navigationStarted = (
1357 event: Electron.Event<Electron.WebContentsDidStartNavigationEventParams>,
1358 ) => {
1359 if (event.isMainFrame && !event.isSameDocument) cancelFaviconCapture();
1360 };
1361 const publishFavicon = Effect.fn("PreviewManager.publishFavicon")(function* (input: {
1362 readonly captureDocumentId: number;
1363 readonly dataUrl: string;
1364 readonly pageUrl: string;
1365 readonly requestId: number;
1366 }) {
1367 const pageOrigin = safeHttpOrigin(input.pageUrl);
1368 const managed = (yield* Ref.get(attachedRef)).get(wc.id);
1369 if (
1370 !pageOrigin ||
1371 wc.isDestroyed() ||
1372 webContents.fromId(wc.id) !== wc ||
1373 managed?.attachmentId !== attachmentId ||
1374 activeCapture?.documentId !== input.captureDocumentId ||
1375 activeCapture.requestId !== input.requestId ||
1376 safeHttpOrigin(wc.getURL()) !== pageOrigin
1377 ) {
1378 return;
1379 }
1380 const capturedAt = yield* currentMillis;
1381 const updatedAt = yield* currentIso;
1382 const next = yield* SynchronizedRef.modify(tabsRef, (tabs) => {
1383 const current = tabs.get(tabId);
1384 if (
1385 !current ||
1386 current.webContentsId !== wc.id ||
1387 webContents.fromId(wc.id) !== wc ||
1388 activeCapture?.documentId !== input.captureDocumentId ||
1389 activeCapture.requestId !== input.requestId
1390 ) {
1391 return [Option.none<PreviewTabState>(), tabs] as const;
1392 }
1393 const state: PreviewTabState = {
1394 ...current,
1395 favicon: { dataUrl: input.dataUrl, pageUrl: pageOrigin, capturedAt },
1396 updatedAt,
1397 };
1398 return [
1399 Option.some(state),
1400 replaceMap(tabs, (copy) => {
1401 copy.set(tabId, state);
1402 }),
1403 ] as const;
1404 });
1405 if (Option.isSome(next)) yield* emitIfCurrent(tabId, next.value);
1406 });
1407 const faviconUpdated = (_event: Event, rawCandidates: ReadonlyArray<string>): void => {
1408 const pageUrl = wc.getURL();
1409 if (!safeHttpOrigin(pageUrl)) return;
1410 const candidates = selectFaviconCandidates(rawCandidates);
1411 if (candidates.length === 0) return;
1412 const eventKey = JSON.stringify([pageUrl, ...candidates]);
1413 if (activeCapture?.eventKey === eventKey) return;
1414 activeCapture?.controller.abort();
1415 const captureDocumentId = documentId;
1416 const requestId = ++nextRequestId;
1417 const controller = new AbortController();
1418 activeCapture = { controller, documentId: captureDocumentId, eventKey, requestId };
1419 runFork(
1420 Effect.tryPromise({
1421 try: () =>
1422 captureFavicon({ webContents: wc, pageUrl, candidates, signal: controller.signal }),
1423 catch: (cause) =>
1424 new PreviewOperationError({
1425 operation: "captureFavicon",
1426 tabId,
1427 webContentsId: wc.id,
1428 cause,
1429 }),
1430 }).pipe(
1431 Effect.flatMap((result) =>
1432 result.kind === "captured"
1433 ? publishFavicon({
1434 captureDocumentId,
1435 dataUrl: result.dataUrl,
1436 pageUrl,
1437 requestId,
1438 })
1439 : Effect.void,
1440 ),
1441 Effect.catch((error) =>
1442 controller.signal.aborted
1443 ? Effect.void
1444 : Effect.logDebug("Favicon capture failed.", { error, tabId, webContentsId: wc.id }),
1445 ),
1446 Effect.ensuring(
1447 Effect.sync(() => {
1448 if (activeCapture?.requestId === requestId) activeCapture = null;
1449 }),
1450 ),
1451 ),
1452 );
1453 };
1314 const failed = (1454 const failed = (
1315 _event: Event,1455 _event: Event,
1316 code: number,1456 code: number,
...@@ -1387,9 +1527,12 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -1387,9 +1527,12 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
1387 yield* Scope.addFinalizer(1527 yield* Scope.addFinalizer(
1388 scope,1528 scope,
1389 attempt({ operation: "detachListeners", tabId, webContentsId: wc.id }, () => {1529 attempt({ operation: "detachListeners", tabId, webContentsId: wc.id }, () => {
1530 cancelFaviconCapture();
1531 wc.off("did-start-navigation", navigationStarted);
1390 wc.off("did-navigate", syncNavigation);1532 wc.off("did-navigate", syncNavigation);
1391 wc.off("did-navigate-in-page", syncNavigation);1533 wc.off("did-navigate-in-page", syncInPageNavigation);
1392 wc.off("page-title-updated", sync);1534 wc.off("page-title-updated", sync);
1535 wc.off("page-favicon-updated", faviconUpdated as never);
1393 wc.off("did-start-loading", sync);1536 wc.off("did-start-loading", sync);
1394 wc.off("did-stop-loading", sync);1537 wc.off("did-stop-loading", sync);
1395 wc.off("did-fail-load", failed as never);1538 wc.off("did-fail-load", failed as never);
...@@ -1399,9 +1542,11 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -1399,9 +1542,11 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
1399 );1542 );
1400 const install = Effect.fn("PreviewManager.installWebContentsListeners")(function* () {1543 const install = Effect.fn("PreviewManager.installWebContentsListeners")(function* () {
1401 yield* attempt({ operation: "attachListeners", tabId, webContentsId: wc.id }, () => {1544 yield* attempt({ operation: "attachListeners", tabId, webContentsId: wc.id }, () => {
1545 wc.on("did-start-navigation", navigationStarted);
1402 wc.on("did-navigate", syncNavigation);1546 wc.on("did-navigate", syncNavigation);
1403 wc.on("did-navigate-in-page", syncNavigation);1547 wc.on("did-navigate-in-page", syncInPageNavigation);
1404 wc.on("page-title-updated", sync);1548 wc.on("page-title-updated", sync);
1549 wc.on("page-favicon-updated", faviconUpdated as never);
1405 wc.on("did-start-loading", sync);1550 wc.on("did-start-loading", sync);
1406 wc.on("did-stop-loading", sync);1551 wc.on("did-stop-loading", sync);
1407 wc.on("did-fail-load", failed as never);1552 wc.on("did-fail-load", failed as never);
...@@ -1418,7 +1563,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -1418,7 +1563,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
1418 });1563 });
1419 yield* Ref.update(attachedRef, (attached) =>1564 yield* Ref.update(attachedRef, (attached) =>
1420 replaceMap(attached, (copy) => {1565 replaceMap(attached, (copy) => {
1421 copy.set(wc.id, { scope });1566 copy.set(wc.id, { attachmentId, cancelFaviconCapture, scope, webContents: wc });
1422 }),1567 }),
1423 );1568 );
1424 });1569 });
...@@ -1561,6 +1706,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -1561,6 +1706,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
1561 const mainWindow = yield* Ref.get(mainWindowRef);1706 const mainWindow = yield* Ref.get(mainWindowRef);
1562 if (1707 if (
1563 !wc ||1708 !wc ||
1709 wc.isDestroyed() ||
1564 wc.getType() !== "webview" ||1710 wc.getType() !== "webview" ||
1565 (Option.isSome(mainWindow) && wc.hostWebContents !== mainWindow.value.webContents)1711 (Option.isSome(mainWindow) && wc.hostWebContents !== mainWindow.value.webContents)
1566 ) {1712 ) {
...@@ -1568,7 +1714,8 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -1568,7 +1714,8 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
1568 }1714 }
1569 const attached = yield* Ref.get(attachedRef);1715 const attached = yield* Ref.get(attachedRef);
1570 const annotationTheme = yield* Ref.get(annotationThemeRef);1716 const annotationTheme = yield* Ref.get(annotationThemeRef);
1571 if (tab.webContentsId === webContentsId && attached.has(webContentsId)) {1717 const currentAttachment = attached.get(webContentsId);
1718 if (tab.webContentsId === webContentsId && currentAttachment?.webContents === wc) {
1572 const zoomFactor = yield* attempt(1719 const zoomFactor = yield* attempt(
1573 { operation: "registerWebview.getZoomFactor", tabId, webContentsId },1720 { operation: "registerWebview.getZoomFactor", tabId, webContentsId },
1574 () => wc.getZoomFactor(),1721 () => wc.getZoomFactor(),
...@@ -1580,7 +1727,10 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -1580,7 +1727,10 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
1580 return;1727 return;
1581 }1728 }
1582 const replacedWebContentsId =1729 const replacedWebContentsId =
1583 tab.webContentsId != null && tab.webContentsId !== webContentsId ? tab.webContentsId : null;1730 tab.webContentsId != null &&
1731 (tab.webContentsId !== webContentsId || currentAttachment?.webContents !== wc)
1732 ? tab.webContentsId
1733 : null;
1584 if (replacedWebContentsId !== null) {1734 if (replacedWebContentsId !== null) {
1585 yield* Effect.all(1735 yield* Effect.all(
1586 [1736 [
...@@ -1627,8 +1777,9 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -1627,8 +1777,9 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
1627 ] as const;1777 ] as const;
1628 }1778 }
1629 const pendingUrl = current.navStatus.kind === "Loading" ? current.navStatus.url : null;1779 const pendingUrl = current.navStatus.kind === "Loading" ? current.navStatus.url : null;
1780 const { favicon: _favicon, ...currentWithoutFavicon } = current;
1630 const next: PreviewTabState = {1781 const next: PreviewTabState = {
1631 ...current,1782 ...currentWithoutFavicon,
1632 webContentsId,1783 webContentsId,
1633 navStatus: pendingUrl === null ? computeNavStatus(wc) : current.navStatus,1784 navStatus: pendingUrl === null ? computeNavStatus(wc) : current.navStatus,
1634 canGoBack: wc.navigationHistory.canGoBack(),1785 canGoBack: wc.navigationHistory.canGoBack(),
...@@ -1707,6 +1858,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -1707,6 +1858,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
1707 pictureInPicture: current?.pictureInPicture ?? false,1858 pictureInPicture: current?.pictureInPicture ?? false,
1708 colorScheme: current?.colorScheme ?? "system",1859 colorScheme: current?.colorScheme ?? "system",
1709 controller: current?.controller ?? "none",1860 controller: current?.controller ?? "none",
1861 ...(current?.favicon ? { favicon: current.favicon } : {}),
1710 updatedAt,1862 updatedAt,
1711 };1863 };
1712 return [1864 return [
...@@ -1718,17 +1870,48 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -1718,17 +1870,48 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
1718 });1870 });
1719 yield* emit(tabId, pending);1871 yield* emit(tabId, pending);
1720 if (pending.webContentsId == null) return;1872 if (pending.webContentsId == null) return;
1721 const wc = webContents.fromId(pending.webContentsId);1873 const webContentsId = pending.webContentsId;
1722 if (!wc) {1874 const wc = webContents.fromId(webContentsId);
1723 const detached = { ...pending, webContentsId: null };1875 if (!wc || wc.isDestroyed()) {
1724 yield* SynchronizedRef.update(tabsRef, (tabs) =>1876 const expectedAttachment = (yield* Ref.get(attachedRef)).get(webContentsId);
1725 tabs.get(tabId)?.webContentsId !== pending.webContentsId1877 yield* withTabLifecycleLock(
1726 ? tabs1878 tabId,
1727 : replaceMap(tabs, (copy) => {1879 Effect.gen(function* () {
1728 copy.set(tabId, detached);1880 const currentTab = (yield* SynchronizedRef.get(tabsRef)).get(tabId);
1729 }),1881 const currentAttachment = (yield* Ref.get(attachedRef)).get(webContentsId);
1882 const currentWebContents = webContents.fromId(webContentsId);
1883 if (
1884 currentTab?.webContentsId !== webContentsId ||
1885 currentAttachment !== expectedAttachment ||
1886 (currentWebContents && !currentWebContents.isDestroyed())
1887 ) {
1888 return;
1889 }
1890 yield* Effect.all(
1891 [
1892 detachControlSession(webContentsId),
1893 detachListeners(webContentsId),
1894 cancelPickElement(tabId),
1895 ],
1896 { concurrency: 3, discard: true },
1897 );
1898 const detached = yield* SynchronizedRef.modify(tabsRef, (tabs) => {
1899 const current = tabs.get(tabId);
1900 if (current?.webContentsId !== webContentsId) {
1901 return [Option.none<PreviewTabState>(), tabs] as const;
1902 }
1903 const { favicon: _favicon, ...currentWithoutFavicon } = current;
1904 const next: PreviewTabState = { ...currentWithoutFavicon, webContentsId: null };
1905 return [
1906 Option.some(next),
1907 replaceMap(tabs, (copy) => {
1908 copy.set(tabId, next);
1909 }),
1910 ] as const;
1911 });
1912 if (Option.isSome(detached)) yield* emitIfCurrent(tabId, detached.value);
1913 }),
1730 );1914 );
1731 yield* emit(tabId, detached);
1732 return;1915 return;
1733 }1916 }
1734 if (wc.getURL() === url) {1917 if (wc.getURL() === url) {
apps/web/src/browser/browserTargetResolver.test.ts+131
...@@ -180,4 +180,135 @@ describe("browser target resolver", () => {...@@ -180,4 +180,135 @@ describe("browser target resolver", () => {
180 const { resolveDiscoveredServerUrl } = await import("./browserTargetResolver");180 const { resolveDiscoveredServerUrl } = await import("./browserTargetResolver");
181 expect(resolveDiscoveredServerUrl(EnvironmentId.make("environment-1"), " ")).toBe(" ");181 expect(resolveDiscoveredServerUrl(EnvironmentId.make("environment-1"), " ")).toBe(" ");
182 });182 });
183
184 it("classifies exact private IPv4 and IPv6 boundaries", async () => {
185 const { isPrivateNetworkHost } = await import("./browserTargetResolver");
186 const privateHosts = [
187 "0.0.0.0",
188 "10.0.0.0",
189 "10.255.255.255",
190 "100.64.0.0",
191 "100.127.255.255",
192 "127.0.0.0",
193 "127.255.255.255",
194 "169.254.0.0",
195 "169.254.255.255",
196 "172.16.0.0",
197 "172.31.255.255",
198 "192.168.0.0",
199 "192.168.255.255",
200 "198.18.0.0",
201 "198.19.255.255",
202 "fc00::",
203 "fdff:ffff:ffff:ffff:ffff:ffff:ffff:ffff",
204 "fe80::",
205 "febf:ffff:ffff:ffff:ffff:ffff:ffff:ffff",
206 "::ffff:192.168.1.1",
207 "localhost.",
208 "localhost..",
209 "devbox.",
210 "devbox..",
211 "printer.local.",
212 "printer.local..",
213 "printer.home.arpa.",
214 "printer.home.arpa..",
215 "devbox.example.ts.net.",
216 "devbox.example.ts.net..",
217 ];
218 const publicHosts = [
219 "1.0.0.0",
220 "100.63.255.255",
221 "100.128.0.0",
222 "169.253.255.255",
223 "169.255.0.0",
224 "172.15.255.255",
225 "172.32.0.0",
226 "192.167.255.255",
227 "192.169.0.0",
228 "198.17.255.255",
229 "198.20.0.0",
230 "fbff:ffff::",
231 "fec0::",
232 "2001:4860:4860::8888",
233 "::ffff:8.8.8.8",
234 "example.com.",
235 ];
236 expect(privateHosts.filter((host) => !isPrivateNetworkHost(host))).toEqual([]);
237 expect(publicHosts.filter(isPrivateNetworkHost)).toEqual([]);
238 });
239
240 it("allows only globally routable hosts to reach a public favicon provider", async () => {
241 const { isPublicFaviconHost } = await import("./browserTargetResolver");
242 const nonPublic = [
243 "192.0.0.0",
244 "192.0.0.255",
245 "192.0.2.0",
246 "192.0.2.255",
247 "192.88.99.0",
248 "192.88.99.255",
249 "198.51.100.0",
250 "198.51.100.255",
251 "203.0.113.0",
252 "203.0.113.255",
253 "224.0.0.0",
254 "255.255.255.255",
255 "::2",
256 "100::",
257 "100::ffff:ffff:ffff:ffff",
258 "100:0:0:1::",
259 "100:0:0:1:ffff:ffff:ffff:ffff",
260 "64:ff9b:1::1",
261 "64:ff9b::a00:1",
262 "64:ff9b::7f00:1",
263 "64:ff9b::c0a8:101",
264 "64:ff9b::c000:201",
265 "2001:5::1",
266 "2001:2::",
267 "2001:2:0:ffff:ffff:ffff:ffff:ffff",
268 "2001:db8::",
269 "2001:db8:ffff:ffff:ffff:ffff:ffff:ffff",
270 "3fff::",
271 "3fff:fff:ffff:ffff:ffff:ffff:ffff:ffff",
272 "5f00::1",
273 "fec0::",
274 "ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff",
275 "::ffff:192.0.2.1",
276 "app.test",
277 "app.test..",
278 "printer.local..",
279 "printer.home.arpa..",
280 "devbox.example.ts.net..",
281 "127.0.0.1..",
282 "127.1..",
283 "10.1..",
284 "172.16.1..",
285 "192.168.1..",
286 "service.internal",
287 "hidden.onion",
288 ];
289 const publicHosts = [
290 "191.255.255.255",
291 "192.0.1.255",
292 "192.0.3.0",
293 "198.51.99.255",
294 "198.51.101.0",
295 "203.0.112.255",
296 "203.0.114.0",
297 "223.255.255.255",
298 "1.1.1.1",
299 "2001:4860:4860::8888",
300 "2606:4700:4700::1111",
301 "64:ff9b::808:808",
302 "2001:1::1",
303 "2001:3::1",
304 "2001:4:112::1",
305 "2001:20::1",
306 "2001:30::1",
307 "::ffff:8.8.8.8",
308 "example.com",
309 "example.com.",
310 ];
311 expect(nonPublic.filter(isPublicFaviconHost)).toEqual([]);
312 expect(publicHosts.filter((host) => !isPublicFaviconHost(host))).toEqual([]);
313 });
183});314});
apps/web/src/browser/browserTargetResolver.ts+121-13
...@@ -8,7 +8,10 @@ import { isLoopbackHost, normalizePreviewUrl } from "@t3tools/shared/preview";...@@ -8,7 +8,10 @@ import { isLoopbackHost, normalizePreviewUrl } from "@t3tools/shared/preview";
8import { readPreparedConnection } from "~/state/session";8import { readPreparedConnection } from "~/state/session";
99
10export const normalizeHostname = (host: string): string =>10export const normalizeHostname = (host: string): string =>
11 host.toLowerCase().replace(/^\[|\]$/g, "");11 host
12 .toLowerCase()
13 .replace(/^\[|\]$/g, "")
14 .replace(/\.+$/u, "");
1215
13const parseIpv4Address = (host: string): readonly number[] | null => {16const parseIpv4Address = (host: string): readonly number[] | null => {
14 const parts = normalizeHostname(host).split(".").map(Number);17 const parts = normalizeHostname(host).split(".").map(Number);
...@@ -18,28 +21,91 @@ const parseIpv4Address = (host: string): readonly number[] | null => {...@@ -18,28 +21,91 @@ const parseIpv4Address = (host: string): readonly number[] | null => {
18 : null;21 : null;
19};22};
2023
24const parseIpv4MappedIpv6Address = (host: string): readonly number[] | null => {
25 const normalized = normalizeHostname(host);
26 if (!normalized.startsWith("::ffff:")) return null;
27 const suffix = normalized.slice("::ffff:".length);
28 const dotted = parseIpv4Address(suffix);
29 if (dotted) return dotted;
30 const hextets = suffix.split(":");
31 if (hextets.length !== 2 || hextets.some((part) => !/^[\da-f]{1,4}$/u.test(part))) return null;
32 const high = Number.parseInt(hextets[0]!, 16);
33 const low = Number.parseInt(hextets[1]!, 16);
34 return [high >>> 8, high & 0xff, low >>> 8, low & 0xff];
35};
36
37const parseIpv6Address = (host: string): readonly number[] | null => {
38 const normalized = normalizeHostname(host);
39 if (!normalized.includes(":")) return null;
40 const halves = normalized.split("::");
41 if (halves.length > 2) return null;
42 const head = halves[0] ? halves[0].split(":") : [];
43 const tail = halves[1] ? halves[1].split(":") : [];
44 if ([...head, ...tail].some((part) => !/^[\da-f]{1,4}$/u.test(part))) return null;
45 const missing = 8 - head.length - tail.length;
46 if ((halves.length === 1 && missing !== 0) || (halves.length === 2 && missing < 1)) return null;
47 return [...head, ...Array.from({ length: missing }, () => "0"), ...tail].map((part) =>
48 Number.parseInt(part, 16),
49 );
50};
51
52const ipv6PrefixMatches = (
53 address: readonly number[],
54 prefix: readonly number[],
55 prefixLength: number,
56): boolean => {
57 const fullHextets = Math.floor(prefixLength / 16);
58 if (address.slice(0, fullHextets).some((part, index) => part !== prefix[index])) return false;
59 const remainingBits = prefixLength % 16;
60 if (remainingBits === 0) return true;
61 const mask = (0xffff << (16 - remainingBits)) & 0xffff;
62 return (address[fullHextets]! & mask) === (prefix[fullHextets]! & mask);
63};
64
65const isPrivateIpv4Address = (parts: readonly number[]): boolean =>
66 parts[0] === 0 ||
67 parts[0] === 10 ||
68 parts[0] === 127 ||
69 (parts[0] === 100 && parts[1]! >= 64 && parts[1]! <= 127) ||
70 (parts[0] === 172 && parts[1]! >= 16 && parts[1]! <= 31) ||
71 (parts[0] === 192 && parts[1] === 168) ||
72 (parts[0] === 169 && parts[1] === 254) ||
73 (parts[0] === 198 && parts[1]! >= 18 && parts[1]! <= 19);
74
75const isSpecialPurposeIpv4Address = (parts: readonly number[]): boolean =>
76 isPrivateIpv4Address(parts) ||
77 parts[0]! >= 224 ||
78 // Deliberately suppress the whole protocol-assignment block. IANA marks
79 // .9 and .10 globally reachable, but privacy-safe false negatives are
80 // preferable to disclosing another special-purpose address by mistake.
81 (parts[0] === 192 && parts[1] === 0 && parts[2] === 0) ||
82 (parts[0] === 192 && parts[1] === 0 && parts[2] === 2) ||
83 (parts[0] === 192 && parts[1] === 88 && parts[2] === 99) ||
84 (parts[0] === 198 && parts[1] === 51 && parts[2] === 100) ||
85 (parts[0] === 203 && parts[1] === 0 && parts[2] === 113);
86
21export const isLocalLoopbackHost = (host: string): boolean => {87export const isLocalLoopbackHost = (host: string): boolean => {
22 const normalized = normalizeHostname(host);88 const normalized = normalizeHostname(host);
23 if (normalized === "localhost" || normalized === "::1") return true;89 if (normalized === "localhost" || normalized === "::1") return true;
24 return parseIpv4Address(normalized)?.[0] === 127;90 return parseIpv4Address(normalized)?.[0] === 127;
25};91};
2692
27const isPrivateNetworkHost = (host: string): boolean => {93export const isPrivateNetworkHost = (host: string): boolean => {
28 const normalized = normalizeHostname(host);94 const normalized = normalizeHostname(host);
29 if (isLocalLoopbackHost(normalized) || normalized.endsWith(".local")) {95 if (
96 normalized === "::" ||
97 isLocalLoopbackHost(normalized) ||
98 normalized.endsWith(".localhost") ||
99 normalized.endsWith(".local") ||
100 normalized === "home.arpa" ||
101 normalized.endsWith(".home.arpa") ||
102 (!normalized.includes(".") && !normalized.includes(":"))
103 ) {
30 return true;104 return true;
31 }105 }
32 if (normalized.endsWith(".ts.net")) return true;106 if (normalized.endsWith(".ts.net")) return true;
33 const parts = parseIpv4Address(normalized);107 const parts = parseIpv4Address(normalized) ?? parseIpv4MappedIpv6Address(normalized);
34 if (parts) {108 if (parts) return isPrivateIpv4Address(parts);
35 return (
36 parts[0] === 10 ||
37 (parts[0] === 100 && parts[1]! >= 64 && parts[1]! <= 127) ||
38 (parts[0] === 172 && parts[1]! >= 16 && parts[1]! <= 31) ||
39 (parts[0] === 192 && parts[1] === 168) ||
40 (parts[0] === 169 && parts[1] === 254)
41 );
42 }
43 const firstIpv6Token = normalized.split(":", 1)[0] ?? "";109 const firstIpv6Token = normalized.split(":", 1)[0] ?? "";
44 if (!normalized.includes(":") || !/^[\da-f]{1,4}$/u.test(firstIpv6Token)) return false;110 if (!normalized.includes(":") || !/^[\da-f]{1,4}$/u.test(firstIpv6Token)) return false;
45 const firstIpv6Hextet = Number.parseInt(firstIpv6Token, 16);111 const firstIpv6Hextet = Number.parseInt(firstIpv6Token, 16);
...@@ -49,6 +115,48 @@ const isPrivateNetworkHost = (host: string): boolean => {...@@ -49,6 +115,48 @@ const isPrivateNetworkHost = (host: string): boolean => {
49 );115 );
50};116};
51117
118/** Whether a hostname is eligible to be disclosed to a public favicon provider. */
119export const isPublicFaviconHost = (host: string): boolean => {
120 // A single trailing dot is a valid absolute DNS name. Repeated trailing
121 // dots are malformed and can conceal legacy numeric forms such as 127.1.
122 if (host.endsWith("..")) return false;
123 const normalized = normalizeHostname(host);
124 if (isPrivateNetworkHost(normalized)) return false;
125 if (
126 [".alt", ".example", ".internal", ".invalid", ".onion", ".test"].some(
127 (suffix) => normalized === suffix.slice(1) || normalized.endsWith(suffix),
128 )
129 ) {
130 return false;
131 }
132 const ipv4 = parseIpv4Address(normalized) ?? parseIpv4MappedIpv6Address(normalized);
133 if (ipv4) return !isSpecialPurposeIpv4Address(ipv4);
134 if (!normalized.includes(":")) return true;
135 const ipv6 = parseIpv6Address(normalized);
136 if (!ipv6) return false;
137 if (ipv6PrefixMatches(ipv6, [0x0064, 0xff9b, 0, 0, 0, 0, 0, 0], 96)) {
138 const embeddedIpv4 = [ipv6[6]! >>> 8, ipv6[6]! & 0xff, ipv6[7]! >>> 8, ipv6[7]! & 0xff];
139 return !isSpecialPurposeIpv4Address(embeddedIpv4);
140 }
141 const first = ipv6[0]!;
142 if ((first & 0xe000) !== 0x2000) return false;
143 if (ipv6PrefixMatches(ipv6, [0x2001, 0, 0, 0, 0, 0, 0, 0], 23)) {
144 const publicProtocolAssignment =
145 (ipv6[1] === 1 &&
146 ipv6.slice(2, 7).every((part) => part === 0) &&
147 [1, 2, 3].includes(ipv6[7]!)) ||
148 ipv6PrefixMatches(ipv6, [0x2001, 3, 0, 0, 0, 0, 0, 0], 32) ||
149 ipv6PrefixMatches(ipv6, [0x2001, 4, 0x0112, 0, 0, 0, 0, 0], 48) ||
150 ipv6PrefixMatches(ipv6, [0x2001, 0x20, 0, 0, 0, 0, 0, 0], 28) ||
151 ipv6PrefixMatches(ipv6, [0x2001, 0x30, 0, 0, 0, 0, 0, 0], 28);
152 return publicProtocolAssignment;
153 }
154 if (ipv6PrefixMatches(ipv6, [0x2001, 0x0db8, 0, 0, 0, 0, 0, 0], 32)) return false;
155 if (ipv6PrefixMatches(ipv6, [0x2002, 0, 0, 0, 0, 0, 0, 0], 16)) return false;
156 if (first === 0x3fff && (ipv6[1]! & 0xf000) === 0) return false;
157 return true;
158};
159
52const readEnvironmentUrl = (environmentId: EnvironmentId): URL => {160const readEnvironmentUrl = (environmentId: EnvironmentId): URL => {
53 const connection = readPreparedConnection(environmentId);161 const connection = readPreparedConnection(environmentId);
54 if (!connection) throw new Error(`Environment ${environmentId} is not connected.`);162 if (!connection) throw new Error(`Environment ${environmentId} is not connected.`);
apps/web/src/browserFaviconLogic.test.ts created+124
...@@ -0,0 +1,124 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import {
4 BROWSER_FAVICON_MAX_ENTRIES,
5 type BrowserFaviconEntry,
6 evictExcessFavicons,
7 faviconKey,
8 faviconStorageLocation,
9 isStorableFaviconDataUrl,
10 migratePersistedBrowserFaviconState,
11} from "./browserFaviconLogic";
12
13const PNG = "data:image/png;base64,AAAA";
14
15function entry(capturedAt = 0): BrowserFaviconEntry {
16 return { dataUrl: PNG, capturedAt };
17}
18
19describe("browser favicon logic", () => {
20 it("keys valid origins canonically while keeping distinct scopes separate", () => {
21 expect(faviconKey("env:project", "http://myapp.test:3000/admin?x=1", null)).toBe(
22 "env:project http://myapp.test:3000",
23 );
24 expect(faviconKey("env:project", "http://192.168.64.2:3000/", "192.168.64.2")).toBe(
25 faviconKey("env:project", "http://localhost:3000/", "192.168.64.2"),
26 );
27 expect(faviconKey("env:project", "http://127.0.0.1:3000/", null)).toBe(
28 faviconKey("env:project", "http://0.0.0.0:3000/", null),
29 );
30 const keys = [
31 faviconKey("env:a", "http://localhost:3000/", null),
32 faviconKey("env:b", "http://localhost:3000/", null),
33 faviconKey("env:a", "http://localhost:5173/", null),
34 faviconKey("env:a", "https://localhost:3000/", null),
35 faviconKey("env:a", "http://192.168.1.50:3000/", "192.168.64.2"),
36 ];
37 expect(new Set(keys).size).toBe(keys.length);
38 expect(faviconKey("env:a", "not a url", null)).toBeNull();
39 expect(faviconKey("env:a", "ftp://example.com/", null)).toBeNull();
40 expect(faviconKey("", "http://localhost/", null)).toBeNull();
41 expect(faviconKey("env:a", "http://local:3000/", null)).not.toBe(
42 faviconKey("env:a", "http://localhost:3000/", null),
43 );
44 });
45
46 it("retains an exact environment-host alias for offline lookup", () => {
47 const expected = {
48 aliases: ["192.168.64.2"],
49 key: "env:project http://localhost:3000",
50 };
51 expect(
52 faviconStorageLocation("env:project", "http://localhost:3000/app", "192.168.64.2"),
53 ).toEqual(expected);
54 expect(
55 faviconStorageLocation("env:project", "http://192.168.64.2:3000/app", "192.168.64.2"),
56 ).toEqual(expected);
57 expect(
58 faviconStorageLocation("env:project", "https://example.com/app", "192.168.64.2"),
59 ).toEqual({ aliases: [], key: "env:project https://example.com:443" });
60 expect(faviconStorageLocation("env:project", "http://localhost:3000/app", "fd00::1")).toEqual({
61 aliases: ["fd00::1"],
62 key: "env:project http://localhost:3000",
63 });
64 expect(faviconKey("env:project", "http://[2001:4860:4860::8888]/", null)).toBe(
65 "env:project http://[2001:4860:4860::8888]:80",
66 );
67 });
68
69 it("accepts only bounded base64 PNG data", () => {
70 expect(isStorableFaviconDataUrl(PNG)).toBe(true);
71 expect(isStorableFaviconDataUrl("data:image/svg+xml;base64,AAAA")).toBe(false);
72 expect(isStorableFaviconDataUrl("data:image/png;base64,")).toBe(false);
73 expect(isStorableFaviconDataUrl("data:image/png;base64,%%%%")).toBe(false);
74 expect(isStorableFaviconDataUrl(`data:image/png;base64,${"A".repeat(8192)}`)).toBe(false);
75 });
76
77 it("evicts old entries and sanitizes hydrated state", () => {
78 const byKey = Object.fromEntries(
79 Array.from({ length: BROWSER_FAVICON_MAX_ENTRIES + 2 }, (_, index) => [
80 `key-${index}`,
81 entry(index),
82 ]),
83 );
84 const result = evictExcessFavicons(byKey);
85 expect(Object.keys(result)).toHaveLength(BROWSER_FAVICON_MAX_ENTRIES);
86 expect(result["key-0"]).toBeUndefined();
87 expect(result["key-1"]).toBeUndefined();
88 expect(
89 migratePersistedBrowserFaviconState({
90 byKey: {
91 "env:project http://local:3000": entry(4),
92 "env:project http://localhost:3000": entry(5),
93 "env:project http://localhost:3003": {
94 ...entry(6),
95 aliases: [
96 "192.168.64.2",
97 "192.168.64.2",
98 "192.168.64.3",
99 "192.168.64.4",
100 "192.168.64.5",
101 "192.168.64.6",
102 "Not Normalized",
103 "not a host/",
104 "x".repeat(5_000),
105 ],
106 },
107 "env:project http://localhost:3001": {
108 dataUrl: "https://example.com/icon.png",
109 capturedAt: 6,
110 },
111 "env:project http://localhost:3002": { dataUrl: PNG, capturedAt: Number.NaN },
112 },
113 }),
114 ).toEqual({
115 byKey: {
116 "env:project http://localhost:3000": entry(5),
117 "env:project http://localhost:3003": {
118 ...entry(6),
119 aliases: ["192.168.64.2", "192.168.64.3", "192.168.64.4", "192.168.64.5"],
120 },
121 },
122 });
123 });
124});
apps/web/src/browserFaviconLogic.ts created+201
...@@ -0,0 +1,201 @@
1import { FAVICON_CAPTURED_AT_MAX, FAVICON_DATA_URL_MAX_LENGTH } from "@t3tools/contracts";
2
3import { isLocalLoopbackHost, normalizeHostname } from "./browser/browserTargetResolver";
4
5export type BrowserFaviconEntry = {
6 dataUrl: string;
7 capturedAt: number;
8 aliases?: ReadonlyArray<string>;
9};
10
11export const BROWSER_FAVICON_MAX_ENTRIES = 40;
12export const BROWSER_FAVICON_MAX_KEY_LENGTH = 4_096;
13const BROWSER_FAVICON_MAX_FUTURE_SKEW_MS = 5 * 60 * 1_000;
14export const BROWSER_FAVICON_MAX_ALIASES_PER_ENTRY = 4;
15const BROWSER_FAVICON_MAX_ALIAS_LENGTH = 255;
16
17function formatFaviconHost(host: string): string {
18 return host.includes(":") ? `[${host}]` : host;
19}
20
21export function canCanonicalizeFaviconWithoutEnvironment(url: string): boolean {
22 try {
23 const parsed = new URL(url);
24 const host = normalizeHostname(parsed.hostname);
25 return (
26 (parsed.protocol === "http:" || parsed.protocol === "https:") &&
27 (isLocalLoopbackHost(host) || host === "0.0.0.0")
28 );
29 } catch {
30 return false;
31 }
32}
33
34export function isValidFaviconCapturedAt(value: unknown): value is number {
35 return (
36 typeof value === "number" &&
37 Number.isFinite(value) &&
38 value >= 0 &&
39 value <= FAVICON_CAPTURED_AT_MAX &&
40 value <= Date.now() + BROWSER_FAVICON_MAX_FUTURE_SKEW_MS
41 );
42}
43
44function migratePersistedFaviconKey(key: string): string | null {
45 if (key.length === 0 || key.length > BROWSER_FAVICON_MAX_KEY_LENGTH) return null;
46 const separator = key.indexOf(" ");
47 if (separator <= 0) return null;
48 const scope = key.slice(0, separator);
49 const origin = key.slice(separator + 1);
50 try {
51 const parsed = new URL(origin);
52 if (
53 (parsed.protocol !== "http:" && parsed.protocol !== "https:") ||
54 parsed.pathname !== "/" ||
55 parsed.search !== "" ||
56 parsed.hash !== "" ||
57 parsed.username !== "" ||
58 parsed.password !== ""
59 )
60 return null;
61 if (normalizeHostname(parsed.hostname) !== "local") return key;
62 const port = parsed.port || (parsed.protocol === "https:" ? "443" : "80");
63 return `${scope} ${parsed.protocol}//localhost:${port}`;
64 } catch {
65 return null;
66 }
67}
68
69function persistedFaviconAlias(value: unknown): string | null {
70 if (typeof value !== "string" || value.length > BROWSER_FAVICON_MAX_ALIAS_LENGTH) return null;
71 const normalized = normalizeHostname(value);
72 if (!normalized || normalized !== value) return null;
73 try {
74 const parsed = new URL(`http://${formatFaviconHost(normalized)}`);
75 return normalizeHostname(parsed.hostname) === normalized ? normalized : null;
76 } catch {
77 return null;
78 }
79}
80
81export function faviconKey(
82 projectRefKey: string,
83 url: string,
84 environmentHostname: string | null,
85): string | null {
86 if (projectRefKey.length === 0) return null;
87 try {
88 const parsed = new URL(url);
89 if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return null;
90 const host = normalizeHostname(parsed.hostname);
91 const canonicalHost =
92 isLocalLoopbackHost(host) ||
93 host === "0.0.0.0" ||
94 (environmentHostname !== null && host === normalizeHostname(environmentHostname))
95 ? "localhost"
96 : host;
97 const port = parsed.port || (parsed.protocol === "https:" ? "443" : "80");
98 return `${projectRefKey} ${parsed.protocol}//${formatFaviconHost(canonicalHost)}:${port}`;
99 } catch {
100 return null;
101 }
102}
103
104export function faviconStorageLocation(
105 projectRefKey: string,
106 url: string,
107 environmentHostname: string | null,
108): { readonly aliases: ReadonlyArray<string>; readonly key: string } | null {
109 const canonicalKey = faviconKey(projectRefKey, url, environmentHostname);
110 if (!canonicalKey) return null;
111 if (environmentHostname === null) return { aliases: [], key: canonicalKey };
112 try {
113 const parsed = new URL(url);
114 const host = normalizeHostname(parsed.hostname);
115 const normalizedEnvironmentHostname = normalizeHostname(environmentHostname);
116 if (
117 normalizedEnvironmentHostname.length === 0 ||
118 (!isLocalLoopbackHost(host) && host !== "0.0.0.0" && host !== normalizedEnvironmentHostname)
119 ) {
120 return { aliases: [], key: canonicalKey };
121 }
122 return {
123 aliases:
124 isLocalLoopbackHost(normalizedEnvironmentHostname) ||
125 normalizedEnvironmentHostname === "0.0.0.0"
126 ? []
127 : [normalizedEnvironmentHostname],
128 key: canonicalKey,
129 };
130 } catch {
131 return { aliases: [], key: canonicalKey };
132 }
133}
134
135export function isStorableFaviconDataUrl(value: unknown): value is string {
136 if (
137 typeof value !== "string" ||
138 !value.startsWith("data:image/png;base64,") ||
139 value.length > FAVICON_DATA_URL_MAX_LENGTH
140 ) {
141 return false;
142 }
143 const payload = value.slice("data:image/png;base64,".length);
144 return (
145 payload.length > 0 &&
146 payload.length % 4 !== 1 &&
147 !/[^a-z0-9+/=]/i.test(payload) &&
148 /^[a-z0-9+/]*={0,2}$/i.test(payload)
149 );
150}
151
152export function evictExcessFavicons(
153 byKey: Record<string, BrowserFaviconEntry>,
154): Record<string, BrowserFaviconEntry> {
155 const keys = Object.keys(byKey);
156 if (keys.length <= BROWSER_FAVICON_MAX_ENTRIES) return byKey;
157 return Object.fromEntries(
158 keys
159 .toSorted((left, right) => (byKey[right]?.capturedAt ?? 0) - (byKey[left]?.capturedAt ?? 0))
160 .slice(0, BROWSER_FAVICON_MAX_ENTRIES)
161 .map((key) => [key, byKey[key] as BrowserFaviconEntry]),
162 );
163}
164
165export function migratePersistedBrowserFaviconState(persistedState: unknown): {
166 byKey: Record<string, BrowserFaviconEntry>;
167} {
168 if (!persistedState || typeof persistedState !== "object") return { byKey: {} };
169 const raw = "byKey" in persistedState ? (persistedState as { byKey?: unknown }).byKey : null;
170 if (!raw || typeof raw !== "object" || Array.isArray(raw)) return { byKey: {} };
171 const byKey: Record<string, BrowserFaviconEntry> = {};
172 for (const [key, value] of Object.entries(raw as Record<string, unknown>)) {
173 const migratedKey = migratePersistedFaviconKey(key);
174 if (!migratedKey) continue;
175 if (!value || typeof value !== "object") continue;
176 const { dataUrl, capturedAt } = value as Record<string, unknown>;
177 if (!isStorableFaviconDataUrl(dataUrl)) continue;
178 if (!isValidFaviconCapturedAt(capturedAt)) continue;
179 const rawAliases = (value as Record<string, unknown>).aliases;
180 const aliases = Array.isArray(rawAliases)
181 ? [...new Set(rawAliases.map(persistedFaviconAlias).filter((alias) => alias !== null))].slice(
182 0,
183 BROWSER_FAVICON_MAX_ALIASES_PER_ENTRY,
184 )
185 : [];
186 const existing = byKey[migratedKey];
187 const newest = !existing || capturedAt > existing.capturedAt;
188 const mergedAliases = [
189 ...new Set([
190 ...(newest ? aliases : (existing?.aliases ?? [])),
191 ...(newest ? (existing?.aliases ?? []) : aliases),
192 ]),
193 ].slice(0, BROWSER_FAVICON_MAX_ALIASES_PER_ENTRY);
194 byKey[migratedKey] = {
195 dataUrl: newest ? dataUrl : existing.dataUrl,
196 capturedAt: newest ? capturedAt : existing.capturedAt,
197 ...(mergedAliases.length > 0 ? { aliases: mergedAliases } : {}),
198 };
199 }
200 return { byKey: evictExcessFavicons(byKey) };
201}
apps/web/src/browserFaviconStore.test.ts created+316
...@@ -0,0 +1,316 @@
1import { scopeProjectRef } from "@t3tools/client-runtime/environment";
2import { EnvironmentId, ProjectId, ThreadId } from "@t3tools/contracts";
3import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
4
5vi.mock("~/state/entities", () => ({ useThreadShell: () => null }));
6vi.mock("~/state/session", () => ({ usePreparedConnection: () => ({ _tag: "None" }) }));
7
8import {
9 flushPendingFaviconsForThread,
10 lookupFavicon,
11 mergeBrowserFaviconState,
12 recordFaviconForProject,
13 recordFaviconForThread,
14 registerFaviconProjectForThread,
15 resetBrowserFaviconsForTests,
16 resolveBrowserFaviconStorage,
17 useBrowserFaviconStore,
18} from "./browserFaviconStore";
19import {
20 BROWSER_FAVICON_MAX_ENTRIES,
21 migratePersistedBrowserFaviconState,
22} from "./browserFaviconLogic";
23
24const environmentId = EnvironmentId.make("env-1");
25const projectRef = scopeProjectRef(environmentId, ProjectId.make("project-1"));
26const threadRef = { environmentId, threadId: ThreadId.make("thread-1") };
27const PNG = "data:image/png;base64,AAAA";
28const favicon = (pageUrl: string, capturedAt: number, dataUrl = PNG) => ({
29 pageUrl,
30 capturedAt,
31 dataUrl,
32});
33
34describe("browser favicon store", () => {
35 beforeEach(resetBrowserFaviconsForTests);
36 afterEach(() => {
37 vi.unstubAllGlobals();
38 vi.restoreAllMocks();
39 });
40
41 it("keeps the newest capture for an origin and permits an identical later revisit", () => {
42 const recordFavicon = vi.spyOn(useBrowserFaviconStore.getState(), "recordFavicon");
43 recordFaviconForProject(projectRef, favicon("http://localhost:3000/", 20), null);
44 recordFaviconForProject(
45 projectRef,
46 favicon("http://localhost:3000/old", 10, "data:image/png;base64,QkJCQg=="),
47 null,
48 );
49 recordFaviconForProject(projectRef, favicon("http://localhost:3000/new", 30), null);
50 recordFaviconForProject(projectRef, favicon("http://localhost:3000/new", 30), null);
51 expect(Object.values(useBrowserFaviconStore.getState().byKey)).toEqual([
52 { dataUrl: PNG, capturedAt: 30 },
53 ]);
54 expect(recordFavicon).toHaveBeenCalledTimes(2);
55 });
56
57 it("does not share localhost icons across environments or physical projects", () => {
58 const otherEnvironment = scopeProjectRef(
59 EnvironmentId.make("env-2"),
60 ProjectId.make("project-1"),
61 );
62 const otherProject = scopeProjectRef(environmentId, ProjectId.make("project-2"));
63 recordFaviconForProject(projectRef, favicon("http://localhost:3000/", 1), null);
64 recordFaviconForProject(otherEnvironment, favicon("http://localhost:3000/", 2), null);
65 recordFaviconForProject(otherProject, favicon("http://localhost:3000/", 3), null);
66 expect(Object.keys(useBrowserFaviconStore.getState().byKey)).toEqual([
67 "env-1:project-1 http://localhost:3000",
68 "env-2:project-1 http://localhost:3000",
69 "env-1:project-2 http://localhost:3000",
70 ]);
71 });
72
73 it("finds a persisted environment icon after shell hydration without a live connection", () => {
74 recordFaviconForProject(projectRef, favicon("http://192.168.64.2:3000/app", 5), "192.168.64.2");
75 const byKey = useBrowserFaviconStore.getState().byKey;
76 expect(lookupFavicon(byKey, null, "http://localhost:3000/app", null)).toBeNull();
77 expect(lookupFavicon(byKey, projectRef, "http://localhost:3000/app", null)).toBe(PNG);
78 expect(lookupFavicon(byKey, projectRef, "http://192.168.64.2:3000/app", null)).toBe(PNG);
79 expect(lookupFavicon(byKey, projectRef, "http://192.168.64.3:3000/app", null)).toBeNull();
80 expect(lookupFavicon(byKey, projectRef, "https://192.168.64.2:3000/app", null)).toBeNull();
81 expect(lookupFavicon(byKey, projectRef, "http://192.168.64.2:3001/app", null)).toBeNull();
82 expect(
83 lookupFavicon(
84 byKey,
85 scopeProjectRef(environmentId, ProjectId.make("project-2")),
86 "http://192.168.64.2:3000/app",
87 null,
88 ),
89 ).toBeNull();
90 });
91
92 it("finds a persisted IPv6 environment icon without a live connection", () => {
93 recordFaviconForProject(projectRef, favicon("http://[fd00::1]:3000/app", 5), "fd00::1");
94 const migrated = migratePersistedBrowserFaviconState({
95 byKey: useBrowserFaviconStore.getState().byKey,
96 }).byKey;
97 expect(lookupFavicon(migrated, projectRef, "http://[fd00::1]:3000/app", null)).toBe(PNG);
98 });
99
100 it("keeps exact host aliases attached to the newest canonical icon", () => {
101 const olderPng = "data:image/png;base64,QkJCQg==";
102 const newerPng = "data:image/png;base64,Q0NDQw==";
103 recordFaviconForProject(
104 projectRef,
105 favicon("http://192.168.64.2:3000/", 10, olderPng),
106 "192.168.64.2",
107 );
108 recordFaviconForProject(
109 projectRef,
110 favicon("http://192.168.64.3:3000/", 20, newerPng),
111 "192.168.64.3",
112 );
113 recordFaviconForProject(
114 projectRef,
115 favicon("http://192.168.64.4:3000/", 15, olderPng),
116 "192.168.64.4",
117 );
118 const byKey = useBrowserFaviconStore.getState().byKey;
119 for (const host of ["192.168.64.2", "192.168.64.3", "192.168.64.4"]) {
120 expect(lookupFavicon(byKey, projectRef, `http://${host}:3000/`, null)).toBe(newerPng);
121 }
122 expect(byKey["env-1:project-1 http://localhost:3000"]?.capturedAt).toBe(20);
123 });
124
125 it("evicts an icon and its exact host aliases atomically", () => {
126 recordFaviconForProject(projectRef, favicon("http://192.168.64.2:3000/", 100), "192.168.64.2");
127 for (let index = 1; index < BROWSER_FAVICON_MAX_ENTRIES; index += 1) {
128 recordFaviconForProject(
129 projectRef,
130 favicon(`https://example-${index}.com/`, 100 + index),
131 null,
132 );
133 }
134 expect(
135 lookupFavicon(
136 useBrowserFaviconStore.getState().byKey,
137 projectRef,
138 "http://192.168.64.2:3000/",
139 null,
140 ),
141 ).toBe(PNG);
142
143 recordFaviconForProject(projectRef, favicon("https://evicts-oldest.example/", 1_000), null);
144 expect(
145 lookupFavicon(
146 useBrowserFaviconStore.getState().byKey,
147 projectRef,
148 "http://192.168.64.2:3000/",
149 null,
150 ),
151 ).toBeNull();
152 });
153
154 it("retains multiple origins until project and connection metadata hydrate", () => {
155 expect(
156 recordFaviconForThread(threadRef, favicon("http://localhost:3000/", 1), null, undefined),
157 ).toBe(false);
158 expect(
159 recordFaviconForThread(threadRef, favicon("http://localhost:5173/", 2), null, undefined),
160 ).toBe(false);
161 expect(
162 Object.keys(Object.values(useBrowserFaviconStore.getState().pendingByThreadKey)[0] ?? {}),
163 ).toHaveLength(2);
164
165 expect(flushPendingFaviconsForThread(threadRef, projectRef, "192.168.64.2")).toBe(true);
166 expect(Object.keys(useBrowserFaviconStore.getState().byKey).toSorted()).toEqual([
167 "env-1:project-1 http://localhost:3000",
168 "env-1:project-1 http://localhost:5173",
169 ]);
170 expect(useBrowserFaviconStore.getState().pendingByThreadKey).toEqual({});
171 });
172
173 it("persists unambiguous loopback captures while the environment is offline", () => {
174 expect(
175 recordFaviconForThread(
176 threadRef,
177 favicon("http://localhost:3000/", 1),
178 projectRef,
179 undefined,
180 ),
181 ).toBe(true);
182 expect(useBrowserFaviconStore.getState().byKey).toEqual({
183 "env-1:project-1 http://localhost:3000": { dataUrl: PNG, capturedAt: 1 },
184 });
185
186 recordFaviconForThread(
187 threadRef,
188 favicon("http://192.168.64.2:5173/", 2),
189 projectRef,
190 undefined,
191 );
192 expect(flushPendingFaviconsForThread(threadRef, projectRef, undefined)).toBe(false);
193 expect(Object.values(useBrowserFaviconStore.getState().pendingByThreadKey)[0]).toBeDefined();
194 });
195
196 it("keeps pending captures in store-owned state independent of bridge lifetime", () => {
197 recordFaviconForThread(threadRef, favicon("http://localhost:3000/", 10), null, undefined);
198 const pendingAfterUnmount = useBrowserFaviconStore.getState().pendingByThreadKey;
199 useBrowserFaviconStore.setState({ pendingByThreadKey: pendingAfterUnmount });
200 flushPendingFaviconsForThread(threadRef, projectRef, "localhost");
201 expect(useBrowserFaviconStore.getState().byKey).toEqual({
202 "env-1:project-1 http://localhost:3000": { dataUrl: PNG, capturedAt: 10 },
203 });
204 });
205
206 it("flushes and resolves a pending draft-thread favicon after physical project registration", () => {
207 recordFaviconForThread(threadRef, favicon("http://localhost:8025/", 10), null, undefined);
208 registerFaviconProjectForThread(threadRef, projectRef);
209 const registered = useBrowserFaviconStore.getState().projectRefByThreadKey["env-1:thread-1"];
210 expect(registered).toEqual(projectRef);
211 expect(flushPendingFaviconsForThread(threadRef, registered!, undefined)).toBe(true);
212 expect(
213 lookupFavicon(
214 useBrowserFaviconStore.getState().byKey,
215 registered!,
216 "http://localhost:8025/",
217 null,
218 ),
219 ).toBe(PNG);
220 });
221
222 it("bounds pending memory by origin and thread", () => {
223 for (let thread = 0; thread < 22; thread += 1) {
224 for (let port = 3000; port < 3012; port += 1) {
225 recordFaviconForThread(
226 { environmentId, threadId: ThreadId.make(`thread-${thread}`) },
227 favicon(`http://localhost:${port}/`, port),
228 null,
229 undefined,
230 );
231 }
232 }
233 const pending = useBrowserFaviconStore.getState().pendingByThreadKey;
234 expect(Object.keys(pending)).toHaveLength(20);
235 expect(Object.values(pending).every((byOrigin) => Object.keys(byOrigin).length === 10)).toBe(
236 true,
237 );
238 });
239
240 it("sanitizes hydrated state while preserving actions and transient pending data", () => {
241 recordFaviconForThread(threadRef, favicon("http://localhost:3000/", 1), null, undefined);
242 const current = useBrowserFaviconStore.getState();
243 const merged = mergeBrowserFaviconState(
244 {
245 byKey: {
246 "env-1:project-1 http://localhost:3000": { dataUrl: PNG, capturedAt: 2 },
247 "env-1:project-1 http://localhost:3001": { dataUrl: "bad", capturedAt: 3 },
248 "env-1:project-1 http://localhost:3002": { dataUrl: PNG, capturedAt: 1e308 },
249 ["x".repeat(5_000)]: { dataUrl: PNG, capturedAt: 4 },
250 },
251 },
252 current,
253 );
254 expect(merged.byKey).toEqual({
255 "env-1:project-1 http://localhost:3000": { dataUrl: PNG, capturedAt: 2 },
256 });
257 expect(merged.pendingByThreadKey).toEqual(current.pendingByThreadKey);
258 expect(typeof merged.recordFavicon).toBe("function");
259 });
260
261 it("falls back to memory when localStorage access throws", () => {
262 vi.stubGlobal(
263 "window",
264 Object.defineProperty({}, "localStorage", {
265 get: () => {
266 throw new Error("storage blocked");
267 },
268 }),
269 );
270 const storage = resolveBrowserFaviconStorage();
271 storage.setItem("key", "value");
272 expect(storage.getItem("key")).toBe("value");
273 });
274
275 it("falls back to memory when localStorage operations throw", () => {
276 vi.stubGlobal("window", {
277 localStorage: {
278 getItem: vi.fn(() => {
279 throw new Error("read blocked");
280 }),
281 setItem: vi.fn(() => {
282 throw new Error("quota exceeded");
283 }),
284 removeItem: vi.fn(() => {
285 throw new Error("remove blocked");
286 }),
287 },
288 });
289 const storage = resolveBrowserFaviconStorage();
290
291 storage.setItem("key", "value");
292 expect(storage.getItem("key")).toBe("value");
293 storage.removeItem("key");
294 expect(storage.getItem("key")).toBeNull();
295 });
296
297 it("keeps the memory shadow authoritative after an asymmetric primary write failure", () => {
298 vi.stubGlobal("window", {
299 localStorage: {
300 getItem: vi.fn(() => "stale"),
301 setItem: vi.fn(() => {
302 throw new Error("quota exceeded");
303 }),
304 removeItem: vi.fn(() => {
305 throw new Error("remove blocked");
306 }),
307 },
308 });
309 const storage = resolveBrowserFaviconStorage();
310
311 storage.setItem("key", "fresh");
312 expect(storage.getItem("key")).toBe("fresh");
313 storage.removeItem("key");
314 expect(storage.getItem("key")).toBeNull();
315 });
316});
apps/web/src/browserFaviconStore.ts created+342
...@@ -0,0 +1,342 @@
1import {
2 scopedProjectKey,
3 scopedThreadKey,
4 scopeProjectRef,
5} from "@t3tools/client-runtime/environment";
6import type { DesktopPreviewFavicon, ScopedProjectRef, ScopedThreadRef } from "@t3tools/contracts";
7import * as Option from "effect/Option";
8import { useMemo } from "react";
9import { create } from "zustand";
10import { createJSONStorage, persist } from "zustand/middleware";
11
12import { normalizeHostname } from "~/browser/browserTargetResolver";
13import { useThreadShell } from "~/state/entities";
14import { usePreparedConnection } from "~/state/session";
15
16import {
17 BROWSER_FAVICON_MAX_ALIASES_PER_ENTRY,
18 type BrowserFaviconEntry,
19 canCanonicalizeFaviconWithoutEnvironment,
20 evictExcessFavicons,
21 faviconKey,
22 faviconStorageLocation,
23 isStorableFaviconDataUrl,
24 isValidFaviconCapturedAt,
25 migratePersistedBrowserFaviconState,
26} from "./browserFaviconLogic";
27import { createMemoryStorage, type StateStorage } from "./lib/storage";
28
29const BROWSER_FAVICON_STORAGE_KEY = "t3code:browser-favicons:v1";
30const MAX_PENDING_ORIGINS_PER_THREAD = 10;
31const MAX_PENDING_THREADS = 20;
32const MAX_REGISTERED_THREADS = 100;
33
34type PendingFavicon = DesktopPreviewFavicon;
35type PendingFaviconsByOrigin = Record<string, PendingFavicon>;
36
37export interface BrowserFaviconStoreState {
38 byKey: Record<string, BrowserFaviconEntry>;
39 /** Capture buffering only. */
40 pendingByThreadKey: Record<string, PendingFaviconsByOrigin>;
41 /** Non-persisted fallback for draft/background threads without a hydrated shell. */
42 projectRefByThreadKey: Record<string, ScopedProjectRef>;
43 recordFavicon: (
44 key: string,
45 dataUrl: string,
46 capturedAt: number,
47 aliases?: ReadonlyArray<string>,
48 ) => void;
49}
50
51function pendingOriginKey(pageUrl: string): string | null {
52 return faviconKey("pending", pageUrl, null)?.slice("pending ".length) ?? null;
53}
54
55function addPendingFavicon(
56 pendingByThreadKey: Record<string, PendingFaviconsByOrigin>,
57 threadKey: string,
58 favicon: PendingFavicon,
59): Record<string, PendingFaviconsByOrigin> {
60 const originKey = pendingOriginKey(favicon.pageUrl);
61 if (!originKey) return pendingByThreadKey;
62 const current = pendingByThreadKey[threadKey] ?? {};
63 const existing = current[originKey];
64 if (existing && existing.capturedAt >= favicon.capturedAt) return pendingByThreadKey;
65 const nextForThread = {
66 ...current,
67 [originKey]: favicon,
68 };
69 const boundedForThread = Object.fromEntries(
70 Object.entries(nextForThread)
71 .toSorted(([, left], [, right]) => right.capturedAt - left.capturedAt)
72 .slice(0, MAX_PENDING_ORIGINS_PER_THREAD),
73 );
74
75 const withoutThread = { ...pendingByThreadKey };
76 delete withoutThread[threadKey];
77 return Object.fromEntries(
78 [...Object.entries(withoutThread), [threadKey, boundedForThread]].slice(-MAX_PENDING_THREADS),
79 );
80}
81
82export function resolveBrowserFaviconStorage(): StateStorage {
83 const fallback = createMemoryStorage();
84 const shadowedNames = new Set<string>();
85 let primary: Storage;
86 try {
87 if (typeof window === "undefined") return fallback;
88 primary = window.localStorage;
89 } catch {
90 return fallback;
91 }
92 return {
93 getItem: (name) => {
94 if (shadowedNames.has(name)) return fallback.getItem(name);
95 try {
96 return primary.getItem(name);
97 } catch {
98 return fallback.getItem(name);
99 }
100 },
101 setItem: (name, value) => {
102 fallback.setItem(name, value);
103 try {
104 primary.setItem(name, value);
105 shadowedNames.delete(name);
106 } catch {
107 shadowedNames.add(name);
108 }
109 },
110 removeItem: (name) => {
111 fallback.removeItem(name);
112 try {
113 primary.removeItem(name);
114 shadowedNames.delete(name);
115 } catch {
116 shadowedNames.add(name);
117 }
118 },
119 };
120}
121
122export const useBrowserFaviconStore = create<BrowserFaviconStoreState>()(
123 persist(
124 (set) => ({
125 byKey: {},
126 pendingByThreadKey: {},
127 projectRefByThreadKey: {},
128 recordFavicon: (key, dataUrl, capturedAt, aliases = []) =>
129 set((state) => {
130 if (!isStorableFaviconDataUrl(dataUrl)) return state;
131 if (!isValidFaviconCapturedAt(capturedAt)) return state;
132 const existing = state.byKey[key];
133 const aliasCandidates =
134 capturedAt > (existing?.capturedAt ?? -1)
135 ? [...aliases, ...(existing?.aliases ?? [])]
136 : [...(existing?.aliases ?? []), ...aliases];
137 const nextAliases = [...new Set(aliasCandidates)].slice(
138 0,
139 BROWSER_FAVICON_MAX_ALIASES_PER_ENTRY,
140 );
141 const aliasesUnchanged =
142 nextAliases.length === (existing?.aliases?.length ?? 0) &&
143 nextAliases.every((alias, index) => alias === existing?.aliases?.[index]);
144 if (existing && capturedAt <= existing.capturedAt && aliasesUnchanged) return state;
145 return {
146 byKey: evictExcessFavicons({
147 ...state.byKey,
148 [key]: {
149 dataUrl: capturedAt > (existing?.capturedAt ?? -1) ? dataUrl : existing!.dataUrl,
150 capturedAt: Math.max(capturedAt, existing?.capturedAt ?? -1),
151 ...(nextAliases.length > 0 ? { aliases: nextAliases } : {}),
152 },
153 }),
154 };
155 }),
156 }),
157 {
158 name: BROWSER_FAVICON_STORAGE_KEY,
159 version: 1,
160 storage: createJSONStorage(resolveBrowserFaviconStorage),
161 partialize: (state) => ({ byKey: state.byKey }),
162 migrate: migratePersistedBrowserFaviconState,
163 merge: mergeBrowserFaviconState,
164 },
165 ),
166);
167
168export function mergeBrowserFaviconState(
169 persistedState: unknown,
170 currentState: BrowserFaviconStoreState,
171): BrowserFaviconStoreState {
172 return {
173 ...currentState,
174 ...migratePersistedBrowserFaviconState(persistedState),
175 };
176}
177
178export function registerFaviconProjectForThread(
179 threadRef: ScopedThreadRef,
180 projectRef: ScopedProjectRef,
181): void {
182 const threadKey = scopedThreadKey(threadRef);
183 const state = useBrowserFaviconStore.getState();
184 const current = state.projectRefByThreadKey[threadKey];
185 if (
186 current?.environmentId === projectRef.environmentId &&
187 current.projectId === projectRef.projectId
188 ) {
189 return;
190 }
191 useBrowserFaviconStore.setState({
192 projectRefByThreadKey: Object.fromEntries(
193 [
194 ...Object.entries(state.projectRefByThreadKey).filter(([key]) => key !== threadKey),
195 [threadKey, projectRef],
196 ].slice(-MAX_REGISTERED_THREADS),
197 ),
198 });
199}
200
201export function useFaviconProjectRefForThread(threadRef: ScopedThreadRef): ScopedProjectRef | null {
202 const shell = useThreadShell(threadRef);
203 const shellProjectId = shell?.projectId ?? null;
204 const shellProjectRef = useMemo(
205 () => (shellProjectId ? scopeProjectRef(threadRef.environmentId, shellProjectId) : null),
206 [shellProjectId, threadRef.environmentId],
207 );
208 const registered = useBrowserFaviconStore(
209 (state) => state.projectRefByThreadKey[scopedThreadKey(threadRef)] ?? null,
210 );
211 return shellProjectRef ?? registered;
212}
213
214export function recordFaviconForProject(
215 projectRef: ScopedProjectRef,
216 favicon: DesktopPreviewFavicon,
217 environmentHostname: string | null,
218): boolean {
219 if (!isStorableFaviconDataUrl(favicon.dataUrl) || !isValidFaviconCapturedAt(favicon.capturedAt)) {
220 return false;
221 }
222 const location = faviconStorageLocation(
223 scopedProjectKey(projectRef),
224 favicon.pageUrl,
225 environmentHostname,
226 );
227 if (!location) return false;
228 const state = useBrowserFaviconStore.getState();
229 const existing = state.byKey[location.key];
230 if (
231 existing &&
232 existing.capturedAt >= favicon.capturedAt &&
233 location.aliases.every((alias) => existing.aliases?.includes(alias))
234 )
235 return true;
236 state.recordFavicon(location.key, favicon.dataUrl, favicon.capturedAt, location.aliases);
237 return true;
238}
239
240export function recordFaviconForThread(
241 threadRef: ScopedThreadRef,
242 favicon: DesktopPreviewFavicon,
243 projectRef: ScopedProjectRef | null,
244 environmentHostname: string | undefined,
245): boolean {
246 if (
247 !isStorableFaviconDataUrl(favicon.dataUrl) ||
248 !isValidFaviconCapturedAt(favicon.capturedAt) ||
249 !pendingOriginKey(favicon.pageUrl)
250 )
251 return false;
252 const hostname =
253 environmentHostname !== undefined
254 ? environmentHostname
255 : canCanonicalizeFaviconWithoutEnvironment(favicon.pageUrl)
256 ? null
257 : undefined;
258 if (
259 projectRef &&
260 hostname !== undefined &&
261 recordFaviconForProject(projectRef, favicon, hostname)
262 ) {
263 return true;
264 }
265 const threadKey = scopedThreadKey(threadRef);
266 const state = useBrowserFaviconStore.getState();
267 const pendingByThreadKey = addPendingFavicon(state.pendingByThreadKey, threadKey, favicon);
268 if (pendingByThreadKey !== state.pendingByThreadKey) {
269 useBrowserFaviconStore.setState({ pendingByThreadKey });
270 }
271 return false;
272}
273
274export function flushPendingFaviconsForThread(
275 threadRef: ScopedThreadRef,
276 projectRef: ScopedProjectRef,
277 environmentHostname: string | undefined,
278): boolean {
279 const threadKey = scopedThreadKey(threadRef);
280 const pending = useBrowserFaviconStore.getState().pendingByThreadKey[threadKey];
281 if (!pending) return true;
282 const remaining = Object.fromEntries(
283 Object.entries(pending).filter(([, favicon]) => {
284 const hostname =
285 environmentHostname !== undefined
286 ? environmentHostname
287 : canCanonicalizeFaviconWithoutEnvironment(favicon.pageUrl)
288 ? null
289 : undefined;
290 return hostname === undefined || !recordFaviconForProject(projectRef, favicon, hostname);
291 }),
292 );
293 useBrowserFaviconStore.setState((state) => {
294 const pendingByThreadKey = { ...state.pendingByThreadKey };
295 if (Object.keys(remaining).length === 0) delete pendingByThreadKey[threadKey];
296 else pendingByThreadKey[threadKey] = remaining;
297 return { pendingByThreadKey };
298 });
299 return Object.keys(remaining).length === 0;
300}
301
302export function useFaviconForThreadUrl(threadRef: ScopedThreadRef, url: string): string | null {
303 const projectRef = useFaviconProjectRefForThread(threadRef);
304 const preparedConnection = usePreparedConnection(threadRef.environmentId);
305 const environmentHostname = Option.isSome(preparedConnection)
306 ? new URL(preparedConnection.value.httpBaseUrl).hostname
307 : null;
308 return useBrowserFaviconStore((state) =>
309 lookupFavicon(state.byKey, projectRef, url, environmentHostname),
310 );
311}
312
313export function lookupFavicon(
314 byKey: Record<string, BrowserFaviconEntry>,
315 projectRef: ScopedProjectRef | null,
316 url: string,
317 environmentHostname: string | null,
318): string | null {
319 const key = projectRef
320 ? faviconKey(scopedProjectKey(projectRef), url, environmentHostname)
321 : null;
322 if (!key) return null;
323 const direct = byKey[key];
324 if (direct) return direct.dataUrl;
325 try {
326 const requestedHost = normalizeHostname(new URL(url).hostname);
327 const localKey = faviconKey(scopedProjectKey(projectRef!), url, requestedHost);
328 const localEntry = localKey ? byKey[localKey] : null;
329 return localEntry?.aliases?.includes(requestedHost) ? localEntry.dataUrl : null;
330 } catch {
331 return null;
332 }
333}
334
335export function resetBrowserFaviconsForTests(): void {
336 useBrowserFaviconStore.setState({
337 byKey: {},
338 pendingByThreadKey: {},
339 projectRefByThreadKey: {},
340 });
341 useBrowserFaviconStore.persist.clearStorage();
342}
apps/web/src/components/ChatView.tsx+12-3
...@@ -179,6 +179,7 @@ import {...@@ -179,6 +179,7 @@ import {
179} from "~/projectScripts";179} from "~/projectScripts";
180import { newDraftId, newMessageId, newThreadId } from "~/lib/utils";180import { newDraftId, newMessageId, newThreadId } from "~/lib/utils";
181import { useBrowserHistoryStore } from "~/browserHistoryStore";181import { useBrowserHistoryStore } from "~/browserHistoryStore";
182import { registerFaviconProjectForThread } from "~/browserFaviconStore";
182import { getProviderModelCapabilities, resolveSelectableProvider } from "../providerModels";183import { getProviderModelCapabilities, resolveSelectableProvider } from "../providerModels";
183import { NO_PROVIDER_MODEL_SELECTION } from "../providerInstances";184import { NO_PROVIDER_MODEL_SELECTION } from "../providerInstances";
184import {185import {
...@@ -1704,9 +1705,11 @@ function ChatViewContent(props: ChatViewProps) {...@@ -1704,9 +1705,11 @@ function ChatViewContent(props: ChatViewProps) {
1704 });1705 });
1705 }, [activeThreadKey, existingOpenTerminalThreadKeys, terminalUiState.terminalOpen]);1706 }, [activeThreadKey, existingOpenTerminalThreadKeys, terminalUiState.terminalOpen]);
1706 const latestTurnSettled = isLatestTurnSettled(activeLatestTurn, activeThread?.session ?? null);1707 const latestTurnSettled = isLatestTurnSettled(activeLatestTurn, activeThread?.session ?? null);
1707 const activeProjectRef = activeThread1708 const activeProjectRef = useMemo(
1708 ? scopeProjectRef(activeThread.environmentId, activeThread.projectId)1709 () =>
1709 : null;1710 activeThread ? scopeProjectRef(activeThread.environmentId, activeThread.projectId) : null,
1711 [activeThread?.environmentId, activeThread?.projectId],
1712 );
1710 const activeProject = useProject(activeProjectRef);1713 const activeProject = useProject(activeProjectRef);
1711 const handleNewThreadInActiveProject = useCallback(() => {1714 const handleNewThreadInActiveProject = useCallback(() => {
1712 startNewThreadForProject(activeProjectRef, handleNewThread);1715 startNewThreadForProject(activeProjectRef, handleNewThread);
...@@ -1758,6 +1761,10 @@ function ChatViewContent(props: ChatViewProps) {...@@ -1758,6 +1761,10 @@ function ChatViewContent(props: ChatViewProps) {
1758 // drive the environment picker in BranchToolbar.1761 // drive the environment picker in BranchToolbar.
1759 const allProjects = useProjects();1762 const allProjects = useProjects();
1760 const primaryEnvironmentId = primaryEnvironment?.environmentId ?? null;1763 const primaryEnvironmentId = primaryEnvironment?.environmentId ?? null;
1764 useEffect(() => {
1765 if (!activeThreadRef || !activeProjectRef) return;
1766 registerFaviconProjectForThread(activeThreadRef, activeProjectRef);
1767 }, [activeProjectRef, activeThreadRef]);
1761 useEffect(() => {1768 useEffect(() => {
1762 if (!clientSettingsHydrated || !activeThreadRef || !activeProject) return;1769 if (!clientSettingsHydrated || !activeThreadRef || !activeProject) return;
1763 // Reuse the sidebar's grouping so history follows the project rows the user1770 // Reuse the sidebar's grouping so history follows the project rows the user
...@@ -6546,6 +6553,7 @@ function ChatViewContent(props: ChatViewProps) {...@@ -6546,6 +6553,7 @@ function ChatViewContent(props: ChatViewProps) {
6546 activeSurfaceId={activeRightPanelSurface?.id ?? null}6553 activeSurfaceId={activeRightPanelSurface?.id ?? null}
6547 pendingSurfaceIds={pendingFileSurfaceIds}6554 pendingSurfaceIds={pendingFileSurfaceIds}
6548 previewSessions={activePreviewState.sessions}6555 previewSessions={activePreviewState.sessions}
6556 desktopByTabId={activePreviewState.desktopByTabId}
6549 terminalLabelsById={activeTerminalLabelsById}6557 terminalLabelsById={activeTerminalLabelsById}
6550 onActivate={activateRightPanelSurface}6558 onActivate={activateRightPanelSurface}
6551 onCloseSurface={closeRightPanelSurface}6559 onCloseSurface={closeRightPanelSurface}
...@@ -6580,6 +6588,7 @@ function ChatViewContent(props: ChatViewProps) {...@@ -6580,6 +6588,7 @@ function ChatViewContent(props: ChatViewProps) {
6580 activeSurfaceId={activeRightPanelSurface?.id ?? null}6588 activeSurfaceId={activeRightPanelSurface?.id ?? null}
6581 pendingSurfaceIds={pendingFileSurfaceIds}6589 pendingSurfaceIds={pendingFileSurfaceIds}
6582 previewSessions={activePreviewState.sessions}6590 previewSessions={activePreviewState.sessions}
6591 desktopByTabId={activePreviewState.desktopByTabId}
6583 terminalLabelsById={activeTerminalLabelsById}6592 terminalLabelsById={activeTerminalLabelsById}
6584 onActivate={activateRightPanelSurface}6593 onActivate={activateRightPanelSurface}
6585 onCloseSurface={closeRightPanelSurface}6594 onCloseSurface={closeRightPanelSurface}
apps/web/src/components/RightPanelTabs.test.tsx created+115
...@@ -0,0 +1,115 @@
1import type { DesktopPreviewFavicon, PreviewSessionSnapshot } from "@t3tools/contracts";
2import { renderToStaticMarkup } from "react-dom/server";
3import { describe, expect, it } from "vite-plus/test";
4
5import { RightPanelTabs } from "./RightPanelTabs";
6
7const previewSurface = {
8 id: "browser:tab-1" as const,
9 kind: "preview" as const,
10 resourceId: "tab-1",
11};
12const secondSurface = {
13 id: "browser:tab-2" as const,
14 kind: "preview" as const,
15 resourceId: "tab-2",
16};
17const sessions: Readonly<Record<string, PreviewSessionSnapshot>> = {
18 "tab-1": {
19 threadId: "thread-1",
20 tabId: "tab-1",
21 navStatus: { _tag: "Success", url: "http://24x.xf.local/", title: "Local site" },
22 canGoBack: false,
23 canGoForward: false,
24 updatedAt: "2026-08-09T00:00:00.000Z",
25 },
26 "tab-2": {
27 threadId: "thread-1",
28 tabId: "tab-2",
29 navStatus: { _tag: "Success", url: "http://24x.xf.local/admin", title: "Admin" },
30 canGoBack: false,
31 canGoForward: false,
32 updatedAt: "2026-08-09T00:00:00.000Z",
33 },
34};
35
36const favicon = (dataUrl: string, pageUrl: string): DesktopPreviewFavicon => ({
37 dataUrl,
38 pageUrl,
39 capturedAt: 1,
40});
41
42function overlay(icon: DesktopPreviewFavicon | null) {
43 return {
44 hasWebContents: true,
45 canGoBack: false,
46 canGoForward: false,
47 loading: false,
48 zoomFactor: 1,
49 pictureInPicture: false,
50 colorScheme: "system" as const,
51 controller: "none" as const,
52 favicon: icon,
53 };
54}
55
56function renderTabs(first: DesktopPreviewFavicon | null, second?: DesktopPreviewFavicon) {
57 return renderToStaticMarkup(
58 <RightPanelTabs
59 mode="inline"
60 surfaces={second ? [previewSurface, secondSurface] : [previewSurface]}
61 activeSurfaceId={previewSurface.id}
62 pendingSurfaceIds={new Set()}
63 previewSessions={sessions}
64 desktopByTabId={{
65 "tab-1": overlay(first),
66 ...(second ? { "tab-2": overlay(second) } : {}),
67 }}
68 terminalLabelsById={new Map()}
69 onActivate={() => undefined}
70 onCloseSurface={() => undefined}
71 onCloseOtherSurfaces={() => undefined}
72 onCloseSurfacesToRight={() => undefined}
73 onCloseAllSurfaces={() => undefined}
74 onCopyFilePath={() => undefined}
75 onAddBrowser={() => undefined}
76 onAddTerminal={() => undefined}
77 onAddPullRequest={() => undefined}
78 onAddDiff={() => undefined}
79 onAddFiles={() => undefined}
80 onAddAgents={() => undefined}
81 liveAgentCount={0}
82 browserAvailable
83 terminalAvailable={false}
84 diffAvailable={false}
85 filesAvailable={false}
86 pullRequestAvailable={false}
87 agentsAvailable={false}
88 >
89 <div>content</div>
90 </RightPanelTabs>,
91 );
92}
93
94describe("RightPanelTabs preview favicon", () => {
95 it("prefers a live capture and never asks Google about a private hostname", () => {
96 const captured = renderTabs(favicon("data:image/png;base64,AAAA", "http://24x.xf.local/"));
97 expect(captured).toContain("data:image/png;base64,AAAA");
98 expect(captured).not.toContain("s2/favicons");
99 expect(renderTabs(null)).not.toContain("s2/favicons");
100 });
101
102 it("keeps route-specific captures isolated between live tabs on one origin", () => {
103 const html = renderTabs(
104 favicon("data:image/png;base64,AAAA", "http://24x.xf.local/"),
105 favicon("data:image/png;base64,BBBB", "http://24x.xf.local/admin"),
106 );
107 expect(html).toContain("data:image/png;base64,AAAA");
108 expect(html).toContain("data:image/png;base64,BBBB");
109 });
110
111 it("hides a capture while the server session still describes another origin", () => {
112 const html = renderTabs(favicon("data:image/png;base64,AAAA", "https://example.com/"));
113 expect(html).not.toContain("data:image/png;base64,AAAA");
114 });
115});
apps/web/src/components/RightPanelTabs.tsx+24-12
...@@ -22,6 +22,7 @@ import {...@@ -22,6 +22,7 @@ import {
22} from "react";22} from "react";
2323
24import { isElectron } from "~/env";24import { isElectron } from "~/env";
25import type { DesktopPreviewOverlay } from "~/previewStateStore";
25import type { RightPanelSurface } from "~/rightPanelStore";26import type { RightPanelSurface } from "~/rightPanelStore";
26import { cn } from "~/lib/utils";27import { cn } from "~/lib/utils";
27import { readLocalApi } from "~/localApi";28import { readLocalApi } from "~/localApi";
...@@ -34,6 +35,7 @@ import { useTheme } from "~/hooks/useTheme";...@@ -34,6 +35,7 @@ import { useTheme } from "~/hooks/useTheme";
34import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar";35import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar";
3536
36import { PreviewPanelShell, type PreviewPanelMode } from "./preview/PreviewPanelShell";37import { PreviewPanelShell, type PreviewPanelMode } from "./preview/PreviewPanelShell";
38import { FaviconImage } from "./preview/PreviewFaviconIcon";
37import { PierreEntryIcon } from "./chat/PierreEntryIcon";39import { PierreEntryIcon } from "./chat/PierreEntryIcon";
3840
39interface RightPanelTabsProps {41interface RightPanelTabsProps {
...@@ -48,6 +50,7 @@ interface RightPanelTabsProps {...@@ -48,6 +50,7 @@ interface RightPanelTabsProps {
48 activeSurfaceId: string | null;50 activeSurfaceId: string | null;
49 pendingSurfaceIds: ReadonlySet<string>;51 pendingSurfaceIds: ReadonlySet<string>;
50 previewSessions: Readonly<Record<string, PreviewSessionSnapshot>>;52 previewSessions: Readonly<Record<string, PreviewSessionSnapshot>>;
53 desktopByTabId: Readonly<Record<string, DesktopPreviewOverlay>>;
51 terminalLabelsById: ReadonlyMap<string, string>;54 terminalLabelsById: ReadonlyMap<string, string>;
52 onActivate: (surface: RightPanelSurface) => void;55 onActivate: (surface: RightPanelSurface) => void;
53 onCloseSurface: (surface: RightPanelSurface) => void;56 onCloseSurface: (surface: RightPanelSurface) => void;
...@@ -435,30 +438,35 @@ function surfaceTitle(...@@ -435,30 +438,35 @@ function surfaceTitle(
435 }438 }
436}439}
437440
438function PreviewFavicon({ url }: { url: string | null }) {441function PreviewFavicon({ capturedUrl, url }: { capturedUrl: string | null; url: string | null }) {
439 const faviconUrl = faviconUrlForOrigin(url, 32);442 const publicProviderUrl = faviconUrlForOrigin(url, 32);
440 const [failedUrl, setFailedUrl] = useState<string | null>(null);
441 if (!faviconUrl || failedUrl === faviconUrl) return <Globe2 className="size-3 shrink-0" />;
442 return (443 return (
443 <img444 <FaviconImage
444 src={faviconUrl}445 sources={[capturedUrl, publicProviderUrl]}
445 alt=""446 fallback={<Globe2 className="size-3 shrink-0" />}
446 aria-hidden447 className="size-3 shrink-0 rounded-sm object-contain"
447 draggable={false}
448 className="size-3 shrink-0 rounded-sm"
449 onError={() => setFailedUrl(faviconUrl)}
450 />448 />
451 );449 );
452}450}
453451
452function sameOrigin(left: string, right: string): boolean {
453 try {
454 return new URL(left).origin === new URL(right).origin;
455 } catch {
456 return false;
457 }
458}
459
454function SurfaceIcon({460function SurfaceIcon({
455 surface,461 surface,
456 sessions,462 sessions,
463 desktopByTabId,
457 theme,464 theme,
458 pullRequestStatuses,465 pullRequestStatuses,
459}: {466}: {
460 surface: RightPanelSurface;467 surface: RightPanelSurface;
461 sessions: Readonly<Record<string, PreviewSessionSnapshot>>;468 sessions: Readonly<Record<string, PreviewSessionSnapshot>>;
469 desktopByTabId: Readonly<Record<string, DesktopPreviewOverlay>>;
462 theme: "light" | "dark";470 theme: "light" | "dark";
463 pullRequestStatuses: Readonly<Record<string, PullRequestTabStatus>> | undefined;471 pullRequestStatuses: Readonly<Record<string, PullRequestTabStatus>> | undefined;
464}) {472}) {
...@@ -466,7 +474,10 @@ function SurfaceIcon({...@@ -466,7 +474,10 @@ function SurfaceIcon({
466 case "preview": {474 case "preview": {
467 const snapshot = surface.resourceId ? sessions[surface.resourceId] : null;475 const snapshot = surface.resourceId ? sessions[surface.resourceId] : null;
468 const url = !snapshot || snapshot.navStatus._tag === "Idle" ? null : snapshot.navStatus.url;476 const url = !snapshot || snapshot.navStatus._tag === "Idle" ? null : snapshot.navStatus.url;
469 return <PreviewFavicon url={url} />;477 const favicon = snapshot ? (desktopByTabId[snapshot.tabId]?.favicon ?? null) : null;
478 const capturedUrl =
479 favicon && url && sameOrigin(favicon.pageUrl, url) ? favicon.dataUrl : null;
480 return <PreviewFavicon capturedUrl={capturedUrl} url={url} />;
470 }481 }
471 case "diff":482 case "diff":
472 return <FileDiff className="size-3 shrink-0" />;483 return <FileDiff className="size-3 shrink-0" />;
...@@ -636,6 +647,7 @@ export function RightPanelTabs(props: RightPanelTabsProps) {...@@ -636,6 +647,7 @@ export function RightPanelTabs(props: RightPanelTabsProps) {
636 <SurfaceIcon647 <SurfaceIcon
637 surface={surface}648 surface={surface}
638 sessions={props.previewSessions}649 sessions={props.previewSessions}
650 desktopByTabId={props.desktopByTabId}
639 theme={resolvedTheme}651 theme={resolvedTheme}
640 pullRequestStatuses={props.pullRequestStatuses}652 pullRequestStatuses={props.pullRequestStatuses}
641 />653 />
apps/web/src/components/preview/PreviewEmptyState.test.tsx+6-1
...@@ -1,4 +1,4 @@...@@ -1,4 +1,4 @@
1import { EnvironmentId } from "@t3tools/contracts";1import { EnvironmentId, ThreadId } from "@t3tools/contracts";
2import { renderToStaticMarkup } from "react-dom/server";2import { renderToStaticMarkup } from "react-dom/server";
3import { describe, expect, it, vi } from "vite-plus/test";3import { describe, expect, it, vi } from "vite-plus/test";
44
...@@ -19,10 +19,14 @@ const mocks = vi.hoisted(() => ({...@@ -19,10 +19,14 @@ const mocks = vi.hoisted(() => ({
19vi.mock("./useDiscoveredLocalServers", () => ({19vi.mock("./useDiscoveredLocalServers", () => ({
20 useDiscoveredLocalServers: () => mocks.servers,20 useDiscoveredLocalServers: () => mocks.servers,
21}));21}));
22vi.mock("./PreviewFaviconIcon", () => ({
23 PreviewFaviconIcon: () => <span data-favicon-icon />,
24}));
2225
23import { PreviewEmptyState } from "./PreviewEmptyState";26import { PreviewEmptyState } from "./PreviewEmptyState";
2427
25const environmentId = EnvironmentId.make("env-1");28const environmentId = EnvironmentId.make("env-1");
29const threadRef = { environmentId, threadId: ThreadId.make("thread-1") };
2630
27function server(port: number) {31function server(port: number) {
28 return {32 return {
...@@ -41,6 +45,7 @@ function server(port: number) {...@@ -41,6 +45,7 @@ function server(port: number) {
41function render(recentEntries: Array<{ url: string; lastVisitedAt: number; title?: string }>) {45function render(recentEntries: Array<{ url: string; lastVisitedAt: number; title?: string }>) {
42 return renderToStaticMarkup(46 return renderToStaticMarkup(
43 <PreviewEmptyState47 <PreviewEmptyState
48 threadRef={threadRef}
44 environmentId={environmentId}49 environmentId={environmentId}
45 recentEntries={recentEntries}50 recentEntries={recentEntries}
46 onRemoveRecent={() => undefined}51 onRemoveRecent={() => undefined}
apps/web/src/components/preview/PreviewEmptyState.tsx+5-1
...@@ -1,4 +1,4 @@...@@ -1,4 +1,4 @@
1import type { EnvironmentId } from "@t3tools/contracts";1import type { EnvironmentId, ScopedThreadRef } from "@t3tools/contracts";
2import { Globe, History, RadioTower } from "lucide-react";2import { Globe, History, RadioTower } from "lucide-react";
33
4import type { BrowserHistoryEntry } from "~/browserHistoryStore";4import type { BrowserHistoryEntry } from "~/browserHistoryStore";
...@@ -9,6 +9,7 @@ import { PreviewRecentUrlCard } from "./PreviewRecentUrlCard";...@@ -9,6 +9,7 @@ import { PreviewRecentUrlCard } from "./PreviewRecentUrlCard";
9import { useDiscoveredLocalServers } from "./useDiscoveredLocalServers";9import { useDiscoveredLocalServers } from "./useDiscoveredLocalServers";
1010
11interface Props {11interface Props {
12 threadRef: ScopedThreadRef;
12 environmentId: EnvironmentId;13 environmentId: EnvironmentId;
13 configuredUrls?: ReadonlyArray<string> | undefined;14 configuredUrls?: ReadonlyArray<string> | undefined;
14 recentlySeenUrls?: ReadonlyArray<string> | undefined;15 recentlySeenUrls?: ReadonlyArray<string> | undefined;
...@@ -18,6 +19,7 @@ interface Props {...@@ -18,6 +19,7 @@ interface Props {
18}19}
1920
20export function PreviewEmptyState({21export function PreviewEmptyState({
22 threadRef,
21 environmentId,23 environmentId,
22 configuredUrls,24 configuredUrls,
23 recentlySeenUrls,25 recentlySeenUrls,
...@@ -60,6 +62,7 @@ export function PreviewEmptyState({...@@ -60,6 +62,7 @@ export function PreviewEmptyState({
60 {recents.map((entry) => (62 {recents.map((entry) => (
61 <PreviewRecentUrlCard63 <PreviewRecentUrlCard
62 key={entry.url}64 key={entry.url}
65 threadRef={threadRef}
63 entry={entry}66 entry={entry}
64 onOpen={() => onOpenUrl(entry.url)}67 onOpen={() => onOpenUrl(entry.url)}
65 onRemove={() => onRemoveRecent(entry.url)}68 onRemove={() => onRemoveRecent(entry.url)}
...@@ -78,6 +81,7 @@ export function PreviewEmptyState({...@@ -78,6 +81,7 @@ export function PreviewEmptyState({
78 {servers.map((server) => (81 {servers.map((server) => (
79 <PreviewLocalServerCard82 <PreviewLocalServerCard
80 key={`${server.host}:${server.port}`}83 key={`${server.host}:${server.port}`}
84 threadRef={threadRef}
81 server={server}85 server={server}
82 onOpen={() => onOpenUrl(server.requestedUrl)}86 onOpen={() => onOpenUrl(server.requestedUrl)}
83 />87 />
apps/web/src/components/preview/PreviewFaviconIcon.test.tsx created+51
...@@ -0,0 +1,51 @@
1import { EnvironmentId, ThreadId } from "@t3tools/contracts";
2import { renderToStaticMarkup } from "react-dom/server";
3import { describe, expect, it, vi } from "vite-plus/test";
4
5const mocks = vi.hoisted(() => ({ favicon: null as string | null }));
6
7vi.mock("~/browserFaviconStore", () => ({
8 useFaviconForThreadUrl: () => mocks.favicon,
9}));
10
11import { FaviconImage, PreviewFaviconIcon, selectFaviconSource } from "./PreviewFaviconIcon";
12
13const threadRef = {
14 environmentId: EnvironmentId.make("env-1"),
15 threadId: ThreadId.make("thread-1"),
16};
17
18describe("preview favicon image", () => {
19 it("renders a captured source before later fallback sources", () => {
20 expect(
21 renderToStaticMarkup(
22 <FaviconImage
23 sources={["data:image/png;base64,AAAA", "https://public.example/icon"]}
24 fallback={<span>fallback</span>}
25 />,
26 ),
27 ).toContain('src="data:image/png;base64,AAAA"');
28 const captured = "data:image/png;base64,AAAA";
29 const google = "https://public.example/icon";
30 expect(selectFaviconSource([captured, google], new Set())).toBe(captured);
31 expect(selectFaviconSource([captured, google], new Set([captured]))).toBe(google);
32 expect(selectFaviconSource([captured, google], new Set([captured, google]))).toBeNull();
33 expect(selectFaviconSource(["data:image/png;base64,BBBB", google], new Set([captured]))).toBe(
34 "data:image/png;base64,BBBB",
35 );
36 });
37
38 it("uses a stored project icon or falls back to the browser mockup", () => {
39 mocks.favicon = null;
40 const html = renderToStaticMarkup(
41 <PreviewFaviconIcon threadRef={threadRef} url="http://localhost:3000/" />,
42 );
43 expect(html).not.toContain("<img");
44 expect(html).toContain("rounded-[5px]");
45 mocks.favicon = "data:image/png;base64,AAAA";
46 const faviconHtml = renderToStaticMarkup(
47 <PreviewFaviconIcon threadRef={threadRef} url="http://localhost:3000/" />,
48 );
49 expect(faviconHtml).toContain('src="data:image/png;base64,AAAA"');
50 });
51});
apps/web/src/components/preview/PreviewFaviconIcon.tsx created+66
...@@ -0,0 +1,66 @@
1import type { ScopedThreadRef } from "@t3tools/contracts";
2import { type ReactNode, useState } from "react";
3
4import { useFaviconForThreadUrl } from "~/browserFaviconStore";
5import { cn } from "~/lib/utils";
6
7import { BrowserMockup } from "./BrowserMockup";
8
9export function selectFaviconSource(
10 sources: ReadonlyArray<string>,
11 failed: ReadonlySet<string>,
12): string | null {
13 return sources.find((candidate) => !failed.has(candidate)) ?? null;
14}
15
16export function FaviconImage(props: {
17 sources: ReadonlyArray<string | null | undefined>;
18 fallback: ReactNode;
19 className?: string | undefined;
20}) {
21 const sources = props.sources.filter((source): source is string => Boolean(source));
22 return (
23 <FaviconImageAttempt
24 key={sources.join("\0")}
25 sources={sources}
26 fallback={props.fallback}
27 className={props.className}
28 />
29 );
30}
31
32function FaviconImageAttempt(props: {
33 sources: ReadonlyArray<string>;
34 fallback: ReactNode;
35 className?: string | undefined;
36}) {
37 const [failed, setFailed] = useState<ReadonlySet<string>>(() => new Set());
38 const source = selectFaviconSource(props.sources, failed);
39 if (!source) return props.fallback;
40 return (
41 <img
42 src={source}
43 alt=""
44 aria-hidden="true"
45 draggable={false}
46 className={props.className}
47 onError={() => setFailed((current) => new Set(current).add(source))}
48 />
49 );
50}
51
52export function PreviewFaviconIcon(props: {
53 threadRef: ScopedThreadRef;
54 url: string;
55 className?: string | undefined;
56}) {
57 const source = useFaviconForThreadUrl(props.threadRef, props.url);
58 const fallback = <BrowserMockup className={cn("size-7 shrink-0", props.className)} />;
59 return (
60 <FaviconImage
61 sources={[source]}
62 fallback={fallback}
63 className={cn("size-7 shrink-0 rounded object-contain", props.className)}
64 />
65 );
66}
apps/web/src/components/preview/PreviewLocalServerCard.tsx+6-3
...@@ -1,12 +1,15 @@...@@ -1,12 +1,15 @@
1import { BrowserMockup } from "./BrowserMockup";1import type { ScopedThreadRef } from "@t3tools/contracts";
2
3import { PreviewFaviconIcon } from "./PreviewFaviconIcon";
2import type { PreviewableServer } from "./useDiscoveredLocalServers";4import type { PreviewableServer } from "./useDiscoveredLocalServers";
35
4interface Props {6interface Props {
7 threadRef: ScopedThreadRef;
5 server: PreviewableServer;8 server: PreviewableServer;
6 onOpen: () => void;9 onOpen: () => void;
7}10}
811
9export function PreviewLocalServerCard({ server, onOpen }: Props) {12export function PreviewLocalServerCard({ threadRef, server, onOpen }: Props) {
10 const subtitle = describeServer(server);13 const subtitle = describeServer(server);
11 return (14 return (
12 <button15 <button
...@@ -14,7 +17,7 @@ export function PreviewLocalServerCard({ server, onOpen }: Props) {...@@ -14,7 +17,7 @@ export function PreviewLocalServerCard({ server, onOpen }: Props) {
14 onClick={onOpen}17 onClick={onOpen}
15 className="group flex w-full items-center gap-3 px-3 py-3 text-left hover:bg-accent/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"18 className="group flex w-full items-center gap-3 px-3 py-3 text-left hover:bg-accent/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
16 >19 >
17 <BrowserMockup className="size-7 shrink-0" />20 <PreviewFaviconIcon threadRef={threadRef} url={server.requestedUrl} />
18 <div className="flex min-w-0 flex-1 flex-col">21 <div className="flex min-w-0 flex-1 flex-col">
19 <span className="truncate text-sm font-medium text-foreground">{subtitle}</span>22 <span className="truncate text-sm font-medium text-foreground">{subtitle}</span>
20 <span className="truncate text-xs text-muted-foreground">23 <span className="truncate text-xs text-muted-foreground">
apps/web/src/components/preview/PreviewRecentUrlCard.tsx+5-3
...@@ -1,18 +1,20 @@...@@ -1,18 +1,20 @@
1import type { ScopedThreadRef } from "@t3tools/contracts";
1import { X } from "lucide-react";2import { X } from "lucide-react";
23
3import { isValidHistoryTimestamp, type BrowserHistoryEntry } from "~/browserHistoryStore";4import { isValidHistoryTimestamp, type BrowserHistoryEntry } from "~/browserHistoryStore";
4import { useNowMinute } from "~/hooks/useNowMinute";5import { useNowMinute } from "~/hooks/useNowMinute";
5import { formatRelativeTimeLabel } from "~/timestampFormat";6import { formatRelativeTimeLabel } from "~/timestampFormat";
67
7import { BrowserMockup } from "./BrowserMockup";8import { PreviewFaviconIcon } from "./PreviewFaviconIcon";
89
9interface Props {10interface Props {
11 threadRef: ScopedThreadRef;
10 entry: BrowserHistoryEntry;12 entry: BrowserHistoryEntry;
11 onOpen: () => void;13 onOpen: () => void;
12 onRemove: () => void;14 onRemove: () => void;
13}15}
1416
15export function PreviewRecentUrlCard({ entry, onOpen, onRemove }: Props) {17export function PreviewRecentUrlCard({ threadRef, entry, onOpen, onRemove }: Props) {
16 const parsed = new URL(entry.url);18 const parsed = new URL(entry.url);
17 const path = parsed.pathname === "/" ? "" : parsed.pathname;19 const path = parsed.pathname === "/" ? "" : parsed.pathname;
18 const label = `${parsed.host}${path}${parsed.search}${parsed.hash}`;20 const label = `${parsed.host}${path}${parsed.search}${parsed.hash}`;
...@@ -27,7 +29,7 @@ export function PreviewRecentUrlCard({ entry, onOpen, onRemove }: Props) {...@@ -27,7 +29,7 @@ export function PreviewRecentUrlCard({ entry, onOpen, onRemove }: Props) {
27 onClick={onOpen}29 onClick={onOpen}
28 className="flex w-full items-center gap-3 px-3 py-3 pr-10 text-left hover:bg-accent/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"30 className="flex w-full items-center gap-3 px-3 py-3 pr-10 text-left hover:bg-accent/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
29 >31 >
30 <BrowserMockup className="size-7 shrink-0" />32 <PreviewFaviconIcon threadRef={threadRef} url={entry.url} />
31 <div className="flex min-w-0 flex-1 flex-col">33 <div className="flex min-w-0 flex-1 flex-col">
32 <span className="truncate text-sm font-medium text-foreground">34 <span className="truncate text-sm font-medium text-foreground">
33 {entry.title ?? label}35 {entry.title ?? label}
apps/web/src/components/preview/PreviewView.tsx+1
...@@ -710,6 +710,7 @@ export function PreviewView({...@@ -710,6 +710,7 @@ export function PreviewView({
710 ) : null}710 ) : null}
711 {showEmptyState ? (711 {showEmptyState ? (
712 <PreviewEmptyState712 <PreviewEmptyState
713 threadRef={threadRef}
713 environmentId={threadRef.environmentId}714 environmentId={threadRef.environmentId}
714 configuredUrls={configuredUrls}715 configuredUrls={configuredUrls}
715 recentlySeenUrls={previewState.recentlySeenUrls}716 recentlySeenUrls={previewState.recentlySeenUrls}
apps/web/src/components/preview/usePreviewBridge.test.ts created+48
...@@ -0,0 +1,48 @@
1import type { DesktopPreviewTabState } from "@t3tools/contracts";
2import { describe, expect, it } from "vite-plus/test";
3
4import { projectDesktopState } from "./usePreviewBridge";
5
6const favicon = {
7 dataUrl: "data:image/png;base64,AAAA",
8 pageUrl: "http://localhost:3000/app",
9 capturedAt: 1,
10};
11
12function state(navStatus: DesktopPreviewTabState["navStatus"]): DesktopPreviewTabState {
13 return {
14 tabId: "tab-1",
15 webContentsId: 1,
16 navStatus,
17 canGoBack: false,
18 canGoForward: false,
19 zoomFactor: 1,
20 pictureInPicture: false,
21 colorScheme: "system",
22 controller: "none",
23 favicon,
24 updatedAt: "2026-08-09T00:00:00.000Z",
25 };
26}
27
28describe("projectDesktopState", () => {
29 it("shows a retained icon only while the current document has the captured origin", () => {
30 expect(
31 projectDesktopState(
32 state({ kind: "Loading", url: "http://localhost:3000/reload", title: "" }),
33 ).favicon,
34 ).toEqual(favicon);
35 expect(
36 projectDesktopState(
37 state({
38 kind: "LoadFailed",
39 url: "https://example.com/",
40 title: "",
41 code: -105,
42 description: "failed",
43 }),
44 ).favicon,
45 ).toBeNull();
46 expect(projectDesktopState(state({ kind: "Idle" })).favicon).toBeNull();
47 });
48});
apps/web/src/components/preview/usePreviewBridge.ts+52-14
...@@ -6,15 +6,31 @@ import type {...@@ -6,15 +6,31 @@ import type {
6 ScopedThreadRef,6 ScopedThreadRef,
7 ThreadId,7 ThreadId,
8} from "@t3tools/contracts";8} from "@t3tools/contracts";
9import { useEffect, useRef } from "react";9import { parseScopedThreadKey, scopedThreadKey } from "@t3tools/client-runtime/environment";
10import * as Option from "effect/Option";
11import { useEffect, useEffectEvent, useMemo, useRef } from "react";
1012
13import {
14 flushPendingFaviconsForThread,
15 recordFaviconForThread,
16 useFaviconProjectRefForThread,
17} from "~/browserFaviconStore";
11import { useBrowserPointerStore } from "~/browser/browserPointerStore";18import { useBrowserPointerStore } from "~/browser/browserPointerStore";
12import { applyPreviewDesktopState, type DesktopPreviewOverlay } from "~/previewStateStore";19import { applyPreviewDesktopState, type DesktopPreviewOverlay } from "~/previewStateStore";
13import { previewEnvironment } from "~/state/preview";20import { previewEnvironment } from "~/state/preview";
21import { usePreparedConnection } from "~/state/session";
14import { useAtomCommand } from "~/state/use-atom-command";22import { useAtomCommand } from "~/state/use-atom-command";
1523
16import { previewBridge } from "./previewBridge";24import { previewBridge } from "./previewBridge";
1725
26function originOf(url: string): string | null {
27 try {
28 return new URL(url).origin;
29 } catch {
30 return null;
31 }
32}
33
18/**34/**
19 * Mirrors low-latency desktop state into the store and reflects navigation35 * Mirrors low-latency desktop state into the store and reflects navigation
20 * events back to the server. Webview lifetime is owned by ElectronBrowserHost.36 * events back to the server. Webview lifetime is owned by ElectronBrowserHost.
...@@ -28,26 +44,36 @@ export function usePreviewBridge(input: {...@@ -28,26 +44,36 @@ export function usePreviewBridge(input: {
28 const clearBrowserPointer = useBrowserPointerStore((state) => state.clear);44 const clearBrowserPointer = useBrowserPointerStore((state) => state.clear);
29 const reportStatus = useAtomCommand(previewEnvironment.reportStatus, "preview status report");45 const reportStatus = useAtomCommand(previewEnvironment.reportStatus, "preview status report");
30 const bridge = previewBridge;46 const bridge = previewBridge;
47 const threadKey = scopedThreadKey(threadRef);
48 const stableThreadRef = useMemo(() => {
49 const parsed = parseScopedThreadKey(threadKey);
50 if (!parsed) throw new Error(`Invalid scoped thread key: ${threadKey}`);
51 return parsed;
52 }, [threadKey]);
53 const projectRef = useFaviconProjectRefForThread(stableThreadRef);
54 const preparedConnection = usePreparedConnection(stableThreadRef.environmentId);
55 const environmentHostname = Option.isSome(preparedConnection)
56 ? new URL(preparedConnection.value.httpBaseUrl).hostname
57 : undefined;
3158
32 // One bridge subscription does both jobs (mirror state + forward to59 // One bridge subscription does both jobs (mirror state + forward to
33 // server) so the desktop bridge keeps a single listener entry per tab.60 // server) so the desktop bridge keeps a single listener entry per tab.
34 const lastReportedUrl = useRef<string | null>(null);61 const lastReportedUrl = useRef<string | null>(null);
35 const lastReportedKind = useRef<DesktopPreviewTabState["navStatus"]["kind"] | null>(null);62 const lastReportedKind = useRef<DesktopPreviewTabState["navStatus"]["kind"] | null>(null);
36 const lastDesktopNavStatus = useRef<DesktopPreviewTabState["navStatus"] | null>(null);63 const lastDesktopNavStatus = useRef<DesktopPreviewTabState["navStatus"] | null>(null);
37 useEffect(() => {64 const handleStateChange = useEffectEvent(
38 if (!bridge || typeof window === "undefined") return;65 (changedTabId: string, state: DesktopPreviewTabState): void => {
39 lastReportedUrl.current = null;
40 lastReportedKind.current = null;
41 lastDesktopNavStatus.current = null;
42 const unsubscribe = bridge.onStateChange((changedTabId, state) => {
43 if (changedTabId !== runtimeTabId) return;66 if (changedTabId !== runtimeTabId) return;
44 if (shouldClearBrowserPointer(lastDesktopNavStatus.current, state.navStatus)) {67 if (shouldClearBrowserPointer(lastDesktopNavStatus.current, state.navStatus)) {
45 clearBrowserPointer(runtimeTabId);68 clearBrowserPointer(runtimeTabId);
46 }69 }
47 lastDesktopNavStatus.current = state.navStatus;70 lastDesktopNavStatus.current = state.navStatus;
48 applyPreviewDesktopState(threadRef, tabId, projectDesktopState(state));71 applyPreviewDesktopState(stableThreadRef, tabId, projectDesktopState(state));
72 if (state.favicon) {
73 recordFaviconForThread(stableThreadRef, state.favicon, projectRef, environmentHostname);
74 }
49 const reported = buildReportInput({75 const reported = buildReportInput({
50 threadId: threadRef.threadId,76 threadId: stableThreadRef.threadId,
51 tabId,77 tabId,
52 state,78 state,
53 lastReportedUrl: lastReportedUrl.current,79 lastReportedUrl: lastReportedUrl.current,
...@@ -57,12 +83,22 @@ export function usePreviewBridge(input: {...@@ -57,12 +83,22 @@ export function usePreviewBridge(input: {
57 lastReportedUrl.current = reported.lastReportedUrl;83 lastReportedUrl.current = reported.lastReportedUrl;
58 lastReportedKind.current = reported.lastReportedKind;84 lastReportedKind.current = reported.lastReportedKind;
59 void reportStatus({85 void reportStatus({
60 environmentId: threadRef.environmentId,86 environmentId: stableThreadRef.environmentId,
61 input: reported.input,87 input: reported.input,
62 });88 });
63 });89 },
64 return unsubscribe;90 );
65 }, [bridge, clearBrowserPointer, reportStatus, runtimeTabId, tabId, threadRef]);91 useEffect(() => {
92 if (!bridge || typeof window === "undefined") return;
93 lastReportedUrl.current = null;
94 lastReportedKind.current = null;
95 lastDesktopNavStatus.current = null;
96 return bridge.onStateChange(handleStateChange);
97 }, [bridge, runtimeTabId, stableThreadRef, tabId]);
98 useEffect(() => {
99 if (!projectRef) return;
100 flushPendingFaviconsForThread(stableThreadRef, projectRef, environmentHostname);
101 }, [environmentHostname, projectRef, stableThreadRef]);
66}102}
67103
68function shouldClearBrowserPointer(104function shouldClearBrowserPointer(
...@@ -75,7 +111,8 @@ function shouldClearBrowserPointer(...@@ -75,7 +111,8 @@ function shouldClearBrowserPointer(
75 return current.url !== previous.url;111 return current.url !== previous.url;
76}112}
77113
78function projectDesktopState(state: DesktopPreviewTabState): DesktopPreviewOverlay {114export function projectDesktopState(state: DesktopPreviewTabState): DesktopPreviewOverlay {
115 const navOrigin = state.navStatus.kind === "Idle" ? null : originOf(state.navStatus.url);
79 return {116 return {
80 hasWebContents: state.webContentsId !== null,117 hasWebContents: state.webContentsId !== null,
81 canGoBack: state.canGoBack,118 canGoBack: state.canGoBack,
...@@ -85,6 +122,7 @@ function projectDesktopState(state: DesktopPreviewTabState): DesktopPreviewOverl...@@ -85,6 +122,7 @@ function projectDesktopState(state: DesktopPreviewTabState): DesktopPreviewOverl
85 pictureInPicture: state.pictureInPicture,122 pictureInPicture: state.pictureInPicture,
86 colorScheme: state.colorScheme,123 colorScheme: state.colorScheme,
87 controller: state.controller,124 controller: state.controller,
125 favicon: state.favicon && originOf(state.favicon.pageUrl) === navOrigin ? state.favicon : null,
88 };126 };
89}127}
90128
apps/web/src/lib/favicon.test.ts created+42
...@@ -0,0 +1,42 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import { faviconUrlForOrigin } from "./favicon";
4
5describe("faviconUrlForOrigin", () => {
6 it("never sends private origin hostnames to the public provider", () => {
7 for (const url of [
8 "http://localhost:3000/",
9 "http://127.0.0.1:3000/",
10 "http://0.0.0.0:3000/",
11 "http://devbox:3000/",
12 "https://24x.xf.local/",
13 "http://printer.home.arpa/",
14 "http://192.168.1.20:3000/",
15 "http://[::]/",
16 "http://[::ffff:192.168.1.20]/",
17 "http://100.65.180.100:3000/",
18 "https://devbox.example.ts.net/",
19 "http://192.0.2.1/",
20 "http://198.51.100.1/",
21 "http://203.0.113.1/",
22 "http://224.0.0.1/",
23 "http://240.0.0.1/",
24 "http://[2001:db8::1]/",
25 "http://[ff02::1]/",
26 "http://app.test../",
27 "https://24x.xf.local../",
28 "http://printer.home.arpa../",
29 "https://devbox.example.ts.net../",
30 "http://127.0.0.1../",
31 "http://127.1../",
32 "http://10.1../",
33 "http://172.16.1../",
34 "http://192.168.1../",
35 ]) {
36 expect(faviconUrlForOrigin(url)).toBeNull();
37 }
38 expect(faviconUrlForOrigin("https://example.com/path", 32)).toBe(
39 "https://www.google.com/s2/favicons?domain=example.com&sz=32",
40 );
41 });
42});
apps/web/src/lib/favicon.ts+3
...@@ -1,3 +1,5 @@...@@ -1,3 +1,5 @@
1import { isPublicFaviconHost } from "~/browser/browserTargetResolver";
2
1/**3/**
2 * Favicon helpers for the preview tab strip.4 * Favicon helpers for the preview tab strip.
3 *5 *
...@@ -13,6 +15,7 @@ export function faviconUrlForOrigin(rawUrl: string | null | undefined, size = 32...@@ -13,6 +15,7 @@ export function faviconUrlForOrigin(rawUrl: string | null | undefined, size = 32
13 const url = new URL(rawUrl);15 const url = new URL(rawUrl);
14 if (!url.host) return null;16 if (!url.host) return null;
15 if (url.protocol !== "http:" && url.protocol !== "https:") return null;17 if (url.protocol !== "http:" && url.protocol !== "https:") return null;
18 if (!isPublicFaviconHost(url.hostname)) return null;
16 return `${FAVICON_PROVIDER}?domain=${encodeURIComponent(url.host)}&sz=${size}`;19 return `${FAVICON_PROVIDER}?domain=${encodeURIComponent(url.host)}&sz=${size}`;
17 } catch {20 } catch {
18 return null;21 return null;
apps/web/src/previewStateStore.test.ts+4
...@@ -322,6 +322,7 @@ describe("previewStateStore (single-tab)", () => {...@@ -322,6 +322,7 @@ describe("previewStateStore (single-tab)", () => {
322 pictureInPicture: false,322 pictureInPicture: false,
323 colorScheme: "system",323 colorScheme: "system",
324 controller: "none",324 controller: "none",
325 favicon: null,
325 });326 });
326 const state = readThreadPreviewState(ref);327 const state = readThreadPreviewState(ref);
327 expect(state.desktopOverlay?.canGoBack).toBe(true);328 expect(state.desktopOverlay?.canGoBack).toBe(true);
...@@ -342,6 +343,7 @@ describe("previewStateStore (single-tab)", () => {...@@ -342,6 +343,7 @@ describe("previewStateStore (single-tab)", () => {
342 pictureInPicture: false,343 pictureInPicture: false,
343 colorScheme: "system",344 colorScheme: "system",
344 controller: "none",345 controller: "none",
346 favicon: null,
345 });347 });
346 setActivePreviewTab(ref, first.tabId);348 setActivePreviewTab(ref, first.tabId);
347349
...@@ -390,6 +392,7 @@ describe("previewStateStore (single-tab)", () => {...@@ -390,6 +392,7 @@ describe("previewStateStore (single-tab)", () => {
390 pictureInPicture: false,392 pictureInPicture: false,
391 colorScheme: "system",393 colorScheme: "system",
392 controller: "none",394 controller: "none",
395 favicon: null,
393 });396 });
394397
395 reconcilePreviewServerSessions(ref, { sessions: [active], serverEpoch, revision: 1 });398 reconcilePreviewServerSessions(ref, { sessions: [active], serverEpoch, revision: 1 });
...@@ -504,6 +507,7 @@ describe("previewStateStore (single-tab)", () => {...@@ -504,6 +507,7 @@ describe("previewStateStore (single-tab)", () => {
504 pictureInPicture: false,507 pictureInPicture: false,
505 colorScheme: "system",508 colorScheme: "system",
506 controller: "none",509 controller: "none",
510 favicon: null,
507 });511 });
508 const restarted = makeSnapshot({512 const restarted = makeSnapshot({
509 navStatus: { _tag: "Success", url: "https://new.example", title: "New" },513 navStatus: { _tag: "Success", url: "https://new.example", title: "New" },
apps/web/src/previewStateStore.ts+2
...@@ -9,6 +9,7 @@ import { useAtomValue } from "@effect/atom-react";...@@ -9,6 +9,7 @@ import { useAtomValue } from "@effect/atom-react";
9import { scopedThreadKey } from "@t3tools/client-runtime/environment";9import { scopedThreadKey } from "@t3tools/client-runtime/environment";
10import {10import {
11 type DesktopPreviewColorScheme,11 type DesktopPreviewColorScheme,
12 type DesktopPreviewFavicon,
12 type PreviewEvent,13 type PreviewEvent,
13 type PreviewListResult,14 type PreviewListResult,
14 type PreviewSessionSnapshot,15 type PreviewSessionSnapshot,
...@@ -28,6 +29,7 @@ export interface DesktopPreviewOverlay {...@@ -28,6 +29,7 @@ export interface DesktopPreviewOverlay {
28 pictureInPicture: boolean;29 pictureInPicture: boolean;
29 colorScheme: DesktopPreviewColorScheme;30 colorScheme: DesktopPreviewColorScheme;
30 controller: "human" | "agent" | "none";31 controller: "human" | "agent" | "none";
32 favicon: DesktopPreviewFavicon | null;
31}33}
3234
33export interface ThreadPreviewState {35export interface ThreadPreviewState {
apps/web/src/routes/_chat.pull-requests.tsx+2
...@@ -175,6 +175,7 @@ const PULL_REQUESTS_PANEL_ENVIRONMENT_ID = "pull-requests-panel" as EnvironmentI...@@ -175,6 +175,7 @@ const PULL_REQUESTS_PANEL_ENVIRONMENT_ID = "pull-requests-panel" as EnvironmentI
175/** Stable so a read that is not wanted right now does not re-key on every render. */175/** Stable so a read that is not wanted right now does not re-key on every render. */
176const NO_LIST_TARGETS: ReadonlyArray<EnvironmentQueryTarget<PullRequestListInput>> = [];176const NO_LIST_TARGETS: ReadonlyArray<EnvironmentQueryTarget<PullRequestListInput>> = [];
177const EMPTY_PREVIEW_SESSIONS = {};177const EMPTY_PREVIEW_SESSIONS = {};
178const EMPTY_PREVIEW_DESKTOP_STATE = {};
178const EMPTY_TERMINAL_LABELS = new Map<string, string>();179const EMPTY_TERMINAL_LABELS = new Map<string, string>();
179const EMPTY_PENDING_SURFACES = new Set<string>();180const EMPTY_PENDING_SURFACES = new Set<string>();
180181
...@@ -1540,6 +1541,7 @@ function PullRequestsRouteView() {...@@ -1540,6 +1541,7 @@ function PullRequestsRouteView() {
1540 activeSurfaceId={activePullRequestSurface.id}1541 activeSurfaceId={activePullRequestSurface.id}
1541 pendingSurfaceIds={EMPTY_PENDING_SURFACES}1542 pendingSurfaceIds={EMPTY_PENDING_SURFACES}
1542 previewSessions={EMPTY_PREVIEW_SESSIONS}1543 previewSessions={EMPTY_PREVIEW_SESSIONS}
1544 desktopByTabId={EMPTY_PREVIEW_DESKTOP_STATE}
1543 terminalLabelsById={EMPTY_TERMINAL_LABELS}1545 terminalLabelsById={EMPTY_TERMINAL_LABELS}
1544 onActivate={(surface) => {1546 onActivate={(surface) => {
1545 if (surface.kind === "pull-request") activateSurface(surface);1547 if (surface.kind === "pull-request") activateSurface(surface);
packages/contracts/src/ipc.ts+24
...@@ -524,6 +524,28 @@ export type DesktopPreviewColorScheme = "system" | "light" | "dark";...@@ -524,6 +524,28 @@ export type DesktopPreviewColorScheme = "system" | "light" | "dark";
524export const DesktopPreviewColorSchemeSchema: Schema.Codec<DesktopPreviewColorScheme> =524export const DesktopPreviewColorSchemeSchema: Schema.Codec<DesktopPreviewColorScheme> =
525 Schema.Literals(["system", "light", "dark"]);525 Schema.Literals(["system", "light", "dark"]);
526526
527export const FAVICON_DATA_URL_MAX_LENGTH = 8192;
528export const FAVICON_CAPTURED_AT_MAX = 8_640_000_000_000_000;
529
530export interface DesktopPreviewFavicon {
531 dataUrl: string;
532 pageUrl: string;
533 capturedAt: number;
534}
535
536export const DesktopPreviewFaviconSchema: Schema.Codec<DesktopPreviewFavicon> = Schema.Struct({
537 dataUrl: Schema.String.check(
538 Schema.isMaxLength(FAVICON_DATA_URL_MAX_LENGTH),
539 Schema.isPattern(/^data:image\/png;base64,[a-z0-9+/]+={0,2}$/i),
540 ),
541 pageUrl: Schema.String.check(Schema.isMaxLength(2_048)),
542 capturedAt: Schema.Number.check(
543 Schema.isFinite(),
544 Schema.isGreaterThanOrEqualTo(0),
545 Schema.isLessThanOrEqualTo(FAVICON_CAPTURED_AT_MAX),
546 ),
547});
548
527export interface DesktopPreviewTabState {549export interface DesktopPreviewTabState {
528 tabId: string;550 tabId: string;
529 webContentsId: number | null;551 webContentsId: number | null;
...@@ -536,6 +558,7 @@ export interface DesktopPreviewTabState {...@@ -536,6 +558,7 @@ export interface DesktopPreviewTabState {
536 pictureInPicture: boolean;558 pictureInPicture: boolean;
537 colorScheme: DesktopPreviewColorScheme;559 colorScheme: DesktopPreviewColorScheme;
538 controller: "human" | "agent" | "none";560 controller: "human" | "agent" | "none";
561 favicon?: DesktopPreviewFavicon;
539 updatedAt: string;562 updatedAt: string;
540}563}
541564
...@@ -574,6 +597,7 @@ export const DesktopPreviewTabStateSchema: Schema.Codec<DesktopPreviewTabState>...@@ -574,6 +597,7 @@ export const DesktopPreviewTabStateSchema: Schema.Codec<DesktopPreviewTabState>
574 pictureInPicture: Schema.Boolean,597 pictureInPicture: Schema.Boolean,
575 colorScheme: DesktopPreviewColorSchemeSchema,598 colorScheme: DesktopPreviewColorSchemeSchema,
576 controller: Schema.Literals(["human", "agent", "none"]),599 controller: Schema.Literals(["human", "agent", "none"]),
600 favicon: Schema.optionalKey(DesktopPreviewFaviconSchema),
577 updatedAt: Schema.String,601 updatedAt: Schema.String,
578});602});
579603