'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 (files/data/appdata//playtime.txt), // updated every ~30s without touching apps.json. 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) { // portable paths resolve against FILES, prefix imports against their prefix 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) if (g.isolation !== undefined) g.isolation = isolationMode(g.isolation) const ps = readPlaySeconds(g.id) if (ps != null) g.playSeconds = ps } return games } // Paths inside FILES/ are stored FILES-relative (portable); others 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 ("drive_c/...") 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 (launcher default when unset). 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 (in-use guard). function gamesUsingPrefix(name) { if (!name) return [] return loadGames().filter(g => prefixNameForGame(g) === name) } // In-use guards: rename/delete of a prefix or build waits until no live run // uses it. `running` is the games service's list of live runs. 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 } // 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 waits while any default-running app is live. function gamesFollowingDefault(running) { return (running || []).filter(r => { const game = getGame(r.id) return !game || !game.prefix }) } // `kind` is only ever written as 'linux'; a missing one is a Windows app. function isLinuxGame(game) { return !!game && game.kind === 'linux' } // none = plain run, relaxed = whole host filesystem mounted writable, isolated // = the stock read-only profile, auto = relaxed where bwrap works. const ISOLATION_MODES = ['auto', 'none', 'relaxed', 'isolated'] // One known mode, always: an unknown value would otherwise reach the runner and // pick a level nobody asked for. function isolationMode(value) { return ISOLATION_MODES.includes(value) ? value : 'auto' } function sanitizeLinuxPatch(patch) { if (!patch) return patch const out = { ...patch } if (out.kind !== undefined) out.kind = out.kind === 'linux' ? 'linux' : 'windows' if (out.isolation !== undefined) out.isolation = isolationMode(out.isolation) if (out.forceX11 !== undefined) out.forceX11 = !!out.forceX11 return out } 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 patch fields are ignored if (g.importSource === 'prefix' && patch) { patch = { ...patch } delete patch.prefix delete patch.importSource } // playSeconds lives in its own file; the registry mirrors it if (patch.playSeconds !== undefined) writePlaySeconds(id, patch.playSeconds) Object.assign(g, sanitizeLinuxPatch(patch)) saveGames(games) return g } function clearPlaytime(id) { return updateGame(id, { playSeconds: 0, lastPlayed: 0 }) } // Delete a portable app's folder with measured progress. 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: drop only the registry entry, never prefix files 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 } // Timestamp id, incremented while it collides. 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, kind }) { return { id: id || newGameId(), name, mode, kind: kind === 'linux' ? 'linux' : 'windows', path: workdir, exePath, workdir, wine: 'default', prefix: '', args: '', dllOverrides: [], // Proton-only: UMU by default, or the build's steam.exe launcher disableUmu: false, // Linux-only: none, relaxed, isolated, or auto (relaxed where bwrap works) isolation: 'auto', // Linux-only: force XWayland, where a window can still carry its icon forceX11: false, env: {}, iconSource: '', playSeconds: 0, created: Date.now(), lastPlayed: 0 } } // Reorder the registry array; unsent ids keep their order at the end. 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 registry. Raw JSON: the normal // load/save would re-relativize paths against the renamed folder. 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, isLinuxGame, addGame, updateGame, removeGame, reorderGames, renamePrefixRefs, prefixNameForGame, gamesUsingPrefix, appsUsingPrefix, isPrefixInUse, isWineInUse, gamesFollowingDefault, loadSettings, saveSettings, newGame, newGameId, resolvePrefixDir, clearPlaytime, readPlaySeconds, writePlaySeconds, playtimeFile }