Download src/server/services/openTargetService.ts from chenbhao/codev: direct link, hf CLI and curl.
- Browser
- Download file 28.5 kB
-
https://huggingface.co/chenbhao/codev/resolve/main/src/server/services/openTargetService.ts
- Command line
-
hf download hf://chenbhao/codev/src/server/services/openTargetService.ts
-
curl -L -o openTargetService.ts https://huggingface.co/chenbhao/codev/resolve/main/src/server/services/openTargetService.ts
28.5 kB
| import { execFile as execFileCallback, spawn } from 'node:child_process' | |
| import { mkdtemp, readFile, readdir, rm, stat } from 'node:fs/promises' | |
| import { homedir, tmpdir } from 'node:os' | |
| import { extname, join, posix as posixPath, resolve, win32 as winPath } from 'node:path' | |
| import { promisify } from 'node:util' | |
| import { ApiError } from '../middleware/errorHandler.js' | |
| const execFile = promisify(execFileCallback) | |
| const DEFAULT_TTL_MS = 30_000 | |
| export type OpenTargetPlatform = NodeJS.Platform | |
| export type OpenTargetKind = 'ide' | 'file_manager' | |
| export type OpenTarget = { | |
| id: string | |
| kind: OpenTargetKind | |
| label: string | |
| icon: string | |
| iconUrl?: string | |
| platform: OpenTargetPlatform | |
| } | |
| export type OpenTargetList = { | |
| platform: OpenTargetPlatform | |
| targets: OpenTarget[] | |
| primaryTargetId: string | null | |
| cachedAt: number | |
| ttlMs: number | |
| } | |
| export type OpenTargetLaunchResult = { | |
| code: number | |
| stdout: string | |
| stderr: string | |
| } | |
| export type OpenTargetIconResult = { | |
| contentType: 'image/png' | |
| data: Uint8Array | |
| } | |
| type Runtime = { | |
| platform: OpenTargetPlatform | |
| 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> | |
| } | |
| type LaunchPlan = { | |
| command: string | |
| args: string[] | |
| } | |
| type TargetDefinition = { | |
| id: string | |
| kind: OpenTargetKind | |
| label: string | |
| icon: string | |
| platforms: OpenTargetPlatform[] | |
| commands?: Partial<Record<OpenTargetPlatform, string[]>> | |
| appPaths?: Partial<Record<OpenTargetPlatform, string[]>> | |
| iconPaths?: Partial<Record<OpenTargetPlatform, string[]>> | |
| windowsExecutableNames?: string[] | |
| fallback?: boolean | |
| } | |
| const TARGET_DEFINITIONS: TargetDefinition[] = [ | |
| { | |
| id: 'vscode', | |
| kind: 'ide', | |
| label: 'VS Code', | |
| icon: 'vscode', | |
| platforms: ['darwin', 'win32', 'linux'], | |
| commands: { | |
| darwin: ['code'], | |
| win32: ['code.cmd', 'code.exe'], | |
| linux: ['code'], | |
| }, | |
| windowsExecutableNames: ['Code.exe'], | |
| appPaths: { | |
| darwin: [ | |
| '/Applications/Visual Studio Code.app', | |
| posixPath.join(homedir(), 'Applications', 'Visual Studio Code.app'), | |
| ], | |
| }, | |
| }, | |
| { | |
| id: 'cursor', | |
| kind: 'ide', | |
| label: 'Cursor', | |
| icon: 'cursor', | |
| platforms: ['darwin', 'win32', 'linux'], | |
| commands: { | |
| darwin: ['cursor'], | |
| win32: ['cursor.cmd', 'cursor.exe'], | |
| linux: ['cursor'], | |
| }, | |
| windowsExecutableNames: ['Cursor.exe'], | |
| appPaths: { | |
| darwin: ['/Applications/Cursor.app', posixPath.join(homedir(), 'Applications', 'Cursor.app')], | |
| }, | |
| }, | |
| { | |
| id: 'sublime', | |
| kind: 'ide', | |
| label: 'Sublime Text', | |
| icon: 'sublime', | |
| platforms: ['darwin', 'win32', 'linux'], | |
| commands: { | |
| darwin: ['subl'], | |
| win32: ['subl.exe', 'subl'], | |
| linux: ['subl'], | |
| }, | |
| windowsExecutableNames: ['sublime_text.exe', 'subl.exe'], | |
| appPaths: { | |
| darwin: ['/Applications/Sublime Text.app', posixPath.join(homedir(), 'Applications', 'Sublime Text.app')], | |
| }, | |
| }, | |
| { | |
| id: 'antigravity', | |
| kind: 'ide', | |
| label: 'Antigravity', | |
| icon: 'antigravity', | |
| platforms: ['darwin'], | |
| commands: { | |
| darwin: ['antigravity'], | |
| }, | |
| appPaths: { | |
| darwin: ['/Applications/Antigravity.app', posixPath.join(homedir(), 'Applications', 'Antigravity.app')], | |
| }, | |
| }, | |
| { | |
| id: 'goland', | |
| kind: 'ide', | |
| label: 'GoLand', | |
| icon: 'goland', | |
| platforms: ['darwin', 'win32', 'linux'], | |
| commands: { | |
| darwin: ['goland'], | |
| win32: ['goland64.exe', 'goland.cmd'], | |
| linux: ['goland'], | |
| }, | |
| windowsExecutableNames: ['goland64.exe', 'goland.exe'], | |
| appPaths: { | |
| darwin: ['/Applications/GoLand.app', posixPath.join(homedir(), 'Applications', 'GoLand.app')], | |
| }, | |
| }, | |
| { | |
| id: 'pycharm', | |
| kind: 'ide', | |
| label: 'PyCharm', | |
| icon: 'pycharm', | |
| platforms: ['darwin', 'win32', 'linux'], | |
| commands: { | |
| darwin: ['pycharm'], | |
| win32: ['pycharm64.exe', 'pycharm.cmd'], | |
| linux: ['pycharm'], | |
| }, | |
| windowsExecutableNames: ['pycharm64.exe', 'pycharm.exe'], | |
| appPaths: { | |
| darwin: ['/Applications/PyCharm.app', posixPath.join(homedir(), 'Applications', 'PyCharm.app')], | |
| }, | |
| }, | |
| { | |
| id: 'finder', | |
| kind: 'file_manager', | |
| label: 'Finder', | |
| icon: 'finder', | |
| platforms: ['darwin'], | |
| iconPaths: { | |
| darwin: ['/System/Library/CoreServices/Finder.app/Contents/Resources/Finder.icns'], | |
| }, | |
| fallback: true, | |
| }, | |
| { | |
| id: 'explorer', | |
| kind: 'file_manager', | |
| label: 'Explorer', | |
| icon: 'folder', | |
| platforms: ['win32'], | |
| fallback: true, | |
| }, | |
| { | |
| id: 'file-manager', | |
| kind: 'file_manager', | |
| label: 'File Manager', | |
| icon: 'folder', | |
| platforms: ['linux'], | |
| fallback: true, | |
| }, | |
| ] | |
| const LINUX_APPLICATION_DIRS = [ | |
| '/usr/share/applications', | |
| '/usr/local/share/applications', | |
| posixPath.join(homedir(), '.local', 'share', 'applications'), | |
| '/var/lib/flatpak/exports/share/applications', | |
| posixPath.join(homedir(), '.local', 'share', 'flatpak', 'exports', 'share', 'applications'), | |
| ] | |
| const LINUX_ICON_ROOTS = [ | |
| posixPath.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 defaultResolveCommand(command: string): Promise<string | null> { | |
| const probe = process.platform === 'win32' ? 'where' : 'which' | |
| try { | |
| const { stdout } = await execFile(probe, [command], { | |
| timeout: 3_000, | |
| windowsHide: true, | |
| }) | |
| const firstPath = String(stdout ?? '') | |
| .split(/\r?\n/) | |
| .map((line) => line.trim()) | |
| .find(Boolean) | |
| return firstPath ?? null | |
| } catch { | |
| 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) | |
| return entry.isFile() || entry.isDirectory() | |
| } catch { | |
| return false | |
| } | |
| } | |
| async function defaultLaunch(command: string, args: string[]): Promise<OpenTargetLaunchResult> { | |
| return await new Promise((resolveLaunch) => { | |
| let settled = false | |
| const settle = (result: OpenTargetLaunchResult) => { | |
| if (settled) return | |
| settled = true | |
| resolveLaunch(result) | |
| } | |
| try { | |
| const child = spawn(command, args, { | |
| detached: true, | |
| stdio: 'ignore', | |
| windowsHide: true, | |
| }) | |
| child.once('error', (error) => { | |
| settle({ | |
| code: 1, | |
| stdout: '', | |
| stderr: error.message, | |
| }) | |
| }) | |
| child.once('spawn', () => { | |
| child.unref() | |
| settle({ | |
| code: 0, | |
| stdout: '', | |
| stderr: '', | |
| }) | |
| }) | |
| } catch (error) { | |
| const err = error as { message?: string } | |
| settle({ | |
| code: 1, | |
| stdout: '', | |
| stderr: String(err.message ?? error), | |
| }) | |
| } | |
| }) | |
| } | |
| async function resolveWindowsApplicationPath( | |
| definition: TargetDefinition, | |
| runtime: Runtime, | |
| ): Promise<string | null> { | |
| 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 executablePath = await resolveWindowsExecutablePath(commandPath, definition, runtime) | |
| if (executablePath) return executablePath | |
| } | |
| return null | |
| } | |
| async function resolveWindowsExecutablePath( | |
| commandPath: string, | |
| definition: TargetDefinition, | |
| runtime: Runtime, | |
| ): Promise<string | null> { | |
| const extension = winPath.extname(commandPath).toLowerCase() | |
| if (extension === '.exe' && 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 | |
| } | |
| async function defaultReadDirNames(targetPath: string): Promise<string[]> { | |
| try { | |
| return await readdir(targetPath) | |
| } catch { | |
| return [] | |
| } | |
| } | |
| 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', [ | |
| '-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 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 { | |
| 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: `/api/open-targets/icons/${encodeURIComponent(definition.id)}`, | |
| platform, | |
| } | |
| } | |
| function isSupportedOnPlatform(definition: TargetDefinition, platform: OpenTargetPlatform): boolean { | |
| return definition.platforms.includes(platform) | |
| } | |
| async function isDetected(definition: TargetDefinition, runtime: Runtime): Promise<boolean> { | |
| if (!isSupportedOnPlatform(definition, runtime.platform)) { | |
| return false | |
| } | |
| if (definition.fallback) { | |
| if (runtime.platform === 'linux') { | |
| return runtime.commandExists('xdg-open') | |
| } | |
| return true | |
| } | |
| if (runtime.platform === 'darwin' && definition.appPaths?.darwin?.length) { | |
| for (const appPath of definition.appPaths.darwin) { | |
| if (await runtime.pathExists(appPath)) { | |
| return true | |
| } | |
| } | |
| return false | |
| } | |
| if (runtime.platform === 'win32') { | |
| return (await resolveWindowsApplicationPath(definition, runtime)) !== null | |
| } | |
| for (const appPath of definition.appPaths?.[runtime.platform] ?? []) { | |
| if (await runtime.pathExists(appPath)) { | |
| return true | |
| } | |
| } | |
| for (const command of definition.commands?.[runtime.platform] ?? []) { | |
| if (await runtime.commandExists(command)) { | |
| return true | |
| } | |
| } | |
| return false | |
| } | |
| async function resolveLaunchPlan( | |
| definition: TargetDefinition, | |
| runtime: Runtime, | |
| targetPath: string, | |
| ): Promise<LaunchPlan | null> { | |
| if (!isSupportedOnPlatform(definition, runtime.platform)) { | |
| return null | |
| } | |
| if (definition.fallback) { | |
| switch (runtime.platform) { | |
| case 'darwin': | |
| return { command: 'open', args: [targetPath] } | |
| case 'win32': | |
| return { command: 'cmd.exe', args: ['/d', '/c', 'start', '', targetPath] } | |
| case 'linux': | |
| return { command: 'xdg-open', args: [targetPath] } | |
| default: | |
| return null | |
| } | |
| } | |
| if (runtime.platform === 'darwin') { | |
| for (const appPath of definition.appPaths?.darwin ?? []) { | |
| if (await runtime.pathExists(appPath)) { | |
| return { command: 'open', args: ['-a', appPath, targetPath] } | |
| } | |
| } | |
| } | |
| if (runtime.platform === 'win32') { | |
| const applicationPath = await resolveWindowsApplicationPath(definition, runtime) | |
| return applicationPath ? { command: applicationPath, args: [targetPath] } : null | |
| } | |
| for (const command of definition.commands?.[runtime.platform] ?? []) { | |
| if (await runtime.commandExists(command)) { | |
| return { command, args: [targetPath] } | |
| } | |
| } | |
| return null | |
| } | |
| async function validateDirectory(targetPath: string): Promise<string> { | |
| const resolvedPath = resolve(targetPath) | |
| let entry | |
| try { | |
| entry = await stat(resolvedPath) | |
| } catch { | |
| throw openTargetError( | |
| 400, | |
| `Directory does not exist: ${resolvedPath}`, | |
| 'OPEN_TARGET_PATH_MISSING', | |
| ) | |
| } | |
| if (!entry.isDirectory()) { | |
| throw openTargetError( | |
| 400, | |
| `Path is not a directory: ${resolvedPath}`, | |
| 'OPEN_TARGET_PATH_NOT_DIRECTORY', | |
| ) | |
| } | |
| 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 = posixPath.join(appPath, 'Contents', 'Resources') | |
| const plistPath = posixPath.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 = posixPath.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 = posixPath.join(resourcesPath, firstIcon) | |
| return await runtime.pathExists(fallbackPath) ? fallbackPath : null | |
| } | |
| async function resolveDarwinIconPath( | |
| definition: TargetDefinition, | |
| runtime: Runtime, | |
| ): Promise<string | 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 | |
| } | |
| 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 | |
| } | |
| const applicationPath = await resolveWindowsApplicationPath(definition, runtime) | |
| if (applicationPath) return applicationPath | |
| 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 = posixPath.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) => posixPath.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 = posixPath.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', | |
| posixPath.join(homedir(), '.local', 'share', 'pixmaps'), | |
| ] | |
| for (const root of directRoots) { | |
| for (const candidateExtension of extensions) { | |
| const candidate = posixPath.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 = posixPath.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, | |
| } | |
| 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) { | |
| return cache | |
| } | |
| const targets: OpenTarget[] = [] | |
| for (const definition of TARGET_DEFINITIONS) { | |
| if (await isDetected(definition, runtime)) { | |
| targets.push(buildOpenTarget(definition, runtime.platform)) | |
| } | |
| } | |
| cache = { | |
| platform: runtime.platform, | |
| targets, | |
| primaryTargetId: targets[0]?.id ?? null, | |
| cachedAt: runtime.now(), | |
| ttlMs: runtime.ttlMs, | |
| } | |
| return cache | |
| } | |
| async function openTarget(input: { targetId: string; path: string }) { | |
| const definition = TARGET_DEFINITIONS.find((candidate) => candidate.id === input.targetId) | |
| if (!definition) { | |
| throw openTargetError( | |
| 400, | |
| `Unknown open target: ${input.targetId}`, | |
| 'OPEN_TARGET_UNKNOWN', | |
| ) | |
| } | |
| const targets = await listTargets() | |
| const target = targets.targets.find((candidate) => candidate.id === input.targetId) | |
| if (!target) { | |
| throw openTargetError( | |
| 400, | |
| `Open target is not available on ${runtime.platform}: ${input.targetId}`, | |
| 'OPEN_TARGET_UNAVAILABLE', | |
| ) | |
| } | |
| const resolvedPath = await validateDirectory(input.path) | |
| const launchPlan = await resolveLaunchPlan(definition, runtime, resolvedPath) | |
| if (!launchPlan) { | |
| throw openTargetError( | |
| 400, | |
| `Unable to launch open target: ${input.targetId}`, | |
| 'OPEN_TARGET_UNAVAILABLE', | |
| ) | |
| } | |
| const launchResult = await runtime.launch(launchPlan.command, launchPlan.args) | |
| if (launchResult.code !== 0) { | |
| throw openTargetError( | |
| 500, | |
| `Failed to launch open target: ${input.targetId}`, | |
| 'OPEN_TARGET_LAUNCH_FAILED', | |
| ) | |
| } | |
| return { | |
| ok: true as const, | |
| targetId: target.id, | |
| path: resolvedPath, | |
| } | |
| } | |
| 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, | |
| } | |
| } | |
| export const openTargetService = createOpenTargetService() | |