354 lines
13 KiB
JavaScript
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 }
|