mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-19 13:33:35 +08:00
1031 lines
28 KiB
TypeScript
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()
|