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