Use native app icons for project opener

The first project opener UI used generic code glyphs for every IDE, which made the menu visually noisy and did not match the native desktop expectation. This changes the macOS path to expose transparent PNG icons generated from each detected local .app bundle and renders them directly in the toolbar/menu, falling back to the existing glyph only when an icon cannot be loaded.

Constraint: Do not redistribute third-party IDE trademark assets in the app bundle.

Constraint: Keep icon detection local to already detected open targets and cache only runtime PNG results.

Rejected: Bundling downloaded IDE logos | creates asset maintenance and trademark redistribution risk.

Rejected: Adding simple-icons as a dependency | it is not a faithful desktop app icon source and still requires brand permission checks.

Confidence: high

Scope-risk: narrow

Directive: Prefer local bundle icons on macOS; add curated official assets only as a cross-platform fallback layer.

Tested: bun test src/server/__tests__/open-target-service.test.ts src/server/__tests__/open-target-api.test.ts

Tested: cd desktop && bun run test -- src/stores/openTargetStore.test.ts src/components/layout/OpenProjectMenu.test.tsx src/components/layout/TabBar.test.tsx

Tested: cd desktop && bun run lint

Tested: bun -e openTargetService.getTargetIcon for vscode and finder returned PNG data

Tested: cd desktop && bun run build
This commit is contained in:
程序员阿江(Relakkes) 2026-05-13 18:09:35 +08:00
parent db7432a39a
commit 1182c5d9c3
8 changed files with 299 additions and 13 deletions

View File

@ -7,6 +7,7 @@ export type OpenTarget = {
kind: OpenTargetKind
label: string
icon: string
iconUrl?: string
platform: string
}

View File

