266 lines
9.9 KiB
JavaScript
266 lines
9.9 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')
|
|
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 <prefix>/drive_c/Program Files (x86)/Steam/
|
|
// appcache -> <fake>/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/<id>/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 }
|