'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, };