195 lines
5.3 KiB
JavaScript
195 lines
5.3 KiB
JavaScript
'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 }
|
|
|