| author | |
| committer | |
| log | 710fd0eeb3b4c4146c28fa0fc558ac4c236a8067 |
| tree | 709b56f498bb0e319881fe74114cee5951548efa |
| parent | e15f655ba423e4b0e50a5692cc06eb9421bff7df |
| signature | Signed by PGP key B5690EEEBB952194 |
28 files changed, 4189 insertions(+), 71 deletions(-)
apps/desktop/src/preview/FaviconCapture.test.ts created+999| ... | ... | @@ -0,0 +1,999 @@ |
| 1 | import { describe, expect, it, vi } from "vite-plus/test"; | |
| 2 | ||
| 3 | import { | |
| 4 | MAX_FAVICON_CANDIDATES, | |
| 5 | MAX_FAVICON_RESPONSE_BYTES, | |
| 6 | captureFavicon, | |
| 7 | selectFaviconCandidates, | |
| 8 | } from "./FaviconCapture.ts"; | |
| 9 | ||
| 10 | const PNG = "data:image/png;base64,cG5n"; | |
| 11 | const SOURCE_PNG = Buffer.alloc(24); | |
| 12 | Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]).copy(SOURCE_PNG); | |
| 13 | SOURCE_PNG.writeUInt32BE(1, 16); | |
| 14 | SOURCE_PNG.writeUInt32BE(1, 20); | |
| 15 | const SOURCE_PNG_URL = `data:image/png;base64,${SOURCE_PNG.toString("base64")}`; | |
| 16 | ||
| 17 | function 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 | ||
| 50 | function 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 | ||
| 74 | function 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 | ||
| 83 | function 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 | ||
| 113 | function 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 | ||
| 122 | function sourceJpegWithOrientationEntries( | |
| 123 | width: number, | |
| 124 | height: number, | |
| 125 | orientations: ReadonlyArray<number>, | |
| 126 | ): Buffer { | |
| 127 | return sourceJpegWithApp1Segments(width, height, [sourceJpegExifSegment(orientations)]); | |
| 128 | } | |
| 129 | ||
| 130 | function 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 | ||
| 137 | function 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 | ||
| 181 | function 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 | ||
| 218 | function 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 | ||
| 243 | function 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 | ||
| 253 | function 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 | ||
| 263 | function makeUnsafePng(): Buffer { | |
| 264 | const buffer = Buffer.from(SOURCE_PNG); | |
| 265 | buffer.writeUInt32BE(4096, 16); | |
| 266 | buffer.writeUInt32BE(4096, 20); | |
| 267 | return buffer; | |
| 268 | } | |
| 269 | ||
| 270 | function 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 | ||
| 277 | function 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 | ||
| 285 | function 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 | ||
| 310 | const JPEG_LANDSCAPE_LAYOUT = { | |
| 311 | draw: "context.drawImage(bitmap, 0, 8, 32, 16)", | |
| 312 | resizeHeight: 16, | |
| 313 | resizeWidth: 32, | |
| 314 | } as const; | |
| 315 | const JPEG_PORTRAIT_LAYOUT = { | |
| 316 | draw: "context.drawImage(bitmap, 8, 0, 16, 32)", | |
| 317 | resizeHeight: 32, | |
| 318 | resizeWidth: 16, | |
| 319 | } as const; | |
| 320 | ||
| 321 | async 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 | ||
| 344 | describe("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 | ||
| 371 | describe("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 @@ |
| 1 | import { FAVICON_DATA_URL_MAX_LENGTH } from "@t3tools/contracts"; | |
| 2 | ||
| 3 | export const MAX_FAVICON_RESPONSE_BYTES = 100_000; | |
| 4 | export const MAX_FAVICON_CANDIDATES = 8; | |
| 5 | export const MAX_FAVICON_HTTP_URL_LENGTH = 2_048; | |
| 6 | ||
| 7 | const MAX_FAVICON_CANDIDATE_INPUT_UNITS = 262_144; | |
| 8 | const MIN_FAVICON_CANDIDATE_INPUT_UNITS = 256; | |
| 9 | const MAX_FAVICON_SOURCE_PIXELS = 1_048_576; | |
| 10 | const MAX_FAVICON_INLINE_URL_LENGTH = Math.ceil((MAX_FAVICON_RESPONSE_BYTES * 4) / 3) + 128; | |
| 11 | const FAVICON_CAPTURE_TIMEOUT_MS = 5_000; | |
| 12 | const FAVICON_RASTER_WORLD_ID = 1001; | |
| 13 | const FAVICON_RASTER_TIMEOUT_MS = 1_000; | |
| 14 | const PNG_SIGNATURE = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]); | |
| 15 | ||
| 16 | interface RasterizationGate { | |
| 17 | generation: number; | |
| 18 | launchAllowed?: Promise<void>; | |
| 19 | } | |
| 20 | ||
| 21 | const rasterizationGates = new WeakMap<Electron.WebContents, RasterizationGate>(); | |
| 22 | ||
| 23 | async 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 | ||
| 35 | export type FaviconCaptureResult = | |
| 36 | | { readonly kind: "captured"; readonly dataUrl: string } | |
| 37 | | { readonly kind: "none" } | |
| 38 | | { readonly kind: "timed-out" }; | |
| 39 | ||
| 40 | type RasterizationResult = | |
| 41 | | { readonly kind: "completed"; readonly value: unknown } | |
| 42 | | { readonly kind: "timed-out" }; | |
| 43 | ||
| 44 | export 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 | ||
| 53 | export 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 | ||
| 69 | export 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 | ||
| 99 | async 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 | ||
| 136 | async 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 | ||
| 175 | function 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 | ||
| 188 | function 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 | ||
| 206 | function 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 | ||
| 244 | interface ImageDimensions { | |
| 245 | readonly width: number; | |
| 246 | readonly height: number; | |
| 247 | } | |
| 248 | ||
| 249 | function 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 | ||
| 260 | function 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 | ||
| 267 | function 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 | ||
| 279 | function 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 | ||
| 331 | interface JpegExifMetadata { | |
| 332 | readonly complete: boolean; | |
| 333 | readonly orientation: number | null; | |
| 334 | } | |
| 335 | ||
| 336 | function 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 | ||
| 406 | function 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 | ||
| 448 | function 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 | ||
| 478 | function 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 | ||
| 494 | function 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 | ||
| 527 | function 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 | ||
| 537 | async 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 | ||
| 575 | async 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 | 186 | capturePage, |
| 187 | 187 | }) as never; |
| 188 | 188 | |
| 189 | const TEST_FAVICON = "data:image/png;base64,cG5n"; | |
| 190 | ||
| 191 | const 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 | ||
| 199 | const 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 | ||
| 279 | const settle = function* (until: () => boolean) { | |
| 280 | for (let attempt = 0; attempt < 30 && !until(); attempt++) { | |
| 281 | yield* Effect.promise(() => Promise.resolve()); | |
| 282 | } | |
| 283 | }; | |
| 284 | ||
| 189 | 285 | const makeTestPictureInPictureWindow = (loadURL: () => Promise<void> = async () => undefined) => { |
| 190 | 286 | const listeners = new Map<string, () => void>(); |
| 191 | 287 | const send = vi.fn(); |
| ... | ... | @@ -257,6 +353,32 @@ describe("PreviewManager", () => { |
| 257 | 353 | ), |
| 258 | 354 | ); |
| 259 | 355 | |
| 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 | 382 | effectIt.effect("isolates failed state listeners and continues delivery", () => { |
| 261 | 383 | const loggedErrors: Array<unknown> = []; |
| 262 | 384 | const logger = Logger.make(({ message }) => { |
| ... | ... | @@ -375,6 +497,488 @@ describe("PreviewManager", () => { |
| 375 | 497 | ), |
| 376 | 498 | ); |
| 377 | 499 | |
| 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 | 982 | effectIt.effect("mirrors Electron's effective zoom across registration and navigation", () => |
| 379 | 983 | withManager((manager) => |
| 380 | 984 | Effect.gen(function* () { |
apps/desktop/src/preview/Manager.ts+204-21| ... | ... | @@ -8,6 +8,7 @@ |
| 8 | 8 | import type { |
| 9 | 9 | DesktopPreviewAnnotationTheme, |
| 10 | 10 | DesktopPreviewColorScheme, |
| 11 | DesktopPreviewFavicon, | |
| 11 | 12 | DesktopPreviewPointerEvent, |
| 12 | 13 | PreviewAnnotationPayload, |
| 13 | 14 | PreviewAnnotationRect, |
| ... | ... | @@ -62,6 +63,7 @@ import { |
| 62 | 63 | import { isPreviewAnnotationPayload } from "./PickedElementPayload.ts"; |
| 63 | 64 | import { playwrightInjectedRuntimeInstallExpression } from "./PlaywrightInjectedRuntime.ts"; |
| 64 | 65 | import { makePreviewAutomationKeySequence } from "./PreviewKeyboard.ts"; |
| 66 | import { captureFavicon, safeHttpOrigin, selectFaviconCandidates } from "./FaviconCapture.ts"; | |
| 65 | 67 | |
| 66 | 68 | export type PreviewNavStatus = |
| 67 | 69 | | { kind: "Idle" } |
| ... | ... | @@ -85,6 +87,7 @@ export interface PreviewTabState { |
| 85 | 87 | pictureInPicture: boolean; |
| 86 | 88 | colorScheme: DesktopPreviewColorScheme; |
| 87 | 89 | controller: "human" | "agent" | "none"; |
| 90 | favicon?: DesktopPreviewFavicon; | |
| 88 | 91 | updatedAt: string; |
| 89 | 92 | } |
| 90 | 93 | |
| ... | ... | @@ -346,7 +349,10 @@ type PreviewInputSignal = |
| 346 | 349 | | { readonly kind: "key"; readonly key: string; readonly code: string }; |
| 347 | 350 | |
| 348 | 351 | interface ManagedListeners { |
| 352 | readonly attachmentId: symbol; | |
| 353 | readonly cancelFaviconCapture: () => void; | |
| 349 | 354 | readonly scope: Scope.Closeable; |
| 355 | readonly webContents: Electron.WebContents; | |
| 350 | 356 | } |
| 351 | 357 | |
| 352 | 358 | type FrameCaptureConsumer = "picture-in-picture" | "recording"; |
| ... | ... | @@ -613,6 +619,15 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 613 | 619 | ); |
| 614 | 620 | }); |
| 615 | 621 | |
| 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 | 631 | const update = Effect.fn("PreviewManager.update")(function* ( |
| 617 | 632 | tabId: string, |
| 618 | 633 | patch: Partial<PreviewTabState>, |
| ... | ... | @@ -1204,7 +1219,10 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 1204 | 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 | }); |
| 1209 | 1227 | |
| 1210 | 1228 | const isAppShortcut = (input: Electron.Input): boolean => |
| ... | ... | @@ -1268,8 +1286,23 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 1268 | 1286 | wc: Electron.WebContents, |
| 1269 | 1287 | ) { |
| 1270 | 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 | 1303 | const syncState = Effect.fn("PreviewManager.syncWebContentsState")(function* ( |
| 1272 | 1304 | preserveLoadFailure: boolean, |
| 1305 | confirmedNavigation = false, | |
| 1273 | 1306 | ) { |
| 1274 | 1307 | if (wc.isDestroyed()) return; |
| 1275 | 1308 | const zoomFactor = yield* attempt( |
| ... | ... | @@ -1282,7 +1315,9 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 1282 | 1315 | const updatedAt = yield* currentIso; |
| 1283 | 1316 | const next = yield* SynchronizedRef.modify(tabsRef, (tabs) => { |
| 1284 | 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 | 1321 | // Electron emits did-stop-loading after did-fail-load. At that point the |
| 1287 | 1322 | // failed guest is no longer "loading", but it has not successfully |
| 1288 | 1323 | // navigated anywhere. Keep the failure until a new load actually starts. |
| ... | ... | @@ -1292,8 +1327,14 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 1292 | 1327 | computedNavStatus.kind === "Success" |
| 1293 | 1328 | ? current.navStatus |
| 1294 | 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 | 1336 | const state: PreviewTabState = { |
| 1296 | ...current, | |
| 1337 | ...(clearFavicon ? currentWithoutFavicon : current), | |
| 1297 | 1338 | navStatus, |
| 1298 | 1339 | canGoBack, |
| 1299 | 1340 | canGoForward, |
| ... | ... | @@ -1307,10 +1348,109 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 1307 | 1348 | }), |
| 1308 | 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 | 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 | 1454 | const failed = ( |
| 1315 | 1455 | _event: Event, |
| 1316 | 1456 | code: number, |
| ... | ... | @@ -1387,9 +1527,12 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 1387 | 1527 | yield* Scope.addFinalizer( |
| 1388 | 1528 | scope, |
| 1389 | 1529 | attempt({ operation: "detachListeners", tabId, webContentsId: wc.id }, () => { |
| 1530 | cancelFaviconCapture(); | |
| 1531 | wc.off("did-start-navigation", navigationStarted); | |
| 1390 | 1532 | wc.off("did-navigate", syncNavigation); |
| 1391 | wc.off("did-navigate-in-page", syncNavigation); | |
| 1533 | wc.off("did-navigate-in-page", syncInPageNavigation); | |
| 1392 | 1534 | wc.off("page-title-updated", sync); |
| 1535 | wc.off("page-favicon-updated", faviconUpdated as never); | |
| 1393 | 1536 | wc.off("did-start-loading", sync); |
| 1394 | 1537 | wc.off("did-stop-loading", sync); |
| 1395 | 1538 | wc.off("did-fail-load", failed as never); |
| ... | ... | @@ -1399,9 +1542,11 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 1399 | 1542 | ); |
| 1400 | 1543 | const install = Effect.fn("PreviewManager.installWebContentsListeners")(function* () { |
| 1401 | 1544 | yield* attempt({ operation: "attachListeners", tabId, webContentsId: wc.id }, () => { |
| 1545 | wc.on("did-start-navigation", navigationStarted); | |
| 1402 | 1546 | wc.on("did-navigate", syncNavigation); |
| 1403 | wc.on("did-navigate-in-page", syncNavigation); | |
| 1547 | wc.on("did-navigate-in-page", syncInPageNavigation); | |
| 1404 | 1548 | wc.on("page-title-updated", sync); |
| 1549 | wc.on("page-favicon-updated", faviconUpdated as never); | |
| 1405 | 1550 | wc.on("did-start-loading", sync); |
| 1406 | 1551 | wc.on("did-stop-loading", sync); |
| 1407 | 1552 | wc.on("did-fail-load", failed as never); |
| ... | ... | @@ -1418,7 +1563,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 1418 | 1563 | }); |
| 1419 | 1564 | yield* Ref.update(attachedRef, (attached) => |
| 1420 | 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 | 1706 | const mainWindow = yield* Ref.get(mainWindowRef); |
| 1562 | 1707 | if ( |
| 1563 | 1708 | !wc || |
| 1709 | wc.isDestroyed() || | |
| 1564 | 1710 | wc.getType() !== "webview" || |
| 1565 | 1711 | (Option.isSome(mainWindow) && wc.hostWebContents !== mainWindow.value.webContents) |
| 1566 | 1712 | ) { |
| ... | ... | @@ -1568,7 +1714,8 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 1568 | 1714 | } |
| 1569 | 1715 | const attached = yield* Ref.get(attachedRef); |
| 1570 | 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 | 1719 | const zoomFactor = yield* attempt( |
| 1573 | 1720 | { operation: "registerWebview.getZoomFactor", tabId, webContentsId }, |
| 1574 | 1721 | () => wc.getZoomFactor(), |
| ... | ... | @@ -1580,7 +1727,10 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 1580 | 1727 | return; |
| 1581 | 1728 | } |
| 1582 | 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 | 1734 | if (replacedWebContentsId !== null) { |
| 1585 | 1735 | yield* Effect.all( |
| 1586 | 1736 | [ |
| ... | ... | @@ -1627,8 +1777,9 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 1627 | 1777 | ] as const; |
| 1628 | 1778 | } |
| 1629 | 1779 | const pendingUrl = current.navStatus.kind === "Loading" ? current.navStatus.url : null; |
| 1780 | const { favicon: _favicon, ...currentWithoutFavicon } = current; | |
| 1630 | 1781 | const next: PreviewTabState = { |
| 1631 | ...current, | |
| 1782 | ...currentWithoutFavicon, | |
| 1632 | 1783 | webContentsId, |
| 1633 | 1784 | navStatus: pendingUrl === null ? computeNavStatus(wc) : current.navStatus, |
| 1634 | 1785 | canGoBack: wc.navigationHistory.canGoBack(), |
| ... | ... | @@ -1707,6 +1858,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 1707 | 1858 | pictureInPicture: current?.pictureInPicture ?? false, |
| 1708 | 1859 | colorScheme: current?.colorScheme ?? "system", |
| 1709 | 1860 | controller: current?.controller ?? "none", |
| 1861 | ...(current?.favicon ? { favicon: current.favicon } : {}), | |
| 1710 | 1862 | updatedAt, |
| 1711 | 1863 | }; |
| 1712 | 1864 | return [ |
| ... | ... | @@ -1718,17 +1870,48 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 1718 | 1870 | }); |
| 1719 | 1871 | yield* emit(tabId, pending); |
| 1720 | 1872 | if (pending.webContentsId == null) return; |
| 1721 | const wc = webContents.fromId(pending.webContentsId); | |
| 1722 | if (!wc) { | |
| 1723 | const detached = { ...pending, webContentsId: null }; | |
| 1724 | yield* SynchronizedRef.update(tabsRef, (tabs) => | |
| 1725 | tabs.get(tabId)?.webContentsId !== pending.webContentsId | |
| 1726 | ? tabs | |
| 1727 | : replaceMap(tabs, (copy) => { | |
| 1728 | copy.set(tabId, detached); | |
| 1729 | }), | |
| 1873 | const webContentsId = pending.webContentsId; | |
| 1874 | const wc = webContents.fromId(webContentsId); | |
| 1875 | if (!wc || wc.isDestroyed()) { | |
| 1876 | const expectedAttachment = (yield* Ref.get(attachedRef)).get(webContentsId); | |
| 1877 | yield* withTabLifecycleLock( | |
| 1878 | tabId, | |
| 1879 | Effect.gen(function* () { | |
| 1880 | const currentTab = (yield* SynchronizedRef.get(tabsRef)).get(tabId); | |
| 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 | 1915 | return; |
| 1733 | 1916 | } |
| 1734 | 1917 | if (wc.getURL() === url) { |
apps/web/src/browser/browserTargetResolver.test.ts+131| ... | ... | @@ -180,4 +180,135 @@ describe("browser target resolver", () => { |
| 180 | 180 | const { resolveDiscoveredServerUrl } = await import("./browserTargetResolver"); |
| 181 | 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 | 8 | import { readPreparedConnection } from "~/state/session"; |
| 9 | 9 | |
| 10 | 10 | export const normalizeHostname = (host: string): string => |
| 11 | host.toLowerCase().replace(/^\[|\]$/g, ""); | |
| 11 | host | |
| 12 | .toLowerCase() | |
| 13 | .replace(/^\[|\]$/g, "") | |
| 14 | .replace(/\.+$/u, ""); | |
| 12 | 15 | |
| 13 | 16 | const parseIpv4Address = (host: string): readonly number[] | null => { |
| 14 | 17 | const parts = normalizeHostname(host).split(".").map(Number); |
| ... | ... | @@ -18,28 +21,91 @@ const parseIpv4Address = (host: string): readonly number[] | null => { |
| 18 | 21 | : null; |
| 19 | 22 | }; |
| 20 | 23 | |
| 24 | const 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 | ||
| 37 | const 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 | ||
| 52 | const 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 | ||
| 65 | const 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 | ||
| 75 | const 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 | ||
| 21 | 87 | export const isLocalLoopbackHost = (host: string): boolean => { |
| 22 | 88 | const normalized = normalizeHostname(host); |
| 23 | 89 | if (normalized === "localhost" || normalized === "::1") return true; |
| 24 | 90 | return parseIpv4Address(normalized)?.[0] === 127; |
| 25 | 91 | }; |
| 26 | 92 | |
| 27 | const isPrivateNetworkHost = (host: string): boolean => { | |
| 93 | export const isPrivateNetworkHost = (host: string): boolean => { | |
| 28 | 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 | 104 | return true; |
| 31 | 105 | } |
| 32 | 106 | if (normalized.endsWith(".ts.net")) return true; |
| 33 | const parts = parseIpv4Address(normalized); | |
| 34 | if (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 | } | |
| 107 | const parts = parseIpv4Address(normalized) ?? parseIpv4MappedIpv6Address(normalized); | |
| 108 | if (parts) return isPrivateIpv4Address(parts); | |
| 43 | 109 | const firstIpv6Token = normalized.split(":", 1)[0] ?? ""; |
| 44 | 110 | if (!normalized.includes(":") || !/^[\da-f]{1,4}$/u.test(firstIpv6Token)) return false; |
| 45 | 111 | const firstIpv6Hextet = Number.parseInt(firstIpv6Token, 16); |
| ... | ... | @@ -49,6 +115,48 @@ const isPrivateNetworkHost = (host: string): boolean => { |
| 49 | 115 | ); |
| 50 | 116 | }; |
| 51 | 117 | |
| 118 | /** Whether a hostname is eligible to be disclosed to a public favicon provider. */ | |
| 119 | export 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 | ||
| 52 | 160 | const readEnvironmentUrl = (environmentId: EnvironmentId): URL => { |
| 53 | 161 | const connection = readPreparedConnection(environmentId); |
| 54 | 162 | if (!connection) throw new Error(`Environment ${environmentId} is not connected.`); |
apps/web/src/browserFaviconLogic.test.ts created+124| ... | ... | @@ -0,0 +1,124 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | |
| 2 | ||
| 3 | import { | |
| 4 | BROWSER_FAVICON_MAX_ENTRIES, | |
| 5 | type BrowserFaviconEntry, | |
| 6 | evictExcessFavicons, | |
| 7 | faviconKey, | |
| 8 | faviconStorageLocation, | |
| 9 | isStorableFaviconDataUrl, | |
| 10 | migratePersistedBrowserFaviconState, | |
| 11 | } from "./browserFaviconLogic"; | |
| 12 | ||
| 13 | const PNG = "data:image/png;base64,AAAA"; | |
| 14 | ||
| 15 | function entry(capturedAt = 0): BrowserFaviconEntry { | |
| 16 | return { dataUrl: PNG, capturedAt }; | |
| 17 | } | |
| 18 | ||
| 19 | describe("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 @@ |
| 1 | import { FAVICON_CAPTURED_AT_MAX, FAVICON_DATA_URL_MAX_LENGTH } from "@t3tools/contracts"; | |
| 2 | ||
| 3 | import { isLocalLoopbackHost, normalizeHostname } from "./browser/browserTargetResolver"; | |
| 4 | ||
| 5 | export type BrowserFaviconEntry = { | |
| 6 | dataUrl: string; | |
| 7 | capturedAt: number; | |
| 8 | aliases?: ReadonlyArray<string>; | |
| 9 | }; | |
| 10 | ||
| 11 | export const BROWSER_FAVICON_MAX_ENTRIES = 40; | |
| 12 | export const BROWSER_FAVICON_MAX_KEY_LENGTH = 4_096; | |
| 13 | const BROWSER_FAVICON_MAX_FUTURE_SKEW_MS = 5 * 60 * 1_000; | |
| 14 | export const BROWSER_FAVICON_MAX_ALIASES_PER_ENTRY = 4; | |
| 15 | const BROWSER_FAVICON_MAX_ALIAS_LENGTH = 255; | |
| 16 | ||
| 17 | function formatFaviconHost(host: string): string { | |
| 18 | return host.includes(":") ? `[${host}]` : host; | |
| 19 | } | |
| 20 | ||
| 21 | export 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 | ||
| 34 | export 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 | ||
| 44 | function 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 | ||
| 69 | function 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 | ||
| 81 | export 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 | ||
| 104 | export 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 | ||
| 135 | export 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 | ||
| 152 | export 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 | ||
| 165 | export 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 @@ |
| 1 | import { scopeProjectRef } from "@t3tools/client-runtime/environment"; | |
| 2 | import { EnvironmentId, ProjectId, ThreadId } from "@t3tools/contracts"; | |
| 3 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; | |
| 4 | ||
| 5 | vi.mock("~/state/entities", () => ({ useThreadShell: () => null })); | |
| 6 | vi.mock("~/state/session", () => ({ usePreparedConnection: () => ({ _tag: "None" }) })); | |
| 7 | ||
| 8 | import { | |
| 9 | flushPendingFaviconsForThread, | |
| 10 | lookupFavicon, | |
| 11 | mergeBrowserFaviconState, | |
| 12 | recordFaviconForProject, | |
| 13 | recordFaviconForThread, | |
| 14 | registerFaviconProjectForThread, | |
| 15 | resetBrowserFaviconsForTests, | |
| 16 | resolveBrowserFaviconStorage, | |
| 17 | useBrowserFaviconStore, | |
| 18 | } from "./browserFaviconStore"; | |
| 19 | import { | |
| 20 | BROWSER_FAVICON_MAX_ENTRIES, | |
| 21 | migratePersistedBrowserFaviconState, | |
| 22 | } from "./browserFaviconLogic"; | |
| 23 | ||
| 24 | const environmentId = EnvironmentId.make("env-1"); | |
| 25 | const projectRef = scopeProjectRef(environmentId, ProjectId.make("project-1")); | |
| 26 | const threadRef = { environmentId, threadId: ThreadId.make("thread-1") }; | |
| 27 | const PNG = "data:image/png;base64,AAAA"; | |
| 28 | const favicon = (pageUrl: string, capturedAt: number, dataUrl = PNG) => ({ | |
| 29 | pageUrl, | |
| 30 | capturedAt, | |
| 31 | dataUrl, | |
| 32 | }); | |
| 33 | ||
| 34 | describe("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 @@ |
| 1 | import { | |
| 2 | scopedProjectKey, | |
| 3 | scopedThreadKey, | |
| 4 | scopeProjectRef, | |
| 5 | } from "@t3tools/client-runtime/environment"; | |
| 6 | import type { DesktopPreviewFavicon, ScopedProjectRef, ScopedThreadRef } from "@t3tools/contracts"; | |
| 7 | import * as Option from "effect/Option"; | |
| 8 | import { useMemo } from "react"; | |
| 9 | import { create } from "zustand"; | |
| 10 | import { createJSONStorage, persist } from "zustand/middleware"; | |
| 11 | ||
| 12 | import { normalizeHostname } from "~/browser/browserTargetResolver"; | |
| 13 | import { useThreadShell } from "~/state/entities"; | |
| 14 | import { usePreparedConnection } from "~/state/session"; | |
| 15 | ||
| 16 | import { | |
| 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"; | |
| 27 | import { createMemoryStorage, type StateStorage } from "./lib/storage"; | |
| 28 | ||
| 29 | const BROWSER_FAVICON_STORAGE_KEY = "t3code:browser-favicons:v1"; | |
| 30 | const MAX_PENDING_ORIGINS_PER_THREAD = 10; | |
| 31 | const MAX_PENDING_THREADS = 20; | |
| 32 | const MAX_REGISTERED_THREADS = 100; | |
| 33 | ||
| 34 | type PendingFavicon = DesktopPreviewFavicon; | |
| 35 | type PendingFaviconsByOrigin = Record<string, PendingFavicon>; | |
| 36 | ||
| 37 | export 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 | ||
| 51 | function pendingOriginKey(pageUrl: string): string | null { | |
| 52 | return faviconKey("pending", pageUrl, null)?.slice("pending ".length) ?? null; | |
| 53 | } | |
| 54 | ||
| 55 | function 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 | ||
| 82 | export 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 | ||
| 122 | export 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 | ||
| 168 | export function mergeBrowserFaviconState( | |
| 169 | persistedState: unknown, | |
| 170 | currentState: BrowserFaviconStoreState, | |
| 171 | ): BrowserFaviconStoreState { | |
| 172 | return { | |
| 173 | ...currentState, | |
| 174 | ...migratePersistedBrowserFaviconState(persistedState), | |
| 175 | }; | |
| 176 | } | |
| 177 | ||
| 178 | export 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 | ||
| 201 | export 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 | ||
| 214 | export 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 | ||
| 240 | export 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 | ||
| 274 | export 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 | ||
| 302 | export 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 | ||
| 313 | export 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 | ||
| 335 | export 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 | 179 | } from "~/projectScripts"; |
| 180 | 180 | import { newDraftId, newMessageId, newThreadId } from "~/lib/utils"; |
| 181 | 181 | import { useBrowserHistoryStore } from "~/browserHistoryStore"; |
| 182 | import { registerFaviconProjectForThread } from "~/browserFaviconStore"; | |
| 182 | 183 | import { getProviderModelCapabilities, resolveSelectableProvider } from "../providerModels"; |
| 183 | 184 | import { NO_PROVIDER_MODEL_SELECTION } from "../providerInstances"; |
| 184 | 185 | import { |
| ... | ... | @@ -1704,9 +1705,11 @@ function ChatViewContent(props: ChatViewProps) { |
| 1704 | 1705 | }); |
| 1705 | 1706 | }, [activeThreadKey, existingOpenTerminalThreadKeys, terminalUiState.terminalOpen]); |
| 1706 | 1707 | const latestTurnSettled = isLatestTurnSettled(activeLatestTurn, activeThread?.session ?? null); |
| 1707 | const activeProjectRef = activeThread | |
| 1708 | ? scopeProjectRef(activeThread.environmentId, activeThread.projectId) | |
| 1709 | : null; | |
| 1708 | const activeProjectRef = useMemo( | |
| 1709 | () => | |
| 1710 | activeThread ? scopeProjectRef(activeThread.environmentId, activeThread.projectId) : null, | |
| 1711 | [activeThread?.environmentId, activeThread?.projectId], | |
| 1712 | ); | |
| 1710 | 1713 | const activeProject = useProject(activeProjectRef); |
| 1711 | 1714 | const handleNewThreadInActiveProject = useCallback(() => { |
| 1712 | 1715 | startNewThreadForProject(activeProjectRef, handleNewThread); |
| ... | ... | @@ -1758,6 +1761,10 @@ function ChatViewContent(props: ChatViewProps) { |
| 1758 | 1761 | // drive the environment picker in BranchToolbar. |
| 1759 | 1762 | const allProjects = useProjects(); |
| 1760 | 1763 | const primaryEnvironmentId = primaryEnvironment?.environmentId ?? null; |
| 1764 | useEffect(() => { | |
| 1765 | if (!activeThreadRef || !activeProjectRef) return; | |
| 1766 | registerFaviconProjectForThread(activeThreadRef, activeProjectRef); | |
| 1767 | }, [activeProjectRef, activeThreadRef]); | |
| 1761 | 1768 | useEffect(() => { |
| 1762 | 1769 | if (!clientSettingsHydrated || !activeThreadRef || !activeProject) return; |
| 1763 | 1770 | // Reuse the sidebar's grouping so history follows the project rows the user |
| ... | ... | @@ -6546,6 +6553,7 @@ function ChatViewContent(props: ChatViewProps) { |
| 6546 | 6553 | activeSurfaceId={activeRightPanelSurface?.id ?? null} |
| 6547 | 6554 | pendingSurfaceIds={pendingFileSurfaceIds} |
| 6548 | 6555 | previewSessions={activePreviewState.sessions} |
| 6556 | desktopByTabId={activePreviewState.desktopByTabId} | |
| 6549 | 6557 | terminalLabelsById={activeTerminalLabelsById} |
| 6550 | 6558 | onActivate={activateRightPanelSurface} |
| 6551 | 6559 | onCloseSurface={closeRightPanelSurface} |
| ... | ... | @@ -6580,6 +6588,7 @@ function ChatViewContent(props: ChatViewProps) { |
| 6580 | 6588 | activeSurfaceId={activeRightPanelSurface?.id ?? null} |
| 6581 | 6589 | pendingSurfaceIds={pendingFileSurfaceIds} |
| 6582 | 6590 | previewSessions={activePreviewState.sessions} |
| 6591 | desktopByTabId={activePreviewState.desktopByTabId} | |
| 6583 | 6592 | terminalLabelsById={activeTerminalLabelsById} |
| 6584 | 6593 | onActivate={activateRightPanelSurface} |
| 6585 | 6594 | onCloseSurface={closeRightPanelSurface} |
apps/web/src/components/RightPanelTabs.test.tsx created+115| ... | ... | @@ -0,0 +1,115 @@ |
| 1 | import type { DesktopPreviewFavicon, PreviewSessionSnapshot } from "@t3tools/contracts"; | |
| 2 | import { renderToStaticMarkup } from "react-dom/server"; | |
| 3 | import { describe, expect, it } from "vite-plus/test"; | |
| 4 | ||
| 5 | import { RightPanelTabs } from "./RightPanelTabs"; | |
| 6 | ||
| 7 | const previewSurface = { | |
| 8 | id: "browser:tab-1" as const, | |
| 9 | kind: "preview" as const, | |
| 10 | resourceId: "tab-1", | |
| 11 | }; | |
| 12 | const secondSurface = { | |
| 13 | id: "browser:tab-2" as const, | |
| 14 | kind: "preview" as const, | |
| 15 | resourceId: "tab-2", | |
| 16 | }; | |
| 17 | const 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 | ||
| 36 | const favicon = (dataUrl: string, pageUrl: string): DesktopPreviewFavicon => ({ | |
| 37 | dataUrl, | |
| 38 | pageUrl, | |
| 39 | capturedAt: 1, | |
| 40 | }); | |
| 41 | ||
| 42 | function 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 | ||
| 56 | function 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 | ||
| 94 | describe("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 | 22 | } from "react"; |
| 23 | 23 | |
| 24 | 24 | import { isElectron } from "~/env"; |
| 25 | import type { DesktopPreviewOverlay } from "~/previewStateStore"; | |
| 25 | 26 | import type { RightPanelSurface } from "~/rightPanelStore"; |
| 26 | 27 | import { cn } from "~/lib/utils"; |
| 27 | 28 | import { readLocalApi } from "~/localApi"; |
| ... | ... | @@ -34,6 +35,7 @@ import { useTheme } from "~/hooks/useTheme"; |
| 34 | 35 | import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar"; |
| 35 | 36 | |
| 36 | 37 | import { PreviewPanelShell, type PreviewPanelMode } from "./preview/PreviewPanelShell"; |
| 38 | import { FaviconImage } from "./preview/PreviewFaviconIcon"; | |
| 37 | 39 | import { PierreEntryIcon } from "./chat/PierreEntryIcon"; |
| 38 | 40 | |
| 39 | 41 | interface RightPanelTabsProps { |
| ... | ... | @@ -48,6 +50,7 @@ interface RightPanelTabsProps { |
| 48 | 50 | activeSurfaceId: string | null; |
| 49 | 51 | pendingSurfaceIds: ReadonlySet<string>; |
| 50 | 52 | previewSessions: Readonly<Record<string, PreviewSessionSnapshot>>; |
| 53 | desktopByTabId: Readonly<Record<string, DesktopPreviewOverlay>>; | |
| 51 | 54 | terminalLabelsById: ReadonlyMap<string, string>; |
| 52 | 55 | onActivate: (surface: RightPanelSurface) => void; |
| 53 | 56 | onCloseSurface: (surface: RightPanelSurface) => void; |
| ... | ... | @@ -435,30 +438,35 @@ function surfaceTitle( |
| 435 | 438 | } |
| 436 | 439 | } |
| 437 | 440 | |
| 438 | function PreviewFavicon({ url }: { url: string | null }) { | |
| 439 | const faviconUrl = faviconUrlForOrigin(url, 32); | |
| 440 | const [failedUrl, setFailedUrl] = useState<string | null>(null); | |
| 441 | if (!faviconUrl || failedUrl === faviconUrl) return <Globe2 className="size-3 shrink-0" />; | |
| 441 | function PreviewFavicon({ capturedUrl, url }: { capturedUrl: string | null; url: string | null }) { | |
| 442 | const publicProviderUrl = faviconUrlForOrigin(url, 32); | |
| 442 | 443 | return ( |
| 443 | <img | |
| 444 | src={faviconUrl} | |
| 445 | alt="" | |
| 446 | aria-hidden | |
| 447 | draggable={false} | |
| 448 | className="size-3 shrink-0 rounded-sm" | |
| 449 | onError={() => setFailedUrl(faviconUrl)} | |
| 444 | <FaviconImage | |
| 445 | sources={[capturedUrl, publicProviderUrl]} | |
| 446 | fallback={<Globe2 className="size-3 shrink-0" />} | |
| 447 | className="size-3 shrink-0 rounded-sm object-contain" | |
| 450 | 448 | /> |
| 451 | 449 | ); |
| 452 | 450 | } |
| 453 | 451 | |
| 452 | function 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 | ||
| 454 | 460 | function SurfaceIcon({ |
| 455 | 461 | surface, |
| 456 | 462 | sessions, |
| 463 | desktopByTabId, | |
| 457 | 464 | theme, |
| 458 | 465 | pullRequestStatuses, |
| 459 | 466 | }: { |
| 460 | 467 | surface: RightPanelSurface; |
| 461 | 468 | sessions: Readonly<Record<string, PreviewSessionSnapshot>>; |
| 469 | desktopByTabId: Readonly<Record<string, DesktopPreviewOverlay>>; | |
| 462 | 470 | theme: "light" | "dark"; |
| 463 | 471 | pullRequestStatuses: Readonly<Record<string, PullRequestTabStatus>> | undefined; |
| 464 | 472 | }) { |
| ... | ... | @@ -466,7 +474,10 @@ function SurfaceIcon({ |
| 466 | 474 | case "preview": { |
| 467 | 475 | const snapshot = surface.resourceId ? sessions[surface.resourceId] : null; |
| 468 | 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 | 482 | case "diff": |
| 472 | 483 | return <FileDiff className="size-3 shrink-0" />; |
| ... | ... | @@ -636,6 +647,7 @@ export function RightPanelTabs(props: RightPanelTabsProps) { |
| 636 | 647 | <SurfaceIcon |
| 637 | 648 | surface={surface} |
| 638 | 649 | sessions={props.previewSessions} |
| 650 | desktopByTabId={props.desktopByTabId} | |
| 639 | 651 | theme={resolvedTheme} |
| 640 | 652 | pullRequestStatuses={props.pullRequestStatuses} |
| 641 | 653 | /> |
apps/web/src/components/preview/PreviewEmptyState.test.tsx+6-1| ... | ... | @@ -1,4 +1,4 @@ |
| 1 | import { EnvironmentId } from "@t3tools/contracts"; | |
| 1 | import { EnvironmentId, ThreadId } from "@t3tools/contracts"; | |
| 2 | 2 | import { renderToStaticMarkup } from "react-dom/server"; |
| 3 | 3 | import { describe, expect, it, vi } from "vite-plus/test"; |
| 4 | 4 | |
| ... | ... | @@ -19,10 +19,14 @@ const mocks = vi.hoisted(() => ({ |
| 19 | 19 | vi.mock("./useDiscoveredLocalServers", () => ({ |
| 20 | 20 | useDiscoveredLocalServers: () => mocks.servers, |
| 21 | 21 | })); |
| 22 | vi.mock("./PreviewFaviconIcon", () => ({ | |
| 23 | PreviewFaviconIcon: () => <span data-favicon-icon />, | |
| 24 | })); | |
| 22 | 25 | |
| 23 | 26 | import { PreviewEmptyState } from "./PreviewEmptyState"; |
| 24 | 27 | |
| 25 | 28 | const environmentId = EnvironmentId.make("env-1"); |
| 29 | const threadRef = { environmentId, threadId: ThreadId.make("thread-1") }; | |
| 26 | 30 | |
| 27 | 31 | function server(port: number) { |
| 28 | 32 | return { |
| ... | ... | @@ -41,6 +45,7 @@ function server(port: number) { |
| 41 | 45 | function render(recentEntries: Array<{ url: string; lastVisitedAt: number; title?: string }>) { |
| 42 | 46 | return renderToStaticMarkup( |
| 43 | 47 | <PreviewEmptyState |
| 48 | threadRef={threadRef} | |
| 44 | 49 | environmentId={environmentId} |
| 45 | 50 | recentEntries={recentEntries} |
| 46 | 51 | onRemoveRecent={() => undefined} |
apps/web/src/components/preview/PreviewEmptyState.tsx+5-1| ... | ... | @@ -1,4 +1,4 @@ |
| 1 | import type { EnvironmentId } from "@t3tools/contracts"; | |
| 1 | import type { EnvironmentId, ScopedThreadRef } from "@t3tools/contracts"; | |
| 2 | 2 | import { Globe, History, RadioTower } from "lucide-react"; |
| 3 | 3 | |
| 4 | 4 | import type { BrowserHistoryEntry } from "~/browserHistoryStore"; |
| ... | ... | @@ -9,6 +9,7 @@ import { PreviewRecentUrlCard } from "./PreviewRecentUrlCard"; |
| 9 | 9 | import { useDiscoveredLocalServers } from "./useDiscoveredLocalServers"; |
| 10 | 10 | |
| 11 | 11 | interface Props { |
| 12 | threadRef: ScopedThreadRef; | |
| 12 | 13 | environmentId: EnvironmentId; |
| 13 | 14 | configuredUrls?: ReadonlyArray<string> | undefined; |
| 14 | 15 | recentlySeenUrls?: ReadonlyArray<string> | undefined; |
| ... | ... | @@ -18,6 +19,7 @@ interface Props { |
| 18 | 19 | } |
| 19 | 20 | |
| 20 | 21 | export function PreviewEmptyState({ |
| 22 | threadRef, | |
| 21 | 23 | environmentId, |
| 22 | 24 | configuredUrls, |
| 23 | 25 | recentlySeenUrls, |
| ... | ... | @@ -60,6 +62,7 @@ export function PreviewEmptyState({ |
| 60 | 62 | {recents.map((entry) => ( |
| 61 | 63 | <PreviewRecentUrlCard |
| 62 | 64 | key={entry.url} |
| 65 | threadRef={threadRef} | |
| 63 | 66 | entry={entry} |
| 64 | 67 | onOpen={() => onOpenUrl(entry.url)} |
| 65 | 68 | onRemove={() => onRemoveRecent(entry.url)} |
| ... | ... | @@ -78,6 +81,7 @@ export function PreviewEmptyState({ |
| 78 | 81 | {servers.map((server) => ( |
| 79 | 82 | <PreviewLocalServerCard |
| 80 | 83 | key={`${server.host}:${server.port}`} |
| 84 | threadRef={threadRef} | |
| 81 | 85 | server={server} |
| 82 | 86 | onOpen={() => onOpenUrl(server.requestedUrl)} |
| 83 | 87 | /> |
apps/web/src/components/preview/PreviewFaviconIcon.test.tsx created+51| ... | ... | @@ -0,0 +1,51 @@ |
| 1 | import { EnvironmentId, ThreadId } from "@t3tools/contracts"; | |
| 2 | import { renderToStaticMarkup } from "react-dom/server"; | |
| 3 | import { describe, expect, it, vi } from "vite-plus/test"; | |
| 4 | ||
| 5 | const mocks = vi.hoisted(() => ({ favicon: null as string | null })); | |
| 6 | ||
| 7 | vi.mock("~/browserFaviconStore", () => ({ | |
| 8 | useFaviconForThreadUrl: () => mocks.favicon, | |
| 9 | })); | |
| 10 | ||
| 11 | import { FaviconImage, PreviewFaviconIcon, selectFaviconSource } from "./PreviewFaviconIcon"; | |
| 12 | ||
| 13 | const threadRef = { | |
| 14 | environmentId: EnvironmentId.make("env-1"), | |
| 15 | threadId: ThreadId.make("thread-1"), | |
| 16 | }; | |
| 17 | ||
| 18 | describe("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 @@ |
| 1 | import type { ScopedThreadRef } from "@t3tools/contracts"; | |
| 2 | import { type ReactNode, useState } from "react"; | |
| 3 | ||
| 4 | import { useFaviconForThreadUrl } from "~/browserFaviconStore"; | |
| 5 | import { cn } from "~/lib/utils"; | |
| 6 | ||
| 7 | import { BrowserMockup } from "./BrowserMockup"; | |
| 8 | ||
| 9 | export function selectFaviconSource( | |
| 10 | sources: ReadonlyArray<string>, | |
| 11 | failed: ReadonlySet<string>, | |
| 12 | ): string | null { | |
| 13 | return sources.find((candidate) => !failed.has(candidate)) ?? null; | |
| 14 | } | |
| 15 | ||
| 16 | export 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 | ||
| 32 | function 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 | ||
| 52 | export 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 | import { BrowserMockup } from "./BrowserMockup"; | |
| 1 | import type { ScopedThreadRef } from "@t3tools/contracts"; | |
| 2 | ||
| 3 | import { PreviewFaviconIcon } from "./PreviewFaviconIcon"; | |
| 2 | 4 | import type { PreviewableServer } from "./useDiscoveredLocalServers"; |
| 3 | 5 | |
| 4 | 6 | interface Props { |
| 7 | threadRef: ScopedThreadRef; | |
| 5 | 8 | server: PreviewableServer; |
| 6 | 9 | onOpen: () => void; |
| 7 | 10 | } |
| 8 | 11 | |
| 9 | export function PreviewLocalServerCard({ server, onOpen }: Props) { | |
| 12 | export function PreviewLocalServerCard({ threadRef, server, onOpen }: Props) { | |
| 10 | 13 | const subtitle = describeServer(server); |
| 11 | 14 | return ( |
| 12 | 15 | <button |
| ... | ... | @@ -14,7 +17,7 @@ export function PreviewLocalServerCard({ server, onOpen }: Props) { |
| 14 | 17 | onClick={onOpen} |
| 15 | 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 | 21 | <div className="flex min-w-0 flex-1 flex-col"> |
| 19 | 22 | <span className="truncate text-sm font-medium text-foreground">{subtitle}</span> |
| 20 | 23 | <span className="truncate text-xs text-muted-foreground"> |
apps/web/src/components/preview/PreviewRecentUrlCard.tsx+5-3| ... | ... | @@ -1,18 +1,20 @@ |
| 1 | import type { ScopedThreadRef } from "@t3tools/contracts"; | |
| 1 | 2 | import { X } from "lucide-react"; |
| 2 | 3 | |
| 3 | 4 | import { isValidHistoryTimestamp, type BrowserHistoryEntry } from "~/browserHistoryStore"; |
| 4 | 5 | import { useNowMinute } from "~/hooks/useNowMinute"; |
| 5 | 6 | import { formatRelativeTimeLabel } from "~/timestampFormat"; |
| 6 | 7 | |
| 7 | import { BrowserMockup } from "./BrowserMockup"; | |
| 8 | import { PreviewFaviconIcon } from "./PreviewFaviconIcon"; | |
| 8 | 9 | |
| 9 | 10 | interface Props { |
| 11 | threadRef: ScopedThreadRef; | |
| 10 | 12 | entry: BrowserHistoryEntry; |
| 11 | 13 | onOpen: () => void; |
| 12 | 14 | onRemove: () => void; |
| 13 | 15 | } |
| 14 | 16 | |
| 15 | export function PreviewRecentUrlCard({ entry, onOpen, onRemove }: Props) { | |
| 17 | export function PreviewRecentUrlCard({ threadRef, entry, onOpen, onRemove }: Props) { | |
| 16 | 18 | const parsed = new URL(entry.url); |
| 17 | 19 | const path = parsed.pathname === "/" ? "" : parsed.pathname; |
| 18 | 20 | const label = `${parsed.host}${path}${parsed.search}${parsed.hash}`; |
| ... | ... | @@ -27,7 +29,7 @@ export function PreviewRecentUrlCard({ entry, onOpen, onRemove }: Props) { |
| 27 | 29 | onClick={onOpen} |
| 28 | 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 | 33 | <div className="flex min-w-0 flex-1 flex-col"> |
| 32 | 34 | <span className="truncate text-sm font-medium text-foreground"> |
| 33 | 35 | {entry.title ?? label} |
apps/web/src/components/preview/PreviewView.tsx+1| ... | ... | @@ -710,6 +710,7 @@ export function PreviewView({ |
| 710 | 710 | ) : null} |
| 711 | 711 | {showEmptyState ? ( |
| 712 | 712 | <PreviewEmptyState |
| 713 | threadRef={threadRef} | |
| 713 | 714 | environmentId={threadRef.environmentId} |
| 714 | 715 | configuredUrls={configuredUrls} |
| 715 | 716 | recentlySeenUrls={previewState.recentlySeenUrls} |
apps/web/src/components/preview/usePreviewBridge.test.ts created+48| ... | ... | @@ -0,0 +1,48 @@ |
| 1 | import type { DesktopPreviewTabState } from "@t3tools/contracts"; | |
| 2 | import { describe, expect, it } from "vite-plus/test"; | |
| 3 | ||
| 4 | import { projectDesktopState } from "./usePreviewBridge"; | |
| 5 | ||
| 6 | const favicon = { | |
| 7 | dataUrl: "data:image/png;base64,AAAA", | |
| 8 | pageUrl: "http://localhost:3000/app", | |
| 9 | capturedAt: 1, | |
| 10 | }; | |
| 11 | ||
| 12 | function 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 | ||
| 28 | describe("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 | 6 | ScopedThreadRef, |
| 7 | 7 | ThreadId, |
| 8 | 8 | } from "@t3tools/contracts"; |
| 9 | import { useEffect, useRef } from "react"; | |
| 9 | import { parseScopedThreadKey, scopedThreadKey } from "@t3tools/client-runtime/environment"; | |
| 10 | import * as Option from "effect/Option"; | |
| 11 | import { useEffect, useEffectEvent, useMemo, useRef } from "react"; | |
| 10 | 12 | |
| 13 | import { | |
| 14 | flushPendingFaviconsForThread, | |
| 15 | recordFaviconForThread, | |
| 16 | useFaviconProjectRefForThread, | |
| 17 | } from "~/browserFaviconStore"; | |
| 11 | 18 | import { useBrowserPointerStore } from "~/browser/browserPointerStore"; |
| 12 | 19 | import { applyPreviewDesktopState, type DesktopPreviewOverlay } from "~/previewStateStore"; |
| 13 | 20 | import { previewEnvironment } from "~/state/preview"; |
| 21 | import { usePreparedConnection } from "~/state/session"; | |
| 14 | 22 | import { useAtomCommand } from "~/state/use-atom-command"; |
| 15 | 23 | |
| 16 | 24 | import { previewBridge } from "./previewBridge"; |
| 17 | 25 | |
| 26 | function originOf(url: string): string | null { | |
| 27 | try { | |
| 28 | return new URL(url).origin; | |
| 29 | } catch { | |
| 30 | return null; | |
| 31 | } | |
| 32 | } | |
| 33 | ||
| 18 | 34 | /** |
| 19 | 35 | * Mirrors low-latency desktop state into the store and reflects navigation |
| 20 | 36 | * events back to the server. Webview lifetime is owned by ElectronBrowserHost. |
| ... | ... | @@ -28,26 +44,36 @@ export function usePreviewBridge(input: { |
| 28 | 44 | const clearBrowserPointer = useBrowserPointerStore((state) => state.clear); |
| 29 | 45 | const reportStatus = useAtomCommand(previewEnvironment.reportStatus, "preview status report"); |
| 30 | 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; | |
| 31 | 58 | |
| 32 | 59 | // One bridge subscription does both jobs (mirror state + forward to |
| 33 | 60 | // server) so the desktop bridge keeps a single listener entry per tab. |
| 34 | 61 | const lastReportedUrl = useRef<string | null>(null); |
| 35 | 62 | const lastReportedKind = useRef<DesktopPreviewTabState["navStatus"]["kind"] | null>(null); |
| 36 | 63 | const lastDesktopNavStatus = useRef<DesktopPreviewTabState["navStatus"] | null>(null); |
| 37 | useEffect(() => { | |
| 38 | if (!bridge || typeof window === "undefined") return; | |
| 39 | lastReportedUrl.current = null; | |
| 40 | lastReportedKind.current = null; | |
| 41 | lastDesktopNavStatus.current = null; | |
| 42 | const unsubscribe = bridge.onStateChange((changedTabId, state) => { | |
| 64 | const handleStateChange = useEffectEvent( | |
| 65 | (changedTabId: string, state: DesktopPreviewTabState): void => { | |
| 43 | 66 | if (changedTabId !== runtimeTabId) return; |
| 44 | 67 | if (shouldClearBrowserPointer(lastDesktopNavStatus.current, state.navStatus)) { |
| 45 | 68 | clearBrowserPointer(runtimeTabId); |
| 46 | 69 | } |
| 47 | 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 | 75 | const reported = buildReportInput({ |
| 50 | threadId: threadRef.threadId, | |
| 76 | threadId: stableThreadRef.threadId, | |
| 51 | 77 | tabId, |
| 52 | 78 | state, |
| 53 | 79 | lastReportedUrl: lastReportedUrl.current, |
| ... | ... | @@ -57,12 +83,22 @@ export function usePreviewBridge(input: { |
| 57 | 83 | lastReportedUrl.current = reported.lastReportedUrl; |
| 58 | 84 | lastReportedKind.current = reported.lastReportedKind; |
| 59 | 85 | void reportStatus({ |
| 60 | environmentId: threadRef.environmentId, | |
| 86 | environmentId: stableThreadRef.environmentId, | |
| 61 | 87 | input: reported.input, |
| 62 | 88 | }); |
| 63 | }); | |
| 64 | return unsubscribe; | |
| 65 | }, [bridge, clearBrowserPointer, reportStatus, runtimeTabId, tabId, threadRef]); | |
| 89 | }, | |
| 90 | ); | |
| 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 | } |
| 67 | 103 | |
| 68 | 104 | function shouldClearBrowserPointer( |
| ... | ... | @@ -75,7 +111,8 @@ function shouldClearBrowserPointer( |
| 75 | 111 | return current.url !== previous.url; |
| 76 | 112 | } |
| 77 | 113 | |
| 78 | function projectDesktopState(state: DesktopPreviewTabState): DesktopPreviewOverlay { | |
| 114 | export function projectDesktopState(state: DesktopPreviewTabState): DesktopPreviewOverlay { | |
| 115 | const navOrigin = state.navStatus.kind === "Idle" ? null : originOf(state.navStatus.url); | |
| 79 | 116 | return { |
| 80 | 117 | hasWebContents: state.webContentsId !== null, |
| 81 | 118 | canGoBack: state.canGoBack, |
| ... | ... | @@ -85,6 +122,7 @@ function projectDesktopState(state: DesktopPreviewTabState): DesktopPreviewOverl |
| 85 | 122 | pictureInPicture: state.pictureInPicture, |
| 86 | 123 | colorScheme: state.colorScheme, |
| 87 | 124 | controller: state.controller, |
| 125 | favicon: state.favicon && originOf(state.favicon.pageUrl) === navOrigin ? state.favicon : null, | |
| 88 | 126 | }; |
| 89 | 127 | } |
| 90 | 128 |
apps/web/src/lib/favicon.test.ts created+42| ... | ... | @@ -0,0 +1,42 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | |
| 2 | ||
| 3 | import { faviconUrlForOrigin } from "./favicon"; | |
| 4 | ||
| 5 | describe("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 | import { isPublicFaviconHost } from "~/browser/browserTargetResolver"; | |
| 2 | ||
| 1 | 3 | /** |
| 2 | 4 | * Favicon helpers for the preview tab strip. |
| 3 | 5 | * |
| ... | ... | @@ -13,6 +15,7 @@ export function faviconUrlForOrigin(rawUrl: string | null | undefined, size = 32 |
| 13 | 15 | const url = new URL(rawUrl); |
| 14 | 16 | if (!url.host) return null; |
| 15 | 17 | if (url.protocol !== "http:" && url.protocol !== "https:") return null; |
| 18 | if (!isPublicFaviconHost(url.hostname)) return null; | |
| 16 | 19 | return `${FAVICON_PROVIDER}?domain=${encodeURIComponent(url.host)}&sz=${size}`; |
| 17 | 20 | } catch { |
| 18 | 21 | return null; |
apps/web/src/previewStateStore.test.ts+4| ... | ... | @@ -322,6 +322,7 @@ describe("previewStateStore (single-tab)", () => { |
| 322 | 322 | pictureInPicture: false, |
| 323 | 323 | colorScheme: "system", |
| 324 | 324 | controller: "none", |
| 325 | favicon: null, | |
| 325 | 326 | }); |
| 326 | 327 | const state = readThreadPreviewState(ref); |
| 327 | 328 | expect(state.desktopOverlay?.canGoBack).toBe(true); |
| ... | ... | @@ -342,6 +343,7 @@ describe("previewStateStore (single-tab)", () => { |
| 342 | 343 | pictureInPicture: false, |
| 343 | 344 | colorScheme: "system", |
| 344 | 345 | controller: "none", |
| 346 | favicon: null, | |
| 345 | 347 | }); |
| 346 | 348 | setActivePreviewTab(ref, first.tabId); |
| 347 | 349 | |
| ... | ... | @@ -390,6 +392,7 @@ describe("previewStateStore (single-tab)", () => { |
| 390 | 392 | pictureInPicture: false, |
| 391 | 393 | colorScheme: "system", |
| 392 | 394 | controller: "none", |
| 395 | favicon: null, | |
| 393 | 396 | }); |
| 394 | 397 | |
| 395 | 398 | reconcilePreviewServerSessions(ref, { sessions: [active], serverEpoch, revision: 1 }); |
| ... | ... | @@ -504,6 +507,7 @@ describe("previewStateStore (single-tab)", () => { |
| 504 | 507 | pictureInPicture: false, |
| 505 | 508 | colorScheme: "system", |
| 506 | 509 | controller: "none", |
| 510 | favicon: null, | |
| 507 | 511 | }); |
| 508 | 512 | const restarted = makeSnapshot({ |
| 509 | 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 | 9 | import { scopedThreadKey } from "@t3tools/client-runtime/environment"; |
| 10 | 10 | import { |
| 11 | 11 | type DesktopPreviewColorScheme, |
| 12 | type DesktopPreviewFavicon, | |
| 12 | 13 | type PreviewEvent, |
| 13 | 14 | type PreviewListResult, |
| 14 | 15 | type PreviewSessionSnapshot, |
| ... | ... | @@ -28,6 +29,7 @@ export interface DesktopPreviewOverlay { |
| 28 | 29 | pictureInPicture: boolean; |
| 29 | 30 | colorScheme: DesktopPreviewColorScheme; |
| 30 | 31 | controller: "human" | "agent" | "none"; |
| 32 | favicon: DesktopPreviewFavicon | null; | |
| 31 | 33 | } |
| 32 | 34 | |
| 33 | 35 | export 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 | 175 | /** Stable so a read that is not wanted right now does not re-key on every render. */ |
| 176 | 176 | const NO_LIST_TARGETS: ReadonlyArray<EnvironmentQueryTarget<PullRequestListInput>> = []; |
| 177 | 177 | const EMPTY_PREVIEW_SESSIONS = {}; |
| 178 | const EMPTY_PREVIEW_DESKTOP_STATE = {}; | |
| 178 | 179 | const EMPTY_TERMINAL_LABELS = new Map<string, string>(); |
| 179 | 180 | const EMPTY_PENDING_SURFACES = new Set<string>(); |
| 180 | 181 | |
| ... | ... | @@ -1540,6 +1541,7 @@ function PullRequestsRouteView() { |
| 1540 | 1541 | activeSurfaceId={activePullRequestSurface.id} |
| 1541 | 1542 | pendingSurfaceIds={EMPTY_PENDING_SURFACES} |
| 1542 | 1543 | previewSessions={EMPTY_PREVIEW_SESSIONS} |
| 1544 | desktopByTabId={EMPTY_PREVIEW_DESKTOP_STATE} | |
| 1543 | 1545 | terminalLabelsById={EMPTY_TERMINAL_LABELS} |
| 1544 | 1546 | onActivate={(surface) => { |
| 1545 | 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 | 524 | export const DesktopPreviewColorSchemeSchema: Schema.Codec<DesktopPreviewColorScheme> = |
| 525 | 525 | Schema.Literals(["system", "light", "dark"]); |
| 526 | 526 | |
| 527 | export const FAVICON_DATA_URL_MAX_LENGTH = 8192; | |
| 528 | export const FAVICON_CAPTURED_AT_MAX = 8_640_000_000_000_000; | |
| 529 | ||
| 530 | export interface DesktopPreviewFavicon { | |
| 531 | dataUrl: string; | |
| 532 | pageUrl: string; | |
| 533 | capturedAt: number; | |
| 534 | } | |
| 535 | ||
| 536 | export 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 | ||
| 527 | 549 | export interface DesktopPreviewTabState { |
| 528 | 550 | tabId: string; |
| 529 | 551 | webContentsId: number | null; |
| ... | ... | @@ -536,6 +558,7 @@ export interface DesktopPreviewTabState { |
| 536 | 558 | pictureInPicture: boolean; |
| 537 | 559 | colorScheme: DesktopPreviewColorScheme; |
| 538 | 560 | controller: "human" | "agent" | "none"; |
| 561 | favicon?: DesktopPreviewFavicon; | |
| 539 | 562 | updatedAt: string; |
| 540 | 563 | } |
| 541 | 564 | |
| ... | ... | @@ -574,6 +597,7 @@ export const DesktopPreviewTabStateSchema: Schema.Codec<DesktopPreviewTabState> |
| 574 | 597 | pictureInPicture: Schema.Boolean, |
| 575 | 598 | colorScheme: DesktopPreviewColorSchemeSchema, |
| 576 | 599 | controller: Schema.Literals(["human", "agent", "none"]), |
| 600 | favicon: Schema.optionalKey(DesktopPreviewFaviconSchema), | |
| 577 | 601 | updatedAt: Schema.String, |
| 578 | 602 | }); |
| 579 | 603 |