'use strict' const path = require('path') const fs = require('fs') const { spawn } = require('child_process') const { DIRS } = require('./paths') const { prefixPath } = require('./prefixes') const { prefixNameForGame } = require('./library') // The fake Steam install Proton is pointed at through // STEAM_COMPAT_CLIENT_INSTALL_PATH. Both folders Proton links into its prefix // have to exist: it symlinks /drive_c/Program Files (x86)/Steam/ // appcache -> /appcache while setting the prefix up, and a link into a // missing folder is a dangling link in a tree that gets synced. 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 } // Wine only ever repairs links that are BROKEN: a prefix that came from // another machine (or from an older PLWL) can still hold absolute links into // its build that happen to resolve here, and those would be synced out again. // Healing once per prefix per launch keeps the whole tree relocatable and // costs a single walk in the common (already relative) case. 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 gets its prefix through STEAM_COMPAT_DATA_PATH, which has to be a // symlink to the launcher's own prefix folder. The parent directory is created // FIRST: symlink() fails with ENOENT when compat/ does not exist yet, and the // old fallback then created a plain empty directory instead - Proton silently // started with an empty prefix (the game never saw its own data). A leftover // EMPTY directory from exactly that situation is safe to replace with the // link; a populated one is left alone. function linkPrefix(compatData, prefixDir) { try { fs.mkdirSync(path.dirname(compatData), { recursive: true }) } catch {} try { const st = fs.lstatSync(compatData) if (st.isSymbolicLink()) { if (fs.readlinkSync(compatData) === prefixDir) return fs.unlinkSync(compatData) } else if (st.isDirectory()) { let names = [] try { names = fs.readdirSync(compatData) } catch {} if (names.length) return fs.rmdirSync(compatData) } else { return } } 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 snapshots into its running entries // (library.prefixNameForGame), so in-use checks always name this folder const prefixDir = prefixPath(prefixNameForGame(game)) if (isProton) { const fakeSteam = ensureFakeSteam() const buildRoot = path.resolve(wine.path, '..') env.STEAM_COMPAT_CLIENT_INSTALL_PATH = fakeSteam env.STEAM_COMPAT_DATA_PATH = path.join(DIRS.DATA, 'compat', game.id) env.STEAM_COMPAT_APP_ID = '0' env.SteamAppId = '0' env.SteamGameId = '0' // Launch like the UMU protocol does (UMU/PortProton/Heroic): with UMU_ID // set, newer Protons (incl. DW Proton 11, GE-Proton 10+) run the game via // their built-in umu.exe instead of the steam.exe helper, which would // otherwise try to load Steam's steamclient.so and crash with an // "Assertion failed \"!status\"" dialog (steamclient_main.c) when no // Steam client is installed. 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 to a real directory: Proton would then set its prefix // up under files/data/compat//pfx, outside files/wineprefixes/ where // PLWL tracks it - rename, delete, the in-use guards and the LAN sync all // look at the launcher prefix, so its C: drive would be orphaned. Only a // missing path is refused; a populated one is linkPrefix's deliberate case. if (!fs.existsSync(env.STEAM_COMPAT_DATA_PATH)) { throw new Error('prefix-link-failed') } } else { // plain wine (system or imported build) env.WINEPREFIX = prefixDir } env.WINEDLLOVERRIDES = (game.dllOverrides || []) .filter(o => o && o.dll && o.setting) .map(o => `${o.dll}=${o.setting}`) .join(';') if (!env.WINEDLLOVERRIDES) delete env.WINEDLLOVERRIDES for (const [k, v] of Object.entries(game.env || {})) env[k] = v return { env, prefixDir } } 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 } const GRACE_MS = 60000 // Live playtime: while the game runs, update ONLY the tiny playtime file // (a number of seconds) every 30s - apps.json is left untouched to spare the // disk. lastPlayed is stamped once at start, final totals are mirrored into // the registry once on exit. // `base` is the seconds total this run adds up to. It is resolved by the // CALLER from the live playtime file (not the registry) so ad-hoc runs that // share one id (see runAdHoc) never mix two apps' 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' }) } async function runGame({ game, wine, systemWine, onExit }) { 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(game.args) let cmd let base if (isProton) { cmd = wine.wineBin || path.join(wine.path, 'proton') base = ['run', exe, ...argList] } else { cmd = systemWine ? 'wine' : wine.wineBin base = [exe, ...argList] } fs.mkdirSync(DIRS.DATA, { recursive: true }) return new Promise((resolve, reject) => { let child try { child = spawn(cmd, base, { cwd: workdir, env, stdio: ['ignore', 'pipe', 'pipe'], detached: true }) } catch (err) { return reject(err) } // spawn() returns before the OS actually starts the program: when the // binary is missing or has no executable bit (a wine build copied without // its permissions, a wrong path) the failure only arrives as an 'error' // event, AFTER this function would have resolved. Resolving there marked // games that never started as running forever, so the promise only settles // once the process really exists ('spawn'). let started = false child.once('error', err => { if (!started) reject(err) }) child.once('spawn', () => { started = true const logFile = latestLogPath(game.id) const ws = openLatestLog(game.id) child.stdout.pipe(ws) child.stderr.pipe(ws) child.unref() const startedAt = Date.now() // read the LIVE playtime file, not the (possibly shared/stale) registry // value - ad-hoc "open with" runs share one id and must not mix playtime const basePlaySeconds = require('./library').readPlaySeconds(game.id) ?? (game.playSeconds || 0) const stopTicker = startPlaytimeTicker({ game, startedAt, base: basePlaySeconds }) // stamp "last played" once at start so it is correct even if we crash later require('./library').updateGame(game.id, { lastPlayed: startedAt }) child.on('exit', (code, signal) => { stopTicker() ws.end() const counted = Math.max(0, Math.round((Date.now() - startedAt - GRACE_MS) / 1000)) require('./library').updateGame(game.id, { lastPlayed: startedAt, playSeconds: basePlaySeconds + counted }) if (onExit) onExit(code, signal) }) resolve({ pid: child.pid, logFile }) }) }) } 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 = ['run', 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 exact runGame pipeline, but with a // synthetic target under the shared `openwith` id - log and playtime files // live in files/data/appdata/openwith/ and no library entry is created. // `prefix` carries the picker's choice ('' = launcher default, resolved by // buildEnv); wine/systemWine are the already-resolved build objects. 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 }