PLWL/src/runner.js

207 lines
7 KiB
JavaScript

'use strict'
const path = require('path')
const fs = require('fs')
const { spawn } = require('child_process')
const { DIRS } = require('./paths')
const { prefixPath } = require('./prefixes')
function ensureFakeSteam() {
const fake = DIRS.FAKE_STEAM
fs.mkdirSync(path.join(fake, 'steamapps'), { recursive: true })
return fake
}
function linkPrefix(compatData, prefixDir) {
try {
const st = fs.lstatSync(compatData)
if (st.isSymbolicLink()) {
if (fs.readlinkSync(compatData) === prefixDir) return
fs.unlinkSync(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'
let prefixDir = game.prefix ? prefixPath(game.prefix) : null
if (!prefixDir) {
const defs = require('./prefixes').listPrefixes()
prefixDir = prefixPath(defs.default || 'default')
}
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)
if (!fs.existsSync(env.STEAM_COMPAT_DATA_PATH)) {
fs.mkdirSync(env.STEAM_COMPAT_DATA_PATH, { recursive: true })
}
} 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' })
}
function runGame({ game, wine, systemWine, onExit }) {
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)
}
const logFile = latestLogPath(game.id)
const ws = openLatestLog(game.id)
child.stdout.pipe(ws)
child.stderr.pipe(ws)
child.on('error', reject)
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 })
})
}
function runTool({ game, wine, tool, args, systemWine }) {
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 }