'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 }