From 425b94fd9352ae53a344921403f58c92398a0e74 Mon Sep 17 00:00:00 2001 From: M_Viper Date: Fri, 14 Aug 2026 08:17:05 +0000 Subject: [PATCH] Upload via GUI (44 Dateien) --- src/backup.js | 902 ++++++++++++++++++++--------------------- src/cfimport.js | 390 +++++++++--------- src/mcimport.js | 432 ++++++++++---------- src/processwatchdog.js | 202 ++++----- 4 files changed, 963 insertions(+), 963 deletions(-) diff --git a/src/backup.js b/src/backup.js index b14118d..27f414a 100644 --- a/src/backup.js +++ b/src/backup.js @@ -1,452 +1,452 @@ -'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, +'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, }; \ No newline at end of file diff --git a/src/cfimport.js b/src/cfimport.js index e84f483..5da5001 100644 --- a/src/cfimport.js +++ b/src/cfimport.js @@ -1,196 +1,196 @@ -'use strict'; - -/* - * cfimport.js – Instanzen aus dem CurseForge-Launcher übernehmen - * --------------------------------------------------------------- - * Unterstützt sowohl den Launcher-Wurzelordner als auch direkt den - * Instances-Ordner. Gelesen werden nach Möglichkeit minecraftinstance.json - * und die vorhandenen Spieldaten im Profilordner. - */ - -const fs = require('fs'); -const os = require('os'); -const path = require('path'); - -const META_FILES = ['minecraftinstance.json', 'instance.json']; - -function readJson(file, fallback) { - try { - return JSON.parse(fs.readFileSync(file, 'utf8').replace(/^\uFEFF/, '')); - } catch { - return fallback; - } -} - -function existingMetaFile(dir) { - return META_FILES.map((name) => path.join(dir, name)).find((file) => fs.existsSync(file)) || null; -} - -function metaVersion(meta) { - return String( - (meta && ( - meta.gameVersion || meta.minecraftVersion || meta.mcVersion || - (meta.installedModpack && meta.installedModpack.gameVersion) - )) || '' - ).trim(); -} - -function resolveInstancesDir(inputPath) { - if (!inputPath || !fs.existsSync(inputPath)) return null; - const directNames = new Set(['instances', 'minecraftinstances']); - if (directNames.has(path.basename(inputPath).toLowerCase())) return inputPath; - - const candidates = [ - path.join(inputPath, 'Instances'), - path.join(inputPath, 'instances'), - path.join(inputPath, 'minecraftInstances'), - path.join(inputPath, 'Minecraft', 'Instances'), - path.join(inputPath, 'minecraft', 'Instances'), - ]; - - const looksLikeWindowsInstall = - /curseforge windows/i.test(inputPath) || - (fs.existsSync(path.join(inputPath, 'CurseForge.exe')) && fs.existsSync(path.join(inputPath, 'resources'))); - if (looksLikeWindowsInstall) { - candidates.push( - path.join(os.homedir(), 'curseforge', 'minecraft', 'Instances'), - path.join(os.homedir(), 'CurseForge', 'minecraft', 'Instances'), - ); - } - return candidates.find((candidate) => fs.existsSync(candidate)) || null; -} - -function normalizeLoaderVersion(loader, rawValue, mcVersion) { - let value = String(rawValue || '').trim(); - if (!value) return ''; - - if (loader === 'forge') { - value = value.replace(/^forge-/i, ''); - if (mcVersion && value.startsWith(mcVersion + '-')) value = value.slice(mcVersion.length + 1); - return value; - } - if (loader === 'neoforge') { - return value.replace(/^neoforge-/i, ''); - } - if (loader === 'fabric') { - value = value.replace(/^fabric(?:-loader)?-/i, ''); - if (mcVersion && value.endsWith('-' + mcVersion)) value = value.slice(0, -1 * (mcVersion.length + 1)); - return value; - } - if (loader === 'quilt') { - value = value.replace(/^quilt(?:-loader)?-/i, ''); - if (mcVersion && value.endsWith('-' + mcVersion)) value = value.slice(0, -1 * (mcVersion.length + 1)); - return value; - } - return value; -} - -function detectLoader(meta, mcVersion) { - const raw = meta && ( - meta.baseModLoader || meta.modLoader || meta.modloader || meta.loader || meta.modLoaderId - ); - const value = typeof raw === 'object' - ? (raw.name || raw.id || raw.value || raw.version || '') - : (raw || ''); - const lower = String(value).toLowerCase(); - const objectVersion = raw && typeof raw === 'object' - ? (raw.version || raw.name || raw.id || '') - : ''; - - if (!lower) return { loader: 'vanilla', loaderVersion: '' }; - if (lower.includes('neoforge')) return { loader: 'neoforge', loaderVersion: normalizeLoaderVersion('neoforge', objectVersion || value, mcVersion) }; - if (lower.includes('fabric')) return { loader: 'fabric', loaderVersion: normalizeLoaderVersion('fabric', objectVersion || value, mcVersion) }; - if (lower.includes('quilt')) return { loader: 'quilt', loaderVersion: normalizeLoaderVersion('quilt', objectVersion || value, mcVersion) }; - if (lower.includes('forge')) return { loader: 'forge', loaderVersion: normalizeLoaderVersion('forge', objectVersion || value, mcVersion) }; - return { loader: 'vanilla', loaderVersion: '' }; -} - -function detectVersion(meta, loaderVersion) { - return metaVersion(meta) || (loaderVersion && loaderVersion.includes('-') ? loaderVersion.split('-')[0] : ''); -} - -function findGameDir(dir) { - const nested = ['minecraft', '.minecraft'] - .map((name) => path.join(dir, name)) - .find((candidate) => fs.existsSync(candidate)); - if (nested) return nested; - - const markers = ['mods', 'config', 'resourcepacks', 'shaderpacks', 'saves', 'options.txt']; - return markers.some((name) => fs.existsSync(path.join(dir, name))) ? dir : null; -} - -function scan(inputPath) { - const instancesDir = resolveInstancesDir(inputPath); - if (!instancesDir) return { ok: false, reason: 'not-found' }; - - const list = []; - for (const entry of fs.readdirSync(instancesDir, { withFileTypes: true })) { - if (!entry.isDirectory()) continue; - const dir = path.join(instancesDir, entry.name); - const metaFile = existingMetaFile(dir); - const meta = metaFile ? readJson(metaFile, {}) : {}; - const gameDir = findGameDir(dir); - if (!metaFile && !gameDir) continue; - - const versionHint = metaVersion(meta); - const loaderInfo = detectLoader(meta, versionHint); - list.push({ - folder: entry.name, - dir, - name: meta.name || meta.displayName || entry.name, - group: '', - notes: meta.notes || meta.summary || '', - version: versionHint || detectVersion(meta, loaderInfo.loaderVersion), - loader: loaderInfo.loader, - loaderVersion: loaderInfo.loaderVersion, - javaPath: String(meta.javaPath || meta.javaExecutable || '').trim(), - minMemMb: Number(meta.minimumMemory || meta.minMemory || meta.minMemAlloc) || null, - maxMemMb: Number(meta.maximumMemory || meta.maxMemory || meta.maxMemAlloc || meta.allocatedMemory) || null, - gameDir: gameDir || dir, - hasGameData: !!gameDir, - }); - } - list.sort((a, b) => a.name.localeCompare(b.name)); - return { ok: true, instancesDir, count: list.length, instances: list }; -} - -function copyInto(sourceDir, targetDir) { - fs.mkdirSync(targetDir, { recursive: true }); - for (const name of fs.readdirSync(sourceDir)) { - if (META_FILES.includes(name)) continue; - fs.cpSync(path.join(sourceDir, name), path.join(targetDir, name), { recursive: true }); - } -} - -function importOne(store, entry, copyData = true) { - const created = store.createOrReplaceImportedInstance({ - name: entry.name, - group: entry.group, - notes: entry.notes, - minecraft: { - version: entry.version, - loader: entry.loader, - loaderVersion: entry.loaderVersion, - }, - }); - - const patch = {}; - if (entry.javaPath || entry.minMemMb || entry.maxMemMb) { - patch.java = { - path: entry.javaPath || '', - minMemMb: entry.minMemMb || null, - maxMemMb: entry.maxMemMb || null, - extraArgs: '', - }; - } - if (Object.keys(patch).length) store.updateInstance(created.id, patch); - - let copied = false; - if (copyData && entry.gameDir && fs.existsSync(entry.gameDir)) { - copyInto(entry.gameDir, store.gameDir(created.id)); - copied = true; - } - return { id: created.id, name: created.name, copied }; -} - +'use strict'; + +/* + * cfimport.js – Instanzen aus dem CurseForge-Launcher übernehmen + * --------------------------------------------------------------- + * Unterstützt sowohl den Launcher-Wurzelordner als auch direkt den + * Instances-Ordner. Gelesen werden nach Möglichkeit minecraftinstance.json + * und die vorhandenen Spieldaten im Profilordner. + */ + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const META_FILES = ['minecraftinstance.json', 'instance.json']; + +function readJson(file, fallback) { + try { + return JSON.parse(fs.readFileSync(file, 'utf8').replace(/^\uFEFF/, '')); + } catch { + return fallback; + } +} + +function existingMetaFile(dir) { + return META_FILES.map((name) => path.join(dir, name)).find((file) => fs.existsSync(file)) || null; +} + +function metaVersion(meta) { + return String( + (meta && ( + meta.gameVersion || meta.minecraftVersion || meta.mcVersion || + (meta.installedModpack && meta.installedModpack.gameVersion) + )) || '' + ).trim(); +} + +function resolveInstancesDir(inputPath) { + if (!inputPath || !fs.existsSync(inputPath)) return null; + const directNames = new Set(['instances', 'minecraftinstances']); + if (directNames.has(path.basename(inputPath).toLowerCase())) return inputPath; + + const candidates = [ + path.join(inputPath, 'Instances'), + path.join(inputPath, 'instances'), + path.join(inputPath, 'minecraftInstances'), + path.join(inputPath, 'Minecraft', 'Instances'), + path.join(inputPath, 'minecraft', 'Instances'), + ]; + + const looksLikeWindowsInstall = + /curseforge windows/i.test(inputPath) || + (fs.existsSync(path.join(inputPath, 'CurseForge.exe')) && fs.existsSync(path.join(inputPath, 'resources'))); + if (looksLikeWindowsInstall) { + candidates.push( + path.join(os.homedir(), 'curseforge', 'minecraft', 'Instances'), + path.join(os.homedir(), 'CurseForge', 'minecraft', 'Instances'), + ); + } + return candidates.find((candidate) => fs.existsSync(candidate)) || null; +} + +function normalizeLoaderVersion(loader, rawValue, mcVersion) { + let value = String(rawValue || '').trim(); + if (!value) return ''; + + if (loader === 'forge') { + value = value.replace(/^forge-/i, ''); + if (mcVersion && value.startsWith(mcVersion + '-')) value = value.slice(mcVersion.length + 1); + return value; + } + if (loader === 'neoforge') { + return value.replace(/^neoforge-/i, ''); + } + if (loader === 'fabric') { + value = value.replace(/^fabric(?:-loader)?-/i, ''); + if (mcVersion && value.endsWith('-' + mcVersion)) value = value.slice(0, -1 * (mcVersion.length + 1)); + return value; + } + if (loader === 'quilt') { + value = value.replace(/^quilt(?:-loader)?-/i, ''); + if (mcVersion && value.endsWith('-' + mcVersion)) value = value.slice(0, -1 * (mcVersion.length + 1)); + return value; + } + return value; +} + +function detectLoader(meta, mcVersion) { + const raw = meta && ( + meta.baseModLoader || meta.modLoader || meta.modloader || meta.loader || meta.modLoaderId + ); + const value = typeof raw === 'object' + ? (raw.name || raw.id || raw.value || raw.version || '') + : (raw || ''); + const lower = String(value).toLowerCase(); + const objectVersion = raw && typeof raw === 'object' + ? (raw.version || raw.name || raw.id || '') + : ''; + + if (!lower) return { loader: 'vanilla', loaderVersion: '' }; + if (lower.includes('neoforge')) return { loader: 'neoforge', loaderVersion: normalizeLoaderVersion('neoforge', objectVersion || value, mcVersion) }; + if (lower.includes('fabric')) return { loader: 'fabric', loaderVersion: normalizeLoaderVersion('fabric', objectVersion || value, mcVersion) }; + if (lower.includes('quilt')) return { loader: 'quilt', loaderVersion: normalizeLoaderVersion('quilt', objectVersion || value, mcVersion) }; + if (lower.includes('forge')) return { loader: 'forge', loaderVersion: normalizeLoaderVersion('forge', objectVersion || value, mcVersion) }; + return { loader: 'vanilla', loaderVersion: '' }; +} + +function detectVersion(meta, loaderVersion) { + return metaVersion(meta) || (loaderVersion && loaderVersion.includes('-') ? loaderVersion.split('-')[0] : ''); +} + +function findGameDir(dir) { + const nested = ['minecraft', '.minecraft'] + .map((name) => path.join(dir, name)) + .find((candidate) => fs.existsSync(candidate)); + if (nested) return nested; + + const markers = ['mods', 'config', 'resourcepacks', 'shaderpacks', 'saves', 'options.txt']; + return markers.some((name) => fs.existsSync(path.join(dir, name))) ? dir : null; +} + +function scan(inputPath) { + const instancesDir = resolveInstancesDir(inputPath); + if (!instancesDir) return { ok: false, reason: 'not-found' }; + + const list = []; + for (const entry of fs.readdirSync(instancesDir, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + const dir = path.join(instancesDir, entry.name); + const metaFile = existingMetaFile(dir); + const meta = metaFile ? readJson(metaFile, {}) : {}; + const gameDir = findGameDir(dir); + if (!metaFile && !gameDir) continue; + + const versionHint = metaVersion(meta); + const loaderInfo = detectLoader(meta, versionHint); + list.push({ + folder: entry.name, + dir, + name: meta.name || meta.displayName || entry.name, + group: '', + notes: meta.notes || meta.summary || '', + version: versionHint || detectVersion(meta, loaderInfo.loaderVersion), + loader: loaderInfo.loader, + loaderVersion: loaderInfo.loaderVersion, + javaPath: String(meta.javaPath || meta.javaExecutable || '').trim(), + minMemMb: Number(meta.minimumMemory || meta.minMemory || meta.minMemAlloc) || null, + maxMemMb: Number(meta.maximumMemory || meta.maxMemory || meta.maxMemAlloc || meta.allocatedMemory) || null, + gameDir: gameDir || dir, + hasGameData: !!gameDir, + }); + } + list.sort((a, b) => a.name.localeCompare(b.name)); + return { ok: true, instancesDir, count: list.length, instances: list }; +} + +function copyInto(sourceDir, targetDir) { + fs.mkdirSync(targetDir, { recursive: true }); + for (const name of fs.readdirSync(sourceDir)) { + if (META_FILES.includes(name)) continue; + fs.cpSync(path.join(sourceDir, name), path.join(targetDir, name), { recursive: true }); + } +} + +function importOne(store, entry, copyData = true) { + const created = store.createOrReplaceImportedInstance({ + name: entry.name, + group: entry.group, + notes: entry.notes, + minecraft: { + version: entry.version, + loader: entry.loader, + loaderVersion: entry.loaderVersion, + }, + }); + + const patch = {}; + if (entry.javaPath || entry.minMemMb || entry.maxMemMb) { + patch.java = { + path: entry.javaPath || '', + minMemMb: entry.minMemMb || null, + maxMemMb: entry.maxMemMb || null, + extraArgs: '', + }; + } + if (Object.keys(patch).length) store.updateInstance(created.id, patch); + + let copied = false; + if (copyData && entry.gameDir && fs.existsSync(entry.gameDir)) { + copyInto(entry.gameDir, store.gameDir(created.id)); + copied = true; + } + return { id: created.id, name: created.name, copied }; +} + module.exports = { scan, importOne, resolveInstancesDir }; \ No newline at end of file diff --git a/src/mcimport.js b/src/mcimport.js index fb8cde2..3cbadbe 100644 --- a/src/mcimport.js +++ b/src/mcimport.js @@ -1,217 +1,217 @@ -'use strict'; - -const fs = require('fs'); -const os = require('os'); -const path = require('path'); - -const PROFILE_FILES = [ - 'launcher_profiles.json', - 'launcher_profiles_microsoft_store.json', - 'launcher_profiles_microsoft_store_2.json', -]; - -const ROOT_COPY_DIRS = new Set([ - 'config', 'defaultconfigs', 'kubejs', 'mods', 'resourcepacks', 'screenshots', 'shaderpacks', 'saves', -]); - -const ROOT_COPY_FILE_PATTERNS = [ - /^options.*\.(txt|of)$/i, - /^servers\.dat(?:_old)?$/i, - /^usercache\.json$/i, - /^tl_skin_cape\.json$/i, - /^journeymap.*\.(json|txt|cfg)$/i, -]; - -function readJson(file, fallback) { - try { - return JSON.parse(fs.readFileSync(file, 'utf8').replace(/^\uFEFF/, '')); - } catch { - return fallback; - } -} - -function resolveLauncherRoot(inputPath) { - const appData = process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'); - const defaultRoot = path.join(appData, '.minecraft'); - const candidates = []; - - if (inputPath && fs.existsSync(inputPath)) candidates.push(inputPath); - candidates.push(defaultRoot); - - for (const candidate of candidates) { - for (const profileFile of PROFILE_FILES) { - if (fs.existsSync(path.join(candidate, profileFile))) return candidate; - } - } - return null; -} - -function resolveProfileFile(rootDir) { - return PROFILE_FILES.map((name) => path.join(rootDir, name)).find((file) => fs.existsSync(file)) || null; -} - -function normalizeLoaderVersion(loader, rawValue, mcVersion) { - let value = String(rawValue || '').trim(); - if (!value) return ''; - - if (loader === 'forge') { - value = value.replace(/^forge-/i, ''); - if (mcVersion && value.startsWith(mcVersion + '-')) value = value.slice(mcVersion.length + 1); - return value; - } - if (loader === 'neoforge') return value.replace(/^neoforge-/i, ''); - if (loader === 'fabric') { - value = value.replace(/^fabric(?:-loader)?-/i, ''); - if (mcVersion && value.endsWith('-' + mcVersion)) value = value.slice(0, -1 * (mcVersion.length + 1)); - return value; - } - if (loader === 'quilt') { - value = value.replace(/^quilt(?:-loader)?-/i, ''); - if (mcVersion && value.endsWith('-' + mcVersion)) value = value.slice(0, -1 * (mcVersion.length + 1)); - return value; - } - return value; -} - -function parseLastVersionId(lastVersionId) { - const raw = String(lastVersionId || '').trim(); - if (!raw) return { version: '', loader: 'vanilla', loaderVersion: '' }; - - let match = raw.match(/^fabric-loader-([^-]+(?:\.[^-]+)*)-(.+)$/i); - if (match) return { version: match[2], loader: 'fabric', loaderVersion: normalizeLoaderVersion('fabric', match[1], match[2]) }; - - match = raw.match(/^quilt-loader-([^-]+(?:\.[^-]+)*)-(.+)$/i); - if (match) return { version: match[2], loader: 'quilt', loaderVersion: normalizeLoaderVersion('quilt', match[1], match[2]) }; - - match = raw.match(/^(.+)-forge-([\w.-]+)$/i); - if (match) return { version: match[1], loader: 'forge', loaderVersion: normalizeLoaderVersion('forge', match[2], match[1]) }; - - match = raw.match(/^forge-(.+)-([\w.-]+)$/i); - if (match) return { version: match[1], loader: 'forge', loaderVersion: normalizeLoaderVersion('forge', match[2], match[1]) }; - - match = raw.match(/^(.+)-neoforge-([\w.-]+)$/i); - if (match) return { version: match[1], loader: 'neoforge', loaderVersion: normalizeLoaderVersion('neoforge', match[2], match[1]) }; - - if (/^\d+(?:\.\d+)+(?:-[\w.]+)?$/i.test(raw)) return { version: raw, loader: 'vanilla', loaderVersion: '' }; - return { version: raw, loader: 'vanilla', loaderVersion: '' }; -} - -function parseJavaSettings(javaDir, javaArgs) { - const args = String(javaArgs || '').trim(); - const minMatch = args.match(/(?:^|\s)-Xms(\d+)([mMgG])/); - const maxMatch = args.match(/(?:^|\s)-Xmx(\d+)([mMgG])/); - const toMb = (match) => { - if (!match) return null; - const num = Number(match[1]); - if (!Number.isFinite(num)) return null; - return match[2].toLowerCase() === 'g' ? num * 1024 : num; - }; - - return { - path: String(javaDir || '').trim(), - minMemMb: toMb(minMatch), - maxMemMb: toMb(maxMatch), - extraArgs: args - .replace(/(?:^|\s)-Xms\d+[mMgG]/g, ' ') - .replace(/(?:^|\s)-Xmx\d+[mMgG]/g, ' ') - .replace(/\s+/g, ' ') - .trim(), - }; -} - -function isImportableProfile(profile) { - const type = String((profile && profile.type) || '').trim().toLowerCase(); - if (type === 'latest-release' || type === 'latest-snapshot') return false; - const name = String((profile && profile.name) || '').trim(); - const lastVersionId = String((profile && profile.lastVersionId) || '').trim(); - return !!(name || lastVersionId); -} - -function shouldCopyRootEntry(name) { - if (ROOT_COPY_DIRS.has(name)) return true; - return ROOT_COPY_FILE_PATTERNS.some((pattern) => pattern.test(name)); -} - -function scan(inputPath) { - const rootDir = resolveLauncherRoot(inputPath); - if (!rootDir) return { ok: false, reason: 'not-found' }; - - const profileFile = resolveProfileFile(rootDir); - const json = readJson(profileFile, {}); - const profiles = json.profiles || {}; - const list = []; - const defaultRoot = path.normalize(rootDir).toLowerCase(); - - for (const [id, profile] of Object.entries(profiles)) { - if (!isImportableProfile(profile)) continue; - - const name = String(profile.name || '').trim() || String(profile.lastVersionId || '').trim() || id; - const parsed = parseLastVersionId(profile.lastVersionId); - const gameDir = path.normalize(String(profile.gameDir || rootDir)); - const java = parseJavaSettings(profile.javaDir, profile.javaArgs); - - list.push({ - id, - name, - group: '', - notes: 'Importiert aus dem Minecraft Launcher', - version: parsed.version, - loader: parsed.loader, - loaderVersion: parsed.loaderVersion, - javaPath: java.path, - minMemMb: java.minMemMb, - maxMemMb: java.maxMemMb, - extraJavaArgs: java.extraArgs, - gameDir, - hasGameData: fs.existsSync(gameDir), - usesDefaultGameDir: gameDir.toLowerCase() === defaultRoot, - rootDir, - }); - } - - list.sort((a, b) => a.name.localeCompare(b.name)); - return { ok: true, rootDir, count: list.length, instances: list }; -} - -function copyInto(sourceDir, targetDir, usesDefaultGameDir) { - fs.mkdirSync(targetDir, { recursive: true }); - for (const name of fs.readdirSync(sourceDir)) { - if (/^launcher_profiles.*\.json$/i.test(name)) continue; - if (name === 'versions' || name === 'libraries' || name === 'assets' || name === 'runtime' || name === 'webcache2') continue; - if (usesDefaultGameDir && !shouldCopyRootEntry(name)) continue; - fs.cpSync(path.join(sourceDir, name), path.join(targetDir, name), { recursive: true }); - } -} - -function importOne(store, entry, copyData = true) { - const created = store.createOrReplaceImportedInstance({ - name: entry.name, - group: entry.group, - notes: entry.notes, - minecraft: { - version: entry.version, - loader: entry.loader, - loaderVersion: entry.loaderVersion, - }, - }); - - const patch = {}; - if (entry.javaPath || entry.minMemMb || entry.maxMemMb || entry.extraJavaArgs) { - patch.java = { - path: entry.javaPath || '', - minMemMb: entry.minMemMb || null, - maxMemMb: entry.maxMemMb || null, - extraArgs: entry.extraJavaArgs || '', - }; - } - if (Object.keys(patch).length) store.updateInstance(created.id, patch); - - let copied = false; - if (copyData && entry.gameDir && fs.existsSync(entry.gameDir)) { - copyInto(entry.gameDir, store.gameDir(created.id), !!entry.usesDefaultGameDir); - copied = true; - } - return { id: created.id, name: created.name, copied }; -} - +'use strict'; + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const PROFILE_FILES = [ + 'launcher_profiles.json', + 'launcher_profiles_microsoft_store.json', + 'launcher_profiles_microsoft_store_2.json', +]; + +const ROOT_COPY_DIRS = new Set([ + 'config', 'defaultconfigs', 'kubejs', 'mods', 'resourcepacks', 'screenshots', 'shaderpacks', 'saves', +]); + +const ROOT_COPY_FILE_PATTERNS = [ + /^options.*\.(txt|of)$/i, + /^servers\.dat(?:_old)?$/i, + /^usercache\.json$/i, + /^tl_skin_cape\.json$/i, + /^journeymap.*\.(json|txt|cfg)$/i, +]; + +function readJson(file, fallback) { + try { + return JSON.parse(fs.readFileSync(file, 'utf8').replace(/^\uFEFF/, '')); + } catch { + return fallback; + } +} + +function resolveLauncherRoot(inputPath) { + const appData = process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'); + const defaultRoot = path.join(appData, '.minecraft'); + const candidates = []; + + if (inputPath && fs.existsSync(inputPath)) candidates.push(inputPath); + candidates.push(defaultRoot); + + for (const candidate of candidates) { + for (const profileFile of PROFILE_FILES) { + if (fs.existsSync(path.join(candidate, profileFile))) return candidate; + } + } + return null; +} + +function resolveProfileFile(rootDir) { + return PROFILE_FILES.map((name) => path.join(rootDir, name)).find((file) => fs.existsSync(file)) || null; +} + +function normalizeLoaderVersion(loader, rawValue, mcVersion) { + let value = String(rawValue || '').trim(); + if (!value) return ''; + + if (loader === 'forge') { + value = value.replace(/^forge-/i, ''); + if (mcVersion && value.startsWith(mcVersion + '-')) value = value.slice(mcVersion.length + 1); + return value; + } + if (loader === 'neoforge') return value.replace(/^neoforge-/i, ''); + if (loader === 'fabric') { + value = value.replace(/^fabric(?:-loader)?-/i, ''); + if (mcVersion && value.endsWith('-' + mcVersion)) value = value.slice(0, -1 * (mcVersion.length + 1)); + return value; + } + if (loader === 'quilt') { + value = value.replace(/^quilt(?:-loader)?-/i, ''); + if (mcVersion && value.endsWith('-' + mcVersion)) value = value.slice(0, -1 * (mcVersion.length + 1)); + return value; + } + return value; +} + +function parseLastVersionId(lastVersionId) { + const raw = String(lastVersionId || '').trim(); + if (!raw) return { version: '', loader: 'vanilla', loaderVersion: '' }; + + let match = raw.match(/^fabric-loader-([^-]+(?:\.[^-]+)*)-(.+)$/i); + if (match) return { version: match[2], loader: 'fabric', loaderVersion: normalizeLoaderVersion('fabric', match[1], match[2]) }; + + match = raw.match(/^quilt-loader-([^-]+(?:\.[^-]+)*)-(.+)$/i); + if (match) return { version: match[2], loader: 'quilt', loaderVersion: normalizeLoaderVersion('quilt', match[1], match[2]) }; + + match = raw.match(/^(.+)-forge-([\w.-]+)$/i); + if (match) return { version: match[1], loader: 'forge', loaderVersion: normalizeLoaderVersion('forge', match[2], match[1]) }; + + match = raw.match(/^forge-(.+)-([\w.-]+)$/i); + if (match) return { version: match[1], loader: 'forge', loaderVersion: normalizeLoaderVersion('forge', match[2], match[1]) }; + + match = raw.match(/^(.+)-neoforge-([\w.-]+)$/i); + if (match) return { version: match[1], loader: 'neoforge', loaderVersion: normalizeLoaderVersion('neoforge', match[2], match[1]) }; + + if (/^\d+(?:\.\d+)+(?:-[\w.]+)?$/i.test(raw)) return { version: raw, loader: 'vanilla', loaderVersion: '' }; + return { version: raw, loader: 'vanilla', loaderVersion: '' }; +} + +function parseJavaSettings(javaDir, javaArgs) { + const args = String(javaArgs || '').trim(); + const minMatch = args.match(/(?:^|\s)-Xms(\d+)([mMgG])/); + const maxMatch = args.match(/(?:^|\s)-Xmx(\d+)([mMgG])/); + const toMb = (match) => { + if (!match) return null; + const num = Number(match[1]); + if (!Number.isFinite(num)) return null; + return match[2].toLowerCase() === 'g' ? num * 1024 : num; + }; + + return { + path: String(javaDir || '').trim(), + minMemMb: toMb(minMatch), + maxMemMb: toMb(maxMatch), + extraArgs: args + .replace(/(?:^|\s)-Xms\d+[mMgG]/g, ' ') + .replace(/(?:^|\s)-Xmx\d+[mMgG]/g, ' ') + .replace(/\s+/g, ' ') + .trim(), + }; +} + +function isImportableProfile(profile) { + const type = String((profile && profile.type) || '').trim().toLowerCase(); + if (type === 'latest-release' || type === 'latest-snapshot') return false; + const name = String((profile && profile.name) || '').trim(); + const lastVersionId = String((profile && profile.lastVersionId) || '').trim(); + return !!(name || lastVersionId); +} + +function shouldCopyRootEntry(name) { + if (ROOT_COPY_DIRS.has(name)) return true; + return ROOT_COPY_FILE_PATTERNS.some((pattern) => pattern.test(name)); +} + +function scan(inputPath) { + const rootDir = resolveLauncherRoot(inputPath); + if (!rootDir) return { ok: false, reason: 'not-found' }; + + const profileFile = resolveProfileFile(rootDir); + const json = readJson(profileFile, {}); + const profiles = json.profiles || {}; + const list = []; + const defaultRoot = path.normalize(rootDir).toLowerCase(); + + for (const [id, profile] of Object.entries(profiles)) { + if (!isImportableProfile(profile)) continue; + + const name = String(profile.name || '').trim() || String(profile.lastVersionId || '').trim() || id; + const parsed = parseLastVersionId(profile.lastVersionId); + const gameDir = path.normalize(String(profile.gameDir || rootDir)); + const java = parseJavaSettings(profile.javaDir, profile.javaArgs); + + list.push({ + id, + name, + group: '', + notes: 'Importiert aus dem Minecraft Launcher', + version: parsed.version, + loader: parsed.loader, + loaderVersion: parsed.loaderVersion, + javaPath: java.path, + minMemMb: java.minMemMb, + maxMemMb: java.maxMemMb, + extraJavaArgs: java.extraArgs, + gameDir, + hasGameData: fs.existsSync(gameDir), + usesDefaultGameDir: gameDir.toLowerCase() === defaultRoot, + rootDir, + }); + } + + list.sort((a, b) => a.name.localeCompare(b.name)); + return { ok: true, rootDir, count: list.length, instances: list }; +} + +function copyInto(sourceDir, targetDir, usesDefaultGameDir) { + fs.mkdirSync(targetDir, { recursive: true }); + for (const name of fs.readdirSync(sourceDir)) { + if (/^launcher_profiles.*\.json$/i.test(name)) continue; + if (name === 'versions' || name === 'libraries' || name === 'assets' || name === 'runtime' || name === 'webcache2') continue; + if (usesDefaultGameDir && !shouldCopyRootEntry(name)) continue; + fs.cpSync(path.join(sourceDir, name), path.join(targetDir, name), { recursive: true }); + } +} + +function importOne(store, entry, copyData = true) { + const created = store.createOrReplaceImportedInstance({ + name: entry.name, + group: entry.group, + notes: entry.notes, + minecraft: { + version: entry.version, + loader: entry.loader, + loaderVersion: entry.loaderVersion, + }, + }); + + const patch = {}; + if (entry.javaPath || entry.minMemMb || entry.maxMemMb || entry.extraJavaArgs) { + patch.java = { + path: entry.javaPath || '', + minMemMb: entry.minMemMb || null, + maxMemMb: entry.maxMemMb || null, + extraArgs: entry.extraJavaArgs || '', + }; + } + if (Object.keys(patch).length) store.updateInstance(created.id, patch); + + let copied = false; + if (copyData && entry.gameDir && fs.existsSync(entry.gameDir)) { + copyInto(entry.gameDir, store.gameDir(created.id), !!entry.usesDefaultGameDir); + copied = true; + } + return { id: created.id, name: created.name, copied }; +} + module.exports = { scan, importOne, parseLastVersionId, resolveLauncherRoot }; \ No newline at end of file diff --git a/src/processwatchdog.js b/src/processwatchdog.js index bf65b25..8488b27 100644 --- a/src/processwatchdog.js +++ b/src/processwatchdog.js @@ -1,101 +1,101 @@ -'use strict'; - -const DEFAULT_GRACE_MS = 15000; - -const CLEAN_SHUTDOWN = /\[(?:Render thread|Client thread)\/INFO\](?:\s+\([^)]*\))?:\s*Stopping!\s*$/; -const FATAL_OUTPUT = [ - /---- Minecraft Crash Report ----/i, - /\b(?:Encountered an unexpected exception|Unreported exception thrown|Reported exception thrown)\b/i, - /\b(?:This crash report has been saved to|Saving crash report to)\b/i, - /\bA fatal error has been detected by the Java Runtime Environment\b/i, - /Exception in thread "(?:Render thread|Client thread|main)"/i, - /\[(?:Render thread|Client thread|main)\/FATAL\]/i, -]; - -function createProcessWatchdog(options) { - const graceMs = options.graceMs || DEFAULT_GRACE_MS; - const schedule = options.setTimer || setTimeout; - const cancel = options.clearTimer || clearTimeout; - const buffers = { stdout: '', stderr: '' }; - let timer = null; - let armed = false; - let crashed = false; - let ended = false; - let forced = false; - // bleibt true, sobald einmal ein regulärer Shutdown ("Stopping!") gesehen - // wurde – im Gegensatz zu `armed` wird das NICHT durch markCrash() - // zurückgesetzt, damit ein später vom internen Watchdog erzwungener Halt - // weiterhin als "war ein regulärer Shutdown" erkennbar bleibt - let sawCleanShutdown = false; - - function clear() { - if (timer !== null) cancel(timer); - timer = null; - armed = false; - } - - function markCrash() { - crashed = true; - clear(); - } - - function scheduleKill() { - if (timer !== null) cancel(timer); - timer = schedule(() => { - timer = null; - if (ended || crashed || !armed || !options.isRunning()) return; - try { - forced = options.kill() === true; - } catch { - forced = false; - } - if (forced && options.onForce) options.onForce(graceMs); - else if (!forced && options.isRunning() && options.onError) options.onError(); - }, graceMs); - if (timer && typeof timer.unref === 'function') timer.unref(); - } - - function inspectLine(line) { - const isCleanShutdown = CLEAN_SHUTDOWN.test(line); - if (isCleanShutdown) sawCleanShutdown = true; - if (FATAL_OUTPUT.some((pattern) => pattern.test(line))) { - markCrash(); - return; - } - if (ended || crashed || armed || !isCleanShutdown) return; - - armed = true; - scheduleKill(); - } - - function feed(chunk, stream) { - if (ended) return; - const key = stream === 'stderr' ? 'stderr' : 'stdout'; - const text = buffers[key] + String(chunk || ''); - const lines = text.split(/\r?\n/); - buffers[key] = lines.pop() || ''; - for (const line of lines) inspectLine(line); - if (armed && !crashed && !ended && lines.length) scheduleKill(); - } - - function end() { - ended = true; - clear(); - } - - return { - feed, - end, - get armed() { return armed; }, - get crashed() { return crashed; }, - get forced() { return forced; }, - get sawCleanShutdown() { return sawCleanShutdown; }, - }; -} - -module.exports = { - DEFAULT_GRACE_MS, - CLEAN_SHUTDOWN, - FATAL_OUTPUT, - createProcessWatchdog, -}; +'use strict'; + +const DEFAULT_GRACE_MS = 15000; + +const CLEAN_SHUTDOWN = /\[(?:Render thread|Client thread)\/INFO\](?:\s+\([^)]*\))?:\s*Stopping!\s*$/; +const FATAL_OUTPUT = [ + /---- Minecraft Crash Report ----/i, + /\b(?:Encountered an unexpected exception|Unreported exception thrown|Reported exception thrown)\b/i, + /\b(?:This crash report has been saved to|Saving crash report to)\b/i, + /\bA fatal error has been detected by the Java Runtime Environment\b/i, + /Exception in thread "(?:Render thread|Client thread|main)"/i, + /\[(?:Render thread|Client thread|main)\/FATAL\]/i, +]; + +function createProcessWatchdog(options) { + const graceMs = options.graceMs || DEFAULT_GRACE_MS; + const schedule = options.setTimer || setTimeout; + const cancel = options.clearTimer || clearTimeout; + const buffers = { stdout: '', stderr: '' }; + let timer = null; + let armed = false; + let crashed = false; + let ended = false; + let forced = false; + // bleibt true, sobald einmal ein regulärer Shutdown ("Stopping!") gesehen + // wurde – im Gegensatz zu `armed` wird das NICHT durch markCrash() + // zurückgesetzt, damit ein später vom internen Watchdog erzwungener Halt + // weiterhin als "war ein regulärer Shutdown" erkennbar bleibt + let sawCleanShutdown = false; + + function clear() { + if (timer !== null) cancel(timer); + timer = null; + armed = false; + } + + function markCrash() { + crashed = true; + clear(); + } + + function scheduleKill() { + if (timer !== null) cancel(timer); + timer = schedule(() => { + timer = null; + if (ended || crashed || !armed || !options.isRunning()) return; + try { + forced = options.kill() === true; + } catch { + forced = false; + } + if (forced && options.onForce) options.onForce(graceMs); + else if (!forced && options.isRunning() && options.onError) options.onError(); + }, graceMs); + if (timer && typeof timer.unref === 'function') timer.unref(); + } + + function inspectLine(line) { + const isCleanShutdown = CLEAN_SHUTDOWN.test(line); + if (isCleanShutdown) sawCleanShutdown = true; + if (FATAL_OUTPUT.some((pattern) => pattern.test(line))) { + markCrash(); + return; + } + if (ended || crashed || armed || !isCleanShutdown) return; + + armed = true; + scheduleKill(); + } + + function feed(chunk, stream) { + if (ended) return; + const key = stream === 'stderr' ? 'stderr' : 'stdout'; + const text = buffers[key] + String(chunk || ''); + const lines = text.split(/\r?\n/); + buffers[key] = lines.pop() || ''; + for (const line of lines) inspectLine(line); + if (armed && !crashed && !ended && lines.length) scheduleKill(); + } + + function end() { + ended = true; + clear(); + } + + return { + feed, + end, + get armed() { return armed; }, + get crashed() { return crashed; }, + get forced() { return forced; }, + get sawCleanShutdown() { return sawCleanShutdown; }, + }; +} + +module.exports = { + DEFAULT_GRACE_MS, + CLEAN_SHUTDOWN, + FATAL_OUTPUT, + createProcessWatchdog, +};