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(' { + 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: PNG/WebP are binary; SVG often arrives as text from remote FS + // providers. Detect format from bytes either way. + const buffer = result.isBinary + ? Buffer.from(result.content, 'base64') + : Buffer.from(result.content, 'utf8') + return repoIconFromImageBuffer(buffer, relativePath) +} + +async function detectLocalImageIcon(repoPath: string): Promise { + for (const relativePath of REPO_ICON_FILE_CANDIDATES) { + try { + const icon = await readLocalImageIcon(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 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 { + for (const relativePath of REPO_ICON_FILE_CANDIDATES) { + try { + const icon = await readRemoteImageIcon(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 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 6c72e259c72..fb59cbd7c1f 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -895,6 +895,7 @@ describe('connectPanePty', () => { afterEach(() => { vi.useRealTimers() + vi.restoreAllMocks() if (originalRequestAnimationFrame) { globalThis.requestAnimationFrame = originalRequestAnimationFrame } else { @@ -16480,6 +16481,9 @@ describe('connectPanePty', () => { const transport = createMockTransport('pty-replaced-codex') transportFactoryQueue.push(transport) vi.useFakeTimers() + // Why: this assertion places the hook update between two active-cadence + // inspections, so pin the coordinator's poll jitter like its unit tests do. + vi.spyOn(Math, 'random').mockReturnValue(0.5) const getForegroundProcess = vi.mocked(window.api.pty.getForegroundProcess) getForegroundProcess.mockResolvedValue('codex') const paneKey = makePaneKey('tab-1', LEAF_1) diff --git a/src/renderer/src/store/slices/repos-update-serialization.test.ts b/src/renderer/src/store/slices/repos-update-serialization.test.ts index 811d4c81f07..6ecbaab1fa4 100644 --- a/src/renderer/src/store/slices/repos-update-serialization.test.ts +++ b/src/renderer/src/store/slices/repos-update-serialization.test.ts @@ -137,7 +137,8 @@ describe('repo update serialization', () => { 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 }) diff --git a/src/shared/repo-icon.test.ts b/src/shared/repo-icon.test.ts index fe2311da5b6..22a89d665e5 100644 --- a/src/shared/repo-icon.test.ts +++ b/src/shared/repo-icon.test.ts @@ -57,6 +57,28 @@ describe('sanitizeRepoIcon', () => { src: 'data:image/png;base64,aGVsbG8=', source: 'file' }) + expect( + sanitizeRepoIcon({ + type: 'image', + src: 'data:image/webp;base64,aGVsbG8=', + source: 'file' + }) + ).toEqual({ + type: 'image', + src: 'data:image/webp;base64,aGVsbG8=', + source: 'file' + }) + expect( + sanitizeRepoIcon({ + type: 'image', + src: 'data:image/svg+xml;base64,PHN2Zz48L3N2Zz4=', + source: 'file' + }) + ).toEqual({ + type: 'image', + src: 'data:image/svg+xml;base64,PHN2Zz48L3N2Zz4=', + source: 'file' + }) }) it('keeps null as an explicit reset', () => { @@ -81,7 +103,7 @@ describe('sanitizeRepoIcon', () => { expect( sanitizeRepoIcon({ type: 'image', - src: 'data:image/svg+xml;base64,PHN2Zz48L3N2Zz4=', + src: 'data:image/gif;base64,aGVsbG8=', source: 'upload' }) ).toBeUndefined() diff --git a/src/shared/repo-icon.ts b/src/shared/repo-icon.ts index 01ac83c1392..6fc856846d3 100644 --- a/src/shared/repo-icon.ts +++ b/src/shared/repo-icon.ts @@ -42,7 +42,9 @@ export function githubAvatarIcon(slug: { owner: string; repo: string }): RepoIco function isSupportedImageSrc(src: string, source: RepoIconImageSource): boolean { if (source === 'upload' || source === 'file') { - return /^data:image\/png;base64,[A-Za-z0-9+/=\s]+$/i.test(src) + // Why: auto-detect and uploads may use PNG, WebP, or SVG (issue #7902). + // SVG is only allowed as a data URL for , never inlined as HTML. + return /^data:image\/(?:png|webp|svg\+xml);base64,[A-Za-z0-9+/=\s]+$/i.test(src) } let url: URL