'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//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 }