Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions src/main/persistence.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
})

Expand Down Expand Up @@ -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
})
)
Expand Down
45 changes: 45 additions & 0 deletions src/main/repo-icon-autodetect.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = '<svg xmlns="http://www.w3.org/2000/svg" width="1" height="1"></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(
Expand Down
196 changes: 5 additions & 191 deletions src/main/repo-icon-autodetect.ts
Original file line number Diff line number Diff line change
@@ -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',
Expand All @@ -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}`)
Expand All @@ -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
Expand Down Expand Up @@ -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
}
Expand All @@ -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({
Expand Down
Loading
Loading