mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
Extract project opener icons cross-platform
Windows and Linux need to follow the same product rule as macOS: show icons from the user's installed applications instead of maintaining bundled brand artwork. The opener service now resolves command paths for Windows, walks from launcher scripts to the real IDE executable, extracts associated executable icons through the existing PNG endpoint, and resolves Linux .desktop Icon entries through common XDG application and icon theme locations. Constraint: Do not redistribute third-party IDE trademark assets in the app bundle. Constraint: Keep the frontend platform-agnostic by preserving the existing iconUrl contract. Rejected: Add static per-IDE icon assets | impossible to keep complete and creates unnecessary trademark redistribution risk. Confidence: medium Scope-risk: moderate Directive: Prefer platform installation metadata over bundled brand art when adding more opener targets. Tested: bun test src/server/__tests__/open-target-service.test.ts src/server/__tests__/open-target-api.test.ts Tested: cd desktop && bun run test -- src/stores/openTargetStore.test.ts src/components/layout/OpenProjectMenu.test.tsx src/components/layout/TabBar.test.tsx Tested: cd desktop && bun run lint Tested: bun run check:server Not-tested: Native Windows and Linux VM smoke tests for real installed IDE icons
This commit is contained in:
parent
1182c5d9c3
commit
f9ac6049de
@ -12,9 +12,11 @@ function createService(
|
||||
platform: NodeJS.Platform,
|
||||
options: {
|
||||
commands?: Record<string, boolean>
|
||||
commandPaths?: Record<string, string | null>
|
||||
paths?: Record<string, boolean>
|
||||
plistValues?: Record<string, string | null>
|
||||
dirNames?: Record<string, string[]>
|
||||
textFiles?: Record<string, string | null>
|
||||
launchResult?: { code: number; stdout: string; stderr: string }
|
||||
iconData?: Uint8Array
|
||||
ttlMs?: number
|
||||
@ -33,7 +35,12 @@ function createService(
|
||||
now: () => now.value,
|
||||
commandExists: async (command) => {
|
||||
commandProbes += 1
|
||||
return options.commands?.[command] === true
|
||||
return options.commands?.[command] === true || Boolean(options.commandPaths?.[command])
|
||||
},
|
||||
resolveCommand: async (command) => {
|
||||
const commandPath = options.commandPaths?.[command]
|
||||
if (commandPath !== undefined) return commandPath
|
||||
return options.commands?.[command] === true ? command : null
|
||||
},
|
||||
pathExists: async (targetPath) => {
|
||||
pathProbes += 1
|
||||
@ -44,6 +51,7 @@ function createService(
|
||||
return options.launchResult ?? { code: 0, stdout: '', stderr: '' }
|
||||
},
|
||||
readDirNames: async (targetPath) => options.dirNames?.[targetPath] ?? [],
|
||||
readTextFile: async (targetPath) => options.textFiles?.[targetPath] ?? null,
|
||||
readPlistValue: async (plistPath) => options.plistValues?.[plistPath] ?? null,
|
||||
convertIconToPng: async (iconPath, size) => {
|
||||
convertedIcons.push({ iconPath, size })
|
||||
@ -95,6 +103,7 @@ describe('openTargetService', () => {
|
||||
|
||||
expect(result.targets.map((target) => target.id)).toEqual(['explorer'])
|
||||
expect(result.primaryTargetId).toBe('explorer')
|
||||
expect(result.targets[0]?.iconUrl).toBe('/api/open-targets/icons/explorer')
|
||||
})
|
||||
|
||||
it('only includes the Linux file-manager fallback when xdg-open is available', async () => {
|
||||
@ -236,4 +245,87 @@ describe('openTargetService', () => {
|
||||
|
||||
expect(state.convertedIcons).toEqual([{ iconPath: finderIcon, size: 64 }])
|
||||
})
|
||||
|
||||
it('extracts Windows target icons from the resolved application executable', async () => {
|
||||
const commandPath = 'C:\\Users\\nanmi\\AppData\\Local\\Programs\\Microsoft VS Code\\bin\\code.cmd'
|
||||
const iconPath = 'C:\\Users\\nanmi\\AppData\\Local\\Programs\\Microsoft VS Code\\Code.exe'
|
||||
const state = createService('win32', {
|
||||
commandPaths: {
|
||||
'code.cmd': commandPath,
|
||||
},
|
||||
paths: {
|
||||
[iconPath]: true,
|
||||
},
|
||||
iconData: new Uint8Array([4, 5, 6]),
|
||||
})
|
||||
|
||||
const icon = await state.service.getTargetIcon('vscode')
|
||||
|
||||
expect(icon.contentType).toBe('image/png')
|
||||
expect(Array.from(icon.data)).toEqual([4, 5, 6])
|
||||
expect(state.convertedIcons).toEqual([{ iconPath, size: 64 }])
|
||||
})
|
||||
|
||||
it('uses the Windows Explorer executable icon for the file-manager fallback', async () => {
|
||||
const explorerPath = 'C:\\Windows\\explorer.exe'
|
||||
const state = createService('win32', {
|
||||
commandPaths: {
|
||||
'explorer.exe': explorerPath,
|
||||
},
|
||||
paths: {
|
||||
[explorerPath]: true,
|
||||
},
|
||||
})
|
||||
|
||||
await state.service.getTargetIcon('explorer')
|
||||
|
||||
expect(state.convertedIcons).toEqual([{ iconPath: explorerPath, size: 64 }])
|
||||
})
|
||||
|
||||
it('extracts Linux target icons from matching desktop entries', async () => {
|
||||
const desktopPath = '/usr/share/applications/code.desktop'
|
||||
const iconPath = '/usr/share/pixmaps/code.png'
|
||||
const state = createService('linux', {
|
||||
commands: {
|
||||
code: true,
|
||||
},
|
||||
dirNames: {
|
||||
'/usr/share/applications': ['code.desktop'],
|
||||
},
|
||||
textFiles: {
|
||||
[desktopPath]: [
|
||||
'[Desktop Entry]',
|
||||
'Name=Visual Studio Code',
|
||||
'Exec=/usr/bin/code --reuse-window %F',
|
||||
'Icon=code',
|
||||
].join('\n'),
|
||||
},
|
||||
paths: {
|
||||
[iconPath]: true,
|
||||
},
|
||||
})
|
||||
|
||||
await state.service.getTargetIcon('vscode')
|
||||
|
||||
expect(state.convertedIcons).toEqual([{ iconPath, size: 64 }])
|
||||
})
|
||||
|
||||
it('uses the Linux folder icon for the file-manager fallback when available', async () => {
|
||||
const folderIcon = '/usr/share/icons/hicolor/64x64/apps/folder.png'
|
||||
const state = createService('linux', {
|
||||
commands: {
|
||||
'xdg-open': true,
|
||||
},
|
||||
dirNames: {
|
||||
'/usr/share/icons': ['hicolor'],
|
||||
},
|
||||
paths: {
|
||||
[folderIcon]: true,
|
||||
},
|
||||
})
|
||||
|
||||
await state.service.getTargetIcon('file-manager')
|
||||
|
||||
expect(state.convertedIcons).toEqual([{ iconPath: folderIcon, size: 64 }])
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { execFile as execFileCallback } from 'node:child_process'
|
||||
import { mkdtemp, readFile, readdir, rm, stat } from 'node:fs/promises'
|
||||
import { homedir, tmpdir } from 'node:os'
|
||||
import { extname, join, resolve } from 'node:path'
|
||||
import { basename, extname, join, resolve, win32 as winPath } from 'node:path'
|
||||
import { promisify } from 'node:util'
|
||||
import { ApiError } from '../middleware/errorHandler.js'
|
||||
|
||||
@ -45,9 +45,11 @@ type Runtime = {
|
||||
ttlMs: number
|
||||
now: () => number
|
||||
commandExists: (command: string) => Promise<boolean>
|
||||
resolveCommand: (command: string) => Promise<string | null>
|
||||
pathExists: (targetPath: string) => Promise<boolean>
|
||||
launch: (command: string, args: string[]) => Promise<OpenTargetLaunchResult>
|
||||
readDirNames: (targetPath: string) => Promise<string[]>
|
||||
readTextFile: (targetPath: string) => Promise<string | null>
|
||||
readPlistValue: (plistPath: string, key: string) => Promise<string | null>
|
||||
convertIconToPng: (iconPath: string, size: number) => Promise<Uint8Array>
|
||||
}
|
||||
@ -66,6 +68,7 @@ type TargetDefinition = {
|
||||
commands?: Partial<Record<OpenTargetPlatform, string[]>>
|
||||
appPaths?: Partial<Record<OpenTargetPlatform, string[]>>
|
||||
iconPaths?: Partial<Record<OpenTargetPlatform, string[]>>
|
||||
windowsExecutableNames?: string[]
|
||||
fallback?: boolean
|
||||
}
|
||||
|
||||
@ -81,6 +84,7 @@ const TARGET_DEFINITIONS: TargetDefinition[] = [
|
||||
win32: ['code.cmd', 'code.exe'],
|
||||
linux: ['code'],
|
||||
},
|
||||
windowsExecutableNames: ['Code.exe'],
|
||||
appPaths: {
|
||||
darwin: [
|
||||
'/Applications/Visual Studio Code.app',
|
||||
@ -99,6 +103,7 @@ const TARGET_DEFINITIONS: TargetDefinition[] = [
|
||||
win32: ['cursor.cmd', 'cursor.exe'],
|
||||
linux: ['cursor'],
|
||||
},
|
||||
windowsExecutableNames: ['Cursor.exe'],
|
||||
appPaths: {
|
||||
darwin: ['/Applications/Cursor.app', join(homedir(), 'Applications', 'Cursor.app')],
|
||||
},
|
||||
@ -114,6 +119,7 @@ const TARGET_DEFINITIONS: TargetDefinition[] = [
|
||||
win32: ['subl.exe', 'subl'],
|
||||
linux: ['subl'],
|
||||
},
|
||||
windowsExecutableNames: ['sublime_text.exe', 'subl.exe'],
|
||||
appPaths: {
|
||||
darwin: ['/Applications/Sublime Text.app', join(homedir(), 'Applications', 'Sublime Text.app')],
|
||||
},
|
||||
@ -142,6 +148,7 @@ const TARGET_DEFINITIONS: TargetDefinition[] = [
|
||||
win32: ['goland64.exe', 'goland.cmd'],
|
||||
linux: ['goland'],
|
||||
},
|
||||
windowsExecutableNames: ['goland64.exe', 'goland.exe'],
|
||||
appPaths: {
|
||||
darwin: ['/Applications/GoLand.app', join(homedir(), 'Applications', 'GoLand.app')],
|
||||
},
|
||||
@ -157,6 +164,7 @@ const TARGET_DEFINITIONS: TargetDefinition[] = [
|
||||
win32: ['pycharm64.exe', 'pycharm.cmd'],
|
||||
linux: ['pycharm'],
|
||||
},
|
||||
windowsExecutableNames: ['pycharm64.exe', 'pycharm.exe'],
|
||||
appPaths: {
|
||||
darwin: ['/Applications/PyCharm.app', join(homedir(), 'Applications', 'PyCharm.app')],
|
||||
},
|
||||
@ -190,23 +198,59 @@ const TARGET_DEFINITIONS: TargetDefinition[] = [
|
||||
},
|
||||
]
|
||||
|
||||
const LINUX_APPLICATION_DIRS = [
|
||||
'/usr/share/applications',
|
||||
'/usr/local/share/applications',
|
||||
join(homedir(), '.local', 'share', 'applications'),
|
||||
'/var/lib/flatpak/exports/share/applications',
|
||||
join(homedir(), '.local', 'share', 'flatpak', 'exports', 'share', 'applications'),
|
||||
]
|
||||
|
||||
const LINUX_ICON_ROOTS = [
|
||||
join(homedir(), '.local', 'share', 'icons'),
|
||||
'/usr/local/share/icons',
|
||||
'/usr/share/icons',
|
||||
]
|
||||
|
||||
const LINUX_ICON_THEME_SUBDIRS = [
|
||||
'scalable/apps',
|
||||
'512x512/apps',
|
||||
'256x256/apps',
|
||||
'128x128/apps',
|
||||
'64x64/apps',
|
||||
'48x48/apps',
|
||||
'32x32/apps',
|
||||
'apps/scalable',
|
||||
'apps/64',
|
||||
'apps/48',
|
||||
'apps/32',
|
||||
]
|
||||
|
||||
function openTargetError(statusCode: number, message: string, code: string): ApiError {
|
||||
return new ApiError(statusCode, message, code)
|
||||
}
|
||||
|
||||
async function defaultCommandExists(command: string): Promise<boolean> {
|
||||
async function defaultResolveCommand(command: string): Promise<string | null> {
|
||||
const probe = process.platform === 'win32' ? 'where' : 'which'
|
||||
try {
|
||||
await execFile(probe, [command], {
|
||||
const { stdout } = await execFile(probe, [command], {
|
||||
timeout: 3_000,
|
||||
windowsHide: true,
|
||||
})
|
||||
return true
|
||||
const firstPath = String(stdout ?? '')
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.find(Boolean)
|
||||
return firstPath ?? null
|
||||
} catch {
|
||||
return false
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function defaultCommandExists(command: string): Promise<boolean> {
|
||||
return (await defaultResolveCommand(command)) !== null
|
||||
}
|
||||
|
||||
async function defaultPathExists(targetPath: string): Promise<boolean> {
|
||||
try {
|
||||
const entry = await stat(targetPath)
|
||||
@ -250,6 +294,14 @@ async function defaultReadDirNames(targetPath: string): Promise<string[]> {
|
||||
}
|
||||
}
|
||||
|
||||
async function defaultReadTextFile(targetPath: string): Promise<string | null> {
|
||||
try {
|
||||
return await readFile(targetPath, 'utf8')
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function defaultReadPlistValue(plistPath: string, key: string): Promise<string | null> {
|
||||
try {
|
||||
const { stdout } = await execFile('/usr/bin/plutil', [
|
||||
@ -271,38 +323,120 @@ async function defaultReadPlistValue(plistPath: string, key: string): Promise<st
|
||||
}
|
||||
|
||||
async function defaultConvertIconToPng(iconPath: string, size: number): Promise<Uint8Array> {
|
||||
const extension = extname(iconPath).toLowerCase()
|
||||
if (extension === '.png') {
|
||||
return await readFile(iconPath)
|
||||
}
|
||||
|
||||
const tmpDir = await mkdtemp(join(tmpdir(), 'cc-haha-open-target-icon-'))
|
||||
const outputPath = join(tmpDir, 'icon.png')
|
||||
try {
|
||||
await execFile('/usr/bin/sips', [
|
||||
'-z',
|
||||
String(size),
|
||||
String(size),
|
||||
'-s',
|
||||
'format',
|
||||
'png',
|
||||
iconPath,
|
||||
'--out',
|
||||
outputPath,
|
||||
], {
|
||||
timeout: 5_000,
|
||||
windowsHide: true,
|
||||
})
|
||||
if (process.platform === 'win32') {
|
||||
await convertWindowsIconToPng(iconPath, outputPath)
|
||||
} else if (extension === '.svg' || extension === '.xpm') {
|
||||
await convertLinuxThemeIconToPng(iconPath, outputPath, size)
|
||||
} else {
|
||||
await execFile('/usr/bin/sips', [
|
||||
'-z',
|
||||
String(size),
|
||||
String(size),
|
||||
'-s',
|
||||
'format',
|
||||
'png',
|
||||
iconPath,
|
||||
'--out',
|
||||
outputPath,
|
||||
], {
|
||||
timeout: 5_000,
|
||||
windowsHide: true,
|
||||
})
|
||||
}
|
||||
return await readFile(outputPath)
|
||||
} finally {
|
||||
await rm(tmpDir, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
async function convertWindowsIconToPng(iconPath: string, outputPath: string): Promise<void> {
|
||||
const script = `
|
||||
Add-Type -AssemblyName System.Drawing
|
||||
$source = $env:CC_HAHA_ICON_SOURCE
|
||||
$output = $env:CC_HAHA_ICON_OUTPUT
|
||||
$icon = [System.Drawing.Icon]::ExtractAssociatedIcon($source)
|
||||
if ($null -eq $icon) { exit 2 }
|
||||
$bitmap = $icon.ToBitmap()
|
||||
$bitmap.Save($output, [System.Drawing.Imaging.ImageFormat]::Png)
|
||||
$bitmap.Dispose()
|
||||
$icon.Dispose()
|
||||
`
|
||||
|
||||
await execFile('powershell.exe', [
|
||||
'-NoProfile',
|
||||
'-NonInteractive',
|
||||
'-ExecutionPolicy',
|
||||
'Bypass',
|
||||
'-Command',
|
||||
script,
|
||||
], {
|
||||
env: {
|
||||
...process.env,
|
||||
CC_HAHA_ICON_SOURCE: iconPath,
|
||||
CC_HAHA_ICON_OUTPUT: outputPath,
|
||||
},
|
||||
timeout: 5_000,
|
||||
windowsHide: true,
|
||||
})
|
||||
}
|
||||
|
||||
async function convertLinuxThemeIconToPng(
|
||||
iconPath: string,
|
||||
outputPath: string,
|
||||
size: number,
|
||||
): Promise<void> {
|
||||
const attempts: Array<{ command: string; args: string[] }> = [
|
||||
{
|
||||
command: 'rsvg-convert',
|
||||
args: ['-w', String(size), '-h', String(size), '-o', outputPath, iconPath],
|
||||
},
|
||||
{
|
||||
command: 'gdk-pixbuf-thumbnailer',
|
||||
args: ['-s', String(size), iconPath, outputPath],
|
||||
},
|
||||
{
|
||||
command: 'magick',
|
||||
args: [iconPath, '-resize', `${size}x${size}`, outputPath],
|
||||
},
|
||||
{
|
||||
command: 'convert',
|
||||
args: [iconPath, '-resize', `${size}x${size}`, outputPath],
|
||||
},
|
||||
]
|
||||
|
||||
let lastError: unknown
|
||||
for (const attempt of attempts) {
|
||||
try {
|
||||
await execFile(attempt.command, attempt.args, {
|
||||
timeout: 5_000,
|
||||
windowsHide: true,
|
||||
})
|
||||
return
|
||||
} catch (error) {
|
||||
lastError = error
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError instanceof Error
|
||||
? lastError
|
||||
: new Error(`Unable to rasterize Linux icon: ${iconPath}`)
|
||||
}
|
||||
|
||||
function buildOpenTarget(definition: TargetDefinition, platform: OpenTargetPlatform): OpenTarget {
|
||||
return {
|
||||
id: definition.id,
|
||||
kind: definition.kind,
|
||||
label: definition.label,
|
||||
icon: definition.icon,
|
||||
iconUrl: platform === 'darwin'
|
||||
? `/api/open-targets/icons/${encodeURIComponent(definition.id)}`
|
||||
: undefined,
|
||||
iconUrl: `/api/open-targets/icons/${encodeURIComponent(definition.id)}`,
|
||||
platform,
|
||||
}
|
||||
}
|
||||
@ -439,14 +573,10 @@ async function findDarwinBundleIconPath(
|
||||
return await runtime.pathExists(fallbackPath) ? fallbackPath : null
|
||||
}
|
||||
|
||||
async function resolveIconPath(
|
||||
async function resolveDarwinIconPath(
|
||||
definition: TargetDefinition,
|
||||
runtime: Runtime,
|
||||
): Promise<string | null> {
|
||||
if (runtime.platform !== 'darwin' || !isSupportedOnPlatform(definition, runtime.platform)) {
|
||||
return null
|
||||
}
|
||||
|
||||
for (const iconPath of definition.iconPaths?.darwin ?? []) {
|
||||
if (await runtime.pathExists(iconPath)) return iconPath
|
||||
}
|
||||
@ -460,15 +590,280 @@ async function resolveIconPath(
|
||||
return null
|
||||
}
|
||||
|
||||
async function resolveWindowsIconPath(
|
||||
definition: TargetDefinition,
|
||||
runtime: Runtime,
|
||||
): Promise<string | null> {
|
||||
if (definition.fallback && definition.id === 'explorer') {
|
||||
const explorerPath = await runtime.resolveCommand('explorer.exe')
|
||||
if (explorerPath) return explorerPath
|
||||
}
|
||||
|
||||
for (const iconPath of definition.iconPaths?.win32 ?? []) {
|
||||
if (await runtime.pathExists(iconPath)) return iconPath
|
||||
}
|
||||
|
||||
for (const appPath of definition.appPaths?.win32 ?? []) {
|
||||
if (await runtime.pathExists(appPath)) return appPath
|
||||
}
|
||||
|
||||
for (const command of definition.commands?.win32 ?? []) {
|
||||
const commandPath = await runtime.resolveCommand(command)
|
||||
if (!commandPath) continue
|
||||
|
||||
const directIconPath = await resolveWindowsExecutableIconPath(commandPath, definition, runtime)
|
||||
if (directIconPath) return directIconPath
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
async function resolveWindowsExecutableIconPath(
|
||||
commandPath: string,
|
||||
definition: TargetDefinition,
|
||||
runtime: Runtime,
|
||||
): Promise<string | null> {
|
||||
const extension = winPath.extname(commandPath).toLowerCase()
|
||||
if ((extension === '.exe' || extension === '.ico') && await runtime.pathExists(commandPath)) {
|
||||
return commandPath
|
||||
}
|
||||
|
||||
if (extension !== '.cmd' && extension !== '.bat') {
|
||||
return null
|
||||
}
|
||||
|
||||
const executableNames = definition.windowsExecutableNames
|
||||
?? definition.commands?.win32?.filter((command) => winPath.extname(command).toLowerCase() === '.exe')
|
||||
?? []
|
||||
|
||||
let currentDir = winPath.dirname(commandPath)
|
||||
for (let depth = 0; depth < 5; depth += 1) {
|
||||
for (const executableName of executableNames) {
|
||||
const candidate = winPath.join(currentDir, executableName)
|
||||
if (await runtime.pathExists(candidate)) return candidate
|
||||
}
|
||||
|
||||
const nextDir = winPath.dirname(currentDir)
|
||||
if (!nextDir || nextDir === currentDir) break
|
||||
currentDir = nextDir
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
type LinuxDesktopEntry = {
|
||||
filePath: string
|
||||
name: string | null
|
||||
exec: string | null
|
||||
icon: string | null
|
||||
}
|
||||
|
||||
async function resolveLinuxIconPath(
|
||||
definition: TargetDefinition,
|
||||
runtime: Runtime,
|
||||
): Promise<string | null> {
|
||||
for (const iconPath of definition.iconPaths?.linux ?? []) {
|
||||
if (await runtime.pathExists(iconPath)) return iconPath
|
||||
}
|
||||
|
||||
const desktopEntries = definition.fallback
|
||||
? []
|
||||
: await findLinuxDesktopEntries(definition, runtime)
|
||||
|
||||
for (const desktopEntry of desktopEntries) {
|
||||
if (!desktopEntry.icon) continue
|
||||
const iconPath = await resolveLinuxIconName(desktopEntry.icon, runtime)
|
||||
if (iconPath) return iconPath
|
||||
}
|
||||
|
||||
if (definition.fallback && definition.kind === 'file_manager') {
|
||||
return await resolveLinuxIconName('folder', runtime)
|
||||
?? await resolveLinuxIconName('system-file-manager', runtime)
|
||||
?? await resolveLinuxIconName('org.gnome.Nautilus', runtime)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
async function findLinuxDesktopEntries(
|
||||
definition: TargetDefinition,
|
||||
runtime: Runtime,
|
||||
): Promise<LinuxDesktopEntry[]> {
|
||||
const matches: LinuxDesktopEntry[] = []
|
||||
for (const directory of LINUX_APPLICATION_DIRS) {
|
||||
const fileNames = await runtime.readDirNames(directory)
|
||||
for (const fileName of fileNames) {
|
||||
if (!fileName.endsWith('.desktop')) continue
|
||||
|
||||
const filePath = join(directory, fileName)
|
||||
const text = await runtime.readTextFile(filePath)
|
||||
if (!text) continue
|
||||
|
||||
const entry = parseLinuxDesktopEntry(filePath, text)
|
||||
if (entry && linuxDesktopEntryMatchesDefinition(entry, fileName, definition)) {
|
||||
matches.push(entry)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return matches
|
||||
}
|
||||
|
||||
function parseLinuxDesktopEntry(filePath: string, text: string): LinuxDesktopEntry | null {
|
||||
let inDesktopEntry = false
|
||||
const values = new Map<string, string>()
|
||||
|
||||
for (const rawLine of text.split(/\r?\n/)) {
|
||||
const line = rawLine.trim()
|
||||
if (!line || line.startsWith('#')) continue
|
||||
|
||||
if (line.startsWith('[') && line.endsWith(']')) {
|
||||
inDesktopEntry = line === '[Desktop Entry]'
|
||||
continue
|
||||
}
|
||||
|
||||
if (!inDesktopEntry) continue
|
||||
|
||||
const equalsIndex = line.indexOf('=')
|
||||
if (equalsIndex <= 0) continue
|
||||
|
||||
const rawKey = line.slice(0, equalsIndex).trim()
|
||||
const key = rawKey.replace(/\[.*\]$/, '')
|
||||
if (key !== 'Name' && key !== 'Exec' && key !== 'Icon') continue
|
||||
|
||||
values.set(key, line.slice(equalsIndex + 1).trim())
|
||||
}
|
||||
|
||||
if (!values.has('Exec') && !values.has('Icon')) return null
|
||||
|
||||
return {
|
||||
filePath,
|
||||
name: values.get('Name') ?? null,
|
||||
exec: values.get('Exec') ?? null,
|
||||
icon: values.get('Icon') ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
function linuxDesktopEntryMatchesDefinition(
|
||||
entry: LinuxDesktopEntry,
|
||||
fileName: string,
|
||||
definition: TargetDefinition,
|
||||
): boolean {
|
||||
const commandNames = new Set(
|
||||
(definition.commands?.linux ?? []).map((command) => basename(command).toLowerCase()),
|
||||
)
|
||||
const normalizedNeedles = [
|
||||
definition.id,
|
||||
definition.icon,
|
||||
definition.label,
|
||||
...commandNames,
|
||||
].map(normalizeLinuxDesktopSearchText)
|
||||
|
||||
const normalizedFileName = normalizeLinuxDesktopSearchText(fileName)
|
||||
const normalizedName = normalizeLinuxDesktopSearchText(entry.name ?? '')
|
||||
if (normalizedNeedles.some((needle) => needle && (
|
||||
normalizedFileName.includes(needle) || normalizedName.includes(needle)
|
||||
))) {
|
||||
return true
|
||||
}
|
||||
|
||||
const execCommand = entry.exec ? extractLinuxDesktopExecCommand(entry.exec) : null
|
||||
return execCommand ? commandNames.has(execCommand) : false
|
||||
}
|
||||
|
||||
function normalizeLinuxDesktopSearchText(value: string): string {
|
||||
return value.toLowerCase().replace(/[^a-z0-9]+/g, '')
|
||||
}
|
||||
|
||||
function extractLinuxDesktopExecCommand(execValue: string): string | null {
|
||||
const tokens = execValue
|
||||
.replace(/%[a-zA-Z]/g, '')
|
||||
.match(/"([^"]+)"|'([^']+)'|(\S+)/g)
|
||||
?.map((token) => token.replace(/^['"]|['"]$/g, '')) ?? []
|
||||
|
||||
for (const token of tokens) {
|
||||
if (!token || token.includes('=')) continue
|
||||
const command = basename(token).toLowerCase()
|
||||
if (command === 'env') continue
|
||||
return command
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
async function resolveLinuxIconName(
|
||||
iconName: string,
|
||||
runtime: Runtime,
|
||||
): Promise<string | null> {
|
||||
const trimmed = iconName.trim()
|
||||
if (!trimmed) return null
|
||||
|
||||
if (trimmed.startsWith('/') && await runtime.pathExists(trimmed)) {
|
||||
return trimmed
|
||||
}
|
||||
|
||||
const extension = extname(trimmed)
|
||||
const baseName = extension ? trimmed.slice(0, -extension.length) : trimmed
|
||||
const extensions = extension ? [extension] : ['.png', '.svg', '.xpm']
|
||||
|
||||
const directRoots = [
|
||||
'/usr/share/pixmaps',
|
||||
'/usr/local/share/pixmaps',
|
||||
join(homedir(), '.local', 'share', 'pixmaps'),
|
||||
]
|
||||
for (const root of directRoots) {
|
||||
for (const candidateExtension of extensions) {
|
||||
const candidate = join(root, `${baseName}${candidateExtension}`)
|
||||
if (await runtime.pathExists(candidate)) return candidate
|
||||
}
|
||||
}
|
||||
|
||||
for (const root of LINUX_ICON_ROOTS) {
|
||||
const themeNames = await runtime.readDirNames(root)
|
||||
for (const themeName of themeNames) {
|
||||
for (const subdir of LINUX_ICON_THEME_SUBDIRS) {
|
||||
for (const candidateExtension of extensions) {
|
||||
const candidate = join(root, themeName, subdir, `${baseName}${candidateExtension}`)
|
||||
if (await runtime.pathExists(candidate)) return candidate
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
async function resolveIconPath(
|
||||
definition: TargetDefinition,
|
||||
runtime: Runtime,
|
||||
): Promise<string | null> {
|
||||
if (!isSupportedOnPlatform(definition, runtime.platform)) {
|
||||
return null
|
||||
}
|
||||
|
||||
switch (runtime.platform) {
|
||||
case 'darwin':
|
||||
return resolveDarwinIconPath(definition, runtime)
|
||||
case 'win32':
|
||||
return resolveWindowsIconPath(definition, runtime)
|
||||
case 'linux':
|
||||
return resolveLinuxIconPath(definition, runtime)
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function createOpenTargetService(overrides: Partial<Runtime> = {}) {
|
||||
const runtime: Runtime = {
|
||||
platform: overrides.platform ?? process.platform,
|
||||
ttlMs: overrides.ttlMs ?? DEFAULT_TTL_MS,
|
||||
now: overrides.now ?? Date.now,
|
||||
commandExists: overrides.commandExists ?? defaultCommandExists,
|
||||
resolveCommand: overrides.resolveCommand ?? defaultResolveCommand,
|
||||
pathExists: overrides.pathExists ?? defaultPathExists,
|
||||
launch: overrides.launch ?? defaultLaunch,
|
||||
readDirNames: overrides.readDirNames ?? defaultReadDirNames,
|
||||
readTextFile: overrides.readTextFile ?? defaultReadTextFile,
|
||||
readPlistValue: overrides.readPlistValue ?? defaultReadPlistValue,
|
||||
convertIconToPng: overrides.convertIconToPng ?? defaultConvertIconToPng,
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user