diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts
index b9acb16ae66..57a29c2d5b2 100644
--- a/src/main/persistence.test.ts
+++ b/src/main/persistence.test.ts
@@ -3843,7 +3843,8 @@ describe('Store', () => {
repoIcon: {
type: 'image',
source: 'upload',
- src: 'data:image/svg+xml;base64,PHN2Zz48L3N2Zz4='
+ // Why: GIF is still unsupported; PNG/WebP/SVG are allowed after #7902.
+ src: 'data:image/gif;base64,aGVsbG8='
} as never
})
@@ -3879,7 +3880,7 @@ describe('Store', () => {
repoIcon: {
type: 'image',
source: 'upload',
- src: 'data:image/svg+xml;base64,PHN2Zz48L3N2Zz4='
+ src: 'data:image/gif;base64,aGVsbG8='
} as never
})
)
diff --git a/src/main/repo-icon-autodetect.test.ts b/src/main/repo-icon-autodetect.test.ts
index e6abbe278f8..e1d5b825a6f 100644
--- a/src/main/repo-icon-autodetect.test.ts
+++ b/src/main/repo-icon-autodetect.test.ts
@@ -37,6 +37,51 @@ 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('detects SVG icons under conventional asset paths', async () => {
+ const repoPath = await makeTempRepoDir()
+ const svg = ''
+ const svgBase64 = Buffer.from(svg, 'utf8').toString('base64')
+ await mkdir(join(repoPath, 'assets'), { recursive: true })
+ await writeFile(join(repoPath, 'assets', 'icon.svg'), svg, 'utf8')
+
+ await expect(detectRepoIcon({ repoPath, kind: 'folder' })).resolves.toEqual({
+ type: 'image',
+ src: `data:image/svg+xml;base64,${svgBase64}`,
+ source: 'file',
+ label: 'assets/icon.svg'
+ })
+ })
+
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 233721a8086..f72d4769947 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
}
@@ -307,6 +119,8 @@ export async function detectRepoIcon({
return undefined
}
+export { REPO_ICON_FILE_CANDIDATES } from './repo-icon-file-detection'
+
// Why: `upstream: null` is a resolved "not a fork" marker and prevents
// repeated best-effort probes.
export async function detectRepoIconAndUpstream({
diff --git a/src/main/repo-icon-file-detection.ts b/src/main/repo-icon-file-detection.ts
new file mode 100644
index 00000000000..2b2e66d27fe
--- /dev/null
+++ b/src/main/repo-icon-file-detection.ts
@@ -0,0 +1,260 @@
+import { readFile, stat } from 'node:fs/promises'
+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. Prefer raster formats before SVG; SVG is last so a same-name PNG wins.
+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', '.svg'] as const
+
+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' | 'image/svg+xml'
+}
+
+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 isSvgBuffer(buffer: Buffer): boolean {
+ // Why: SVG is text; reject binary noise and require a root marker so random
+ // files are not base64-encoded as icons. Rendered only via
, not
+ // inline HTML, so script in SVG does not execute in Chromium.
+ const head = buffer.subarray(0, Math.min(buffer.length, 512)).toString('utf8').trimStart()
+ if (!head) {
+ return false
+ }
+ return (
+ head.startsWith('