PLWL/src/library.js
2026-10-07 16:29:40 +02:00

265 lines
8.5 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 (files/data/appdata/<id>/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 }