Extract project opener icons cross-platform

Windows and Linux need to follow the same product rule as macOS: show icons from the user's installed applications instead of maintaining bundled brand artwork. The opener service now resolves command paths for Windows, walks from launcher scripts to the real IDE executable, extracts associated executable icons through the existing PNG endpoint, and resolves Linux .desktop Icon entries through common XDG application and icon theme locations.

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

Constraint: Keep the frontend platform-agnostic by preserving the existing iconUrl contract.

Rejected: Add static per-IDE icon assets | impossible to keep complete and creates unnecessary trademark redistribution risk.

Confidence: medium

Scope-risk: moderate

Directive: Prefer platform installation metadata over bundled brand art when adding more opener targets.

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 run check:server

Not-tested: Native Windows and Linux VM smoke tests for real installed IDE icons
This commit is contained in:
程序员阿江(Relakkes) 2026-05-13 18:19:35 +08:00
parent 1182c5d9c3
commit f9ac6049de
2 changed files with 515 additions and 28 deletions

View File

@ -12,9 +12,11 @@ function createService(
platform: NodeJS.Platform,
options: {
commands?: Record<string, boolean>
commandPaths?: Record<string, string | null>
paths?: Record<string, boolean>
plistValues?: Record<string, string | null>
dirNames?: Record<string, string[]>
textFiles?: Record<string, string | null>
launchResult?: { code: number; stdout: string; stderr: string }
iconData?: Uint8Array
ttlMs?: number
@ -33,7 +35,12 @@ function createService(
now: () => now.value,
commandExists: async (command) => {
commandProbes += 1
return options.commands?.[command] === true
return options.commands?.[command] === true || Boolean(options.commandPaths?.[command])
},
resolveCommand: async (command) => {
const commandPath = options.commandPaths?.[command]
if (commandPath !== undefined) return commandPath
return options.commands?.[command] === true ? command : null
},
pathExists: async (targetPath) => {
pathProbes += 1
@ -44,6 +51,7 @@ function createService(
return options.launchResult ?? { code: 0, stdout: '', stderr: '' }
},
readDirNames: async (targetPath) => options.dirNames?.[targetPath] ?? [],
readTextFile: async (targetPath) => options.textFiles?.[targetPath] ?? null,
readPlistValue: async (plistPath) => options.plistValues?.[plistPath] ?? null,
convertIconToPng: async (iconPath, size) => {
convertedIcons.push({ iconPath, size })
@ -95,6 +103,7 @@ describe('openTargetService', () => {
expect(result.targets.map((target) => target.id)).toEqual(['explorer'])
expect(result.primaryTargetId).toBe('explorer')
expect(result.targets[0]?.iconUrl).toBe('/api/open-targets/icons/explorer')
})
it('only includes the Linux file-manager fallback when xdg-open is available', async () => {
@ -236,4 +245,87 @@ describe('openTargetService', () => {
expect(state.convertedIcons).toEqual([{ iconPath: finderIcon, size: 64 }])
})
it('extracts Windows target icons from the resolved application executable', async () => {
const commandPath = 'C:\\Users\\nanmi\\AppData\\Local\\Programs\\Microsoft VS Code\\bin\\code.cmd'
const iconPath = 'C:\\Users\\nanmi\\AppData\\Local\\Programs\\Microsoft VS Code\\Code.exe'
const state = createService('win32', {
commandPaths: {
'code.cmd': commandPath,
},
paths: {
[iconPath]: true,
},
iconData: new Uint8Array([4, 5, 6]),
})
const icon = await state.service.getTargetIcon('vscode')
expect(icon.contentType).toBe('image/png')
expect(Array.from(icon.data)).toEqual([4, 5, 6])
expect(state.convertedIcons).toEqual([{ iconPath, size: 64 }])
})
it('uses the Windows Explorer executable icon for the file-manager fallback', async () => {
const explorerPath = 'C:\\Windows\\explorer.exe'
const state = createService('win32', {
commandPaths: {
'explorer.exe': explorerPath,
},
paths: {
[explorerPath]: true,
},
})
await state.service.getTargetIcon('explorer')
expect(state.convertedIcons).toEqual([{ iconPath: explorerPath, size: 64 }])
})
it('extracts Linux target icons from matching desktop entries', async () => {
const desktopPath = '/usr/share/applications/code.desktop'
const iconPath = '/usr/share/pixmaps/code.png'
const state = createService('linux', {
commands: {
code: true,
},
dirNames: {
'/usr/share/applications': ['code.desktop'],
},
textFiles: {
[desktopPath]: [
'[Desktop Entry]',
'Name=Visual Studio Code',
'Exec=/usr/bin/code --reuse-window %F',
'Icon=code',
].join('\n'),
},
paths: {
[iconPath]: true,
},
})
await state.service.getTargetIcon('vscode')
expect(state.convertedIcons).toEqual([{ iconPath, size: 64 }])
})
it('uses the Linux folder icon for the file-manager fallback when available', async () => {
const folderIcon = '/usr/share/icons/hicolor/64x64/apps/folder.png'
const state = createService('linux', {
commands: {
'xdg-open': true,
},
dirNames: {
'/usr/share/icons': ['hicolor'],
},
paths: {
[folderIcon]: true,
},
})
await state.service.getTargetIcon('file-manager')
expect(state.convertedIcons).toEqual([{ iconPath: folderIcon, size: 64 }])
})
})

View File

@ -1,7 +1,7 @@
import { execFile as execFileCallback } from 'node:child_process'
import { mkdtemp, readFile, readdir, rm, stat } from 'node:fs/promises'
import { homedir, tmpdir } from 'node:os'
import { extname, join, resolve } from 'node:path'
import { basename, extname, join, resolve, win32 as winPath } from 'node:path'
import { promisify } from 'node:util'
import { ApiError } from '../middleware/errorHandler.js'
@ -45,9 +45,11 @@ type Runtime = {
ttlMs: number
now: () => number
commandExists: (command: string) => Promise<boolean>
resolveCommand: (command: string) => Promise<string | null>
pathExists: (targetPath: string) => Promise<boolean>
launch: (command: string, args: string[]) => Promise<OpenTargetLaunchResult>
readDirNames: (targetPath: string) => Promise<string[]>
readTextFile: (targetPath: string) => Promise<string | null>
readPlistValue: (plistPath: string, key: string) => Promise<string | null>
convertIconToPng: (iconPath: string, size: number) => Promise<Uint8Array>
}
@ -66,6 +68,7 @@ type TargetDefinition = {
commands?: Partial<Record<OpenTargetPlatform, string[]>>
appPaths?: Partial<Record<OpenTargetPlatform, string[]>>
iconPaths?: Partial<Record<OpenTargetPlatform, string[]>>
windowsExecutableNames?: string[]
fallback?: boolean
}
@ -81,6 +84,7 @@ const TARGET_DEFINITIONS: TargetDefinition[] = [
win32: ['code.cmd', 'code.exe'],
linux: ['code'],
},
windowsExecutableNames: ['Code.exe'],
appPaths: {
darwin: [
'/Applications/Visual Studio Code.app',
@ -99,6 +103,7 @@ const TARGET_DEFINITIONS: TargetDefinition[] = [
win32: ['cursor.cmd', 'cursor.exe'],
linux: ['cursor'],
},
windowsExecutableNames: ['Cursor.exe'],
appPaths: {
darwin: ['/Applications/Cursor.app', join(homedir(), 'Applications', 'Cursor.app')],
},
@ -114,6 +119,7 @@ const TARGET_DEFINITIONS: TargetDefinition[] = [
win32: ['subl.exe', 'subl'],
linux: ['subl'],
},
windowsExecutableNames: ['sublime_text.exe', 'subl.exe'],
appPaths: {
darwin: ['/Applications/Sublime Text.app', join(homedir(), 'Applications', 'Sublime Text.app')],
},
@ -142,6 +148,7 @@ const TARGET_DEFINITIONS: TargetDefinition[] = [
win32: ['goland64.exe', 'goland.cmd'],
linux: ['goland'],
},
windowsExecutableNames: ['goland64.exe', 'goland.exe'],
appPaths: {
darwin: ['/Applications/GoLand.app', join(homedir(), 'Applications', 'GoLand.app')],
},
@ -157,6 +164,7 @@ const TARGET_DEFINITIONS: TargetDefinition[] = [
win32: ['pycharm64.exe', 'pycharm.cmd'],
linux: ['pycharm'],
},
windowsExecutableNames: ['pycharm64.exe', 'pycharm.exe'],
appPaths: {
darwin: ['/Applications/PyCharm.app', join(homedir(), 'Applications', 'PyCharm.app')],
},
@ -190,23 +198,59 @@ const TARGET_DEFINITIONS: TargetDefinition[] = [
},
]
const LINUX_APPLICATION_DIRS = [
'/usr/share/applications',
'/usr/local/share/applications',
join(homedir(), '.local', 'share', 'applications'),
'/var/lib/flatpak/exports/share/applications',
join(homedir(), '.local', 'share', 'flatpak', 'exports', 'share', 'applications'),
]
const LINUX_ICON_ROOTS = [
join(homedir(), '.local', 'share', 'icons'),
'/usr/local/share/icons',
'/usr/share/icons',
]
const LINUX_ICON_THEME_SUBDIRS = [
'scalable/apps',
'512x512/apps',
'256x256/apps',
'128x128/apps',
'64x64/apps',
'48x48/apps',
'32x32/apps',
'apps/scalable',
'apps/64',
'apps/48',
'apps/32',
]
function openTargetError(statusCode: number, message: string, code: string): ApiError {
return new ApiError(statusCode, message, code)
}
async function defaultCommandExists(command: string): Promise<boolean> {
async function defaultResolveCommand(command: string): Promise<string | null> {
const probe = process.platform === 'win32' ? 'where' : 'which'
try {
await execFile(probe, [command], {
const { stdout } = await execFile(probe, [command], {
timeout: 3_000,
windowsHide: true,
})
return true
const firstPath = String(stdout ?? '')
.split(/\r?\n/)
.map((line) => line.trim())
.find(Boolean)
return firstPath ?? null
} catch {
return false
return null
}
}
async function defaultCommandExists(command: string): Promise<boolean> {
return (await defaultResolveCommand(command)) !== null
}
async function defaultPathExists(targetPath: string): Promise<boolean> {
try {
const entry = await stat(targetPath)
@ -250,6 +294,14 @@ async function defaultReadDirNames(targetPath: string): Promise<string[]> {
}
}
async function defaultReadTextFile(targetPath: string): Promise<string | null> {
try {
return await readFile(targetPath, 'utf8')
} catch {
return null
}
}
async function defaultReadPlistValue(plistPath: string, key: string): Promise<string | null> {
try {
const { stdout } = await execFile('/usr/bin/plutil', [
@ -271,38 +323,120 @@ async function defaultReadPlistValue(plistPath: string, key: string): Promise<st
}
async function defaultConvertIconToPng(iconPath: string, size: number): Promise<Uint8Array> {
const extension = extname(iconPath).toLowerCase()
if (extension === '.png') {
return await readFile(iconPath)
}
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,
})
if (process.platform === 'win32') {
await convertWindowsIconToPng(iconPath, outputPath)
} else if (extension === '.svg' || extension === '.xpm') {
await convertLinuxThemeIconToPng(iconPath, outputPath, size)
} else {
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 })
}
}
async function convertWindowsIconToPng(iconPath: string, outputPath: string): Promise<void> {
const script = `
Add-Type -AssemblyName System.Drawing
$source = $env:CC_HAHA_ICON_SOURCE
$output = $env:CC_HAHA_ICON_OUTPUT
$icon = [System.Drawing.Icon]::ExtractAssociatedIcon($source)
if ($null -eq $icon) { exit 2 }
$bitmap = $icon.ToBitmap()
$bitmap.Save($output, [System.Drawing.Imaging.ImageFormat]::Png)
$bitmap.Dispose()
$icon.Dispose()
`
await execFile('powershell.exe', [
'-NoProfile',
'-NonInteractive',
'-ExecutionPolicy',
'Bypass',
'-Command',
script,
], {
env: {
...process.env,
CC_HAHA_ICON_SOURCE: iconPath,
CC_HAHA_ICON_OUTPUT: outputPath,
},
timeout: 5_000,
windowsHide: true,
})
}
async function convertLinuxThemeIconToPng(
iconPath: string,
outputPath: string,
size: number,
): Promise<void> {
const attempts: Array<{ command: string; args: string[] }> = [
{
command: 'rsvg-convert',
args: ['-w', String(size), '-h', String(size), '-o', outputPath, iconPath],
},
{
command: 'gdk-pixbuf-thumbnailer',
args: ['-s', String(size), iconPath, outputPath],
},
{
command: 'magick',
args: [iconPath, '-resize', `${size}x${size}`, outputPath],
},
{
command: 'convert',
args: [iconPath, '-resize', `${size}x${size}`, outputPath],
},
]
let lastError: unknown
for (const attempt of attempts) {
try {
await execFile(attempt.command, attempt.args, {
timeout: 5_000,
windowsHide: true,
})
return
} catch (error) {
lastError = error
}
}
throw lastError instanceof Error
? lastError
: new Error(`Unable to rasterize Linux icon: ${iconPath}`)
}
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,
iconUrl: `/api/open-targets/icons/${encodeURIComponent(definition.id)}`,
platform,
}
}
@ -439,14 +573,10 @@ async function findDarwinBundleIconPath(
return await runtime.pathExists(fallbackPath) ? fallbackPath : null
}
async function resolveIconPath(
async function resolveDarwinIconPath(
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
}
@ -460,15 +590,280 @@ async function resolveIconPath(
return null
}
async function resolveWindowsIconPath(
definition: TargetDefinition,
runtime: Runtime,
): Promise<string | null> {
if (definition.fallback && definition.id === 'explorer') {
const explorerPath = await runtime.resolveCommand('explorer.exe')
if (explorerPath) return explorerPath
}
for (const iconPath of definition.iconPaths?.win32 ?? []) {
if (await runtime.pathExists(iconPath)) return iconPath
}
for (const appPath of definition.appPaths?.win32 ?? []) {
if (await runtime.pathExists(appPath)) return appPath
}
for (const command of definition.commands?.win32 ?? []) {
const commandPath = await runtime.resolveCommand(command)
if (!commandPath) continue
const directIconPath = await resolveWindowsExecutableIconPath(commandPath, definition, runtime)
if (directIconPath) return directIconPath
}
return null
}
async function resolveWindowsExecutableIconPath(
commandPath: string,
definition: TargetDefinition,
runtime: Runtime,
): Promise<string | null> {
const extension = winPath.extname(commandPath).toLowerCase()
if ((extension === '.exe' || extension === '.ico') && await runtime.pathExists(commandPath)) {
return commandPath
}
if (extension !== '.cmd' && extension !== '.bat') {
return null
}
const executableNames = definition.windowsExecutableNames
?? definition.commands?.win32?.filter((command) => winPath.extname(command).toLowerCase() === '.exe')
?? []
let currentDir = winPath.dirname(commandPath)
for (let depth = 0; depth < 5; depth += 1) {
for (const executableName of executableNames) {
const candidate = winPath.join(currentDir, executableName)
if (await runtime.pathExists(candidate)) return candidate
}
const nextDir = winPath.dirname(currentDir)
if (!nextDir || nextDir === currentDir) break
currentDir = nextDir
}
return null
}
type LinuxDesktopEntry = {
filePath: string
name: string | null
exec: string | null
icon: string | null
}
async function resolveLinuxIconPath(
definition: TargetDefinition,
runtime: Runtime,
): Promise<string | null> {
for (const iconPath of definition.iconPaths?.linux ?? []) {
if (await runtime.pathExists(iconPath)) return iconPath
}
const desktopEntries = definition.fallback
? []
: await findLinuxDesktopEntries(definition, runtime)
for (const desktopEntry of desktopEntries) {
if (!desktopEntry.icon) continue
const iconPath = await resolveLinuxIconName(desktopEntry.icon, runtime)
if (iconPath) return iconPath
}
if (definition.fallback && definition.kind === 'file_manager') {
return await resolveLinuxIconName('folder', runtime)
?? await resolveLinuxIconName('system-file-manager', runtime)
?? await resolveLinuxIconName('org.gnome.Nautilus', runtime)
}
return null
}
async function findLinuxDesktopEntries(
definition: TargetDefinition,
runtime: Runtime,
): Promise<LinuxDesktopEntry[]> {
const matches: LinuxDesktopEntry[] = []
for (const directory of LINUX_APPLICATION_DIRS) {
const fileNames = await runtime.readDirNames(directory)
for (const fileName of fileNames) {
if (!fileName.endsWith('.desktop')) continue
const filePath = join(directory, fileName)
const text = await runtime.readTextFile(filePath)
if (!text) continue
const entry = parseLinuxDesktopEntry(filePath, text)
if (entry && linuxDesktopEntryMatchesDefinition(entry, fileName, definition)) {
matches.push(entry)
}
}
}
return matches
}
function parseLinuxDesktopEntry(filePath: string, text: string): LinuxDesktopEntry | null {
let inDesktopEntry = false
const values = new Map<string, string>()
for (const rawLine of text.split(/\r?\n/)) {
const line = rawLine.trim()
if (!line || line.startsWith('#')) continue
if (line.startsWith('[') && line.endsWith(']')) {
inDesktopEntry = line === '[Desktop Entry]'
continue
}
if (!inDesktopEntry) continue
const equalsIndex = line.indexOf('=')
if (equalsIndex <= 0) continue
const rawKey = line.slice(0, equalsIndex).trim()
const key = rawKey.replace(/\[.*\]$/, '')
if (key !== 'Name' && key !== 'Exec' && key !== 'Icon') continue
values.set(key, line.slice(equalsIndex + 1).trim())
}
if (!values.has('Exec') && !values.has('Icon')) return null
return {
filePath,
name: values.get('Name') ?? null,
exec: values.get('Exec') ?? null,
icon: values.get('Icon') ?? null,
}
}
function linuxDesktopEntryMatchesDefinition(
entry: LinuxDesktopEntry,
fileName: string,
definition: TargetDefinition,
): boolean {
const commandNames = new Set(
(definition.commands?.linux ?? []).map((command) => basename(command).toLowerCase()),
)
const normalizedNeedles = [
definition.id,
definition.icon,
definition.label,
...commandNames,
].map(normalizeLinuxDesktopSearchText)
const normalizedFileName = normalizeLinuxDesktopSearchText(fileName)
const normalizedName = normalizeLinuxDesktopSearchText(entry.name ?? '')
if (normalizedNeedles.some((needle) => needle && (
normalizedFileName.includes(needle) || normalizedName.includes(needle)
))) {
return true
}
const execCommand = entry.exec ? extractLinuxDesktopExecCommand(entry.exec) : null
return execCommand ? commandNames.has(execCommand) : false
}
function normalizeLinuxDesktopSearchText(value: string): string {
return value.toLowerCase().replace(/[^a-z0-9]+/g, '')
}
function extractLinuxDesktopExecCommand(execValue: string): string | null {
const tokens = execValue
.replace(/%[a-zA-Z]/g, '')
.match(/"([^"]+)"|'([^']+)'|(\S+)/g)
?.map((token) => token.replace(/^['"]|['"]$/g, '')) ?? []
for (const token of tokens) {
if (!token || token.includes('=')) continue
const command = basename(token).toLowerCase()
if (command === 'env') continue
return command
}
return null
}
async function resolveLinuxIconName(
iconName: string,
runtime: Runtime,
): Promise<string | null> {
const trimmed = iconName.trim()
if (!trimmed) return null
if (trimmed.startsWith('/') && await runtime.pathExists(trimmed)) {
return trimmed
}
const extension = extname(trimmed)
const baseName = extension ? trimmed.slice(0, -extension.length) : trimmed
const extensions = extension ? [extension] : ['.png', '.svg', '.xpm']
const directRoots = [
'/usr/share/pixmaps',
'/usr/local/share/pixmaps',
join(homedir(), '.local', 'share', 'pixmaps'),
]
for (const root of directRoots) {
for (const candidateExtension of extensions) {
const candidate = join(root, `${baseName}${candidateExtension}`)
if (await runtime.pathExists(candidate)) return candidate
}
}
for (const root of LINUX_ICON_ROOTS) {
const themeNames = await runtime.readDirNames(root)
for (const themeName of themeNames) {
for (const subdir of LINUX_ICON_THEME_SUBDIRS) {
for (const candidateExtension of extensions) {
const candidate = join(root, themeName, subdir, `${baseName}${candidateExtension}`)
if (await runtime.pathExists(candidate)) return candidate
}
}
}
}
return null
}
async function resolveIconPath(
definition: TargetDefinition,
runtime: Runtime,
): Promise<string | null> {
if (!isSupportedOnPlatform(definition, runtime.platform)) {
return null
}
switch (runtime.platform) {
case 'darwin':
return resolveDarwinIconPath(definition, runtime)
case 'win32':
return resolveWindowsIconPath(definition, runtime)
case 'linux':
return resolveLinuxIconPath(definition, runtime)
default:
return null
}
}
export function createOpenTargetService(overrides: Partial<Runtime> = {}) {
const runtime: Runtime = {
platform: overrides.platform ?? process.platform,
ttlMs: overrides.ttlMs ?? DEFAULT_TTL_MS,
now: overrides.now ?? Date.now,
commandExists: overrides.commandExists ?? defaultCommandExists,
resolveCommand: overrides.resolveCommand ?? defaultResolveCommand,
pathExists: overrides.pathExists ?? defaultPathExists,
launch: overrides.launch ?? defaultLaunch,
readDirNames: overrides.readDirNames ?? defaultReadDirNames,
readTextFile: overrides.readTextFile ?? defaultReadTextFile,
readPlistValue: overrides.readPlistValue ?? defaultReadPlistValue,
convertIconToPng: overrides.convertIconToPng ?? defaultConvertIconToPng,
}