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,
}
}