diff --git a/README.md b/README.md index fe8a008..9d42d9b 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ cd PLWL ## Importing from a wine prefix -Besides copy/move/leave, an app can be imported **from a wine prefix**: pick the prefix, then the app folder inside its `drive_c`. Nothing is copied — paths are stored relative to the prefix, so wine sees the app as if it were normally installed on `C:\` (registry entries pointing to `C:\...` keep working). Both layouts work: plain wine prefixes (`drive_c` at the top) and Proton-managed ones (`drive_c` under `pfx/`). The app stays tied to that prefix; removing the launcher entry never deletes files from the prefix. A prefix can be renamed later (Wine prefixes): the folder moves on disk and everything running in it follows the new name, because those paths are stored relative to the prefix. Imports keep the folder as it was: symlinks are recreated (rewritten to relative whenever the target is part of the launcher) and file permissions are carried over, so an imported game or build runs straight away. +Besides copy/move/leave, an app can be imported **from a wine prefix**: pick the prefix, then the app folder inside its `drive_c`. Nothing is copied — paths are stored relative to the prefix, so wine sees the app as if it were normally installed on `C:\` (registry entries pointing to `C:\...` keep working). Both layouts work: plain wine prefixes (`drive_c` at the top) and Proton-managed ones (`drive_c` under `pfx/`). The app stays tied to that prefix; removing the launcher entry never deletes files from the prefix. A prefix can be renamed later (Wine prefixes): the folder moves on disk and everything running in it follows the new name, because those paths are stored relative to the prefix. Edits wait for running apps: while anything from a prefix is playing, renaming or deleting it (and switching the default prefix away from an app that has none of its own) is refused with the name of the app to close first — wine keeps its registry and user files open in that folder. Deleting is also refused while a prefix still has apps registered in it, so an app is never silently orphaned into the default prefix. Imports keep the folder as it was: symlinks are recreated (rewritten to relative whenever the target is part of the launcher) and file permissions are carried over, so an imported game or build runs straight away. ## "Open with PLWL" in the file manager diff --git a/development.md b/development.md index a5677f0..8d6c3a6 100644 --- a/development.md +++ b/development.md @@ -14,7 +14,8 @@ src/ main process (Node/Electron backend) games.js games service: run/stop state, wine resolution, open-with handoff lansync.js LAN sync handlers (identity, devices, re-roll, sync + progress) runner.js spawns wine/proton (env, prefix, playtime, logs) - library.js apps registry (apps.json) + settings + playtime files + library.js apps registry (apps.json) + settings + playtime files, plus + the "which prefix does this app use" / in-use guards wines.js wine/proton build scan, registry, portable symlink healing prefixes.js prefix registry (files/wineprefixes) symlinks.js relative symlink rewiring + executable-bit restoration @@ -37,7 +38,8 @@ renderer/ UI (plain HTML/CSS/JS, no framework) style.css the whole look; CSS variables at the top files/ portable data the launcher ships/creates at runtime translations/ en-us.json, pl.json (copied to the data dir on start) -test/ plain node scripts, run them with `node test/.test.js` +test/ plain node scripts, run them with `node test/.test.js`; + electron test/.test.js for ones that need the real IPC/Electron tools/ install.sh / createshortcut.sh / build helpers ``` @@ -66,6 +68,11 @@ handlers in src/ipc/. Keep new APIs in that pattern. try/catch-and-ignore with a comment saying why it is safe to ignore. - Windows apps run through runGame()/buildEnv() - never spawn wine directly somewhere else (prefix handling, Proton env and playtime live there). +- A prefix or wine build that a running app uses cannot be renamed/deleted + (wine holds its registry and user files open in that folder): the guards sit + next to the handlers in src/ipc/, use the games service's running list plus + the helpers in library.js (prefixNameForGame/gamesUsingPrefix/is*InUse) and + answer { ok:false, error:'inUse'|'hasApps', apps:[names] } for the toast. - CSS: reuse the existing variables/classes (.button, .field-label, .custom-select, form-card); spacing rhythm comes from --button-margin and the flex gap of the container, not per-element magic numbers. diff --git a/files/translations/en-us.json b/files/translations/en-us.json index 836c7b9..4f193d3 100644 --- a/files/translations/en-us.json +++ b/files/translations/en-us.json @@ -120,6 +120,9 @@ "prefixes.renameHint": "The prefix folder and every app using it move to the new name.", "prefixes.renamed": "Prefix {0} renamed to {1}", "prefixes.renameFailed": "Could not rename the prefix: {0}", + "prefixes.inUse": "Cannot change prefix {0} while an app from it is running: {1}. Close it first.", + "prefixes.inUseHint": "An app is running from this prefix right now - close it before renaming.", + "prefixes.hasApps": "Cannot delete prefix {0} - these apps still use it: {1}. Point them at another prefix or remove them first.", "wines.title": "Wine versions", "wines.subtitle": "You can import your wine/proton builds here. Proton runs fully without Steam.", "wines.system": "System Wine", @@ -144,6 +147,7 @@ "wines.renameHint": "Only the name shown in the launcher changes - the build folder in files/wines stays as it is.", "wines.renamed": "{0} is now shown as {1}", "wines.renameFailed": "Could not rename: {0}", + "wines.inUse": "Cannot delete this Wine version while an app using it is running: {0}. Close it first.", "wines.invalidName": "Invalid name", "wines.import": "Import", "wines.importPick": "Choose the Wine/Proton build folder", diff --git a/files/translations/pl.json b/files/translations/pl.json index 9ed8d54..85dfe12 100644 --- a/files/translations/pl.json +++ b/files/translations/pl.json @@ -120,6 +120,9 @@ "prefixes.renameHint": "Folder prefiksu i wszystkie używające go aplikacje przechodzą na nową nazwę.", "prefixes.renamed": "Zmieniono nazwę prefiksu {0} na {1}", "prefixes.renameFailed": "Nie udało się zmienić nazwy prefiksu: {0}", + "prefixes.inUse": "Nie można zmienić prefiksu {0}, gdy działa z niego aplikacja: {1}. Najpierw ją zamknij.", + "prefixes.inUseHint": "Właśnie działa aplikacja z tego prefiksu - zamknij ją przed zmianą nazwy.", + "prefixes.hasApps": "Nie można usunąć prefiksu {0} - używają go jeszcze aplikacje: {1}. Wskaż im inny prefiks albo usuń je najpierw.", "wines.title": "Wersje Wine", "wines.subtitle": "Tutaj możesz zaimportować swoje buildy wine/proton. Proton działa w pełni bez Steama.", "wines.system": "Wine systemowe", @@ -144,6 +147,7 @@ "wines.renameHint": "Zmienia się tylko nazwa pokazywana w launcherze - folder builda w files/wines zostaje bez zmian.", "wines.renamed": "{0} wyświetla się teraz jako {1}", "wines.renameFailed": "Nie udało się zmienić nazwy: {0}", + "wines.inUse": "Nie można usunąć tej wersji Wine, gdy działa aplikacja, która jej używa: {0}. Najpierw ją zamknij.", "wines.invalidName": "Nieprawidłowa nazwa", "wines.import": "Importuj", "wines.importPick": "Wybierz folder z buildem Wine/Proton", diff --git a/renderer/app.js b/renderer/app.js index 7a1c30e..8875e49 100644 --- a/renderer/app.js +++ b/renderer/app.js @@ -921,6 +921,16 @@ async function doPrefixImport(prefix, srcDir) { } } +// Apps the launcher would name when it refuses a prefix rename/delete: a +// running app (blocking immediately) or a registered one (orphaned by a +// delete). Fetched fresh - the Prefixes view does not keep the library hot. +async function prefixUsers(name) { + const [games, running] = await Promise.all([window.plwl.gamesList(), window.plwl.gamesListRunning()]) + const runningSet = new Set(running || []) + const users = (games || []).filter(g => (g.prefix || state.prefixes.default || 'Main') === name) + return { all: users, running: users.filter(g => runningSet.has(g.id)) } +} + function renderPrefixes() { const list = qs('#prefix-list') list.innerHTML = '' @@ -952,10 +962,11 @@ function renderPrefixes() { if (renBtn) { renBtn.textContent = t('prefixes.rename') renBtn.onclick = async () => { + const users = await prefixUsers(p.name) const name = await promptModal({ title: t('prefixes.renameTitle'), label: t('prefixes.namePlaceholder'), - hint: t('prefixes.renameHint'), + hint: users.running.length ? t('prefixes.inUseHint') : t('prefixes.renameHint'), value: p.name, confirm: t('prefixes.rename') }) @@ -963,8 +974,12 @@ function renderPrefixes() { const res = await window.plwl.prefixesRename(p.name, name) if (!res || !res.ok) { const err = (res && res.error) || '' - const known = { alreadyExists: 'prefixes.exists', invalidName: 'prefixes.invalidName', isMain: 'prefixes.mainLocked' } - toast(known[err] ? t(known[err]) : t('prefixes.renameFailed', err), 'error') + if (err === 'inUse') toast(t('prefixes.inUse', p.name, (res.apps || []).join(', ')), 'error') + else if (err === 'hasApps') toast(t('prefixes.hasApps', p.name, (res.apps || []).join(', ')), 'error') + else { + const known = { alreadyExists: 'prefixes.exists', invalidName: 'prefixes.invalidName', isMain: 'prefixes.mainLocked' } + toast(known[err] ? t(known[err]) : t('prefixes.renameFailed', err), 'error') + } return } toast(t('prefixes.renamed', p.name, res.name), 'success') @@ -979,17 +994,24 @@ function renderPrefixes() { defBtn.onclick = async () => { const res = await window.plwl.prefixesSetDefault(p.name) if (res && res.ok !== false) toast(t('toast.prefixDefault', p.name), 'success') + else if (res && res.error === 'inUse') toast(t('prefixes.inUse', p.name, (res.apps || []).join(', ')), 'error') + else if (res && res.error === 'notFound') toast(t('toast.prefixDeleteFailed', p.name), 'error') state.prefixes = await window.plwl.prefixesList() renderPrefixes() } const delBtn = row.querySelector('[data-act=delete]') delBtn.innerHTML = `${ICONS.trash()}${t('prefixes.delete')}` delBtn.onclick = async () => { + // refused before the confirm prompt, with the reason: the folder is + // either held by a running app or still referenced by registered ones + const users = await prefixUsers(p.name) + if (users.all.length) return toast(t('prefixes.hasApps', p.name, users.all.map(g => g.name).join(', ')), 'error') const ok = await confirmModal(t('prefixes.deleteConfirm', p.name), t('prefixes.delete')) if (!ok) return const res = await window.plwl.prefixesDelete(p.name) if (res.ok) toast(t('toast.prefixDeleted', p.name), 'success') else if (res.error === 'isMain') toast(t('prefixes.mainLocked'), 'error') + else if (res.error === 'inUse' || res.error === 'hasApps') toast(t('prefixes.hasApps', p.name, (res.apps || []).join(', ')), 'error') else toast(t('toast.prefixDeleteFailed', p.name), 'error') state.prefixes = await window.plwl.prefixesList() renderPrefixes() @@ -1076,6 +1098,8 @@ function renderWines() { state.settings = await window.plwl.settingsGet() } else if (res.error === 'isSystem') { toast(t('wines.systemLocked'), 'error') + } else if (res.error === 'inUse') { + toast(t('wines.inUse', (res.apps || []).join(', ')), 'error') } renderWines() } diff --git a/src/ipc/games.js b/src/ipc/games.js index b72c8bb..e9d620d 100644 --- a/src/ipc/games.js +++ b/src/ipc/games.js @@ -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 } diff --git a/src/ipc/index.js b/src/ipc/index.js index d7cbb78..701d50a 100644 --- a/src/ipc/index.js +++ b/src/ipc/index.js @@ -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()) diff --git a/src/library.js b/src/library.js index 0673e62..1b1afe7 100644 --- a/src/library.js +++ b/src/library.js @@ -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 } diff --git a/src/runner.js b/src/runner.js index 897249d..c27a827 100644 --- a/src/runner.js +++ b/src/runner.js @@ -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()