@ -27,6 +27,7 @@ const storeMocks = vi.hoisted(() => ({
kind: 'ide' | 'file_manager'
label: string
icon: string
iconUrl?: string
platform: string
}>,
primaryTargetId: null as string | null,
@ -80,18 +81,22 @@ describe('OpenProjectMenu', () => {
it('renders a dropdown with detected IDEs and Finder', async () => {
storeMocks.state.targets = [
{ id: 'vscode', kind: 'ide', label: 'VS Code', icon: 'vscode', platform: 'darwin' },
{ id: 'finder', kind: 'file_manager', label: 'Finder', icon: 'finder', platform: 'darwin' },
{ id: 'vscode', kind: 'ide', label: 'VS Code', icon: 'vscode', iconUrl: '/api/open-targets/icons/vscode', platform: 'darwin' },
{ id: 'finder', kind: 'file_manager', label: 'Finder', icon: 'finder', iconUrl: '/api/open-targets/icons/finder', platform: 'darwin' },
]
storeMocks.state.primaryTargetId = 'vscode'
storeMocks.openTarget.mockResolvedValue(undefined)
render(<OpenProjectMenu path="/repo" />)
const { container } = render(<OpenProjectMenu path="/repo" />)
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: 'Open project' }))
})
expect(screen.getByRole('menu')).toBeInTheDocument()
expect([
...Array.from(container.querySelectorAll('img')),
...Array.from(document.body.querySelectorAll('[role="menu"] img')),
].map((img) => img.getAttribute('src'))).toContain('/api/open-targets/icons/vscode')
await act(async () => {
fireEvent.click(screen.getByRole('menuitem', { name: 'Finder' }))
})

View File

@ -2,17 +2,41 @@ import { useEffect, useMemo, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { ChevronDown, Code2, FolderOpen } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { useOpenTargetStore } from '../../stores/openTargetStore'
import { useOpenTargetStore, type OpenTarget } from '../../stores/openTargetStore'
type Props = {
path: string | null | undefined
}
function getTargetIcon(kind: 'ide' | 'file_manager') {
function getFallbackIcon(kind: 'ide' | 'file_manager', size = 17) {
if (kind === 'file_manager') {
return <FolderOpen size={17} strokeWidth={1.9} />
return <FolderOpen size={size} strokeWidth={1.9} />
}
return <Code2 size={17} strokeWidth={1.9} />
return <Code2 size={size} strokeWidth={1.9} />
}
function TargetIcon({ target, size = 18 }: { target: OpenTarget; size?: number }) {
const [failed, setFailed] = useState(false)
useEffect(() => {
setFailed(false)
}, [target.iconUrl])
if (target.iconUrl && !failed) {
return (
<img
src={target.iconUrl}
alt=""
aria-hidden="true"
draggable={false}
onError={() => setFailed(true)}
className="block shrink-0 object-contain"
style={{ width: size, height: size }}
/>
)
}
return getFallbackIcon(target.kind, Math.max(16, size - 1))
}
export function OpenProjectMenu({ path }: Props) {
@ -101,7 +125,7 @@ export function OpenProjectMenu({ path }: Props) {
: 'w-8 hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)]'
}`}
>
{getTargetIcon(primaryTarget.kind)}
<TargetIcon target={primaryTarget} />
{hasMenu && <ChevronDown size={14} strokeWidth={1.9} />}
</button>
@ -120,8 +144,8 @@ export function OpenProjectMenu({ path }: Props) {
onClick={() => void handleOpenTarget(target.id)}
className="flex w-full items-center gap-3 px-3 py-2.5 text-left text-sm font-medium text-[var(--color-text-primary)] transition-colors hover:bg-[var(--color-surface-hover)] focus-visible:outline-none focus-visible:bg-[var(--color-surface-hover)]"
>
<span className="flex h-7 w-7 items-center justify-center rounded-md bg-[var(--color-surface-container)] text-[var(--color-text-secondary)]">
{getTargetIcon(target.kind)}
<span className="flex h-7 w-7 items-center justify-center text-[var(--color-text-secondary)]">
<TargetIcon target={target} size={24} />
</span>
<span className="min-w-0 truncate">{target.label}</span>
</button>

View File

@ -1,6 +1,8 @@
import { create } from 'zustand'
import { openTargetsApi, type OpenTarget } from '../api/openTargets'
export type { OpenTarget } from '../api/openTargets'
const CLIENT_CACHE_TTL_MS = 60_000
type OpenTargetState = {

View File

@ -4,6 +4,7 @@ import { openTargetService } from '../services/openTargetService.js'
let listTargetsSpy: ReturnType<typeof spyOn> | undefined
let openTargetSpy: ReturnType<typeof spyOn> | undefined
let getTargetIconSpy: ReturnType<typeof spyOn> | undefined
function makeRequest(
method: string,
@ -30,6 +31,8 @@ describe('open-targets API', () => {
listTargetsSpy = undefined
openTargetSpy?.mockRestore()
openTargetSpy = undefined
getTargetIconSpy?.mockRestore()
getTargetIconSpy = undefined
})
it('returns detected targets from GET /api/open-targets', async () => {
@ -104,4 +107,20 @@ describe('open-targets API', () => {
message: 'Invalid JSON body',
})
})
it('returns a target icon as cacheable PNG', async () => {
getTargetIconSpy = spyOn(openTargetService, 'getTargetIcon').mockResolvedValue({
contentType: 'image/png',
data: new Uint8Array([1, 2, 3]),
})
const { req, url, segments } = makeRequest('GET', '/api/open-targets/icons/vscode')
const res = await handleOpenTargetsApi(req, url, segments)
expect(res.status).toBe(200)
expect(res.headers.get('Content-Type')).toBe('image/png')
expect(res.headers.get('Cache-Control')).toBe('private, max-age=86400')
expect(getTargetIconSpy).toHaveBeenCalledWith('vscode')
expect(Array.from(new Uint8Array(await res.arrayBuffer()))).toEqual([1, 2, 3])
})
})

View File

@ -13,12 +13,16 @@ function createService(
options: {
commands?: Record<string, boolean>
paths?: Record<string, boolean>
plistValues?: Record<string, string | null>
dirNames?: Record<string, string[]>
launchResult?: { code: number; stdout: string; stderr: string }
iconData?: Uint8Array
ttlMs?: number
now?: { value: number }
} = {},
) {
const launched: Array<{ command: string; args: string[] }> = []
const convertedIcons: Array<{ iconPath: string; size: number }> = []
let commandProbes = 0
let pathProbes = 0
const now = options.now ?? { value: 100 }
@ -39,11 +43,18 @@ function createService(
launched.push({ command, args })
return options.launchResult ?? { code: 0, stdout: '', stderr: '' }
},
readDirNames: async (targetPath) => options.dirNames?.[targetPath] ?? [],
readPlistValue: async (plistPath) => options.plistValues?.[plistPath] ?? null,
convertIconToPng: async (iconPath, size) => {
convertedIcons.push({ iconPath, size })
return options.iconData ?? new Uint8Array([1, 2, 3])
},
})
return {
service,
launched,
convertedIcons,
now,
get commandProbes() {
return commandProbes
@ -73,6 +84,8 @@ describe('openTargetService', () => {
])
expect(result.primaryTargetId).toBe('vscode')
expect(result.targets.find((target) => target.id === 'finder')?.kind).toBe('file_manager')
expect(result.targets.find((target) => target.id === 'vscode')?.iconUrl)
.toBe('/api/open-targets/icons/vscode')
})
it('falls back to Explorer when no Windows IDE is detected', async () => {
@ -187,4 +200,40 @@ describe('openTargetService', () => {
await rm(dir, { recursive: true, force: true })
}
})
it('extracts macOS target icons from the detected app bundle icon file', async () => {
const iconPath = '/Applications/Visual Studio Code.app/Contents/Resources/Code.icns'
const state = createService('darwin', {
paths: {
'/Applications/Visual Studio Code.app': true,
[iconPath]: true,
},
plistValues: {
'/Applications/Visual Studio Code.app/Contents/Info.plist': 'Code.icns',
},
iconData: new Uint8Array([9, 8, 7]),
})
const icon = await state.service.getTargetIcon('vscode')
expect(icon.contentType).toBe('image/png')
expect(Array.from(icon.data)).toEqual([9, 8, 7])
expect(state.convertedIcons).toEqual([{ iconPath, size: 64 }])
await state.service.getTargetIcon('vscode')
expect(state.convertedIcons).toHaveLength(1)
})
it('uses Finder system icon for the macOS file-manager fallback', async () => {
const finderIcon = '/System/Library/CoreServices/Finder.app/Contents/Resources/Finder.icns'
const state = createService('darwin', {
paths: {
[finderIcon]: true,
},
})
await state.service.getTargetIcon('finder')
expect(state.convertedIcons).toEqual([{ iconPath: finderIcon, size: 64 }])
})
})

View File

@ -37,6 +37,25 @@ export async function handleOpenTargetsApi(
return Response.json(await openTargetService.openTarget({ targetId, path }))
}
if (action === 'icons') {
if (req.method !== 'GET') {
throw new ApiError(405, `Method ${req.method} not allowed`, 'METHOD_NOT_ALLOWED')
}
const targetId = typeof segments[3] === 'string' ? decodeURIComponent(segments[3]).trim() : ''
if (!targetId) {
throw ApiError.badRequest('Missing open target icon id')
}
const icon = await openTargetService.getTargetIcon(targetId)
return new Response(icon.data, {
headers: {
'Cache-Control': 'private, max-age=86400',
'Content-Type': icon.contentType,
},
})
}
throw ApiError.notFound(`Unknown open-targets endpoint: ${action}`)
} catch (error) {
return errorResponse(error)

View File

@ -1,7 +1,7 @@
import { execFile as execFileCallback } from 'node:child_process'
import { stat } from 'node:fs/promises'
import { homedir } from 'node:os'
import { join, resolve } from 'node:path'
import { mkdtemp, readFile, readdir, rm, stat } from 'node:fs/promises'
import { homedir, tmpdir } from 'node:os'
import { extname, join, resolve } from 'node:path'
import { promisify } from 'node:util'
import { ApiError } from '../middleware/errorHandler.js'
@ -17,6 +17,7 @@ export type OpenTarget = {
kind: OpenTargetKind
label: string
icon: string
iconUrl?: string
platform: OpenTargetPlatform
}
@ -34,6 +35,11 @@ export type OpenTargetLaunchResult = {
stderr: string
}
export type OpenTargetIconResult = {
contentType: 'image/png'
data: Uint8Array
}
type Runtime = {
platform: OpenTargetPlatform
ttlMs: number
@ -41,6 +47,9 @@ type Runtime = {
commandExists: (command: string) => Promise<boolean>
pathExists: (targetPath: string) => Promise<boolean>
launch: (command: string, args: string[]) => Promise<OpenTargetLaunchResult>
readDirNames: (targetPath: string) => Promise<string[]>
readPlistValue: (plistPath: string, key: string) => Promise<string | null>
convertIconToPng: (iconPath: string, size: number) => Promise<Uint8Array>
}
type LaunchPlan = {
@ -56,6 +65,7 @@ type TargetDefinition = {
platforms: OpenTargetPlatform[]
commands?: Partial<Record<OpenTargetPlatform, string[]>>
appPaths?: Partial<Record<OpenTargetPlatform, string[]>>
iconPaths?: Partial<Record<OpenTargetPlatform, string[]>>
fallback?: boolean
}
@ -157,6 +167,9 @@ const TARGET_DEFINITIONS: TargetDefinition[] = [
label: 'Finder',
icon: 'finder',
platforms: ['darwin'],
iconPaths: {
darwin: ['/System/Library/CoreServices/Finder.app/Contents/Resources/Finder.icns'],
},
fallback: true,
},
{
@ -229,12 +242,67 @@ async function defaultLaunch(command: string, args: string[]): Promise<OpenTarge
}
}
async function defaultReadDirNames(targetPath: string): Promise<string[]> {
try {
return await readdir(targetPath)
} catch {
return []
}
}
async function defaultReadPlistValue(plistPath: string, key: string): Promise<string | null> {
try {
const { stdout } = await execFile('/usr/bin/plutil', [
'-extract',
key,
'raw',
'-o',
'-',
plistPath,
], {
timeout: 3_000,
windowsHide: true,
})
const value = String(stdout ?? '').trim()
return value || null
} catch {
return null
}
}
async function defaultConvertIconToPng(iconPath: string, size: number): Promise<Uint8Array> {
const tmpDir = await mkdtemp(join(tmpdir(), 'cc-haha-open-target-icon-'))
const outputPath = join(tmpDir, 'icon.png')
try {
await execFile('/usr/bin/sips', [
'-z',
String(size),
String(size),
'-s',
'format',
'png',
iconPath,
'--out',
outputPath,
], {
timeout: 5_000,
windowsHide: true,
})
return await readFile(outputPath)
} finally {
await rm(tmpDir, { recursive: true, force: true })
}
}
function buildOpenTarget(definition: TargetDefinition, platform: OpenTargetPlatform): OpenTarget {
return {
id: definition.id,
kind: definition.kind,
label: definition.label,
icon: definition.icon,
iconUrl: platform === 'darwin'
? `/api/open-targets/icons/${encodeURIComponent(definition.id)}`
: undefined,
platform,
}
}
@ -335,6 +403,63 @@ async function validateDirectory(targetPath: string): Promise<string> {
return resolvedPath
}
function normalizeIconFileName(iconFile: string): string {
const trimmed = iconFile.trim()
if (!trimmed) return trimmed
return extname(trimmed) ? trimmed : `${trimmed}.icns`
}
async function findDarwinBundleIconPath(
appPath: string,
definition: TargetDefinition,
runtime: Runtime,
): Promise<string | null> {
const resourcesPath = join(appPath, 'Contents', 'Resources')
const plistPath = join(appPath, 'Contents', 'Info.plist')
const plistIcon = await runtime.readPlistValue(plistPath, 'CFBundleIconFile')
const candidates = [
plistIcon ? normalizeIconFileName(plistIcon) : null,
`${definition.label}.icns`,
`${definition.icon}.icns`,
].filter((value): value is string => Boolean(value))
for (const fileName of candidates) {
const iconPath = join(resourcesPath, fileName)
if (await runtime.pathExists(iconPath)) return iconPath
}
const iconFiles = await runtime.readDirNames(resourcesPath)
const firstIcon = iconFiles
.filter((fileName) => fileName.endsWith('.icns'))
.find((fileName) => !/document/i.test(fileName)) ?? null
if (!firstIcon) return null
const fallbackPath = join(resourcesPath, firstIcon)
return await runtime.pathExists(fallbackPath) ? fallbackPath : null
}
async function resolveIconPath(
definition: TargetDefinition,
runtime: Runtime,
): Promise<string | null> {
if (runtime.platform !== 'darwin' || !isSupportedOnPlatform(definition, runtime.platform)) {
return null
}
for (const iconPath of definition.iconPaths?.darwin ?? []) {
if (await runtime.pathExists(iconPath)) return iconPath
}
for (const appPath of definition.appPaths?.darwin ?? []) {
if (!(await runtime.pathExists(appPath))) continue
const iconPath = await findDarwinBundleIconPath(appPath, definition, runtime)
if (iconPath) return iconPath
}
return null
}
export function createOpenTargetService(overrides: Partial<Runtime> = {}) {
const runtime: Runtime = {
platform: overrides.platform ?? process.platform,
@ -343,9 +468,13 @@ export function createOpenTargetService(overrides: Partial<Runtime> = {}) {
commandExists: overrides.commandExists ?? defaultCommandExists,
pathExists: overrides.pathExists ?? defaultPathExists,
launch: overrides.launch ?? defaultLaunch,
readDirNames: overrides.readDirNames ?? defaultReadDirNames,
readPlistValue: overrides.readPlistValue ?? defaultReadPlistValue,
convertIconToPng: overrides.convertIconToPng ?? defaultConvertIconToPng,
}
let cache: OpenTargetList | null = null
const iconCache = new Map<string, OpenTargetIconResult>()
async function listTargets(forceRefresh = false): Promise<OpenTargetList> {
if (!forceRefresh && cache && runtime.now() - cache.cachedAt < runtime.ttlMs) {
@ -416,9 +545,47 @@ export function createOpenTargetService(overrides: Partial<Runtime> = {}) {
}
}
async function getTargetIcon(targetId: string, size = 64): Promise<OpenTargetIconResult> {
const definition = TARGET_DEFINITIONS.find((candidate) => candidate.id === targetId)
if (!definition) {
throw openTargetError(404, `Unknown open target icon: ${targetId}`, 'OPEN_TARGET_ICON_UNKNOWN')
}
const normalizedSize = Number.isFinite(size) ? Math.min(256, Math.max(16, Math.round(size))) : 64
const cacheKey = `${runtime.platform}:${targetId}:${normalizedSize}`
const cachedIcon = iconCache.get(cacheKey)
if (cachedIcon) return cachedIcon
const targets = await listTargets()
if (!targets.targets.some((target) => target.id === targetId)) {
throw openTargetError(
404,
`Open target icon is not available on ${runtime.platform}: ${targetId}`,
'OPEN_TARGET_ICON_UNAVAILABLE',
)
}
const iconPath = await resolveIconPath(definition, runtime)
if (!iconPath) {
throw openTargetError(
404,
`Open target icon is not available on ${runtime.platform}: ${targetId}`,
'OPEN_TARGET_ICON_UNAVAILABLE',
)
}
const icon = {
contentType: 'image/png' as const,
data: await runtime.convertIconToPng(iconPath, normalizedSize),
}
iconCache.set(cacheKey, icon)
return icon
}
return {
listTargets,
openTarget,
getTargetIcon,
}
}