PLWL/src/runner.js
2026-10-07 16:29:40 +02:00

354 lines
13 KiB
JavaScript

'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 }