PLWL/src/linuxapp.js

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
}