- added 'open with' handling to desktop shortcuts - move logs button to app configuration - fix sync progress disappearing after long hashing - fix sync double file counter - merge wine and proton prefix folders via symlink
216 lines
7.7 KiB
JavaScript
216 lines
7.7 KiB
JavaScript
'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 }
|