'use strict' const path = require('path') const fs = require('fs') const os = require('os') const { spawn } = require('child_process') const { DIRS } = require('./paths') const { prefixPath, ensureUnifiedPrefix } = require('./prefixes') const { prefixNameForGame } = require('./library') // The fake Steam install used when no real Steam client is found. Both folders // Proton links into its prefix have to exist, or the links dangle. function ensureFakeSteam() { const fake = DIRS.FAKE_STEAM fs.mkdirSync(path.join(fake, 'steamapps'), { recursive: true }) fs.mkdirSync(path.join(fake, 'appcache'), { recursive: true }) return fake } // Proton needs the Steam client at STEAM_COMPAT_CLIENT_INSTALL_PATH or a Valve // build dies in a "steamclient_main.c" assert; use a real client read-only, // the empty fake otherwise (GE builds skip steam.exe entirely). const STEAM_CLIENT_MARKERS = [ ['legacycompat', 'steamclient.dll'], ['ubuntu12_32', 'steamclient.so'] ] function findSteamClientDir() { const home = os.homedir() const dataHome = process.env.XDG_DATA_HOME || path.join(home, '.local', 'share') const candidates = [ process.env.STEAM_COMPAT_CLIENT_INSTALL_PATH, path.join(home, '.steam', 'steam'), path.join(home, '.steam', 'root'), path.join(dataHome, 'Steam'), // flatpak Steam keeps its install inside the sandbox's data dir path.join(home, '.var', 'app', 'com.valvesoftware.Steam', '.local', 'share', 'Steam') ] for (const dir of candidates) { if (!dir) continue for (const marker of STEAM_CLIENT_MARKERS) { if (fs.existsSync(path.join(dir, ...marker))) return dir } } return null } function steamClientDir() { return findSteamClientDir() || ensureFakeSteam() } // WINEDLLOVERRIDES: ';'-separated, '=' splits name/setting, ',' separates // names. Guides write a trailing .dll; Wine strips it, so store the bare name. function sanitizeDllName(name) { const clean = String(name || '').trim().replace(/\.dll$/i, '') return /^[^;=\s]+$/.test(clean) ? clean : '' } // Wine keeps the LAST entry per module: inherited overrides go first, the // app's own after. An empty setting is Wine's "disabled" and stays as `name=`. function dllOverridesFor(game, env) { const parts = [] const inherited = String(env.WINEDLLOVERRIDES || '').trim().replace(/;+$/, '') if (inherited) parts.push(inherited) for (const entry of game.dllOverrides || []) { if (!entry) continue const dll = sanitizeDllName(entry.dll) if (!dll) continue const setting = String(entry.setting == null ? '' : entry.setting).trim() if (!/^[a-z,]*$/i.test(setting)) continue parts.push(`${dll}=${setting}`) } return parts.join(';') } // Repair only broken links (a resolvable absolute link would be synced out // again). Once per prefix per launch. const healedPrefixes = new Set() async function healPrefixOnce(game) { const name = prefixNameForGame(game) const key = `${name}\u0000${DIRS.BUILD}` if (!name || healedPrefixes.has(key)) return 0 healedPrefixes.add(key) try { return await require('./prefixes').healPrefix(name) } catch { return 0 } } // Proton's STEAM_COMPAT_DATA_PATH must be a symlink to the launcher prefix; // a real compat dir already sitting there is not ours to replace. function linkPrefix(compatData, prefixDir) { try { fs.mkdirSync(path.dirname(compatData), { recursive: true }) } catch {} try { if (!fs.lstatSync(compatData).isSymbolicLink()) return if (fs.readlinkSync(compatData) === prefixDir) return fs.unlinkSync(compatData) } catch {} try { fs.symlinkSync(prefixDir, compatData, 'dir') } catch {} } function buildEnv({ game, wine }) { const env = { ...process.env } const isProton = wine && wine.type === 'proton' // same fallback the games service uses, so in-use checks name this folder const prefixName = prefixNameForGame(game) // no pfx yet -> shared-link layout; an existing nested layout is left as is try { ensureUnifiedPrefix(prefixName) } catch {} const prefixDir = prefixPath(prefixName) if (isProton) { env.STEAM_COMPAT_DATA_PATH = path.join(DIRS.DATA, 'compat', game.id) env.STEAM_COMPAT_APP_ID = '0' env.SteamAppId = '0' env.SteamGameId = '0' // every Proton verb reads STEAM_COMPAT_CLIENT_INSTALL_PATH (a missing one // aborts with a KeyError). UMU run: real client; disableUmu: the fake. if (game.disableUmu) { delete env.UMU_ID env.STEAM_COMPAT_CLIENT_INSTALL_PATH = ensureFakeSteam() } else { env.STEAM_COMPAT_CLIENT_INSTALL_PATH = steamClientDir() // UMU_ID makes newer Protons run through umu.exe instead of steam.exe, // which needs the Steam client's DLLs (see steamClientDir). if (!env.UMU_ID) env.UMU_ID = 'umu-0' } env.WINEPREFIX = path.join(env.STEAM_COMPAT_DATA_PATH, 'pfx') env.PROTON_LOG_DIR = path.join(DIRS.DATA, 'logs') fs.mkdirSync(env.PROTON_LOG_DIR, { recursive: true }) linkPrefix(env.STEAM_COMPAT_DATA_PATH, prefixDir) // no silent fallback: a real dir would orphan the prefix outside where // PLWL tracks it; only a missing path is refused if (!fs.existsSync(env.STEAM_COMPAT_DATA_PATH)) { throw new Error('prefix-link-failed') } } else { // plain wine (system or imported build) env.WINEPREFIX = prefixDir } const overrides = dllOverridesFor(game, env) if (overrides) env.WINEDLLOVERRIDES = overrides else delete env.WINEDLLOVERRIDES // 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, prefixDir } } // Proton verbs: 'run' goes through umu.exe/steam.exe, 'runinprefix' runs the // build's wine directly and skips Proton's prefix setup (see game.disableUmu). function protonVerb(game) { return game.disableUmu ? 'runinprefix' : 'run' } function parseArgs(args) { const out = [] const re = /"([^"]*)"|'([^']*)'|(\S+)/g let m while ((m = re.exec(String(args || '')))) out.push(m[1] ?? m[2] ?? m[3]) return out } // "Open with" handoff: the file reaches the app as a WINDOWS path (C:\ inside // the prefix's drive, Z:\ for the rest). function windowsPathFor(file, prefixDir) { let abs = String(file || '') try { abs = fs.realpathSync(abs) } catch {} try { const driveC = fs.realpathSync(path.join(prefixDir, 'drive_c')) const rel = path.relative(driveC, abs) if (rel && !rel.startsWith('..') && !path.isAbsolute(rel)) { return 'C:\\' + rel.split(path.sep).join('\\') } } catch {} return `Z:${abs.split(path.sep).join('\\')}` } 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 // shared ids never mix playtime. function startPlaytimeTicker({ game, startedAt, base }) { const library = require('./library') const timer = setInterval(() => { const counted = Math.max(0, Math.round((Date.now() - startedAt - GRACE_MS) / 1000)) library.writePlaySeconds(game.id, base + counted) }, 30000) return () => clearInterval(timer) } function latestLogPath(gameId) { return path.join(DIRS.DATA, 'logs', `${gameId}-latest.log`) } function openLatestLog(gameId) { const logFile = latestLogPath(gameId) fs.mkdirSync(path.dirname(logFile), { recursive: true }) // 'w' truncates, so every session starts with a fresh log return fs.createWriteStream(logFile, { flags: 'w' }) } // 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, args, { cwd, env, // a handoff is untracked: its pipes drain so the app never blocks stdio: handoff ? 'ignore' : ['ignore', 'pipe', 'pipe'], detached: true }) } catch (err) { return reject(err) } // spawn() resolves before the process exists; a missing binary only // surfaces as an 'error' event, so wait for the real 'spawn'. let started = false child.once('error', err => { if (!started) reject(err) }) child.once('spawn', () => { started = true // a handoff writes no log or playtime: the tracked run owns both if (handoff) { child.unref() return resolve({ pid: child.pid, logFile: null }) } 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} ${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 = 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 library.updateGame(game.id, { lastPlayed: startedAt }) child.on('exit', (code, signal) => { stopTicker() // 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)) library.updateGame(game.id, { lastPlayed: startedAt, playSeconds: basePlaySeconds + counted }) if (onExit) onExit(code, signal) }) resolve({ pid: child.pid, logFile }) }) }) } 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' const { env } = buildEnv({ game, wine }) const exe = game.exePath const workdir = game.workdir || path.dirname(exe) const argList = parseArgs(args) let cmd let base if (isProton) { cmd = wine.wineBin || path.join(wine.path, 'proton') base = [protonVerb(game), tool, ...argList] } else if (systemWine) { cmd = tool base = argList } else { cmd = wine.wineBin base = [tool, ...argList] } return new Promise((resolve, reject) => { const child = spawn(cmd, base, { cwd: workdir, env, stdio: 'ignore', detached: true }) child.on('error', reject) child.unref() resolve({ pid: child.pid }) }) } // Ad-hoc launch ("Open with PLWL"): the runGame pipeline under the shared // `openwith` id; no library entry is created. `prefix` is the picker's // choice ('' = launcher default). function runAdHoc({ openPath, wine, systemWine, prefix, onExit }) { const game = require('./openwith').makeTarget(openPath, 'default', prefix || '') return runGame({ game, wine, systemWine, onExit }) } module.exports = { buildEnv, runGame, runTool, runAdHoc, parseArgs, latestLogPath, windowsPathFor, spawnTracked }