[EXPERIMENTAL] linux apps support

This commit is contained in:
olcxja 2026-10-06 20:54:16 +02:00
commit e7636afe3d
20 changed files with 1377 additions and 101 deletions

View file

@ -161,6 +161,44 @@ async function copyTree(src, dst, onProgress) {
return { done, bytes }
}
// Imported trees come from media that lose permissions (archives, FAT, a
// read-only backup drive), and copyFile keeps whatever the source had: every
// node gets owner rwx and `x` mirrored from `r`, so the app can run its own
// binaries and write its own files.
async function chmodTree(root) {
const desired = mode => mode | 0o700 | ((mode & 0o444) >> 2)
let files = 0
let dirs = 0
const walk = async dir => {
let entries
try {
entries = await fsp.readdir(dir, { withFileTypes: true })
} catch {
return
}
for (const entry of entries) {
const full = path.join(dir, entry.name)
if (entry.isSymbolicLink()) continue
if (entry.isDirectory()) {
await walk(full)
} else if (!entry.isFile()) {
continue
}
try {
const before = (await fsp.stat(full)).mode & 0o7777
const after = desired(before)
if (after !== before) {
await fsp.chmod(full, after)
if (entry.isDirectory()) dirs++
else files++
}
} catch {}
}
}
await walk(root)
return { files, dirs }
}
// Delete deepest-first with progress through onProgress; symlinks are
// unlinked, never followed.
async function removeTree(root, onProgress) {
@ -228,5 +266,5 @@ async function removeTree(root, onProgress) {
return { files, dirs, bytes }
}
module.exports = { pathExists, dedupeDir, copyTree, treeSize, removeTree, progressGate, mapLimit }
module.exports = { pathExists, dedupeDir, copyTree, treeSize, removeTree, chmodTree, progressGate, mapLimit }

View file

@ -2,6 +2,7 @@
const fs = require('fs')
const path = require('path')
const { desktopFileFor, readDesktopEntry, findIconFile, pngOrSvg } = require('./linuxapp')
function rvaToOffset(sections, rva) {
for (const s of sections) {
@ -208,4 +209,35 @@ function extractIconFromExe(exePath, outPath) {
}
}
module.exports = { extractIconFromExe, parseIcons, buildIco }
// Native Linux apps carry no icon inside the binary: it comes from the
// .desktop entry (Icon=), an image next to the program, or the theme.
// `outBase` is a path without extension; the sniffed one is appended.
function iconFromLinuxApp(exePath, outBase) {
try {
const desktop = desktopFileFor(exePath)
const entry = desktop ? readDesktopEntry(desktop) : null
const baseDir = desktop ? path.dirname(desktop) : path.dirname(exePath)
let source = findIconFile(entry && entry.Icon, baseDir)
if (!source) {
// no .desktop: a same-named image (or the AppImage .DirIcon) next to it
const dir = path.dirname(exePath)
const stem = path.basename(exePath).replace(/\.(appimage|sh|bin)$/i, '')
for (const name of [stem + '.png', stem + '.svg', '.DirIcon', 'icon.png', 'icon.svg']) {
const candidate = path.join(dir, name)
if (pngOrSvg(candidate)) {
source = candidate
break
}
}
}
if (!source) return { ok: false, error: 'no-icon' }
const outPath = `${outBase}.${pngOrSvg(source)}`
fs.mkdirSync(path.dirname(outPath), { recursive: true })
fs.copyFileSync(source, outPath)
return { ok: true, path: outPath }
} catch (err) {
return { ok: false, error: String((err && err.message) || err) }
}
}
module.exports = { extractIconFromExe, iconFromLinuxApp, parseIcons, buildIco }

View file

@ -5,10 +5,11 @@ const fsp = fs.promises
const path = require('path')
const { DIRS, resolvePortablePath } = require('./paths')
const { newGame, newGameId, addGame } = require('./library')
const { extractIconFromExe } = require('./icons')
const { extractIconFromExe, iconFromLinuxApp } = require('./icons')
const { findLinuxExe } = require('./linuxapp')
const { listPrefixes, prefixDriveRoots, isPrefixRoot } = require('./prefixes')
const { rewireSymlinks } = require('./symlinks')
const { copyTree, treeSize } = require('./fsutil')
const { copyTree, treeSize, chmodTree } = require('./fsutil')
// Directories that typically hold installers/redistributables, not the game.
const SKIPPED_DIRS = new Set(['redist', 'directx', '_commonredist', 'support', 'dotnet', 'vc'])
@ -21,13 +22,16 @@ function moveFolder(src, dst, onProgress) {
return fsp.rename(src, dst).catch(fallback)
}
async function importGame(srcDir, mode, onProgress) {
async function importGame(srcDir, mode, onProgress, kind = 'windows') {
const isLinux = kind === 'linux'
// a prefix (or its drive_c/pfx) is not an app folder
if (isPrefixRoot(srcDir)) return { ok: false, error: 'prefix-root' }
const name = path.basename(srcDir)
// validate before touching anything: no .exe -> refuse
const preExe = await findDefaultExe(srcDir)
if (!preExe) return { ok: false, error: 'no-exe-found' }
const finder = isLinux ? findLinuxExe : findDefaultExe
const missing = isLinux ? 'no-linux-exe' : 'no-exe-found'
// validate before touching anything: no entry point -> refuse
const preExe = await finder(srcDir)
if (!preExe) return { ok: false, error: missing }
// the id doubles as the imported folder name
const id = newGameId()
let workdir = srcDir
@ -43,21 +47,27 @@ async function importGame(srcDir, mode, onProgress) {
await copyTree(srcDir, dst, p => emit('copy', { files: p.done, bytes: p.bytes, totalFiles: total.files, totalBytes: total.bytes }))
} else await moveFolder(srcDir, dst, onProgress)
workdir = dst
// the source's permissions are not the source's truth: an app out of a
// read-only or mode-less medium has to become runnable and writable
await chmodTree(dst)
// in-data link targets become relative, so the import survives a move
try {
await rewireSymlinks(dst, [DIRS.FILES])
} catch {}
}
const exePath = (await findDefaultExe(workdir)) || preExe
const exePath = (await finder(workdir)) || preExe
if (!exePath) {
return { ok: false, error: 'no-exe-found' }
return { ok: false, error: missing }
}
const game = newGame({ id, name, exePath, workdir, mode })
const game = newGame({ id, name, exePath, workdir, mode, kind })
game.imported = mode === 'copy' || mode === 'move'
// auto-picked exe -> grab its icon; a missing icon is not a failure
const icon = path.join(DIRS.DATA, 'icons', id, 'icon.ico')
if (extractIconFromExe(resolvePortablePath(exePath), icon).ok) {
game.iconSource = icon
const iconDir = path.join(DIRS.DATA, 'icons', id)
const icon = isLinux
? iconFromLinuxApp(resolvePortablePath(exePath), path.join(iconDir, 'icon'))
: extractIconFromExe(resolvePortablePath(exePath), path.join(iconDir, 'icon.ico'))
if (icon.ok) {
game.iconSource = icon.path
}
return addGame(game)
}
@ -140,4 +150,4 @@ async function findDefaultExe(dir) {
return best
}
module.exports = { importGame, importGameFromPrefix, findDefaultExe, moveFolder }
module.exports = { importGame, importGameFromPrefix, findDefaultExe, findLinuxExe, moveFolder }

View file

@ -1,6 +1,7 @@
'use strict'
const { runGame, runTool } = require('../runner')
const { runLinuxGame } = require('../linuxrunner')
const { importGame, importGameFromPrefix } = require('../importer')
const library = require('../library')
@ -22,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 }) {
function createGamesService({ getWindow, onGamesChanged, sandbox }) {
const running = new Map()
const startedAt = new Map()
const killRequests = new Set()
@ -91,30 +92,40 @@ function createGamesService({ getWindow, onGamesChanged }) {
if (onGameExit) onGameExit(id)
}
// Native Linux apps skip wine entirely; everything else stays the same.
function runnerFor(game) {
if (!library.isLinuxGame(game)) return null
return payload => runLinuxGame({ game, sandbox, ...payload })
}
async function start(win, id, openFile) {
const game = library.getGame(id)
if (!game) return { ok: false, error: 'game-not-found' }
prune()
let wine
try {
wine = resolveWine(game)
} catch {
return { ok: false, error: 'wine-not-found' }
const isLinux = library.isLinuxGame(game)
let wine = null
if (!isLinux) {
try {
wine = resolveWine(game)
} catch {
return { ok: false, error: 'wine-not-found' }
}
}
const run = isLinux ? runnerFor(game) : payload => runGame({ game, wine, systemWine: wine.type === 'system', ...payload })
if (running.has(id)) {
// a document into a running app is a handoff, not a tracked run
if (!openFile) return { ok: false, error: 'already-running' }
try {
await runGame({ game, wine, systemWine: wine.type === 'system', openFile, handoff: true })
await run({ openFile, handoff: true })
return { ok: true, pid: null, logFile: null }
} catch (err) {
return { ok: false, error: String((err && err.message) || err) }
}
}
running.set(id, { pid: null, prefix: library.prefixNameForGame(game), wine: wine.id })
running.set(id, { pid: null, prefix: library.prefixNameForGame(game), wine: wine ? wine.id : '' })
startedAt.set(id, Date.now())
try {
const res = await runGame({ game, wine, systemWine: wine.type === 'system', onExit: () => handleExit(id), openFile })
const res = await run({ onExit: () => handleExit(id), openFile })
// stop requested while still starting: kill and clear
if (killRequests.has(id)) {
killRequests.delete(id)
@ -158,6 +169,8 @@ function createGamesService({ getWindow, onGamesChanged }) {
async function runToolFor(id, tool, args) {
const game = library.getGame(id)
if (!game) return { ok: false, error: 'game-not-found' }
// Wine tools make no sense for a native Linux app
if (library.isLinuxGame(game)) return { ok: false, error: 'not-windows' }
let wine
try {
wine = resolveWine(game)
@ -177,8 +190,8 @@ function createGamesService({ getWindow, onGamesChanged }) {
onGameExit = typeof fn === 'function' ? fn : null
}
async function importTo(win, srcDir, mode) {
return importGame(srcDir, mode, data => sendToWindow(win, 'import:progress', data))
async function importTo(win, srcDir, mode, kind) {
return importGame(srcDir, mode, data => sendToWindow(win, 'import:progress', data), kind)
}
// prefix import: register in place, nothing copied

View file

@ -9,7 +9,9 @@ const library = require('../library')
const wines = require('../wines')
const prefixes = require('../prefixes')
const runner = require('../runner')
const { extractIconFromExe } = require('../icons')
const linuxrunner = require('../linuxrunner')
const { createSandboxProbe } = require('../sandbox')
const { extractIconFromExe, iconFromLinuxApp } = require('../icons')
const { createGamesService, sendToWindow } = require('./games')
const shortcuts = require('../shortcuts')
const openwith = require('../openwith')
@ -34,13 +36,15 @@ function clearIconDir(id, keep) {
} catch {}
}
function extractGameIcon(id, exePath) {
const out = path.join(iconDir(id), 'icon.ico')
const res = extractIconFromExe(exePath, out)
function extractGameIcon(id, exePath, kind) {
const isLinux = kind === 'linux'
// the Linux helper picks the extension itself (png/svg), the PE one is ico
const out = path.join(iconDir(id), isLinux ? 'icon' : 'icon.ico')
const res = isLinux ? iconFromLinuxApp(exePath, out) : extractIconFromExe(exePath, out)
// on failure nothing is cleared, so the previous icon survives a wrong exe
if (res.ok) {
clearIconDir(id, 'icon.ico')
library.updateGame(id, { iconSource: out })
clearIconDir(id, path.basename(res.path))
library.updateGame(id, { iconSource: res.path })
}
return res
}
@ -48,7 +52,16 @@ function extractGameIcon(id, exePath) {
// --- registration -----------------------------------------------------------
function registerIpc({ getWindow, onGamesChanged }) {
const games = createGamesService({ getWindow, onGamesChanged })
// one probe per launcher, cached: it decides bwrap vs plain run
const sandbox = createSandboxProbe()
const games = createGamesService({ getWindow, onGamesChanged, sandbox })
// sandbox --------------------------------------------------------------------
ipcMain.handle('sandbox:status', async () => sandbox.status() || (await sandbox.probe()))
ipcMain.handle('sandbox:probe', async () => {
sandbox.invalidate()
return sandbox.probe()
})
// i18n ---------------------------------------------------------------------
ipcMain.handle('i18n:list', async () => {
@ -107,6 +120,12 @@ function registerIpc({ getWindow, onGamesChanged }) {
return prefixes.deletePrefix(name, data => sendToWindow(getWindow(), 'import:progress', data))
})
ipcMain.handle('prefixes:rename', (e, oldName, newName) => prefixInUse(oldName) || prefixes.renamePrefix(oldName, newName))
// The seeded desktop config is ours to drop (app data is not a seed), but a
// running app would lose the files under its feet.
ipcMain.handle('prefixes:resetLinuxConfig', (e, name) => {
if (!prefixes.listPrefixes().prefixes.some(p => p.name === name)) return { ok: false, error: 'notFound' }
return prefixInUse(name) || linuxrunner.resetSeededConfig(name)
})
ipcMain.handle('prefixes:setDefault', (e, name) => {
// switching the default would silently move apps that follow it
const defs = prefixes.listPrefixes()
@ -175,7 +194,7 @@ function registerIpc({ getWindow, onGamesChanged }) {
ipcMain.handle('games:extractIcon', (e, id) => {
const game = library.getGame(id)
if (!game || !game.exePath) return { ok: false, error: 'bad-request' }
return extractGameIcon(id, game.exePath)
return extractGameIcon(id, game.exePath, game.kind)
})
// use the exe the field holds (maybe picked but not saved yet)
ipcMain.handle('games:autoIcon', (e, id, exePath) => {
@ -183,7 +202,19 @@ function registerIpc({ getWindow, onGamesChanged }) {
if (!game) return { ok: false, error: 'bad-request' }
const exe = exePath || game.exePath
if (!exe) return { ok: false, error: 'bad-request' }
return extractGameIcon(id, exe)
return extractGameIcon(id, exe, game.kind)
})
// Linux apps keep their data next to the prefix's Windows side
ipcMain.handle('games:openDataFolder', async (e, id) => {
const game = library.getGame(id)
if (!library.isLinuxGame(game)) return { ok: false, error: 'not-linux' }
try {
const root = linuxrunner.ensureLinuxPfx(library.prefixNameForGame(game))
const err = await shell.openPath(root)
return err ? { ok: false, error: err } : { ok: true, path: root }
} catch (err) {
return errorResult(err)
}
})
// "Open with PLWL" ------------------------------------------------------------------
@ -226,9 +257,9 @@ function registerIpc({ getWindow, onGamesChanged }) {
})
return res.canceled || !res.filePaths[0] ? null : res.filePaths[0]
})
ipcMain.handle('games:import', async (e, srcDir, mode) => {
ipcMain.handle('games:import', async (e, srcDir, mode, kind) => {
try {
return await games.import(getWindow(), srcDir, mode)
return await games.import(getWindow(), srcDir, mode, kind)
} catch (err) {
return errorResult(err)
}

View file

@ -6,6 +6,7 @@
const path = require('path')
const fs = require('fs')
const fsp = fs.promises
const { isLocalOnlyRel } = require('./prefixes')
function createManifestBuilder({ filesPath, filesRoot, hash, mapLimit, concurrency }) {
// Targets inside the launcher data travel relative (the mirrored layout is
@ -38,6 +39,8 @@ function createManifestBuilder({ filesPath, filesRoot, hash, mapLimit, concurren
const leaves = []
for (const entry of entries) {
const relChild = `${dirRel}/${entry.name}`
// native Linux app data never leaves this machine
if (isLocalOnlyRel(relChild)) continue
if (entry.isDirectory()) {
const record = { path: relChild, type: 'dir' }
out.push(record)

View file

@ -21,7 +21,7 @@ const { createDiscovery } = require('./lansync-discovery')
const library = require('./library')
const wines = require('./wines')
const prefixes = require('./prefixes')
const { isPrefixRel } = prefixes
const { isPrefixRel, isLocalOnlyRel } = prefixes
const CONFIG = path.join(DIRS.DATA, 'lansync.json')
@ -432,6 +432,8 @@ async function pruneTree(abs, rel, keep) {
for (const entry of entries) {
const childAbs = path.join(abs, entry.name)
const childRel = `${rel}/${entry.name}`
// a mirror must not delete this machine's native Linux app data
if (isLocalOnlyRel(childRel)) continue
if (!keep.has(childRel)) {
if (removeAbs(childAbs)) removed++
continue

View file

@ -108,6 +108,27 @@ function gamesFollowingDefault(running) {
})
}
// Windows is the default: entries written before Linux apps existed have no
// kind at all.
function isLinuxGame(game) {
return !!game && game.kind === 'linux'
}
const ISOLATION_MODES = ['auto', 'off', 'require']
// 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.forceX11 !== undefined) out.forceX11 = !!out.forceX11
return out
}
function addGame(game) {
const games = loadGames()
games.push(game)
@ -127,7 +148,7 @@ function updateGame(id, patch) {
}
// playSeconds lives in its own file; the registry mirrors it
if (patch.playSeconds !== undefined) writePlaySeconds(id, patch.playSeconds)
Object.assign(g, patch)
Object.assign(g, sanitizeLinuxPatch(patch))
saveGames(games)
return g
}
@ -175,11 +196,12 @@ function newGameId() {
return id
}
function newGame({ id, name, exePath, workdir, mode }) {
function newGame({ id, name, exePath, workdir, mode, kind }) {
return {
id: id || newGameId(),
name,
mode,
kind: kind === 'linux' ? 'linux' : 'windows',
path: workdir,
exePath,
workdir,
@ -189,6 +211,10 @@ function newGame({ id, name, exePath, workdir, mode }) {
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
isolation: 'auto',
// Linux-only: force XWayland, where a window can still carry its icon
forceX11: false,
env: {},
iconSource: '',
playSeconds: 0,
@ -232,4 +258,4 @@ function resolvePrefixDir(game) {
return prefixPath(prefixNameForGame(game))
}
module.exports = { loadGames, saveGames, portableGame, getGame, addGame, updateGame, removeGame, reorderGames, renamePrefixRefs, prefixNameForGame, gamesUsingPrefix, appsUsingPrefix, isPrefixInUse, isWineInUse, gamesFollowingDefault, loadSettings, saveSettings, newGame, newGameId, resolvePrefixDir, clearPlaytime, readPlaySeconds, writePlaySeconds, playtimeFile }
module.exports = { loadGames, saveGames, portableGame, getGame, isLinuxGame, addGame, updateGame, removeGame, reorderGames, renamePrefixRefs, prefixNameForGame, gamesUsingPrefix, appsUsingPrefix, isPrefixInUse, isWineInUse, gamesFollowingDefault, loadSettings, saveSettings, newGame, newGameId, resolvePrefixDir, clearPlaytime, readPlaySeconds, writePlaySeconds, playtimeFile }

265
src/linuxapp.js Normal file
View file

@ -0,0 +1,265 @@
'use strict'
// What makes a file a runnable native Linux app: an ELF binary or an AppImage
// (both with the executable bit), or a .desktop entry pointing at one.
const fs = require('fs')
const fsp = fs.promises
const os = require('os')
const path = require('path')
const ELF_MAGIC = Buffer.from([0x7f, 0x45, 0x4c, 0x46])
// Folders that ship libraries/data, never the entry point. Skipping them is
// what keeps `share/` and `lib/` .desktop files out of the candidates.
const SKIPPED_DIRS = new Set([
'lib', 'lib32', 'lib64', 'libexec', 'share', 'include', 'man', 'doc', 'docs',
'locale', 'node_modules', 'redist', 'python', 'site-packages', '__pycache__',
'translations', 'resources', 'licenses', 'locale'
])
const LIBRARY_RE = /\.(so(\.\d+(\.\d+)*)?|a|o|py|pyc|jar|dll|dylib)$/i
const SCRIPT_RE = /^(run|start|launch|play|app)\.sh$/i
function isElfBuffer(buf) {
return !!buf && buf.length >= 4 && buf.subarray(0, 4).equals(ELF_MAGIC)
}
function isElfSync(file) {
try {
const fd = fs.openSync(file, 'r')
try {
const buf = Buffer.alloc(4)
fs.readSync(fd, buf, 0, 4, 0)
return isElfBuffer(buf)
} finally {
fs.closeSync(fd)
}
} catch {
return false
}
}
// [Desktop Entry] fields we care about; the first group wins, later groups
// (Desktop Action ...) are ignored.
function parseDesktopEntry(text) {
const entry = {}
let inEntry = false
for (const raw of String(text || '').split('\n')) {
const line = raw.trim()
if (line.startsWith('[')) {
inEntry = line === '[Desktop Entry]'
continue
}
if (!inEntry || !line || line.startsWith('#')) continue
const eq = line.indexOf('=')
if (eq <= 0) continue
const key = line.slice(0, eq).trim()
const value = line.slice(eq + 1).trim()
if (key === 'Exec' || key === 'Icon' || key === 'Path' || key === 'Name' || key === 'Type') {
if (!(key in entry)) entry[key] = value
}
}
return entry
}
function readDesktopEntry(file) {
try {
return parseDesktopEntry(fs.readFileSync(file, 'utf8'))
} catch {
return null
}
}
// Exec is a command line with field codes: the first token is the program.
function execToken(exec) {
const m = String(exec || '').match(/"([^"]*)"|'([^']*)'|(\S+)/)
const token = m ? (m[1] ?? m[2] ?? m[3]) : ''
return token && !/^%[fFuUdDnNickvm]?$/.test(token) ? token : ''
}
// A .desktop's program, resolved against the entry's own folder. Relative
// Exec= paths are common in app dirs; the spec says that is not allowed, so
// the fallback is to the folder itself.
function desktopExecPath(entry, desktopFile) {
const token = execToken(entry && entry.Exec)
if (!token) return ''
const dir = path.dirname(desktopFile)
const resolved = path.isAbsolute(token) ? token : path.resolve(dir, token)
try {
fs.accessSync(resolved)
return resolved
} catch {
const base = path.join(dir, path.basename(token))
try {
fs.accessSync(base)
return base
} catch {
return ''
}
}
}
function isExecutable(file) {
try {
return (fs.statSync(file).mode & 0o111) !== 0
} catch {
return false
}
}
async function sizeOf(file) {
try {
return (await fsp.stat(file)).size
} catch {
return 0
}
}
// Best entry point in an app folder: the same question findDefaultExe answers
// for Windows, with ELF/AppImage/.desktop instead of .exe.
async function findLinuxExe(dir) {
const candidates = []
const desktops = []
const walk = async (d, depth) => {
let entries
try {
entries = await fsp.readdir(d, { withFileTypes: true })
} catch {
return
}
const subdirs = []
for (const entry of entries) {
const full = path.join(d, entry.name)
if (entry.isDirectory()) {
if (!SKIPPED_DIRS.has(entry.name.toLowerCase())) subdirs.push([full, depth + 1])
continue
}
const lower = entry.name.toLowerCase()
if (lower.endsWith('.desktop')) {
desktops.push([full, depth])
continue
}
if (lower.endsWith('.appimage')) {
candidates.push({ file: full, depth, score: 100 })
continue
}
if (LIBRARY_RE.test(entry.name)) continue
if (SCRIPT_RE.test(entry.name)) {
candidates.push({ file: full, depth, score: 60 })
continue
}
if (isElfSync(full)) candidates.push({ file: full, depth, score: isExecutable(full) ? 55 : 40 })
}
await Promise.all(subdirs.map(([full, next]) => walk(full, next)))
}
await walk(dir, 0)
// A .desktop is the intended way in when it names a program in the tree.
for (const [full, depth] of desktops) {
const exec = desktopExecPath(readDesktopEntry(full), full)
if (exec) candidates.push({ file: exec, depth, score: 90 })
}
if (!candidates.length) return ''
let best = ''
let bestScore = -Infinity
for (const candidate of candidates) {
const size = await sizeOf(candidate.file)
// shallow wins, and between equals the bigger binary is the app
const score = candidate.score - candidate.depth * 5 + Math.min(size / 1000000, 20)
if (score > bestScore) {
bestScore = score
best = candidate.file
}
}
return best
}
// The .desktop that belongs to this program: one in the same folder, whose
// Exec points at it (or the only one there).
function desktopFileFor(exePath) {
const dir = path.dirname(exePath)
let names = []
try {
names = fs.readdirSync(dir).filter(n => n.toLowerCase().endsWith('.desktop'))
} catch {}
if (!names.length) {
try {
names = fs.readdirSync(path.join(dir, '..')).filter(n => n.toLowerCase().endsWith('.desktop'))
} catch {}
if (names.length) return path.join(dir, '..', names[0])
return ''
}
for (const name of names) {
const file = path.join(dir, name)
if (desktopExecPath(readDesktopEntry(file), file) === exePath) return file
}
return path.join(dir, names[0])
}
function pngOrSvg(file) {
try {
const fd = fs.openSync(file, 'r')
try {
const buf = Buffer.alloc(64)
const read = fs.readSync(fd, buf, 0, 64, 0)
const head = buf.subarray(0, read)
if (head.length >= 8 && head.readUInt32BE(0) === 0x89504e47) return 'png'
const text = head.toString('utf8').trim().toLowerCase()
if (text.startsWith('<svg') || text.startsWith('<?xml')) return 'svg'
if (head.length >= 3 && head[0] === 0xff && head[1] === 0xd8) return 'jpg'
} finally {
fs.closeSync(fd)
}
} catch {}
return ''
}
const THEME_SIZES = ['512x512', '256x256', '128x128', 'scalable', '64x64', '48x48', '32x32']
// An `Icon=` value: an absolute path, a path relative to the .desktop's
// folder (AppImage app dirs put .DirIcon next to it), or a theme icon name.
function findIconFile(spec, baseDir) {
const candidates = []
const name = String(spec || '').trim() || '.DirIcon'
if (path.isAbsolute(name)) {
candidates.push(name)
} else {
if (baseDir) candidates.push(path.join(baseDir, name))
if (!path.extname(name)) {
const dataHome = process.env.XDG_DATA_HOME || path.join(os.homedir(), '.local', 'share')
for (const dir of [path.join(dataHome, 'icons'), '/usr/share/icons']) {
for (const size of THEME_SIZES) {
for (const ext of ['png', 'svg']) candidates.push(path.join(dir, 'hicolor', size, 'apps', `${name}.${ext}`))
}
}
for (const dir of [dataHome, '/usr/share', '/usr/local/share']) {
candidates.push(path.join(dir, 'pixmaps', name + '.png'), path.join(dir, 'pixmaps', name + '.svg'))
}
} else {
for (const dir of ['/usr/share/pixmaps', process.env.XDG_DATA_HOME ? path.join(process.env.XDG_DATA_HOME, 'pixmaps') : '']) {
if (dir) candidates.push(path.join(dir, name))
}
}
}
for (const candidate of candidates) {
if (!candidate) continue
const kind = pngOrSvg(candidate)
if (kind) return candidate
}
return ''
}
module.exports = {
ELF_MAGIC,
isElfBuffer,
isElfSync,
findLinuxExe,
parseDesktopEntry,
readDesktopEntry,
desktopExecPath,
desktopFileFor,
findIconFile,
pngOrSvg
}

349
src/linuxrunner.js Normal file
View file

@ -0,0 +1,349 @@
'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
}

View file

@ -38,6 +38,20 @@ function prefixPath(name) {
return path.join(DIRS.PREFIXES, name)
}
// Native Linux apps keep their redirected home/config/cache in here, next to
// the Windows side of the same prefix.
const LINUX_PFX = 'linux_pfx'
function prefixLinuxRoot(name) {
return path.join(prefixPath(name), LINUX_PFX)
}
// Native Linux app data is machine-local (sockets, caches, machine-id, the
// sandbox's home): a sync neither sends it nor deletes it as an extra.
function isLocalOnlyRel(rel) {
return String(rel || '').split('/').includes(LINUX_PFX)
}
function prefixExists(name) {
try {
fs.accessSync(prefixPath(name))
@ -100,6 +114,7 @@ function isPrefixRoot(p) {
const root = prefixPath(entry.name)
if (abs === root || abs === path.join(root, 'pfx')) return true
if (abs === path.join(root, 'drive_c') || abs === path.join(root, 'pfx', 'drive_c')) return true
if (abs === path.join(root, LINUX_PFX) || abs === path.join(root, LINUX_PFX, 'home')) return true
}
return false
}
@ -173,4 +188,4 @@ function setDefaultPrefix(name) {
return { ok: true }
}
module.exports = { isPrefixRel, PREFIX_REL_RE, listPrefixes, savePrefixes, createPrefix, deletePrefix, renamePrefix, setDefaultPrefix, prefixPath, prefixExists, prefixDriveRoots, isPrefixRoot, healPrefix, ensureUnifiedPrefix, MAIN }
module.exports = { isPrefixRel, PREFIX_REL_RE, LINUX_PFX, isLocalOnlyRel, listPrefixes, savePrefixes, createPrefix, deletePrefix, renamePrefix, setDefaultPrefix, prefixPath, prefixLinuxRoot, prefixExists, prefixDriveRoots, isPrefixRoot, healPrefix, ensureUnifiedPrefix, MAIN }

View file

@ -41,6 +41,7 @@ contextBridge.exposeInMainWorld('plwl', {
prefixesDelete: name => ipcRenderer.invoke('prefixes:delete', name),
prefixesRename: (oldName, newName) => ipcRenderer.invoke('prefixes:rename', oldName, newName),
prefixesSetDefault: name => ipcRenderer.invoke('prefixes:setDefault', name),
prefixesResetLinuxConfig: name => ipcRenderer.invoke('prefixes:resetLinuxConfig', name),
openwithTake: () => ipcRenderer.invoke('openwith:take'),
openwithRun: payload => ipcRenderer.invoke('openwith:run', payload),
openwithTexts: lang => ipcRenderer.invoke('openwith:texts', lang),
@ -68,7 +69,10 @@ contextBridge.exposeInMainWorld('plwl', {
gamesShortcutPaths: id => ipcRenderer.invoke('games:shortcutPaths', id),
gamesPickFolder: () => ipcRenderer.invoke('games:pickFolder'),
gamesPickPrefixFolder: prefix => ipcRenderer.invoke('games:pickPrefixFolder', prefix),
gamesImport: (src, mode) => ipcRenderer.invoke('games:import', src, mode),
gamesImport: (src, mode, kind) => ipcRenderer.invoke('games:import', src, mode, kind),
gamesOpenDataFolder: id => ipcRenderer.invoke('games:openDataFolder', id),
sandboxStatus: () => ipcRenderer.invoke('sandbox:status'),
sandboxProbe: () => ipcRenderer.invoke('sandbox:probe'),
gamesImportFromPrefix: (prefix, src) => ipcRenderer.invoke('games:importFromPrefix', prefix, src),
gamesRun: id => ipcRenderer.invoke('games:run', id),
gamesRunTool: (id, tool, args) => ipcRenderer.invoke('games:runTool', id, tool, args),

View file

@ -198,6 +198,10 @@ function windowsPathFor(file, prefixDir) {
}
const GRACE_MS = 60000
// A finished run may leave output in the pipes (or a grandchild holding them
// open): the log closes when its writers are done, at the latest this long
// after the exit.
const LOG_DRAIN_MS = 5000
// Live playtime: tiny file updated every 30s, totals mirrored to the registry
// on exit. `base` comes from the caller (live file, not the registry) so
@ -222,32 +226,17 @@ function openLatestLog(gameId) {
return fs.createWriteStream(logFile, { flags: 'w' })
}
async function runGame({ game, wine, systemWine, onExit, openFile, handoff = false }) {
await healPrefixOnce(game)
const isProton = wine && wine.type === 'proton'
const { env, prefixDir } = buildEnv({ game, wine })
const exe = game.exePath
const workdir = game.workdir || path.dirname(exe)
const argList = parseArgs(game.args)
// an opened file is an argument to the exe, exactly like Windows' "Open with"
if (openFile) argList.push(windowsPathFor(openFile, prefixDir))
let cmd
let base
if (isProton) {
cmd = wine.wineBin || path.join(wine.path, 'proton')
base = [protonVerb(game), exe, ...argList]
} else {
cmd = systemWine ? 'wine' : wine.wineBin
base = [exe, ...argList]
}
// The tail every tracked run shares (wine and native Linux alike): spawn
// detached, tee output into the app's latest log, tick playtime, stamp
// lastPlayed, report the exit. `header` lines land right after the launch line.
function spawnTracked({ game, cmd, args, cwd, env, handoff = false, header = [], onExit }) {
const library = require('./library')
fs.mkdirSync(DIRS.DATA, { recursive: true })
return new Promise((resolve, reject) => {
let child
try {
child = spawn(cmd, base, {
cwd: workdir,
child = spawn(cmd, args, {
cwd,
env,
// a handoff is untracked: its pipes drain so the app never blocks
stdio: handoff ? 'ignore' : ['ignore', 'pipe', 'pipe'],
@ -272,24 +261,36 @@ async function runGame({ game, wine, systemWine, onExit, openFile, handoff = fal
const logFile = latestLogPath(game.id)
const ws = openLatestLog(game.id)
// a launch line first, so the log answers "which prefix/overrides/run"
ws.write(`[plwl] ${cmd} ${base.join(' ')}\n`)
ws.write(`[plwl] WINEPREFIX=${env.WINEPREFIX}\n`)
if (env.WINEDLLOVERRIDES) ws.write(`[plwl] WINEDLLOVERRIDES=${env.WINEDLLOVERRIDES}\n`)
if (isProton) ws.write(`[plwl] proton launcher: ${game.disableUmu ? 'direct wine (no UMU, no Steam)' : `umu.exe (UMU_ID=${env.UMU_ID})`}\n`)
child.stdout.pipe(ws)
child.stderr.pipe(ws)
ws.write(`[plwl] ${cmd} ${args.join(' ')}\n`)
for (const line of header) ws.write(line + '\n')
// two pipes feed one log, so neither may end it: pipe() would close the
// stream on the first EOF, and the second writer then kills the process
// with ERR_STREAM_WRITE_AFTER_END (an unhandled WriteStream 'error')
child.stdout.pipe(ws, { end: false })
child.stderr.pipe(ws, { end: false })
let openPipes = 2
const endLog = () => {
if (--openPipes <= 0) ws.end()
}
child.stdout.once('end', endLog)
child.stderr.once('end', endLog)
// an unwritable log (full disk, file removed) must not take us down
ws.on('error', () => {})
child.unref()
const startedAt = Date.now()
// read the LIVE playtime file, not the registry (shared ids)
const basePlaySeconds = require('./library').readPlaySeconds(game.id) ?? (game.playSeconds || 0)
const basePlaySeconds = library.readPlaySeconds(game.id) ?? (game.playSeconds || 0)
const stopTicker = startPlaytimeTicker({ game, startedAt, base: basePlaySeconds })
// stamp "last played" once at start, even if we crash later
require('./library').updateGame(game.id, { lastPlayed: startedAt })
library.updateGame(game.id, { lastPlayed: startedAt })
child.on('exit', (code, signal) => {
stopTicker()
ws.end()
// nothing may outlive the run: end the log once the buffered output had
// its chance, even if something else still holds the pipes
const drain = setTimeout(() => ws.end(), LOG_DRAIN_MS)
if (drain.unref) drain.unref()
const counted = Math.max(0, Math.round((Date.now() - startedAt - GRACE_MS) / 1000))
require('./library').updateGame(game.id, {
library.updateGame(game.id, {
lastPlayed: startedAt,
playSeconds: basePlaySeconds + counted
})
@ -300,6 +301,32 @@ async function runGame({ game, wine, systemWine, onExit, openFile, handoff = fal
})
}
async function runGame({ game, wine, systemWine, onExit, openFile, handoff = false }) {
await healPrefixOnce(game)
const isProton = wine && wine.type === 'proton'
const { env, prefixDir } = buildEnv({ game, wine })
const exe = game.exePath
const workdir = game.workdir || path.dirname(exe)
const argList = parseArgs(game.args)
// an opened file is an argument to the exe, exactly like Windows' "Open with"
if (openFile) argList.push(windowsPathFor(openFile, prefixDir))
let cmd
let base
if (isProton) {
cmd = wine.wineBin || path.join(wine.path, 'proton')
base = [protonVerb(game), exe, ...argList]
} else {
cmd = systemWine ? 'wine' : wine.wineBin
base = [exe, ...argList]
}
const header = [`[plwl] WINEPREFIX=${env.WINEPREFIX}`]
if (env.WINEDLLOVERRIDES) header.push(`[plwl] WINEDLLOVERRIDES=${env.WINEDLLOVERRIDES}`)
if (isProton) header.push(`[plwl] proton launcher: ${game.disableUmu ? 'direct wine (no UMU, no Steam)' : `umu.exe (UMU_ID=${env.UMU_ID})`}`)
return spawnTracked({ game, cmd, args: base, cwd: workdir, env, handoff, header, onExit })
}
async function runTool({ game, wine, tool, args, systemWine }) {
await healPrefixOnce(game)
const isProton = wine && wine.type === 'proton'
@ -336,4 +363,4 @@ function runAdHoc({ openPath, wine, systemWine, prefix, onExit }) {
return runGame({ game, wine, systemWine, onExit })
}
module.exports = { buildEnv, runGame, runTool, runAdHoc, parseArgs, latestLogPath, windowsPathFor }
module.exports = { buildEnv, runGame, runTool, runAdHoc, parseArgs, latestLogPath, windowsPathFor, spawnTracked }

160
src/sandbox.js Normal file
View file

@ -0,0 +1,160 @@
'use strict'
const { execFile } = require('child_process')
const fs = require('fs')
const path = require('path')
// bubblewrap is only usable when unprivileged user namespaces actually work:
// Ubuntu 24.04 ships bwrap but AppArmor kills it ("Failed to unshare user
// namespace"), so a functional probe decides - not `which bwrap`.
const PROBE_TIMEOUT_MS = 3000
// /bin/true is a symlink on most distros but missing on a few; env is the
// last resort that still exits 0.
const TRUE_BINARIES = ['/bin/true', '/usr/bin/true', '/usr/bin/env']
function firstExisting(candidates) {
for (const candidate of candidates) {
try {
fs.accessSync(candidate)
return candidate
} catch {}
}
return null
}
function firstLine(text) {
return String(text || '').trim().split('\n')[0].trim()
}
// A failed probe maps to a reason the UI and the log can name.
function reasonFor(err, stderr) {
if (err && err.code === 'ENOENT') return 'missing'
if (err && (err.killed || err.signal === 'SIGKILL')) return 'timeout'
const text = String(stderr || '') + ' ' + String((err && err.message) || '')
if (/Operation not permitted|Permission denied|Failed to unshare|uid map|setting up uid|No permissions to create/i.test(text)) return 'not-permitted'
return 'failed'
}
function run(cmd, args, timeoutMs) {
return new Promise(resolve => {
execFile(cmd, args, { timeout: timeoutMs, killSignal: 'SIGKILL' }, (err, stdout, stderr) => {
resolve({ err, stdout: String(stdout || ''), stderr: String(stderr || '') })
})
})
}
// Status = { backend: 'bwrap' | 'env', version, reason, detail, checkedAt }
// reason = null | 'missing' | 'not-permitted' | 'timeout' | 'failed'
function createSandboxProbe({ timeoutMs = PROBE_TIMEOUT_MS } = {}) {
let cached = null
let inflight = null
const makeStatus = (backend, version, reason, detail) => ({
backend,
version: version || null,
reason: reason || null,
detail: detail || '',
checkedAt: Date.now()
})
async function measure() {
const trueBin = firstExisting(TRUE_BINARIES)
if (!trueBin) {
cached = makeStatus('env', null, 'missing', 'no /bin/true to probe with')
return cached
}
const versionRun = await run('bwrap', ['--version'], timeoutMs)
if (versionRun.err && versionRun.err.code === 'ENOENT') {
cached = makeStatus('env', null, 'missing', 'bubblewrap is not installed')
return cached
}
const version = firstLine(versionRun.stdout).split(/\s+/).pop() || null
const probeRun = await run('bwrap', ['--ro-bind', '/', '/', '--proc', '/proc', '--dev', '/dev', '--', trueBin], timeoutMs)
if (probeRun.err) {
cached = makeStatus('env', version, reasonFor(probeRun.err, probeRun.stderr), firstLine(probeRun.stderr) || firstLine(probeRun.err.message))
return cached
}
cached = makeStatus('bwrap', version, null, '')
return cached
}
// Runs the probe once and answers from the cache afterwards (3s on every
// launch click would be unacceptable).
function probe() {
if (cached) return Promise.resolve(cached)
if (!inflight) inflight = measure().finally(() => { inflight = null })
return inflight
}
return {
probe,
status: () => cached,
invalidate: () => { cached = null }
}
}
// Host theme data (fonts, icons, colour schemes) is mounted read-only under
// this path - inside the private /tmp the app cannot mistake it for its own
// data, and the caller lists it in XDG_DATA_DIRS. A bind, not a symlink: the
// prefix is mounted over ~, so a link back into ~ would point at itself.
const SHARE_ROOT = '/tmp/.plwl-host'
// 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.
function buildBwrapArgs({
dataRoot,
user,
home = `/home/${user}`,
uid = typeof process.getuid === 'function' ? process.getuid() : 0,
devices = [],
x11 = '',
runUser = '',
shares = [],
hideHome = false,
etcRoot = '',
unshareNet = false,
appDir = ''
}) {
const xdgDir = sub => path.join(home, sub)
return [
'--die-with-parent',
'--new-session',
// network stays: multiplayer and DNS keep working
'--unshare-all',
unshareNet ? '--unshare-net' : '--share-net',
'--hostname', 'plwl',
// --- filesystem ---
'--ro-bind', '/', '/',
'--proc', '/proc',
'--dev', '/dev',
...devices.flatMap(device => ['--dev-bind', device, device]),
'--tmpfs', '/tmp',
// X11 sockets live in /tmp, which the tmpfs above just emptied
...(x11 ? ['--ro-bind', x11, x11] : []),
// the host's theme data, read-only: fonts, icons, colour schemes
...shares.flatMap(dir => ['--ro-bind', dir, path.join(SHARE_ROOT, path.basename(dir))]),
// hard profile: nothing of anybody's /home is even readable
...(hideHome ? ['--tmpfs', '/home', '--tmpfs', '/root', '--tmpfs', '/mnt', '--tmpfs', '/media'] : []),
// the app's own folder stays writable (portable apps keep logs/data next
// to themselves); the rest of the read-only root does not
...(appDir ? ['--bind', appDir, appDir] : []),
// --- app data (host paths bound to a fresh HOME) ---
'--bind', path.join(dataRoot, 'home'), home,
'--bind', path.join(dataRoot, 'config'), xdgDir('.config'),
'--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')),
// Wayland, PipeWire, PulseAudio and the session bus all live here
...(runUser ? ['--bind', runUser, runUser] : []),
...(etcRoot ? ['--bind', etcRoot, '/etc'] : []),
// --- environment ---
'--setenv', 'HOME', home,
'--setenv', 'XDG_CONFIG_HOME', xdgDir('.config'),
'--setenv', 'XDG_DATA_HOME', xdgDir(path.join('.local', 'share')),
'--setenv', 'XDG_CACHE_HOME', xdgDir('.cache'),
'--setenv', 'XDG_STATE_HOME', xdgDir(path.join('.local', 'state'))
]
}
module.exports = { createSandboxProbe, buildBwrapArgs, SHARE_ROOT, PROBE_TIMEOUT_MS }