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