cleanup comments, update translations, bump to 0.4.1

This commit is contained in:
olcxja 2026-10-05 15:42:44 +02:00
commit 7d8ab87eee
28 changed files with 412 additions and 1114 deletions

View file

@ -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 })