guard prefix and wine edits while their apps run

This commit is contained in:
olcxja 2026-10-02 14:46:21 +02:00
commit 38eea05789
9 changed files with 170 additions and 39 deletions

View file

@ -17,7 +17,13 @@ function sendToWindow(win, channel, data) {
if (win && !win.isDestroyed()) win.webContents.send(channel, data)
}
// Game-process state: id -> pid (or null while the process is starting).
// Game-process state: id -> { pid (null while the process is starting), the
// prefix name and wine id the run was resolved to }. Both are frozen here:
// the default prefix/wine can be switched and the app's own config edited
// while it plays, and the in-use guards have to name what the process really
// locked - not what the registry says now. Editing an app's own prefix while
// it runs does not move the process, so the new folder is blocked next to the
// snapshot (a pre-edit prefix does not exist in it yet).
// onGamesChanged fires in the MAIN process whenever the running-games set
// changes (the tray menu rebuilds itself from it).
const STARTUP_GRACE_MS = 60000
@ -47,10 +53,10 @@ function createGamesService({ getWindow, onGamesChanged }) {
// restarted) would otherwise answer every later Play with "already
// running".
function prune() {
for (const [id, pid] of [...running]) {
if (alive(pid)) continue
for (const [id, entry] of [...running]) {
if (alive(entry.pid)) continue
const age = Date.now() - (startedAt.get(id) || 0)
if (!pid && age < STARTUP_GRACE_MS) continue // may still be starting
if (!entry.pid && age < STARTUP_GRACE_MS) continue // may still be starting
running.delete(id)
startedAt.delete(id)
}
@ -64,6 +70,22 @@ function createGamesService({ getWindow, onGamesChanged }) {
prune()
return [...running.keys()]
}
// What the still-running games actually use, for the "is this prefix/wine in
// use?" guards: [{ id, name, prefixes: [snapshot, ...], wine }]. The
// snapshot alone goes stale when a running app is pointed at another prefix
// (the process keeps its folder open); the live name alone misses the folder
// it currently holds. Together they name every folder the run touches.
const runningInfo = () => {
prune()
const out = []
for (const [id, entry] of running) {
const game = library.getGame(id)
const live = game && game.prefix ? library.prefixNameForGame(game) : ''
const prefixes = live && live !== entry.prefix ? [entry.prefix, live] : [entry.prefix]
out.push({ id, name: (game && game.name) || id, prefixes, wine: entry.wine })
}
return out
}
// "Open with PLWL" handoff: queue the file for the renderer (picker popup
// with wine version + prefix). Safe to call at any time - if the window is
@ -90,16 +112,14 @@ function createGamesService({ getWindow, onGamesChanged }) {
if (!game) return { ok: false, error: 'game-not-found' }
prune()
if (running.has(id)) return { ok: false, error: 'already-running' }
running.set(id, null)
startedAt.set(id, Date.now())
let wine
try {
wine = resolveWine(game)
} catch {
running.delete(id)
startedAt.delete(id)
return { ok: false, error: 'wine-not-found' }
}
running.set(id, { pid: null, prefix: library.prefixNameForGame(game), wine: wine.id })
startedAt.set(id, Date.now())
try {
const res = await runGame({ game, wine, systemWine: wine.type === 'system', onExit: () => handleExit(id) })
// stop was requested while the process was still starting: kill it and
@ -120,7 +140,8 @@ function createGamesService({ getWindow, onGamesChanged }) {
startedAt.delete(id)
return { ok: false, error: 'not-started' }
}
running.set(id, res.pid)
const entry = running.get(id)
if (entry) entry.pid = res.pid
sendToWindow(win, 'games:changed')
notifyChanged()
return { ok: true, pid: res.pid, logFile: res.logFile || null }
@ -134,8 +155,8 @@ function createGamesService({ getWindow, onGamesChanged }) {
// Stop always clears the state: whether the process was running, still
// starting (kill request for as soon as it has a pid) or already gone.
async function kill(id) {
const pid = running.get(id)
if (pid) killProcessGroup(pid)
const entry = running.get(id)
if (entry) killProcessGroup(entry.pid)
else if (running.has(id)) killRequests.add(id)
running.delete(id)
startedAt.delete(id)
@ -177,7 +198,7 @@ function createGamesService({ getWindow, onGamesChanged }) {
return importGameFromPrefix(prefix, srcDir)
}
return { isRunning, listRunning, start, kill, runToolFor, import: importTo, importFromPrefix, setOnGameExit, openExternally }
return { isRunning, listRunning, runningInfo, start, kill, runToolFor, import: importTo, importFromPrefix, setOnGameExit, openExternally }
}
module.exports = { createGamesService, killProcessGroup, sendToWindow }

View file

@ -85,7 +85,15 @@ function registerIpc({ getWindow, onGamesChanged }) {
ipcMain.handle('wines:setDisabled', (e, id, disabled) => wines.setWineDisabled(id, disabled))
ipcMain.handle('wines:setDisplayName', (e, id, name) => wines.setWineDisplayName(id, name))
ipcMain.handle('wines:setDefault', (e, id) => wines.setDefaultWine(id))
ipcMain.handle('wines:delete', (e, id) => wines.deleteWine(id))
ipcMain.handle('wines:delete', (e, id) => {
// the build's files back the running prefix (dlls, stub exes) - deleting
// it under a running game breaks that game
if (library.isWineInUse(id, games.runningInfo())) {
const apps = games.runningInfo().filter(r => r.wine === id).map(r => r.name)
return { ok: false, error: 'inUse', apps }
}
return wines.deleteWine(id)
})
ipcMain.handle('wines:import', async (e, srcDir) => {
try {
return await wines.importWineFolder(srcDir)
@ -95,11 +103,37 @@ function registerIpc({ getWindow, onGamesChanged }) {
})
// prefixes ---------------------------------------------------------------------
// A prefix folder must not move or disappear while a game runs in it: wine
// keeps the registry and user files open and would write into a folder that
// no longer exists (or, for a delete, lose them mid-run). The renderer shows
// these as a toast naming the blocking app.
const prefixInUse = name => {
const apps = library.appsUsingPrefix(name, games.runningInfo())
return apps.length ? { ok: false, error: 'inUse', apps: apps.map(r => r.name) } : null
}
ipcMain.handle('prefixes:list', () => prefixes.listPrefixes())
ipcMain.handle('prefixes:create', (e, name) => prefixes.createPrefix(name))
ipcMain.handle('prefixes:delete', (e, name) => prefixes.deletePrefix(name))
ipcMain.handle('prefixes:rename', (e, oldName, newName) => prefixes.renamePrefix(oldName, newName))
ipcMain.handle('prefixes:setDefault', (e, name) => prefixes.setDefaultPrefix(name))
ipcMain.handle('prefixes:delete', (e, name) => {
const busy = prefixInUse(name)
if (busy) return busy
// deleting a prefix that still has apps registered in it would orphan
// them (they fall back to the default prefix and lose their files)
const users = library.gamesUsingPrefix(name).map(g => g.name)
if (users.length) return { ok: false, error: 'hasApps', apps: users }
return prefixes.deletePrefix(name)
})
ipcMain.handle('prefixes:rename', (e, oldName, newName) => prefixInUse(oldName) || prefixes.renamePrefix(oldName, newName))
ipcMain.handle('prefixes:setDefault', (e, name) => {
// apps without a prefix field of their own resolve the default at launch;
// switching it now would silently move their next run (a prefix import is
// pinned to its own name)
const defs = prefixes.listPrefixes()
if (name !== defs.default) {
const affected = library.gamesFollowingDefault(games.runningInfo())
if (affected.length) return { ok: false, error: 'inUse', apps: affected.map(r => r.name) }
}
return prefixes.setDefaultPrefix(name)
})
// games: library -----------------------------------------------------------------
ipcMain.handle('games:list', () => library.loadGames())

View file

@ -82,6 +82,53 @@ function getGame(id) {
return loadGames().find(g => g.id === id) || null
}
// Which prefix a game's runs land in: an app without one follows the launcher
// default. The single place for that fallback (runner.buildEnv and the in-use
// guards both use it, so a rename/delete can never blame the wrong folder).
function prefixNameForGame(game) {
if (game && game.prefix) return game.prefix
const defs = readJson(path.join(DIRS.DATA, 'prefixes.json'), { default: 'Main', prefixes: [] })
return defs.default || 'Main'
}
// Apps whose runs land in this prefix - used to keep a prefix that any app
// still references from being deleted out from under it.
function gamesUsingPrefix(name) {
if (!name) return []
return loadGames().filter(g => prefixNameForGame(g) === name)
}
// In-use guards for registry edits: a prefix folder being renamed or deleted
// (or a wine build deleted) while a game runs in it would leave that process
// without its registry/files - the folder move/removal has to wait until the
// game stops. `running` is the games service's list of live runs
// ([{ name, prefixes, wine }]); prefixes lists every folder a run holds (its
// snapshot plus a new one it was pointed at mid-run).
function appsUsingPrefix(name, running) {
if (!name) return []
return (running || []).filter(r => (r.prefixes || []).includes(name))
}
function isPrefixInUse(name, running) {
return appsUsingPrefix(name, running).length > 0
}
// A run with no explicit wine falls back to System Wine, which cannot be
// deleted anyway - only an exact match on a registered build counts.
function isWineInUse(id, running) {
if (!id || id === 'system') return false
return (running || []).some(r => r.wine === id)
}
// Switching the default prefix would silently move the next launch of every
// app that has no prefix of its own - so it waits while any of them runs.
function gamesFollowingDefault(running) {
return (running || []).filter(r => {
const game = getGame(r.id)
return !game || !game.prefix
})
}
function addGame(game) {
const games = loadGames()
games.push(game)
@ -207,9 +254,7 @@ function renamePrefixRefs(oldName, newName) {
}
function resolvePrefixDir(game) {
if (game.prefix) return prefixPath(game.prefix)
const defs = readJson(path.join(DIRS.DATA, 'prefixes.json'), { default: 'default', prefixes: [] })
return prefixPath(defs.default || 'default')
return prefixPath(prefixNameForGame(game))
}
module.exports = { loadGames, saveGames, portableGame, getGame, addGame, updateGame, removeGame, reorderGames, renamePrefixRefs, loadSettings, saveSettings, newGame, newGameId, resolvePrefixDir, clearPlaytime, readPlaySeconds, writePlaySeconds, playtimeFile }
module.exports = { loadGames, saveGames, portableGame, getGame, addGame, updateGame, removeGame, reorderGames, renamePrefixRefs, prefixNameForGame, gamesUsingPrefix, appsUsingPrefix, isPrefixInUse, isWineInUse, gamesFollowingDefault, loadSettings, saveSettings, newGame, newGameId, resolvePrefixDir, clearPlaytime, readPlaySeconds, writePlaySeconds, playtimeFile }

View file

@ -5,6 +5,7 @@ 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
@ -18,13 +19,6 @@ function ensureFakeSteam() {
return fake
}
// The prefix a run will use, by NAME (buildEnv resolves the same fallback).
function prefixNameFor(game) {
if (game && game.prefix) return game.prefix
const defs = require('./prefixes').listPrefixes()
return defs.default || 'default'
}
// 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.
@ -32,7 +26,7 @@ function prefixNameFor(game) {
// costs a single walk in the common (already relative) case.
const healedPrefixes = new Set()
async function healPrefixOnce(game) {
const name = prefixNameFor(game)
const name = prefixNameForGame(game)
const key = `${name}\u0000${DIRS.BUILD}`
if (!name || healedPrefixes.has(key)) return 0
healedPrefixes.add(key)
@ -78,11 +72,9 @@ function linkPrefix(compatData, prefixDir) {
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')
}
// 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()