'use strict' const { runGame, runTool } = require('../runner') const { importGame, importGameFromPrefix } = require('../importer') const library = require('../library') function killProcessGroup(pid) { try { process.kill(-pid, 'SIGKILL') } catch {} try { process.kill(pid, 'SIGKILL') } catch {} } function sendToWindow(win, channel, data) { if (win && !win.isDestroyed()) win.webContents.send(channel, data) } // 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 function createGamesService({ getWindow, onGamesChanged }) { const running = new Map() const startedAt = new Map() const killRequests = new Set() let onGameExit = null const notifyChanged = () => { if (typeof onGamesChanged === 'function') onGamesChanged() } function alive(pid) { if (!pid) return false try { process.kill(pid, 0) return true } catch { return false } } // A game is "running" only while its process really is. An entry left behind // by a launch that never got a pid (the binary could not be executed, so the // spawn failed) or whose pid is gone (the game crashed, the launcher was // restarted) would otherwise answer every later Play with "already // running". function prune() { for (const [id, entry] of [...running]) { if (alive(entry.pid)) continue const age = Date.now() - (startedAt.get(id) || 0) if (!entry.pid && age < STARTUP_GRACE_MS) continue // may still be starting running.delete(id) startedAt.delete(id) } } const isRunning = id => { prune() return running.has(id) } const listRunning = () => { 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 // not up yet, the renderer picks the file up right after boot. function openExternally(openPath) { require('../openwith').setPending(openPath) sendToWindow(getWindow(), 'openwith:file', openPath) } function resolveWine(game) { return require('../wines').resolveWine(game.wine || 'system') } function handleExit(id) { running.delete(id) startedAt.delete(id) sendToWindow(getWindow(), 'games:changed') notifyChanged() if (onGameExit) onGameExit(id) } async function start(win, id, openFile) { const game = library.getGame(id) if (!game) return { ok: false, error: 'game-not-found' } prune() let wine try { wine = resolveWine(game) } catch { return { ok: false, error: 'wine-not-found' } } if (running.has(id)) { // Opening a document in an app that is already running is a HANDOFF, // exactly like Windows: a second process receives the file and the app's // own single-instance logic takes it over. It is not a tracked run - // playtime, the tray and the kill switch belong to the first process. if (!openFile) return { ok: false, error: 'already-running' } try { await runGame({ game, wine, systemWine: wine.type === 'system', openFile, handoff: true }) return { ok: true, pid: null, logFile: null } } catch (err) { return { ok: false, error: String((err && err.message) || err) } } } 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), openFile }) // stop was requested while the process was still starting: kill it and // leave nothing in the running set if (killRequests.has(id)) { killRequests.delete(id) if (res.pid) killProcessGroup(res.pid) running.delete(id) startedAt.delete(id) sendToWindow(win, 'games:changed') notifyChanged() return { ok: true, pid: null, logFile: res.logFile || null } } // no pid means the process was not started after all - never keep such // an entry, it would block the next launch with "already running" if (!res.pid) { running.delete(id) startedAt.delete(id) return { ok: false, error: 'not-started' } } 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 } } catch (err) { running.delete(id) startedAt.delete(id) return { ok: false, error: String((err && err.message) || err) } } } // 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 entry = running.get(id) if (entry) killProcessGroup(entry.pid) else if (running.has(id)) killRequests.add(id) running.delete(id) startedAt.delete(id) sendToWindow(getWindow(), 'games:changed') notifyChanged() return { ok: true } } async function runToolFor(id, tool, args) { const game = library.getGame(id) if (!game) return { ok: false, error: 'game-not-found' } let wine try { wine = resolveWine(game) } catch { return { ok: false, error: 'wine-not-found' } } try { await runTool({ game, wine, tool, args, systemWine: wine.type === 'system' }) return { ok: true } } catch (err) { return { ok: false, error: String((err && err.message) || err) } } } // subscribe to game exits (used by the tray/headless mode to e.g. quit when // the game that auto-started the launcher has finished) function setOnGameExit(fn) { onGameExit = typeof fn === 'function' ? fn : null } async function importTo(win, srcDir, mode) { return importGame(srcDir, mode, data => sendToWindow(win, 'import:progress', data)) } // prefix import: nothing is copied, the game just gets registered with // paths relative to the prefix (wine sees it as a normal C:\ install) async function importFromPrefix(win, prefix, srcDir) { return importGameFromPrefix(prefix, srcDir) } return { isRunning, listRunning, runningInfo, start, kill, runToolFor, import: importTo, importFromPrefix, setOnGameExit, openExternally } } module.exports = { createGamesService, killProcessGroup, sendToWindow }