fix(repo-icon): detect Tauri and WebP icons (#7942)

Expand repository icon auto-detection to conventional Tauri and public/icon paths with PNG/WebP magic and dimension validation. Bound SSH probing while preserving candidate priority and PNG-only user uploads; SVG remains rejected.
This commit is contained in:
BingZ 2026-07-26 17:32:05 +08:00 committed by GitHub
parent f3d8edb29e
commit 248c0d9cda
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 409 additions and 192 deletions

View File

@ -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(

View File

@ -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 =
/<link\b(?=[^>]*\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<RepoIcon | null> {
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<RepoIcon | null> {
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<RepoIcon | null> {
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<RepoIcon | null> {
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<RepoIcon | undefined> {
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
}

View File

@ -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<FileStat>
readFile: (filePath: string) => Promise<FileReadResult>
}): 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<FileStat> => {
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)
})
})

View File

@ -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 =
/<link\b(?=[^>]*\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 <img>.
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<RepoIcon | null> {
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<RepoIcon | null> {
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<RepoIcon | null>
): Promise<RepoIcon | null> {
// 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<RepoIcon | null> {
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<RepoIcon | null> {
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<RepoIcon | null> {
return fsProvider ? detectRemoteImageIcon(repoPath, fsProvider) : detectLocalImageIcon(repoPath)
}

View File

@ -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 {

View File

@ -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',

View File

@ -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)