[EXPERIMENTAL] linux apps support
This commit is contained in:
parent
d4996988d9
commit
e7636afe3d
20 changed files with 1377 additions and 101 deletions
265
src/linuxapp.js
Normal file
265
src/linuxapp.js
Normal file
|
|
@ -0,0 +1,265 @@
|
|||
'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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue