'use strict' const fs = require('fs') const fsp = require('fs').promises const path = require('path') // Returns a non-existing path in `base` named `name` (or `name (2)`, `name (3)`...). function dedupeDir(base, name) { const candidate = path.join(base, name) if (!fs.existsSync(candidate)) return candidate let n = 2 while (fs.existsSync(path.join(base, `${name} (${n})`))) n++ return path.join(base, `${name} (${n})`) } // Progress is cheap to produce and expensive to render: hashing or copying a // tree can emit tens of thousands of events, and forwarding every single one // over IPC makes the window stutter instead of showing progress. Events are // rate limited to ~10/s; `force` lets a caller push the value that matters // (the end of a unit) through immediately. const PROGRESS_MIN_MS = 100 function progressGate(emit) { let last = 0 let pending = null let timer = null const flush = () => { timer = null if (!pending) return last = Date.now() const data = pending pending = null emit(data) } const push = (data, force) => { pending = data const wait = PROGRESS_MIN_MS - (Date.now() - last) if (force || wait <= 0) { if (timer) clearTimeout(timer) flush() } else if (!timer) { timer = setTimeout(flush, wait) if (timer.unref) timer.unref() } } push.done = () => { if (timer) clearTimeout(timer) flush() } return push } // What a tree copy will have to move. Symlinks have no size of their own // (they are recreated, not read), so they only count for the file total. // `skipDir` is a single directory name to leave out (a game folder's // redistributables) - walking the whole tree first is fast next to the copy. async function treeSize(root) { let files = 0 let bytes = 0 const walk = async dir => { let entries try { entries = await fsp.readdir(dir, { withFileTypes: true }) } catch { return } for (const entry of entries) { const full = path.join(dir, entry.name) if (entry.isSymbolicLink()) { files++ } else if (entry.isDirectory()) { await walk(full) } else if (entry.isFile()) { files++ try { bytes += (await fsp.stat(full)).size } catch {} } } } await walk(root) return { files, bytes } } // Copy a directory tree the way an imported game/wine build has to arrive: // symlinks recreated as they are (copyFile would silently drop them) and file // permissions carried over (copyFile writes 0644, killing scripts and any // binary the game executes itself). Reports cumulative { done, bytes } after // every file through `onProgress`, so long copies can drive a progress bar. async function copyTree(src, dst, onProgress) { let done = 0 let bytes = 0 const gate = onProgress ? progressGate(onProgress) : null const tick = () => { if (gate) gate({ done, bytes }, false) } const walk = async (from, to) => { let entries try { entries = await fsp.readdir(from, { withFileTypes: true }) } catch { return } await fsp.mkdir(to, { recursive: true }) for (const entry of entries) { const s = path.join(from, entry.name) const d = path.join(to, entry.name) if (entry.isSymbolicLink()) { let target = null try { target = await fsp.readlink(s) } catch {} if (!target) continue try { await fsp.symlink(target, d) } catch {} done++ tick() } else if (entry.isDirectory()) { await walk(s, d) } else if (entry.isFile()) { let size = 0 try { size = (await fsp.stat(s)).size } catch {} try { await fsp.copyFile(s, d) await fsp.chmod(d, (await fsp.stat(s)).mode & 0o777) } catch {} done++ bytes += size tick() } } } await walk(src, dst) if (gate) gate({ done, bytes }, true) return { done, bytes } } // Delete a tree deepest-first so a long removal (a multi-GB prefix) can drive // a progress bar instead of freezing the window on one rmSync. Symlinks are // unlinked, never followed. Reports cumulative { files, bytes, dirs } through // `onProgress`; a missing tree is simply done. async function removeTree(root, onProgress) { let files = 0 let dirs = 0 let bytes = 0 const gate = onProgress ? progressGate(onProgress) : null const tick = () => { if (gate) gate({ files, dirs, bytes }, false) } const walk = async dir => { let entries try { entries = await fsp.readdir(dir, { withFileTypes: true }) } catch { return false } for (const entry of entries) { const full = path.join(dir, entry.name) if (entry.isDirectory()) { await walk(full) try { await fsp.rmdir(full) } catch {} dirs++ tick() continue } let size = 0 try { const st = await fsp.lstat(full) size = st.isFile() ? st.size : 0 } catch {} try { await fsp.unlink(full) } catch {} files++ bytes += size tick() } try { await fsp.rmdir(dir) dirs++ } catch {} return true } await walk(root) if (gate) gate({ files, dirs, bytes }, true) return { files, dirs, bytes } } module.exports = { dedupeDir, copyTree, treeSize, removeTree, progressGate }