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:
parent
f3d8edb29e
commit
248c0d9cda
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in New Issue