guard prefix and wine edits while their apps run
This commit is contained in:
parent
aca9f839e3
commit
38eea05789
9 changed files with 170 additions and 39 deletions
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue