From 1182c5d9c3c67f21f70b8ceb98b765e739da683e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Wed, 13 May 2026 18:09:35 +0800 Subject: [PATCH] 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 --- desktop/src/api/openTargets.ts | 1 + .../layout/OpenProjectMenu.test.tsx | 11 +- .../src/components/layout/OpenProjectMenu.tsx | 38 +++- desktop/src/stores/openTargetStore.ts | 2 + src/server/__tests__/open-target-api.test.ts | 19 ++ .../__tests__/open-target-service.test.ts | 49 +++++ src/server/api/open-targets.ts | 19 ++ src/server/services/openTargetService.ts | 173 +++++++++++++++++- 8 files changed, 299 insertions(+), 13 deletions(-) diff --git a/desktop/src/api/openTargets.ts b/desktop/src/api/openTargets.ts index 0d80d30e..23d124e5 100644 --- a/desktop/src/api/openTargets.ts +++ b/desktop/src/api/openTargets.ts @@ -7,6 +7,7 @@ export type OpenTarget = { kind: OpenTargetKind label: string icon: string + iconUrl?: string platform: string } diff --git a/desktop/src/components/layout/OpenProjectMenu.test.tsx b/desktop/src/components/layout/OpenProjectMenu.test.tsx index bcbaa623..7f3ae0f0 100644 --- a/desktop/src/components/layout/OpenProjectMenu.test.tsx +++ b/desktop/src/components/layout/OpenProjectMenu.test.tsx @@ -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() + const { container } = render() 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' })) }) diff --git a/desktop/src/components/layout/OpenProjectMenu.tsx b/desktop/src/components/layout/OpenProjectMenu.tsx index 20b60ecf..69abe11e 100644 --- a/desktop/src/components/layout/OpenProjectMenu.tsx +++ b/desktop/src/components/layout/OpenProjectMenu.tsx @@ -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 + return } - return + return +} + +function TargetIcon({ target, size = 18 }: { target: OpenTarget; size?: number }) { + const [failed, setFailed] = useState(false) + + useEffect(() => { + setFailed(false) + }, [target.iconUrl]) + + if (target.iconUrl && !failed) { + return ( + 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)} + {hasMenu && } @@ -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)]" > - - {getTargetIcon(target.kind)} + + {target.label} diff --git a/desktop/src/stores/openTargetStore.ts b/desktop/src/stores/openTargetStore.ts index e49142d2..49573b5b 100644 --- a/desktop/src/stores/openTargetStore.ts +++ b/desktop/src/stores/openTargetStore.ts @@ -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 = { diff --git a/src/server/__tests__/open-target-api.test.ts b/src/server/__tests__/open-target-api.test.ts index 2a337b12..3be015c5 100644 --- a/src/server/__tests__/open-target-api.test.ts +++ b/src/server/__tests__/open-target-api.test.ts @@ -4,6 +4,7 @@ import { openTargetService } from '../services/openTargetService.js' let listTargetsSpy: ReturnType | undefined let openTargetSpy: ReturnType | undefined +let getTargetIconSpy: ReturnType | 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]) + }) }) diff --git a/src/server/__tests__/open-target-service.test.ts b/src/server/__tests__/open-target-service.test.ts index 7047fc7e..884452fb 100644 --- a/src/server/__tests__/open-target-service.test.ts +++ b/src/server/__tests__/open-target-service.test.ts @@ -13,12 +13,16 @@ function createService( options: { commands?: Record paths?: Record + plistValues?: Record + dirNames?: Record 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 }]) + }) }) diff --git a/src/server/api/open-targets.ts b/src/server/api/open-targets.ts index b7436d25..52740acb 100644 --- a/src/server/api/open-targets.ts +++ b/src/server/api/open-targets.ts @@ -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) diff --git a/src/server/services/openTargetService.ts b/src/server/services/openTargetService.ts index 0f30b295..8914f924 100644 --- a/src/server/services/openTargetService.ts +++ b/src/server/services/openTargetService.ts @@ -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 pathExists: (targetPath: string) => Promise launch: (command: string, args: string[]) => Promise + readDirNames: (targetPath: string) => Promise + readPlistValue: (plistPath: string, key: string) => Promise + convertIconToPng: (iconPath: string, size: number) => Promise } type LaunchPlan = { @@ -56,6 +65,7 @@ type TargetDefinition = { platforms: OpenTargetPlatform[] commands?: Partial> appPaths?: Partial> + iconPaths?: Partial> 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 { + try { + return await readdir(targetPath) + } catch { + return [] + } +} + +async function defaultReadPlistValue(plistPath: string, key: string): Promise { + 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 { + 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 { 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 { + 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 { + 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 = {}) { const runtime: Runtime = { platform: overrides.platform ?? process.platform, @@ -343,9 +468,13 @@ export function createOpenTargetService(overrides: Partial = {}) { 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() async function listTargets(forceRefresh = false): Promise { if (!forceRefresh && cache && runtime.now() - cache.cachedAt < runtime.ttlMs) { @@ -416,9 +545,47 @@ export function createOpenTargetService(overrides: Partial = {}) { } } + async function getTargetIcon(targetId: string, size = 64): Promise { + 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, } }