* Render SVG project icons on native and fall back for ICO React Native's <Image> silently decodes neither SVG nor ICO, so any project whose discovered icon was one of those formats showed a blank slot on iOS/Android while web/Electron rendered it fine. Route SVG data URIs through react-native-svg on native and let ICO fall through to the initial-letter avatar instead of rendering nothing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Import Buffer explicitly so SVG icons actually decode on device React Native has no global Buffer and the app installs no global polyfill, so projectIconSvgXml threw ReferenceError inside its try/catch and returned null on every SVG — the exact blank-slot bug the previous commit meant to fix. Vitest runs in Node where Buffer is global, which is why the tests passed anyway; the new regression test deletes globalThis.Buffer to keep the import honest. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Prefer renderable icon formats and unwrap PNG-bearing ICOs favicon.ico ranked first in ICON_PATTERNS, so any repo with a standard public/ layout served ICO — which native <Image> cannot decode — even when an SVG or PNG sat in the same directory. Rank SVG and PNG above ICO for each name, sniff magic bytes instead of trusting the extension (renamed PNGs shipped as image/x-icon are common), and extract the largest PNG-encoded frame from real ICO containers so native clients get a decodable image instead of falling back to the letter avatar. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Render SVG icons with CSS support and fall back on parse failure SvgXml ignores <style> blocks, which favicon SVGs commonly use, leaving paths with a default black fill — invisible on dark themes. SvgCss inlines the styles before rendering. Pass the letter avatar as the fallback so a malformed SVG degrades to it instead of an empty slot, and fold the workspace setup dialog's hand-rolled icon cascade into ProjectIconView so the format handling lives in one place. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Take the platform as an explicit parameter in icon source helpers The tests previously pinned isNative to true with a module-level vi.mock, which coupled them to import initialization and left the web pass-through paths uncovered. Defaulting an explicit `native` parameter to the real constant keeps call sites unchanged while letting tests exercise both variants directly. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor(app): isolate native project icon rendering --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
526 lines
15 KiB
TypeScript
526 lines
15 KiB
TypeScript
import { readdir, readFile, stat } from "fs/promises";
|
|
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 <Image> 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.svg",
|
|
"favicon.png",
|
|
"favicon.ico",
|
|
"favico.svg",
|
|
"favico.png",
|
|
"favico.ico",
|
|
"icon.svg",
|
|
"icon.png",
|
|
"app-icon.svg",
|
|
"app-icon.png",
|
|
"apple-touch-icon.png",
|
|
"icon-*.png",
|
|
"logo.svg",
|
|
"logo.png",
|
|
];
|
|
|
|
/**
|
|
* Directories or directory paths to search first (in priority order).
|
|
*/
|
|
export const PRIORITY_DIRS = ["public", "static", "priv/static", "assets", "images", "img"];
|
|
|
|
/**
|
|
* Monorepo package directory patterns to scan (e.g., packages/app, apps/web).
|
|
*/
|
|
export const MONOREPO_PACKAGE_DIRS = ["packages", "apps"];
|
|
|
|
/**
|
|
* Directories to ignore during search.
|
|
*/
|
|
export const IGNORED_DIRS = [
|
|
".git",
|
|
"node_modules",
|
|
"dist",
|
|
"build",
|
|
".next",
|
|
".nuxt",
|
|
".output",
|
|
"coverage",
|
|
".cache",
|
|
"vendor",
|
|
"src",
|
|
"lib",
|
|
"test",
|
|
"tests",
|
|
"__tests__",
|
|
];
|
|
|
|
export interface ProjectIcon {
|
|
data: string;
|
|
mimeType: string;
|
|
}
|
|
|
|
const MAX_ICON_SIZE = 32 * 1024; // 32KB max
|
|
|
|
export interface ImageDimensions {
|
|
width: number;
|
|
height: number;
|
|
}
|
|
|
|
function getPngDimensions(buffer: Buffer): ImageDimensions | null {
|
|
// PNG header: 89 50 4E 47 0D 0A 1A 0A
|
|
if (buffer.length < 24) return null;
|
|
if (buffer[0] !== 0x89 || buffer[1] !== 0x50 || buffer[2] !== 0x4e || buffer[3] !== 0x47) {
|
|
return null;
|
|
}
|
|
// Width and height are at bytes 16-19 and 20-23 (big endian)
|
|
const width = buffer.readUInt32BE(16);
|
|
const height = buffer.readUInt32BE(20);
|
|
return { width, height };
|
|
}
|
|
|
|
function getJpegDimensions(buffer: Buffer): ImageDimensions | null {
|
|
// JPEG starts with FF D8 FF
|
|
if (buffer.length < 4) return null;
|
|
if (buffer[0] !== 0xff || buffer[1] !== 0xd8) return null;
|
|
|
|
let offset = 2;
|
|
while (offset < buffer.length - 8) {
|
|
if (buffer[offset] !== 0xff) {
|
|
offset++;
|
|
continue;
|
|
}
|
|
|
|
const marker = buffer[offset + 1];
|
|
// SOF0-SOF2 markers contain dimensions
|
|
if (marker >= 0xc0 && marker <= 0xc2) {
|
|
const height = buffer.readUInt16BE(offset + 5);
|
|
const width = buffer.readUInt16BE(offset + 7);
|
|
return { width, height };
|
|
}
|
|
|
|
// Skip to next marker
|
|
const length = buffer.readUInt16BE(offset + 2);
|
|
offset += 2 + length;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function getGifDimensions(buffer: Buffer): ImageDimensions | null {
|
|
// GIF header: GIF87a or GIF89a
|
|
if (buffer.length < 10) return null;
|
|
if (buffer[0] !== 0x47 || buffer[1] !== 0x49 || buffer[2] !== 0x46) return null;
|
|
// Width and height at bytes 6-7 and 8-9 (little endian)
|
|
const width = buffer.readUInt16LE(6);
|
|
const height = buffer.readUInt16LE(8);
|
|
return { width, height };
|
|
}
|
|
|
|
function getWebpDimensions(buffer: Buffer): ImageDimensions | null {
|
|
// WEBP: RIFF....WEBP
|
|
if (buffer.length < 30) return null;
|
|
if (buffer.toString("ascii", 0, 4) !== "RIFF") return null;
|
|
if (buffer.toString("ascii", 8, 12) !== "WEBP") return null;
|
|
|
|
const chunkType = buffer.toString("ascii", 12, 16);
|
|
if (chunkType === "VP8 ") {
|
|
// Lossy format - dimensions at offset 26-27 and 28-29
|
|
const width = buffer.readUInt16LE(26) & 0x3fff;
|
|
const height = buffer.readUInt16LE(28) & 0x3fff;
|
|
return { width, height };
|
|
} else if (chunkType === "VP8L") {
|
|
// Lossless format
|
|
const bits = buffer.readUInt32LE(21);
|
|
const width = (bits & 0x3fff) + 1;
|
|
const height = ((bits >> 14) & 0x3fff) + 1;
|
|
return { width, height };
|
|
}
|
|
return null;
|
|
}
|
|
|
|
export function getImageDimensions(buffer: Buffer, mimeType: string): ImageDimensions | null {
|
|
switch (mimeType) {
|
|
case "image/png":
|
|
return getPngDimensions(buffer);
|
|
case "image/jpeg":
|
|
return getJpegDimensions(buffer);
|
|
case "image/gif":
|
|
return getGifDimensions(buffer);
|
|
case "image/webp":
|
|
return getWebpDimensions(buffer);
|
|
case "image/x-icon":
|
|
// ICO files are typically square, trust them
|
|
return { width: 1, height: 1 };
|
|
case "image/svg+xml":
|
|
// SVG can be any aspect ratio but icons are typically square, trust them
|
|
return { width: 1, height: 1 };
|
|
default:
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function isSquareImage(buffer: Buffer, mimeType: string): boolean {
|
|
const dimensions = getImageDimensions(buffer, mimeType);
|
|
if (!dimensions) return false;
|
|
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 <Image> 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) {
|
|
case ".ico":
|
|
return "image/x-icon";
|
|
case ".png":
|
|
return "image/png";
|
|
case ".svg":
|
|
return "image/svg+xml";
|
|
case ".jpg":
|
|
case ".jpeg":
|
|
return "image/jpeg";
|
|
case ".gif":
|
|
return "image/gif";
|
|
case ".webp":
|
|
return "image/webp";
|
|
default:
|
|
return "application/octet-stream";
|
|
}
|
|
}
|
|
|
|
function matchesPattern(filename: string, pattern: string): boolean {
|
|
if (pattern.includes("*")) {
|
|
// Convert glob pattern to regex
|
|
const regexPattern = pattern.replace(/\./g, "\\.").replace(/\*/g, ".*");
|
|
return new RegExp(`^${regexPattern}$`).test(filename);
|
|
}
|
|
return filename === pattern;
|
|
}
|
|
|
|
async function isExistingFile(fullPath: string): Promise<boolean> {
|
|
try {
|
|
const stats = await stat(fullPath);
|
|
return stats.isFile();
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async function isExistingDirectory(fullPath: string): Promise<boolean> {
|
|
try {
|
|
const stats = await stat(fullPath);
|
|
return stats.isDirectory();
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async function findIconInDir(dir: string, patterns: string[]): Promise<string | null> {
|
|
let entries: string[];
|
|
try {
|
|
entries = await readdir(dir);
|
|
} catch {
|
|
return null;
|
|
}
|
|
|
|
// Collect candidate paths in priority order (pattern, then entry)
|
|
const candidatePaths: string[] = [];
|
|
for (const pattern of patterns) {
|
|
for (const entry of entries) {
|
|
if (matchesPattern(entry, pattern)) {
|
|
candidatePaths.push(join(dir, entry));
|
|
}
|
|
}
|
|
}
|
|
|
|
const existsResults = await Promise.all(candidatePaths.map((p) => isExistingFile(p)));
|
|
const foundIndex = existsResults.findIndex((exists) => exists);
|
|
return foundIndex === -1 ? null : (candidatePaths[foundIndex] ?? null);
|
|
}
|
|
|
|
async function searchPriorityDirs(
|
|
basePath: string,
|
|
ignoredDirsSet: Set<string>,
|
|
remainingDepth: number,
|
|
): Promise<string | null> {
|
|
const priorityPaths = PRIORITY_DIRS.map((priorityDir) => join(basePath, priorityDir));
|
|
const existenceResults = await Promise.all(
|
|
priorityPaths.map((priorityPath) => isExistingDirectory(priorityPath)),
|
|
);
|
|
const searchResults = await Promise.all(
|
|
priorityPaths.map((priorityPath, index) =>
|
|
existenceResults[index]
|
|
? searchDirRecursively(priorityPath, ICON_PATTERNS, ignoredDirsSet, remainingDepth)
|
|
: Promise.resolve(null),
|
|
),
|
|
);
|
|
return searchResults.find((result): result is string => result !== null) ?? null;
|
|
}
|
|
|
|
async function searchDirRecursively(
|
|
dir: string,
|
|
patterns: string[],
|
|
ignoredDirs: Set<string>,
|
|
maxDepth: number,
|
|
currentDepth: number = 0,
|
|
): Promise<string | null> {
|
|
if (currentDepth > maxDepth) {
|
|
return null;
|
|
}
|
|
|
|
// First check this directory for icons
|
|
const found = await findIconInDir(dir, patterns);
|
|
if (found) {
|
|
return found;
|
|
}
|
|
|
|
// Then recurse into subdirectories
|
|
let entries: string[];
|
|
try {
|
|
entries = await readdir(dir);
|
|
} catch {
|
|
return null;
|
|
}
|
|
|
|
const candidatePaths = entries
|
|
.filter((entry) => !ignoredDirs.has(entry))
|
|
.map((entry) => join(dir, entry));
|
|
const isDirResults = await Promise.all(
|
|
candidatePaths.map((fullPath) => isExistingDirectory(fullPath)),
|
|
);
|
|
const recursionResults = await Promise.all(
|
|
candidatePaths.map((fullPath, index) =>
|
|
isDirResults[index]
|
|
? searchDirRecursively(fullPath, patterns, ignoredDirs, maxDepth, currentDepth + 1)
|
|
: Promise.resolve(null),
|
|
),
|
|
);
|
|
return recursionResults.find((result): result is string => result !== null) ?? null;
|
|
}
|
|
|
|
/**
|
|
* Find a project icon/favicon in the given directory.
|
|
* Searches priority directories first, then falls back to scanning the root.
|
|
*
|
|
* @param projectDir - The root directory of the project to search
|
|
* @param maxDepth - Maximum depth to search (default: 3)
|
|
* @returns The absolute path to the found icon, or null if not found
|
|
*/
|
|
export async function findProjectIcon(
|
|
projectDir: string,
|
|
maxDepth: number = 3,
|
|
): Promise<string | null> {
|
|
const ignoredDirsSet = new Set(IGNORED_DIRS);
|
|
|
|
// First search priority directories
|
|
const priorityResult = await searchPriorityDirs(projectDir, ignoredDirsSet, maxDepth - 1);
|
|
if (priorityResult) {
|
|
return priorityResult;
|
|
}
|
|
|
|
// Then search monorepo package directories (packages/*, apps/*)
|
|
const monoPaths = MONOREPO_PACKAGE_DIRS.map((monoDir) => join(projectDir, monoDir));
|
|
const monoEntries = await Promise.all(
|
|
monoPaths.map(async (monoPath): Promise<string[] | null> => {
|
|
try {
|
|
return await readdir(monoPath);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}),
|
|
);
|
|
const monoResults = await Promise.all(
|
|
monoPaths.map(async (monoPath, monoIdx): Promise<string | null> => {
|
|
const packageEntries = monoEntries[monoIdx];
|
|
if (!packageEntries) return null;
|
|
const packagePaths = packageEntries.map((packageName) => join(monoPath, packageName));
|
|
const isDirResults = await Promise.all(
|
|
packagePaths.map((packagePath) => isExistingDirectory(packagePath)),
|
|
);
|
|
const packageResults = await Promise.all(
|
|
packagePaths.map(async (packagePath, idx): Promise<string | null> => {
|
|
if (!isDirResults[idx]) return null;
|
|
const packagePriorityResult = await searchPriorityDirs(
|
|
packagePath,
|
|
ignoredDirsSet,
|
|
maxDepth - 1,
|
|
);
|
|
if (packagePriorityResult) return packagePriorityResult;
|
|
return await findIconInDir(packagePath, ICON_PATTERNS);
|
|
}),
|
|
);
|
|
return packageResults.find((result): result is string => result !== null) ?? null;
|
|
}),
|
|
);
|
|
const monoMatch = monoResults.find((result): result is string => result !== null);
|
|
if (monoMatch) {
|
|
return monoMatch;
|
|
}
|
|
|
|
// Then search root and any other non-priority directories
|
|
const found = await findDirRecursively(projectDir);
|
|
if (found) {
|
|
return found;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
async function findDirRecursively(
|
|
dir: string,
|
|
maxDepth: number = 2,
|
|
currentDepth: number = 0,
|
|
): Promise<string | null> {
|
|
const ignoredDirsSet = new Set(IGNORED_DIRS);
|
|
const priorityDirsSet = new Set(PRIORITY_DIRS);
|
|
|
|
if (currentDepth > maxDepth) {
|
|
return null;
|
|
}
|
|
|
|
// Check root for icons
|
|
const found = await findIconInDir(dir, ICON_PATTERNS);
|
|
if (found) {
|
|
return found;
|
|
}
|
|
|
|
// Don't recurse further from root - we already searched priority dirs
|
|
if (currentDepth === 0) {
|
|
return null;
|
|
}
|
|
|
|
let entries: string[];
|
|
try {
|
|
entries = await readdir(dir);
|
|
} catch {
|
|
return null;
|
|
}
|
|
|
|
const candidatePaths = entries
|
|
.filter((entry) => !ignoredDirsSet.has(entry) && !priorityDirsSet.has(entry))
|
|
.map((entry) => join(dir, entry));
|
|
const isDirResults = await Promise.all(
|
|
candidatePaths.map((fullPath) => isExistingDirectory(fullPath)),
|
|
);
|
|
const recursionResults = await Promise.all(
|
|
candidatePaths.map((fullPath, index) =>
|
|
isDirResults[index]
|
|
? findDirRecursively(fullPath, maxDepth, currentDepth + 1)
|
|
: Promise.resolve(null),
|
|
),
|
|
);
|
|
return recursionResults.find((result): result is string => result !== null) ?? null;
|
|
}
|
|
|
|
/**
|
|
* Find and read a project icon/favicon, returning it as base64.
|
|
* Only returns square icons smaller than MAX_ICON_SIZE (32KB).
|
|
*
|
|
* @param projectDir - The root directory of the project to search
|
|
* @returns The icon data with mime type, or null if not found
|
|
*/
|
|
export async function getProjectIcon(projectDir: string): Promise<ProjectIcon | null> {
|
|
const iconPath = await findProjectIcon(projectDir);
|
|
if (!iconPath) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
const stats = await stat(iconPath);
|
|
if (stats.size > MAX_ICON_SIZE) {
|
|
return null;
|
|
}
|
|
|
|
const fileBuffer = await readFile(iconPath);
|
|
let mimeType = sniffMimeType(fileBuffer) ?? getMimeType(iconPath);
|
|
let buffer: Buffer = fileBuffer;
|
|
if (mimeType === "image/x-icon") {
|
|
const pngFrame = extractIcoPngFrame(fileBuffer);
|
|
if (pngFrame) {
|
|
buffer = pngFrame;
|
|
mimeType = "image/png";
|
|
}
|
|
}
|
|
|
|
// Only return square images
|
|
if (!isSquareImage(buffer, mimeType)) {
|
|
return null;
|
|
}
|
|
|
|
const data = buffer.toString("base64");
|
|
return { data, mimeType };
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|