PLWL/src/library.js
2026-10-01 14:18:15 +02:00

180 lines
5.9 KiB
JavaScript

'use strict'
const path = require('path')
const fs = require('fs')
const { DIRS, readJson, writeJson, toPortablePath, resolvePortablePath } = require('./paths')
const { prefixPath } = 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))))
}
// Prefix imports keep exe/workdir paths relative to the PREFIX ROOT
// ("drive_c/..." for plain wine, "pfx/drive_c/..." for Proton-managed
// prefixes) so wine sees them under C:\... - everything else is
// FILES-relative/absolute.
const isPrefixRel = v => typeof v === 'string' && /^(pfx[/\\])?drive_c([/\\]|$)/i.test(v)
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
}
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 })
}
function removeGame(id) {
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)) {
try {
require('fs').rmSync(g.path, { recursive: true, force: true })
} catch {}
}
// 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: [],
env: {},
iconSource: '',
playSeconds: 0,
created: Date.now(),
lastPlayed: 0
}
}
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')
}
module.exports = { loadGames, saveGames, portableGame, getGame, addGame, updateGame, removeGame, loadSettings, saveSettings, newGame, newGameId, resolvePrefixDir, clearPlaytime, readPlaySeconds, writePlaySeconds, playtimeFile }