diff --git a/src/server/__tests__/open-target-service.test.ts b/src/server/__tests__/open-target-service.test.ts index 884452fb..3815bd19 100644 --- a/src/server/__tests__/open-target-service.test.ts +++ b/src/server/__tests__/open-target-service.test.ts @@ -12,9 +12,11 @@ function createService( platform: NodeJS.Platform, options: { commands?: Record + commandPaths?: Record paths?: Record plistValues?: Record dirNames?: Record + textFiles?: Record 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 }]) + }) }) diff --git a/src/server/services/openTargetService.ts b/src/server/services/openTargetService.ts index 8914f924..43ca7573 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 { 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 + resolveCommand: (command: string) => Promise pathExists: (targetPath: string) => Promise launch: (command: string, args: string[]) => Promise readDirNames: (targetPath: string) => Promise + readTextFile: (targetPath: string) => Promise readPlistValue: (plistPath: string, key: string) => Promise convertIconToPng: (iconPath: string, size: number) => Promise } @@ -66,6 +68,7 @@ type TargetDefinition = { commands?: Partial> appPaths?: Partial> iconPaths?: Partial> + 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 { +async function defaultResolveCommand(command: string): Promise { 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 { + return (await defaultResolveCommand(command)) !== null +} + async function defaultPathExists(targetPath: string): Promise { try { const entry = await stat(targetPath) @@ -250,6 +294,14 @@ async function defaultReadDirNames(targetPath: string): Promise { } } +async function defaultReadTextFile(targetPath: string): Promise { + try { + return await readFile(targetPath, 'utf8') + } catch { + return null + } +} + async function defaultReadPlistValue(plistPath: string, key: string): Promise { try { const { stdout } = await execFile('/usr/bin/plutil', [ @@ -271,38 +323,120 @@ async function defaultReadPlistValue(plistPath: string, key: string): Promise { + 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 { + 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 { + 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 { - 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 { + 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 { + 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 { + 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 { + 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() + + 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 { + 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 { + 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 = {}) { 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, }