PLWL/src/importer.js

143 lines
5.2 KiB
JavaScript

'use strict'
const fs = require('fs')
const fsp = fs.promises
const path = require('path')
const { DIRS, resolvePortablePath } = require('./paths')
const { newGame, newGameId, addGame } = require('./library')
const { extractIconFromExe } = require('./icons')
const { listPrefixes, prefixDriveRoots, isPrefixRoot } = require('./prefixes')
const { rewireSymlinks } = require('./symlinks')
const { copyTree, treeSize } = require('./fsutil')
// Directories that typically hold installers/redistributables, not the game.
const SKIPPED_DIRS = new Set(['redist', 'directx', '_commonredist', 'support', 'dotnet', 'vc'])
// The copy itself lives in fsutil.copyTree; moveFolder falls back to it
// across filesystems only.
function moveFolder(src, dst, onProgress) {
const fallback = () => copyTree(src, dst, onProgress).then(() => fsp.rm(src, { recursive: true, force: true }))
if (path.parse(src).root !== path.parse(dst).root) return fallback()
return fsp.rename(src, dst).catch(fallback)
}
async function importGame(srcDir, mode, onProgress) {
// a prefix (or its drive_c/pfx) is not an app folder
if (isPrefixRoot(srcDir)) return { ok: false, error: 'prefix-root' }
const name = path.basename(srcDir)
// validate before touching anything: no .exe -> refuse
const preExe = await findDefaultExe(srcDir)
if (!preExe) return { ok: false, error: 'no-exe-found' }
// the id doubles as the imported folder name
const id = newGameId()
let workdir = srcDir
if (mode === 'copy' || mode === 'move') {
const dst = path.join(DIRS.APPS, id)
if (mode === 'copy') {
// measured before it starts, so the bar can follow the bytes landing
const emit = (phase, detail) => {
if (onProgress) onProgress({ phase, label: name, ...detail })
}
emit('count', { files: 0, bytes: 0 })
const total = await treeSize(srcDir)
await copyTree(srcDir, dst, p => emit('copy', { files: p.done, bytes: p.bytes, totalFiles: total.files, totalBytes: total.bytes }))
} else await moveFolder(srcDir, dst, onProgress)
workdir = dst
// in-data link targets become relative, so the import survives a move
try {
await rewireSymlinks(dst, [DIRS.FILES])
} catch {}
}
const exePath = (await findDefaultExe(workdir)) || preExe
if (!exePath) {
return { ok: false, error: 'no-exe-found' }
}
const game = newGame({ id, name, exePath, workdir, mode })
game.imported = mode === 'copy' || mode === 'move'
// auto-picked exe -> grab its icon; a missing icon is not a failure
const icon = path.join(DIRS.DATA, 'icons', id, 'icon.ico')
if (extractIconFromExe(resolvePortablePath(exePath), icon).ok) {
game.iconSource = icon
}
return addGame(game)
}
// Import from a prefix: the folder stays in place, paths stored prefix-relative.
async function importGameFromPrefix(prefix, srcDir, onProgress) {
if (!listPrefixes().prefixes.some(p => p.name === prefix)) return { ok: false, error: 'prefix-not-found' }
// the folder must live inside one of the prefix's C: drives
const driveRoots = prefixDriveRoots(prefix)
if (!driveRoots.length) return { ok: false, error: 'no-drive-c' }
const src = path.resolve(String(srcDir))
// realpath both sides: a unified prefix reaches drive_c through pfx too
let realSrc
try {
realSrc = fs.realpathSync(src)
} catch {
return { ok: false, error: 'outside-drive-c' }
}
const insideDrive = driveRoots.some(root => {
let realRoot
try {
realRoot = fs.realpathSync(root)
} catch {
return false
}
const rel = path.relative(realRoot, realSrc)
return rel && !rel.startsWith('..') && !path.isAbsolute(rel)
})
if (!insideDrive) return { ok: false, error: 'outside-drive-c' }
const preExe = await findDefaultExe(src)
if (!preExe) return { ok: false, error: 'no-exe-found' }
const id = newGameId()
const game = newGame({ id, name: path.basename(src), exePath: preExe, workdir: src, mode: 'prefix' })
game.importSource = 'prefix'
game.prefix = prefix
// auto-picked exe -> grab its icon; a missing icon is not a failure
const icon = path.join(DIRS.DATA, 'icons', id, 'icon.ico')
if (extractIconFromExe(resolvePortablePath(preExe), icon).ok) {
game.iconSource = icon
}
return addGame(game)
}
// Largest .exe in the tree, skipping redistributable folders.
async function findDefaultExe(dir) {
let best = ''
let bestSize = -1
const pending = []
const consider = full => {
const size = fsp.stat(full).then(st => st.size).catch(() => -1)
pending.push(
size.then(sz => {
if (sz > bestSize) {
best = full
bestSize = sz
}
})
)
}
const walk = async d => {
let entries = []
try {
entries = await fsp.readdir(d, { withFileTypes: true })
} catch {
return
}
const subdirs = []
for (const e of entries) {
const full = path.join(d, e.name)
if (e.isDirectory()) {
if (!SKIPPED_DIRS.has(e.name.toLowerCase())) subdirs.push(full)
} else if (e.name.toLowerCase().endsWith('.exe')) {
consider(full)
}
}
await Promise.all(subdirs.map(walk))
}
await walk(dir)
await Promise.all(pending)
return best
}
module.exports = { importGame, importGameFromPrefix, findDefaultExe, moveFolder }