PLWL/src/importer.js
2026-09-30 15:13:21 +02:00

136 lines
5.1 KiB
JavaScript

'use strict'
const fsp = require('fs').promises
const path = require('path')
const { DIRS, resolvePortablePath } = require('./paths')
const { newGame, newGameId, addGame } = require('./library')
const { extractIconFromExe } = require('./icons')
const { prefixPath } = require('./prefixes')
// Directories that typically hold installers/redistributables, not the game.
const SKIPPED_DIRS = new Set(['redist', 'directx', '_commonredist', 'support', 'dotnet', 'vc'])
function copyRecursive(src, dst, onProgress) {
return fsp.readdir(src, { withFileTypes: true }).then(async entries => {
await fsp.mkdir(dst, { recursive: true })
for (const e of entries) {
const s = path.join(src, e.name)
const d = path.join(dst, e.name)
if (e.isDirectory()) {
await copyRecursive(s, d, onProgress)
} else if (e.isFile()) {
await fsp.copyFile(s, d)
if (onProgress) onProgress(s)
}
}
})
}
function moveFolder(src, dst, onProgress) {
const fallback = () => copyRecursive(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) {
const name = path.basename(srcDir)
// Validate before touching the user's files: refuse an import without any
// .exe (and don't half-move a folder we'd then have to roll back).
const preExe = await findDefaultExe(srcDir)
if (!preExe) return { ok: false, error: 'no-exe-found' }
// The id doubles as the imported folder name, so generate it first and
// create files/apps/<id> - no dedupe suffixes, ids can never collide.
const id = newGameId()
let workdir = srcDir
if (mode === 'copy' || mode === 'move') {
const dst = path.join(DIRS.APPS, id)
if (mode === 'copy') await copyRecursive(srcDir, dst, onProgress)
else await moveFolder(srcDir, dst, onProgress)
workdir = dst
}
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'
// the exe was picked automatically -> grab its icon right away; a missing
// icon is not an import failure (game can still get one from config)
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 wine prefix: the folder is picked under the prefix's
// drive_c and left in place - the game paths are stored relative to the
// prefix, so wine sees the app exactly as if it had been installed
// normally into C:\...
async function importGameFromPrefix(prefix, srcDir, onProgress) {
const prefixes = require('./prefixes')
if (!prefixes.listPrefixes().prefixes.some(p => p.name === prefix)) return { ok: false, error: 'prefix-not-found' }
// the folder must live inside the prefix's C: drive so the C:\ mapping
// holds - drive_c sits at the prefix top for plain wine, under pfx/ for
// Proton-managed prefixes
const driveRoot = prefixes.prefixDriveRoot(prefix)
if (!driveRoot) return { ok: false, error: 'no-drive-c' }
const rel = path.relative(driveRoot, srcDir)
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return { ok: false, error: 'outside-drive-c' }
const preExe = await findDefaultExe(srcDir)
if (!preExe) return { ok: false, error: 'no-exe-found' }
const id = newGameId()
const game = newGame({ id, name: path.basename(srcDir), exePath: preExe, workdir: srcDir, mode: 'prefix' })
game.importSource = 'prefix'
game.prefix = prefix
// exe picked automatically -> grab its icon right away; a missing icon is
// not an import failure (game can still get one from config)
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. Files are
// stat'ed in parallel and folders walked breadth-first so big games don't
// turn the picker into a sequential crawl.
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, copyRecursive, moveFolder }