From 30e5dfa450af75aa04cfdab735aa6ee452c9ba42 Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Mon, 25 May 2026 14:51:06 -0700 Subject: [PATCH 1/4] Centralize image extension and MIME utilities --- apps/code/src/main/trpc/routers/os.ts | 2 +- .../components/ui/SafeImagePreview.tsx | 4 +- .../components/CodeEditorPanel.tsx | 9 +- .../editor/utils/cloud-prompt.test.ts | 7 +- .../features/editor/utils/cloud-prompt.ts | 8 +- .../components/AttachmentMenu.tsx | 2 +- .../components/AttachmentsBar.tsx | 2 +- .../message-editor/utils/persistFile.test.ts | 7 +- .../message-editor/utils/persistFile.ts | 2 +- .../components/session-update/CodePreview.tsx | 2 +- apps/code/src/shared/constants/image.ts | 27 ---- apps/code/src/shared/utils/imageDataUrl.ts | 52 -------- apps/mobile/metro.config.js | 5 +- apps/mobile/package.json | 1 + .../tasks/composer/attachments/pickers.ts | 8 +- .../adapters/claude/conversion/acp-to-sdk.ts | 20 +-- .../shared/src/image.test.ts | 96 ++++++++++++++- packages/shared/src/image.ts | 116 ++++++++++++++++++ packages/shared/src/index.ts | 14 +++ pnpm-lock.yaml | 3 + 20 files changed, 264 insertions(+), 123 deletions(-) delete mode 100644 apps/code/src/shared/constants/image.ts delete mode 100644 apps/code/src/shared/utils/imageDataUrl.ts rename apps/code/src/shared/utils/imageDataUrl.test.ts => packages/shared/src/image.test.ts (63%) create mode 100644 packages/shared/src/image.ts diff --git a/apps/code/src/main/trpc/routers/os.ts b/apps/code/src/main/trpc/routers/os.ts index 046fadd003..a18287cadf 100644 --- a/apps/code/src/main/trpc/routers/os.ts +++ b/apps/code/src/main/trpc/routers/os.ts @@ -5,7 +5,7 @@ import type { IAppMeta } from "@posthog/platform/app-meta"; import type { DialogSeverity, IDialog } from "@posthog/platform/dialog"; import type { IImageProcessor } from "@posthog/platform/image-processor"; import type { IUrlLauncher } from "@posthog/platform/url-launcher"; -import { IMAGE_MIME_TYPES } from "@shared/constants/image"; +import { IMAGE_MIME_TYPES } from "@posthog/shared"; import { z } from "zod"; import { container } from "../../di/container"; import { MAIN_TOKENS } from "../../di/tokens"; diff --git a/apps/code/src/renderer/components/ui/SafeImagePreview.tsx b/apps/code/src/renderer/components/ui/SafeImagePreview.tsx index cbe6fbd637..a65c85c030 100644 --- a/apps/code/src/renderer/components/ui/SafeImagePreview.tsx +++ b/apps/code/src/renderer/components/ui/SafeImagePreview.tsx @@ -1,9 +1,9 @@ -import { Flex, Text } from "@radix-ui/themes"; import { buildImageDataUrl, isAllowedImageMimeType, MAX_IMAGE_BASE64_LENGTH, -} from "@shared/utils/imageDataUrl"; +} from "@posthog/shared"; +import { Flex, Text } from "@radix-ui/themes"; import { useState } from "react"; interface SafeImagePreviewProps { diff --git a/apps/code/src/renderer/features/code-editor/components/CodeEditorPanel.tsx b/apps/code/src/renderer/features/code-editor/components/CodeEditorPanel.tsx index 0cdb2e6df0..aa7b1f7bca 100644 --- a/apps/code/src/renderer/features/code-editor/components/CodeEditorPanel.tsx +++ b/apps/code/src/renderer/features/code-editor/components/CodeEditorPanel.tsx @@ -12,11 +12,14 @@ import { useFileTreeStore } from "@features/right-sidebar/stores/fileTreeStore"; import { useCwd } from "@features/sidebar/hooks/useCwd"; import { useIsWorkspaceCloudRun } from "@features/workspace/hooks/useWorkspace"; import { Check, Copy } from "@phosphor-icons/react"; +import { + getImageMimeType, + isRasterImageFile, + parseImageDataUrl, +} from "@posthog/shared"; import { Box, Flex, IconButton, Text } from "@radix-ui/themes"; import { trpcClient, useTRPC } from "@renderer/trpc/client"; -import { getImageMimeType, isImageFile } from "@shared/constants/image"; import type { Task } from "@shared/types"; -import { parseImageDataUrl } from "@shared/utils/imageDataUrl"; import { useQuery } from "@tanstack/react-query"; import { useCallback, useMemo, useState } from "react"; @@ -73,7 +76,7 @@ export function CodeEditorPanel({ const repoPath = useCwd(taskId); const isInsideRepo = !!repoPath && absolutePath.startsWith(repoPath); const filePath = getRelativePath(absolutePath, repoPath); - const isImage = isImageFile(absolutePath); + const isImage = isRasterImageFile(absolutePath); const isMarkdown = isMarkdownFile(absolutePath); const openFileInSplit = usePanelLayoutStore((s) => s.openFileInSplit); const expandToFile = useFileTreeStore((s) => s.expandToFile); diff --git a/apps/code/src/renderer/features/editor/utils/cloud-prompt.test.ts b/apps/code/src/renderer/features/editor/utils/cloud-prompt.test.ts index 7194a769a0..d2355673d1 100644 --- a/apps/code/src/renderer/features/editor/utils/cloud-prompt.test.ts +++ b/apps/code/src/renderer/features/editor/utils/cloud-prompt.test.ts @@ -7,10 +7,9 @@ const mockFs = vi.hoisted(() => ({ readFileAsBase64: { query: vi.fn() }, })); -vi.mock("@shared/constants/image", async () => { - const actual = await vi.importActual< - typeof import("@shared/constants/image") - >("@shared/constants/image"); +vi.mock("@posthog/shared", async () => { + const actual = + await vi.importActual("@posthog/shared"); return { ...actual, getImageMimeType: (name: string) => { diff --git a/apps/code/src/renderer/features/editor/utils/cloud-prompt.ts b/apps/code/src/renderer/features/editor/utils/cloud-prompt.ts index ccbf2d6395..95a84b7d6a 100644 --- a/apps/code/src/renderer/features/editor/utils/cloud-prompt.ts +++ b/apps/code/src/renderer/features/editor/utils/cloud-prompt.ts @@ -1,7 +1,11 @@ import type { ContentBlock } from "@agentclientprotocol/sdk"; -import { CLOUD_PROMPT_PREFIX, serializeCloudPrompt } from "@posthog/shared"; +import { + CLOUD_PROMPT_PREFIX, + getImageMimeType, + isImageFile, + serializeCloudPrompt, +} from "@posthog/shared"; import { trpcClient } from "@renderer/trpc/client"; -import { getImageMimeType, isImageFile } from "@shared/constants/image"; import { getFileExtension, getFileName, diff --git a/apps/code/src/renderer/features/message-editor/components/AttachmentMenu.tsx b/apps/code/src/renderer/features/message-editor/components/AttachmentMenu.tsx index 5cb3c971eb..d01ce57dbd 100644 --- a/apps/code/src/renderer/features/message-editor/components/AttachmentMenu.tsx +++ b/apps/code/src/renderer/features/message-editor/components/AttachmentMenu.tsx @@ -12,9 +12,9 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from "@posthog/quill"; +import { isImageFile } from "@posthog/shared"; import { trpcClient, useTRPC } from "@renderer/trpc/client"; import { toast } from "@renderer/utils/toast"; -import { isImageFile } from "@shared/constants/image"; import { useQuery } from "@tanstack/react-query"; import { useRef, useState } from "react"; import { diff --git a/apps/code/src/renderer/features/message-editor/components/AttachmentsBar.tsx b/apps/code/src/renderer/features/message-editor/components/AttachmentsBar.tsx index 5c4408100b..a117cca57a 100644 --- a/apps/code/src/renderer/features/message-editor/components/AttachmentsBar.tsx +++ b/apps/code/src/renderer/features/message-editor/components/AttachmentsBar.tsx @@ -1,7 +1,7 @@ import { File, X } from "@phosphor-icons/react"; +import { isGifFile, isImageFile } from "@posthog/shared"; import { Dialog, Flex, IconButton, Text } from "@radix-ui/themes"; import { useTRPC } from "@renderer/trpc/client"; -import { isGifFile, isImageFile } from "@shared/constants/image"; import { useQuery } from "@tanstack/react-query"; import { useEffect, useRef } from "react"; import type { FileAttachment } from "../utils/content"; diff --git a/apps/code/src/renderer/features/message-editor/utils/persistFile.test.ts b/apps/code/src/renderer/features/message-editor/utils/persistFile.test.ts index 79fa90d0ce..7a7e73fd56 100644 --- a/apps/code/src/renderer/features/message-editor/utils/persistFile.test.ts +++ b/apps/code/src/renderer/features/message-editor/utils/persistFile.test.ts @@ -25,10 +25,9 @@ vi.mock("@renderer/trpc/client", () => ({ }, })); -vi.mock("@shared/constants/image", async () => { - const actual = await vi.importActual< - typeof import("@shared/constants/image") - >("@shared/constants/image"); +vi.mock("@posthog/shared", async () => { + const actual = + await vi.importActual("@posthog/shared"); return { ...actual, getImageMimeType: () => "image/png" }; }); diff --git a/apps/code/src/renderer/features/message-editor/utils/persistFile.ts b/apps/code/src/renderer/features/message-editor/utils/persistFile.ts index 8ae3c42404..c15b5d9aef 100644 --- a/apps/code/src/renderer/features/message-editor/utils/persistFile.ts +++ b/apps/code/src/renderer/features/message-editor/utils/persistFile.ts @@ -1,6 +1,6 @@ +import { getImageMimeType, isImageFile } from "@posthog/shared"; import { trpcClient } from "@renderer/trpc/client"; import { toast } from "@renderer/utils/toast"; -import { getImageMimeType, isImageFile } from "@shared/constants/image"; import { getFilePath } from "@utils/getFilePath"; import type { FileAttachment } from "./content"; diff --git a/apps/code/src/renderer/features/sessions/components/session-update/CodePreview.tsx b/apps/code/src/renderer/features/sessions/components/session-update/CodePreview.tsx index 7f7841d282..eebf2b948c 100644 --- a/apps/code/src/renderer/features/sessions/components/session-update/CodePreview.tsx +++ b/apps/code/src/renderer/features/sessions/components/session-update/CodePreview.tsx @@ -1,8 +1,8 @@ import { EditorView } from "@codemirror/view"; import { SafeImagePreview } from "@components/ui/SafeImagePreview"; import { MultiFileDiff } from "@pierre/diffs/react"; +import { parseImageDataUrl } from "@posthog/shared"; import { Code } from "@radix-ui/themes"; -import { parseImageDataUrl } from "@shared/utils/imageDataUrl"; import { useThemeStore } from "@stores/themeStore"; import { compactHomePath } from "@utils/path"; import { useEffect, useMemo, useRef } from "react"; diff --git a/apps/code/src/shared/constants/image.ts b/apps/code/src/shared/constants/image.ts deleted file mode 100644 index b7b2e11fc3..0000000000 --- a/apps/code/src/shared/constants/image.ts +++ /dev/null @@ -1,27 +0,0 @@ -export const IMAGE_MIME_TYPES: Record = { - png: "image/png", - jpg: "image/jpeg", - jpeg: "image/jpeg", - gif: "image/gif", - webp: "image/webp", - bmp: "image/bmp", - ico: "image/x-icon", - tiff: "image/tiff", - tif: "image/tiff", -}; - -const IMAGE_EXTENSIONS = new Set(Object.keys(IMAGE_MIME_TYPES)); - -export function isImageFile(filename: string): boolean { - const ext = filename.split(".").pop()?.toLowerCase(); - return !!ext && IMAGE_EXTENSIONS.has(ext); -} - -export function isGifFile(filename: string): boolean { - return filename.split(".").pop()?.toLowerCase() === "gif"; -} - -export function getImageMimeType(filePath: string): string { - const ext = filePath.split(".").pop()?.toLowerCase() ?? ""; - return IMAGE_MIME_TYPES[ext] ?? "application/octet-stream"; -} diff --git a/apps/code/src/shared/utils/imageDataUrl.ts b/apps/code/src/shared/utils/imageDataUrl.ts deleted file mode 100644 index 7ce8522656..0000000000 --- a/apps/code/src/shared/utils/imageDataUrl.ts +++ /dev/null @@ -1,52 +0,0 @@ -const ALLOWED_IMAGE_MIME_TYPES = new Set([ - "image/png", - "image/jpeg", - "image/gif", - "image/webp", - "image/bmp", - "image/x-icon", - "image/vnd.microsoft.icon", - "image/tiff", - "image/avif", -]); - -const DATA_URL_PATTERN = - /^data:([a-zA-Z]+\/[a-zA-Z0-9.+-]+)(?:;[a-zA-Z0-9-]+=[^;,]+)*;base64,([A-Za-z0-9+/=\s]+)$/; - -const MAX_DATA_URL_LENGTH = 20 * 1024 * 1024; -export const MAX_IMAGE_BASE64_LENGTH = 15 * 1024 * 1024; - -export interface ParsedImageDataUrl { - mimeType: string; - base64: string; -} - -export function parseImageDataUrl(value: string): ParsedImageDataUrl | null { - if (typeof value !== "string" || value.length === 0) return null; - if (value.length > MAX_DATA_URL_LENGTH) return null; - if (!/^\s{0,1024}data:/.test(value)) return null; - - const trimmed = value.trim(); - if (trimmed.length === 0) return null; - - const match = DATA_URL_PATTERN.exec(trimmed); - if (!match) return null; - - const mimeType = match[1].toLowerCase(); - if (!ALLOWED_IMAGE_MIME_TYPES.has(mimeType)) return null; - - const base64 = match[2].replace(/\s+/g, ""); - if (base64.length === 0 || base64.length > MAX_IMAGE_BASE64_LENGTH) { - return null; - } - - return { mimeType, base64 }; -} - -export function isAllowedImageMimeType(mimeType: string): boolean { - return ALLOWED_IMAGE_MIME_TYPES.has(mimeType.toLowerCase()); -} - -export function buildImageDataUrl(mimeType: string, base64: string): string { - return `data:${mimeType};base64,${base64}`; -} diff --git a/apps/mobile/metro.config.js b/apps/mobile/metro.config.js index 8afd10d43f..384c7b4449 100644 --- a/apps/mobile/metro.config.js +++ b/apps/mobile/metro.config.js @@ -16,9 +16,12 @@ config.resolver.nodeModulesPaths = [ path.resolve(monorepoRoot, "node_modules"), ]; -// Force React to resolve from monorepo root +// Force React to resolve from monorepo root, and alias workspace packages +// directly to their TypeScript source so Babel can transpile them (Metro +// does not consume the ESM `dist/` build cleanly). config.resolver.extraNodeModules = { react: path.resolve(monorepoRoot, "node_modules/react"), + "@posthog/shared": path.resolve(monorepoRoot, "packages/shared/src/index.ts"), }; // Apply NativeWind first so its resolver/transformer changes are in place diff --git a/apps/mobile/package.json b/apps/mobile/package.json index b5de3bef6e..daa6872662 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -27,6 +27,7 @@ "@expo/ui": "0.2.0-beta.9", "@modelcontextprotocol/ext-apps": "^1.2.2", "@modelcontextprotocol/sdk": "^1.29.0", + "@posthog/shared": "workspace:*", "@react-native-async-storage/async-storage": "^2.2.0", "@react-native-community/netinfo": "^12.0.1", "@tanstack/react-query": "^5.90.12", diff --git a/apps/mobile/src/features/tasks/composer/attachments/pickers.ts b/apps/mobile/src/features/tasks/composer/attachments/pickers.ts index b3e220f165..375948798d 100644 --- a/apps/mobile/src/features/tasks/composer/attachments/pickers.ts +++ b/apps/mobile/src/features/tasks/composer/attachments/pickers.ts @@ -1,3 +1,4 @@ +import { getImageMimeType } from "@posthog/shared"; import { Alert } from "react-native"; import { logger } from "@/lib/logger"; import type { PendingAttachment } from "./types"; @@ -10,11 +11,8 @@ function makeId(): string { function inferImageMime(uri: string, mime?: string | null): string { if (mime) return mime; - const lower = uri.toLowerCase(); - if (lower.endsWith(".png")) return "image/png"; - if (lower.endsWith(".gif")) return "image/gif"; - if (lower.endsWith(".webp")) return "image/webp"; - return "image/jpeg"; + const detected = getImageMimeType(uri); + return detected === "application/octet-stream" ? "image/jpeg" : detected; } function deriveFileName(uri: string, fallback: string): string { diff --git a/packages/agent/src/adapters/claude/conversion/acp-to-sdk.ts b/packages/agent/src/adapters/claude/conversion/acp-to-sdk.ts index 323e4bc43b..fd0dfb0e7f 100644 --- a/packages/agent/src/adapters/claude/conversion/acp-to-sdk.ts +++ b/packages/agent/src/adapters/claude/conversion/acp-to-sdk.ts @@ -3,24 +3,10 @@ import { fileURLToPath } from "node:url"; import type { PromptRequest } from "@agentclientprotocol/sdk"; import type { SDKUserMessage } from "@anthropic-ai/claude-agent-sdk"; import type { ContentBlockParam } from "@anthropic-ai/sdk/resources"; - -type ImageMimeType = "image/jpeg" | "image/png" | "image/gif" | "image/webp"; +import { type ClaudeImageMimeType, isImageFile } from "@posthog/shared"; const PDF_EXTENSIONS = new Set(["pdf"]); -const COMMON_IMAGE_EXTENSIONS = new Set([ - "png", - "jpg", - "jpeg", - "gif", - "webp", - "bmp", - "svg", - "heic", - "tif", - "tiff", -]); - const VIDEO_EXTENSIONS = new Set([ "mp4", "mov", @@ -53,7 +39,7 @@ export function readToolGuidanceForPath(filePath: string): string { if (PDF_EXTENSIONS.has(ext)) { return 'Optional `pages` string (e.g. "1-5") per Read call instead of loading the entire PDF.'; } - if (COMMON_IMAGE_EXTENSIONS.has(ext) || VIDEO_EXTENSIONS.has(ext)) { + if (isImageFile(filePath) || VIDEO_EXTENSIONS.has(ext)) { return "Binary file — use Read with `file_path`; prefer bounded reads where supported."; } return "Large text — use multiple Read calls with optional `offset` and `limit`."; @@ -136,7 +122,7 @@ function processPromptChunk( source: { type: "base64", data: chunk.data, - media_type: chunk.mimeType as ImageMimeType, + media_type: chunk.mimeType as ClaudeImageMimeType, }, }); } else if (chunk.uri?.startsWith("http")) { diff --git a/apps/code/src/shared/utils/imageDataUrl.test.ts b/packages/shared/src/image.test.ts similarity index 63% rename from apps/code/src/shared/utils/imageDataUrl.test.ts rename to packages/shared/src/image.test.ts index f197142da6..a961d6ebae 100644 --- a/apps/code/src/shared/utils/imageDataUrl.test.ts +++ b/packages/shared/src/image.test.ts @@ -1,9 +1,13 @@ import { describe, expect, it } from "vitest"; import { buildImageDataUrl, + getImageMimeType, isAllowedImageMimeType, + isGifFile, + isImageFile, + isRasterImageFile, parseImageDataUrl, -} from "./imageDataUrl"; +} from "./image"; const TINY_PNG_BASE64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII="; @@ -126,6 +130,8 @@ describe("isAllowedImageMimeType", () => { it.each([ ["image/svg+xml"], + ["image/heic"], + ["image/heif"], ["text/html"], ["application/javascript"], ["text/plain"], @@ -141,3 +147,91 @@ describe("buildImageDataUrl", () => { ); }); }); + +describe("isImageFile", () => { + it.each([ + ["foo.png"], + ["foo.PNG"], + ["path/to/foo.jpg"], + ["foo.jpeg"], + ["foo.gif"], + ["foo.webp"], + ["foo.bmp"], + ["foo.ico"], + ["foo.tiff"], + ["foo.tif"], + ["foo.svg"], + ["foo.heic"], + ["foo.heif"], + ["foo.avif"], + ])("returns true for %s", (filename) => { + expect(isImageFile(filename)).toBe(true); + }); + + it.each([["foo.txt"], ["foo.md"], ["foo"], ["foo.ts"], ["foo.pdf"], [""]])( + "returns false for %s", + (filename) => { + expect(isImageFile(filename)).toBe(false); + }, + ); +}); + +describe("isRasterImageFile", () => { + it.each([ + ["foo.png"], + ["foo.jpg"], + ["foo.gif"], + ["foo.webp"], + ["foo.bmp"], + ["foo.avif"], + ])("returns true for raster %s", (filename) => { + expect(isRasterImageFile(filename)).toBe(true); + }); + + it.each([["foo.svg"], ["foo.heic"], ["foo.heif"]])( + "returns false for non-raster %s", + (filename) => { + expect(isRasterImageFile(filename)).toBe(false); + }, + ); + + it("returns false for non-images", () => { + expect(isRasterImageFile("foo.txt")).toBe(false); + expect(isRasterImageFile("foo")).toBe(false); + }); +}); + +describe("isGifFile", () => { + it("returns true for .gif", () => { + expect(isGifFile("foo.gif")).toBe(true); + expect(isGifFile("foo.GIF")).toBe(true); + }); + + it("returns false for non-gif images", () => { + expect(isGifFile("foo.png")).toBe(false); + }); +}); + +describe("getImageMimeType", () => { + it.each([ + ["foo.png", "image/png"], + ["foo.jpg", "image/jpeg"], + ["foo.JPEG", "image/jpeg"], + ["foo.gif", "image/gif"], + ["foo.webp", "image/webp"], + ["foo.svg", "image/svg+xml"], + ["foo.heic", "image/heic"], + ["foo.heif", "image/heif"], + ["foo.avif", "image/avif"], + ["foo.ico", "image/x-icon"], + ["foo.tiff", "image/tiff"], + ["foo.tif", "image/tiff"], + ])("maps %s to %s", (filename, expected) => { + expect(getImageMimeType(filename)).toBe(expected); + }); + + it("falls back to application/octet-stream for unknown extensions", () => { + expect(getImageMimeType("foo.unknown")).toBe("application/octet-stream"); + expect(getImageMimeType("foo")).toBe("application/octet-stream"); + }); +}); diff --git a/packages/shared/src/image.ts b/packages/shared/src/image.ts new file mode 100644 index 0000000000..b82c0b49ab --- /dev/null +++ b/packages/shared/src/image.ts @@ -0,0 +1,116 @@ +export const IMAGE_MIME_TYPES: Record = { + png: "image/png", + jpg: "image/jpeg", + jpeg: "image/jpeg", + gif: "image/gif", + webp: "image/webp", + bmp: "image/bmp", + ico: "image/x-icon", + tiff: "image/tiff", + tif: "image/tiff", + svg: "image/svg+xml", + heic: "image/heic", + heif: "image/heif", + avif: "image/avif", +}; + +const IMAGE_EXTENSIONS: ReadonlySet = new Set( + Object.keys(IMAGE_MIME_TYPES), +); + +const RASTER_IMAGE_EXTENSIONS: ReadonlySet = new Set([ + "png", + "jpg", + "jpeg", + "gif", + "webp", + "bmp", + "ico", + "tiff", + "tif", + "avif", +]); + +// SVG is intentionally excluded — SVG can contain