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