183 lines
7.6 KiB
JavaScript
183 lines
7.6 KiB
JavaScript
/*
|
||
* Große Ordner kopieren, ohne den Launcher einzufrieren.
|
||
*
|
||
* Früher liefen Importe mit fs.cpSync – synchron im Hauptprozess. Bei
|
||
* Spielständen mit vielen Gigabyte stand dann der komplette Launcher
|
||
* minutenlang still (keine Anzeige, kein Abbrechen, „Keine Rückmeldung“).
|
||
*
|
||
* Hier wird asynchron gearbeitet: erst Dateien und Größe zählen, dann mit
|
||
* mehreren Dateien gleichzeitig kopieren. Große Dateien gehen stückweise, damit
|
||
* der Fortschritt auch bei einer einzelnen 4-GB-Datei weiterläuft. Gesperrte
|
||
* Einzeldateien werden übersprungen und gemeldet statt alles abzubrechen.
|
||
*/
|
||
const fs = require('fs');
|
||
const fsp = fs.promises;
|
||
const path = require('path');
|
||
const { pipeline } = require('stream/promises');
|
||
|
||
const GLEICHZEITIG = 8; // Dateien parallel
|
||
const STUECKWEISE_AB = 64 * 1024 * 1024; // ab hier mit Zwischenständen kopieren
|
||
const MAX_TIEFE = 64; // Schutz vor Verknüpfungs-Schleifen
|
||
const MELDE_ALLE_MS = 200;
|
||
|
||
class Abgebrochen extends Error {
|
||
constructor() { super('Vom Nutzer abgebrochen.'); this.abgebrochen = true; }
|
||
}
|
||
|
||
// Ordner-/Dateieintrag auflösen (Verknüpfungen werden wie ihr Ziel behandelt –
|
||
// die Modrinth App verlinkt z. B. Dateien aus ihrem gemeinsamen Speicher)
|
||
async function art(voll, eintrag) {
|
||
if (!eintrag.isSymbolicLink()) return { ordner: eintrag.isDirectory(), datei: eintrag.isFile() };
|
||
const st = await fsp.stat(voll).catch(() => null);
|
||
return st ? { ordner: st.isDirectory(), datei: st.isFile() } : { ordner: false, datei: false };
|
||
}
|
||
|
||
/** Zählt Dateien und Bytes unter allen Quellen (Dateien oder Ordner). */
|
||
async function zaehle(quellen, job, melde) {
|
||
const summe = { dateien: 0, bytes: 0 };
|
||
let zuletzt = 0;
|
||
async function lauf(voll, tiefe) {
|
||
if (job && job.abgebrochen) throw new Abgebrochen();
|
||
const st = await fsp.stat(voll).catch(() => null);
|
||
if (!st) return;
|
||
if (st.isFile()) { summe.dateien++; summe.bytes += st.size; }
|
||
else if (st.isDirectory() && tiefe < MAX_TIEFE) {
|
||
const eintraege = await fsp.readdir(voll, { withFileTypes: true }).catch(() => []);
|
||
for (const e of eintraege) {
|
||
const p = path.join(voll, e.name);
|
||
const a = await art(p, e);
|
||
if (a.ordner) await lauf(p, tiefe + 1);
|
||
else if (a.datei) {
|
||
const s = await fsp.stat(p).catch(() => null);
|
||
if (s) { summe.dateien++; summe.bytes += s.size; }
|
||
}
|
||
}
|
||
}
|
||
if (melde && Date.now() - zuletzt > MELDE_ALLE_MS) { zuletzt = Date.now(); melde({ ...summe }); }
|
||
}
|
||
for (const q of quellen) await lauf(q, 0);
|
||
if (melde) melde({ ...summe });
|
||
return summe;
|
||
}
|
||
|
||
/** Freier Platz (Bytes) auf dem Laufwerk von `ordner`, null wenn unbekannt. */
|
||
async function freierPlatz(ordner) {
|
||
try {
|
||
let pruefe = ordner;
|
||
while (!fs.existsSync(pruefe) && path.dirname(pruefe) !== pruefe) pruefe = path.dirname(pruefe);
|
||
const s = await fsp.statfs(pruefe);
|
||
return s.bavail * s.bsize;
|
||
} catch { return null; }
|
||
}
|
||
|
||
/**
|
||
* Fortschritt über mehrere Kopiervorgänge hinweg (z. B. alle Instanzen eines
|
||
* Imports). `melde` bekommt höchstens alle 200 ms einen Zwischenstand.
|
||
*/
|
||
function neuerFortschritt(gesamt, melde) {
|
||
const f = {
|
||
gesamtBytes: gesamt.bytes || 0, gesamtDateien: gesamt.dateien || 0,
|
||
bytes: 0, dateien: 0, datei: '', uebersprungen: [],
|
||
beginn: Date.now(), zuletzt: 0, verlauf: [],
|
||
};
|
||
f.melden = (sofort) => {
|
||
const jetzt = Date.now();
|
||
if (!melde || (!sofort && jetzt - f.zuletzt < MELDE_ALLE_MS)) return;
|
||
f.zuletzt = jetzt;
|
||
// Tempo über die letzten ~5 Sekunden glätten
|
||
f.verlauf.push([jetzt, f.bytes]);
|
||
while (f.verlauf.length > 2 && jetzt - f.verlauf[0][0] > 5000) f.verlauf.shift();
|
||
const [t0, b0] = f.verlauf[0];
|
||
const tempo = jetzt > t0 ? ((f.bytes - b0) * 1000) / (jetzt - t0) : 0;
|
||
// erst nach 1,5 s schätzen – davor gibt es noch kein brauchbares Tempo
|
||
const eingeschwungen = jetzt - f.beginn > 1500;
|
||
const rest = eingeschwungen && tempo > 0 && f.gesamtBytes > f.bytes ? Math.round((f.gesamtBytes - f.bytes) / tempo) : null;
|
||
melde({
|
||
bytes: f.bytes, gesamtBytes: f.gesamtBytes, dateien: f.dateien, gesamtDateien: f.gesamtDateien,
|
||
datei: f.datei, tempo: eingeschwungen ? Math.round(tempo) : 0, restSekunden: rest, uebersprungen: f.uebersprungen.length,
|
||
});
|
||
};
|
||
return f;
|
||
}
|
||
|
||
async function kopiereDatei(von, nach, groesse, fortschritt, job) {
|
||
if (groesse >= STUECKWEISE_AB) {
|
||
// große Datei: stückweise, damit der Balken weiterläuft
|
||
const lesen = fs.createReadStream(von, { highWaterMark: 4 * 1024 * 1024 });
|
||
lesen.on('data', (stueck) => {
|
||
if (job && job.abgebrochen) { lesen.destroy(new Abgebrochen()); return; }
|
||
if (fortschritt) { fortschritt.bytes += stueck.length; fortschritt.melden(); }
|
||
});
|
||
await pipeline(lesen, fs.createWriteStream(nach));
|
||
} else {
|
||
await fsp.copyFile(von, nach);
|
||
if (fortschritt) fortschritt.bytes += groesse;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Kopiert `von` (Datei oder Ordner) nach `nach`. Läuft asynchron, meldet über
|
||
* `fortschritt` (siehe neuerFortschritt) und bricht ab, sobald job.abgebrochen.
|
||
* Gesperrte Einzeldateien landen in fortschritt.uebersprungen; volle Platte
|
||
* (ENOSPC) und Abbruch beenden den ganzen Vorgang mit Fehler.
|
||
*/
|
||
async function kopiere(von, nach, fortschritt, job) {
|
||
const laufend = new Set();
|
||
let schwererFehler = null;
|
||
|
||
async function platz(aufgabe) {
|
||
while (laufend.size >= GLEICHZEITIG) await Promise.race(laufend);
|
||
if (schwererFehler) throw schwererFehler;
|
||
const p = aufgabe().catch((err) => {
|
||
if (err && (err.abgebrochen || err.code === 'ENOSPC')) schwererFehler = schwererFehler || err;
|
||
else if (fortschritt) fortschritt.uebersprungen.push({ datei: err && err.path ? err.path : '?', grund: err && err.code ? err.code : String(err && err.message) });
|
||
}).finally(() => laufend.delete(p));
|
||
laufend.add(p);
|
||
}
|
||
|
||
const basis = von;
|
||
async function datei(q, z) {
|
||
if (job && job.abgebrochen) throw new Abgebrochen();
|
||
const st = await fsp.stat(q);
|
||
if (fortschritt) fortschritt.datei = path.relative(path.dirname(basis), q);
|
||
await kopiereDatei(q, z, st.size, fortschritt, job);
|
||
if (fortschritt) { fortschritt.dateien++; fortschritt.melden(); }
|
||
}
|
||
|
||
async function ordner(q, z, tiefe) {
|
||
if (job && job.abgebrochen) throw new Abgebrochen();
|
||
if (schwererFehler) throw schwererFehler;
|
||
await fsp.mkdir(z, { recursive: true });
|
||
if (tiefe >= MAX_TIEFE) return;
|
||
const eintraege = await fsp.readdir(q, { withFileTypes: true });
|
||
for (const e of eintraege) {
|
||
const qq = path.join(q, e.name);
|
||
const zz = path.join(z, e.name);
|
||
const a = await art(qq, e);
|
||
if (a.ordner) await ordner(qq, zz, tiefe + 1);
|
||
else if (a.datei) await platz(() => datei(qq, zz));
|
||
}
|
||
}
|
||
|
||
let fehler = null;
|
||
try {
|
||
const st = await fsp.stat(von);
|
||
if (st.isDirectory()) await ordner(von, nach, 0);
|
||
else {
|
||
await fsp.mkdir(path.dirname(nach), { recursive: true });
|
||
await platz(() => datei(von, nach));
|
||
}
|
||
} catch (err) {
|
||
fehler = err;
|
||
}
|
||
// erst zurückkehren, wenn keine Datei mehr geschrieben wird – sonst würde
|
||
// das Aufräumen nach einem Abbruch mit noch laufenden Kopien kollidieren
|
||
while (laufend.size) await Promise.allSettled([...laufend]);
|
||
if (fehler) throw fehler;
|
||
if (schwererFehler) throw schwererFehler;
|
||
if (job && job.abgebrochen) throw new Abgebrochen();
|
||
if (fortschritt) fortschritt.melden(true);
|
||
}
|
||
|
||
module.exports = { zaehle, kopiere, freierPlatz, neuerFortschritt, Abgebrochen };
|