PLWL/src/linuxrunner.js

349 lines
12 KiB
JavaScript

'use strict'
// Native Linux apps: the twin of runner.js. Same tracked spawn tail, but the
// program runs directly (no wine) with a redirected home and, when bubblewrap
// works, inside a namespace.
const crypto = require('crypto')
const fs = require('fs')
const os = require('os')
const path = require('path')
const { DIRS, readJson, writeJson } = require('./paths')
const { prefixPath, prefixLinuxRoot } = require('./prefixes')
const { prefixNameForGame } = require('./library')
const { spawnTracked, parseArgs } = require('./runner')
const { buildBwrapArgs, SHARE_ROOT } = require('./sandbox')
const DATA_DIRS = ['home', 'config', 'data', 'cache', 'state']
// host folder <-> in-sandbox XDG variable, so both backends set the same env
const XDG_LAYOUT = {
XDG_CONFIG_HOME: ['config', '.config'],
XDG_DATA_HOME: ['data', path.join('.local', 'share')],
XDG_CACHE_HOME: ['cache', '.cache'],
XDG_STATE_HOME: ['state', path.join('.local', 'state')]
}
// Write access is handed out to the folder the app was imported from and to
// nothing else outside its home: a portable app writes its logs and data next
// to itself (Prism Launcher refuses to start otherwise), while system trees
// must never come back writable.
const SYSTEM_DIRS = ['/bin', '/sbin', '/lib', '/lib32', '/lib64', '/libx32', '/usr', '/etc', '/boot', '/proc', '/sys', '/dev', '/run']
function writableAppDir(game) {
const dir = (game && (game.path || (game.exePath && path.dirname(game.exePath)))) || ''
// realpathSync('') would answer with the cwd - never hand that out
if (!dir) return ''
let real
try {
real = fs.realpathSync(dir)
if (!fs.statSync(real).isDirectory()) return ''
} catch {
return ''
}
// launcher-owned folders: only an imported app may be written to
if (real.startsWith(DIRS.FILES + path.sep)) {
return real.startsWith(DIRS.APPS + path.sep) ? real : ''
}
if (real === '/') return ''
for (const system of SYSTEM_DIRS) {
if (real === system || real.startsWith(system + '/')) return ''
}
return real
}
function linuxUser() {
try {
return os.userInfo().username
} catch {
return process.env.USER || 'user'
}
}
function copyFromHost(src, dst, { refresh = false } = {}) {
try {
if (!refresh && fs.existsSync(dst)) return false
if (!fs.statSync(src).isFile()) return false
fs.mkdirSync(path.dirname(dst), { recursive: true })
fs.copyFileSync(src, dst)
return true
} catch {
return false
}
}
// PulseAudio looks for its cookie under $HOME/.config/pulse; with HOME
// redirected there is none, and sound dies for everything going through
// Pulse. Copied once - the app owns it afterwards.
function seedPulseCookie(root) {
const dst = path.join(root, 'config', 'pulse', 'cookie')
const seeded = copyFromHost(path.join(os.homedir(), '.config', 'pulse', 'cookie'), dst)
if (seeded) {
try {
fs.chmodSync(dst, 0o600)
} catch {}
}
}
// The host's desktop preferences, not its data: a redirected home has no
// theme at all, and Qt/GTK then fall back to their built-in light look (KDE's
// dark palette lives in kdeglobals).
const DESKTOP_SEEDS = [
'kdeglobals',
'kdedefaults/kdeglobals',
'gtk-3.0/settings.ini',
'gtk-4.0/settings.ini',
'qt5ct/qt5ct.conf',
'qt6ct/qt6ct.conf',
'Trolltech.conf',
'user-dirs.dirs'
]
// The host's desktop *data* - fonts, icons, themes, colour schemes - is not
// copied but handed over read-only (see buildBwrapArgs), so it follows the
// host without an import.
const DATA_SHARES = ['fonts', 'icons', 'themes', 'color-schemes']
function hostShareDir() {
return path.join(os.homedir(), '.local', 'share')
}
function dataShares() {
return DATA_SHARES.map(name => path.join(hostShareDir(), name)).filter(dir => fs.existsSync(dir))
}
function hashOf(file) {
try {
return crypto.createHash('sha1').update(fs.readFileSync(file)).digest('hex')
} catch {
return ''
}
}
function mtimeOf(file) {
try {
return fs.statSync(file).mtimeMs
} catch {
return 0
}
}
// Hashes of what we put in the prefix, so a host change can be followed while
// the app has not touched our copy. Kept in the prefix root: the app only
// ever sees home/config/data/cache/state.
const SEED_MARKER = '.seeded.json'
// Copies a preference in, then keeps following it for as long as we can prove
// the copy is still ours: an unusable theme is worse than a stale one, and an
// app's own edit is never overwritten.
function syncDesktopConfig(root) {
const hostConfig = path.join(os.homedir(), '.config')
const markerFile = path.join(root, SEED_MARKER)
const marker = readJson(markerFile, {})
let changed = false
for (const rel of DESKTOP_SEEDS) {
const src = path.join(hostConfig, rel)
const dst = path.join(root, 'config', rel)
const hostHash = hashOf(src)
if (!hostHash) continue
const known = marker[rel]
if (fs.existsSync(dst)) {
const current = hashOf(dst)
if (known) {
// the app owns it now, or there is nothing to do
if (current !== known || current === hostHash) continue
} else if (current !== hostHash && mtimeOf(dst) >= mtimeOf(src)) {
// a prefix from an older launcher: an identical copy is ours, and so is
// one the host's file has since outgrown. A copy that is not older than
// the host's is the app's own - ties stay with the app
continue
}
}
// refresh: this is provably still our own copy
if (copyFromHost(src, dst, { refresh: true })) {
marker[rel] = hostHash
changed = true
}
}
if (changed) {
try {
writeJson(markerFile, marker)
} catch {}
}
}
// X11 access: XAUTHORITY usually lives in /run/user (bound), but a launcher
// started without it would leave X11 dead inside. Refreshed: a cookie is
// session state, not data.
function seedXauthority(root) {
copyFromHost(path.join(os.homedir(), '.Xauthority'), path.join(root, 'home', '.Xauthority'), { refresh: true })
}
// Everything this module copies in from the host, prefix-relative: the one
// list a reset has to clear.
function seededRelPaths() {
return [
...DESKTOP_SEEDS.map(rel => path.join('config', rel)),
path.join('config', 'pulse', 'cookie'),
path.join('home', '.Xauthority'),
SEED_MARKER
]
}
// syncDesktopConfig only follows the host while the copy is provably ours, so
// an app that edited its own theme keeps it forever. The reset drops our
// copies (and the hashes that track them) and lets the next start seed clean
// ones - the app's own files in the same folders are never touched.
function resetSeededConfig(name) {
const root = prefixLinuxRoot(name)
const removed = []
for (const rel of seededRelPaths()) {
const file = path.join(root, rel)
try {
// lstat: a link pointing out of the prefix is none of our business
if (!fs.lstatSync(file).isFile()) continue
fs.rmSync(file, { force: true })
removed.push(rel)
} catch {}
}
// folders that exist only for a seed, and only while nothing else lives there
const dirs = ['kdedefaults', 'gtk-3.0', 'gtk-4.0', 'qt5ct', 'qt6ct', 'pulse']
for (const dir of dirs) {
try {
fs.rmdirSync(path.join(root, 'config', dir))
} catch {}
}
return { ok: true, removed }
}
function ensureLinuxPfx(name) {
const root = prefixLinuxRoot(name)
fs.mkdirSync(path.join(root, 'home', '.local', 'share'), { recursive: true })
for (const dir of DATA_DIRS) fs.mkdirSync(path.join(root, dir), { recursive: true })
seedPulseCookie(root)
seedXauthority(root)
return root
}
// Wayland cannot carry a window icon and the compositor only guesses one from
// the app id; XWayland can, and GLFW/Qt/GTK all read these, so one flag moves
// the whole process tree (Prism and the Minecraft it launches) back to X11.
const X11_ENV = { XDG_SESSION_TYPE: 'x11', QT_QPA_PLATFORM: 'xcb', GDK_BACKEND: 'x11' }
// The theme data sits in front of the inherited list: on the host the user's
// own icons and fonts also win over the system ones.
function withHostShare(env, entries) {
const own = entries.filter(Boolean)
if (!own.length) return
env.XDG_DATA_DIRS = [...own, env.XDG_DATA_DIRS].filter(Boolean).join(':')
}
// HOME and the XDG folders point at the prefix (plain run) or at the bind
// targets inside the sandbox - the two must agree or the app writes outside.
function buildLinuxEnv({ game, backend = 'env' }) {
const prefixName = prefixNameForGame(game)
const root = ensureLinuxPfx(prefixName)
syncDesktopConfig(root)
const sandboxed = backend === 'bwrap'
const home = sandboxed ? `/home/${linuxUser()}` : path.join(root, 'home')
const env = { ...process.env }
env.HOME = home
for (const [key, [hostDir, sandboxSub]] of Object.entries(XDG_LAYOUT)) {
env[key] = sandboxed ? path.join(home, sandboxSub) : path.join(root, hostDir)
}
// XDG_RUNTIME_DIR stays the host's: Wayland, PipeWire and the session bus
// live there. It weakens the isolation, and the UI says so.
// The X session manager sits in the private /tmp, so its pointer would only
// lead to a dead socket; clients skip it when it is absent.
if (sandboxed) delete env.SESSION_MANAGER
// the host's fonts/icons/themes: mounted read-only by buildBwrapArgs, or - in
// a plain run, where nothing is hidden - just put on the search path
if (dataShares().length) withHostShare(env, [sandboxed ? SHARE_ROOT : hostShareDir()])
if (game.forceX11) Object.assign(env, X11_ENV)
// the app's own environment comes last: it overrides everything above
for (const [k, v] of Object.entries(game.env || {})) env[k] = v
return { env, root, home, prefixName, prefixDir: prefixPath(prefixName) }
}
// bwrap when it works, plain run otherwise - unless the app demands a sandbox.
function pickBackend({ game, sandbox }) {
const policy = (game && game.isolation) || 'auto'
const status = sandbox && typeof sandbox.status === 'function' ? sandbox.status() : null
const available = !!status && status.backend === 'bwrap'
if (policy === 'off') return { ok: true, backend: 'env', policy, status }
if (available) return { ok: true, backend: 'bwrap', policy, status }
if (policy === 'require') return { ok: false, error: 'sandbox-required', policy, status }
return { ok: true, backend: 'env', policy, status }
}
function isolationLine({ backend, status }) {
if (backend === 'bwrap') return `[plwl] isolation: bwrap ${(status && status.version) || ''}`.trim()
const reason = (status && status.reason) || 'missing'
return `[plwl] isolation: env-only (reason: ${reason})`
}
// "Open with": a native app takes the file where it is (the sandbox sees /).
function linuxPathFor(file) {
let abs = String(file || '')
try {
abs = fs.realpathSync(abs)
} catch {}
return abs
}
function bwrapSource() {
const devices = ['/dev/shm', '/dev/dri', '/dev/snd', '/dev/input'].filter(d => fs.existsSync(d))
const x11 = fs.existsSync('/tmp/.X11-unix') ? '/tmp/.X11-unix' : ''
const runUser = `/run/user/${typeof process.getuid === 'function' ? process.getuid() : 0}`
return {
devices,
x11,
runUser: fs.existsSync(runUser) ? runUser : '',
shares: dataShares(),
user: linuxUser()
}
}
async function runLinuxGame({ game, sandbox, onExit, openFile, handoff = false }) {
const chosen = pickBackend({ game, sandbox })
if (!chosen.ok) throw new Error(chosen.error)
const exe = game.exePath
const workdir = game.workdir || path.dirname(exe)
const argList = parseArgs(game.args)
if (openFile) argList.push(linuxPathFor(openFile))
const { env, root } = buildLinuxEnv({ game, backend: chosen.backend })
const header = [isolationLine(chosen)]
if (game.forceX11) header.push('[plwl] x11 forced (XWayland)')
let cmd = exe
let args = argList
if (chosen.backend === 'bwrap') {
const source = bwrapSource()
cmd = 'bwrap'
args = [...buildBwrapArgs({ dataRoot: root, user: source.user, devices: source.devices, x11: source.x11, runUser: source.runUser, shares: source.shares, appDir: writableAppDir(game) }), '--', exe, ...argList]
header.push(`[plwl] HOME=${env.HOME}`)
}
return spawnTracked({ game, cmd, args, cwd: workdir, env, handoff, header, onExit })
}
module.exports = {
DATA_DIRS,
XDG_LAYOUT,
SYSTEM_DIRS,
DESKTOP_SEEDS,
DATA_SHARES,
X11_ENV,
writableAppDir,
ensureLinuxPfx,
resetSeededConfig,
dataShares,
syncDesktopConfig,
buildLinuxEnv,
pickBackend,
isolationLine,
linuxPathFor,
runLinuxGame
}