'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('= 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 = { findLinuxExe, readDesktopEntry, desktopFileFor, findIconFile, pngOrSvg }