[EXPERIMENTAL] linux apps support
This commit is contained in:
parent
d4996988d9
commit
e7636afe3d
20 changed files with 1377 additions and 101 deletions
|
|
@ -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 }
|
||||
|
||||
|
|
|
|||
34
src/icons.js
34
src/icons.js
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
265
src/linuxapp.js
Normal 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
349
src/linuxrunner.js
Normal 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
|
||||
}
|
||||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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
160
src/sandbox.js
Normal 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 }
|
||||
Loading…
Add table
Add a link
Reference in a new issue