143 lines
5.2 KiB
JavaScript
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 }
|