diff --git a/apps/desktop/src/ipc/methods/window.test.ts b/apps/desktop/src/ipc/methods/window.test.ts index 155b89c1cc57b..ed821165b40f7 100644 --- a/apps/desktop/src/ipc/methods/window.test.ts +++ b/apps/desktop/src/ipc/methods/window.test.ts @@ -232,6 +232,7 @@ describe("pickProjectFavicon", () => { "gif", "ico", "heic", + "heif", "jpeg", "jpg", "png", diff --git a/apps/mobile/src/features/threads/fileChipMenu.test.ts b/apps/mobile/src/features/threads/fileChipMenu.test.ts index 35757b8ae9196..5c992b58f379a 100644 --- a/apps/mobile/src/features/threads/fileChipMenu.test.ts +++ b/apps/mobile/src/features/threads/fileChipMenu.test.ts @@ -59,6 +59,14 @@ describe("file chip downloads", () => { ["/tmp/take%2520%23one.mp4:12", "/tmp/take%20#one.mp4", "video/mp4"], ["/tmp/report.pdf", "/tmp/report.pdf", "application/pdf"], ["screens/image.PNG", "/repo/screens/image.PNG", "image/png"], + ["/tmp/archive.zip", "/tmp/archive.zip", "application/zip"], + ["/tmp/archive.jar", "/tmp/archive.jar", "application/java-archive"], + ["/tmp/archive.tar", "/tmp/archive.tar", "application/x-tar"], + ["/tmp/archive.gz", "/tmp/archive.gz", "application/gzip"], + ["/tmp/archive.tgz", "/tmp/archive.tgz", "application/gzip"], + ["/tmp/data.sqlite", "/tmp/data.sqlite", "application/vnd.sqlite3"], + ["/tmp/data.sqlite3", "/tmp/data.sqlite3", "application/vnd.sqlite3"], + ["/tmp/data.db", "/tmp/data.db", "application/vnd.sqlite3"], ])("offers a host download for %s", (href, path, mimeType) => { const target = resolveFileChipTarget(href, "/repo")!; expect(fileChipMenu(target).actions).toContainEqual({ @@ -73,7 +81,7 @@ describe("file chip downloads", () => { }); it("does not offer downloads the host asset endpoint cannot serve", () => { - for (const href of ["src/app.ts", "/tmp/archive.zip", "/tmp/clip.mp4.txt"]) { + for (const href of ["src/app.ts", "/tmp/archive.7z", "/tmp/clip.mp4.txt"]) { const target = resolveFileChipTarget(href, "/repo")!; expect(fileChipShareSource(target, threadId)).toBeNull(); expect(fileChipMenu(target).actions.some(({ id }) => id === "save")).toBe(false); diff --git a/apps/mobile/src/lib/markdownMedia.ts b/apps/mobile/src/lib/markdownMedia.ts index 06d12256e2807..31dcefb29f127 100644 --- a/apps/mobile/src/lib/markdownMedia.ts +++ b/apps/mobile/src/lib/markdownMedia.ts @@ -5,7 +5,7 @@ import { import { audioMimeTypeFromExtension, mediaMimeTypeFromExtension, -} from "@t3tools/shared/filePreview"; +} from "@t3tools/shared/mediaPreview"; import { splitFilePathPosition } from "@t3tools/shared/fileLinks"; import { parseMarkdownFileLink } from "@t3tools/shared/markdownLinks"; import { diff --git a/apps/server/src/assets/AssetAccess.test.ts b/apps/server/src/assets/AssetAccess.test.ts index 70cbe82968b92..b208f09d96e84 100644 --- a/apps/server/src/assets/AssetAccess.test.ts +++ b/apps/server/src/assets/AssetAccess.test.ts @@ -223,6 +223,14 @@ describe("AssetAccess", () => { ["report.pdf", "application/pdf"], ["tone.wav", "audio/wav"], ["tone.webm-audio", "audio/webm"], + ["archive.zip", "application/zip"], + ["archive.jar", "application/java-archive"], + ["archive.tar", "application/x-tar"], + ["archive.gz", "application/gzip"], + ["archive.tgz", "application/gzip"], + ["data.sqlite", "application/vnd.sqlite3"], + ["data.sqlite3", "application/vnd.sqlite3"], + ["data.db", "application/vnd.sqlite3"], ] as const) { const filePath = path.join(outside, name); yield* fs.writeFileString(filePath, "media"); @@ -1022,6 +1030,14 @@ describe("AssetAccess", () => { "otf", "woff", "woff2", + "zip", + "jar", + "tar", + "gz", + "tgz", + "sqlite", + "sqlite3", + "db", ]) { const name = `preview.${extension}`; const filePath = path.join(assetsDirectory, name); @@ -1033,7 +1049,11 @@ describe("AssetAccess", () => { ); const canonicalPath = yield* fileSystem.realPath(filePath); for (const resource of [ - { _tag: "workspace-file" as const, threadId: ThreadId.make("thread-1"), path: filePath }, + { + _tag: "workspace-file" as const, + threadId: ThreadId.make("thread-1"), + path: filePath, + }, { _tag: "draft-workspace-file" as const, cwd: root, path: `assets/${name}` }, ]) { const result = yield* issueAssetUrl({ resource, workspaceRoot: root }); @@ -1162,6 +1182,14 @@ describe("AssetAccess", () => { ["woff", "font/woff"], ["woff2", "font/woff2"], ["weba", "audio/webm"], + ["zip", "application/zip"], + ["jar", "application/java-archive"], + ["tar", "application/x-tar"], + ["gz", "application/gzip"], + ["tgz", "application/gzip"], + ["sqlite", "application/vnd.sqlite3"], + ["sqlite3", "application/vnd.sqlite3"], + ["db", "application/vnd.sqlite3"], ] as const) { const attachmentId = `thread-1-00000000-0000-4000-8000-000000000001-${extension}`; const attachmentPath = path.join(config.attachmentsDir, `${attachmentId}.${extension}`); @@ -1172,7 +1200,7 @@ describe("AssetAccess", () => { _tag: "attachment", attachmentId, fileName: `report.${extension}`, - mimeType: "application/octet-stream", + mimeType: "text/html", disposition: "inline", }, }); @@ -1196,8 +1224,8 @@ describe("AssetAccess", () => { const config = yield* ServerConfig.ServerConfig; const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const attachmentId = "thread-1-00000000-0000-4000-8000-000000000002-zip"; - const attachmentPath = path.join(config.attachmentsDir, `${attachmentId}.zip`); + const attachmentId = "thread-1-00000000-0000-4000-8000-000000000002-7z"; + const attachmentPath = path.join(config.attachmentsDir, `${attachmentId}.7z`); yield* fileSystem.makeDirectory(config.attachmentsDir, { recursive: true }); yield* fileSystem.writeFile(attachmentPath, new Uint8Array([1, 2, 3])); @@ -1205,7 +1233,7 @@ describe("AssetAccess", () => { resource: { _tag: "attachment", attachmentId, - fileName: "archive.zip", + fileName: "archive.7z", mimeType: "text/html", disposition: "inline", }, diff --git a/apps/web/package.json b/apps/web/package.json index c5248737ce4a5..938feb52a9304 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -80,7 +80,9 @@ "remark-math": "6.0.0", "remark-parse": "catalog:", "saxes": "6.0.0", + "sql.js": "1.15.0", "tailwind-merge": "^3.4.0", + "tiff": "7.1.3", "unbash": "^5.0.0", "unified": "catalog:", "use-sync-external-store": "^1.6.0", @@ -96,6 +98,7 @@ "@types/mdast": "^4.0.4", "@types/react": "catalog:", "@types/react-dom": "catalog:", + "@types/sql.js": "1.4.11", "@types/use-sync-external-store": "^0.0.6", "@vercel/config": "^0.3.0", "@vitejs/plugin-react": "^6.1.2", diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index a06f86f4e94d7..e5691c8cbd35e 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -63,7 +63,7 @@ import { markdownImageSourceFragment, } from "@t3tools/client-runtime/markdown-images"; import { mediaUrlReference } from "@t3tools/client-runtime/media-reference"; -import { mediaKindFromPath, mediaMimeTypeFromExtension } from "@t3tools/shared/filePreview"; +import { mediaKindFromPath, mediaMimeTypeFromExtension } from "@t3tools/shared/mediaPreview"; import { InlineAudioPreview } from "./files/InlineAudioPreview"; import * as Cause from "effect/Cause"; import * as Option from "effect/Option"; diff --git a/apps/web/src/components/chat/composerAttachmentFiles.ts b/apps/web/src/components/chat/composerAttachmentFiles.ts index 283aef09dc8e6..96575d393018e 100644 --- a/apps/web/src/components/chat/composerAttachmentFiles.ts +++ b/apps/web/src/components/chat/composerAttachmentFiles.ts @@ -12,7 +12,7 @@ import type { ComposerFileAttachment, ComposerImageAttachment } from "../../comp import { isHeicImageFile } from "../../lib/imageCompression"; import { isVideoAttachment, videoMimeType } from "../../types"; import { isSvgAttachment } from "@t3tools/shared/image"; -import { isWorkspaceImagePreviewPath } from "@t3tools/shared/filePreview"; +import { isWorkspaceImagePreviewPath } from "@t3tools/shared/mediaPreview"; type ComposerAttachmentFileKind = "image" | "file" | "unsupported-image"; diff --git a/apps/web/src/components/chat/markdownImageGallery.ts b/apps/web/src/components/chat/markdownImageGallery.ts index 464e97adf2bd4..ea49e5826be13 100644 --- a/apps/web/src/components/chat/markdownImageGallery.ts +++ b/apps/web/src/components/chat/markdownImageGallery.ts @@ -1,4 +1,4 @@ -import { mediaKindFromPath } from "@t3tools/shared/filePreview"; +import { mediaKindFromPath } from "@t3tools/shared/mediaPreview"; import { mediaUrlReference } from "@t3tools/client-runtime/media-reference"; import type { ExpandedImageItem, ExpandedImagePreview } from "./ExpandedImagePreview"; import { resolveExternalWebLinkHost } from "./externalLinkContextMenu"; diff --git a/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx b/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx index 263e9fd2eb050..aa21c06f8033c 100644 --- a/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx +++ b/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx @@ -1,7 +1,7 @@ import { act, render, stubGlobal, type Screen } from "../../../test/render"; import { EnvironmentId, ThreadId } from "@t3tools/contracts"; import { describe, expect, it, vi } from "vite-plus/test"; -import { page } from "vite-plus/test/browser"; +import { page, userEvent } from "vite-plus/test/browser"; import "~/index.css"; const { refresh, assetUrlState } = vi.hoisted(() => ({ @@ -25,6 +25,414 @@ import LargeTextViewer from "./LargeTextViewer"; import VirtualTablePreview from "./VirtualTablePreview"; import { OfficeFilePreview } from "./OfficeFilePreview"; import { MermaidFilePreview } from "./MermaidFilePreview"; +import JSZip from "jszip"; +import { boundedZip } from "./boundedZip"; +import BinaryViewer from "./BinaryViewer"; +import { decodeImagePreview } from "./decodeImagePreview"; +import type { BinaryWorkerResult } from "./binaryPreviewProtocol"; + +// Real worker imports and WASM initialization can exceed waitFor's default second +// when the full browser project runs concurrently on CI. +describe("bounded binary previews", () => { + it("preserves length-delimited local and global PAX attributes", async () => { + const encode = (value: string) => new TextEncoder().encode(value); + const block = (name: string, content: Uint8Array, type = "0", size = content.length) => { + const bytes = new Uint8Array(512 + Math.ceil(content.length / 512) * 512); + const write = (offset: number, value: string) => bytes.set(encode(value), offset); + write(0, name); + write(124, size.toString(8).padStart(11, "0") + "\0"); + bytes.fill(32, 148, 156); + write(156, type); + write( + 148, + bytes + .subarray(0, 512) + .reduce((sum, byte) => sum + byte, 0) + .toString(8) + .padStart(6, "0") + "\0 ", + ); + bytes.set(content, 512); + return bytes; + }; + const attributes = (values: Record) => + encode( + Object.entries(values) + .map(([key, value]) => { + const payload = ` ${key}=${value}\n`; + let record = `${encode(payload).length + 1}${payload}`; + while (encode(record).length !== Number.parseInt(record, 10)) + record = `${encode(record).length}${payload}`; + return record; + }) + .join(""), + ); + const chunks = [ + block("Global", attributes({ size: "5", mtime: "-86400" }), "g"), + block("Local", attributes({ path: "café\n9 size=1\nname.txt" }), "x"), + block("first.txt", encode("hello"), "0", 0), + block("world.txt", encode("world"), "0", 0), + block("Local", attributes({ size: "3", mtime: "-172800" }), "x"), + block("short.txt", encode("bye"), "0", 0), + block("again.txt", encode("again"), "0", 0), + new Uint8Array(1024), + ]; + const bytes = new Uint8Array(chunks.reduce((size, chunk) => size + chunk.length, 0)); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.length; + } + const worker = new Worker(new URL("./binaryPreview.worker.ts", import.meta.url), { + type: "module", + }); + const request = (input: object) => + new Promise((resolve, reject) => { + worker.addEventListener("message", (event) => resolve(event.data), { once: true }); + worker.addEventListener("error", reject, { once: true }); + worker.postMessage(input, []); + }); + try { + const result = await request({ + type: "open", + kind: "archive", + name: "pax.tar", + bytes: bytes.buffer, + }); + expect(result.type).toBe("archive"); + if (result.type !== "archive") throw new Error("Expected archive"); + expect(result.entries.map((entry) => [entry.path, entry.size, entry.modified])).toEqual([ + ["café\n9 size=1\nname.txt", 5, "1969-12-31T00:00:00.000Z"], + ["world.txt", 5, "1969-12-31T00:00:00.000Z"], + ["short.txt", 3, "1969-12-30T00:00:00.000Z"], + ["again.txt", 5, "1969-12-31T00:00:00.000Z"], + ]); + const entry = await request({ type: "entry", path: "café\n9 size=1\nname.txt" }); + if (entry.type !== "entry") throw new Error("Expected entry"); + expect(new TextDecoder().decode(entry.bytes)).toBe("hello"); + expect(entry.bytes.buffer.byteLength).toBe(5); + entry.bytes[0] = 0; + const reopened = await request({ type: "entry", path: entry.path }); + if (reopened.type !== "entry") throw new Error("Expected entry"); + expect(new TextDecoder().decode(reopened.bytes)).toBe("hello"); + expect(reopened.bytes.buffer.byteLength).toBe(5); + } finally { + worker.terminate(); + } + }); + + it("can leave an archive folder with a repeated separator", async () => { + const bytes = new Uint8Array(2048); + const write = (offset: number, value: string) => + bytes.set(new TextEncoder().encode(value), offset); + write(0, "a//hello.txt"); + write(124, "00000000014\0"); + bytes.fill(32, 148, 156); + write(156, "0"); + write( + 148, + bytes + .subarray(0, 512) + .reduce((sum, byte) => sum + byte, 0) + .toString(8) + .padStart(6, "0") + "\0 ", + ); + write(512, "nested hello"); + const src = URL.createObjectURL(new Blob([bytes])); + try { + await using screen = await render(); + const child = () => screen.container.querySelector("tbody button"); + await vi.waitFor(() => expect(child()?.textContent).toBe("a"), 10_000); + await act(() => child()!.click()); + await act(() => child()!.click()); + await vi.waitFor(() => expect(child()?.textContent).toBe("hello.txt")); + const parent = () => + screen.container.querySelector('[aria-label="Parent folder"]')!; + await act(() => parent().click()); + await act(() => parent().click()); + expect(parent().disabled).toBe(true); + expect(child()?.textContent).toBe("a"); + } finally { + URL.revokeObjectURL(src); + } + }); + + it("uses a PAX size override for the following TAR payload and bounds it", async () => { + const archive = (size: string) => { + const header = (name: string, size: number, type: string) => { + const bytes = new Uint8Array(512); + const write = (offset: number, value: string) => + bytes.set(new TextEncoder().encode(value), offset); + write(0, name); + write(124, size.toString(8).padStart(11, "0") + "\0"); + bytes.fill(32, 148, 156); + write(156, type); + write( + 148, + bytes + .reduce((sum, byte) => sum + byte, 0) + .toString(8) + .padStart(6, "0") + "\0 ", + ); + return bytes; + }; + const payload = ` size=${size}\n`; + let record = `${payload.length + 1}${payload}`; + while (record.length !== Number.parseInt(record, 10)) record = `${record.length}${payload}`; + const bytes = new Uint8Array(3072); + bytes.set(header("PaxHeader", record.length, "x")); + bytes.set(new TextEncoder().encode(record), 512); + bytes.set(header("hello.txt", 0, "0"), 1024); + bytes.set(new TextEncoder().encode("hello"), 1536); + return bytes; + }; + for (const size of ["5", "999999999999999999999"]) { + const src = URL.createObjectURL(new Blob([archive(size)])); + try { + await using screen = await render(); + if (size === "5") { + await vi.waitFor( + () => + expect(screen.container.querySelector("tbody button")?.textContent).toBe("hello.txt"), + 10_000, + ); + await act(() => + screen.container.querySelector("tbody button")!.click(), + ); + await vi.waitFor( + () => expect(screen.container.querySelector("pre")?.textContent).toBe("hello"), + 10_000, + ); + } else { + await vi.waitFor( + () => + expect(screen.container.querySelector('[role="alert"]')?.textContent).toContain( + "too large or damaged", + ), + 10_000, + ); + } + } finally { + URL.revokeObjectURL(src); + } + } + }); + + it.each([320, 1280])( + "shows generated SQLite columns and retains picker focus at %i px", + async (width) => { + await page.viewport(width, 800); + const [{ default: initSqlJs }, { default: wasmUrl }] = await Promise.all([ + import("sql.js"), + import("sql.js/dist/sql-wasm.wasm?url"), + ]); + const SQL = await initSqlJs({ locateFile: () => wasmUrl }); + const database = new SQL.Database(); + database.run( + "CREATE TABLE sqliteData (price INTEGER, quantity INTEGER, total INTEGER GENERATED ALWAYS AS (price * quantity) STORED, doubled INTEGER GENERATED ALWAYS AS (price * 2) VIRTUAL); INSERT INTO sqliteData(price, quantity) VALUES (7, 3); CREATE TABLE zzInventory (stock INTEGER); INSERT INTO zzInventory VALUES(42)", + ); + const src = URL.createObjectURL(new Blob([new Uint8Array(database.export())])); + database.close(); + try { + await using screen = await render( + , + ); + await vi.waitFor( + () => + expect([...screen.container.querySelectorAll("th")].map((x) => x.textContent)).toEqual([ + "price", + "quantity", + "total", + "doubled", + ]), + 10_000, + ); + expect([...screen.container.querySelectorAll("td")].map((x) => x.textContent)).toEqual([ + "7", + "3", + "21", + "14", + ]); + const trigger = screen.container.querySelector('[aria-label="Table"]')!; + await act(() => trigger.focus()); + await userEvent.keyboard("{Enter}"); + await expect + .element(page.getByRole("option", { name: "zzInventory (1 rows)" })) + .toBeVisible(); + await userEvent.keyboard("{ArrowDown}{Enter}"); + await vi.waitFor(() => + expect([...screen.container.querySelectorAll("td")].map((x) => x.textContent)).toEqual([ + "42", + ]), + ); + await vi.waitFor(() => expect(document.activeElement).toBe(trigger)); + await userEvent.keyboard("{Enter}"); + await expect + .element(page.getByRole("option", { name: "zzInventory (1 rows)" })) + .toBeVisible(); + await userEvent.keyboard("{Escape}"); + } finally { + URL.revokeObjectURL(src); + await page.viewport(1280, 800); + } + }, + ); + + it("reauthorizes a failed image when retry is clicked", async () => { + const image = + 'data:image/svg+xml,'; + refresh + .mockReset() + .mockResolvedValueOnce("data:image/png;base64,broken") + .mockResolvedValue(image); + await using screen = await render( + , + ); + await vi.waitFor(() => + expect(screen.container.querySelector('[role="alert"]')?.textContent).toContain( + "Unable to load image.", + ), + ); + await act(() => + screen.container.querySelector('[role="alert"] button')!.click(), + ); + await vi.waitFor(() => + expect(screen.container.querySelector("img")?.getAttribute("src")).toBe(image), + ); + expect(refresh).toHaveBeenCalledTimes(2); + }); + + it("reads a small ZIP64 archive and rejects understated directory counts", async () => { + const zip = new JSZip().file("readme.txt", "hello"); + const bytes = await zip.generateAsync({ type: "uint8array" }); + const end = bytes.length - 22; + const legacy = new DataView(bytes.buffer); + const extended = new Uint8Array(bytes.length + 76); + extended.set(bytes.subarray(0, end)); + extended.set(bytes.subarray(end), end + 76); + const view = new DataView(extended.buffer); + view.setUint32(end, 0x06064b50, true); + view.setBigUint64(end + 4, 44n, true); + view.setBigUint64(end + 24, 1n, true); + view.setBigUint64(end + 32, 1n, true); + view.setBigUint64(end + 40, BigInt(legacy.getUint32(end + 12, true)), true); + view.setBigUint64(end + 48, BigInt(legacy.getUint32(end + 16, true)), true); + view.setUint32(end + 56, 0x07064b50, true); + view.setBigUint64(end + 64, BigInt(end), true); + view.setUint32(end + 72, 1, true); + view.setUint16(end + 76 + 10, 0xffff, true); + expect( + new TextDecoder().decode((await boundedZip(extended.buffer)).get("readme.txt")?.bytes), + ).toBe("hello"); + + const redirected = new Uint8Array(extended.length + 56); + redirected.set(extended.subarray(0, end + 56)); + redirected.set(extended.subarray(end + 56), end + 112); + const redirect = new DataView(redirected.buffer); + redirect.setUint32(end + 56, 0x06064b50, true); + redirect.setBigUint64(end + 60, 44n, true); + redirect.setBigUint64(end + 104, BigInt(end), true); + await expect(boundedZip(redirected.buffer)).rejects.toThrow("Unsupported ZIP64 directory"); + + const commented = new Uint8Array(extended.length + 20); + commented.set(extended); + const comment = new DataView(commented.buffer); + comment.setUint16(extended.length - 2, 20, true); + comment.setUint32(extended.length, 0x07064b50, true); + comment.setBigUint64(extended.length + 8, BigInt(end), true); + comment.setUint32(extended.length + 16, 1, true); + const load = vi.spyOn(JSZip, "loadAsync"); + try { + await expect(boundedZip(commented.buffer)).rejects.toThrow("Unsupported ZIP64 directory"); + expect(load).not.toHaveBeenCalled(); + } finally { + load.mockRestore(); + } + + const many = new JSZip(); + for (let i = 0; i < 2049; i++) many.file(`file-${i}`, ""); + const crowded = await many.generateAsync({ type: "arraybuffer" }); + new DataView(crowded).setUint16(crowded.byteLength - 12, 1, true); + await expect(boundedZip(crowded)).rejects.toThrow("more than 2,048 entries"); + }); + + it("keeps a file and directory with the same basename accessible", async () => { + const bytes = await new JSZip() + .file("foo.txt", "root file") + .file("foo.txt/bar.txt", "child") + .generateAsync({ type: "arraybuffer" }); + const src = URL.createObjectURL(new Blob([bytes])); + try { + await using screen = await render(); + await vi.waitFor( + () => expect(screen.container.querySelectorAll("tbody button")).toHaveLength(2), + 10_000, + ); + const buttons = [...screen.container.querySelectorAll("tbody button")]; + expect(buttons.map((button) => button.textContent)).toEqual(["foo.txt", "foo.txt"]); + await act(() => buttons[1]!.click()); + await vi.waitFor(() => expect(screen.container.textContent).toContain("root file"), 10_000); + } finally { + URL.revokeObjectURL(src); + } + }); + + it("keeps WhiteIsZero TIFF brightness and rejects oversized metadata before decoding", async () => { + const tags = [ + [256, 2], + [257, 1], + [258, 8], + [259, 1], + [262, 0], + [273, 134], + [277, 1], + [278, 1], + [279, 2], + [284, 1], + ]; + const bytes = new ArrayBuffer(136); + const view = new DataView(bytes); + view.setUint16(0, 0x4949); + view.setUint16(2, 42, true); + view.setUint32(4, 8, true); + view.setUint16(8, tags.length, true); + tags.forEach(([tag, value], index) => { + const offset = 10 + index * 12; + view.setUint16(offset, tag!, true); + view.setUint16(offset + 2, 4, true); + view.setUint32(offset + 4, 1, true); + view.setUint32(offset + 8, value!, true); + }); + view.setUint8(135, 255); + const src = URL.createObjectURL(new Blob([bytes])); + try { + const blob = await decodeImagePreview(src, false, new AbortController().signal); + const bitmap = await createImageBitmap(blob); + const canvas = new OffscreenCanvas(2, 1); + const context = canvas.getContext("2d")!; + context.drawImage(bitmap, 0, 0); + expect([...context.getImageData(0, 0, 2, 1).data]).toEqual([ + 255, 255, 255, 255, 0, 0, 0, 255, + ]); + bitmap.close(); + } finally { + URL.revokeObjectURL(src); + } + view.setUint32(14, 1_000_000, true); + const invalid = URL.createObjectURL(new Blob([bytes])); + try { + await expect( + decodeImagePreview(invalid, false, new AbortController().signal), + ).rejects.toThrow("metadata"); + } finally { + URL.revokeObjectURL(invalid); + } + }); +}); import { OFFICE_PREVIEW_MAX_COLUMNS, type OfficeWorkerResult } from "./officePreviewProtocol"; describe("large text preview recovery", () => { @@ -235,8 +643,9 @@ describe("large text preview recovery", () => { await using screen = await render( , ); - await vi.waitFor(() => - expect(screen.container.querySelector('[role="alert"]')).not.toBeNull(), + await vi.waitFor( + () => expect(screen.container.querySelector('[role="alert"]')).not.toBeNull(), + 10_000, ); } finally { URL.revokeObjectURL(src); @@ -265,7 +674,7 @@ describe("large text preview recovery", () => { , ); - await vi.waitFor(() => expect(screen.container.textContent).toContain("current-40")); + await vi.waitFor(() => expect(screen.container.textContent).toContain("current-40"), 10_000); expect(screen.container.textContent).not.toContain("old-"); } finally { URL.revokeObjectURL(src); @@ -417,11 +826,15 @@ describe("large text preview recovery", () => { await using screen = await render( , ); - await vi.waitFor(() => - expect(screen.container.textContent).toContain("This file contains binary data."), + await vi.waitFor( + () => expect(screen.container.textContent).toContain("This file contains binary data."), + 10_000, ); await screen.rerender(); - await vi.waitFor(() => expect(screen.container.textContent).toContain("This file is empty.")); + await vi.waitFor( + () => expect(screen.container.textContent).toContain("This file is empty."), + 10_000, + ); expect(screen.container.querySelector('[role="alert"]')).toBeNull(); }); @@ -1776,6 +2189,61 @@ it("copies every indexed line rather than the mounted preview rows", async () => expect(screen.container.querySelector('[role="alert"]')).toBeNull(); }); +it.each(["archive", "sqlite"] as const)( + "reauthorizes and retries a failed %s attachment", + async (kind) => { + const bad = URL.createObjectURL(new Blob(["invalid"])); + let bytes: Uint8Array; + if (kind === "archive") + bytes = new Uint8Array( + await new JSZip().file("recovered.txt", "ok").generateAsync({ type: "uint8array" }), + ); + else { + const [{ default: initSqlJs }, { default: wasmUrl }] = await Promise.all([ + import("sql.js"), + import("sql.js/dist/sql-wasm.wasm?url"), + ]); + const SQL = await initSqlJs({ locateFile: () => wasmUrl }); + const database = new SQL.Database(); + database.run( + "CREATE TABLE recovered (value TEXT); INSERT INTO recovered VALUES('retry succeeded')", + ); + bytes = new Uint8Array(database.export()); + database.close(); + } + const good = URL.createObjectURL(new Blob([bytes])); + refresh.mockReset().mockResolvedValueOnce(bad).mockResolvedValue(good); + try { + await using screen = await render( + , + ); + await vi.waitFor( + () => expect(screen.container.querySelector('[role="alert"] button')).not.toBeNull(), + 10_000, + ); + await act(() => + screen.container.querySelector('[role="alert"] button')!.click(), + ); + await vi.waitFor( + () => + expect(screen.container.textContent).toContain( + kind === "archive" ? "recovered.txt" : "retry succeeded", + ), + 10_000, + ); + expect(refresh).toHaveBeenCalledTimes(2); + } finally { + URL.revokeObjectURL(bad); + URL.revokeObjectURL(good); + } + }, +); + it("loads a font attachment through the lazy preview", async () => { const { default: fontUrl } = await import("../../../node_modules/@betteroffice/fonts/assets/Carlito-Regular.ttf?url"); diff --git a/apps/web/src/components/files/AttachmentFilePreview.tsx b/apps/web/src/components/files/AttachmentFilePreview.tsx index 53d96ec849311..d582bfa1591eb 100644 --- a/apps/web/src/components/files/AttachmentFilePreview.tsx +++ b/apps/web/src/components/files/AttachmentFilePreview.tsx @@ -23,6 +23,8 @@ import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; import { useClientSettings, updateClientSettings } from "~/hooks/useSettings"; import { cn } from "~/lib/utils"; +import { BinaryFilePreview } from "./BinaryFilePreview"; +import { PreviewImage } from "./PreviewImage"; import { AudioPreview } from "./AudioPreview"; import { BrowserDocumentFrame } from "./BrowserDocumentFrame"; import { DelimitedTablePreview } from "./DelimitedTablePreview"; @@ -284,6 +286,14 @@ export function AttachmentFilePreview(props: { ) : ( ) + ) : kind === "archive" || kind === "sqlite" ? ( + setRevision((value) => value + 1)} + /> ) : kind === "font" ? ( }> @@ -319,15 +329,13 @@ export function AttachmentFilePreview(props: { /> ) : kind === "image" ? ( - // oxlint-disable-next-line t3code/require-centered-scroll-gutter -- The image is capped at max-h-full max-w-full, so this never scrolls. -
- {props.name} setError("Unable to load image.")} - /> -
+ setError("Unable to load image.")} + /> ) : (

No preview for this file

diff --git a/apps/web/src/components/files/BinaryFilePreview.tsx b/apps/web/src/components/files/BinaryFilePreview.tsx new file mode 100644 index 0000000000000..bc3295ad6168e --- /dev/null +++ b/apps/web/src/components/files/BinaryFilePreview.tsx @@ -0,0 +1,18 @@ +import { lazy, Suspense } from "react"; + +import { FileSurfaceLoading } from "./fileSurfaceChrome"; + +const BinaryViewer = lazy(() => import("./BinaryViewer")); + +export function BinaryFilePreview(props: { + readonly src: string; + readonly name: string; + readonly kind: "archive" | "sqlite"; + readonly onRetry?: () => void; +}) { + return ( + }> + + + ); +} diff --git a/apps/web/src/components/files/BinaryViewer.tsx b/apps/web/src/components/files/BinaryViewer.tsx new file mode 100644 index 0000000000000..74a26455a3bf9 --- /dev/null +++ b/apps/web/src/components/files/BinaryViewer.tsx @@ -0,0 +1,393 @@ +/* oxlint-disable react/no-array-index-key -- Read-only cells retain their database row and column positions. */ +import { + decodeFilePreviewText, + filePreviewKind, + hostPreviewMimeTypeFromExtension, +} from "@t3tools/shared/filePreview"; +import { formatAttachmentSize } from "@t3tools/client-runtime/state/attachments"; +import { ChevronLeftIcon, ChevronRightIcon, FileIcon, FolderIcon } from "lucide-react"; +import { lazy, Suspense, useEffect, useRef, useState } from "react"; + +import { + Select, + SelectTrigger, + SelectValue, + SelectPopup, + SelectItem, +} from "~/components/ui/select"; + +import { + FileSurfaceAction, + FileSurfaceFailure, + FileSurfaceLoading, + FileSurfaceNotice, +} from "./fileSurfaceChrome"; +import { + SQLITE_PAGE_SIZE, + type BinaryWorkerRequest, + type BinaryWorkerResult, +} from "./binaryPreviewProtocol"; +import { PreviewImage } from "./PreviewImage"; +import { readBinaryPreview } from "./readBinaryPreview"; + +const SourcePreview = lazy(() => import("./ReadOnlySourcePreview")); +type Archive = Extract; +type Database = Extract; +type Rows = Extract; + +function ArchiveFile(props: { readonly path: string; readonly bytes: Uint8Array }) { + const [url, setUrl] = useState(null); + const kind = filePreviewKind({ name: props.path }); + const text = (() => { + if (!["text", "markdown", "html", "mermaid"].includes(kind)) return null; + try { + return decodeFilePreviewText(props.bytes); + } catch { + return null; + } + })(); + // oxlint-disable-next-line t3code/react-effect-budget -- Create an object URL for the extracted image and revoke it when the entry changes or unmounts. + useEffect(() => { + if (kind !== "image") return; + const extension = props.path.slice(props.path.lastIndexOf(".")); + const objectUrl = URL.createObjectURL( + new Blob([props.bytes], { + type: hostPreviewMimeTypeFromExtension(extension) ?? "application/octet-stream", + }), + ); + // oxlint-disable-next-line react/set-state-in-effect -- Publish a Blob URL only after registering its cleanup. + setUrl(objectUrl); + return () => URL.revokeObjectURL(objectUrl); + }, [props.bytes, props.path, kind]); + if (kind === "image") + return url ? : ; + if (!text) + return ( + + ); + return ( + <> + {text.truncated ? ( + Preview limited to the first 1 MB. + ) : null} + }> + + + + ); +} + +export default function BinaryViewer(props: { + readonly src: string; + readonly name: string; + readonly kind: "archive" | "sqlite"; + readonly onRetry?: () => void; +}) { + const [sizeBytes, setSizeBytes] = useState(null); + const [archive, setArchive] = useState(null); + const [database, setDatabase] = useState(null); + const [rows, setRows] = useState(null); + const [entry, setEntry] = useState | null>(null); + const [folder, setFolder] = useState(""); + const [selectedPath, setSelectedPath] = useState(null); + const [selectedTable, setSelectedTable] = useState(""); + const [page, setPage] = useState(0); + const [busy, setBusy] = useState(true); + const [error, setError] = useState(null); + const workerRef = useRef(null); + const timerRef = useRef | null>(null); + const send = (request: BinaryWorkerRequest) => { + setBusy(true); + setError(null); + if (timerRef.current) clearTimeout(timerRef.current); + timerRef.current = setTimeout(() => { + setError("This file took too long to preview. Close it and try again."); + setBusy(false); + workerRef.current?.terminate(); + workerRef.current = null; + }, 15_000); + workerRef.current?.postMessage(request); + }; + // oxlint-disable-next-line t3code/react-effect-budget -- Own the binary fetch, archive or database worker, message subscription, and request timeout for this file. + useEffect(() => { + const controller = new AbortController(); + const worker = new Worker(new URL("./binaryPreview.worker.ts", import.meta.url), { + type: "module", + }); + workerRef.current = worker; + const fail = (message: string) => { + if (controller.signal.aborted) return; + setError(message); + setBusy(false); + controller.abort(); + worker.terminate(); + workerRef.current = null; + if (timerRef.current) clearTimeout(timerRef.current); + }; + timerRef.current = setTimeout( + () => fail("This file took too long to preview. Close it and try again."), + 30_000, + ); + worker.addEventListener("error", () => + fail("Could not load the file viewer. Close it and try again."), + ); + worker.addEventListener("message", (event: MessageEvent) => { + if (controller.signal.aborted) return; + if (timerRef.current) clearTimeout(timerRef.current); + const result = event.data; + setBusy(false); + if (result.type === "error") setError(result.message); + else if (result.type === "archive") setArchive(result); + else if (result.type === "entry") setEntry(result); + else if (result.type === "rows") setRows(result); + else { + setDatabase(result); + const table = result.tables[0]; + if (table) { + setSelectedTable(table.name); + send({ type: "rows", table: table.name, page: 0 }); + } + } + }); + void readBinaryPreview(props.src, controller.signal) + .then((bytes) => { + if (!controller.signal.aborted) { + setSizeBytes(bytes.byteLength); + worker.postMessage({ type: "open", name: props.name, kind: props.kind, bytes }, [bytes]); + } + }) + .catch((cause: unknown) => + fail(cause instanceof Error ? cause.message : "Could not read this file."), + ); + return () => { + controller.abort(); + worker.terminate(); + workerRef.current = null; + if (timerRef.current) clearTimeout(timerRef.current); + }; + // oxlint-disable-next-line react/exhaustive-effect-dependencies -- One worker owns this keyed file and its message stream. + }, [props.src, props.kind, props.name]); + const table = database?.tables.find((item) => item.name === selectedTable); + const selectPage = (next: number, name = selectedTable) => { + setSelectedTable(name); + setPage(next); + setRows(null); + send({ type: "rows", table: name, page: next }); + }; + const children = new Map< + string, + { path: string; directory: boolean; size: number; modified: string | null } + >(); + for (const item of archive?.entries ?? []) { + if (!item.path.startsWith(folder)) continue; + const rest = item.path.slice(folder.length).replace(/\/$/, ""); + if (!rest) continue; + const slash = rest.indexOf("/"); + const path = slash < 0 ? item.path.replace(/\/$/, "") : `${folder}${rest.slice(0, slash)}`; + const directory = slash >= 0 || item.directory; + const key = `${path}${directory ? "/" : ""}`; + const previous = children.get(key); + if (previous) previous.size += item.size; + else children.set(key, { path, directory, size: item.size, modified: item.modified }); + } + return ( +
+ {archive ? ( +
+ { + setError(null); + setEntry(null); + if (selectedPath) setSelectedPath(null); + else setFolder(folder.replace(/(?:[^/]+)?\/$/, "")); + }} + > + + + + {selectedPath ?? (folder || "Archive contents")} + + + {archive.entries.length.toLocaleString()} entries + {sizeBytes !== null ? ` · ${formatAttachmentSize(sizeBytes)}` : ""} + +
+ ) : null} + {database && table ? ( + <> +
+
+ +
+ selectPage(page - 1)} + > + + + + {table.count === 0 + ? "0 rows" + : `${page * SQLITE_PAGE_SIZE + 1}–${Math.min(table.count, (page + 1) * SQLITE_PAGE_SIZE)} of ${table.count.toLocaleString()}`} + + = table.count} + onPress={() => selectPage(page + 1)} + > + + +
+
+ {table.count.toLocaleString()} rows · {rows?.columns.length ?? "…"} columns + {sizeBytes !== null ? ` · ${formatAttachmentSize(sizeBytes)}` : ""} +
+ + Read-only. Up to 50 columns and 1,024 characters per cell. Blob contents are omitted. + + + ) : null} + {error ? ( + + ) : busy ? ( + + ) : selectedPath ? ( + entry ? ( + + ) : null + ) : archive ? ( +
+ + + + + + + + + + {[...children.values()] + .sort( + (a, b) => + Number(b.directory) - Number(a.directory) || a.path.localeCompare(b.path), + ) + .map((item) => ( + + + + + + ))} + +
+ Name + + Size + + Modified +
+ + + {formatAttachmentSize(item.size)} + + {item.modified ? new Date(item.modified).toLocaleDateString() : "-"} +
+ {children.size === 0 ? ( +

This folder is empty.

+ ) : null} +
+ ) : rows ? ( +
+ + + + {rows.columns.map((name) => ( + + ))} + + + + {rows.rows.map((row, index) => ( + + {row.map((cell, column) => ( + + ))} + + ))} + +
+ {name} +
+ {cell} +
+ {rows.rows.length === 0 ? ( +

This table is empty.

+ ) : null} +
+ ) : ( +

This database has no readable tables.

+ )} +
+ ); +} diff --git a/apps/web/src/components/files/FilePreviewPanel.tsx b/apps/web/src/components/files/FilePreviewPanel.tsx index 5649829b840eb..9be8dfbaf497d 100644 --- a/apps/web/src/components/files/FilePreviewPanel.tsx +++ b/apps/web/src/components/files/FilePreviewPanel.tsx @@ -16,6 +16,7 @@ import { isWorkspaceFontPreviewPath, isWorkspaceVideoPreviewPath, officePreviewKind, + binaryPreviewKind, } from "@t3tools/shared/filePreview"; import { useAtomValue } from "@effect/atom-react"; import { @@ -81,6 +82,7 @@ import { AttachmentFilePreview } from "./AttachmentFilePreview"; import { AudioPreview } from "./AudioPreview"; import { BrowserDocumentFrame, isPdfPreviewFile } from "./BrowserDocumentFrame"; import { DelimitedTablePreview } from "./DelimitedTablePreview"; +import { PreviewImage } from "./PreviewImage"; import FileBrowserPanel from "./FileBrowserPanel"; import { FileBreadcrumbs } from "./FileBreadcrumbs"; import { breadcrumbPathOf, retainedBreadcrumbTrail, trailChildOf } from "./filePath"; @@ -169,14 +171,16 @@ function WorkspaceImagePreview(props: { readonly workspaceMutationId: string | null; }) { const resource = workspaceAssetResource({ - kind: "workspace-file", + kind: + mediaFileReference(props.absolutePath, props.workspaceRoot).relativePath !== undefined + ? "workspace-file" + : "media-file", threadRef: props.threadRef, draft: props.draft, workspaceRoot: props.workspaceRoot, absolutePath: props.absolutePath, }); const assetUrl = useAssetUrlState(props.environmentId, resource); - const [failedUrl, setFailedUrl] = useState(null); const revisionSuffix = props.workspaceMutationId === null ? "" @@ -190,7 +194,7 @@ function WorkspaceImagePreview(props: { asset: { environmentId: props.environmentId, resource }, }; - if (assetUrl._tag === "Failure" || (imageUrl !== null && failedUrl === imageUrl)) { + if (assetUrl._tag === "Failure") { return (
@@ -201,17 +205,11 @@ function WorkspaceImagePreview(props: { } return assetUrl._tag === "Success" && imageUrl !== null ? ( - // oxlint-disable-next-line t3code/require-centered-scroll-gutter -- The image is capped at max-h-full max-w-full, so this never scrolls. -
- - {props.alt} setFailedUrl(imageUrl)} - /> - -
+ +
+ +
+
) : (
@@ -1025,7 +1023,8 @@ export default function FilePreviewPanel({ const isPdf = relativePath !== null && isPdfPreviewFile(relativePath); const isDocument = relativePath !== null && - (officePreviewKind({ name: relativePath }) !== null || + (binaryPreviewKind({ name: relativePath }) !== null || + officePreviewKind({ name: relativePath }) !== null || isWorkspaceFontPreviewPath(relativePath)); const isHtml = relativePath !== null && !isPdf && isBrowserPreviewFile(relativePath); const isHostFile = diff --git a/apps/web/src/components/files/PreviewImage.tsx b/apps/web/src/components/files/PreviewImage.tsx new file mode 100644 index 0000000000000..5343d32607510 --- /dev/null +++ b/apps/web/src/components/files/PreviewImage.tsx @@ -0,0 +1,18 @@ +import { lazy, Suspense } from "react"; + +import { FileSurfaceLoading } from "./fileSurfaceChrome"; + +const PreviewImageContent = lazy(() => import("./PreviewImageContent")); + +export function PreviewImage(props: { + readonly src: string; + readonly name: string; + readonly mimeType?: string; + readonly onError?: () => void; +}) { + return ( + }> + + + ); +} diff --git a/apps/web/src/components/files/PreviewImageContent.tsx b/apps/web/src/components/files/PreviewImageContent.tsx new file mode 100644 index 0000000000000..ab673d92cb8da --- /dev/null +++ b/apps/web/src/components/files/PreviewImageContent.tsx @@ -0,0 +1,134 @@ +import { useLayoutEffect, useRef, useState } from "react"; +import { MaximizeIcon, MinusIcon, PlusIcon, ScanIcon } from "lucide-react"; +import { ExpandedImageDialog } from "~/components/chat/ExpandedImageDialog"; +import { observeResize } from "~/lib/observeResize"; +import { AttachmentImage } from "../media/AttachmentImage"; +import { FileSurfaceAction, FileSurfaceFailure } from "./fileSurfaceChrome"; + +export default function PreviewImageContent(props: { + readonly src: string; + readonly name: string; + readonly mimeType?: string; + readonly onError?: () => void; +}) { + const [failed, setFailed] = useState(false); + const [expanded, setExpanded] = useState(false); + const [natural, setNatural] = useState({ width: 0, height: 0 }); + const [viewport, setViewport] = useState({ width: 0, height: 0 }); + const [zoom, setZoom] = useState(null); + const ref = useRef(null); + // oxlint-disable-next-line t3code/react-effect-budget -- Measure the image viewport before paint and observe resizes to keep fit-to-view scaling accurate. + useLayoutEffect(() => { + const node = ref.current; + if (!node) return; + const update = () => setViewport({ width: node.clientWidth, height: node.clientHeight }); + update(); + return observeResize(node, update); + }, []); + const fit = natural.width + ? Math.min( + 1, + Math.max(1, viewport.width - 32) / natural.width, + Math.max(1, viewport.height - 32) / natural.height, + ) + : 1; + const scale = zoom ?? fit; + const changeZoom = (factor: number) => setZoom(Math.max(0.05, Math.min(8, scale * factor))); + if (failed) return ; + return ( + <> +
+ + {natural.width + ? `${natural.width.toLocaleString()} × ${natural.height.toLocaleString()}` + : "Loading image…"} + +
+ changeZoom(1 / 1.25)} + > + + + {Math.round(scale * 100)}% + = 8} + onPress={() => changeZoom(1.25)} + > + + + setZoom(null)} + > + + + setZoom(1)} + > + + +
+
+
+
+ +
+
+ {expanded ? ( + setExpanded(false)} + /> + ) : null} + + ); +} diff --git a/apps/web/src/components/files/binaryPreview.worker.ts b/apps/web/src/components/files/binaryPreview.worker.ts new file mode 100644 index 0000000000000..1b170d3cb524b --- /dev/null +++ b/apps/web/src/components/files/binaryPreview.worker.ts @@ -0,0 +1,249 @@ +import type { Database } from "sql.js"; + +import { boundedZip } from "./boundedZip"; +import { + ARCHIVE_ENTRY_MAX_BYTES, + ARCHIVE_MAX_ENTRIES, + ARCHIVE_MAX_RATIO, + BINARY_PREVIEW_MAX_BYTES, + SQLITE_PAGE_SIZE, + type ArchiveEntry, + type BinaryWorkerRequest, + type BinaryWorkerResult, +} from "./binaryPreviewProtocol"; + +let database: Database | null = null; +let tables: string[] = []; +let files = new Map>(); +const reply = (result: BinaryWorkerResult) => self.postMessage(result, { transfer: [] }); +const quote = (name: string) => `"${name.replaceAll('"', '""')}"`; + +async function gunzip(bytes: ArrayBuffer) { + const reader = new Blob([bytes]) + .stream() + .pipeThrough(new DecompressionStream("gzip")) + .getReader(); + const chunks: Uint8Array[] = []; + let length = 0; + try { + while (true) { + const { value, done } = await reader.read(); + if (done) break; + length += value.length; + if (length > BINARY_PREVIEW_MAX_BYTES || length > bytes.byteLength * ARCHIVE_MAX_RATIO) + throw new Error( + "The expanded archive exceeds the preview size or compression ratio limit.", + ); + chunks.push(value); + } + } finally { + await reader.cancel(); + } + return new Uint8Array(await new Blob(chunks).arrayBuffer()); +} + +function tarEntries(bytes: Uint8Array) { + const entries: ArchiveEntry[] = []; + const text = (offset: number, length: number) => + new TextDecoder().decode(bytes.subarray(offset, offset + length)).split("\0", 1)[0] ?? ""; + const number = (offset: number, length: number) => { + const value = text(offset, length).trim(); + if (!/^[0-7]*$/.test(value)) throw new Error("This TAR uses an unsupported numeric format."); + return Number.parseInt(value || "0", 8); + }; + let longPath: string | null = null; + const globalPax: Record = {}; + let localPax: Record = {}; + let offset = 0; + while (offset + 512 <= bytes.length) { + if (bytes.subarray(offset, offset + 512).every((byte) => byte === 0)) break; + if (entries.length >= ARCHIVE_MAX_ENTRIES) + throw new Error("This archive contains more than 2,048 entries."); + let checksum = 0; + for (let index = 0; index < 512; index++) + checksum += index >= 148 && index < 156 ? 32 : bytes[offset + index]!; + if (checksum !== number(offset + 148, 8)) throw new Error("The TAR header is damaged."); + const type = text(offset + 156, 1); + const metadata = type === "L" || type === "x" || type === "g"; + const attributes: Record = metadata ? {} : { ...globalPax, ...localPax }; + const size = attributes.size ? Number(attributes.size) : number(offset + 124, 12); + if (attributes.size && !/^\d+$/.test(attributes.size)) + throw new Error("An archive entry is too large or damaged."); + const prefix = + text(offset + 257, 6) === "ustar" && text(offset + 263, 2) === "00" + ? text(offset + 345, 155) + : ""; + const path = + attributes.path || longPath || [prefix, text(offset, 100)].filter(Boolean).join("/"); + if (!metadata) { + longPath = null; + localPax = {}; + } + if ( + path.length > 1024 || + !Number.isSafeInteger(size) || + size < 0 || + size > BINARY_PREVIEW_MAX_BYTES || + offset + 512 + size > bytes.length + ) + throw new Error("An archive entry is too large or damaged."); + const content = bytes.subarray(offset + 512, offset + 512 + size); + if (type === "L") longPath = new TextDecoder().decode(content).replace(/\0.*$/s, ""); + else if (type === "x" || type === "g") { + if (size > 65536) throw new Error("Archive metadata is too large."); + for (let cursor = 0; cursor < content.length;) { + const separator = content.indexOf(32, cursor); + if (separator < cursor || separator - cursor > 10) + throw new Error("The TAR metadata is damaged."); + const lengthText = new TextDecoder().decode(content.subarray(cursor, separator)); + const length = Number(lengthText); + const end = cursor + length; + if ( + !/^[1-9]\d*$/.test(lengthText) || + !Number.isSafeInteger(length) || + end > content.length || + end <= separator + 1 || + content[end - 1] !== 10 + ) + throw new Error("The TAR metadata is damaged."); + const record = new TextDecoder().decode(content.subarray(separator + 1, end - 1)); + const equals = record.indexOf("="); + if (equals < 1) throw new Error("The TAR metadata is damaged."); + const key = record.slice(0, equals); + const value = record.slice(equals + 1); + if (["path", "size", "mtime"].includes(key)) { + if (type === "x") localPax[key] = value; + else if (value) globalPax[key] = value; + else delete globalPax[key]; + } + cursor = end; + } + } else if (type === "" || type === "0" || type === "5") { + if (attributes.mtime && !/^[+-]?\d+(?:\.\d+)?$/.test(attributes.mtime)) + throw new Error("The TAR metadata is damaged."); + const modified = + (attributes.mtime ? Number(attributes.mtime) : number(offset + 136, 12)) * 1000; + const date = new Date(modified); + entries.push({ + path, + size, + directory: type === "5", + modified: Number.isNaN(date.getTime()) ? null : date.toISOString(), + }); + if (type !== "5") files.set(path, content.slice()); + } + offset += 512 + Math.ceil(size / 512) * 512; + } + if (entries.length === 0) throw new Error("This archive has no readable entries."); + return entries; +} + +async function handle(input: BinaryWorkerRequest) { + if (input.type === "entry") { + const bytes = files.get(input.path); + if (!bytes) throw new Error("This archive entry is unavailable."); + if (bytes.length > ARCHIVE_ENTRY_MAX_BYTES) + throw new Error("Entry previews are limited to 10 MB."); + reply({ type: "entry", path: input.path, bytes }); + return; + } + if (input.type === "rows") { + if ( + !database || + !tables.includes(input.table) || + !Number.isSafeInteger(input.page) || + input.page < 0 + ) + throw new Error("This table is unavailable."); + const columns = + database + .exec(`PRAGMA table_xinfo(${quote(input.table)})`)[0] + ?.values.filter((row) => row[6] !== 1) + .map((row) => String(row[1])) + .slice(0, 50) ?? []; + const fields = columns.map((name) => { + const field = quote(name); + return `CASE WHEN typeof(${field}) = 'blob' THEN '[blob: ' || length(${field}) || ' bytes]' ELSE substr(CAST(${field} AS TEXT), 1, 1024) END`; + }); + const result = database.exec( + `SELECT ${fields.join(",")} FROM ${quote(input.table)} LIMIT ${SQLITE_PAGE_SIZE} OFFSET ${input.page * SQLITE_PAGE_SIZE}`, + )[0]; + reply({ + type: "rows", + table: input.table, + page: input.page, + columns, + rows: + result?.values.map((row) => row.map((cell) => (cell === null ? "NULL" : String(cell)))) ?? + [], + }); + return; + } + if (input.bytes.byteLength > BINARY_PREVIEW_MAX_BYTES) + throw new Error("Preview limited to files under 50 MB."); + if (input.kind === "sqlite") { + const [{ default: initSqlJs }, { default: wasmUrl }] = await Promise.all([ + import("sql.js"), + import("sql.js/dist/sql-wasm.wasm?url"), + ]); + const SQL = await initSqlJs({ locateFile: () => wasmUrl }); + database = new SQL.Database(new Uint8Array(input.bytes)); + database.run("PRAGMA query_only=ON; PRAGMA trusted_schema=OFF;"); + tables = + database + .exec( + "SELECT name FROM sqlite_schema WHERE type='table' AND substr(name, 1, 7) != 'sqlite_' AND sql NOT LIKE 'CREATE VIRTUAL TABLE%' ORDER BY name LIMIT 101", + )[0] + ?.values.map((row) => String(row[0])) ?? []; + if (tables.length > 100) throw new Error("Database previews are limited to 100 tables."); + reply({ + type: "sqlite", + tables: tables.map((name) => ({ + name, + count: Number( + database!.exec(`SELECT count(*) FROM ${quote(name)}`)[0]?.values[0]?.[0] ?? 0, + ), + })), + }); + } else { + files = new Map(); + let entries: ArchiveEntry[]; + if ( + /\.(zip|jar)$/i.test(input.name) || + (input.bytes.byteLength >= 4 && + [0x04034b50, 0x06054b50].includes(new DataView(input.bytes).getUint32(0, true))) + ) { + const archive = await boundedZip(input.bytes, BINARY_PREVIEW_MAX_BYTES); + entries = [...archive].map(([path, file]) => { + files.set(path, file.bytes); + return { + path, + size: file.bytes.length, + directory: file.directory, + modified: Number.isNaN(file.date.getTime()) ? null : file.date.toISOString(), + }; + }); + } else if (/\.(gz|tgz)$/i.test(input.name) || new Uint8Array(input.bytes)[0] === 0x1f) { + const bytes = await gunzip(input.bytes); + if (/\.(tar\.gz|tgz)$/i.test(input.name)) entries = tarEntries(bytes); + else { + const path = input.name.replace(/\.gz$/i, "") || "contents"; + if (/\.tar$/i.test(path)) entries = tarEntries(bytes); + else { + files.set(path, bytes); + entries = [{ path, size: bytes.length, directory: false, modified: null }]; + } + } + } else entries = tarEntries(new Uint8Array(input.bytes)); + reply({ type: "archive", entries }); + } +} + +self.addEventListener("message", (event: MessageEvent) => { + void handle(event.data).catch((cause: unknown) => + reply({ + type: "error", + message: cause instanceof Error ? cause.message : "Could not preview this file.", + }), + ); +}); diff --git a/apps/web/src/components/files/binaryPreviewProtocol.ts b/apps/web/src/components/files/binaryPreviewProtocol.ts new file mode 100644 index 0000000000000..137c021ae965b --- /dev/null +++ b/apps/web/src/components/files/binaryPreviewProtocol.ts @@ -0,0 +1,24 @@ +export const BINARY_PREVIEW_MAX_BYTES = 50 * 1024 * 1024; +export const ARCHIVE_ENTRY_MAX_BYTES = 10 * 1024 * 1024; +export const ARCHIVE_MAX_ENTRIES = 2048; +export const ARCHIVE_MAX_RATIO = 200; +export const SQLITE_PAGE_SIZE = 50; + +export interface ArchiveEntry { + path: string; + size: number; + modified: string | null; + directory: boolean; +} + +export type BinaryWorkerRequest = + | { type: "open"; kind: "archive" | "sqlite"; name: string; bytes: ArrayBuffer } + | { type: "entry"; path: string } + | { type: "rows"; table: string; page: number }; + +export type BinaryWorkerResult = + | { type: "error"; message: string } + | { type: "archive"; entries: ArchiveEntry[] } + | { type: "entry"; path: string; bytes: Uint8Array } + | { type: "sqlite"; tables: { name: string; count: number }[] } + | { type: "rows"; table: string; page: number; columns: string[]; rows: string[][] }; diff --git a/apps/web/src/components/files/boundedZip.ts b/apps/web/src/components/files/boundedZip.ts new file mode 100644 index 0000000000000..7d9e331910950 --- /dev/null +++ b/apps/web/src/components/files/boundedZip.ts @@ -0,0 +1,79 @@ +import JSZip from "jszip"; +import { checkZipDirectory } from "./zipDirectory"; + +import { + ARCHIVE_MAX_ENTRIES, + ARCHIVE_MAX_RATIO, + BINARY_PREVIEW_MAX_BYTES, +} from "./binaryPreviewProtocol"; + +export async function boundedZip( + bytes: ArrayBuffer, + entryLimit = BINARY_PREVIEW_MAX_BYTES, + limits: { + totalLimit?: number; + xmlLimit?: number; + ratioFloor?: number; + markupLimit?: number; + } = {}, +) { + if (bytes.byteLength > BINARY_PREVIEW_MAX_BYTES) + throw new Error("Preview limited to files under 50 MB."); + checkZipDirectory(bytes, ARCHIVE_MAX_ENTRIES); + const zip = await JSZip.loadAsync(bytes); + const entries = Object.values(zip.files); + if (entries.length > ARCHIVE_MAX_ENTRIES) + throw new Error("This archive contains more than 2,048 entries."); + const files = new Map< + string, + { bytes: Uint8Array; date: Date; directory: boolean } + >(); + let total = 0; + let markup = 0; + for (const entry of entries) { + if (entry.name.length > 1024) throw new Error("An archive path is too long to preview."); + const chunks: Uint8Array[] = []; + let length = 0; + await new Promise((resolve, reject) => { + const stream = ( + entry as JSZip.JSZipObject & { + internalStream(type: "uint8array"): JSZip.JSZipStreamHelper>; + } + ).internalStream("uint8array"); + stream + .on("data", (chunk) => { + total += chunk.length; + length += chunk.length; + // Word relationship targets can use arbitrary extensions, so count every part. + if (limits.markupLimit !== undefined) + for (const byte of chunk) if (byte === 0x3c) markup++; + if ( + markup > (limits.markupLimit ?? Infinity) || + total > (limits.totalLimit ?? BINARY_PREVIEW_MAX_BYTES) || + total > + Math.max(limits.ratioFloor ?? 0, Math.max(1, bytes.byteLength) * ARCHIVE_MAX_RATIO) || + length > entryLimit || + (entry.name.endsWith(".xml") && length > (limits.xmlLimit ?? 8 * 1024 * 1024)) + ) { + stream.pause(); + reject( + new Error( + "The expanded archive exceeds the preview size or compression ratio limit.", + ), + ); + } else chunks.push(chunk); + }) + .on("error", reject) + .on("end", resolve) + .resume(); + }); + const content = new Uint8Array(length); + let offset = 0; + for (const chunk of chunks) { + content.set(chunk, offset); + offset += chunk.length; + } + files.set(entry.name, { bytes: content, date: entry.date, directory: entry.dir }); + } + return files; +} diff --git a/apps/web/src/components/files/decodeImagePreview.ts b/apps/web/src/components/files/decodeImagePreview.ts new file mode 100644 index 0000000000000..db8b19ee48429 --- /dev/null +++ b/apps/web/src/components/files/decodeImagePreview.ts @@ -0,0 +1,30 @@ +import { readBinaryPreview } from "./readBinaryPreview"; + +export async function decodeImagePreview(src: string, heif: boolean, signal: AbortSignal) { + const worker = new Worker(new URL("./imageDecode.worker.ts", import.meta.url), { + type: "module", + }); + const controller = new AbortController(); + try { + return await new Promise((resolve, reject) => { + const fail = (message: string) => reject(new Error(message)); + const abort = () => fail("Image decoding cancelled."); + signal.addEventListener("abort", abort, { signal: controller.signal }); + if (signal.aborted) return abort(); + const timer = setTimeout(() => fail("This image took too long to decode."), 30_000); + controller.signal.addEventListener("abort", () => clearTimeout(timer), { once: true }); + worker.addEventListener("error", () => fail("Could not load the image decoder.")); + worker.addEventListener("message", (event: MessageEvent<{ blob?: Blob; error?: string }>) => { + if (event.data.blob) resolve(event.data.blob); + else fail(event.data.error ?? "Could not decode this image."); + }); + void readBinaryPreview(src, controller.signal).then( + (bytes) => worker.postMessage({ bytes, heif }, [bytes]), + reject, + ); + }); + } finally { + controller.abort(); + worker.terminate(); + } +} diff --git a/apps/web/src/components/files/imageDecode.worker.ts b/apps/web/src/components/files/imageDecode.worker.ts new file mode 100644 index 0000000000000..cf3c9c121f9be --- /dev/null +++ b/apps/web/src/components/files/imageDecode.worker.ts @@ -0,0 +1,208 @@ +const MAX_PIXELS = 32_000_000; + +function validateTiffMetadata(bytes: ArrayBuffer) { + const view = new DataView(bytes); + if (bytes.byteLength < 8) throw new Error("Invalid TIFF metadata."); + const order = view.getUint16(0); + const littleEndian = order === 0x4949; + if (![0x4949, 0x4d4d].includes(order) || view.getUint16(2, littleEndian) !== 42) + throw new Error("Invalid TIFF metadata."); + const visited = new Set(); + let entries = 0; + let allocation = 0; + const inspect = (offset: number, depth: number) => { + if (depth > 8 || visited.has(offset) || offset < 8 || offset + 2 > bytes.byteLength) + throw new Error("Invalid TIFF metadata."); + visited.add(offset); + const count = view.getUint16(offset, littleEndian); + entries += count; + if (entries > 2048 || offset + 2 + count * 12 + 4 > bytes.byteLength) + throw new Error("This TIFF has too much or truncated metadata."); + for (let index = 0; index < count; index++) { + const entry = offset + 2 + index * 12; + const tag = view.getUint16(entry, littleEndian); + const type = view.getUint16(entry + 2, littleEndian); + const values = view.getUint32(entry + 4, littleEndian); + const width = [0, 1, 1, 2, 4, 8, 1, 1, 2, 4, 8, 4, 8][type]; + if (!width) throw new Error("Unsupported TIFF metadata type."); + const length = values * width; + const start = length > 4 ? view.getUint32(entry + 8, littleEndian) : entry + 8; + // The decoder retains a separate array or string for every tag, including aliases. + allocation += values * Math.max(16, width); + if (start + length > bytes.byteLength || allocation > 8 * 1024 * 1024) + throw new Error("This TIFF has too much or truncated metadata."); + if (tag === 0x8769 || tag === 0x8825) { + if (type !== 4 || values !== 1) throw new Error("Invalid TIFF metadata pointer."); + inspect(view.getUint32(entry + 8, littleEndian), depth + 1); + } + } + }; + inspect(view.getUint32(4, littleEndian), 0); +} + +function dimensions(width: number, height: number) { + if (!(width > 0 && height > 0) || width > 16384 || height > 16384 || width > MAX_PIXELS / height) + throw new Error("Image previews are limited to 32 megapixels and 16,384 pixels per side."); +} + +function validateHeif(bytes: ArrayBuffer) { + const view = new DataView(bytes); + let found = false; + let count = 0; + const boxes = (start: number, end: number, depth: number) => { + if (depth > 4) return; + let offset = start; + while (offset + 8 <= end) { + if (++count > 2048) throw new Error("This image has too much metadata."); + let size = view.getUint32(offset); + let header = 8; + if (size === 1) { + if (offset + 16 > end) throw new Error("Invalid HEIF metadata."); + const large = view.getBigUint64(offset + 8); + if (large > BigInt(bytes.byteLength)) throw new Error("Invalid HEIF metadata."); + size = Number(large); + header = 16; + } else if (size === 0) size = end - offset; + if (size < header || offset + size > end) throw new Error("Invalid HEIF metadata."); + const type = view.getUint32(offset + 4); + if (type === 0x69737065) { + if (size < header + 12) throw new Error("Invalid HEIF dimensions."); + dimensions(view.getUint32(offset + header + 4), view.getUint32(offset + header + 8)); + found = true; + } else if (type === 0x6d657461 || type === 0x69707270 || type === 0x6970636f) + boxes(offset + header + (type === 0x6d657461 ? 4 : 0), offset + size, depth + 1); + offset += size; + } + }; + boxes(0, bytes.byteLength, 0); + if (!found) throw new Error("This HEIF image has no readable dimensions."); +} + +// Count LZW output without building strings before the decoder allocates its strip buffer. +function validateLzw(bytes: Uint8Array, limit: number) { + const lengths = new Uint16Array(4096).fill(1, 0, 256); + let bits = 9; + let bit = 0; + let next = 258; + let previous = 0; + let total = 0; + while (bit + bits <= bytes.length * 8) { + let code = 0; + for (let index = 0; index < bits; index++, bit++) + code = (code << 1) | ((bytes[bit >> 3]! >> (7 - (bit & 7))) & 1); + if (code === 257) return; + if (code === 256) { + bits = 9; + next = 258; + previous = 0; + continue; + } + const length = code < next ? lengths[code]! : code === next && previous ? previous + 1 : 0; + if (!length || (total += length) > limit) throw new Error("Invalid or oversized TIFF strip."); + if (previous) { + if (next >= 4096) throw new Error("Invalid TIFF LZW dictionary."); + lengths[next++] = previous + 1; + if (next === (1 << bits) - 1 && bits < 12) bits++; + } + previous = length; + } + throw new Error("Truncated TIFF strip."); +} + +async function validateTiffStrips(bytes: ArrayBuffer, image: import("tiff").TiffIfd) { + const offsets = image.tiled ? image.tileOffsets : image.stripOffsets; + const counts = image.tiled ? image.tileByteCounts : image.stripByteCounts; + const width = image.tiled ? image.tileWidth! : image.width; + const height = image.tiled ? image.tileHeight! : Math.min(image.rowsPerStrip, image.height); + dimensions(width, height); + const limit = width * height * image.components * (image.bitsPerSample / 8); + if (!offsets?.length || offsets.length > 2048 || counts?.length !== offsets.length) + throw new Error("Unsupported TIFF strip layout."); + let expanded = 0; + for (let index = 0; index < offsets.length; index++) { + const offset = offsets[index]!; + const size = counts[index]!; + if (offset < 0 || size < 1 || offset + size > bytes.byteLength) + throw new Error("Invalid TIFF strip."); + const strip = new Uint8Array(bytes, offset, size); + if (image.compression === 5) validateLzw(strip, limit); + else if (image.compression === 8 || image.compression === 32946) { + const reader = new Blob([strip]) + .stream() + .pipeThrough(new DecompressionStream("deflate")) + .getReader(); + let length = 0; + try { + while (true) { + const { value, done } = await reader.read(); + if (done) break; + if ((length += value.length) > limit) throw new Error("Oversized TIFF strip."); + } + } finally { + await reader.cancel(); + } + } else if (image.compression !== 1) throw new Error("This TIFF compression is not supported."); + // Bound repeated strips and padding as well as the visible image dimensions. + expanded += limit; + if (expanded > 256 * 1024 * 1024) throw new Error("This TIFF has too much image data."); + } +} + +async function decode(bytes: ArrayBuffer, heif: boolean) { + if (bytes.byteLength > 50 * 1024 * 1024) throw new Error("Preview limited to files under 50 MB."); + if (heif) { + validateHeif(bytes); + const { heicTo } = await import("heic-to/next"); + return await heicTo({ blob: new Blob([bytes]), type: "image/png" }); + } + const { decode: decodeTiff } = await import("tiff"); + validateTiffMetadata(bytes); + const metadata = decodeTiff(bytes, { ignoreImageData: true, pages: [0] })[0]; + if (!metadata) throw new Error("This TIFF has no readable image."); + dimensions(metadata.width, metadata.height); + if (metadata.orientation && metadata.orientation !== 1) + throw new Error("This TIFF orientation is not supported."); + if ( + (metadata.tiled && metadata.components > 1) || + ![1, 2, 3, 4].includes(metadata.components) || + ![8, 16].includes(metadata.bitsPerSample) || + ![0, 1, 2, 3].includes(metadata.type) + ) + throw new Error("This TIFF color format is not supported."); + await validateTiffStrips(bytes, metadata); + const image = decodeTiff(bytes, { pages: [0] })[0]!; + const rgba = new Uint8ClampedArray(image.width * image.height * 4); + const scale = image.bitsPerSample === 16 ? 257 : 1; + const palette = image.palette; + for (let pixel = 0; pixel < image.width * image.height; pixel++) { + const index = pixel * image.components; + if (palette) { + const color = palette[image.data[index]!] ?? [0, 0, 0]; + for (let channel = 0; channel < 3; channel++) + rgba[pixel * 4 + channel] = color[channel]! / 257; + } else if (image.type === 2) { + for (let channel = 0; channel < 3; channel++) + rgba[pixel * 4 + channel] = image.data[index + channel]! / scale; + } else { + const gray = image.data[index]! / scale; + rgba.fill(gray, pixel * 4, pixel * 4 + 3); + } + rgba[pixel * 4 + 3] = image.alpha ? image.data[index + image.components - 1]! / scale : 255; + } + const canvas = new OffscreenCanvas(image.width, image.height); + const context = canvas.getContext("2d"); + if (!context) throw new Error("Image decoding is unavailable."); + context.putImageData(new ImageData(rgba, image.width, image.height), 0, 0); + return await canvas.convertToBlob({ type: "image/png" }); +} + +self.addEventListener("message", (event: MessageEvent<{ bytes: ArrayBuffer; heif: boolean }>) => { + void decode(event.data.bytes, event.data.heif).then( + (blob) => self.postMessage({ blob }, { transfer: [] }), + (cause: unknown) => + self.postMessage( + { error: cause instanceof Error ? cause.message : "Could not decode this image." }, + { transfer: [] }, + ), + ); +}); diff --git a/apps/web/src/components/files/readBinaryPreview.ts b/apps/web/src/components/files/readBinaryPreview.ts new file mode 100644 index 0000000000000..e721d5d4af53e --- /dev/null +++ b/apps/web/src/components/files/readBinaryPreview.ts @@ -0,0 +1,24 @@ +import { BINARY_PREVIEW_MAX_BYTES } from "./binaryPreviewProtocol"; + +export async function readBinaryPreview(src: string, signal: AbortSignal) { + const response = await fetch(src, { signal }); + if (!response.ok) throw new Error("The file could not be loaded. Close it and try again."); + if (Number(response.headers.get("content-length")) > BINARY_PREVIEW_MAX_BYTES) + throw new Error("Preview limited to files under 50 MB."); + const reader = response.body?.getReader(); + if (!reader) throw new Error("The file is unavailable."); + const chunks: Uint8Array[] = []; + let size = 0; + try { + while (true) { + const { value, done } = await reader.read(); + if (done) break; + size += value.length; + if (size > BINARY_PREVIEW_MAX_BYTES) throw new Error("Preview limited to files under 50 MB."); + chunks.push(value); + } + } finally { + await reader.cancel(); + } + return new Blob(chunks).arrayBuffer(); +} diff --git a/apps/web/src/components/media/AttachmentImage.browser.test.tsx b/apps/web/src/components/media/AttachmentImage.browser.test.tsx new file mode 100644 index 0000000000000..a91af59f6de7e --- /dev/null +++ b/apps/web/src/components/media/AttachmentImage.browser.test.tsx @@ -0,0 +1,109 @@ +import { act, render, type Screen } from "../../../test/render"; +import { expect, it, vi } from "vite-plus/test"; +import { AttachmentImage } from "./AttachmentImage"; +import { decodeImagePreview } from "../files/decodeImagePreview"; + +vi.mock("../files/decodeImagePreview", () => ({ + decodeImagePreview: vi.fn(), +})); + +const revokeUrl = vi.fn(); +const bitmap = new Blob(["png"], { type: "image/png" }); + +function setup() { + revokeUrl.mockReset(); + vi.mocked(decodeImagePreview).mockReset().mockResolvedValue(bitmap); +} + +function image(screen: Screen) { + const img = screen.container.querySelector("img"); + expect(img).not.toBeNull(); + return img!; +} + +// Stands in for the browser failing to decode the current source. +async function failDecode(screen: Screen) { + const img = image(screen); + await act(() => { + img.dispatchEvent(new Event("error")); + }); + await act(() => vi.dynamicImportSettled()); + return img; +} + +function expectDecodeErrorFor(onError: ReturnType, img: HTMLImageElement) { + expect(onError).toHaveBeenCalledWith(expect.objectContaining({ type: "error", target: img })); +} + +it.each([ + { name: "photo.HEIC", mimeType: undefined, heif: true }, + { name: "photo.heif", mimeType: undefined, heif: true }, + { name: "photo", mimeType: "image/heif", heif: true }, + { name: "scan.TIFF", mimeType: undefined, heif: false }, + { name: "scan", mimeType: "image/tiff", heif: false }, +])( + "converts $name after native decoding fails and releases its preview", + async ({ name, mimeType, heif }) => { + setup(); + using _createUrl = vi.spyOn(URL, "createObjectURL").mockReturnValue("blob:jpeg"); + using _revokeUrl = vi.spyOn(URL, "revokeObjectURL").mockImplementation(revokeUrl); + await using screen = await render( + , + ); + expect(decodeImagePreview).not.toHaveBeenCalled(); + await failDecode(screen); + expect(image(screen).getAttribute("src")).toBe("blob:jpeg"); + expect(decodeImagePreview).toHaveBeenCalledWith( + "https://environment.test/photo", + heif, + expect.any(AbortSignal), + ); + await screen.unmount(); + expect(revokeUrl).toHaveBeenCalledWith("blob:jpeg"); + }, +); + +it("leaves PNG errors to the caller without fetching or converting", async () => { + setup(); + using _createUrl = vi.spyOn(URL, "createObjectURL").mockReturnValue("blob:jpeg"); + using _revokeUrl = vi.spyOn(URL, "revokeObjectURL").mockImplementation(revokeUrl); + const onError = vi.fn(); + await using screen = await render( + , + ); + expectDecodeErrorFor(onError, await failDecode(screen)); + expect(decodeImagePreview).not.toHaveBeenCalled(); +}); + +it("reports failed HEIC conversion through the existing error handler", async () => { + setup(); + using _createUrl = vi.spyOn(URL, "createObjectURL").mockReturnValue("blob:jpeg"); + using _revokeUrl = vi.spyOn(URL, "revokeObjectURL").mockImplementation(revokeUrl); + const onError = vi.fn(); + vi.mocked(decodeImagePreview).mockRejectedValue(new Error("unreadable")); + await using screen = await render( + , + ); + expectDecodeErrorFor(onError, await failDecode(screen)); + expect(URL.createObjectURL).not.toHaveBeenCalled(); +}); + +it("discards an old decode when navigating to a different source", async () => { + setup(); + using _createUrl = vi.spyOn(URL, "createObjectURL").mockReturnValue("blob:jpeg"); + using _revokeUrl = vi.spyOn(URL, "revokeObjectURL").mockImplementation(revokeUrl); + let finishDecode!: (result: Blob) => void; + vi.mocked(decodeImagePreview).mockReturnValue( + new Promise((resolve) => { + finishDecode = resolve; + }), + ); + await using screen = await render(); + await failDecode(screen); + const secondSource = "data:image/svg+xml,"; + await screen.rerender(); + await act(() => finishDecode(bitmap)); + expect(image(screen).getAttribute("src")).toBe(secondSource); + expect(URL.createObjectURL).not.toHaveBeenCalled(); + expect(vi.mocked(decodeImagePreview).mock.calls[0]?.[2].aborted).toBe(true); +}); diff --git a/apps/web/src/components/media/AttachmentImage.tsx b/apps/web/src/components/media/AttachmentImage.tsx index dbe813a2ec0ef..294a59309fd12 100644 --- a/apps/web/src/components/media/AttachmentImage.tsx +++ b/apps/web/src/components/media/AttachmentImage.tsx @@ -5,57 +5,53 @@ import { type ComponentProps, type SyntheticEvent, } from "react"; -import { - isHeicImageFile, - MAX_COMPRESSIBLE_SOURCE_BYTES, - prepareImageForAttachment, -} from "../../lib/imageCompression"; +import { isHeicImageFile } from "../../lib/imageCompression"; type AttachmentImageProps = ComponentProps<"img"> & { name: string; mimeType?: string | undefined }; +// Keep import expressions outside hooks until React Compiler supports them. +const loadImageDecoder = () => import("../files/decodeImagePreview"); + export function AttachmentImage({ name, mimeType, ...props }: AttachmentImageProps) { - return isHeicImageFile({ name, type: mimeType ?? "" }) ? ( - + const fallback = + isHeicImageFile({ name, type: mimeType ?? "" }) || + /\.tiff?$/i.test(name) || + /^image\/tiff$/i.test(mimeType ?? ""); + return fallback ? ( + ) : ( ); } -function HeicImage({ name, src, onError, ...props }: Omit) { +function DecodedImage({ name, mimeType, src, onError, ...props }: AttachmentImageProps) { const [decodeError, setDecodeError] = useState | null>(null); const [previewUrl, setPreviewUrl] = useState(); - const reportError = useEffectEvent((event: SyntheticEvent) => onError?.(event)); - useEffect(() => { if (!decodeError || !src) return; const controller = new AbortController(); let objectUrl: string | undefined; - const decode = async () => { - try { - const response = await fetch(src, { signal: controller.signal }); - if (!response.ok) return reportError(decodeError); - const blob = await response.blob(); + void loadImageDecoder() + .then(async ({ decodeImagePreview }) => { if (controller.signal.aborted) return; - const result = await prepareImageForAttachment( - new File([blob], name, { type: "image/heic" }), - MAX_COMPRESSIBLE_SOURCE_BYTES, + const blob = await decodeImagePreview( + src, + /\.hei[cf]$/i.test(name) || /^image\/hei[cf]$/i.test(mimeType ?? ""), + controller.signal, ); if (controller.signal.aborted) return; - if (!result.ok) return reportError(decodeError); - objectUrl = URL.createObjectURL(result.file); + objectUrl = URL.createObjectURL(blob); setPreviewUrl(objectUrl); - } catch { + }) + .catch(() => { if (!controller.signal.aborted) reportError(decodeError); - } - }; - void decode(); + }); return () => { controller.abort(); if (objectUrl) URL.revokeObjectURL(objectUrl); }; - }, [decodeError, name, src]); - + }, [decodeError, name, src, mimeType]); return ( ); diff --git a/apps/web/src/pierre-icons.test.ts b/apps/web/src/pierre-icons.test.ts index 989e16e50297c..1379100702c61 100644 --- a/apps/web/src/pierre-icons.test.ts +++ b/apps/web/src/pierre-icons.test.ts @@ -1,8 +1,19 @@ import { assert, describe, it } from "vite-plus/test"; -import { resolvePierreIconForEntry, T3_PIERRE_ICONS } from "./pierre-icons"; +import { + hasSpecificPierreIconForFileName, + resolvePierreIconForEntry, + T3_PIERRE_ICONS, +} from "./pierre-icons"; describe("Pierre file icons", () => { + it.each(["photo.heic", "photo.heif", "PHOTO.HEIC", "folder/photo.HEIF"])( + "uses the image icon for %s", + (name) => { + assert.equal(resolvePierreIconForEntry(name, "file")?.token, "image"); + assert.isTrue(hasSpecificPierreIconForFileName(name)); + }, + ); it("uses detected types for extensionless files while keeping known filename icons", () => { assert.equal( resolvePierreIconForEntry("custom-script", "file", "text/x-python")?.token, diff --git a/apps/web/src/pierre-icons.ts b/apps/web/src/pierre-icons.ts index 800e4951ce29c..ace85fc6355c5 100644 --- a/apps/web/src/pierre-icons.ts +++ b/apps/web/src/pierre-icons.ts @@ -4,7 +4,7 @@ import { type FileTreeIcons, } from "@pierre/trees"; import { VIDEO_FILE_EXTENSIONS } from "@t3tools/shared/video"; -import { fileIconPathForMimeType } from "@t3tools/shared/filePreview"; +import { fileIconPathForMimeType } from "@t3tools/shared/mediaPreview"; export interface PierreIconResolution { name: string; @@ -103,6 +103,8 @@ export const T3_PIERRE_ICONS = { ex: "t3-file-icon-elixir", exs: "t3-file-icon-elixir", hs: "t3-file-icon-haskell", + heic: "file-tree-builtin-image", + heif: "file-tree-builtin-image", lhs: "t3-file-icon-haskell", java: "t3-file-icon-java", kt: "t3-file-icon-kotlin", @@ -168,6 +170,8 @@ export function resolvePierreIconForEntry( ): PierreIconResolution | null { if (kind === "directory") return null; const icon = completeIconResolver.resolveIcon("file-tree-icon-file", pathValue); + if (icon?.name === "file-tree-builtin-image" && icon.token === undefined) + return { ...icon, token: "image" }; return icon?.token === "default" && mimeType ? completeIconResolver.resolveIcon( "file-tree-icon-file", diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 0628bf3179d46..a155ac5917d70 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -113,7 +113,10 @@ const browserTestProject = { "@betteroffice/xlsx/headless", "fast-xml-parser", "dompurify", + "heic-to/next", "jszip", + "sql.js", + "tiff", ], }, test: { @@ -346,6 +349,20 @@ export default defineConfig(() => { emptyOutDir: true, manifest: true, sourcemap: buildSourcemap, + rolldownOptions: { + output: { + codeSplitting: { + // Preview imports must not fold shared file icons back into main. + groups: [ + { + name: "icons", + test: /\/(?:components\/chat\/PierreEntryIcon\.tsx|pierre-icons\.ts|mediaPreview\.ts)$/, + includeDependenciesRecursively: false, + }, + ], + }, + }, + }, }, worker: { format: "es" }, test: { diff --git a/packages/client-runtime/src/mediaSource.ts b/packages/client-runtime/src/mediaSource.ts index a46346fa70233..36b191dc13cff 100644 --- a/packages/client-runtime/src/mediaSource.ts +++ b/packages/client-runtime/src/mediaSource.ts @@ -1,9 +1,9 @@ import type { AssetResource, ThreadId } from "@t3tools/contracts"; import { - filePreviewKind, + audioMimeTypeFromExtension, mediaMimeType, mediaMimeTypeFromExtension, -} from "@t3tools/shared/filePreview"; +} from "@t3tools/shared/mediaPreview"; import { classifyMarkdownImageSource, @@ -69,15 +69,14 @@ export function resolveMediaSource( : splitFilePathPosition(classified.path).path; // Local paths have already been decoded. Do not interpret literal #, ?, or % characters again. const basename = fileBasename(path); - if (classified._tag === "WorkspaceFile" && filePreviewKind({ name: basename }) === "audio") - return null; const extensionIndex = basename.lastIndexOf("."); const detectedMimeType = classified._tag === "Direct" ? mediaMimeType(classified.uri) : extensionIndex < 0 ? null - : mediaMimeTypeFromExtension(basename.slice(extensionIndex)); + : (mediaMimeTypeFromExtension(basename.slice(extensionIndex)) ?? + audioMimeTypeFromExtension(basename.slice(extensionIndex))); const mimeType = detectedMimeType ?? (input.imageEmbed ? "image/*" : null); if (mimeType === null || mimeType.startsWith("audio/")) return null; const kind = mimeType.startsWith("video/") ? "video" : "image"; diff --git a/packages/client-runtime/src/projectFaviconCache.ts b/packages/client-runtime/src/projectFaviconCache.ts index b194f809e1419..0e53de3136b9e 100644 --- a/packages/client-runtime/src/projectFaviconCache.ts +++ b/packages/client-runtime/src/projectFaviconCache.ts @@ -1,5 +1,5 @@ import { EnvironmentId } from "@t3tools/contracts"; -import { mediaMimeType } from "@t3tools/shared/filePreview"; +import { mediaMimeType } from "@t3tools/shared/mediaPreview"; import { getProjectFaviconCacheKey, getProjectFaviconResourceKey, diff --git a/packages/client-runtime/src/state/assets.ts b/packages/client-runtime/src/state/assets.ts index 36a789b3089ad..298772ca05cc3 100644 --- a/packages/client-runtime/src/state/assets.ts +++ b/packages/client-runtime/src/state/assets.ts @@ -7,7 +7,7 @@ import { type ProjectCloneSnapshot, WS_METHODS, } from "@t3tools/contracts"; -import { mediaMimeTypeFromExtension } from "@t3tools/shared/filePreview"; +import { mediaMimeTypeFromExtension } from "@t3tools/shared/mediaPreview"; import { isWindowsAbsolutePath } from "@t3tools/shared/path"; import { getProjectFaviconResourceKey, diff --git a/packages/client-runtime/src/work-log/presentation.ts b/packages/client-runtime/src/work-log/presentation.ts index f8c4ca5eebca9..de0c0d6739340 100644 --- a/packages/client-runtime/src/work-log/presentation.ts +++ b/packages/client-runtime/src/work-log/presentation.ts @@ -17,7 +17,7 @@ import { import { classifyMarkdownImageSource } from "@t3tools/client-runtime/markdown-images"; import { resolveMediaSource } from "@t3tools/client-runtime/media-source"; import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; -import { isWorkspaceImagePreviewPath } from "@t3tools/shared/filePreview"; +import { isWorkspaceImagePreviewPath } from "@t3tools/shared/mediaPreview"; import { formatTokens } from "@t3tools/shared/usageFormat"; import { classifyToolActivity } from "@t3tools/shared/toolActivity"; import { toolOutputIndicatesFailure } from "@t3tools/shared/toolOutput"; diff --git a/packages/client-runtime/src/work-log/toolPresentation.ts b/packages/client-runtime/src/work-log/toolPresentation.ts index f69ddbd3c6516..b9b4d46411ddc 100644 --- a/packages/client-runtime/src/work-log/toolPresentation.ts +++ b/packages/client-runtime/src/work-log/toolPresentation.ts @@ -1,4 +1,4 @@ -import { isWorkspaceImagePreviewPath } from "@t3tools/shared/filePreview"; +import { isWorkspaceImagePreviewPath } from "@t3tools/shared/mediaPreview"; import { COMPUTER_USE_TOOL_SOURCE, dynamicToolSurface } from "@t3tools/shared/toolActivity"; import type { ToolActivityIcon, diff --git a/packages/shared/package.json b/packages/shared/package.json index baa12fd43c5a3..f968d276883e1 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -299,6 +299,10 @@ "types": "./src/previewViewport.ts", "import": "./src/previewViewport.ts" }, + "./mediaPreview": { + "types": "./src/mediaPreview.ts", + "import": "./src/mediaPreview.ts" + }, "./filePreview": { "types": "./src/filePreview.ts", "import": "./src/filePreview.ts" diff --git a/packages/shared/src/filePreview.test.ts b/packages/shared/src/filePreview.test.ts index 0153166252d59..ef63070eeeaf3 100644 --- a/packages/shared/src/filePreview.test.ts +++ b/packages/shared/src/filePreview.test.ts @@ -13,6 +13,29 @@ import { } from "./filePreview.ts"; describe("workspace file previews", () => { + it.each([ + ["zip", "archive", "application/zip"], + ["jar", "archive", "application/java-archive"], + ["tar", "archive", "application/x-tar"], + ["gz", "archive", "application/gzip"], + ["tgz", "archive", "application/gzip"], + ["sqlite", "sqlite", "application/vnd.sqlite3"], + ["sqlite3", "sqlite", "application/vnd.sqlite3"], + ["db", "sqlite", "application/vnd.sqlite3"], + ])("recognizes binary preview %s", (extension, kind, mimeType) => { + const name = `files#draft/sample.${extension.toUpperCase()}`; + expect(filePreviewKind({ name, mimeType: "application/octet-stream" })).toBe(kind); + expect(filePreviewKind({ name: "download", mimeType })).toBe(kind); + expect(isWorkspacePreviewEntryPath(name)).toBe(true); + expect(isWorkspacePreviewEntryPath(`${name}?download=1`)).toBe(false); + expect(hostPreviewMimeTypeFromExtension(`.${extension}`)).toBe(mimeType); + }); + it("recognizes compound archives and MIME aliases", () => { + expect(filePreviewKind({ name: "archive.tar.gz" })).toBe("archive"); + expect(filePreviewKind({ name: "download", mimeType: "application/x-gzip" })).toBe("archive"); + expect(filePreviewKind({ name: "download", mimeType: "application/x-sqlite3" })).toBe("sqlite"); + expect(filePreviewKind({ name: "misleading.zip", mimeType: "application/pdf" })).toBe("pdf"); + }); it.each(["ttf", "otf", "woff", "woff2"])( "previews %s fonts through signed host assets", (extension) => { @@ -25,7 +48,7 @@ describe("workspace file previews", () => { expect(hostPreviewMimeTypeFromExtension(`.${extension}`)).toBe(`font/${extension}`); expect(isWorkspaceFontPreviewPath(`sample.${extension}?download=1`)).toBe(false); expect(filePreviewKind({ name: `sample.${extension}`, mimeType: "application/zip" })).toBe( - "unsupported", + "archive", ); }, ); @@ -51,7 +74,7 @@ describe("workspace file previews", () => { expect(isWorkspacePreviewEntryPath(name)).toBe(true); expect(hostPreviewMimeTypeFromExtension(`.${kind}`)).toBe(mimeType); expect(filePreviewKind({ name: "download", mimeType })).toBe(kind); - expect(filePreviewKind({ name, mimeType: "application/zip" })).toBe("unsupported"); + expect(filePreviewKind({ name, mimeType: "application/zip" })).toBe("archive"); }); // regression: #10311 previews classify by the literal filename, not URL-like suffixes it.each([ @@ -67,7 +90,7 @@ describe("workspace file previews", () => { expect(isWorkspacePreviewEntryPath(path)).toBe(false); }); - it.each(["picture.bmp", "photo.heic", "scan.tif", "scan.TIFF"])( + it.each(["picture.bmp", "photo.heic", "photo.heif", "scan.tif", "scan.TIFF"])( "recognizes image preview path %s", (path) => { expect(isWorkspaceImagePreviewPath(path)).toBe(true); @@ -132,8 +155,9 @@ describe("attachment preview classification", () => { ["recording.webm-audio", "", "audio"], ["recording.webm", "audio/webm", "audio"], ["payload", "application/problem+json", "text"], + ["archive.zip", "application/zip", "archive"], ["misleading.json", "application/pdf", "pdf"], - ["misleading.pdf", "application/zip", "unsupported"], + ["misleading.pdf", "application/zip", "archive"], ])("classifies %s (%s) as %s", (name, mimeType, expected) => { expect(filePreviewKind({ name, mimeType })).toBe(expected); }); @@ -141,7 +165,7 @@ describe("attachment preview classification", () => { expect(() => decodeFilePreviewText(new Uint8Array([65, 0, 66]))).toThrow("binary"); expect(() => decodeFilePreviewText(new Uint8Array([255]))).toThrow("UTF-8"); }); - it.each(["doc", "xls", "ppt", "odt", "ods", "odp", "epub", "zip", "tar", "gz", "7z", "rar"])( + it.each(["doc", "xls", "ppt", "odt", "ods", "odp", "epub", "7z", "rar"])( "retains the binary fallback for %s, which opencode does not render", (extension) => { expect(filePreviewKind({ name: `sample.${extension}` })).toBe("unsupported"); diff --git a/packages/shared/src/filePreview.ts b/packages/shared/src/filePreview.ts index 2703c4bdef313..e9871fb698aaa 100644 --- a/packages/shared/src/filePreview.ts +++ b/packages/shared/src/filePreview.ts @@ -1,24 +1,16 @@ +import { + audioMimeTypeFromExtension, + isWorkspaceImagePreviewPath, + mediaMimeTypeFromExtension, +} from "./mediaPreview.ts"; +export { + isWorkspaceAudioPreviewPath, + isWorkspaceImagePreviewPath, + mediaKindFromPath, + mediaMimeTypeFromExtension, +} from "./mediaPreview.ts"; import { videoMimeType } from "./video.ts"; -const ICON_EXTENSION_BY_MIME_TYPE: Readonly> = { - "image/png": "png", - "image/jpeg": "jpg", - "image/gif": "gif", - "image/webp": "webp", - "application/pdf": "pdf", - "application/zip": "zip", - "application/vnd.sqlite3": "sqlite", - "text/x-python": "py", - "text/javascript": "js", - "text/x-ruby": "rb", - "text/x-shellscript": "sh", -}; - -export function fileIconPathForMimeType(path: string, mimeType?: string): string { - const extension = ICON_EXTENSION_BY_MIME_TYPE[mimeType ?? ""]; - return extension ? `file.${extension}` : path; -} - export type FilePreviewKind = | "image" | "video" @@ -28,6 +20,8 @@ export type FilePreviewKind = | "markdown" | "mermaid" | "font" + | "archive" + | "sqlite" | "docx" | "xlsx" | "pptx" @@ -45,6 +39,8 @@ export function filePreviewKind( const richDocuments = options?.richDocuments !== false; const office = richDocuments ? officePreviewKind(file) : null; if (office) return office; + const binary = binaryPreviewKind(file); + if (binary) return binary; if (mime === "application/pdf") return "pdf"; if (mime === "text/html") return "html"; if (mime === "text/markdown" || mime === "text/x-markdown") return "markdown"; @@ -93,6 +89,43 @@ export function filePreviewKind( return "unsupported"; } +const BINARY_MIME_TYPES = new Map([ + [".zip", "application/zip"], + [".jar", "application/java-archive"], + [".tar", "application/x-tar"], + [".gz", "application/gzip"], + [".tgz", "application/gzip"], + [".sqlite", "application/vnd.sqlite3"], + [".sqlite3", "application/vnd.sqlite3"], + [".db", "application/vnd.sqlite3"], +]); + +export function binaryPreviewKind(file: { + readonly name: string; + readonly mimeType?: string; +}): "archive" | "sqlite" | null { + const mime = file.mimeType?.split(";", 1)[0]?.trim().toLowerCase() ?? ""; + if (["application/vnd.sqlite3", "application/x-sqlite3", "application/x-sqlite"].includes(mime)) + return "sqlite"; + if ( + [ + "application/zip", + "application/x-zip-compressed", + "application/java-archive", + "application/x-java-archive", + "application/x-tar", + "application/gzip", + "application/x-gzip", + "application/x-compressed-tar", + ].includes(mime) + ) + return "archive"; + if (mime && mime !== "application/octet-stream" && mime !== "text/plain") return null; + const extension = file.name.slice(file.name.lastIndexOf(".")).toLowerCase(); + const inferred = BINARY_MIME_TYPES.get(extension); + return inferred === "application/vnd.sqlite3" ? "sqlite" : inferred ? "archive" : null; +} + export const FILE_TEXT_PREVIEW_MAX_BYTES = 1024 * 1024; const OFFICE_MIME_TYPES = { @@ -136,56 +169,12 @@ export function decodeFilePreviewText(bytes: Uint8Array, truncated = false) { export const WORKSPACE_BROWSER_PREVIEW_EXTENSIONS = [".htm", ".html", ".pdf"] as const; -export const WORKSPACE_IMAGE_PREVIEW_EXTENSIONS = [ - ".avif", - ".bmp", - ".gif", - ".ico", - ".heic", - ".jpeg", - ".jpg", - ".png", - ".svg", - ".tif", - ".tiff", - ".webp", -] as const; - -const IMAGE_MIME_TYPE_BY_EXTENSION = new Map([ - [".avif", "image/avif"], - [".bmp", "image/bmp"], - [".heic", "image/heic"], - [".gif", "image/gif"], - [".ico", "image/x-icon"], - [".jpeg", "image/jpeg"], - [".jpg", "image/jpeg"], - [".png", "image/png"], - [".svg", "image/svg+xml"], - [".tif", "image/tiff"], - [".tiff", "image/tiff"], - [".webp", "image/webp"], -]); - const BROWSER_MIME_TYPE_BY_EXTENSION = new Map([ [".htm", "text/html"], [".html", "text/html"], [".pdf", "application/pdf"], ]); -const AUDIO_MIME_TYPE_BY_EXTENSION = new Map([ - [".mp3", "audio/mpeg"], - [".wav", "audio/wav"], - [".ogg", "audio/ogg"], - [".oga", "audio/ogg"], - [".flac", "audio/flac"], - [".aac", "audio/aac"], - [".m4a", "audio/mp4"], - [".opus", "audio/ogg"], - [".aiff", "audio/aiff"], - [".webm-audio", "audio/webm"], - [".weba", "audio/webm"], -]); - const FONT_MIME_TYPE_BY_EXTENSION = new Map([ [".ttf", "font/ttf"], [".otf", "font/otf"], @@ -197,20 +186,6 @@ export function isWorkspaceFontPreviewPath(path: string): boolean { return /\.(ttf|otf|woff2?)$/i.test(path); } -export function audioMimeTypeFromExtension(extension: string): string | null { - if (!/^\.[a-z0-9-]+$/i.test(extension)) return null; - return AUDIO_MIME_TYPE_BY_EXTENSION.get(extension.toLowerCase()) ?? null; -} - -/** Classifies a literal filesystem extension, without URL decoding or suffix removal. */ -export function mediaMimeTypeFromExtension(extension: string): string | null { - if (!/^\.[a-z0-9]+$/i.test(extension)) return null; - return ( - IMAGE_MIME_TYPE_BY_EXTENSION.get(extension.toLowerCase()) ?? - videoMimeType({ name: `media${extension}`, mimeType: "" }) - ); -} - export function hostPreviewMimeTypeFromExtension(extension: string): string | null { if (!/^\.[a-z0-9-]+$/i.test(extension)) return null; return ( @@ -218,6 +193,7 @@ export function hostPreviewMimeTypeFromExtension(extension: string): string | nu audioMimeTypeFromExtension(extension) ?? BROWSER_MIME_TYPE_BY_EXTENSION.get(extension.toLowerCase()) ?? FONT_MIME_TYPE_BY_EXTENSION.get(extension.toLowerCase()) ?? + BINARY_MIME_TYPES.get(extension.toLowerCase()) ?? (Object.hasOwn(OFFICE_MIME_TYPES, extension.toLowerCase().slice(1)) ? OFFICE_MIME_TYPES[extension.toLowerCase().slice(1) as OfficePreviewKind] : null) ?? @@ -225,42 +201,6 @@ export function hostPreviewMimeTypeFromExtension(extension: string): string | nu ); } -/** Classifies an authored media path or URL. Filesystem validation uses the literal extension. */ -export function mediaMimeType(path: string): string | null { - const trimmed = path.trim(); - const source = trimmed.startsWith("<") && trimmed.endsWith(">") ? trimmed.slice(1, -1) : trimmed; - const dataMimeType = /^data:((?:image|video|audio)\/[\w.+-]+)[;,]/i.exec(source)?.[1]; - if (dataMimeType) return dataMimeType.toLowerCase(); - - let sourcePath = source.split(/[?#]/, 1)[0] ?? ""; - if (/^(?:https?:|file:|\/\/)/i.test(source)) { - try { - sourcePath = new URL(source, "https://media.invalid").pathname; - } catch { - return null; - } - } - try { - sourcePath = decodeURIComponent(sourcePath); - } catch {} - const basename = sourcePath.split(/[\\/]/).at(-1) ?? ""; - const extensionIndex = basename.lastIndexOf("."); - return extensionIndex < 0 - ? null - : (mediaMimeTypeFromExtension(basename.slice(extensionIndex)) ?? - audioMimeTypeFromExtension(basename.slice(extensionIndex))); -} - -export function mediaKindFromPath(path: string): "image" | "video" | "audio" | null { - const mimeType = mediaMimeType(path); - if (mimeType === null) return null; - return mimeType.startsWith("audio/") - ? "audio" - : mimeType.startsWith("video/") - ? "video" - : "image"; -} - function hasPreviewExtension(path: string, extensions: ReadonlyArray): boolean { const literalPath = path.toLowerCase(); return extensions.some((extension) => literalPath.endsWith(extension)); @@ -270,25 +210,17 @@ export function isWorkspaceBrowserPreviewPath(path: string): boolean { return hasPreviewExtension(path, WORKSPACE_BROWSER_PREVIEW_EXTENSIONS); } -export function isWorkspaceImagePreviewPath(path: string): boolean { - return hasPreviewExtension(path, WORKSPACE_IMAGE_PREVIEW_EXTENSIONS); -} - /** File viewers receive literal filesystem paths, not Markdown URLs. */ export function isWorkspaceVideoPreviewPath(path: string): boolean { return videoMimeType({ name: path, mimeType: "" }) !== null; } -export function isWorkspaceAudioPreviewPath(path: string): boolean { - const extensionIndex = path.lastIndexOf("."); - return extensionIndex >= 0 && audioMimeTypeFromExtension(path.slice(extensionIndex)) !== null; -} - export function isWorkspacePreviewEntryPath(path: string): boolean { return ( isWorkspaceBrowserPreviewPath(path) || isWorkspaceImagePreviewPath(path) || isWorkspaceFontPreviewPath(path) || - officePreviewKind({ name: path }) !== null + officePreviewKind({ name: path }) !== null || + binaryPreviewKind({ name: path }) !== null ); } diff --git a/packages/shared/src/mediaPreview.ts b/packages/shared/src/mediaPreview.ts new file mode 100644 index 0000000000000..0ff96e6a65509 --- /dev/null +++ b/packages/shared/src/mediaPreview.ts @@ -0,0 +1,126 @@ +import { videoMimeType } from "./video.ts"; + +const ICON_EXTENSION_BY_MIME_TYPE: Readonly> = { + "image/png": "png", + "image/jpeg": "jpg", + "image/gif": "gif", + "image/webp": "webp", + "application/pdf": "pdf", + "application/zip": "zip", + "application/vnd.sqlite3": "sqlite", + "text/x-python": "py", + "text/javascript": "js", + "text/x-ruby": "rb", + "text/x-shellscript": "sh", +}; + +export function fileIconPathForMimeType(path: string, mimeType?: string): string { + const extension = ICON_EXTENSION_BY_MIME_TYPE[mimeType ?? ""]; + return extension ? `file.${extension}` : path; +} + +export const WORKSPACE_IMAGE_PREVIEW_EXTENSIONS = [ + ".avif", + ".bmp", + ".gif", + ".ico", + ".heic", + ".heif", + ".jpeg", + ".jpg", + ".png", + ".svg", + ".tif", + ".tiff", + ".webp", +] as const; + +const IMAGE_MIME_TYPE_BY_EXTENSION = new Map([ + [".avif", "image/avif"], + [".bmp", "image/bmp"], + [".heic", "image/heic"], + [".heif", "image/heif"], + [".gif", "image/gif"], + [".ico", "image/x-icon"], + [".jpeg", "image/jpeg"], + [".jpg", "image/jpeg"], + [".png", "image/png"], + [".svg", "image/svg+xml"], + [".tif", "image/tiff"], + [".tiff", "image/tiff"], + [".webp", "image/webp"], +]); + +const AUDIO_MIME_TYPE_BY_EXTENSION = new Map([ + [".mp3", "audio/mpeg"], + [".wav", "audio/wav"], + [".ogg", "audio/ogg"], + [".oga", "audio/ogg"], + [".flac", "audio/flac"], + [".aac", "audio/aac"], + [".m4a", "audio/mp4"], + [".opus", "audio/ogg"], + [".aiff", "audio/aiff"], + [".webm-audio", "audio/webm"], + [".weba", "audio/webm"], +]); + +export function audioMimeTypeFromExtension(extension: string): string | null { + if (!/^\.[a-z0-9-]+$/i.test(extension)) return null; + return AUDIO_MIME_TYPE_BY_EXTENSION.get(extension.toLowerCase()) ?? null; +} + +/** Classifies a literal filesystem extension, without URL decoding or suffix removal. */ +export function mediaMimeTypeFromExtension(extension: string): string | null { + if (!/^\.[a-z0-9]+$/i.test(extension)) return null; + return ( + IMAGE_MIME_TYPE_BY_EXTENSION.get(extension.toLowerCase()) ?? + videoMimeType({ name: `media${extension}`, mimeType: "" }) + ); +} + +/** Classifies an authored media path or URL. Filesystem validation uses the literal extension. */ +export function mediaMimeType(path: string): string | null { + const trimmed = path.trim(); + const source = trimmed.startsWith("<") && trimmed.endsWith(">") ? trimmed.slice(1, -1) : trimmed; + const dataMimeType = /^data:((?:image|video|audio)\/[\w.+-]+)[;,]/i.exec(source)?.[1]; + if (dataMimeType) return dataMimeType.toLowerCase(); + + let sourcePath = source.split(/[?#]/, 1)[0] ?? ""; + if (/^(?:https?:|file:|\/\/)/i.test(source)) { + try { + sourcePath = new URL(source, "https://media.invalid").pathname; + } catch { + return null; + } + } + try { + sourcePath = decodeURIComponent(sourcePath); + } catch {} + const basename = sourcePath.split(/[\\/]/).at(-1) ?? ""; + const extensionIndex = basename.lastIndexOf("."); + return extensionIndex < 0 + ? null + : (mediaMimeTypeFromExtension(basename.slice(extensionIndex)) ?? + audioMimeTypeFromExtension(basename.slice(extensionIndex))); +} + +export function mediaKindFromPath(path: string): "image" | "video" | "audio" | null { + const mimeType = mediaMimeType(path); + if (mimeType === null) return null; + return mimeType.startsWith("audio/") + ? "audio" + : mimeType.startsWith("video/") + ? "video" + : "image"; +} + +export function isWorkspaceImagePreviewPath(path: string): boolean { + const literalPath = path.toLowerCase(); + return WORKSPACE_IMAGE_PREVIEW_EXTENSIONS.some((extension) => literalPath.endsWith(extension)); +} + +export function isWorkspaceAudioPreviewPath(path: string): boolean { + const extensionIndex = path.lastIndexOf("."); + return extensionIndex >= 0 && audioMimeTypeFromExtension(path.slice(extensionIndex)) !== null; +} diff --git a/packages/shared/src/projectFavicon.ts b/packages/shared/src/projectFavicon.ts index d07ec3a087efa..6da9c77b782c5 100644 --- a/packages/shared/src/projectFavicon.ts +++ b/packages/shared/src/projectFavicon.ts @@ -1,4 +1,4 @@ -import { WORKSPACE_IMAGE_PREVIEW_EXTENSIONS } from "./filePreview.ts"; +import { WORKSPACE_IMAGE_PREVIEW_EXTENSIONS } from "./mediaPreview.ts"; export const PROJECT_FAVICON_FALLBACK_MARKER = "project-favicon-missing"; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 044ae76110741..de0c270b07b21 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1193,9 +1193,15 @@ importers: saxes: specifier: 6.0.0 version: 6.0.0 + sql.js: + specifier: 1.15.0 + version: 1.15.0 tailwind-merge: specifier: ^3.4.0 version: 3.6.0 + tiff: + specifier: 7.1.3 + version: 7.1.3 unbash: specifier: ^5.0.0 version: 5.0.0 @@ -1236,6 +1242,9 @@ importers: '@types/react-dom': specifier: 'catalog:' version: 19.3.0(@types/react@19.3.0) + '@types/sql.js': + specifier: 1.4.11 + version: 1.4.11 '@types/use-sync-external-store': specifier: ^0.0.6 version: 0.0.6 @@ -1298,10 +1307,10 @@ importers: version: link:../../packages/shared alchemy: specifier: 2.0.0-beta.80 - version: 2.0.0-beta.80(patch_hash=bea1b6c0c0b23157fd5f439a1d7feff430f28ac1621ba135d8a13eeec60ccacd)(ed9f72c53f648b6275121476d8becac3) + version: 2.0.0-beta.80(patch_hash=bea1b6c0c0b23157fd5f439a1d7feff430f28ac1621ba135d8a13eeec60ccacd)(e50ec971fc482761b0a5b8ee3921094f) drizzle-orm: specifier: 1.0.0-rc.5-ab785fc - version: 1.0.0-rc.5-ab785fc(@cloudflare/workers-types@4.20260604.1)(@effect/sql-d1@4.0.0(effect@4.0.5))(@effect/sql-pg@4.0.5(effect@4.0.5))(@effect/sql-sqlite-bun@4.0.0-rc.112(effect@4.0.5))(@effect/sql-sqlite-do@4.0.5(effect@4.0.5))(@electric-sql/pglite@0.3.15)(@libsql/client@0.17.3(bufferutil@4.1.0)(utf-8-validate@6.0.6))(bun-types@1.3.14)(effect@4.0.5)(expo-sqlite@58.0.13(expo@58.0.14)(react-native@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0))(pg@8.23.0)(zod@4.4.3) + version: 1.0.0-rc.5-ab785fc(@cloudflare/workers-types@4.20260604.1)(@effect/sql-d1@4.0.0(effect@4.0.5))(@effect/sql-pg@4.0.5(effect@4.0.5))(@effect/sql-sqlite-bun@4.0.0-rc.112(effect@4.0.5))(@effect/sql-sqlite-do@4.0.5(effect@4.0.5))(@electric-sql/pglite@0.3.15)(@libsql/client@0.17.3(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/sql.js@1.4.11)(bun-types@1.3.14)(effect@4.0.5)(expo-sqlite@58.0.13(expo@58.0.14)(react-native@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0))(pg@8.23.0)(sql.js@1.15.0)(zod@4.6.5) effect: specifier: 4.0.5 version: 4.0.5 @@ -1335,7 +1344,7 @@ importers: dependencies: alchemy: specifier: 2.0.0-beta.80 - version: 2.0.0-beta.80(patch_hash=bea1b6c0c0b23157fd5f439a1d7feff430f28ac1621ba135d8a13eeec60ccacd)(93312816496722c8e540690c17876476) + version: 2.0.0-beta.80(patch_hash=bea1b6c0c0b23157fd5f439a1d7feff430f28ac1621ba135d8a13eeec60ccacd)(e50ec971fc482761b0a5b8ee3921094f) effect: specifier: 4.0.5 version: 4.0.5 @@ -1369,7 +1378,7 @@ importers: version: 4.0.5(bufferutil@4.1.0)(effect@4.0.5)(redis@6.2.1)(utf-8-validate@6.0.6) '@oxlint/plugins': specifier: ^1.63.0 - version: 1.68.0 + version: 1.89.0 effect: specifier: 4.0.5 version: 4.0.5 @@ -2236,10 +2245,6 @@ packages: peerDependencies: zod: ^3.25.76 || ^4.1.8 - '@ai-sdk/provider@3.0.15': - resolution: {integrity: sha512-XeZW1CcDF2GMbH4wejW6xBRI2QCOgnkVYUnxoeDadB1mf85riL2bMUeDoh+6gJ/r4mjNfzUPW8OjLjvwTP0u1Q==} - engines: {node: '>=18'} - '@ai-sdk/provider@3.0.18': resolution: {integrity: sha512-IpefF5ssZVOZCD4Wu7zxt6PwZoGT2HLVjxpJ10Bjx6EAmRfLYJPjyrfxb0RYDlwvXnDb9nkQ7Do/oJVDuRFzew==} engines: {node: '>=18'} @@ -2887,10 +2892,6 @@ packages: resolution: {integrity: sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==} engines: {node: '>=6.9.0'} - '@babel/types@7.29.7': - resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} - engines: {node: '>=6.9.0'} - '@babel/types@7.29.8': resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} engines: {node: '>=6.9.0'} @@ -4638,12 +4639,6 @@ packages: resolution: {integrity: sha512-WrOw/bcXm0f9qHkumlT1QlArXSTWqaY9sunsDpOk+yCCorCKMxvWT/a3xko4EYHVdeZoh00yI2TydXn6eyICDA==} engines: {node: '>= 10'} - '@napi-rs/wasm-runtime@1.1.6': - resolution: {integrity: sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==} - peerDependencies: - '@emnapi/core': ^1.7.1 - '@emnapi/runtime': ^1.7.1 - '@napi-rs/wasm-runtime@1.2.4': resolution: {integrity: sha512-AJxoUD2/15ESHbvpcyjU274nsAPLuOtPHCk0vKJM5pj//Fg/B1FXNWjPnXTT9PymCYYiHo4zPj0ZomXBKhoy7g==} engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0} @@ -5608,10 +5603,6 @@ packages: cpu: [x64] os: [win32] - '@oxlint/plugins@1.68.0': - resolution: {integrity: sha512-titLmukUt/h8ho7Svlf0xSBjoy2ccZKrXjpXpZCj+v6V4CJccC2KyP45BLSCMx8YIpifMyiDyUptM4+5sruKbQ==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - '@oxlint/plugins@1.89.0': resolution: {integrity: sha512-TJBBNl4KIxbxrSk7gsqlz6iM1kxidfyTizEzfWqXvijF2Ou8/Da58GvjemBVmCmaUamwRij+JwsVc0kpBFuqfg==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -6614,9 +6605,6 @@ packages: '@tweenjs/tween.js@23.1.3': resolution: {integrity: sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==} - '@tybys/wasm-util@0.10.3': - resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} - '@tybys/wasm-util@0.10.4': resolution: {integrity: sha512-W3c4gRigFS0T/Ma4qIYF3GDAc5AQdHb1yL5znJT1Zv1YaD9Kitx656wBjvr19qbiosmZT8lWDM5BEMynUqX65A==} @@ -6745,6 +6733,9 @@ packages: '@types/serve-static@2.2.0': resolution: {integrity: sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==} + '@types/sql.js@1.4.11': + resolution: {integrity: sha512-QXIx38p2ZThJaK9vP5ZdqdlRe1FG9I8SmCZOS7FHfB/2qPAjZwkL7/vlfPg6N/oWHuuOaGg/P/IRwfP2W0kWVQ==} + '@types/stats.js@0.17.4': resolution: {integrity: sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==} @@ -9205,6 +9196,9 @@ packages: invariant@2.2.4: resolution: {integrity: sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==} + iobuffer@6.0.1: + resolution: {integrity: sha512-SZWYkWNfjIXIBYSDpXDYIgshqtbOPsi4lviawAEceR1Kqk+sHDlcQjWrzNQsii80AyBY0q5c8HCTNjqo74ul+Q==} + ioredis@5.11.0: resolution: {integrity: sha512-EZBErytyVovD8f6pDfG3Kb37N6Y3lmDA9NNj+4+IP13CzzHGeX+OyeRM2Um13khRzoBSzzL+5lVnCX8V2RLeMg==} engines: {node: '>=12.22.0'} @@ -9340,9 +9334,6 @@ packages: jose@6.2.2: resolution: {integrity: sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==} - jose@6.2.3: - resolution: {integrity: sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==} - js-base64@3.7.8: resolution: {integrity: sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow==} @@ -9623,9 +9614,6 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} - magic-string@1.4.1: - resolution: {integrity: sha512-8lyCu36ErXR0J9uaGKlKQoiLZKmtI63YGLE8G2o9jyRPdr4X47LusSOwgOJOzcVtp81fTAAjxR7BwKz682Jhow==} - magic-string@1.4.3: resolution: {integrity: sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==} @@ -10039,11 +10027,6 @@ packages: multitars@1.0.2: resolution: {integrity: sha512-6GwVw5eLi9sThdtlS4PKwC7yRLaf45pYhIEzKBHdKxi+YOXGKFX8acIniH+Uh/+k9mS2lQOupTccjoe5r0/1IQ==} - nanoid@3.3.19: - resolution: {integrity: sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==} - engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} - hasBin: true - nanoid@3.3.20: resolution: {integrity: sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -10328,9 +10311,6 @@ packages: resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} engines: {node: '>=6'} - package-manager-detector@1.6.0: - resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==} - package-manager-detector@1.8.0: resolution: {integrity: sha512-yQA4H19AmPEoMUeavPMDIe1higySl/gH/yaQrkT/s07Qp+7pp2hYz30N3z2l5BkjVkF9Ow6o0wjJamm2y7Sn0A==} @@ -11202,10 +11182,6 @@ packages: resolution: {integrity: sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==} engines: {node: '>= 10.0.0', npm: '>= 3.0.0'} - source-map-js@1.2.1: - resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} - engines: {node: '>=0.10.0'} - source-map-js@1.2.2: resolution: {integrity: sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==} engines: {node: '>=0.10.0'} @@ -11237,6 +11213,9 @@ packages: sprintf-js@1.1.3: resolution: {integrity: sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==} + sql.js@1.15.0: + resolution: {integrity: sha512-54VT5h9/VGlLaQan5pX/Zx8k6Vbykwl5hRUtl4h4+VYnaZCTraUs4P1i2WLz21O6oPVp6Gvg+d1OyPo01ZG5ig==} + stackframe@1.3.4: resolution: {integrity: sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw==} @@ -11410,6 +11389,9 @@ packages: through@2.3.8: resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==} + tiff@7.1.3: + resolution: {integrity: sha512-YEEq3fT++2pdta/9P/vGG4QRMdZQoe6W6JNaWnIi6NvAsbeNITwFCtmWwL/BZvOi+uo2I3ohyOkD3sZfme+c6g==} + timestring@6.0.0: resolution: {integrity: sha512-wMctrWD2HZZLuIlchlkE2dfXJh7J2KDI9Dwl+2abPYg0mswQHfOAyQW3jJg1pY5VfttSINZuKcXoB3FGypVklA==} engines: {node: '>=8'} @@ -11431,10 +11413,6 @@ packages: resolution: {integrity: sha512-nTv2raKFMzGG6JJp0lpNDsRPdfFE6UHlu2rVEjXo8vkx1ZS3LW2CVXqIaLu8xZlu+6tbFs9V58Qc/Jmk/Ii7wA==} engines: {node: ^16.14.0 || >= 17.3.0} - tinyexec@1.2.4: - resolution: {integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==} - engines: {node: '>=18'} - tinyexec@1.3.1: resolution: {integrity: sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==} engines: {node: '>=18'} @@ -12192,9 +12170,6 @@ packages: zod@3.25.76: resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} - zod@4.4.3: - resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} - zod@4.5.4: resolution: {integrity: sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA==} @@ -12233,17 +12208,13 @@ snapshots: optionalDependencies: typescript: '@tsc-rs/typescript@0.8.0' - '@ai-sdk/provider-utils@4.0.57(zod@4.4.3)': + '@ai-sdk/provider-utils@4.0.57(zod@4.6.5)': dependencies: '@ai-sdk/provider': 3.0.18 '@standard-schema/spec': 1.1.0 eventsource-parser: 3.1.0 undici: 6.29.0 - zod: 4.4.3 - - '@ai-sdk/provider@3.0.15': - dependencies: - json-schema: 0.4.0 + zod: 4.6.5 '@ai-sdk/provider@3.0.18': dependencies: @@ -13145,11 +13116,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/types@7.29.7': - dependencies: - '@babel/helper-string-parser': 7.29.7 - '@babel/helper-validator-identifier': 7.29.7 - '@babel/types@7.29.8': dependencies: '@babel/helper-string-parser': 7.29.7 @@ -14809,7 +14775,7 @@ snapshots: express: 5.2.1(supports-color@8.1.1) express-rate-limit: 8.5.2(express@5.2.1(supports-color@8.1.1)) hono: 4.13.7 - jose: 6.2.3 + jose: 6.2.2 json-schema-typed: 8.0.2 pkce-challenge: 5.0.1 raw-body: 3.0.2 @@ -14878,13 +14844,6 @@ snapshots: '@napi-rs/keyring-win32-ia32-msvc': 1.3.0 '@napi-rs/keyring-win32-x64-msvc': 1.3.0 - '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)': - dependencies: - '@emnapi/core': 1.11.2 - '@emnapi/runtime': 1.11.2 - '@tybys/wasm-util': 0.10.3 - optional: true - '@napi-rs/wasm-runtime@1.2.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': dependencies: '@emnapi/core': 1.10.0 @@ -14899,6 +14858,13 @@ snapshots: '@tybys/wasm-util': 0.10.4 optional: true + '@napi-rs/wasm-runtime@1.2.4(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)': + dependencies: + '@emnapi/core': 1.11.2 + '@emnapi/runtime': 1.11.2 + '@tybys/wasm-util': 0.10.4 + optional: true + '@napi-rs/wasm-runtime@1.2.4(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)': dependencies: '@emnapi/core': 1.11.3 @@ -14920,8 +14886,7 @@ snapshots: '@noble/hashes@1.8.0': {} - '@nodable/entities@2.1.1': - optional: true + '@nodable/entities@2.1.1': {} '@nornagon/put@0.0.8': {} @@ -15258,7 +15223,7 @@ snapshots: dependencies: '@emnapi/core': 1.11.2 '@emnapi/runtime': 1.11.2 - '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2) + '@napi-rs/wasm-runtime': 1.2.4(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2) optional: true '@oxc-resolver/binding-win32-arm64-msvc@11.24.2': @@ -15456,8 +15421,6 @@ snapshots: '@oxlint/binding-win32-x64-msvc@1.89.0': optional: true - '@oxlint/plugins@1.68.0': {} - '@oxlint/plugins@1.89.0': {} '@peculiar/asn1-schema@2.8.0': @@ -15546,17 +15509,17 @@ snapshots: commander: 14.0.3 ignore: 7.0.5 ora: 9.4.0 - package-manager-detector: 1.6.0 + package-manager-detector: 1.8.0 picocolors: 1.1.1 prompts: 2.4.2 - tinyexec: 1.2.4 + tinyexec: 1.3.1 '@react-native-ai/apple@0.12.0(patch_hash=2d09870c2848d185cb05b53ed823a46e12dba519324d8dd8e584e28731990f9d)(react-native@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))': dependencies: - '@ai-sdk/provider': 3.0.15 - '@ai-sdk/provider-utils': 4.0.57(zod@4.4.3) + '@ai-sdk/provider': 3.0.18 + '@ai-sdk/provider-utils': 4.0.57(zod@4.6.5) react-native: 0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6) - zod: 4.4.3 + zod: 4.6.5 '@react-native-async-storage/async-storage@3.1.1(react-native@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0)': dependencies: @@ -15755,7 +15718,7 @@ snapshots: '@react-navigation/core': 7.24.0(react@19.3.0) escape-string-regexp: 4.0.0 fast-deep-equal: 3.1.3 - nanoid: 3.3.19 + nanoid: 3.3.20 react: 19.3.0 react-native: 0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6) standard-navigation: 0.0.8(react@19.3.0) @@ -16165,14 +16128,14 @@ snapshots: '@babel/plugin-syntax-typescript': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1)) '@babel/template': 7.29.7 '@babel/traverse': 7.29.7(supports-color@8.1.1) - '@babel/types': 7.29.7 + '@babel/types': 7.29.8 '@tanstack/router-core': 1.171.8 '@tanstack/router-generator': 1.167.12(supports-color@8.1.1) '@tanstack/router-utils': 1.162.1(supports-color@8.1.1) '@tanstack/virtual-file-routes': 1.162.0 chokidar: 5.0.0 unplugin: 3.0.0 - zod: 4.4.3 + zod: 4.6.5 optionalDependencies: '@tanstack/react-router': 1.170.10(react-dom@19.3.0(react@19.3.0))(react@19.3.0) vite: '@voidzero-dev/vite-plus-core@1.4.0(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(unrun@0.2.39)(yaml@2.9.0)' @@ -16424,11 +16387,6 @@ snapshots: '@tweenjs/tween.js@23.1.3': {} - '@tybys/wasm-util@0.10.3': - dependencies: - tslib: 2.8.1 - optional: true - '@tybys/wasm-util@0.10.4': dependencies: tslib: 2.8.1 @@ -16581,6 +16539,11 @@ snapshots: '@types/http-errors': 2.0.5 '@types/node': 24.12.4 + '@types/sql.js@1.4.11': + dependencies: + '@types/emscripten': 1.41.5 + '@types/node': 24.12.4 + '@types/stats.js@0.17.4': {} '@types/three@0.180.0': @@ -17100,7 +17063,7 @@ snapshots: json-schema-traverse: 1.0.0 require-from-string: 2.0.2 - alchemy@2.0.0-beta.80(patch_hash=bea1b6c0c0b23157fd5f439a1d7feff430f28ac1621ba135d8a13eeec60ccacd)(93312816496722c8e540690c17876476): + alchemy@2.0.0-beta.80(patch_hash=bea1b6c0c0b23157fd5f439a1d7feff430f28ac1621ba135d8a13eeec60ccacd)(e50ec971fc482761b0a5b8ee3921094f): dependencies: '@alchemy.run/cloudflare-runtime': 2.0.0-beta.80(@distilled.cloud/cloudflare@1.0.0-rc.13(effect@4.0.5))(@effect/platform-node@4.0.5(bufferutil@4.1.0)(effect@4.0.5)(redis@6.2.1)(utf-8-validate@6.0.6))(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@voidzero-dev/vite-plus-core@1.4.0(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(unrun@0.2.39)(yaml@2.9.0))(effect@4.0.5)(rolldown@1.2.5) '@alchemy.run/floci': 2.0.0-beta.80(effect@4.0.5) @@ -17143,65 +17106,7 @@ snapshots: '@effect/sql-pg': 4.0.5(effect@4.0.5) '@effect/vitest': 4.0.5(patch_hash=2731c9c3761c595f25ddc625a7d61ca376b389d5b3049ecb837c8940f21cfa02)(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(effect@4.0.5)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) drizzle-kit: 1.0.0-rc.5-ab785fc - drizzle-orm: 1.0.0-rc.5-ab785fc(@cloudflare/workers-types@4.20260604.1)(@effect/sql-d1@4.0.0(effect@4.0.5))(@effect/sql-pg@4.0.5(effect@4.0.5))(@effect/sql-sqlite-bun@4.0.0-rc.112(effect@4.0.5))(@effect/sql-sqlite-do@4.0.5(effect@4.0.5))(@electric-sql/pglite@0.3.15)(@libsql/client@0.17.3(bufferutil@4.1.0)(utf-8-validate@6.0.6))(bun-types@1.3.14)(effect@4.0.5)(expo-sqlite@58.0.13(expo@58.0.14)(react-native@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0))(pg@8.23.0)(zod@4.6.5) - mongodb: 6.21.0(@aws-sdk/credential-providers@3.1062.0)(socks@2.8.9) - pg: 8.23.0 - vite: '@voidzero-dev/vite-plus-core@1.4.0(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(unrun@0.2.39)(yaml@2.9.0)' - ws: 8.22.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) - transitivePeerDependencies: - - '@types/node' - - '@types/react' - - bufferutil - - hono - - proxy-agent - - react-devtools-core - - typescript - - utf-8-validate - - alchemy@2.0.0-beta.80(patch_hash=bea1b6c0c0b23157fd5f439a1d7feff430f28ac1621ba135d8a13eeec60ccacd)(ed9f72c53f648b6275121476d8becac3): - dependencies: - '@alchemy.run/cloudflare-runtime': 2.0.0-beta.80(@distilled.cloud/cloudflare@1.0.0-rc.13(effect@4.0.5))(@effect/platform-node@4.0.5(bufferutil@4.1.0)(effect@4.0.5)(redis@6.2.1)(utf-8-validate@6.0.6))(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@voidzero-dev/vite-plus-core@1.4.0(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(unrun@0.2.39)(yaml@2.9.0))(effect@4.0.5)(rolldown@1.2.5) - '@alchemy.run/floci': 2.0.0-beta.80(effect@4.0.5) - '@alchemy.run/node-utils': 2.0.0-beta.80 - '@alchemy.run/sigil': 0.1.0-alpha.1(@types/react@19.3.0)(react-devtools-core@6.1.5(bufferutil@4.1.0)(utf-8-validate@6.0.6)) - '@distilled.cloud/acme': 1.0.0-rc.13(effect@4.0.5) - '@distilled.cloud/aws': 1.0.0-rc.13(effect@4.0.5) - '@distilled.cloud/axiom': 1.0.0-rc.13(effect@4.0.5) - '@distilled.cloud/cloudflare': 1.0.0-rc.13(effect@4.0.5) - '@distilled.cloud/core': 1.0.0-rc.13(effect@4.0.5) - '@distilled.cloud/doppler': 1.0.0-rc.13(effect@4.0.5) - '@distilled.cloud/fly-io': 1.0.0-rc.13(effect@4.0.5) - '@distilled.cloud/gcp': 1.0.0-rc.13(effect@4.0.5) - '@distilled.cloud/hetzner': 1.0.0-rc.13(effect@4.0.5) - '@distilled.cloud/infisical': 1.0.0-rc.13(effect@4.0.5) - '@distilled.cloud/neon': 1.0.0-rc.13(effect@4.0.5) - '@distilled.cloud/planetscale': 1.0.0-rc.13(effect@4.0.5) - '@distilled.cloud/prisma': 1.0.0-rc.13(effect@4.0.5) - '@distilled.cloud/railway': 1.0.0-rc.13(effect@4.0.5) - '@distilled.cloud/stripe': 1.0.0-rc.13(effect@4.0.5) - '@distilled.cloud/zerossl': 1.0.0-rc.13(effect@4.0.5) - '@effect/sql-d1': 4.0.0(effect@4.0.5) - '@effect/sql-sqlite-do': 4.0.5(effect@4.0.5) - '@libsql/client': 0.17.3(bufferutil@4.1.0)(utf-8-validate@6.0.6) - '@neon/functions': 0.11.0(hono@4.13.7) - '@octokit/rest': 22.0.1 - '@octokit/webhooks': 14.2.0 - '@types/aws-lambda': 8.10.161 - capnweb: 0.12.0 - effect: 4.0.5 - fflate: 0.8.3 - libsodium-wrappers: 0.8.4 - pathe: 2.0.3 - picomatch: 4.0.7 - rolldown: 1.2.5 - tinyglobby: 0.2.17 - yaml: 2.9.0 - optionalDependencies: - '@effect/platform-node': 4.0.5(bufferutil@4.1.0)(effect@4.0.5)(redis@6.2.1)(utf-8-validate@6.0.6) - '@effect/sql-pg': 4.0.5(effect@4.0.5) - '@effect/vitest': 4.0.5(patch_hash=2731c9c3761c595f25ddc625a7d61ca376b389d5b3049ecb837c8940f21cfa02)(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(effect@4.0.5)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) - drizzle-kit: 1.0.0-rc.5-ab785fc - drizzle-orm: 1.0.0-rc.5-ab785fc(@cloudflare/workers-types@4.20260604.1)(@effect/sql-d1@4.0.0(effect@4.0.5))(@effect/sql-pg@4.0.5(effect@4.0.5))(@effect/sql-sqlite-bun@4.0.0-rc.112(effect@4.0.5))(@effect/sql-sqlite-do@4.0.5(effect@4.0.5))(@electric-sql/pglite@0.3.15)(@libsql/client@0.17.3(bufferutil@4.1.0)(utf-8-validate@6.0.6))(bun-types@1.3.14)(effect@4.0.5)(expo-sqlite@58.0.13(expo@58.0.14)(react-native@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0))(pg@8.23.0)(zod@4.4.3) + drizzle-orm: 1.0.0-rc.5-ab785fc(@cloudflare/workers-types@4.20260604.1)(@effect/sql-d1@4.0.0(effect@4.0.5))(@effect/sql-pg@4.0.5(effect@4.0.5))(@effect/sql-sqlite-bun@4.0.0-rc.112(effect@4.0.5))(@effect/sql-sqlite-do@4.0.5(effect@4.0.5))(@electric-sql/pglite@0.3.15)(@libsql/client@0.17.3(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/sql.js@1.4.11)(bun-types@1.3.14)(effect@4.0.5)(expo-sqlite@58.0.13(expo@58.0.14)(react-native@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0))(pg@8.23.0)(sql.js@1.15.0)(zod@4.6.5) mongodb: 6.21.0(@aws-sdk/credential-providers@3.1062.0)(socks@2.8.9) pg: 8.23.0 vite: '@voidzero-dev/vite-plus-core@1.4.0(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(unrun@0.2.39)(yaml@2.9.0)' @@ -17253,8 +17158,7 @@ snapshots: normalize-path: 3.0.0 picomatch: 2.3.2 - anynum@1.0.1: - optional: true + anynum@1.0.1: {} app-builder-lib@26.16.1(patch_hash=a28c2dfcfc2b90d6c6247e199e6537391db9ecd941854c650910d607442512d3)(dmg-builder@26.16.1)(electron-builder-squirrel-windows@26.16.1)(supports-color@8.1.1): dependencies: @@ -17356,7 +17260,7 @@ snapshots: http-cache-semantics: 4.2.0 js-yaml: 4.3.2 jsonc-parser: 3.3.1 - magic-string: 1.4.1 + magic-string: 1.4.3 magicast: 0.5.5 mrmime: 2.0.1 neotraverse: 1.0.1 @@ -18167,22 +18071,7 @@ snapshots: get-tsconfig: 4.14.3 jiti: 2.7.0 - drizzle-orm@1.0.0-rc.5-ab785fc(@cloudflare/workers-types@4.20260604.1)(@effect/sql-d1@4.0.0(effect@4.0.5))(@effect/sql-pg@4.0.5(effect@4.0.5))(@effect/sql-sqlite-bun@4.0.0-rc.112(effect@4.0.5))(@effect/sql-sqlite-do@4.0.5(effect@4.0.5))(@electric-sql/pglite@0.3.15)(@libsql/client@0.17.3(bufferutil@4.1.0)(utf-8-validate@6.0.6))(bun-types@1.3.14)(effect@4.0.5)(expo-sqlite@58.0.13(expo@58.0.14)(react-native@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0))(pg@8.23.0)(zod@4.4.3): - optionalDependencies: - '@cloudflare/workers-types': 4.20260604.1 - '@effect/sql-d1': 4.0.0(effect@4.0.5) - '@effect/sql-pg': 4.0.5(effect@4.0.5) - '@effect/sql-sqlite-bun': 4.0.0-rc.112(effect@4.0.5) - '@effect/sql-sqlite-do': 4.0.5(effect@4.0.5) - '@electric-sql/pglite': 0.3.15 - '@libsql/client': 0.17.3(bufferutil@4.1.0)(utf-8-validate@6.0.6) - bun-types: 1.3.14 - effect: 4.0.5 - expo-sqlite: 58.0.13(expo@58.0.14)(react-native@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0) - pg: 8.23.0 - zod: 4.4.3 - - drizzle-orm@1.0.0-rc.5-ab785fc(@cloudflare/workers-types@4.20260604.1)(@effect/sql-d1@4.0.0(effect@4.0.5))(@effect/sql-pg@4.0.5(effect@4.0.5))(@effect/sql-sqlite-bun@4.0.0-rc.112(effect@4.0.5))(@effect/sql-sqlite-do@4.0.5(effect@4.0.5))(@electric-sql/pglite@0.3.15)(@libsql/client@0.17.3(bufferutil@4.1.0)(utf-8-validate@6.0.6))(bun-types@1.3.14)(effect@4.0.5)(expo-sqlite@58.0.13(expo@58.0.14)(react-native@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0))(pg@8.23.0)(zod@4.6.5): + drizzle-orm@1.0.0-rc.5-ab785fc(@cloudflare/workers-types@4.20260604.1)(@effect/sql-d1@4.0.0(effect@4.0.5))(@effect/sql-pg@4.0.5(effect@4.0.5))(@effect/sql-sqlite-bun@4.0.0-rc.112(effect@4.0.5))(@effect/sql-sqlite-do@4.0.5(effect@4.0.5))(@electric-sql/pglite@0.3.15)(@libsql/client@0.17.3(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/sql.js@1.4.11)(bun-types@1.3.14)(effect@4.0.5)(expo-sqlite@58.0.13(expo@58.0.14)(react-native@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0))(pg@8.23.0)(sql.js@1.15.0)(zod@4.6.5): optionalDependencies: '@cloudflare/workers-types': 4.20260604.1 '@effect/sql-d1': 4.0.0(effect@4.0.5) @@ -18191,12 +18080,13 @@ snapshots: '@effect/sql-sqlite-do': 4.0.5(effect@4.0.5) '@electric-sql/pglite': 0.3.15 '@libsql/client': 0.17.3(bufferutil@4.1.0)(utf-8-validate@6.0.6) + '@types/sql.js': 1.4.11 bun-types: 1.3.14 effect: 4.0.5 expo-sqlite: 58.0.13(expo@58.0.14)(react-native@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0) pg: 8.23.0 + sql.js: 1.15.0 zod: 4.6.5 - optional: true dset@3.1.4: {} @@ -18938,7 +18828,6 @@ snapshots: dependencies: path-expression-matcher: 1.6.2 xml-naming: 0.1.0 - optional: true fast-xml-parser@5.7.3: dependencies: @@ -18946,7 +18835,6 @@ snapshots: fast-xml-builder: 1.2.0 path-expression-matcher: 1.6.2 strnum: 2.4.2 - optional: true fastest-levenshtein@1.0.16: {} @@ -19176,7 +19064,7 @@ snapshots: es6-error: 4.1.1 matcher: 3.0.0 roarr: 2.15.4 - semver: 7.8.5 + semver: 7.7.4 serialize-error: 7.0.1 optional: true @@ -19430,6 +19318,8 @@ snapshots: dependencies: loose-envify: 1.4.0 + iobuffer@6.0.1: {} + ioredis@5.11.0(supports-color@8.1.1): dependencies: '@ioredis/commands': 1.10.0 @@ -19543,8 +19433,6 @@ snapshots: jose@6.2.2: {} - jose@6.2.3: {} - js-base64@3.7.8: {} js-cookie@3.0.8: {} @@ -19660,7 +19548,7 @@ snapshots: tinyglobby: 0.2.17 unbash: 4.0.11 yaml: 2.9.0 - zod: 4.4.3 + zod: 4.6.5 lan-network@0.2.1: {} @@ -19804,10 +19692,6 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.6.0 - magic-string@1.4.1: - dependencies: - '@jridgewell/sourcemap-codec': 1.6.0 - magic-string@1.4.3: dependencies: '@jridgewell/sourcemap-codec': 1.6.0 @@ -19816,7 +19700,7 @@ snapshots: dependencies: '@babel/parser': 7.29.8 '@babel/types': 7.29.8 - source-map-js: 1.2.1 + source-map-js: 1.2.2 makeerror@1.0.12: dependencies: @@ -20512,8 +20396,6 @@ snapshots: multitars@1.0.2: {} - nanoid@3.3.19: {} - nanoid@3.3.20: {} negotiator@0.6.3: {} @@ -20530,7 +20412,7 @@ snapshots: node-abi@4.33.0: dependencies: - semver: 7.8.5 + semver: 7.7.4 node-addon-api@7.1.1: {} @@ -20539,7 +20421,7 @@ snapshots: node-api-version@0.2.1: dependencies: - semver: 7.8.5 + semver: 7.7.4 node-fetch-h2@2.3.0: dependencies: @@ -20565,7 +20447,7 @@ snapshots: graceful-fs: 4.2.11 nopt: 9.0.0 proc-log: 6.1.0 - semver: 7.8.5 + semver: 7.7.4 tar: 7.5.22 tinyglobby: 0.2.17 undici: 6.29.0 @@ -20919,8 +20801,6 @@ snapshots: p-try@2.2.0: {} - package-manager-detector@1.6.0: {} - package-manager-detector@1.8.0: {} pako@1.0.11: {} @@ -20953,8 +20833,7 @@ snapshots: path-exists@3.0.0: {} - path-expression-matcher@1.6.2: - optional: true + path-expression-matcher@1.6.2: {} path-is-absolute@1.0.1: {} @@ -22019,8 +21898,6 @@ snapshots: smart-buffer: 4.2.0 optional: true - source-map-js@1.2.1: {} - source-map-js@1.2.2: {} source-map-support@0.5.21: @@ -22049,6 +21926,8 @@ snapshots: sprintf-js@1.1.3: optional: true + sql.js@1.15.0: {} + stackframe@1.3.4: {} stacktrace-parser@0.1.11: @@ -22132,7 +22011,6 @@ snapshots: strnum@2.4.2: dependencies: anynum: 1.0.1 - optional: true structured-headers@0.4.1: {} @@ -22239,6 +22117,11 @@ snapshots: through@2.3.8: {} + tiff@7.1.3: + dependencies: + fflate: 0.8.3 + iobuffer: 6.0.1 + timestring@6.0.0: {} tiny-async-pool@1.3.0: @@ -22253,8 +22136,6 @@ snapshots: tinyclip@1.0.3: {} - tinyexec@1.2.4: {} - tinyexec@1.3.1: {} tinyglobby@0.2.17: @@ -22892,8 +22773,7 @@ snapshots: xml-name-validator@5.0.0: {} - xml-naming@0.1.0: - optional: true + xml-naming@0.1.0: {} xml2js@0.4.23: dependencies: @@ -23001,8 +22881,6 @@ snapshots: zod@3.25.76: {} - zod@4.4.3: {} - zod@4.5.4: {} zod@4.6.5: {} diff --git a/third-party-licenses.config.json b/third-party-licenses.config.json index adddaaaf113eb..313d1502e3efc 100644 --- a/third-party-licenses.config.json +++ b/third-party-licenses.config.json @@ -841,6 +841,21 @@ "licenseId": "MIT", "copyrights": ["Copyright (c) 2019-present Fabio Spampinato, Andrew Maney"] } + }, + { + "name": "sql.js", + "license": "MIT", + "sourceUrl": "https://github.com/sql-js/sql.js" + }, + { + "name": "tiff", + "license": "MIT", + "sourceUrl": "https://github.com/image-js/tiff" + }, + { + "name": "iobuffer", + "license": "MIT", + "sourceUrl": "https://github.com/image-js/iobuffer" } ] }