cc-haha/src/server/services/openTargetService.ts
2026-05-16 21:17:39 +08:00

1031 lines
28 KiB
TypeScript

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()