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)