136 lines
5.1 KiB
JavaScript
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 }
|