improve native isolation

This commit is contained in:
olcxja 2026-10-07 08:26:57 +02:00
commit 9748b98163
15 changed files with 341 additions and 95 deletions

View file

@ -199,6 +199,49 @@ async function chmodTree(root) {
return { files, dirs }
}
// One file, streamed, with byte progress: copyFile says nothing until a
// multi-gigabyte copy is over. The bytes land on a .part name and are renamed
// at the end, so nothing can ever read a half-written document.
async function copyFileProgress(src, dst, onProgress) {
const stat = await fsp.stat(src)
if (!stat.isFile()) throw new Error('not a file: ' + src)
const gate = onProgress ? progressGate(onProgress) : null
await fsp.mkdir(path.dirname(dst), { recursive: true })
const part = dst + '.part'
let bytes = 0
try {
await new Promise((resolve, reject) => {
const input = fs.createReadStream(src)
const output = fs.createWriteStream(part)
const fail = err => {
input.destroy()
output.destroy()
reject(err)
}
input.on('error', fail)
output.on('error', fail)
input.on('data', chunk => {
bytes += chunk.length
if (gate) gate({ done: 0, bytes }, false)
})
output.on('finish', resolve)
input.pipe(output)
})
await fsp.chmod(part, stat.mode & 0o777)
await fsp.rename(part, dst)
} catch (err) {
try {
await fsp.rm(part, { force: true })
} catch {}
throw err
}
if (gate) {
gate({ done: 1, bytes: stat.size }, true)
gate.done()
}
return { files: 1, bytes: stat.size }
}
// Delete deepest-first with progress through onProgress; symlinks are
// unlinked, never followed.
async function removeTree(root, onProgress) {
@ -266,5 +309,5 @@ async function removeTree(root, onProgress) {
return { files, dirs, bytes }
}
module.exports = { pathExists, dedupeDir, copyTree, treeSize, removeTree, chmodTree, progressGate, mapLimit }
module.exports = { pathExists, dedupeDir, copyTree, copyFileProgress, treeSize, removeTree, chmodTree, progressGate, mapLimit }

View file

@ -23,7 +23,7 @@ function sendToWindow(win, channel, data) {
// onGamesChanged fires in the main process when the running set changes.
const STARTUP_GRACE_MS = 60000
function createGamesService({ getWindow, onGamesChanged, sandbox }) {
function createGamesService({ getWindow, onGamesChanged, sandbox, onHandoffProgress }) {
const running = new Map()
const startedAt = new Map()
const killRequests = new Set()
@ -98,6 +98,15 @@ function createGamesService({ getWindow, onGamesChanged, sandbox }) {
return payload => runLinuxGame({ game, sandbox, ...payload })
}
// An isolated hand-off copies the document before the app starts, which is
// long enough to be worth reporting. The window may not exist yet (a
// shortcut boots headless), so the main process gets the event first and
// decides whether to put a window in front of the user.
function handoffProgress(data) {
if (typeof onHandoffProgress === 'function') onHandoffProgress(data)
sendToWindow(getWindow(), 'handoff:progress', data)
}
async function start(win, id, openFile) {
const game = library.getGame(id)
if (!game) return { ok: false, error: 'game-not-found' }
@ -116,7 +125,7 @@ function createGamesService({ getWindow, onGamesChanged, sandbox }) {
// a document into a running app is a handoff, not a tracked run
if (!openFile) return { ok: false, error: 'already-running' }
try {
await run({ openFile, handoff: true })
await run({ openFile, handoff: true, onProgress: handoffProgress })
return { ok: true, pid: null, logFile: null }
} catch (err) {
return { ok: false, error: String((err && err.message) || err) }
@ -125,7 +134,7 @@ function createGamesService({ getWindow, onGamesChanged, sandbox }) {
running.set(id, { pid: null, prefix: library.prefixNameForGame(game), wine: wine ? wine.id : '' })
startedAt.set(id, Date.now())
try {
const res = await run({ onExit: () => handleExit(id), openFile })
const res = await run({ onExit: () => handleExit(id), openFile, onProgress: handoffProgress })
// stop requested while still starting: kill and clear
if (killRequests.has(id)) {
killRequests.delete(id)

View file

@ -51,10 +51,10 @@ function extractGameIcon(id, exePath, kind) {
// --- registration -----------------------------------------------------------
function registerIpc({ getWindow, onGamesChanged }) {
function registerIpc({ getWindow, onGamesChanged, onHandoffProgress }) {
// one probe per launcher, cached: it decides bwrap vs plain run
const sandbox = createSandboxProbe()
const games = createGamesService({ getWindow, onGamesChanged, sandbox })
const games = createGamesService({ getWindow, onGamesChanged, sandbox, onHandoffProgress })
// sandbox --------------------------------------------------------------------
ipcMain.handle('sandbox:status', async () => sandbox.status() || (await sandbox.probe()))

View file

@ -38,6 +38,8 @@ function loadGames() {
g[k] = usePrefix && isPrefixRel(g[k]) ? path.join(usePrefix, g[k]) : resolvePortablePath(g[k])
}
if (g.iconSource) g.iconSource = resolvePortablePath(g.iconSource)
// an entry written by an older launcher keeps its meaning
if (g.isolation !== undefined) g.isolation = isolationMode(g.isolation)
const ps = readPlaySeconds(g.id)
if (ps != null) g.playSeconds = ps
}
@ -114,17 +116,26 @@ function isLinuxGame(game) {
return !!game && game.kind === 'linux'
}
const ISOLATION_MODES = ['auto', 'off', 'require']
// none = plain run, relaxed = bwrap + the whole host filesystem mounted rw,
// isolated = bwrap + handed documents copied in, auto = relaxed where bwrap
// works and none where it does not.
const ISOLATION_MODES = ['auto', 'none', 'relaxed', 'isolated']
// Modes written by launcher 0.5.x: `off` was the plain run, `require` was
// "bwrap or refuse" - which is exactly what the strictest level means now.
const LEGACY_ISOLATION_MODES = { off: 'none', require: 'isolated' }
// One known mode, always: an unknown value would otherwise reach the runner
// and pick something nobody asked for.
function isolationMode(value) {
const mode = LEGACY_ISOLATION_MODES[value] || value
return ISOLATION_MODES.includes(mode) ? mode : 'auto'
}
// Only known values reach the registry; anything else falls back to the safe
// default (a typo must not silently disable the sandbox).
function sanitizeLinuxPatch(patch) {
if (!patch) return patch
const out = { ...patch }
if (out.kind !== undefined) out.kind = out.kind === 'linux' ? 'linux' : 'windows'
if (out.isolation !== undefined) {
out.isolation = ISOLATION_MODES.includes(out.isolation) ? out.isolation : 'auto'
}
if (out.isolation !== undefined) out.isolation = isolationMode(out.isolation)
if (out.forceX11 !== undefined) out.forceX11 = !!out.forceX11
return out
}
@ -211,7 +222,7 @@ function newGame({ id, name, exePath, workdir, mode, kind }) {
dllOverrides: [],
// Proton-only: UMU by default, or the build's steam.exe launcher
disableUmu: false,
// Linux-only: how hard to try the bwrap sandbox before falling back
// Linux-only: none, relaxed, isolated, or auto (relaxed where bwrap works)
isolation: 'auto',
// Linux-only: force XWayland, where a window can still carry its icon
forceX11: false,

View file

@ -13,7 +13,8 @@ const { prefixPath, prefixLinuxRoot } = require('./prefixes')
const { prefixNameForGame, loadSettings } = require('./library')
const i18n = require('./i18n')
const { spawnTracked, parseArgs } = require('./runner')
const { buildBwrapArgs, SHARE_ROOT } = require('./sandbox')
const { buildBwrapArgs, SHARE_ROOT, FS_ROOT } = require('./sandbox')
const { copyFileProgress } = require('./fsutil')
const DATA_DIRS = ['home', 'config', 'data', 'cache', 'state']
@ -424,21 +425,58 @@ function buildLinuxEnv({ game, backend = 'env' }) {
return { env, root, home, prefixName, prefixDir: prefixPath(prefixName), routing: urlRouting({ root, home, sandboxed }) }
}
// 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 }
// Where a handed document goes inside the app's own home. The prefix home is
// a live bind of a host folder, so a copy written here is visible to an app
// that is already running - the one route a running namespace can be reached
// through at all.
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
// The probe measures once and answers from its cache afterwards. It is awaited
// here because a shortcut boots HEADLESS: no renderer ran, so nothing else
// would have measured, and "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
}
}
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})`
// bwrap when it works, plain run otherwise - unless the app demands a sandbox.
// `auto` is the only level allowed to take a weaker one: a machine that cannot
// sandbox runs the app plain, while an explicit relaxed/isolated refuses
// instead of quietly dropping the promise. (Values written by launcher 0.5.x
// are mapped to these four by library.js.)
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 /).
@ -450,24 +488,39 @@ function linuxPathFor(file) {
return abs
}
// The document a shortcut was handed. The sandbox cannot hand it over by
// itself: a file out of the user's home is buried under the prefix home, one
// out of /tmp sits behind the private tmpfs, and the rest of the machine is
// read-only. So it is mounted where the app was told it is, writable - the way
// a Wine app gets it as Z:\... and may save it. Prefix data needs nothing: it
// already is the app's own writable home.
function handedFileBinds({ openFile, root }) {
if (!openFile) return []
let abs = ''
try {
abs = fs.realpathSync(String(openFile))
if (!fs.statSync(abs).isFile()) return []
} catch {
// a path that is gone must not become a bind bwrap would refuse
return []
// The document a shortcut was handed, in the one shape the chosen level can
// reach - and the app is told nothing else, so a second hand-off names the
// same file the same way.
// none the host path, exactly what a shell would name
// relaxed the same file through the whole-host mount every run carries
// isolated a copy in the app's own home, refreshed on every open (one copy
// per name, so the folder cannot grow; the app's edits stay in the
// copy - that is this level's promise)
// Returns '' when there is nothing to hand over, so argv stays honest.
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 || '' })
}
if (abs === root || abs.startsWith(root + path.sep)) return []
return [abs]
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() {
@ -483,16 +536,17 @@ function bwrapSource() {
}
}
async function runLinuxGame({ game, sandbox, onExit, openFile, handoff = false }) {
const chosen = pickBackend({ game, sandbox })
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, routing } = buildLinuxEnv({ game, backend: chosen.backend })
const argList = parseArgs(game.args)
if (openFile) argList.push(linuxPathFor(openFile))
const handed = await handedPathFor({ openFile, level: chosen.level, root, home, onProgress })
if (handed) argList.push(handed)
const { env, root, routing } = buildLinuxEnv({ game, backend: chosen.backend })
const header = [isolationLine(chosen)]
if (game.forceX11) header.push('[plwl] x11 forced (XWayland)')
@ -501,7 +555,7 @@ async function runLinuxGame({ game, sandbox, onExit, openFile, handoff = false }
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), openFiles: handedFileBinds({ openFile, root }), urlOpener: routing }), '--', exe, ...argList]
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}`)
}
@ -510,6 +564,8 @@ async function runLinuxGame({ game, sandbox, onExit, openFile, handoff = false }
module.exports = {
DESKTOP_SEEDS,
HANDOFF_DIR,
STAGE_NOTICE_BYTES,
SHIM_REL,
OPENER_REL,
MIME_REL,
@ -525,9 +581,9 @@ module.exports = {
resetSeededConfig,
dataShares,
buildLinuxEnv,
pickBackend,
resolveLevel,
isolationLine,
linuxPathFor,
handedFileBinds,
handedPathFor,
runLinuxGame
}

View file

@ -154,6 +154,15 @@ function loadLangSafe() {
}
}
// An isolated hand-off copies the document before the app starts. When that
// copy is big enough to be noticed (linuxrunner decides) the user gets a
// window with the progress in it - a shortcut boots headless, so without this
// a 2 GB document would look like nothing happening at all.
function onHandoffProgress(data) {
if (!data || !data.notice || data.phase !== 'copy') return
if (!win || win.isDestroyed() || !win.isVisible()) showMainWindow()
}
function showMainWindow() {
// the user is present now - headless auto-quit must never fire again
headless = false
@ -226,7 +235,7 @@ app.whenReady().then(async () => {
ensureDirs()
await i18n.init()
await wines.refreshWines()
const { games } = registerIpc({ getWindow, onGamesChanged: rebuildTrayMenu })
const { games } = registerIpc({ getWindow, onGamesChanged: rebuildTrayMenu, onHandoffProgress })
gamesService = games
// the tray comes first: nothing may keep it from coming up

View file

@ -15,6 +15,8 @@ function singleSlot(channel) {
}
const onImportProgress = singleSlot('import:progress')
// "Open with" into an isolated app: the document is copied first
const onHandoffProgress = singleSlot('handoff:progress')
// "Open with PLWL": files handed over via --open (Exec %f).
const onOpenWith = singleSlot('openwith:file')
// one sync (and one pending consent request, enforced server-side) at a time
@ -84,6 +86,7 @@ contextBridge.exposeInMainWorld('plwl', {
shellOpenPath: p => ipcRenderer.invoke('shell:openPath', p),
shellShowItemInFolder: p => ipcRenderer.invoke('shell:showItemInFolder', p),
onImportProgress: onImportProgress,
onHandoffProgress: onHandoffProgress,
onGamesChanged: cb => ipcRenderer.on('games:changed', () => cb()),
lansyncGet: () => ipcRenderer.invoke('lansync:get'),
lansyncDevices: () => ipcRenderer.invoke('lansync:devices'),

View file

@ -99,6 +99,14 @@ function createSandboxProbe({ timeoutMs = PROBE_TIMEOUT_MS } = {}) {
// prefix is mounted over ~, so a link back into ~ would point at itself.
const SHARE_ROOT = '/tmp/.plwl-host'
// Relaxed isolation hands the app the whole host filesystem as ONE writable
// mount. It has to sit under the private /tmp: bubblewrap creates a mount
// point itself, and the root of a read-only root cannot be written (measured:
// "Can't create file /PLWL_FS_ROOT: Read-only file system"). Being there, one
// path string works in every run - which is what a hand-off into an app that
// is ALREADY running needs, since a mount cannot be added to a live namespace.
const FS_ROOT = '/tmp/plwl-fs-root'
// The bwrap profile for a native Linux app. Pure: the caller decides which
// devices and host folders exist on this machine, so the argv is testable
// as-is.
@ -115,7 +123,7 @@ function buildBwrapArgs({
etcRoot = '',
unshareNet = false,
appDir = '',
openFiles = [],
fsRoot = false,
urlOpener = null
}) {
const xdgDir = sub => path.join(home, sub)
@ -134,6 +142,11 @@ function buildBwrapArgs({
'--tmpfs', '/tmp',
// X11 sockets live in /tmp, which the tmpfs above just emptied
...(x11 ? ['--ro-bind', x11, x11] : []),
// relaxed: the host filesystem, whole and writable. Nothing is hidden in
// here, so a document handed over later can be opened - and saved - where
// the app was told it is. The price is the filesystem promise itself: an
// app at this level may write anywhere on the machine.
...(fsRoot ? ['--bind', '/', FS_ROOT] : []),
// the host's theme data, read-only: fonts, icons, colour schemes
...shares.flatMap(dir => ['--ro-bind', dir, path.join(SHARE_ROOT, path.basename(dir))]),
// a link clicked in here belongs to the host desktop: xdg-open is replaced
@ -153,10 +166,6 @@ function buildBwrapArgs({
'--bind', path.join(dataRoot, 'data'), xdgDir(path.join('.local', 'share')),
'--bind', path.join(dataRoot, 'cache'), xdgDir('.cache'),
'--bind', path.join(dataRoot, 'state'), xdgDir(path.join('.local', 'state')),
// a file handed over from the desktop, mounted where the app was told it
// is: the prefix home sits ON TOP of the user's real home and /tmp is
// emptied, so such a document would otherwise simply not be there
...openFiles.flatMap(file => ['--bind', file, file]),
// Wayland, PipeWire, PulseAudio and the session bus all live here
...(runUser ? ['--bind', runUser, runUser] : []),
...(etcRoot ? ['--bind', etcRoot, '/etc'] : []),
@ -169,4 +178,4 @@ function buildBwrapArgs({
]
}
module.exports = { createSandboxProbe, buildBwrapArgs, SHARE_ROOT }
module.exports = { createSandboxProbe, buildBwrapArgs, SHARE_ROOT, FS_ROOT }