452 lines
16 KiB
JavaScript
452 lines
16 KiB
JavaScript
'use strict';
|
|
|
|
const archiver = require('archiver');
|
|
const fs = require('fs');
|
|
const os = require('os');
|
|
const path = require('path');
|
|
const { pipeline } = require('stream/promises');
|
|
const unzipper = require('unzipper');
|
|
|
|
function pad2(value) {
|
|
return String(value).padStart(2, '0');
|
|
}
|
|
|
|
function defaultBackupFileName(date = new Date()) {
|
|
return 'AeroMC-Backup-' +
|
|
date.getFullYear() + '-' +
|
|
pad2(date.getMonth() + 1) + '-' +
|
|
pad2(date.getDate()) + '_' +
|
|
pad2(date.getHours()) + '-' +
|
|
pad2(date.getMinutes()) + '-' +
|
|
pad2(date.getSeconds()) +
|
|
'.zip';
|
|
}
|
|
|
|
function defaultBackupDir(documentsDir) {
|
|
return path.join(documentsDir, 'AeroMC Launcher', 'Backups');
|
|
}
|
|
|
|
function resolveAutoBackupPath(documentsDir, date = new Date()) {
|
|
const dir = defaultBackupDir(documentsDir);
|
|
ensureDir(dir);
|
|
|
|
const baseName = defaultBackupFileName(date);
|
|
const ext = path.extname(baseName);
|
|
const stem = baseName.slice(0, -ext.length);
|
|
let attempt = 0;
|
|
let candidate = path.join(dir, baseName);
|
|
|
|
while (fs.existsSync(candidate)) {
|
|
attempt += 1;
|
|
candidate = path.join(dir, `${stem}-${attempt}${ext}`);
|
|
}
|
|
|
|
return candidate;
|
|
}
|
|
|
|
function listGlobalBackups(documentsDir) {
|
|
const dir = defaultBackupDir(documentsDir);
|
|
ensureDir(dir);
|
|
|
|
return fs.readdirSync(dir, { withFileTypes: true })
|
|
.filter((entry) => entry.isFile() && /\.zip$/i.test(entry.name) && !/\.partial\.zip$/i.test(entry.name))
|
|
.map((entry) => {
|
|
const filePath = path.join(dir, entry.name);
|
|
const stat = fs.statSync(filePath);
|
|
return {
|
|
name: entry.name,
|
|
path: filePath,
|
|
size: stat.size,
|
|
modifiedAt: stat.mtime.toISOString(),
|
|
};
|
|
})
|
|
.sort((a, b) => {
|
|
const timeDiff = new Date(b.modifiedAt).getTime() - new Date(a.modifiedAt).getTime();
|
|
return timeDiff || a.name.localeCompare(b.name, 'de');
|
|
});
|
|
}
|
|
|
|
function ensureDir(dir) {
|
|
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
}
|
|
|
|
function countInstanceDirs(instancesDir) {
|
|
if (!fs.existsSync(instancesDir)) return 0;
|
|
return fs.readdirSync(instancesDir, { withFileTypes: true }).filter((entry) => entry.isDirectory()).length;
|
|
}
|
|
|
|
function readJson(file, fallback) {
|
|
try {
|
|
return JSON.parse(fs.readFileSync(file, 'utf8').replace(/^\uFEFF/, ''));
|
|
} catch {
|
|
return fallback;
|
|
}
|
|
}
|
|
|
|
function writeJson(file, data) {
|
|
ensureDir(path.dirname(file));
|
|
fs.writeFileSync(file, JSON.stringify(data, null, 2), 'utf8');
|
|
}
|
|
|
|
function emitProgress(cb, mode, percent, detail) {
|
|
if (!cb) return;
|
|
cb({ mode, percent: Math.max(0, Math.min(100, Math.round(percent))), detail: detail || '' });
|
|
}
|
|
|
|
function normalizeZipEntryPath(entryPath) {
|
|
const parts = String(entryPath || '')
|
|
.replace(/\\/g, '/')
|
|
.split('/')
|
|
.filter(Boolean);
|
|
|
|
if (!parts.length) return '';
|
|
if (parts.some((part) => part === '.' || part === '..')) {
|
|
throw new Error('Das Backup-Archiv enthält ungültige Pfade.');
|
|
}
|
|
|
|
return path.join(...parts);
|
|
}
|
|
|
|
async function extractBackupArchive(sourcePath, destinationDir, options) {
|
|
const opts = options || {};
|
|
const onProgress = opts.onProgress;
|
|
const mode = opts.mode || 'restore';
|
|
const startPercent = opts.startPercent ?? 5;
|
|
const endPercent = opts.endPercent ?? 18;
|
|
const detail = opts.detail || 'Entpacke Backup';
|
|
const directory = await unzipper.Open.file(sourcePath);
|
|
const files = directory.files || [];
|
|
const fileEntries = files.filter((entry) => entry.type !== 'Directory');
|
|
const totalBytes = fileEntries.reduce((sum, entry) => sum + Number(entry.uncompressedSize || 0), 0);
|
|
let processedBytes = 0;
|
|
let processedEntries = 0;
|
|
|
|
ensureDir(destinationDir);
|
|
if (!files.length) {
|
|
emitProgress(onProgress, mode, endPercent, detail + ' abgeschlossen');
|
|
return;
|
|
}
|
|
|
|
for (const entry of files) {
|
|
const relativePath = normalizeZipEntryPath(entry.path);
|
|
if (!relativePath) continue;
|
|
|
|
const targetPath = path.join(destinationDir, relativePath);
|
|
if (entry.type === 'Directory') {
|
|
ensureDir(targetPath);
|
|
continue;
|
|
}
|
|
|
|
ensureDir(path.dirname(targetPath));
|
|
await pipeline(entry.stream(), fs.createWriteStream(targetPath));
|
|
|
|
processedBytes += Number(entry.uncompressedSize || 0);
|
|
processedEntries += 1;
|
|
const ratio = totalBytes > 0
|
|
? Math.max(0, Math.min(1, processedBytes / totalBytes))
|
|
: Math.max(0, Math.min(1, processedEntries / Math.max(1, fileEntries.length)));
|
|
const percent = startPercent + ((endPercent - startPercent) * ratio);
|
|
emitProgress(onProgress, mode, percent, detail + ' ...');
|
|
}
|
|
}
|
|
|
|
function createBackupArchive(options) {
|
|
const opts = options || {};
|
|
const instancesDir = opts.instancesDir;
|
|
const settingsFile = opts.settingsFile;
|
|
const includeSettings = opts.includeSettings !== false;
|
|
const backupInfo = opts.backupInfo || {};
|
|
const destinationPath = opts.destinationPath;
|
|
const onProgress = opts.onProgress;
|
|
const mode = opts.mode || 'create';
|
|
const startPercent = opts.startPercent ?? 15;
|
|
const endPercent = opts.endPercent ?? 98;
|
|
const detail = opts.detail || 'Packe ZIP-Archiv';
|
|
|
|
return new Promise((resolve, reject) => {
|
|
ensureDir(path.dirname(destinationPath));
|
|
|
|
const output = fs.createWriteStream(destinationPath);
|
|
const archive = archiver('zip', { store: true });
|
|
let done = false;
|
|
|
|
const fail = (err) => {
|
|
if (done) return;
|
|
done = true;
|
|
try { archive.destroy(); } catch { /* ignore */ }
|
|
try { output.destroy(); } catch { /* ignore */ }
|
|
reject(err);
|
|
};
|
|
|
|
output.on('close', () => {
|
|
if (done) return;
|
|
done = true;
|
|
resolve({ bytes: archive.pointer() });
|
|
});
|
|
output.on('error', fail);
|
|
archive.on('error', fail);
|
|
archive.on('warning', (err) => {
|
|
if (err && err.code === 'ENOENT') return;
|
|
fail(err);
|
|
});
|
|
archive.on('progress', (progress) => {
|
|
const totalBytes = progress && progress.fs ? progress.fs.totalBytes : 0;
|
|
const processedBytes = progress && progress.fs ? progress.fs.processedBytes : 0;
|
|
if (!totalBytes) return;
|
|
|
|
const ratio = Math.max(0, Math.min(1, processedBytes / totalBytes));
|
|
const percent = startPercent + ((endPercent - startPercent) * ratio);
|
|
emitProgress(onProgress, mode, percent, detail + ' ...');
|
|
});
|
|
|
|
archive.pipe(output);
|
|
if (fs.existsSync(instancesDir)) archive.directory(instancesDir, 'instances');
|
|
else archive.append('', { name: 'instances/.keep' });
|
|
|
|
if (includeSettings && settingsFile && fs.existsSync(settingsFile)) {
|
|
archive.file(settingsFile, { name: 'settings.json' });
|
|
}
|
|
|
|
archive.append(`${JSON.stringify(backupInfo, null, 2)}\n`, { name: 'backup-info.json' });
|
|
|
|
try {
|
|
const finalizeResult = archive.finalize();
|
|
if (finalizeResult && typeof finalizeResult.catch === 'function') finalizeResult.catch(fail);
|
|
} catch (err) {
|
|
fail(err);
|
|
}
|
|
});
|
|
}
|
|
|
|
function copyDirContentsTracked(sourceDir, targetDir, options) {
|
|
const opts = options || {};
|
|
const mode = opts.mode || 'create';
|
|
const onProgress = opts.onProgress;
|
|
const startPercent = opts.startPercent ?? 0;
|
|
const endPercent = opts.endPercent ?? 100;
|
|
const detailPrefix = opts.detailPrefix || 'Kopiere';
|
|
|
|
ensureDir(targetDir);
|
|
if (!fs.existsSync(sourceDir)) {
|
|
emitProgress(onProgress, mode, endPercent, detailPrefix + ' abgeschlossen');
|
|
return;
|
|
}
|
|
|
|
const entries = fs.readdirSync(sourceDir, { withFileTypes: true });
|
|
if (!entries.length) {
|
|
emitProgress(onProgress, mode, endPercent, detailPrefix + ' abgeschlossen');
|
|
return;
|
|
}
|
|
|
|
entries.forEach((entry, index) => {
|
|
fs.cpSync(path.join(sourceDir, entry.name), path.join(targetDir, entry.name), { recursive: true });
|
|
const ratio = (index + 1) / entries.length;
|
|
const percent = startPercent + ((endPercent - startPercent) * ratio);
|
|
emitProgress(onProgress, mode, percent, `${detailPrefix}: ${entry.name}`);
|
|
});
|
|
}
|
|
|
|
function moveDirTracked(sourceDir, targetDir, options) {
|
|
const opts = options || {};
|
|
const mode = opts.mode || 'restore';
|
|
const onProgress = opts.onProgress;
|
|
const startPercent = opts.startPercent ?? 0;
|
|
const endPercent = opts.endPercent ?? 100;
|
|
const detailPrefix = opts.detailPrefix || 'Verschiebe';
|
|
|
|
if (!fs.existsSync(sourceDir)) {
|
|
emitProgress(onProgress, mode, endPercent, detailPrefix + ' abgeschlossen');
|
|
return;
|
|
}
|
|
|
|
ensureDir(path.dirname(targetDir));
|
|
try {
|
|
fs.renameSync(sourceDir, targetDir);
|
|
emitProgress(onProgress, mode, endPercent, detailPrefix + ' abgeschlossen');
|
|
return;
|
|
} catch (err) {
|
|
if (!err || !['EXDEV', 'EPERM', 'EACCES'].includes(err.code)) throw err;
|
|
}
|
|
|
|
copyDirContentsTracked(sourceDir, targetDir, opts);
|
|
try { fs.rmSync(sourceDir, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
}
|
|
|
|
function resolveExtractedBackupRoot(extractDir) {
|
|
if (fs.existsSync(path.join(extractDir, 'instances'))) return extractDir;
|
|
const entries = fs.readdirSync(extractDir, { withFileTypes: true }).filter((entry) => entry.isDirectory());
|
|
if (entries.length === 1) {
|
|
const nested = path.join(extractDir, entries[0].name);
|
|
if (fs.existsSync(path.join(nested, 'instances'))) return nested;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
async function createGlobalBackup(options) {
|
|
const instancesDir = options && options.instancesDir;
|
|
const userDataDir = options && options.userDataDir;
|
|
const destinationPath = options && options.destinationPath;
|
|
const includeSettings = !options || options.includeSettings !== false;
|
|
const onProgress = options && options.onProgress;
|
|
if (!instancesDir || !userDataDir || !destinationPath) throw new Error('Backup-Parameter unvollständig.');
|
|
|
|
const settingsFile = path.join(userDataDir, 'settings.json');
|
|
const workingArchivePath = destinationPath.replace(/\.zip$/i, '') + '.partial.zip';
|
|
const backupInfo = {
|
|
createdAt: new Date().toISOString(),
|
|
instanceCount: countInstanceDirs(instancesDir),
|
|
includeSettings,
|
|
app: 'AeroMC',
|
|
version: 1,
|
|
};
|
|
|
|
try {
|
|
emitProgress(onProgress, 'create', 5, 'Bereite Backup vor');
|
|
emitProgress(onProgress, 'create', 10, 'Ermittle Backup-Inhalt');
|
|
|
|
if (fs.existsSync(destinationPath)) fs.unlinkSync(destinationPath);
|
|
if (fs.existsSync(workingArchivePath)) fs.unlinkSync(workingArchivePath);
|
|
emitProgress(onProgress, 'create', 90, 'Packe ZIP-Archiv');
|
|
await createBackupArchive({
|
|
instancesDir,
|
|
settingsFile,
|
|
includeSettings,
|
|
backupInfo,
|
|
destinationPath: workingArchivePath,
|
|
mode: 'create',
|
|
onProgress,
|
|
startPercent: 15,
|
|
endPercent: 98,
|
|
detail: 'Packe ZIP-Archiv',
|
|
});
|
|
emitProgress(onProgress, 'create', 99, 'Finalisiere Backup');
|
|
fs.renameSync(workingArchivePath, destinationPath);
|
|
emitProgress(onProgress, 'create', 100, 'Backup abgeschlossen');
|
|
|
|
return {
|
|
ok: true,
|
|
path: destinationPath,
|
|
instanceCount: countInstanceDirs(instancesDir),
|
|
includedSettings: includeSettings && fs.existsSync(settingsFile),
|
|
};
|
|
} finally {
|
|
try {
|
|
if (fs.existsSync(workingArchivePath)) fs.rmSync(workingArchivePath, { force: true });
|
|
} catch { /* ignore */ }
|
|
}
|
|
}
|
|
|
|
async function restoreGlobalBackup(options) {
|
|
const instancesDir = options && options.instancesDir;
|
|
const userDataDir = options && options.userDataDir;
|
|
const sourcePath = options && options.sourcePath;
|
|
const includeSettings = !!(options && options.includeSettings);
|
|
const onProgress = options && options.onProgress;
|
|
const instancesDirSetting = options && Object.prototype.hasOwnProperty.call(options, 'instancesDirSetting')
|
|
? options.instancesDirSetting
|
|
: null;
|
|
if (!instancesDir || !userDataDir || !sourcePath) throw new Error('Restore-Parameter unvollständig.');
|
|
|
|
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'aeromc-restore-'));
|
|
const extractDir = path.join(tmp, 'extract');
|
|
const rollbackDir = path.join(tmp, 'rollback');
|
|
const rollbackInstances = path.join(rollbackDir, 'instances');
|
|
const settingsFile = path.join(userDataDir, 'settings.json');
|
|
const rollbackSettings = path.join(rollbackDir, 'settings.json');
|
|
let settingsPreviouslyExisted = false;
|
|
// wird erst true, sobald der aktuelle Stand tatsächlich weggesichert wurde -
|
|
// vorher darf der Rollback nichts anfassen, sonst löscht ein früher Fehler
|
|
// (z. B. ungültiges Archiv) den unveränderten, intakten Originalbestand
|
|
let originalMovedAway = false;
|
|
|
|
try {
|
|
ensureDir(extractDir);
|
|
emitProgress(onProgress, 'restore', 5, 'Entpacke Backup');
|
|
await extractBackupArchive(sourcePath, extractDir, {
|
|
mode: 'restore',
|
|
onProgress,
|
|
startPercent: 5,
|
|
endPercent: 18,
|
|
detail: 'Entpacke Backup',
|
|
});
|
|
|
|
const backupRoot = resolveExtractedBackupRoot(extractDir);
|
|
if (!backupRoot) throw new Error('Das Archiv enthält kein gültiges AeroMC-Backup.');
|
|
emitProgress(onProgress, 'restore', 18, 'Backup geprüft');
|
|
|
|
const sourceInstances = path.join(backupRoot, 'instances');
|
|
const sourceSettings = path.join(backupRoot, 'settings.json');
|
|
ensureDir(rollbackDir);
|
|
|
|
emitProgress(onProgress, 'restore', 25, 'Sichere aktuellen Stand');
|
|
moveDirTracked(instancesDir, rollbackInstances, {
|
|
mode: 'restore',
|
|
onProgress,
|
|
startPercent: 28,
|
|
endPercent: 40,
|
|
detailPrefix: 'Sichere aktuelle Instanzen',
|
|
});
|
|
originalMovedAway = true;
|
|
settingsPreviouslyExisted = fs.existsSync(settingsFile);
|
|
if (settingsPreviouslyExisted) fs.copyFileSync(settingsFile, rollbackSettings);
|
|
|
|
ensureDir(path.dirname(instancesDir));
|
|
moveDirTracked(sourceInstances, instancesDir, {
|
|
mode: 'restore',
|
|
onProgress,
|
|
startPercent: 45,
|
|
endPercent: 78,
|
|
detailPrefix: 'Stelle Instanzen wieder her',
|
|
});
|
|
|
|
let restoredSettings = false;
|
|
if (includeSettings && fs.existsSync(sourceSettings)) {
|
|
const restored = readJson(sourceSettings, {});
|
|
restored.instancesDir = instancesDirSetting;
|
|
writeJson(settingsFile, restored);
|
|
restoredSettings = true;
|
|
emitProgress(onProgress, 'restore', 90, 'Stelle Einstellungen wieder her');
|
|
} else {
|
|
emitProgress(onProgress, 'restore', 90, 'Einstellungen übersprungen');
|
|
}
|
|
|
|
emitProgress(onProgress, 'restore', 100, 'Wiederherstellen abgeschlossen');
|
|
|
|
return {
|
|
ok: true,
|
|
path: sourcePath,
|
|
instanceCount: countInstanceDirs(instancesDir),
|
|
restoredSettings,
|
|
};
|
|
} catch (err) {
|
|
// Nur zurückrollen, wenn wir den ursprünglichen Bestand überhaupt schon
|
|
// angefasst haben (siehe originalMovedAway oben). Schlägt die
|
|
// Wiederherstellung vorher fehl (ungültiges/beschädigtes Archiv o. Ä.),
|
|
// ist instancesDir noch der unveränderte Originalbestand - den lassen wir
|
|
// dann bewusst in Ruhe, statt ihn zu löschen.
|
|
if (originalMovedAway) {
|
|
try {
|
|
if (fs.existsSync(instancesDir)) fs.rmSync(instancesDir, { recursive: true, force: true });
|
|
if (fs.existsSync(rollbackInstances)) fs.cpSync(rollbackInstances, instancesDir, { recursive: true });
|
|
else ensureDir(instancesDir);
|
|
|
|
if (includeSettings) {
|
|
if (fs.existsSync(rollbackSettings)) fs.copyFileSync(rollbackSettings, settingsFile);
|
|
else if (!settingsPreviouslyExisted && fs.existsSync(settingsFile)) fs.rmSync(settingsFile, { force: true });
|
|
}
|
|
} catch { /* ignore rollback errors */ }
|
|
}
|
|
throw err;
|
|
} finally {
|
|
try { fs.rmSync(tmp, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
}
|
|
}
|
|
|
|
module.exports = {
|
|
createGlobalBackup,
|
|
restoreGlobalBackup,
|
|
defaultBackupFileName,
|
|
defaultBackupDir,
|
|
listGlobalBackups,
|
|
resolveAutoBackupPath,
|
|
}; |