265 lines
8 KiB
JavaScript
265 lines
8 KiB
JavaScript
'use strict'
|
|
|
|
// What makes a file a runnable native Linux app: an ELF binary or an AppImage
|
|
// (both with the executable bit), or a .desktop entry pointing at one.
|
|
|
|
const fs = require('fs')
|
|
const fsp = fs.promises
|
|
const os = require('os')
|
|
const path = require('path')
|
|
|
|
const ELF_MAGIC = Buffer.from([0x7f, 0x45, 0x4c, 0x46])
|
|
|
|
// Folders that ship libraries/data, never the entry point. Skipping them is
|
|
// what keeps `share/` and `lib/` .desktop files out of the candidates.
|
|
const SKIPPED_DIRS = new Set([
|
|
'lib', 'lib32', 'lib64', 'libexec', 'share', 'include', 'man', 'doc', 'docs',
|
|
'locale', 'node_modules', 'redist', 'python', 'site-packages', '__pycache__',
|
|
'translations', 'resources', 'licenses', 'locale'
|
|
])
|
|
|
|
const LIBRARY_RE = /\.(so(\.\d+(\.\d+)*)?|a|o|py|pyc|jar|dll|dylib)$/i
|
|
const SCRIPT_RE = /^(run|start|launch|play|app)\.sh$/i
|
|
|
|
function isElfBuffer(buf) {
|
|
return !!buf && buf.length >= 4 && buf.subarray(0, 4).equals(ELF_MAGIC)
|
|
}
|
|
|
|
function isElfSync(file) {
|
|
try {
|
|
const fd = fs.openSync(file, 'r')
|
|
try {
|
|
const buf = Buffer.alloc(4)
|
|
fs.readSync(fd, buf, 0, 4, 0)
|
|
return isElfBuffer(buf)
|
|
} finally {
|
|
fs.closeSync(fd)
|
|
}
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
// [Desktop Entry] fields we care about; the first group wins, later groups
|
|
// (Desktop Action ...) are ignored.
|
|
function parseDesktopEntry(text) {
|
|
const entry = {}
|
|
let inEntry = false
|
|
for (const raw of String(text || '').split('\n')) {
|
|
const line = raw.trim()
|
|
if (line.startsWith('[')) {
|
|
inEntry = line === '[Desktop Entry]'
|
|
continue
|
|
}
|
|
if (!inEntry || !line || line.startsWith('#')) continue
|
|
const eq = line.indexOf('=')
|
|
if (eq <= 0) continue
|
|
const key = line.slice(0, eq).trim()
|
|
const value = line.slice(eq + 1).trim()
|
|
if (key === 'Exec' || key === 'Icon' || key === 'Path' || key === 'Name' || key === 'Type') {
|
|
if (!(key in entry)) entry[key] = value
|
|
}
|
|
}
|
|
return entry
|
|
}
|
|
|
|
function readDesktopEntry(file) {
|
|
try {
|
|
return parseDesktopEntry(fs.readFileSync(file, 'utf8'))
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
// Exec is a command line with field codes: the first token is the program.
|
|
function execToken(exec) {
|
|
const m = String(exec || '').match(/"([^"]*)"|'([^']*)'|(\S+)/)
|
|
const token = m ? (m[1] ?? m[2] ?? m[3]) : ''
|
|
return token && !/^%[fFuUdDnNickvm]?$/.test(token) ? token : ''
|
|
}
|
|
|
|
// A .desktop's program, resolved against the entry's own folder. Relative
|
|
// Exec= paths are common in app dirs; the spec says that is not allowed, so
|
|
// the fallback is to the folder itself.
|
|
function desktopExecPath(entry, desktopFile) {
|
|
const token = execToken(entry && entry.Exec)
|
|
if (!token) return ''
|
|
const dir = path.dirname(desktopFile)
|
|
const resolved = path.isAbsolute(token) ? token : path.resolve(dir, token)
|
|
try {
|
|
fs.accessSync(resolved)
|
|
return resolved
|
|
} catch {
|
|
const base = path.join(dir, path.basename(token))
|
|
try {
|
|
fs.accessSync(base)
|
|
return base
|
|
} catch {
|
|
return ''
|
|
}
|
|
}
|
|
}
|
|
|
|
function isExecutable(file) {
|
|
try {
|
|
return (fs.statSync(file).mode & 0o111) !== 0
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
async function sizeOf(file) {
|
|
try {
|
|
return (await fsp.stat(file)).size
|
|
} catch {
|
|
return 0
|
|
}
|
|
}
|
|
|
|
// Best entry point in an app folder: the same question findDefaultExe answers
|
|
// for Windows, with ELF/AppImage/.desktop instead of .exe.
|
|
async function findLinuxExe(dir) {
|
|
const candidates = []
|
|
const desktops = []
|
|
const walk = async (d, depth) => {
|
|
let entries
|
|
try {
|
|
entries = await fsp.readdir(d, { withFileTypes: true })
|
|
} catch {
|
|
return
|
|
}
|
|
const subdirs = []
|
|
for (const entry of entries) {
|
|
const full = path.join(d, entry.name)
|
|
if (entry.isDirectory()) {
|
|
if (!SKIPPED_DIRS.has(entry.name.toLowerCase())) subdirs.push([full, depth + 1])
|
|
continue
|
|
}
|
|
const lower = entry.name.toLowerCase()
|
|
if (lower.endsWith('.desktop')) {
|
|
desktops.push([full, depth])
|
|
continue
|
|
}
|
|
if (lower.endsWith('.appimage')) {
|
|
candidates.push({ file: full, depth, score: 100 })
|
|
continue
|
|
}
|
|
if (LIBRARY_RE.test(entry.name)) continue
|
|
if (SCRIPT_RE.test(entry.name)) {
|
|
candidates.push({ file: full, depth, score: 60 })
|
|
continue
|
|
}
|
|
if (isElfSync(full)) candidates.push({ file: full, depth, score: isExecutable(full) ? 55 : 40 })
|
|
}
|
|
await Promise.all(subdirs.map(([full, next]) => walk(full, next)))
|
|
}
|
|
await walk(dir, 0)
|
|
|
|
// A .desktop is the intended way in when it names a program in the tree.
|
|
for (const [full, depth] of desktops) {
|
|
const exec = desktopExecPath(readDesktopEntry(full), full)
|
|
if (exec) candidates.push({ file: exec, depth, score: 90 })
|
|
}
|
|
if (!candidates.length) return ''
|
|
|
|
let best = ''
|
|
let bestScore = -Infinity
|
|
for (const candidate of candidates) {
|
|
const size = await sizeOf(candidate.file)
|
|
// shallow wins, and between equals the bigger binary is the app
|
|
const score = candidate.score - candidate.depth * 5 + Math.min(size / 1000000, 20)
|
|
if (score > bestScore) {
|
|
bestScore = score
|
|
best = candidate.file
|
|
}
|
|
}
|
|
return best
|
|
}
|
|
|
|
// The .desktop that belongs to this program: one in the same folder, whose
|
|
// Exec points at it (or the only one there).
|
|
function desktopFileFor(exePath) {
|
|
const dir = path.dirname(exePath)
|
|
let names = []
|
|
try {
|
|
names = fs.readdirSync(dir).filter(n => n.toLowerCase().endsWith('.desktop'))
|
|
} catch {}
|
|
if (!names.length) {
|
|
try {
|
|
names = fs.readdirSync(path.join(dir, '..')).filter(n => n.toLowerCase().endsWith('.desktop'))
|
|
} catch {}
|
|
if (names.length) return path.join(dir, '..', names[0])
|
|
return ''
|
|
}
|
|
for (const name of names) {
|
|
const file = path.join(dir, name)
|
|
if (desktopExecPath(readDesktopEntry(file), file) === exePath) return file
|
|
}
|
|
return path.join(dir, names[0])
|
|
}
|
|
|
|
function pngOrSvg(file) {
|
|
try {
|
|
const fd = fs.openSync(file, 'r')
|
|
try {
|
|
const buf = Buffer.alloc(64)
|
|
const read = fs.readSync(fd, buf, 0, 64, 0)
|
|
const head = buf.subarray(0, read)
|
|
if (head.length >= 8 && head.readUInt32BE(0) === 0x89504e47) return 'png'
|
|
const text = head.toString('utf8').trim().toLowerCase()
|
|
if (text.startsWith('<svg') || text.startsWith('<?xml')) return 'svg'
|
|
if (head.length >= 3 && head[0] === 0xff && head[1] === 0xd8) return 'jpg'
|
|
} finally {
|
|
fs.closeSync(fd)
|
|
}
|
|
} catch {}
|
|
return ''
|
|
}
|
|
|
|
const THEME_SIZES = ['512x512', '256x256', '128x128', 'scalable', '64x64', '48x48', '32x32']
|
|
|
|
// An `Icon=` value: an absolute path, a path relative to the .desktop's
|
|
// folder (AppImage app dirs put .DirIcon next to it), or a theme icon name.
|
|
function findIconFile(spec, baseDir) {
|
|
const candidates = []
|
|
const name = String(spec || '').trim() || '.DirIcon'
|
|
if (path.isAbsolute(name)) {
|
|
candidates.push(name)
|
|
} else {
|
|
if (baseDir) candidates.push(path.join(baseDir, name))
|
|
if (!path.extname(name)) {
|
|
const dataHome = process.env.XDG_DATA_HOME || path.join(os.homedir(), '.local', 'share')
|
|
for (const dir of [path.join(dataHome, 'icons'), '/usr/share/icons']) {
|
|
for (const size of THEME_SIZES) {
|
|
for (const ext of ['png', 'svg']) candidates.push(path.join(dir, 'hicolor', size, 'apps', `${name}.${ext}`))
|
|
}
|
|
}
|
|
for (const dir of [dataHome, '/usr/share', '/usr/local/share']) {
|
|
candidates.push(path.join(dir, 'pixmaps', name + '.png'), path.join(dir, 'pixmaps', name + '.svg'))
|
|
}
|
|
} else {
|
|
for (const dir of ['/usr/share/pixmaps', process.env.XDG_DATA_HOME ? path.join(process.env.XDG_DATA_HOME, 'pixmaps') : '']) {
|
|
if (dir) candidates.push(path.join(dir, name))
|
|
}
|
|
}
|
|
}
|
|
for (const candidate of candidates) {
|
|
if (!candidate) continue
|
|
const kind = pngOrSvg(candidate)
|
|
if (kind) return candidate
|
|
}
|
|
return ''
|
|
}
|
|
|
|
module.exports = {
|
|
ELF_MAGIC,
|
|
isElfBuffer,
|
|
isElfSync,
|
|
findLinuxExe,
|
|
parseDesktopEntry,
|
|
readDesktopEntry,
|
|
desktopExecPath,
|
|
desktopFileFor,
|
|
findIconFile,
|
|
pngOrSvg
|
|
}
|