349 lines
12 KiB
JavaScript
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
|
|
}
|