PLWL/src/linuxrunner.js
2026-10-19 11:02:52 +02:00

641 lines
24 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, loadSettings, appFolder } = require('./library')
const i18n = require('./i18n')
const { spawnTracked, parseArgs } = require('./runner')
const { buildBwrapArgs, SHARE_ROOT, FS_ROOT } = require('./sandbox')
const { PLWL_DIR, requestPath } = require('./openrelay')
const { copyFileProgress } = require('./fsutil')
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')]
}
// Every folder the app is addressed by - the tree it was imported as, the one
// it runs in, and the one its binary sits in (a bundle may keep that in bin/,
// or start an app from a folder of its own) - each as stated and as it really
// is, since an app imported from the home may sit behind a link of its own.
function appShapes(game) {
const dirs = [game && game.path, game && game.workdir, game && game.exePath && path.dirname(game.exePath)]
const shapes = new Set()
for (const dir of dirs) {
if (!dir) continue
shapes.add(path.resolve(dir))
try {
shapes.add(fs.realpathSync(dir))
} catch {}
}
return [...shapes]
}
function writableAppDir(game) {
const dir = appFolder(game)
// 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'
}
}
// The names the app owns in its home: the redirected XDG folders and the
// launcher's own folder. The host home never gets to shadow them.
const OWNED_HOME_NAMES = new Set([PLWL_DIR, '.config', '.local', '.cache'])
// The host home's own entries - what the desktop can hand an app (a drop from
// the file manager, a file picked in a dialog). A link resolving nowhere is
// not a path anything can be given, so it is skipped too.
function hostHomeEntries() {
const home = os.homedir()
let names
try {
names = fs.readdirSync(home)
} catch {
return []
}
return names.filter(name => !OWNED_HOME_NAMES.has(name) && fs.existsSync(path.join(home, name)))
}
// The launcher's own links are the ones pointing into the relaxed view.
function isFsRootLink(file) {
let target
try {
target = fs.readlinkSync(file)
} catch {
return false
}
return target.startsWith(FS_ROOT + path.sep)
}
function clearHomeRedirects(root) {
const dir = path.join(root, 'home')
let names
try {
names = fs.readdirSync(dir)
} catch {
return
}
for (const name of names) {
if (!isFsRootLink(path.join(dir, name))) continue
try {
fs.rmSync(path.join(dir, name), { force: true })
} catch {}
}
}
// Relaxed is the one level where the real filesystem is reachable (through
// FS_ROOT) - and there the app's home is mounted over the real one, so a path
// the desktop hands over names a folder the app cannot see. A link into that
// view gives exactly those names back, at their real paths; the app's own
// entries are never touched. No other level has a view to point at, so the
// links come off again there - they would only dangle.
// One name is never linked: the one the app's own folder sits under. A portable
// bundle resolves itself with `readlink -f "$0"`, so a link there answers with
// FS_ROOT - a path under /tmp - and Prism Launcher reads that as "Your instance
// folder is in a temporary folder" on every start (measured in a real profile).
function syncHomeRedirects(root, { relaxed, appDirs = [] }) {
clearHomeRedirects(root)
if (!relaxed) return []
const home = os.homedir()
const made = []
for (const name of hostHomeEntries()) {
const real = path.join(home, name)
if (appDirs.some(dir => dir === real || dir.startsWith(real + path.sep))) continue
const file = path.join(root, 'home', name)
if (fs.existsSync(file)) continue
try {
fs.symlinkSync(path.join(FS_ROOT, real), file)
made.push(name)
} catch {}
}
return made
}
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
}
}
// With HOME redirected there is no pulse cookie, and everything through Pulse
// comes up silent; copied once, then the app owns it.
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: without them Qt/GTK 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) is not copied but handed
// over read-only: see buildBwrapArgs.
const DATA_SHARES = ['fonts', 'icons', 'themes', 'color-schemes']
// A clicked link is the host desktop's business: the association naming a
// browser lives in the host's own home, so the URI goes to the host's portal.
const PORTAL_BUS = 'org.freedesktop.portal.Desktop'
const PORTAL_METHOD = 'org.freedesktop.portal.OpenURI.OpenURI'
const OPEN_BINS = ['/usr/bin/xdg-open', '/bin/xdg-open', '/usr/local/bin/xdg-open']
// Launcher-owned files in the redirected home (never mounted into a sandbox),
// rewritten whenever they differ.
const SHIM_NAME = 'xdg-open'
const OPENER_NAME = 'open-url'
const OPENER_DESKTOP = 'plwl-url-opener.desktop'
const SHIM_REL = path.join('home', PLWL_DIR, SHIM_NAME)
const OPENER_REL = path.join('home', PLWL_DIR, OPENER_NAME)
const MIME_REL = path.join('config', 'mimeapps.list')
const DESKTOP_REL = path.join('data', 'applications', OPENER_DESKTOP)
// What an opened thing in an app resolves to. Nothing else about the app's
// file types is touched: the rest of the MIME lists stay the system's.
const OPEN_TYPES = ['x-scheme-handler/http', 'x-scheme-handler/https', 'text/html', 'x-scheme-handler/file', 'inode/directory']
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 readText(file) {
try {
return fs.readFileSync(file, 'utf8')
} catch {
return ''
}
}
function writeIfChanged(file, text, mode) {
try {
fs.mkdirSync(path.dirname(file), { recursive: true })
if (readText(file) !== text) fs.writeFileSync(file, text)
fs.chmodSync(file, mode)
return file
} catch {
return ''
}
}
// The one script both hand-offs run: xdg-open's stand-in inside a sandbox, and
// what the seeded desktop entry starts. A scheme goes to the host's portal
// (chooser included), a path to the request file the launcher watches - the
// portal's answer is unreadable (development.md), the call is all we can trust.
// Mounted over the app's own xdg-open as well: see bundleOpenMounts.
function handOffScript({ hostHome, fsRoot, request }) {
const cases = [
`file://"$HOME") arg="file://${hostHome}" ;;`,
`file://"$HOME"/*) arg="file://${hostHome}\${arg#file://"$HOME"}" ;;`,
...(fsRoot
? [
`file://${FS_ROOT}) arg="file:///" ;;`,
`file://${FS_ROOT}/*) arg="file:///\${arg#file://${FS_ROOT}/}" ;;`
]
: []),
`"$HOME") arg="${hostHome}" ;;`,
`"$HOME"/*) arg="${hostHome}\${arg#$HOME}" ;;`,
...(fsRoot ? [`${FS_ROOT}) arg="/" ;;`, `${FS_ROOT}/*) arg="/\${arg#${FS_ROOT}/}" ;;`] : [])
].join('\n ')
return `#!/bin/sh
# Written by PLWL: what an app opens belongs to the host desktop.
arg="$1"
# the bundle's own xdg-open answers to gio-launch-desktop too (its bin links the
# two names to one file) and there the argument is not something to open but a
# program to start, so that name goes straight back to the command line
if [ "\${0##*/}" = "gio-launch-desktop" ]; then
export GIO_LAUNCHED_DESKTOP_FILE_PID=$$
exec "$@"
fi
[ -n "$arg" ] || exit 1
# a flag is not something to open: Java probes for xdg-open by running it with
# one, and handing that over would open garbage on the host desktop
case "$arg" in
-*) exit 2 ;;
esac
# a link is the host portal's from here on; anything else falls back
open_portal() {
command -v gdbus >/dev/null 2>&1 || return 1
gdbus call --session --dest ${PORTAL_BUS} \\
--object-path /org/freedesktop/portal/desktop \\
--method ${PORTAL_METHOD} "" "$1" "{}" >/dev/null 2>&1
}
# a local document is not the portal's business - it cannot see this path
case "$arg" in
file:*) ;;
*://*|mailto:*) open_portal "$arg" && exit 0 ;;
esac
case "$arg" in
${cases}
esac
printf '%s\\n' "$arg" > "${request}.part" && mv "${request}.part" "${request}" || exit 1
`
}
// The stand-in for xdg-open inside the sandbox, and (same script) what the
// seeded desktop entry runs: a browser started in here would be the wrong one,
// with an empty profile growing inside the prefix.
function writeUrlShim(root, opts) {
return writeIfChanged(path.join(root, SHIM_REL), handOffScript(opts), 0o755)
}
function writeUrlOpener(root, opts) {
return writeIfChanged(path.join(root, OPENER_REL), handOffScript(opts), 0o755)
}
// The entry's display name. A translation that is not loaded answers with its
// own key, and that would end up in a chooser dialog, so it is never used.
function linkOpenerName() {
const text = i18n.translate(loadSettings().language, 'sandbox.linkOpener')
return text === 'sandbox.linkOpener' ? 'PLWL' : text
}
// GIO resolves an URI in-process (gtk_show_uri, `gio open`) and would pick a
// system browser in here: naming our opener the default for exactly these types
// changes only its answer. GIO's own portal path is not reachable (it wants a
// sandbox marker this launcher does not fake).
function writeMimeSeed(root, home) {
const desktop = [
'[Desktop Entry]',
'Type=Application',
`Name=${linkOpenerName()}`,
`Exec="${path.join(home, PLWL_DIR, OPENER_NAME)}" %u`,
`MimeType=${OPEN_TYPES.join(';')};`,
'NoDisplay=true',
'Terminal=false',
''
].join('\n')
writeIfChanged(path.join(root, DESKTOP_REL), desktop, 0o644)
const mime = ['[Default Applications]', ...OPEN_TYPES.map(type => `${type}=${OPENER_DESKTOP};`), ''].join('\n')
return writeIfChanged(path.join(root, MIME_REL), mime, 0o644)
}
// A bundle ships an xdg-open wrapper in its own bin/ (sharun's) and that bin is
// first in the app's PATH, so the wrapper - not /usr/bin - is what an app's
// opens really reach. It dies before delegating whenever the caller closes the
// child's streams, which is what Minecraft does right after Runtime.exec
// (measured: SIGPIPE, and no hand-off at all), so the launcher's shim is
// mounted over the bundle's own path as well. Names the bundle links to that
// file (its gio-launch-desktop) are answered by the shim itself - bwrap will
// not mount over a symlink, so they cannot be given a script of their own.
function bundleOpenMounts({ appDir, shim }) {
if (!appDir || !shim) return []
const wrapper = path.join(appDir, 'bin', SHIM_NAME)
return fs.existsSync(wrapper) ? [{ source: shim, target: wrapper }] : []
}
// Everything an opened thing needs: the hand-off script, the association naming
// it the default, and - in a sandbox - xdg-open replaced by the same script.
function urlRouting({ root, home, sandboxed, fsRoot = false, appDir = '' }) {
const target = OPEN_BINS.find(bin => fs.existsSync(bin)) || ''
const opts = { hostHome: path.join(root, 'home'), fsRoot, request: requestPath(home) }
if (!writeUrlOpener(root, opts)) return null
writeMimeSeed(root, home)
const shim = sandboxed && target ? writeUrlShim(root, opts) : ''
return { target, shim, mounts: bundleOpenMounts({ appDir, shim }) }
}
function hashOf(file) {
try {
return crypto.createHash('sha1').update(fs.readFileSync(file)).digest('hex')
} catch {
return ''
}
}
// Hashes of what we seeded, kept in the prefix root: the app only ever sees
// home/config/data/cache/state.
const SEED_MARKER = '.seeded.json'
// Keeps a seeded preference following the host while the copy is provably ours;
// 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
if (fs.existsSync(dst)) {
// only a marked copy is provably ours, and only while it is untouched
const current = hashOf(dst)
if (current !== marker[rel] || current === hostHash) continue
}
if (copyFromHost(src, dst, { refresh: true })) {
marker[rel] = hostHash
changed = true
}
}
if (changed) {
try {
writeJson(markerFile, marker)
} catch {}
}
}
// X11 access: a cookie is session state, not data, so XAUTHORITY is refreshed
// rather than seeded once.
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
]
}
// Drops what we seeded (and the hashes tracking it) so the next start seeds
// 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
}
// XWayland is the only place a window can carry its own icon, and GLFW/Qt/GTK
// all read these, so one switch moves the whole process tree 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', fsRoot = false }) {
const prefixName = prefixNameForGame(game)
const root = ensureLinuxPfx(prefixName)
syncDesktopConfig(root)
const homeRedirects = syncHomeRedirects(root, { relaxed: backend === 'bwrap' && fsRoot, appDirs: appShapes(game) })
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)
}
// The X session manager sits in the private /tmp, so its pointer would lead
// to a dead socket. XDG_RUNTIME_DIR stays the host's (it weakens isolation).
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
// an opened link or document leaves for the host desktop (see openrelay.js)
return { env, root, home, prefixName, prefixDir: prefixPath(prefixName), homeRedirects, routing: urlRouting({ root, home, sandboxed, fsRoot, appDir: writableAppDir(game) }) }
}
// Where a handed document goes inside the app's own home - a live bind, so a
// copy written here reaches an app that is already running too.
const HANDOFF_DIR = 'plwl-handoff'
// Copies above this are worth a popup: below it the copy is over before a
// window could paint, and the launcher would flash for nothing.
const STAGE_NOTICE_BYTES = 50 * 1000 * 1000
// Awaited because a shortcut boots HEADLESS: without a measurement "auto" would
// quietly pick the weaker level.
async function levelStatus(sandbox) {
if (!sandbox || typeof sandbox.status !== 'function') return null
const cached = sandbox.status()
if (cached) return cached
if (typeof sandbox.probe !== 'function') return null
try {
return await sandbox.probe()
} catch {
return null
}
}
// `auto` is the only level that may weaken itself: a machine without bwrap runs
// the app plain, while an explicit relaxed/isolated refuses instead.
async function resolveLevel({ game, sandbox }) {
const policy = (game && game.isolation) || 'auto'
const status = await levelStatus(sandbox)
const available = !!status && status.backend === 'bwrap'
if (policy === 'none') return { ok: true, level: 'none', backend: 'env', status }
if (policy === 'auto') {
return { ok: true, level: available ? 'relaxed' : 'none', backend: available ? 'bwrap' : 'env', status }
}
if (!available) return { ok: false, error: 'sandbox-required', level: policy, backend: 'env', status }
return { ok: true, level: policy, backend: 'bwrap', status }
}
// The line the app log and the UI both quote: a silent downgrade is the one
// failure this whole setting exists to avoid.
function isolationLine({ level, status }) {
const version = (status && status.version) || ''
if (level === 'none') {
const reason = (status && status.reason) || ''
return reason ? `[plwl] isolation: none (no sandbox: ${reason})` : '[plwl] isolation: none (plain run)'
}
const bwrap = version ? `bwrap ${version}` : 'bwrap'
if (level === 'relaxed') return `[plwl] isolation: relaxed (${bwrap}, host filesystem at ${FS_ROOT})`
return `[plwl] isolation: isolated (${bwrap})`
}
// "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
}
// The document a shortcut was handed, in the shape the chosen level can reach:
// none = the host path, relaxed = through FS_ROOT, isolated = a copy in the
// app's own home (refreshed on every open). '' when there is nothing to hand over.
async function handedPathFor({ openFile, level, root, home, onProgress }) {
if (!openFile) return ''
const abs = linuxPathFor(openFile)
if (level === 'relaxed') return path.join(FS_ROOT, abs)
if (level !== 'isolated') return abs
const name = path.basename(abs)
if (!name || name === '.' || name === '..') return ''
const stat = fs.statSync(abs)
if (!stat.isFile()) return ''
const total = stat.size
const notice = total >= STAGE_NOTICE_BYTES
const emit = (phase, bytes, error) => {
if (onProgress) onProgress({ phase, name, bytes: bytes || 0, totalBytes: total, notice, error: error || '' })
}
emit('copy', 0)
try {
await copyFileProgress(abs, path.join(root, 'home', HANDOFF_DIR, name), p => emit('copy', p.bytes))
} catch (err) {
emit('error', 0, String((err && err.message) || err))
throw err
}
emit('done', total)
return path.join(home, HANDOFF_DIR, name)
}
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, onProgress }) {
const chosen = await resolveLevel({ game, sandbox })
if (!chosen.ok) throw new Error(chosen.error)
const exe = game.exePath
const workdir = game.workdir || path.dirname(exe)
const { env, root, home, homeRedirects, routing } = buildLinuxEnv({ game, backend: chosen.backend, fsRoot: chosen.level === 'relaxed' })
const argList = parseArgs(game.args)
const handed = await handedPathFor({ openFile, level: chosen.level, root, home, onProgress })
if (handed) argList.push(handed)
const header = [isolationLine(chosen)]
if (homeRedirects.length) header.push(`[plwl] home redirects: ${homeRedirects.length} entries through ${FS_ROOT}`)
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), fsRoot: chosen.level === 'relaxed', urlOpener: routing }), '--', exe, ...argList]
header.push(`[plwl] HOME=${env.HOME}`)
}
return spawnTracked({ game, cmd, args, cwd: workdir, env, handoff, header, onExit })
}
module.exports = {
DESKTOP_SEEDS,
hostHomeEntries,
syncHomeRedirects,
HANDOFF_DIR,
STAGE_NOTICE_BYTES,
SHIM_REL,
OPENER_REL,
MIME_REL,
DESKTOP_REL,
writeUrlShim,
writeUrlOpener,
writeMimeSeed,
urlRouting,
writableAppDir,
ensureLinuxPfx,
resetSeededConfig,
dataShares,
buildLinuxEnv,
resolveLevel,
isolationLine,
handedPathFor,
runLinuxGame,
appShapes
}