264 lines
9.3 KiB
JavaScript
264 lines
9.3 KiB
JavaScript
'use strict'
|
|
|
|
const path = require('path')
|
|
const fs = require('fs')
|
|
const { DIRS, readJson, writeJson, toPortablePath, resolvePortablePath } = require('./paths')
|
|
const { prefixPath, isPrefixRel } = require('./prefixes')
|
|
|
|
const REGISTRY = path.join(DIRS.DATA, 'apps.json')
|
|
const SETTINGS = path.join(DIRS.DATA, 'settings.json')
|
|
|
|
// Playtime lives in its own tiny file holding ONLY a number of seconds
|
|
// (files/data/appdata/<id>/playtime.txt) so the running game can update it
|
|
// every ~30s without rewriting the whole apps.json registry on each tick.
|
|
function playtimeFile(id) {
|
|
return path.join(DIRS.DATA, 'appdata', id, 'playtime.txt')
|
|
}
|
|
|
|
function readPlaySeconds(id) {
|
|
try {
|
|
const n = parseInt(fs.readFileSync(playtimeFile(id), 'utf8').trim(), 10)
|
|
if (Number.isFinite(n) && n >= 0) return n
|
|
} catch {}
|
|
return null // no file yet -> fall back to the registry value
|
|
}
|
|
|
|
function writePlaySeconds(id, seconds) {
|
|
const file = playtimeFile(id)
|
|
fs.mkdirSync(path.dirname(file), { recursive: true })
|
|
fs.writeFileSync(file, String(Math.max(0, Math.round(seconds))))
|
|
}
|
|
|
|
function loadGames() {
|
|
const games = readJson(REGISTRY, [])
|
|
for (const g of games) {
|
|
// stored relative to FILES (portable) -> resolve to absolute for use.
|
|
// Prefix imports resolve against their own prefix instead (never the
|
|
// launcher-wide default).
|
|
const usePrefix = g.importSource === 'prefix' && g.prefix ? prefixPath(g.prefix) : null
|
|
for (const k of ['path', 'exePath', 'workdir']) {
|
|
if (!g[k]) continue
|
|
g[k] = usePrefix && isPrefixRel(g[k]) ? path.join(usePrefix, g[k]) : resolvePortablePath(g[k])
|
|
}
|
|
if (g.iconSource) g.iconSource = resolvePortablePath(g.iconSource)
|
|
const ps = readPlaySeconds(g.id)
|
|
if (ps != null) g.playSeconds = ps
|
|
}
|
|
return games
|
|
}
|
|
|
|
// Paths of anything living inside FILES/ are stored RELATIVE to FILES so the
|
|
// whole launcher folder can be moved (portable mode). Absolute paths outside
|
|
// FILES (apps left in place) are kept as-is.
|
|
function portableGame(g) {
|
|
const out = { ...g }
|
|
const pfx = out.importSource === 'prefix' && out.prefix ? prefixPath(out.prefix) : null
|
|
for (const k of ['path', 'exePath', 'workdir', 'iconSource']) {
|
|
const v = out[k]
|
|
if (!v) continue
|
|
if (pfx && k !== 'iconSource' && !isPrefixRel(v)) {
|
|
// prefix game: store relative to the PREFIX ROOT (keeps the drive_c
|
|
// segment, e.g. "drive_c/Games/..." or "pfx/drive_c/Games/...")
|
|
const rel = path.relative(pfx, resolvePortablePath(v))
|
|
if (rel && !rel.startsWith('..') && !path.isAbsolute(rel)) out[k] = rel
|
|
} else if (!(pfx && isPrefixRel(v))) {
|
|
out[k] = toPortablePath(v)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
function saveGames(games) {
|
|
writeJson(REGISTRY, games.map(portableGame))
|
|
}
|
|
|
|
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)
|
|
saveGames(games)
|
|
return game
|
|
}
|
|
|
|
function updateGame(id, patch) {
|
|
const games = loadGames()
|
|
const g = games.find(x => x.id === id)
|
|
if (!g) return null
|
|
// prefix imports are pinned: their paths are stored relative to their
|
|
// prefix, so NOTHING may move them to another prefix or flip their
|
|
// import source (the patch fields are simply ignored)
|
|
if (g.importSource === 'prefix' && patch) {
|
|
patch = { ...patch }
|
|
delete patch.prefix
|
|
delete patch.importSource
|
|
}
|
|
// playSeconds goes to its own tiny file; the registry just mirrors it for
|
|
// anything reading apps.json directly (loadGames prefers the file anyway).
|
|
if (patch.playSeconds !== undefined) writePlaySeconds(id, patch.playSeconds)
|
|
Object.assign(g, patch)
|
|
saveGames(games)
|
|
return g
|
|
}
|
|
|
|
function clearPlaytime(id) {
|
|
return updateGame(id, { playSeconds: 0, lastPlayed: 0 })
|
|
}
|
|
|
|
// A portable app's folder is removed along with its registry entry - measured
|
|
// first (staging) so the renderer can show a bar while the tree is walked,
|
|
// instead of freezing on one rmSync of a multi-GB game.
|
|
async function removeGame(id, onProgress) {
|
|
const games = loadGames()
|
|
const idx = games.findIndex(g => g.id === id)
|
|
if (idx === -1) return false
|
|
const g = games[idx]
|
|
if (g.imported && g.path && g.path.startsWith(DIRS.APPS)) {
|
|
const emit = (phase, detail) => {
|
|
if (onProgress) onProgress({ phase, label: g.name || id, ...detail })
|
|
}
|
|
const { treeSize, removeTree } = require('./fsutil')
|
|
emit('count', { files: 0, bytes: 0 })
|
|
const total = await treeSize(g.path)
|
|
await removeTree(g.path, p => emit('remove', { files: p.files, bytes: p.bytes, totalFiles: total.files, totalBytes: total.bytes }))
|
|
}
|
|
// prefix imports live inside the prefix: deleting launcher files would
|
|
// nuke the user's prefix (installed DLLs, saves of every other app), so
|
|
// only the registry entry is dropped
|
|
games.splice(idx, 1)
|
|
saveGames(games)
|
|
return true
|
|
}
|
|
|
|
function loadSettings() {
|
|
return readJson(SETTINGS, { language: 'en-us', defaultWine: 'system', theme: 'dark' })
|
|
}
|
|
|
|
function saveSettings(patch) {
|
|
const s = { ...loadSettings(), ...patch }
|
|
writeJson(SETTINGS, s)
|
|
return s
|
|
}
|
|
|
|
// ID is the current timestamp (ms); while a game with that id or a folder
|
|
// with that name exists, keep incrementing - so ids and import folder names
|
|
// can never collide.
|
|
function newGameId() {
|
|
let id = Date.now().toString()
|
|
const taken = () => getGame(id) || fs.existsSync(path.join(DIRS.APPS, id))
|
|
while (taken()) id = String(Number(id) + 1)
|
|
return id
|
|
}
|
|
|
|
function newGame({ id, name, exePath, workdir, mode }) {
|
|
return {
|
|
id: id || newGameId(),
|
|
name,
|
|
mode,
|
|
path: workdir,
|
|
exePath,
|
|
workdir,
|
|
wine: 'default',
|
|
prefix: '',
|
|
args: '',
|
|
dllOverrides: [],
|
|
// Proton-only switch: keep UMU (the default path) or fall back to the
|
|
// build's steam.exe launcher for this app (see runner.buildEnv)
|
|
disableUmu: false,
|
|
env: {},
|
|
iconSource: '',
|
|
playSeconds: 0,
|
|
created: Date.now(),
|
|
lastPlayed: 0
|
|
}
|
|
}
|
|
|
|
// The registry array IS the library order, so a drag & drop reorder rewrites
|
|
// it whole. Ids the caller did not send keep their relative order at the end:
|
|
// a renderer that raced an import must never drop an entry.
|
|
function reorderGames(ids) {
|
|
const games = loadGames()
|
|
const rest = new Map(games.map(g => [g.id, g]))
|
|
const out = []
|
|
for (const id of Array.isArray(ids) ? ids : []) {
|
|
const g = rest.get(String(id))
|
|
if (!g) continue
|
|
rest.delete(String(id))
|
|
out.push(g)
|
|
}
|
|
for (const g of games) if (rest.has(g.id)) out.push(g)
|
|
saveGames(out)
|
|
return out
|
|
}
|
|
|
|
// Follows a prefix rename through the app registry. Raw JSON on purpose:
|
|
// loadGames()/saveGames() would resolve and re-relativize every path against
|
|
// a prefix folder that was just renamed. The stored paths of prefix imports
|
|
// are relative to the prefix root, so only the name reference changes.
|
|
function renamePrefixRefs(oldName, newName) {
|
|
const games = readJson(REGISTRY, [])
|
|
let updated = 0
|
|
for (const g of games) {
|
|
if (g.prefix === oldName) {
|
|
g.prefix = newName
|
|
updated++
|
|
}
|
|
}
|
|
if (updated) writeJson(REGISTRY, games)
|
|
return updated
|
|
}
|
|
|
|
function resolvePrefixDir(game) {
|
|
return prefixPath(prefixNameForGame(game))
|
|
}
|
|
|
|
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 }
|