cleanup comments, update translations, bump to 0.4.1
This commit is contained in:
parent
9edcc9841b
commit
7d8ab87eee
28 changed files with 412 additions and 1114 deletions
139
src/runner.js
139
src/runner.js
|
|
@ -8,11 +8,8 @@ const { DIRS } = require('./paths')
|
|||
const { prefixPath, ensureUnifiedPrefix } = require('./prefixes')
|
||||
const { prefixNameForGame } = require('./library')
|
||||
|
||||
// The fake Steam install PLWL falls back to when no real Steam client is
|
||||
// found (see findSteamClientDir). 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.
|
||||
// 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 })
|
||||
|
|
@ -20,14 +17,9 @@ function ensureFakeSteam() {
|
|||
return fake
|
||||
}
|
||||
|
||||
// Proton expects the Steam client it is normally started by at
|
||||
// STEAM_COMPAT_CLIENT_INSTALL_PATH: it copies the client's legacy DLLs from
|
||||
// <client>/legacycompat into the prefix, its built-in steam.exe loads the
|
||||
// client's own steamclient.so, and media libs come from <client>/ubuntu12_*.
|
||||
// A client installed on this machine is worth using (read-only) - without one
|
||||
// a Valve build's launch dies in a "steamclient_main.c" assert dialog - and
|
||||
// only when there is none does PLWL fall back to the empty fake folder. GE
|
||||
// builds don't need any of it: their UMU path never starts steam.exe.
|
||||
// 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']
|
||||
|
|
@ -57,21 +49,15 @@ function steamClientDir() {
|
|||
return findSteamClientDir() || ensureFakeSteam()
|
||||
}
|
||||
|
||||
// Module names travel in a semicolon-separated WINEDLLOVERRIDES list where
|
||||
// '=' splits name from setting and ',' separates names - anything else in a
|
||||
// name would corrupt the entries around it. A trailing .dll is what guides
|
||||
// write; Wine strips it itself, so PLWL stores the bare name.
|
||||
// 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 replaces an earlier entry for the same module with a later one, so an
|
||||
// inherited WINEDLLOVERRIDES goes FIRST and the app's own overrides are
|
||||
// appended after it. An empty setting is Wine's "disabled" and has to survive
|
||||
// as `name=`, not be dropped. Proton appends its own dxvk/nvapi/steam.exe
|
||||
// defaults after everything - an app override for one of those modules only
|
||||
// takes effect where Proton itself has no default.
|
||||
// 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(/;+$/, '')
|
||||
|
|
@ -87,11 +73,8 @@ function dllOverridesFor(game, env) {
|
|||
return parts.join(';')
|
||||
}
|
||||
|
||||
// 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.
|
||||
// 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)
|
||||
|
|
@ -105,13 +88,9 @@ async function healPrefixOnce(game) {
|
|||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
// Proton's STEAM_COMPAT_DATA_PATH must be a symlink to the launcher prefix.
|
||||
// A leftover EMPTY compat dir (an older PLWL bug) is replaced by the link; a
|
||||
// populated one is left alone.
|
||||
function linkPrefix(compatData, prefixDir) {
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(compatData), { recursive: true })
|
||||
|
|
@ -140,12 +119,9 @@ function linkPrefix(compatData, prefixDir) {
|
|||
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
|
||||
// same fallback the games service uses, so in-use checks name this folder
|
||||
const prefixName = prefixNameForGame(game)
|
||||
// a prefix with no pfx yet gets the shared-link layout, so every launch -
|
||||
// wine, Proton, tools - lands on that prefix's ONE C: drive; an existing
|
||||
// nested layout is left as it is (see ensureUnifiedPrefix)
|
||||
// no pfx yet -> shared-link layout; an existing nested layout is left as is
|
||||
try {
|
||||
ensureUnifiedPrefix(prefixName)
|
||||
} catch {}
|
||||
|
|
@ -156,36 +132,23 @@ function buildEnv({ game, wine }) {
|
|||
env.STEAM_COMPAT_APP_ID = '0'
|
||||
env.SteamAppId = '0'
|
||||
env.SteamGameId = '0'
|
||||
// Proton's session init reads STEAM_COMPAT_CLIENT_INSTALL_PATH on EVERY
|
||||
// verb: its appcache sync (g_compatdata.sync_appcache) runs even for
|
||||
// runinprefix and does os.environ["STEAM_COMPAT_CLIENT_INSTALL_PATH"] as
|
||||
// soon as SteamGameId is set, so the variable must never be dropped - a
|
||||
// missing one aborts the launch with a KeyError before wine even starts.
|
||||
// Only what it POINTS AT depends on the mode: a UMU run may use a real
|
||||
// client found on this machine, a disableUmu run always gets the empty
|
||||
// fake folder (runinprefix copies nothing out of it; no steam.exe and no
|
||||
// umu.exe is started there).
|
||||
// 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()
|
||||
// Launch like the UMU protocol does (UMU/PortProton/Heroic): with UMU_ID
|
||||
// set, newer Protons (incl. GE-Proton 9+) run the game through their
|
||||
// built-in umu.exe instead of the steam.exe helper, which needs the
|
||||
// Steam client's DLLs and dies in a "steamclient_main.c" assert dialog
|
||||
// without them (see 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 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.
|
||||
// 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')
|
||||
}
|
||||
|
|
@ -198,17 +161,13 @@ function buildEnv({ game, wine }) {
|
|||
if (overrides) env.WINEDLLOVERRIDES = overrides
|
||||
else delete env.WINEDLLOVERRIDES
|
||||
|
||||
// the app's own environment comes last: it overrides everything above,
|
||||
// including a WINEDLLOVERRIDES configured here
|
||||
// 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 the build's umu.exe/steam.exe launcher,
|
||||
// while 'runinprefix' runs the build's wine directly (same session env, no
|
||||
// umu/steam launcher ever starts - see game.disableUmu). runinprefix also skips
|
||||
// Proton's prefix setup, so a prefix that never saw a normal Proton run stays
|
||||
// a plain wine prefix instead of gaining Proton's default DLLs.
|
||||
// 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'
|
||||
}
|
||||
|
|
@ -221,10 +180,8 @@ function parseArgs(args) {
|
|||
return out
|
||||
}
|
||||
|
||||
// A file handed over from the Linux file manager ("Open with" on an app
|
||||
// shortcut) has to reach the Windows app as a WINDOWS path, the way it would
|
||||
// on Windows: C:\ for anything inside the prefix's C: drive, and wine's Z:\
|
||||
// mapping (the filesystem root) for everything else.
|
||||
// "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 {
|
||||
|
|
@ -242,13 +199,9 @@ function windowsPathFor(file, prefixDir) {
|
|||
|
||||
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.
|
||||
// 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(() => {
|
||||
|
|
@ -296,38 +249,29 @@ async function runGame({ game, wine, systemWine, onExit, openFile, handoff = fal
|
|||
child = spawn(cmd, base, {
|
||||
cwd: workdir,
|
||||
env,
|
||||
// a handoff (a file given to an already running app) is not tracked:
|
||||
// its pipes are left unread, so the output goes nowhere instead of
|
||||
// filling up and blocking the app
|
||||
// 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() 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').
|
||||
// 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 never writes the app's log or playtime: the tracked run owns
|
||||
// both, and truncating the live log would destroy what it is writing
|
||||
// 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 of our own before the raw wine output: when a game
|
||||
// refuses to start, "which prefix, which overrides, umu or steam.exe"
|
||||
// is exactly what has to be answerable from the log
|
||||
// a launch line first, so the log answers "which prefix/overrides/run"
|
||||
ws.write(`[plwl] ${cmd} ${base.join(' ')}\n`)
|
||||
ws.write(`[plwl] WINEPREFIX=${env.WINEPREFIX}\n`)
|
||||
if (env.WINEDLLOVERRIDES) ws.write(`[plwl] WINEDLLOVERRIDES=${env.WINEDLLOVERRIDES}\n`)
|
||||
|
|
@ -336,11 +280,10 @@ async function runGame({ game, wine, systemWine, onExit, openFile, handoff = fal
|
|||
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
|
||||
// read the LIVE playtime file, not the registry (shared ids)
|
||||
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
|
||||
// stamp "last played" once at start, even if we crash later
|
||||
require('./library').updateGame(game.id, { lastPlayed: startedAt })
|
||||
child.on('exit', (code, signal) => {
|
||||
stopTicker()
|
||||
|
|
@ -385,11 +328,9 @@ async function runTool({ game, wine, tool, args, systemWine }) {
|
|||
})
|
||||
}
|
||||
|
||||
// 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.
|
||||
// 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 })
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue