diff --git a/packages/app/src/components/project-icon-image.tsx b/packages/app/src/components/project-icon-image.tsx new file mode 100644 index 000000000..6e9345939 --- /dev/null +++ b/packages/app/src/components/project-icon-image.tsx @@ -0,0 +1,55 @@ +import { Buffer } from "buffer"; +import { type ReactElement, useMemo } from "react"; +import { Image, type ImageStyle, type StyleProp, View } from "react-native"; +import { SvgCss } from "react-native-svg/css"; + +const SVG_MIME_TYPE = "image/svg+xml"; +const ICO_MIME_TYPES = new Set(["image/x-icon", "image/vnd.microsoft.icon"]); +const SVG_CONTAINER = { overflow: "hidden" } as const; + +export interface ProjectIconImageProps { + dataUri: string; + fallback: ReactElement; + style: StyleProp; +} + +function parseDataUri(dataUri: string): { mimeType: string; payload: string } | null { + const match = /^data:([^;,]+);base64,(.*)$/s.exec(dataUri); + if (!match) return null; + + const [, mimeType, payload] = match; + return mimeType && payload ? { mimeType: mimeType.toLowerCase(), payload } : null; +} + +function ignoreSvgParseError() { + // Invalid repository SVGs use the same fallback as unsupported icon formats. +} + +export function ProjectIconImage({ dataUri, fallback, style }: ProjectIconImageProps) { + const source = useMemo(() => ({ uri: dataUri }), [dataUri]); + const parts = useMemo(() => parseDataUri(dataUri), [dataUri]); + const svgXml = useMemo( + () => + parts?.mimeType === SVG_MIME_TYPE + ? Buffer.from(parts.payload, "base64").toString("utf8") + : null, + [parts], + ); + const svgContainerStyle = useMemo(() => [style, SVG_CONTAINER], [style]); + + if (svgXml) { + return ( + + + + ); + } + if (parts && ICO_MIME_TYPES.has(parts.mimeType)) return fallback; + return ; +} diff --git a/packages/app/src/components/project-icon-image.web.tsx b/packages/app/src/components/project-icon-image.web.tsx new file mode 100644 index 000000000..8e5cad919 --- /dev/null +++ b/packages/app/src/components/project-icon-image.web.tsx @@ -0,0 +1,8 @@ +import { useMemo } from "react"; +import { Image } from "react-native"; +import type { ProjectIconImageProps } from "@/components/project-icon-image"; + +export function ProjectIconImage({ dataUri, style }: ProjectIconImageProps) { + const source = useMemo(() => ({ uri: dataUri }), [dataUri]); + return ; +} diff --git a/packages/app/src/components/project-icon-view.tsx b/packages/app/src/components/project-icon-view.tsx index 79668617b..3a31da5ff 100644 --- a/packages/app/src/components/project-icon-view.tsx +++ b/packages/app/src/components/project-icon-view.tsx @@ -1,5 +1,6 @@ import { useMemo } from "react"; -import { Image, type StyleProp, Text, type TextStyle, View } from "react-native"; +import { type StyleProp, Text, type TextStyle, View } from "react-native"; +import { ProjectIconImage } from "@/components/project-icon-image"; import { deriveIdentityColorName, identityColor } from "@/styles/identity-colors"; const WHITE_TEXT = { color: "#ffffff" } as const; @@ -28,6 +29,7 @@ export function projectIconRadius(size: number): number { * width/height/radius/centering block, which is how the radius drifted apart in the first * place — pass a `size` and the shape follows. */ + export function ProjectIconView({ iconDataUri, initial, @@ -41,7 +43,6 @@ export function ProjectIconView({ size: number; textStyle: StyleProp; }) { - const imageSource = useMemo(() => ({ uri: iconDataUri ?? "" }), [iconDataUri]); // The uploaded image is sized but never clipped — see projectIconRadius. const box = useMemo(() => ({ width: size, height: size }), [size]); const fallbackStyles = useMemo( @@ -55,12 +56,18 @@ export function ProjectIconView({ ); const textStyles = useMemo(() => [textStyle, WHITE_TEXT], [textStyle]); - if (iconDataUri) { - return ; - } - return ( - - {initial} - + const fallback = useMemo( + () => ( + + {initial} + + ), + [fallbackStyles, initial, textStyles], + ); + + return iconDataUri ? ( + + ) : ( + fallback ); } diff --git a/packages/app/src/components/workspace-setup-dialog.tsx b/packages/app/src/components/workspace-setup-dialog.tsx index 6a433efc0..1985a8361 100644 --- a/packages/app/src/components/workspace-setup-dialog.tsx +++ b/packages/app/src/components/workspace-setup-dialog.tsx @@ -1,11 +1,13 @@ import { useCallback, useEffect, useMemo, useState } from "react"; -import { Image, Text, View } from "react-native"; +import { Text, View } from "react-native"; import { StyleSheet } from "react-native-unistyles"; import { useTranslation } from "react-i18next"; import { createNameId } from "mnemonic-id"; import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet"; import { FileDropZone } from "@/components/file-drop/file-drop-zone"; import { Composer } from "@/composer"; +import { ProjectIconView } from "@/components/project-icon-view"; +import { ICON_SIZE } from "@/styles/theme"; import { useToast } from "@/contexts/toast-context"; import { useAgentInputDraft } from "@/composer/draft/input-draft"; import { useProjectIcon } from "@/projects/icons"; @@ -29,7 +31,6 @@ import { requireWorkspaceDirectory } from "@/utils/workspace-directory"; import { navigateToAgent } from "@/utils/navigate-to-agent"; import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store"; import type { MessagePayload } from "@/composer/types"; -import { projectIconRadius } from "@/components/project-icon-view"; function toProjectIconDataUri(icon: { mimeType: string; data: string } | null): string | null { if (!icon) { @@ -381,7 +382,6 @@ export function WorkspaceSetupDialog() { const placeholderLabel = projectIconPlaceholderLabelFromDisplayName(workspaceTitle); const placeholderInitial = placeholderLabel.charAt(0).toUpperCase(); - const iconSource = useMemo(() => (iconDataUri ? { uri: iconDataUri } : null), [iconDataUri]); const agentControlsWithDisabled = useMemo( () => composerState @@ -396,19 +396,19 @@ export function WorkspaceSetupDialog() { const subtitleContent = useMemo( () => ( - {iconSource ? ( - - ) : ( - - {placeholderInitial} - - )} + {workspaceTitle} ), - [iconSource, placeholderInitial, workspaceTitle], + [iconDataUri, placeholderInitial, sourceDirectory, workspaceTitle], ); const sheetHeader = useMemo( @@ -461,22 +461,7 @@ const styles = StyleSheet.create((theme) => ({ alignItems: "center", gap: theme.spacing[2], }, - // A user's uploaded mark is sized, never clipped — see projectIconRadius. - projectIcon: { - width: theme.iconSize.md, - height: theme.iconSize.md, - }, - projectIconFallback: { - width: theme.iconSize.md, - height: theme.iconSize.md, - borderRadius: projectIconRadius(theme.iconSize.md), - borderWidth: 1, - borderColor: theme.colors.border, - alignItems: "center", - justifyContent: "center", - }, projectIconFallbackText: { - color: theme.colors.foregroundMuted, fontSize: 9, }, projectTitle: { diff --git a/packages/server/src/utils/project-icon.test.ts b/packages/server/src/utils/project-icon.test.ts index 5d50c13f0..18289d848 100644 --- a/packages/server/src/utils/project-icon.test.ts +++ b/packages/server/src/utils/project-icon.test.ts @@ -192,13 +192,20 @@ describe("findProjectIcon", () => { expect(result).toBeNull(); }); - it("returns the first match when multiple icons exist in same location", async () => { + it("prefers SVG over PNG over ICO for the same name", async () => { writeFileSync(join(tempDir, "favicon.ico"), "ico"); writeFileSync(join(tempDir, "favicon.png"), "png"); writeFileSync(join(tempDir, "favicon.svg"), "svg"); + // SVG and PNG render on every client; native cannot decode ICO. + const result = await findProjectIcon(tempDir); + expect(result).toBe(join(tempDir, "favicon.svg")); + }); + + it("falls back to ICO when no SVG or PNG exists", async () => { + writeFileSync(join(tempDir, "favicon.ico"), "ico"); + const result = await findProjectIcon(tempDir); - // Should return the first one based on pattern order (favicon.ico comes first) expect(result).toBe(join(tempDir, "favicon.ico")); }); @@ -401,6 +408,51 @@ describe("getProjectIcon", () => { expect(result?.mimeType).toBe("image/x-icon"); }); + it("reports PNG for a .ico file that actually contains PNG data", async () => { + // Renamed PNGs are a common real-world favicon.ico; the extension lies. + writeFileSync(join(tempDir, "favicon.ico"), squarePng); + + const result = await getProjectIcon(tempDir); + expect(result?.mimeType).toBe("image/png"); + expect(result?.data).toBe(squarePng.toString("base64")); + }); + + it("extracts the PNG frame from an ICO container", async () => { + const header = Buffer.alloc(6); + header.writeUInt16LE(0, 0); // reserved + header.writeUInt16LE(1, 2); // type: icon + header.writeUInt16LE(1, 4); // one frame + const entry = Buffer.alloc(16); + entry[0] = 1; // width + entry[1] = 1; // height + entry.writeUInt32LE(squarePng.length, 8); // data size + entry.writeUInt32LE(22, 12); // data offset (6 header + 16 entry) + writeFileSync(join(tempDir, "favicon.ico"), Buffer.concat([header, entry, squarePng])); + + const result = await getProjectIcon(tempDir); + expect(result?.mimeType).toBe("image/png"); + expect(result?.data).toBe(squarePng.toString("base64")); + }); + + it("keeps ICO containers without a PNG frame as x-icon", async () => { + const header = Buffer.alloc(6); + header.writeUInt16LE(0, 0); + header.writeUInt16LE(1, 2); + header.writeUInt16LE(1, 4); + const bmpFrame = Buffer.alloc(32, 0x42); + const entry = Buffer.alloc(16); + entry[0] = 1; + entry[1] = 1; + entry.writeUInt32LE(bmpFrame.length, 8); + entry.writeUInt32LE(22, 12); + const ico = Buffer.concat([header, entry, bmpFrame]); + writeFileSync(join(tempDir, "favicon.ico"), ico); + + const result = await getProjectIcon(tempDir); + expect(result?.mimeType).toBe("image/x-icon"); + expect(result?.data).toBe(ico.toString("base64")); + }); + it("returns icon data for SVG files (assumed square)", async () => { writeFileSync(join(tempDir, "favicon.svg"), ""); diff --git a/packages/server/src/utils/project-icon.ts b/packages/server/src/utils/project-icon.ts index 3741e0ab3..259e29377 100644 --- a/packages/server/src/utils/project-icon.ts +++ b/packages/server/src/utils/project-icon.ts @@ -4,22 +4,26 @@ import { extname, join } from "path"; /** * Icon file patterns to search for, in priority order. * Patterns starting with '*' are glob patterns (e.g., icon-*.png). + * + * Within each name, SVG and PNG outrank ICO: every client renders them (React + * Native's cannot decode ICO at all) and they scale better. ICO is the + * last resort for each name, not the first. */ export const ICON_PATTERNS = [ - "favicon.ico", - "favicon.png", "favicon.svg", - "favico.ico", - "favico.png", + "favicon.png", + "favicon.ico", "favico.svg", - "icon.png", + "favico.png", + "favico.ico", "icon.svg", - "app-icon.png", + "icon.png", "app-icon.svg", + "app-icon.png", "apple-touch-icon.png", "icon-*.png", - "logo.png", "logo.svg", + "logo.png", ]; /** @@ -163,6 +167,76 @@ function isSquareImage(buffer: Buffer, mimeType: string): boolean { return dimensions.width === dimensions.height; } +const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + +/** + * Detect the mime type from the file's magic bytes. Extensions lie: a large + * share of real-world favicon.ico files are PNG data renamed, which browsers + * render regardless — but native clients key off the reported mime type, so + * mislabelling them image/x-icon needlessly drops the icon there. + */ +function sniffMimeType(buffer: Buffer): string | null { + if (buffer.length >= 8 && buffer.subarray(0, 8).equals(PNG_SIGNATURE)) { + return "image/png"; + } + if (buffer.length >= 3 && buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) { + return "image/jpeg"; + } + if (buffer.length >= 6 && buffer.toString("ascii", 0, 3) === "GIF") { + return "image/gif"; + } + if ( + buffer.length >= 12 && + buffer.toString("ascii", 0, 4) === "RIFF" && + buffer.toString("ascii", 8, 12) === "WEBP" + ) { + return "image/webp"; + } + if ( + buffer.length >= 6 && + buffer.readUInt16LE(0) === 0 && + buffer.readUInt16LE(2) === 1 && + buffer.readUInt16LE(4) > 0 + ) { + return "image/x-icon"; + } + return null; +} + +/** + * ICO is a container; modern favicons usually carry a PNG-encoded frame for the + * larger sizes. Native can't decode ICO but decodes PNG fine, so when a + * PNG frame exists, serve the largest one as image/png instead of the container. + */ +export function extractIcoPngFrame(buffer: Buffer): Buffer | null { + if (buffer.length < 6 || buffer.readUInt16LE(0) !== 0 || buffer.readUInt16LE(2) !== 1) { + return null; + } + const frameCount = buffer.readUInt16LE(4); + let best: Buffer | null = null; + let bestWidth = -1; + for (let index = 0; index < frameCount; index += 1) { + const entryOffset = 6 + index * 16; + if (entryOffset + 16 > buffer.length) { + break; + } + // Directory entry: width(1) height(1) colors(1) reserved(1) planes(2) + // bpp(2) dataSize(4 LE) dataOffset(4 LE). Width byte 0 means 256. + const width = buffer[entryOffset] === 0 ? 256 : (buffer[entryOffset] ?? 0); + const dataSize = buffer.readUInt32LE(entryOffset + 8); + const dataOffset = buffer.readUInt32LE(entryOffset + 12); + if (dataOffset + dataSize > buffer.length) { + continue; + } + const frame = buffer.subarray(dataOffset, dataOffset + dataSize); + if (frame.length >= 8 && frame.subarray(0, 8).equals(PNG_SIGNATURE) && width > bestWidth) { + best = frame; + bestWidth = width; + } + } + return best; +} + function getMimeType(filename: string): string { const ext = extname(filename).toLowerCase(); switch (ext) { @@ -427,8 +501,16 @@ export async function getProjectIcon(projectDir: string): Promise