diff --git a/src/main/repo-icon-autodetect.test.ts b/src/main/repo-icon-autodetect.test.ts index c491f3444..03de06f15 100644 --- a/src/main/repo-icon-autodetect.test.ts +++ b/src/main/repo-icon-autodetect.test.ts @@ -37,6 +37,36 @@ describe('detectRepoIcon', () => { }) }) + it('detects Tauri bundle icons under src-tauri/icons', async () => { + const repoPath = await makeTempRepoDir() + await mkdir(join(repoPath, 'src-tauri', 'icons'), { recursive: true }) + await writeFile( + join(repoPath, 'src-tauri', 'icons', 'icon.png'), + Buffer.from(PNG_1X1_BASE64, 'base64') + ) + + await expect(detectRepoIcon({ repoPath, kind: 'folder' })).resolves.toEqual({ + type: 'image', + src: `data:image/png;base64,${PNG_1X1_BASE64}`, + source: 'file', + label: 'src-tauri/icons/icon.png' + }) + }) + + it('detects public WebP icons used by CLI tools', async () => { + const repoPath = await makeTempRepoDir() + const webpBase64 = 'UklGRhoAAABXRUJQVlA4IA4AAAAwAQCdASoBAAEAAQIlSkwAAA==' + await mkdir(join(repoPath, 'public'), { recursive: true }) + await writeFile(join(repoPath, 'public', 'icon.webp'), Buffer.from(webpBase64, 'base64')) + + await expect(detectRepoIcon({ repoPath, kind: 'folder' })).resolves.toEqual({ + type: 'image', + src: `data:image/webp;base64,${webpBase64}`, + source: 'file', + label: 'public/icon.webp' + }) + }) + it('uses a package homepage favicon when no local icon file exists', async () => { const repoPath = await makeTempRepoDir() await writeFile( diff --git a/src/main/repo-icon-autodetect.ts b/src/main/repo-icon-autodetect.ts index 233721a80..e0dcd15c8 100644 --- a/src/main/repo-icon-autodetect.ts +++ b/src/main/repo-icon-autodetect.ts @@ -1,51 +1,13 @@ import { readFile, stat } from 'node:fs/promises' import type { GitHubRepositoryIdentity, RepoKind } from '../shared/types' -import { - faviconUrlFromWebsite, - githubAvatarIcon, - MAX_REPO_ICON_UPLOAD_BYTES, - type RepoIcon -} from '../shared/repo-icon' +import { faviconUrlFromWebsite, githubAvatarIcon, type RepoIcon } from '../shared/repo-icon' import { getRepoSlug, getRepoUpstream } from './github/client' import { getSshFilesystemProvider } from './providers/ssh-filesystem-dispatch' import type { IFilesystemProvider } from './providers/types' import { detectGitRemoteIdentity } from './repo-git-remote-identity' -import { iconHrefCandidates } from './repo-icon-href-candidates' +import { detectRepoFileIcon } from './repo-icon-file-detection' import { joinWorktreeRelativePath } from './runtime/runtime-relative-paths' -const REPO_ICON_FILE_CANDIDATES = [ - 'favicon.png', - 'public/favicon.png', - 'app/favicon.png', - 'app/icon.png', - 'src/favicon.png', - 'src/app/icon.png', - 'assets/favicon.png', - 'assets/icon.png', - 'static/favicon.png', - 'logo.png', - 'public/logo.png' -] - -const REPO_ICON_SOURCE_FILE_CANDIDATES = [ - 'index.html', - 'public/index.html', - 'app/routes/__root.tsx', - 'src/routes/__root.tsx', - 'app/root.tsx', - 'src/root.tsx', - 'src/index.html' -] - -// Why: repo icon detection runs while adding repos; declared-icon probing should -// not read large app entrypoints just to find a small favicon href. -const MAX_REPO_ICON_SOURCE_BYTES = 256 * 1024 - -const LINK_ICON_HTML_RE = - /]*\brel=["'](?:icon|shortcut icon)["'])(?=[^>]*\bhref=["']([^"'?]+))[^>]*>/i -const LINK_ICON_OBJECT_RE = - /(?=[^}]*\brel\s*:\s*["'](?:icon|shortcut icon)["'])(?=[^}]*\bhref\s*:\s*["']([^"'?]+))[^}]*/i - const WEBSITE_HOSTS_TO_SKIP = new Set([ 'github.com', 'www.github.com', @@ -55,20 +17,6 @@ const WEBSITE_HOSTS_TO_SKIP = new Set([ 'www.bitbucket.org' ]) -function isPngBuffer(buffer: Buffer): boolean { - return ( - buffer.length >= 8 && - buffer[0] === 0x89 && - buffer[1] === 0x50 && - buffer[2] === 0x4e && - buffer[3] === 0x47 && - buffer[4] === 0x0d && - buffer[5] === 0x0a && - buffer[6] === 0x1a && - buffer[7] === 0x0a - ) -} - function shouldUseWebsiteFavicon(rawUrl: string): boolean { try { const url = new URL(rawUrl.includes('://') ? rawUrl : `https://${rawUrl}`) @@ -78,140 +26,6 @@ function shouldUseWebsiteFavicon(rawUrl: string): boolean { } } -function extractIconHref(source: string): string | null { - return source.match(LINK_ICON_HTML_RE)?.[1] ?? source.match(LINK_ICON_OBJECT_RE)?.[1] ?? null -} - -async function readLocalPngIcon(repoPath: string, relativePath: string): Promise { - const filePath = joinWorktreeRelativePath(repoPath, relativePath) - const info = await stat(filePath) - if (!info.isFile() || info.size > MAX_REPO_ICON_UPLOAD_BYTES) { - return null - } - const buffer = await readFile(filePath) - if (!isPngBuffer(buffer)) { - return null - } - return { - type: 'image', - src: `data:image/png;base64,${buffer.toString('base64')}`, - source: 'file', - label: relativePath - } -} - -async function readRemotePngIcon( - repoPath: string, - fsProvider: IFilesystemProvider, - relativePath: string -): Promise { - const filePath = joinWorktreeRelativePath(repoPath, relativePath) - const info = await fsProvider.stat(filePath) - if (info.type !== 'file' || info.size > MAX_REPO_ICON_UPLOAD_BYTES) { - return null - } - const result = await fsProvider.readFile(filePath) - if (!result.isBinary || result.mimeType !== 'image/png' || !result.content) { - return null - } - const buffer = Buffer.from(result.content, 'base64') - if (!isPngBuffer(buffer)) { - return null - } - return { - type: 'image', - src: `data:image/png;base64,${buffer.toString('base64')}`, - source: 'file', - label: relativePath - } -} - -async function detectLocalPngIcon(repoPath: string): Promise { - for (const relativePath of REPO_ICON_FILE_CANDIDATES) { - try { - const icon = await readLocalPngIcon(repoPath, relativePath) - if (icon) { - return icon - } - } catch { - // Try the next conventional icon path. - } - } - for (const sourceFile of REPO_ICON_SOURCE_FILE_CANDIDATES) { - try { - const sourcePath = joinWorktreeRelativePath(repoPath, sourceFile) - const sourceInfo = await stat(sourcePath) - if (!sourceInfo.isFile() || sourceInfo.size > MAX_REPO_ICON_SOURCE_BYTES) { - continue - } - const source = await readFile(sourcePath, 'utf8') - const href = extractIconHref(source) - if (!href) { - continue - } - for (const relativePath of iconHrefCandidates(href, sourceFile)) { - try { - const icon = await readLocalPngIcon(repoPath, relativePath) - if (icon) { - return icon - } - } catch { - // Try the next href resolution. - } - } - } catch { - // Try the next source file. - } - } - return null -} - -async function detectRemotePngIcon( - repoPath: string, - fsProvider: IFilesystemProvider -): Promise { - for (const relativePath of REPO_ICON_FILE_CANDIDATES) { - try { - const icon = await readRemotePngIcon(repoPath, fsProvider, relativePath) - if (icon) { - return icon - } - } catch { - // Try the next conventional icon path. - } - } - for (const sourceFile of REPO_ICON_SOURCE_FILE_CANDIDATES) { - try { - const sourcePath = joinWorktreeRelativePath(repoPath, sourceFile) - const sourceInfo = await fsProvider.stat(sourcePath) - if (sourceInfo.type !== 'file' || sourceInfo.size > MAX_REPO_ICON_SOURCE_BYTES) { - continue - } - const result = await fsProvider.readFile(sourcePath) - if (result.isBinary) { - continue - } - const href = extractIconHref(result.content) - if (!href) { - continue - } - for (const relativePath of iconHrefCandidates(href, sourceFile)) { - try { - const icon = await readRemotePngIcon(repoPath, fsProvider, relativePath) - if (icon) { - return icon - } - } catch { - // Try the next href resolution. - } - } - } catch { - // Try the next source file. - } - } - return null -} - function packageHomepageIcon(packageJson: unknown): RepoIcon | null { if (!packageJson || typeof packageJson !== 'object') { return null @@ -284,9 +98,7 @@ export async function detectRepoIcon({ }): Promise { try { const fsProvider = connectionId ? getSshFilesystemProvider(connectionId) : undefined - const fileIcon = fsProvider - ? await detectRemotePngIcon(repoPath, fsProvider) - : await detectLocalPngIcon(repoPath) + const fileIcon = await detectRepoFileIcon(repoPath, fsProvider) if (fileIcon) { return fileIcon } diff --git a/src/main/repo-icon-file-detection.test.ts b/src/main/repo-icon-file-detection.test.ts new file mode 100644 index 000000000..5aec20d90 --- /dev/null +++ b/src/main/repo-icon-file-detection.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it, vi } from 'vitest' +import type { FileReadResult, FileStat, IFilesystemProvider } from './providers/types' +import { detectRepoFileIcon } from './repo-icon-file-detection' + +const WEBP_BASE64 = 'UklGRhoAAABXRUJQVlA4IA4AAAAwAQCdASoBAAEAAQIlSkwAAA==' +const PNG_BASE64 = + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=' + +function remoteFilesystemProvider({ + stat, + readFile +}: { + stat: (filePath: string) => Promise + readFile: (filePath: string) => Promise +}): IFilesystemProvider { + return { stat, readFile } as IFilesystemProvider +} + +describe('detectRepoFileIcon remote probing', () => { + it('detects binary WebP icons through a remote filesystem provider', async () => { + const provider = remoteFilesystemProvider({ + stat: async (filePath) => { + if (!filePath.endsWith('/public/icon.webp')) { + throw new Error('ENOENT') + } + return { type: 'file', size: 34, mtime: 0 } + }, + readFile: async () => ({ content: WEBP_BASE64, isBinary: true, mimeType: 'image/webp' }) + }) + + await expect(detectRepoFileIcon('/repo', provider)).resolves.toEqual({ + type: 'image', + src: `data:image/webp;base64,${WEBP_BASE64}`, + source: 'file', + label: 'public/icon.webp' + }) + }) + + it('keeps conventional-path priority when probes resolve concurrently', async () => { + const provider = remoteFilesystemProvider({ + stat: async (filePath) => { + if (filePath.endsWith('/favicon.png') || filePath.endsWith('/public/favicon.png')) { + return { type: 'file', size: 8, mtime: 0 } + } + throw new Error('ENOENT') + }, + readFile: async (filePath) => { + if (filePath.endsWith('/favicon.png')) { + await Promise.resolve() + } + return { content: PNG_BASE64, isBinary: true, mimeType: 'image/png' } + } + }) + + await expect(detectRepoFileIcon('/repo', provider)).resolves.toMatchObject({ + source: 'file', + label: 'favicon.png' + }) + }) + + it('bounds concurrent remote probes when no conventional icon exists', async () => { + let activeStats = 0 + let maxActiveStats = 0 + const stat = vi.fn(async (): Promise => { + activeStats += 1 + maxActiveStats = Math.max(maxActiveStats, activeStats) + await Promise.resolve() + activeStats -= 1 + throw new Error('ENOENT') + }) + const provider = remoteFilesystemProvider({ + stat, + readFile: async () => { + throw new Error('unexpected read') + } + }) + + await expect(detectRepoFileIcon('/repo', provider)).resolves.toBeNull() + expect(maxActiveStats).toBeGreaterThan(1) + expect(maxActiveStats).toBeLessThanOrEqual(6) + }) +}) diff --git a/src/main/repo-icon-file-detection.ts b/src/main/repo-icon-file-detection.ts new file mode 100644 index 000000000..d3339e2cd --- /dev/null +++ b/src/main/repo-icon-file-detection.ts @@ -0,0 +1,266 @@ +import { readFile, stat } from 'node:fs/promises' +import { buildImageDataUri } from '../shared/image-data-uri' +import { MAX_REPO_ICON_UPLOAD_BYTES, type RepoIcon } from '../shared/repo-icon' +import type { IFilesystemProvider } from './providers/types' +import { iconHrefCandidates } from './repo-icon-href-candidates' +import { joinWorktreeRelativePath } from './runtime/runtime-relative-paths' + +// Why: conventional locations only — keep the list short so add-repo stays +// snappy. Support bounded raster images only. +const REPO_ICON_FILE_STEMS = [ + 'favicon', + 'public/favicon', + 'app/favicon', + 'app/icon', + 'src/favicon', + 'src/app/icon', + 'assets/favicon', + 'assets/icon', + 'static/favicon', + 'logo', + 'public/logo', + // Why: CLI tools and branded assets often use public/icon.* (issue #7902). + 'public/icon', + // Why: Tauri's default bundle icon path (issue #7902). + 'src-tauri/icons/icon', + 'app-icon', + 'icon' +] as const + +const REPO_ICON_FILE_EXTENSIONS = ['.png', '.webp'] as const +const REPO_ICON_FILE_PROBE_CONCURRENCY = 6 + +export const REPO_ICON_FILE_CANDIDATES = REPO_ICON_FILE_STEMS.flatMap((stem) => + REPO_ICON_FILE_EXTENSIONS.map((extension) => `${stem}${extension}`) +) + +const REPO_ICON_SOURCE_FILE_CANDIDATES = [ + 'index.html', + 'public/index.html', + 'app/routes/__root.tsx', + 'src/routes/__root.tsx', + 'app/root.tsx', + 'src/root.tsx', + 'src/index.html' +] + +// Why: repo icon detection runs while adding repos; declared-icon probing should +// not read large app entrypoints just to find a small favicon href. +const MAX_REPO_ICON_SOURCE_BYTES = 256 * 1024 + +const LINK_ICON_HTML_RE = + /]*\brel=["'](?:icon|shortcut icon)["'])(?=[^>]*\bhref=["']([^"'?]+))[^>]*>/i +const LINK_ICON_OBJECT_RE = + /(?=[^}]*\brel\s*:\s*["'](?:icon|shortcut icon)["'])(?=[^}]*\bhref\s*:\s*["']([^"'?]+))[^}]*/i + +type DetectedImageFormat = { + mimeType: 'image/png' | 'image/webp' +} + +function isPngBuffer(buffer: Buffer): boolean { + return ( + buffer.length >= 8 && + buffer[0] === 0x89 && + buffer[1] === 0x50 && + buffer[2] === 0x4e && + buffer[3] === 0x47 && + buffer[4] === 0x0d && + buffer[5] === 0x0a && + buffer[6] === 0x1a && + buffer[7] === 0x0a + ) +} + +function isWebpBuffer(buffer: Buffer): boolean { + // Why: RIFF container with WEBP fourcc — enough to reject non-images without + // a full decoder; the sidebar only needs a valid data URL for . + return ( + buffer.length >= 12 && + buffer[0] === 0x52 && + buffer[1] === 0x49 && + buffer[2] === 0x46 && + buffer[3] === 0x46 && + buffer[8] === 0x57 && + buffer[9] === 0x45 && + buffer[10] === 0x42 && + buffer[11] === 0x50 + ) +} + +function detectImageFormat(buffer: Buffer): DetectedImageFormat | null { + if (isPngBuffer(buffer)) { + return { mimeType: 'image/png' } + } + if (isWebpBuffer(buffer)) { + return { mimeType: 'image/webp' } + } + return null +} + +function extractIconHref(source: string): string | null { + return source.match(LINK_ICON_HTML_RE)?.[1] ?? source.match(LINK_ICON_OBJECT_RE)?.[1] ?? null +} + +function repoIconFromImageBuffer(buffer: Buffer, relativePath: string): RepoIcon | null { + const format = detectImageFormat(buffer) + if (!format) { + return null + } + const src = buildImageDataUri(format.mimeType, buffer.toString('base64')) + if (!src) { + return null + } + return { + type: 'image', + src, + source: 'file', + label: relativePath + } +} + +async function readLocalImageIcon( + repoPath: string, + relativePath: string +): Promise { + const filePath = joinWorktreeRelativePath(repoPath, relativePath) + const info = await stat(filePath) + if (!info.isFile() || info.size > MAX_REPO_ICON_UPLOAD_BYTES) { + return null + } + const buffer = await readFile(filePath) + return repoIconFromImageBuffer(buffer, relativePath) +} + +async function readRemoteImageIcon( + repoPath: string, + fsProvider: IFilesystemProvider, + relativePath: string +): Promise { + const filePath = joinWorktreeRelativePath(repoPath, relativePath) + const info = await fsProvider.stat(filePath) + if (info.type !== 'file' || info.size > MAX_REPO_ICON_UPLOAD_BYTES) { + return null + } + const result = await fsProvider.readFile(filePath) + if (!result.content) { + return null + } + // Why: detect the binary format after decoding remote file content. + const buffer = result.isBinary + ? Buffer.from(result.content, 'base64') + : Buffer.from(result.content, 'utf8') + return repoIconFromImageBuffer(buffer, relativePath) +} + +async function detectConventionalImageIcon( + readIcon: (relativePath: string) => Promise +): Promise { + // Why: SSH stats are network round trips; bounded batches avoid making the + // expanded candidate list serial without flooding the remote filesystem. + for ( + let offset = 0; + offset < REPO_ICON_FILE_CANDIDATES.length; + offset += REPO_ICON_FILE_PROBE_CONCURRENCY + ) { + const batch = REPO_ICON_FILE_CANDIDATES.slice(offset, offset + REPO_ICON_FILE_PROBE_CONCURRENCY) + const icons = await Promise.all( + batch.map(async (relativePath) => { + try { + return await readIcon(relativePath) + } catch { + return null + } + }) + ) + const icon = icons.find((candidate): candidate is RepoIcon => candidate !== null) + if (icon) { + return icon + } + } + return null +} + +async function detectLocalImageIcon(repoPath: string): Promise { + const conventionalIcon = await detectConventionalImageIcon((relativePath) => + readLocalImageIcon(repoPath, relativePath) + ) + if (conventionalIcon) { + return conventionalIcon + } + for (const sourceFile of REPO_ICON_SOURCE_FILE_CANDIDATES) { + try { + const sourcePath = joinWorktreeRelativePath(repoPath, sourceFile) + const sourceInfo = await stat(sourcePath) + if (!sourceInfo.isFile() || sourceInfo.size > MAX_REPO_ICON_SOURCE_BYTES) { + continue + } + const source = await readFile(sourcePath, 'utf8') + const href = extractIconHref(source) + if (!href) { + continue + } + for (const relativePath of iconHrefCandidates(href, sourceFile)) { + try { + const icon = await readLocalImageIcon(repoPath, relativePath) + if (icon) { + return icon + } + } catch { + // Try the next href resolution. + } + } + } catch { + // Try the next source file. + } + } + return null +} + +async function detectRemoteImageIcon( + repoPath: string, + fsProvider: IFilesystemProvider +): Promise { + const conventionalIcon = await detectConventionalImageIcon((relativePath) => + readRemoteImageIcon(repoPath, fsProvider, relativePath) + ) + if (conventionalIcon) { + return conventionalIcon + } + for (const sourceFile of REPO_ICON_SOURCE_FILE_CANDIDATES) { + try { + const sourcePath = joinWorktreeRelativePath(repoPath, sourceFile) + const sourceInfo = await fsProvider.stat(sourcePath) + if (sourceInfo.type !== 'file' || sourceInfo.size > MAX_REPO_ICON_SOURCE_BYTES) { + continue + } + const result = await fsProvider.readFile(sourcePath) + if (result.isBinary) { + continue + } + const href = extractIconHref(result.content) + if (!href) { + continue + } + for (const relativePath of iconHrefCandidates(href, sourceFile)) { + try { + const icon = await readRemoteImageIcon(repoPath, fsProvider, relativePath) + if (icon) { + return icon + } + } catch { + // Try the next href resolution. + } + } + } catch { + // Try the next source file. + } + } + return null +} + +export function detectRepoFileIcon( + repoPath: string, + fsProvider?: IFilesystemProvider +): Promise { + return fsProvider ? detectRemoteImageIcon(repoPath, fsProvider) : detectLocalImageIcon(repoPath) +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection.test.ts b/src/renderer/src/components/terminal-pane/pty-connection.test.ts index ab40d2246..422c25307 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -933,6 +933,7 @@ describe('connectPanePty', () => { // Why: drain in-flight foreground-confirm microtasks while this test still owns the store mock, so its async fallout can't leak into (and flake) the next test. await flushAsyncTicks() vi.useRealTimers() + vi.restoreAllMocks() if (originalRequestAnimationFrame) { globalThis.requestAnimationFrame = originalRequestAnimationFrame } else { diff --git a/src/shared/repo-icon.test.ts b/src/shared/repo-icon.test.ts index f8d54c437..3875f5174 100644 --- a/src/shared/repo-icon.test.ts +++ b/src/shared/repo-icon.test.ts @@ -3,6 +3,7 @@ import { githubAvatarIcon, sanitizeRepoIcon } from './repo-icon' const PNG_1X1_BASE64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=' +const WEBP_1X1_BASE64 = 'UklGRhoAAABXRUJQVlA4IA4AAAAwAQCdASoBAAEAAQIlSkwAAA==' function pngBase64(width: number, height: number): string { const bytes = Buffer.alloc(24) @@ -83,6 +84,17 @@ describe('sanitizeRepoIcon', () => { src: `data:image/png;base64,${PNG_1X1_BASE64}`, source: 'file' }) + expect( + sanitizeRepoIcon({ + type: 'image', + src: `data:image/webp;base64,${WEBP_1X1_BASE64}`, + source: 'file' + }) + ).toEqual({ + type: 'image', + src: `data:image/webp;base64,${WEBP_1X1_BASE64}`, + source: 'file' + }) }) it('keeps null as an explicit reset', () => { @@ -118,6 +130,13 @@ describe('sanitizeRepoIcon', () => { source: 'upload' }) ).toBeUndefined() + expect( + sanitizeRepoIcon({ + type: 'image', + src: 'data:image/svg+xml;base64,PHN2Zz48L3N2Zz4=', + source: 'file' + }) + ).toBeUndefined() expect( sanitizeRepoIcon({ type: 'image', diff --git a/src/shared/repo-icon.ts b/src/shared/repo-icon.ts index 55e9e29fb..f5520b570 100644 --- a/src/shared/repo-icon.ts +++ b/src/shared/repo-icon.ts @@ -64,13 +64,20 @@ function normalizeGitHubAvatarHost(rawHost?: string): string { } function isSupportedImageSrc(src: string, source: RepoIconImageSource): boolean { - if (source === 'upload' || source === 'file') { + if (source === 'upload') { return ( /^data:image\/png;base64,[A-Za-z0-9+/=\s]+$/i.test(src) && validateRasterImageDataUri(src) !== null ) } + if (source === 'file') { + return ( + /^data:image\/(?:png|webp);base64,[A-Za-z0-9+/=\s]+$/i.test(src) && + validateRasterImageDataUri(src) !== null + ) + } + let url: URL try { url = new URL(src)