mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
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:
parent
db7432a39a
commit
1182c5d9c3
@ -7,6 +7,7 @@ export type OpenTarget = {
|
||||
kind: OpenTargetKind
|
||||
label: string
|
||||
icon: string
|
||||
iconUrl?: string
|
||||
platform: string
|
||||
}
|
||||
|
||||
|
||||
@ -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' }))
|
||||
})
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 = {
|
||||
|
||||
@ -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])
|
||||
})
|
||||
})
|
||||
|
||||
@ -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 }])
|
||||
})
|
||||
})
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user