Upload via GUI (25 Dateien)

This commit is contained in:
Git Manager GUI
2026-08-02 10:14:02 +02:00
parent dc55e64ca3
commit a803f297ea
15 changed files with 2587 additions and 324 deletions

View File

@@ -26,6 +26,7 @@ Der Launcher konzentriert sich auf den Alltag: Instanz anlegen, Konto auswählen
- Reimport überschreibt vorhandene Instanzen mit gleichem Namen statt Dubletten anzulegen - Reimport überschreibt vorhandene Instanzen mit gleichem Namen statt Dubletten anzulegen
- Export, Kopieren, Gruppieren und Verwalten von Instanzen - Export, Kopieren, Gruppieren und Verwalten von Instanzen
- Mehrfachauswahl mit Strg-Klick für Sammellöschen mehrerer Instanzen - Mehrfachauswahl mit Strg-Klick für Sammellöschen mehrerer Instanzen
- Globales Backup und Wiederherstellung für alle Instanzen direkt im Einstellungsbereich
- Verwaltung von Welten, Screenshots und Spielordnern direkt aus dem Launcher - Verwaltung von Welten, Screenshots und Spielordnern direkt aus dem Launcher
- Bedrock-Bereich für Start und Verwaltung vorhandener Bedrock-Daten unter Windows - Bedrock-Bereich für Start und Verwaltung vorhandener Bedrock-Daten unter Windows
- Update-Funktion für neue Launcher-Versionen - Update-Funktion für neue Launcher-Versionen
@@ -41,6 +42,8 @@ Für die meisten Nutzer ist die Setup-Version die bessere Wahl.
Die portable Datei ist praktisch zum Mitnehmen, braucht beim Öffnen aber in der Regel etwas länger. Die portable Datei ist praktisch zum Mitnehmen, braucht beim Öffnen aber in der Regel etwas länger.
Automatische Updates bleiben dabei sauber getrennt: Die Setup-Version erhält nur Setup-Updates, die portable Variante nur Portable-Updates. Ein Wechsel zwischen beiden Varianten passiert nicht automatisch.
## Voraussetzungen ## Voraussetzungen
- Windows x64 - Windows x64
@@ -71,6 +74,17 @@ Wenn bereits eine Instanz mit demselben Namen existiert, wird sie beim Import er
Beim Import aus dem offiziellen Minecraft Launcher werden nur echte benutzerdefinierte Profile angezeigt. Standardprofile wie Latest Release oder Latest Snapshot werden ausgeblendet. Nutzt ein Profil den gemeinsamen Standard-Spielordner, übernimmt AeroMC daraus nur spielrelevante Daten wie Mods, Welten und Konfiguration statt den kompletten Launcher-Cache zu kopieren. Beim Import aus dem offiziellen Minecraft Launcher werden nur echte benutzerdefinierte Profile angezeigt. Standardprofile wie Latest Release oder Latest Snapshot werden ausgeblendet. Nutzt ein Profil den gemeinsamen Standard-Spielordner, übernimmt AeroMC daraus nur spielrelevante Daten wie Mods, Welten und Konfiguration statt den kompletten Launcher-Cache zu kopieren.
## Backup und Wiederherstellung
Im Bereich Einstellungen > Backup können komplette Sicherungen aller Instanzen erstellt und wieder eingespielt werden.
- Backups werden automatisch unter Dokumente\AeroMC Launcher\Backups gespeichert
- Gesichert werden alle Instanzen sowie die Launcher-Einstellungen
- Microsoft-Konten und Tokens werden bewusst nicht mitgesichert
- Vorhandene Backups werden im Wiederherstellen-Bereich direkt in einem Dropdown aufgelistet
Wichtig: Beim Wiederherstellen werden alle aktuell vorhandenen Instanzen mit dem Inhalt des gewählten Backups überschrieben. Die Launcher-Einstellungen werden ebenfalls aus dem Backup übernommen.
## Was AeroMC verwaltet ## Was AeroMC verwaltet
- Minecraft-Versionen und gemeinsame Spieldateien - Minecraft-Versionen und gemeinsame Spieldateien

817
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
{ {
"name": "aeromc", "name": "aeromc",
"productName": "AeroMC", "productName": "AeroMC",
"version": "1.0.0-beta", "version": "1.0.1-beta",
"description": "AeroMC Instanz-basierter Minecraft-Launcher (à la MultiMC/Prism)", "description": "AeroMC Instanz-basierter Minecraft-Launcher (à la MultiMC/Prism)",
"homepage": "https://M_Viper.de", "homepage": "https://M_Viper.de",
"main": "src/main.js", "main": "src/main.js",
@@ -39,7 +39,9 @@
"electron-builder": "^24.0.0" "electron-builder": "^24.0.0"
}, },
"dependencies": { "dependencies": {
"archiver": "^7.0.1",
"skinview3d": "^3.4.2", "skinview3d": "^3.4.2",
"undici": "^6.28.0" "undici": "^6.28.0",
"unzipper": "^0.12.5"
} }
} }

440
src/backup.js Normal file
View File

@@ -0,0 +1,440 @@
'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;
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',
});
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) {
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,
};

196
src/cfimport.js Normal file
View File

@@ -0,0 +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 };
}
module.exports = { scan, importOne, resolveInstancesDir };

View File

@@ -11,8 +11,7 @@
<!-- Obere Werkzeugleiste (MultiMC-Stil: Icon + Text) --> <!-- Obere Werkzeugleiste (MultiMC-Stil: Icon + Text) -->
<header class="toolbar"> <header class="toolbar">
<button id="btn-new" class="tbtn" data-ic="plus">Instanz hinzufügen</button> <button id="btn-new" class="tbtn" data-ic="plus">Instanz hinzufügen</button>
<button id="btn-import" class="tbtn" data-ic="import" title="Instanz aus .zip importieren">Importieren</button> <button id="btn-import" class="tbtn" data-ic="import" title="Instanzen importieren">Importieren</button>
<button id="btn-modpack" class="tbtn" data-ic="package" title="Modrinth-Modpack (.mrpack)">Modpack</button>
<button id="btn-open-dir" class="tbtn" data-ic="folder" title="Ordner öffnen">Ordner</button> <button id="btn-open-dir" class="tbtn" data-ic="folder" title="Ordner öffnen">Ordner</button>
<span class="tbsep"></span> <span class="tbsep"></span>
<button id="btn-bedrock" class="tbtn" data-ic="blocks" title="Minecraft Bedrock (Windows-Ausgabe)">Bedrock</button> <button id="btn-bedrock" class="tbtn" data-ic="blocks" title="Minecraft Bedrock (Windows-Ausgabe)">Bedrock</button>
@@ -192,6 +191,7 @@
<button class="snav" data-pane="pane-start">Start &amp; Java</button> <button class="snav" data-pane="pane-start">Start &amp; Java</button>
<button class="snav" data-pane="pane-network">Netzwerk</button> <button class="snav" data-pane="pane-network">Netzwerk</button>
<button class="snav" data-pane="pane-accounts">Konten</button> <button class="snav" data-pane="pane-accounts">Konten</button>
<button class="snav" data-pane="pane-backup">Backup</button>
<button class="snav" data-pane="pane-info">Info</button> <button class="snav" data-pane="pane-info">Info</button>
</nav> </nav>
<div class="settings-content"> <div class="settings-content">
@@ -229,12 +229,6 @@
</div> </div>
<small class="hint">Hier werden alle Instanzen als Unterordner mit ihrem Namen gespeichert.</small> <small class="hint">Hier werden alle Instanzen als Unterordner mit ihrem Namen gespeichert.</small>
</label> </label>
<label class="field">
<span>Instanzen übernehmen</span>
<button id="btn-mmc" class="btn">Aus MultiMC importieren…</button>
<small class="hint">Übernimmt Instanzen samt Version, Loader, Gruppe und Spieldaten aus einem MultiMC-Ordner.</small>
</label>
</section> </section>
<section class="settings-card"> <section class="settings-card">
@@ -426,6 +420,44 @@
</div> </div>
</div> </div>
<div id="pane-backup" class="spane hidden">
<div class="settings-hero">
<h4>Backup</h4>
<p>Alle Instanzen gesammelt als ZIP sichern, bevor größere Änderungen anstehen.</p>
</div>
<div class="settings-grid">
<section class="settings-card settings-card-wide">
<h5>Globales Backup</h5>
<small class="hint">Backups werden automatisch unter Dokumente/AeroMC Launcher/Backups gespeichert. Instanzen und Launcher-Einstellungen werden gesichert. Konten und Tokens werden nicht mitgesichert.</small>
<div class="path-row" style="margin-top:14px">
<button id="s-backup-global" class="btn btn-primary">Backup erstellen</button>
<span id="s-backup-status" class="hint">Noch kein Backup erstellt.</span>
</div>
<div id="s-backup-progress" class="backup-progress hidden" aria-hidden="true">
<div class="backup-progress-bar"><div id="s-backup-progress-fill" class="backup-progress-fill"></div></div>
<div id="s-backup-progress-text" class="backup-progress-text hint">0 %</div>
</div>
</section>
<section class="settings-card settings-card-wide">
<h5>Wiederherstellen</h5>
<small class="hint">Wähle ein Backup aus Dokumente/AeroMC Launcher/Backups aus. Achtung: Alle vorhandenen Instanzen werden beim Wiederherstellen mit dem Inhalt des Backups überschrieben. Launcher-Einstellungen werden ebenfalls wiederhergestellt. Konten und Tokens bleiben unverändert.</small>
<label class="field backup-select-field">
<span>Verfügbares Backup</span>
<select id="s-restore-backup"></select>
</label>
<div class="path-row" style="margin-top:14px">
<button id="s-restore-global" class="btn">Backup wiederherstellen</button>
<span id="s-restore-status" class="hint">Noch kein Backup wiederhergestellt.</span>
</div>
<div id="s-restore-progress" class="backup-progress hidden" aria-hidden="true">
<div class="backup-progress-bar"><div id="s-restore-progress-fill" class="backup-progress-fill"></div></div>
<div id="s-restore-progress-text" class="backup-progress-text hint">0 %</div>
</div>
</section>
</div>
</div>
<div id="pane-info" class="spane hidden"> <div id="pane-info" class="spane hidden">
<div class="settings-hero"> <div class="settings-hero">
<h4>Info</h4> <h4>Info</h4>
@@ -643,6 +675,7 @@
<span class="upd-arrow"></span> <span class="upd-arrow"></span>
<span id="upd-new" class="upd-newv"></span> <span id="upd-new" class="upd-newv"></span>
</p> </p>
<p id="upd-channel" class="upd-channel hint">Update-Kanal: </p>
<div id="upd-notes" class="upd-notes"></div> <div id="upd-notes" class="upd-notes"></div>
<div id="upd-progress" class="upd-progress hidden"> <div id="upd-progress" class="upd-progress hidden">
<div class="upd-bar"><div id="upd-bar-fill" class="upd-bar-fill"></div></div> <div class="upd-bar"><div id="upd-bar-fill" class="upd-bar-fill"></div></div>
@@ -657,41 +690,115 @@
</div> </div>
</div> </div>
<!-- ===================== Dialog: MultiMC-Import ===================== --> <!-- ===================== Dialog: Import ===================== -->
<div id="modal-mmc" class="modal hidden"> <div id="modal-import" class="modal hidden">
<div class="modal-card modal-lg"> <div class="modal-card modal-lg">
<div class="modal-head"> <div class="modal-head">
<h3>Instanzen aus MultiMC übernehmen</h3> <h3>Importieren</h3>
<button class="modal-close" data-close></button> <button class="modal-close" data-close></button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<label class="field"> <div class="mods-tabs import-tabs">
<span>MultiMC-Ordner</span> <button id="import-tab-zip" class="mtab on" data-kind="zip">ZIP-Archiv</button>
<div class="path-row"> <button id="import-tab-modpack" class="mtab" data-kind="modpack">Modpack</button>
<input id="mmc-path" type="text" placeholder="z. B. E:\Minecraft\MultiMC" /> <button id="import-tab-mc" class="mtab" data-kind="mc">Minecraft Launcher</button>
<button id="mmc-pick" class="btn">Wählen…</button> <button id="import-tab-cf" class="mtab" data-kind="cf">CurseForge</button>
<button id="mmc-scan" class="btn btn-primary">Suchen</button> <button id="import-tab-mmc" class="mtab" data-kind="mmc">MultiMC / Prism</button>
</div> </div>
<small id="mmc-status" class="hint">Gib den Ordner an, in dem MultiMC.exe liegt.</small>
</label>
<div id="mmc-result" class="hidden"> <div id="import-pane-zip" class="import-pane">
<div class="mmc-bar"> <p class="import-copy">Importiert eine exportierte AeroMC-Instanz aus einem ZIP-Archiv.</p>
<label class="checkbox-field" style="margin:0"> <p class="hint">Wähle ein Archiv mit einer gültigen instance.json. Der Launcher legt daraus automatisch eine neue Instanz an.</p>
<input id="mmc-all" type="checkbox" checked /> <span>Alle auswählen</span> </div>
</label>
<label class="checkbox-field" style="margin:0"> <div id="import-pane-modpack" class="import-pane hidden">
<input id="mmc-copy" type="checkbox" checked /> <span>Spieldaten mitkopieren (Welten, Mods, Konfiguration)</span> <p class="import-copy">Importiert ein Modrinth-Modpack als neue Instanz.</p>
</label> <p class="hint">Unterstützt werden .mrpack und kompatible ZIP-Dateien mit modrinth.index.json.</p>
</div>
<div id="import-pane-mc" class="import-pane hidden">
<label class="field">
<span>Minecraft-Launcher-Ordner</span>
<div class="path-row">
<input id="mc-path" type="text" placeholder="z. B. C:\Users\DeinName\AppData\Roaming\.minecraft" />
<button id="mc-pick" class="btn">Wählen…</button>
<button id="mc-scan" class="btn btn-primary">Suchen</button>
</div>
<small id="mc-status" class="hint">Leer lassen nutzt automatisch den Standardpfad unter Windows.</small>
</label>
<div id="mc-result" class="hidden">
<div class="mmc-bar">
<label class="checkbox-field" style="margin:0">
<input id="mc-all" type="checkbox" checked /> <span>Alle auswählen</span>
</label>
<label class="checkbox-field" style="margin:0">
<input id="mc-copy" type="checkbox" checked /> <span>Spieldaten mitkopieren (Mods, Welten, Konfiguration)</span>
</label>
</div>
<div id="mc-list" class="mods-list"></div>
</div>
</div>
<div id="import-pane-cf" class="import-pane hidden">
<label class="field">
<span>CurseForge-Instanzen</span>
<div class="path-row">
<input id="cf-path" type="text" placeholder="z. B. C:\Users\DeinName\curseforge\minecraft\Instances" />
<button id="cf-pick" class="btn">Wählen…</button>
<button id="cf-scan" class="btn btn-primary">Suchen</button>
</div>
<small id="cf-status" class="hint">Standardpfad unter Windows: C:\Users\DeinName\curseforge\minecraft\Instances</small>
</label>
<div id="cf-result" class="hidden">
<div class="mmc-bar">
<label class="checkbox-field" style="margin:0">
<input id="cf-all" type="checkbox" checked /> <span>Alle auswählen</span>
</label>
<label class="checkbox-field" style="margin:0">
<input id="cf-copy" type="checkbox" checked /> <span>Spieldaten mitkopieren (Welten, Mods, Konfiguration)</span>
</label>
</div>
<div id="cf-list" class="mods-list"></div>
</div>
</div>
<div id="import-pane-mmc" class="import-pane hidden">
<label class="field">
<span>MultiMC- oder Prism-Ordner</span>
<div class="path-row">
<input id="mmc-path" type="text" placeholder="z. B. E:\Minecraft\MultiMC" />
<button id="mmc-pick" class="btn">Wählen…</button>
<button id="mmc-scan" class="btn btn-primary">Suchen</button>
</div>
<small id="mmc-status" class="hint">Gib den Ordner an, in dem MultiMC.exe oder PrismLauncher.exe liegt.</small>
</label>
<div id="mmc-result" class="hidden">
<div class="mmc-bar">
<label class="checkbox-field" style="margin:0">
<input id="mmc-all" type="checkbox" checked /> <span>Alle auswählen</span>
</label>
<label class="checkbox-field" style="margin:0">
<input id="mmc-copy" type="checkbox" checked /> <span>Spieldaten mitkopieren (Welten, Mods, Konfiguration)</span>
</label>
</div>
<div id="mmc-list" class="mods-list"></div>
</div> </div>
<div id="mmc-list" class="mods-list"></div>
</div> </div>
</div> </div>
<div class="modal-foot"> <div class="modal-foot">
<div id="mmc-progress" class="hint"></div> <div id="mc-progress" class="hint hidden"></div>
<div id="mmc-progress" class="hint hidden"></div>
<div id="cf-progress" class="hint hidden"></div>
<div class="spacer"></div> <div class="spacer"></div>
<button class="btn" data-close>Abbrechen</button> <button class="btn" data-close>Abbrechen</button>
<button id="mmc-do" class="btn btn-primary" disabled>Übernehmen</button> <button id="import-zip-do" class="btn btn-primary">ZIP wählen</button>
<button id="import-modpack-do" class="btn btn-primary hidden">Modpack wählen…</button>
<button id="mc-do" class="btn btn-primary hidden" disabled>Übernehmen</button>
<button id="cf-do" class="btn btn-primary hidden" disabled>Übernehmen</button>
<button id="mmc-do" class="btn btn-primary hidden" disabled>Übernehmen</button>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -150,6 +150,36 @@ async function prepareLibraries(vj, sharedDir, nativesDir, onProgress) {
return classpath; return classpath;
} }
function classpathArtifactKey(filePath, sharedDir) {
const libDir = path.join(sharedDir, 'libraries');
const rel = path.relative(libDir, filePath);
if (rel.startsWith('..') || path.isAbsolute(rel)) return path.normalize(filePath).toLowerCase();
const parts = rel.split(path.sep);
if (parts.length < 4) return rel.replace(/\\/g, '/').toLowerCase();
const fileName = parts[parts.length - 1];
const version = parts[parts.length - 2];
const artifact = parts[parts.length - 3];
const group = parts.slice(0, -3).join('/').toLowerCase();
const base = fileName.replace(/\.[^.]+$/, '');
const prefix = `${artifact}-${version}`;
const classifier = base.startsWith(prefix + '-') ? base.slice(prefix.length + 1).toLowerCase() : '';
return `${group}:${artifact.toLowerCase()}:${classifier}`;
}
function dedupeClasspath(classpath, sharedDir) {
const seen = new Set();
const out = [];
for (const filePath of classpath) {
const key = classpathArtifactKey(filePath, sharedDir);
if (seen.has(key)) continue;
seen.add(key);
out.push(filePath);
}
return out;
}
// entpackt eine Natives-JAR (ZIP) nach nativesDir (ohne externe Abhängigkeit) // entpackt eine Natives-JAR (ZIP) nach nativesDir (ohne externe Abhängigkeit)
async function extractNatives(jarPath, nativesDir, extractRule) { async function extractNatives(jarPath, nativesDir, extractRule) {
ensureDir(nativesDir); ensureDir(nativesDir);
@@ -407,6 +437,8 @@ async function prepareAndLaunch(opts, onProgress) {
effectiveVj = mergeLoader(vj, fvj); effectiveVj = mergeLoader(vj, fvj);
} }
const finalClasspath = dedupeClasspath(classpath, opts.sharedDir);
// Ohne Login: alles vorbereitet, aber kein Start (kein Crack-Bypass) // Ohne Login: alles vorbereitet, aber kein Start (kein Crack-Bypass)
if (!opts.auth) { if (!opts.auth) {
return { return {
@@ -425,7 +457,7 @@ async function prepareAndLaunch(opts, onProgress) {
gameDir: opts.gameDir, gameDir: opts.gameDir,
sharedDir: opts.sharedDir, sharedDir: opts.sharedDir,
nativesDir, nativesDir,
classpath, classpath: finalClasspath,
minMemMb: opts.minMemMb || 2048, minMemMb: opts.minMemMb || 2048,
maxMemMb: opts.maxMemMb || 4096, maxMemMb: opts.maxMemMb || 4096,
extraArgs: opts.extraArgs || '', extraArgs: opts.extraArgs || '',
@@ -475,5 +507,5 @@ async function prepareAndLaunch(opts, onProgress) {
module.exports = { module.exports = {
prepareAndLaunch, getVersionJson, buildArgs, pickJava, rulesAllow, prepareLibraries, prepareAssets, prepareAndLaunch, getVersionJson, buildArgs, pickJava, rulesAllow, prepareLibraries, prepareAssets,
downloadIfNeeded, extractNatives, downloadIfNeeded, extractNatives, mergeLoader, dedupeClasspath, classpathArtifactKey,
}; };

View File

@@ -20,7 +20,10 @@ const auth = require('./auth');
const updater = require('./updater'); const updater = require('./updater');
const icons = require('./icons'); const icons = require('./icons');
const bedrock = require('./bedrock'); const bedrock = require('./bedrock');
const backup = require('./backup');
const mmcimport = require('./mmcimport'); const mmcimport = require('./mmcimport');
const cfimport = require('./cfimport');
const mcimport = require('./mcimport');
// undici als globales fetch verwenden -> ermöglicht Proxy via setGlobalDispatcher // undici als globales fetch verwenden -> ermöglicht Proxy via setGlobalDispatcher
const undici = require('undici'); const undici = require('undici');
@@ -412,7 +415,7 @@ async function importMrpack(buf, zip) {
else if (deps.forge) { loader = 'forge'; loaderVersion = deps.forge; } else if (deps.forge) { loader = 'forge'; loaderVersion = deps.forge; }
else if (deps.neoforge) { loader = 'neoforge'; loaderVersion = deps.neoforge; } else if (deps.neoforge) { loader = 'neoforge'; loaderVersion = deps.neoforge; }
const created = store.createInstance({ const created = store.createOrReplaceImportedInstance({
name: index.name || 'Modpack', name: index.name || 'Modpack',
minecraft: { version: deps.minecraft || '', loader, loaderVersion }, minecraft: { version: deps.minecraft || '', loader, loaderVersion },
}); });
@@ -495,8 +498,8 @@ ipcMain.handle('instances:import', async () => {
throw new Error('Keine gültige Instanz im Archiv (instance.json fehlt).'); throw new Error('Keine gültige Instanz im Archiv (instance.json fehlt).');
} }
const imported = JSON.parse(fs.readFileSync(path.join(base, 'instance.json'), 'utf8')); const imported = JSON.parse(fs.readFileSync(path.join(base, 'instance.json'), 'utf8'));
const created = store.createInstance({ const created = store.createOrReplaceImportedInstance({
name: (imported.name || 'Import') + ' (Import)', name: imported.name || 'Import',
icon: imported.icon || 'grass', icon: imported.icon || 'grass',
notes: imported.notes || '', notes: imported.notes || '',
minecraft: imported.minecraft || {}, minecraft: imported.minecraft || {},
@@ -519,6 +522,54 @@ ipcMain.handle('instances:import', async () => {
} }
}); });
ipcMain.handle('backup:global', async () => {
const destinationPath = backup.resolveAutoBackupPath(app.getPath('documents'));
try {
const result = await backup.createGlobalBackup({
instancesDir: store.getInstancesDir(),
userDataDir: app.getPath('userData'),
includeSettings: true,
destinationPath,
onProgress: (progress) => {
if (mainWindow && !mainWindow.isDestroyed()) mainWindow.webContents.send('backup:progress', progress);
},
});
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('backup:progress', { mode: 'create', percent: 100, detail: 'Backup abgeschlossen' });
}
return result;
} catch (err) {
return { ok: false, message: 'Backup fehlgeschlagen: ' + err.message };
}
});
ipcMain.handle('backup:list', async () => backup.listGlobalBackups(app.getPath('documents')));
ipcMain.handle('backup:restore', async (_e, sourcePath) => {
if (!sourcePath || !String(sourcePath).trim()) {
return { ok: false, message: 'Kein Backup ausgewählt.' };
}
try {
const currentSettings = store.getSettings();
const result = await backup.restoreGlobalBackup({
sourcePath,
instancesDir: store.getInstancesDir(),
userDataDir: app.getPath('userData'),
includeSettings: true,
instancesDirSetting: currentSettings.instancesDir || null,
onProgress: (progress) => {
if (mainWindow && !mainWindow.isDestroyed()) mainWindow.webContents.send('backup:progress', progress);
},
});
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('backup:progress', { mode: 'restore', percent: 100, detail: 'Wiederherstellen abgeschlossen' });
}
return result;
} catch (err) {
return { ok: false, message: 'Wiederherstellen fehlgeschlagen: ' + err.message };
}
});
// Phase 2: echter Vanilla-Download & -Start (Start selbst ist an Login gebunden). // Phase 2: echter Vanilla-Download & -Start (Start selbst ist an Login gebunden).
ipcMain.handle('instances:launch', async (_e, id, accountId) => { ipcMain.handle('instances:launch', async (_e, id, accountId) => {
const inst = store.getInstance(id); const inst = store.getInstance(id);
@@ -666,6 +717,58 @@ ipcMain.handle('mmc:import', (_e, entries, copyData) => {
return { ok: true, imported: done, failed }; return { ok: true, imported: done, failed };
}); });
ipcMain.handle('cf:pickFolder', async () => {
const res = await dialog.showOpenDialog(mainWindow, {
title: 'CurseForge-Ordner wählen (oder direkt den Instances-Ordner)',
properties: ['openDirectory'],
});
return res.canceled ? null : res.filePaths[0];
});
ipcMain.handle('cf:scan', (_e, folder) => {
try { return cfimport.scan(folder); }
catch (err) { return { ok: false, reason: err.message }; }
});
ipcMain.handle('cf:import', (_e, entries, copyData) => {
const done = [];
const failed = [];
for (const entry of entries || []) {
try { done.push(cfimport.importOne(store, entry, copyData !== false)); }
catch (err) { failed.push({ name: entry.name, message: err.message }); }
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('cf:progress', { current: done.length + failed.length, total: entries.length });
}
}
return { ok: true, imported: done, failed };
});
ipcMain.handle('mc:pickFolder', async () => {
const res = await dialog.showOpenDialog(mainWindow, {
title: 'Minecraft-Launcher-Ordner waehlen (meist .minecraft)',
properties: ['openDirectory'],
});
return res.canceled ? null : res.filePaths[0];
});
ipcMain.handle('mc:scan', (_e, folder) => {
try { return mcimport.scan(folder); }
catch (err) { return { ok: false, reason: err.message }; }
});
ipcMain.handle('mc:import', (_e, entries, copyData) => {
const done = [];
const failed = [];
for (const entry of entries || []) {
try { done.push(mcimport.importOne(store, entry, copyData !== false)); }
catch (err) { failed.push({ name: entry.name, message: err.message }); }
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('mc:progress', { current: done.length + failed.length, total: entries.length });
}
}
return { ok: true, imported: done, failed };
});
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// IPC Minecraft Bedrock (Windows-Store-App) // IPC Minecraft Bedrock (Windows-Store-App)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

217
src/mcimport.js Normal file
View File

@@ -0,0 +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 };
}
module.exports = { scan, importOne, parseLastVersionId, resolveLauncherRoot };

View File

@@ -113,7 +113,7 @@ function scan(inputPath) {
* store: unser Datenspeicher; copyData: Spielverzeichnis mitkopieren * store: unser Datenspeicher; copyData: Spielverzeichnis mitkopieren
*/ */
function importOne(store, entry, copyData = true) { function importOne(store, entry, copyData = true) {
const created = store.createInstance({ const created = store.createOrReplaceImportedInstance({
name: entry.name, name: entry.name,
group: entry.group, group: entry.group,
notes: entry.notes, notes: entry.notes,

View File

@@ -45,6 +45,14 @@ contextBridge.exposeInMainWorld('api', {
openScreenshot: (id, name) => ipcRenderer.invoke('instance:openScreenshot', id, name), openScreenshot: (id, name) => ipcRenderer.invoke('instance:openScreenshot', id, name),
launchInstance: (id, accountId) => ipcRenderer.invoke('instances:launch', id, accountId), launchInstance: (id, accountId) => ipcRenderer.invoke('instances:launch', id, accountId),
exportInstance: (id) => ipcRenderer.invoke('instances:export', id), exportInstance: (id) => ipcRenderer.invoke('instances:export', id),
createGlobalBackup: () => ipcRenderer.invoke('backup:global'),
listGlobalBackups: () => ipcRenderer.invoke('backup:list'),
restoreGlobalBackup: (sourcePath) => ipcRenderer.invoke('backup:restore', sourcePath),
onBackupProgress: (cb) => {
const l = (_e, d) => cb(d);
ipcRenderer.on('backup:progress', l);
return () => ipcRenderer.removeListener('backup:progress', l);
},
importInstance: () => ipcRenderer.invoke('instances:import'), importInstance: () => ipcRenderer.invoke('instances:import'),
importModpack: () => ipcRenderer.invoke('modpack:import'), importModpack: () => ipcRenderer.invoke('modpack:import'),
onLaunchProgress: (cb) => { onLaunchProgress: (cb) => {
@@ -95,6 +103,22 @@ contextBridge.exposeInMainWorld('api', {
ipcRenderer.on('mmc:progress', l); ipcRenderer.on('mmc:progress', l);
return () => ipcRenderer.removeListener('mmc:progress', l); return () => ipcRenderer.removeListener('mmc:progress', l);
}, },
cfPickFolder: () => ipcRenderer.invoke('cf:pickFolder'),
cfScan: (folder) => ipcRenderer.invoke('cf:scan', folder),
cfImport: (entries, copyData) => ipcRenderer.invoke('cf:import', entries, copyData),
onCfProgress: (cb) => {
const l = (_e, d) => cb(d);
ipcRenderer.on('cf:progress', l);
return () => ipcRenderer.removeListener('cf:progress', l);
},
mcPickFolder: () => ipcRenderer.invoke('mc:pickFolder'),
mcScan: (folder) => ipcRenderer.invoke('mc:scan', folder),
mcImport: (entries, copyData) => ipcRenderer.invoke('mc:import', entries, copyData),
onMcProgress: (cb) => {
const l = (_e, d) => cb(d);
ipcRenderer.on('mc:progress', l);
return () => ipcRenderer.removeListener('mc:progress', l);
},
// Bedrock (Windows-Store-Ausgabe) // Bedrock (Windows-Store-Ausgabe)
bedrockDetect: () => ipcRenderer.invoke('bedrock:detect'), bedrockDetect: () => ipcRenderer.invoke('bedrock:detect'),

View File

@@ -473,6 +473,7 @@ const LOADER_LABEL = {
// ---------- Zustand ---------- // ---------- Zustand ----------
let instances = []; let instances = [];
let currentDetailId = null; let currentDetailId = null;
let selectedInstanceIds = new Set();
let editingId = null; let editingId = null;
let confirmAction = null; let confirmAction = null;
let mcVersionData = null; // gecachte Mojang-Versionsliste let mcVersionData = null; // gecachte Mojang-Versionsliste
@@ -525,14 +526,235 @@ function applyTheme(theme) {
// ---------- Instanzen aus MultiMC übernehmen ---------- // ---------- Instanzen aus MultiMC übernehmen ----------
let mmcFound = []; let mmcFound = [];
let cfFound = [];
let mcFound = [];
let importKind = 'zip';
function openMmc() { async function ensureDefaultMcScan() {
if (importKind !== 'mc') return;
if (el('mc-path').value.trim()) return;
if (mcFound.length) return;
await scanMc();
}
function setImportKind(kind) {
importKind = ['zip', 'modpack', 'mc', 'cf', 'mmc'].includes(kind) ? kind : 'zip';
['zip', 'modpack', 'mc', 'cf', 'mmc'].forEach((entryKind) => {
el(`import-tab-${entryKind}`).classList.toggle('on', entryKind === importKind);
el(`import-pane-${entryKind}`).classList.toggle('hidden', entryKind !== importKind);
});
el('import-zip-do').classList.toggle('hidden', importKind !== 'zip');
el('import-modpack-do').classList.toggle('hidden', importKind !== 'modpack');
el('mc-do').classList.toggle('hidden', importKind !== 'mc');
el('cf-do').classList.toggle('hidden', importKind !== 'cf');
el('mmc-do').classList.toggle('hidden', importKind !== 'mmc');
el('mc-progress').classList.toggle('hidden', importKind !== 'mc' || !el('mc-progress').textContent);
el('cf-progress').classList.toggle('hidden', importKind !== 'cf' || !el('cf-progress').textContent);
el('mmc-progress').classList.toggle('hidden', importKind !== 'mmc' || !el('mmc-progress').textContent);
}
function openImportDialog(kind = 'zip') {
mcFound = [];
el('mc-result').classList.add('hidden');
el('mc-do').disabled = true;
el('mc-progress').textContent = '';
el('mc-progress').classList.add('hidden');
el('mc-status').textContent = 'Leer lassen nutzt automatisch den Standardpfad unter Windows.';
el('cf-result').classList.add('hidden');
el('cf-do').disabled = true;
el('cf-progress').textContent = '';
el('cf-progress').classList.add('hidden');
el('cf-status').textContent = 'Standardpfad unter Windows: C:\\Users\\DeinName\\curseforge\\minecraft\\Instances';
el('mmc-result').classList.add('hidden'); el('mmc-result').classList.add('hidden');
el('mmc-do').disabled = true; el('mmc-do').disabled = true;
el('mmc-progress').textContent = ''; el('mmc-progress').textContent = '';
el('mmc-status').textContent = 'Gib den Ordner an, in dem MultiMC.exe liegt.'; el('mmc-progress').classList.add('hidden');
show('modal-mmc'); el('mmc-status').textContent = 'Gib den Ordner an, in dem MultiMC.exe oder PrismLauncher.exe liegt.';
el('mmc-path').focus(); setImportKind(kind);
show('modal-import');
if (importKind === 'mc') {
el('mc-path').focus();
ensureDefaultMcScan();
}
if (importKind === 'cf') el('cf-path').focus();
if (importKind === 'mmc') el('mmc-path').focus();
}
async function runZipImport() {
el('import-zip-do').disabled = true;
try {
const res = await window.api.importInstance();
if (res.canceled) return;
if (res.ok) {
await reload();
hide('modal-import');
toast('Importiert: ' + res.instance.name);
return;
}
toast(res.message || 'Import fehlgeschlagen.', true);
} finally {
el('import-zip-do').disabled = false;
}
}
async function runModpackImport() {
el('import-modpack-do').disabled = true;
status('Importiere Modpack …');
try {
const res = await window.api.importModpack();
status('Bereit'); el('status-right').textContent = '';
if (res.canceled) return;
if (res.ok) {
await reload();
hide('modal-import');
toast(`Modpack importiert: ${res.instance.name} (${res.fileCount} Dateien)`);
return;
}
toast(res.message || 'Modpack-Import fehlgeschlagen.', true);
} finally {
status('Bereit'); el('status-right').textContent = '';
el('import-modpack-do').disabled = false;
}
}
async function scanMc() {
const folder = el('mc-path').value.trim();
el('mc-status').textContent = 'Suche Launcher-Profile …';
const res = await window.api.mcScan(folder);
if (!res.ok) {
el('mc-status').textContent = '✖ Dort wurde kein launcher_profiles.json gefunden.';
el('mc-result').classList.add('hidden');
el('mc-do').disabled = true;
return;
}
mcFound = res.instances;
if (!folder && res.rootDir) el('mc-path').value = res.rootDir;
el('mc-status').textContent = `${res.count} Profil${res.count === 1 ? '' : 'e'} gefunden.`;
el('mc-result').classList.remove('hidden');
el('mc-all').checked = true;
renderMcList();
}
function renderMcList() {
const box = el('mc-list');
box.innerHTML = '';
if (!mcFound.length) { box.innerHTML = '<div class="mods-empty">Keine Profile gefunden.</div>'; return; }
mcFound.forEach((inst, idx) => {
const row = document.createElement('label');
row.className = 'mmc-item';
const loaderTxt = inst.loader === 'vanilla'
? 'Vanilla'
: (LOADER_LABEL[inst.loader] || inst.loader) + (inst.loaderVersion ? ' ' + inst.loaderVersion : '');
const meta = [inst.version || 'ohne Version', loaderTxt, inst.usesDefaultGameDir ? 'Standard-Spielordner' : 'eigener Spielordner']
.filter(Boolean).join(' · ');
row.innerHTML =
`<input type="checkbox" data-idx="${idx}" checked />
<div class="mod-info">
<div class="mmc-name">${escapeHtml(inst.name)}</div>
<div class="mmc-meta">${escapeHtml(meta)}</div>
</div>`;
box.appendChild(row);
});
updateMcButton();
}
function selectedMc() {
return [...document.querySelectorAll('#mc-list input[type=checkbox]')]
.filter((c) => c.checked)
.map((c) => mcFound[Number(c.dataset.idx)]);
}
function updateMcButton() {
const n = selectedMc().length;
el('mc-do').disabled = n === 0;
el('mc-do').textContent = n ? `${n} übernehmen` : 'Übernehmen';
}
async function runMcImport() {
const entries = selectedMc();
if (!entries.length) return;
const copy = el('mc-copy').checked;
el('mc-do').disabled = true;
el('mc-progress').textContent = 'Übernehme …';
el('mc-progress').classList.remove('hidden');
const res = await window.api.mcImport(entries, copy);
await reload();
hide('modal-import');
const n = res.imported.length;
const f = res.failed.length;
toast(`${n} Profil${n === 1 ? '' : 'e'} aus dem Minecraft Launcher übernommen${f ? ` · ${f} fehlgeschlagen` : ''}.`, f > 0);
if (instances.length) await selectInstance(instances[0].id);
}
async function scanCf() {
const folder = el('cf-path').value.trim();
if (!folder) { el('cf-status').textContent = 'Bitte zuerst einen Ordner angeben.'; return; }
el('cf-status').textContent = 'Suche Instanzen …';
const res = await window.api.cfScan(folder);
if (!res.ok) {
el('cf-status').textContent = '✖ Dort wurde kein CurseForge-Instances-Ordner gefunden.';
el('cf-result').classList.add('hidden');
el('cf-do').disabled = true;
return;
}
cfFound = res.instances;
el('cf-status').textContent = `${res.count} Instanz${res.count === 1 ? '' : 'en'} gefunden.`;
el('cf-result').classList.remove('hidden');
el('cf-all').checked = true;
renderCfList();
}
function renderCfList() {
const box = el('cf-list');
box.innerHTML = '';
if (!cfFound.length) { box.innerHTML = '<div class="mods-empty">Keine Instanzen gefunden.</div>'; return; }
cfFound.forEach((inst, idx) => {
const row = document.createElement('label');
row.className = 'mmc-item';
const loaderTxt = inst.loader === 'vanilla'
? 'Vanilla'
: (LOADER_LABEL[inst.loader] || inst.loader) + (inst.loaderVersion ? ' ' + inst.loaderVersion : '');
const meta = [inst.version || 'ohne Version', loaderTxt]
.filter(Boolean).join(' · ');
row.innerHTML =
`<input type="checkbox" data-idx="${idx}" checked />
<div class="mod-info">
<div class="mmc-name">${escapeHtml(inst.name)}</div>
<div class="mmc-meta">${escapeHtml(meta)}</div>
</div>`;
box.appendChild(row);
});
updateCfButton();
}
function selectedCf() {
return [...document.querySelectorAll('#cf-list input[type=checkbox]')]
.filter((c) => c.checked)
.map((c) => cfFound[Number(c.dataset.idx)]);
}
function updateCfButton() {
const n = selectedCf().length;
el('cf-do').disabled = n === 0;
el('cf-do').textContent = n ? `${n} übernehmen` : 'Übernehmen';
}
async function runCfImport() {
const entries = selectedCf();
if (!entries.length) return;
const copy = el('cf-copy').checked;
el('cf-do').disabled = true;
el('cf-progress').textContent = 'Übernehme …';
el('cf-progress').classList.remove('hidden');
const res = await window.api.cfImport(entries, copy);
await reload();
hide('modal-import');
const n = res.imported.length;
const f = res.failed.length;
toast(`${n} CurseForge-Instanz${n === 1 ? '' : 'en'} übernommen${f ? ` · ${f} fehlgeschlagen` : ''}.`, f > 0);
if (instances.length) await selectInstance(instances[0].id);
} }
async function scanMmc() { async function scanMmc() {
@@ -594,10 +816,11 @@ async function runMmcImport() {
const copy = el('mmc-copy').checked; const copy = el('mmc-copy').checked;
el('mmc-do').disabled = true; el('mmc-do').disabled = true;
el('mmc-progress').textContent = 'Übernehme …'; el('mmc-progress').textContent = 'Übernehme …';
el('mmc-progress').classList.remove('hidden');
const res = await window.api.mmcImport(entries, copy); const res = await window.api.mmcImport(entries, copy);
await reload(); await reload();
hide('modal-mmc'); hide('modal-import');
const n = res.imported.length; const n = res.imported.length;
const f = res.failed.length; const f = res.failed.length;
toast(`${n} Instanz${n === 1 ? '' : 'en'} übernommen${f ? ` · ${f} fehlgeschlagen` : ''}.`, f > 0); toast(`${n} Instanz${n === 1 ? '' : 'en'} übernommen${f ? ` · ${f} fehlgeschlagen` : ''}.`, f > 0);
@@ -741,11 +964,12 @@ function showUpdateDialog(info) {
pendingUpdate = info; pendingUpdate = info;
el('upd-current').textContent = formatVersionLabel(info.currentVersion); el('upd-current').textContent = formatVersionLabel(info.currentVersion);
el('upd-new').textContent = formatVersionLabel(info.latestVersion); el('upd-new').textContent = formatVersionLabel(info.latestVersion);
el('upd-channel').textContent = `Update-Kanal: ${info.installKind === 'portable' ? 'Portable' : 'Setup'}`;
el('upd-notes').textContent = (info.notes || '').trim() || 'Keine Änderungshinweise hinterlegt.'; el('upd-notes').textContent = (info.notes || '').trim() || 'Keine Änderungshinweise hinterlegt.';
el('upd-progress').classList.add('hidden'); el('upd-progress').classList.add('hidden');
el('upd-bar-fill').style.width = '0%'; el('upd-bar-fill').style.width = '0%';
el('upd-install').disabled = !info.asset; el('upd-install').disabled = !info.asset;
el('upd-install').textContent = info.asset ? 'Installieren' : 'Kein Installer im Release'; el('upd-install').textContent = info.asset ? 'Installieren' : `Kein passendes ${info.installKind === 'portable' ? 'Portable' : 'Setup'}-Update im Release`;
show('modal-update'); show('modal-update');
} }
@@ -954,9 +1178,114 @@ function applyIcons(root = document) {
} }
// ---------- Instanz-Kacheln ---------- // ---------- Instanz-Kacheln ----------
const SINGLE_SELECTION_BUTTON_IDS = [
'p-play', 'p-offline', 'p-edit', 'p-notes', 'p-mods', 'p-worlds', 'p-screens',
'p-mcfolder', 'p-configfolder', 'p-instfolder', 'p-shortcut', 'p-export', 'p-copy',
];
function orderedSelectedInstanceIds() {
return instances.map((inst) => inst.id).filter((id) => selectedInstanceIds.has(id));
}
function syncCardSelectionUi() {
document.querySelectorAll('.card').forEach((card) => {
card.classList.toggle('sel', selectedInstanceIds.has(card.dataset.id));
});
}
function setSingleSelectionActionsDisabled(disabled) {
SINGLE_SELECTION_BUTTON_IDS.forEach((id) => { el(id).disabled = disabled; });
const groupLine = el('p-groupline');
groupLine.style.pointerEvents = disabled ? 'none' : '';
groupLine.style.color = disabled ? 'var(--dim)' : '';
}
async function refreshSelectionPanel() {
const selectedIds = orderedSelectedInstanceIds();
const count = selectedIds.length;
const panelC = el('p-content');
const panelE = el('p-empty');
syncCardSelectionUi();
if (!count) {
currentDetailId = null;
setSingleSelectionActionsDisabled(false);
el('p-delete').disabled = false;
el('p-delete').textContent = 'Löschen';
panelC.classList.add('hidden');
panelE.classList.remove('hidden');
el('status-left').textContent = 'Bereit';
el('status-play').textContent = 'Keine Instanz ausgewählt';
el('status-total').textContent = '';
return;
}
if (count > 1) {
currentDetailId = null;
setSingleSelectionActionsDisabled(true);
el('p-delete').disabled = false;
el('p-delete').textContent = `${count} Instanzen löschen`;
panelE.classList.add('hidden');
panelC.classList.remove('hidden');
el('p-icon').innerHTML = iconOf('chest', 60);
el('p-name').textContent = `${count} Instanzen ausgewählt`;
el('p-groupline').textContent = 'Mehrfachauswahl aktiv';
el('status-left').textContent = `${count} Instanzen ausgewählt`;
el('status-play').textContent = 'Strg+Klick wählt weitere Instanzen aus oder ab';
el('status-total').textContent = 'Sammellöschen verfügbar';
return;
}
const id = selectedIds[0];
currentDetailId = id;
setSingleSelectionActionsDisabled(false);
el('p-delete').disabled = false;
el('p-delete').textContent = 'Löschen';
window.api.saveSettings({ lastSelectedId: id || '' });
const inst = id ? await window.api.getInstance(id) : null;
if (!inst) {
selectedInstanceIds.delete(id);
panelC.classList.add('hidden');
panelE.classList.remove('hidden');
el('status-left').textContent = 'Bereit';
el('status-play').textContent = 'Keine Instanz ausgewählt';
el('status-total').textContent = '';
return;
}
panelE.classList.add('hidden');
panelC.classList.remove('hidden');
const iconData = inst.customImage ? await window.api.instanceIcon(id) : null;
if (iconData) el('p-icon').innerHTML = `<img src="${iconData}" alt="" />`;
else { el('p-icon').innerHTML = iconOf(inst.icon, 60); }
el('p-name').textContent = inst.name;
el('p-groupline').textContent = inst.group ? `Gruppe: ${inst.group}` : 'Ohne Gruppe';
const loaderTxt = inst.minecraft.loader && inst.minecraft.loader !== 'vanilla'
? ' · ' + (LOADER_LABEL[inst.minecraft.loader] || inst.minecraft.loader) : '';
el('status-left').textContent = inst.name + ' (' + (inst.minecraft.version || 'ohne Version') + loaderTxt + ')';
el('status-play').textContent = 'Zuletzt gespielt: ' + (inst.lastPlayed ? fmtDate(inst.lastPlayed) : 'nie');
el('status-total').textContent = 'Spielzeit: ' + fmtDuration(inst.totalPlaySeconds);
}
async function applySelection(ids) {
const valid = new Set(instances.map((inst) => inst.id));
selectedInstanceIds = new Set((ids || []).filter((id) => valid.has(id)));
await refreshSelectionPanel();
}
async function toggleInstanceSelection(id) {
const next = new Set(selectedInstanceIds);
if (next.has(id)) next.delete(id);
else next.add(id);
await applySelection([...next]);
}
function buildCard(inst) { function buildCard(inst) {
const card = document.createElement('div'); const card = document.createElement('div');
card.className = 'card' + (inst.id === currentDetailId ? ' sel' : ''); card.className = 'card' + (selectedInstanceIds.has(inst.id) ? ' sel' : '');
card.dataset.id = inst.id; card.dataset.id = inst.id;
const iconHtml = inst.iconData const iconHtml = inst.iconData
@@ -974,14 +1303,17 @@ function buildCard(inst) {
<div class="card-name">${escapeHtml(inst.name)}</div>`; <div class="card-name">${escapeHtml(inst.name)}</div>`;
// einfacher Klick = auswählen (MultiMC), Doppelklick = starten // einfacher Klick = auswählen (MultiMC), Doppelklick = starten
card.addEventListener('click', () => selectInstance(inst.id)); card.addEventListener('click', (e) => {
if (e.ctrlKey || e.metaKey) toggleInstanceSelection(inst.id);
else selectInstance(inst.id);
});
card.addEventListener('dblclick', () => launch(inst.id)); card.addEventListener('dblclick', () => launch(inst.id));
// Rechtsklick: Instanz auswählen und Kontextmenü öffnen // Rechtsklick: Instanz auswählen und Kontextmenü öffnen
card.addEventListener('contextmenu', (e) => { card.addEventListener('contextmenu', async (e) => {
e.preventDefault(); e.preventDefault();
if (!selectedInstanceIds.has(inst.id)) await selectInstance(inst.id);
openContextMenu(e.clientX, e.clientY); // sofort anzeigen openContextMenu(e.clientX, e.clientY); // sofort anzeigen
selectInstance(inst.id); // Auswahl folgt
}); });
// Drag & Drop in Gruppen // Drag & Drop in Gruppen
@@ -1060,7 +1392,10 @@ function fillGroupDatalist() {
async function reload() { async function reload() {
instances = await window.api.listInstances(); instances = await window.api.listInstances();
const valid = new Set(instances.map((inst) => inst.id));
selectedInstanceIds = new Set([...selectedInstanceIds].filter((id) => valid.has(id)));
render(); render();
await refreshSelectionPanel();
} }
// ---------- Minecraft-Versionen ---------- // ---------- Minecraft-Versionen ----------
@@ -1299,35 +1634,7 @@ async function saveInstance() {
// ---------- Auswahl + rechte Aktionsleiste (MultiMC-Stil) ---------- // ---------- Auswahl + rechte Aktionsleiste (MultiMC-Stil) ----------
async function selectInstance(id) { async function selectInstance(id) {
currentDetailId = id; await applySelection(id ? [id] : []);
document.querySelectorAll('.card').forEach((c) => c.classList.toggle('sel', c.dataset.id === id));
// Auswahl merken, damit sie beim nächsten Start wiederhergestellt wird
window.api.saveSettings({ lastSelectedId: id || '' });
const inst = id ? await window.api.getInstance(id) : null;
const panelC = el('p-content');
const panelE = el('p-empty');
if (!inst) {
panelC.classList.add('hidden');
panelE.classList.remove('hidden');
el('status-left').textContent = 'Bereit';
el('status-play').textContent = 'Keine Instanz ausgewählt';
el('status-total').textContent = '';
return;
}
panelE.classList.add('hidden');
panelC.classList.remove('hidden');
const iconData = inst.customImage ? await window.api.instanceIcon(id) : null;
if (iconData) el('p-icon').innerHTML = `<img src="${iconData}" alt="" />`;
else { el('p-icon').innerHTML = iconOf(inst.icon, 60); }
el('p-name').textContent = inst.name;
const loaderTxt = inst.minecraft.loader && inst.minecraft.loader !== 'vanilla'
? ' · ' + (LOADER_LABEL[inst.minecraft.loader] || inst.minecraft.loader) : '';
el('status-left').textContent = inst.name + ' (' + (inst.minecraft.version || 'ohne Version') + loaderTxt + ')';
el('status-play').textContent = 'Zuletzt gespielt: ' + (inst.lastPlayed ? fmtDate(inst.lastPlayed) : 'nie');
el('status-total').textContent = 'Spielzeit: ' + fmtDuration(inst.totalPlaySeconds);
} }
let launching = false; let launching = false;
@@ -1703,6 +2010,14 @@ async function openSettings() {
el('s-proxy-port').value = s.proxyPort || ''; el('s-proxy-port').value = s.proxyPort || '';
el('s-proxy-user').value = s.proxyUser || ''; el('s-proxy-user').value = s.proxyUser || '';
el('s-proxy-pass').value = s.proxyPass || ''; el('s-proxy-pass').value = s.proxyPass || '';
el('s-backup-status').className = 'hint';
el('s-backup-status').textContent = 'Noch kein Backup erstellt.';
el('s-backup-global').disabled = false;
el('s-restore-status').className = 'hint';
el('s-restore-status').textContent = 'Noch kein Backup wiederhergestellt.';
await refreshGlobalBackupOptions();
resetBackupProgressUi();
setBackupButtonsBusy(false);
el('s-instances-dir').value = await window.api.getInstancesDir(); el('s-instances-dir').value = await window.api.getInstancesDir();
el('s-java').value = s.javaPath || ''; el('s-java').value = s.javaPath || '';
el('s-min-mem').value = s.defaultMinMemMb; el('s-min-mem').value = s.defaultMinMemMb;
@@ -1809,6 +2124,257 @@ async function saveSettings() {
await reload(); await reload();
} }
function resetBackupProgressUi() {
['backup', 'restore'].forEach((kind) => {
clearBackupProgressTimer(kind);
backupProgressUiState[kind].shownPercent = 0;
backupProgressUiState[kind].targetPercent = 0;
backupProgressUiState[kind].detail = '';
backupProgressUiState[kind].packing = false;
el(`s-${kind}-progress`).dataset.percent = '0';
el(`s-${kind}-progress`).dataset.complete = '0';
el(`s-${kind}-progress`).classList.add('hidden');
el(`s-${kind}-progress-fill`).style.width = '0%';
el(`s-${kind}-progress-fill`).classList.remove('backup-progress-fill-busy');
el(`s-${kind}-progress-text`).textContent = '0 %';
});
}
const backupProgressActive = { backup: false, restore: false };
const backupProgressUiState = {
backup: { timer: null, shownPercent: 0, targetPercent: 0, detail: '', packing: false },
restore: { timer: null, shownPercent: 0, targetPercent: 0, detail: '', packing: false },
};
function clearBackupProgressTimer(prefix) {
const state = backupProgressUiState[prefix];
if (!state.timer) return;
clearInterval(state.timer);
state.timer = null;
}
function renderBackupProgress(prefix) {
const state = backupProgressUiState[prefix];
const box = el(`s-${prefix}-progress`);
const fill = el(`s-${prefix}-progress-fill`);
const text = el(`s-${prefix}-progress-text`);
const displayPercent = state.targetPercent >= 100 ? 100 : Math.floor(state.shownPercent);
box.dataset.percent = String(displayPercent);
box.dataset.complete = state.targetPercent >= 100 && state.shownPercent >= 100 ? '1' : '0';
box.classList.remove('hidden');
fill.classList.toggle('backup-progress-fill-busy', state.packing && state.targetPercent < 99);
fill.style.width = `${Math.max(0, Math.min(100, state.shownPercent))}%`;
text.textContent = `${displayPercent} %${state.detail ? ' · ' + state.detail : ''}`;
}
function ensureBackupProgressTimer(prefix) {
const state = backupProgressUiState[prefix];
if (state.timer) return;
state.timer = setInterval(() => {
const gap = state.targetPercent - state.shownPercent;
if (gap > 0.05) {
const step = state.packing ? Math.max(0.18, gap * 0.18) : Math.max(0.8, gap * 0.4);
state.shownPercent = Math.min(state.targetPercent, state.shownPercent + step);
} else {
state.shownPercent = state.targetPercent;
}
renderBackupProgress(prefix);
if (state.targetPercent >= 100 && state.shownPercent >= 100) clearBackupProgressTimer(prefix);
}, 120);
}
function setBackupProgressActive(mode, active) {
const prefix = mode === 'restore' ? 'restore' : 'backup';
backupProgressActive[prefix] = !!active;
}
function applyBackupProgressEvent(mode, percent, detail) {
const prefix = mode === 'restore' ? 'restore' : 'backup';
if (!backupProgressActive[prefix]) return;
updateBackupProgressUi(mode, percent, detail);
}
function setBackupButtonsBusy(busy) {
const restoreSelect = el('s-restore-backup');
const hasPlaceholderOnly = restoreSelect.options.length === 1 && !restoreSelect.options[0].value;
const canRestore = restoreSelect.options.length > 0 && !hasPlaceholderOnly && !!restoreSelect.value;
el('s-backup-global').disabled = busy;
restoreSelect.disabled = busy || !canRestore;
el('s-restore-global').disabled = busy || !canRestore;
}
function formatBackupSize(bytes) {
const value = Number(bytes) || 0;
if (value < 1024 * 1024) return `${Math.max(1, Math.round(value / 1024))} KB`;
return `${(value / (1024 * 1024)).toFixed(1)} MB`;
}
function formatBackupModified(isoString) {
try {
return new Date(isoString).toLocaleString('de-DE');
} catch {
return isoString || '';
}
}
async function refreshGlobalBackupOptions(selectedPath) {
const select = el('s-restore-backup');
const backups = await window.api.listGlobalBackups();
select.innerHTML = '';
if (!backups.length) {
const option = document.createElement('option');
option.value = '';
option.textContent = 'Keine Backups gefunden';
select.appendChild(option);
select.disabled = true;
el('s-restore-global').disabled = true;
return [];
}
backups.forEach((backup) => {
const option = document.createElement('option');
option.value = backup.path;
option.textContent = `${backup.name} · ${formatBackupModified(backup.modifiedAt)} · ${formatBackupSize(backup.size)}`;
select.appendChild(option);
});
select.value = backups.some((backup) => backup.path === selectedPath) ? selectedPath : backups[0].path;
select.disabled = false;
el('s-restore-global').disabled = !select.value;
return backups;
}
function updateBackupProgressUi(mode, percent, detail) {
const prefix = mode === 'restore' ? 'restore' : 'backup';
const state = backupProgressUiState[prefix];
const nextPercent = Math.max(0, Math.min(100, Number(percent) || 0));
const completed = state.targetPercent >= 100;
if (nextPercent === 0) {
state.shownPercent = 0;
state.targetPercent = 0;
state.detail = detail || '';
state.packing = false;
renderBackupProgress(prefix);
return;
}
if ((completed && nextPercent < 100) || nextPercent < state.targetPercent) {
return;
}
state.targetPercent = nextPercent;
state.detail = detail || '';
state.packing = /^Packe ZIP-Archiv/.test(state.detail) && nextPercent < 99;
if (nextPercent >= 99 || (nextPercent - state.shownPercent) >= 20 || state.shownPercent === 0) {
state.shownPercent = nextPercent;
}
renderBackupProgress(prefix);
if (state.shownPercent < state.targetPercent) ensureBackupProgressTimer(prefix);
else if (state.targetPercent >= 100) clearBackupProgressTimer(prefix);
}
async function runGlobalBackup() {
const statusLine = el('s-backup-status');
setBackupButtonsBusy(true);
setBackupProgressActive('create', true);
statusLine.className = 'hint';
updateBackupProgressUi('create', 0, 'Bereite Zielordner vor');
statusLine.textContent = 'Erstelle Backup in Dokumente/AeroMC Launcher/Backups ...';
try {
const res = await window.api.createGlobalBackup();
if (res.canceled) {
statusLine.className = 'hint';
statusLine.textContent = 'Backup abgebrochen.';
resetBackupProgressUi();
setBackupProgressActive('create', false);
return;
}
if (res.ok) {
updateBackupProgressUi('create', 100, 'Backup abgeschlossen');
setBackupProgressActive('create', false);
statusLine.className = 'hint status-ok';
statusLine.textContent = 'Backup erfolgreich erstellt';
await refreshGlobalBackupOptions(res.path);
toast('Globales Backup erstellt: ' + res.path);
return;
}
setBackupProgressActive('create', false);
statusLine.className = 'hint status-bad';
statusLine.textContent = '✖ ' + (res.message || 'Backup fehlgeschlagen.');
toast(res.message || 'Backup fehlgeschlagen.', true);
} finally {
setBackupProgressActive('create', false);
setBackupButtonsBusy(false);
}
}
async function runGlobalRestore() {
const statusLine = el('s-restore-status');
const selectedBackup = el('s-restore-backup').value;
if (!selectedBackup) {
statusLine.className = 'hint status-bad';
statusLine.textContent = 'Bitte ein Backup auswählen.';
return;
}
setBackupButtonsBusy(true);
setBackupProgressActive('restore', true);
updateBackupProgressUi('restore', 0, 'Warte auf Backup-Datei');
statusLine.className = 'hint';
statusLine.textContent = 'Stelle Backup wieder her ...';
try {
const res = await window.api.restoreGlobalBackup(selectedBackup);
if (res.canceled) {
statusLine.className = 'hint';
statusLine.textContent = 'Wiederherstellen abgebrochen.';
resetBackupProgressUi();
setBackupProgressActive('restore', false);
return;
}
if (!res.ok) {
setBackupProgressActive('restore', false);
statusLine.className = 'hint status-bad';
statusLine.textContent = '✖ ' + (res.message || 'Wiederherstellen fehlgeschlagen.');
toast(res.message || 'Wiederherstellen fehlgeschlagen.', true);
return;
}
updateBackupProgressUi('restore', 100, 'Wiederherstellen abgeschlossen');
setBackupProgressActive('restore', false);
statusLine.className = 'hint status-ok';
statusLine.textContent = 'Backup erfolgreich wiederhergestellt';
const s = await window.api.getSettings();
applyTheme(s.theme || 'dark');
iso3dEnabled = s.iso3dIcons !== false;
await applyBedrockVisibility();
await applySkinBackground();
hide('modal-settings');
await reload();
toast('Backup wiederhergestellt: ' + res.path);
} finally {
setBackupProgressActive('restore', false);
setBackupButtonsBusy(false);
}
}
function promptGlobalRestore() {
askConfirm(
'Backup wiederherstellen',
'Das überschreibt alle aktuellen Instanzen mit dem Inhalt des Backups. Fortfahren?',
'Wiederherstellen',
runGlobalRestore,
);
}
// ---------- Microsoft-Login / Kontoverwaltung ---------- // ---------- Microsoft-Login / Kontoverwaltung ----------
async function refreshAccountButton() { async function refreshAccountButton() {
const acc = await window.api.authStatus(); const acc = await window.api.authStatus();
@@ -1960,21 +2526,7 @@ function wire() {
else toast('Noch keine Instanz vorhanden.', false); else toast('Noch keine Instanz vorhanden.', false);
}); });
el('btn-import').addEventListener('click', async () => { el('btn-import').addEventListener('click', () => { openImportDialog('zip'); });
const res = await window.api.importInstance();
if (res.canceled) return;
if (res.ok) { await reload(); toast('Importiert: ' + res.instance.name); }
else toast(res.message || 'Import fehlgeschlagen.', true);
});
el('btn-modpack').addEventListener('click', async () => {
status('Importiere Modpack …');
const res = await window.api.importModpack();
status('Bereit'); el('status-right').textContent = '';
if (res.canceled) return;
if (res.ok) { await reload(); toast(`Modpack importiert: ${res.instance.name} (${res.fileCount} Dateien)`); }
else toast(res.message || 'Modpack-Import fehlgeschlagen.', true);
});
// Skin-Hintergrund an/aus (wie MultiMCs Katzen-Knopf) // Skin-Hintergrund an/aus (wie MultiMCs Katzen-Knopf)
el('btn-skin').addEventListener('click', async () => { el('btn-skin').addEventListener('click', async () => {
@@ -2017,8 +2569,39 @@ function wire() {
if (h.ok) toast('Anmelde-Dienste wieder erreichbar.'); if (h.ok) toast('Anmelde-Dienste wieder erreichbar.');
}); });
// MultiMC-Import // Import
el('btn-mmc').addEventListener('click', () => { hide('modal-settings'); openMmc(); }); document.querySelectorAll('.import-tabs .mtab').forEach((tab) => {
tab.addEventListener('click', async () => {
setImportKind(tab.dataset.kind);
if (tab.dataset.kind === 'mc') await ensureDefaultMcScan();
});
});
el('import-zip-do').addEventListener('click', runZipImport);
el('import-modpack-do').addEventListener('click', runModpackImport);
el('mc-pick').addEventListener('click', async () => {
const p = await window.api.mcPickFolder();
if (p) { el('mc-path').value = p; await scanMc(); }
});
el('mc-scan').addEventListener('click', scanMc);
el('mc-path').addEventListener('keydown', (e) => { if (e.key === 'Enter') scanMc(); });
el('mc-all').addEventListener('change', (e) => {
document.querySelectorAll('#mc-list input[type=checkbox]').forEach((c) => { c.checked = e.target.checked; });
updateMcButton();
});
el('mc-list').addEventListener('change', updateMcButton);
el('mc-do').addEventListener('click', runMcImport);
el('cf-pick').addEventListener('click', async () => {
const p = await window.api.cfPickFolder();
if (p) { el('cf-path').value = p; await scanCf(); }
});
el('cf-scan').addEventListener('click', scanCf);
el('cf-path').addEventListener('keydown', (e) => { if (e.key === 'Enter') scanCf(); });
el('cf-all').addEventListener('change', (e) => {
document.querySelectorAll('#cf-list input[type=checkbox]').forEach((c) => { c.checked = e.target.checked; });
updateCfButton();
});
el('cf-list').addEventListener('change', updateCfButton);
el('cf-do').addEventListener('click', runCfImport);
el('mmc-pick').addEventListener('click', async () => { el('mmc-pick').addEventListener('click', async () => {
const p = await window.api.mmcPickFolder(); const p = await window.api.mmcPickFolder();
if (p) { el('mmc-path').value = p; await scanMmc(); } if (p) { el('mmc-path').value = p; await scanMmc(); }
@@ -2031,8 +2614,17 @@ function wire() {
}); });
el('mmc-list').addEventListener('change', updateMmcButton); el('mmc-list').addEventListener('change', updateMmcButton);
el('mmc-do').addEventListener('click', runMmcImport); el('mmc-do').addEventListener('click', runMmcImport);
window.api.onMcProgress((p) => {
el('mc-progress').textContent = `Übernehme … ${p.current}/${p.total}`;
el('mc-progress').classList.remove('hidden');
});
window.api.onCfProgress((p) => {
el('cf-progress').textContent = `Übernehme … ${p.current}/${p.total}`;
el('cf-progress').classList.remove('hidden');
});
window.api.onMmcProgress((p) => { window.api.onMmcProgress((p) => {
el('mmc-progress').textContent = `Übernehme … ${p.current}/${p.total}`; el('mmc-progress').textContent = `Übernehme … ${p.current}/${p.total}`;
el('mmc-progress').classList.remove('hidden');
}); });
// Bedrock // Bedrock
@@ -2083,9 +2675,9 @@ function wire() {
el('p-play').addEventListener('click', () => currentDetailId && launch(currentDetailId)); el('p-play').addEventListener('click', () => currentDetailId && launch(currentDetailId));
el('p-offline').addEventListener('click', () => currentDetailId && launch(currentDetailId)); el('p-offline').addEventListener('click', () => currentDetailId && launch(currentDetailId));
el('p-shortcut').addEventListener('click', () => toast('Verknüpfung erstellen folgt in einem Update.', false)); el('p-shortcut').addEventListener('click', () => toast('Verknüpfung erstellen folgt in einem Update.', false));
el('p-edit').addEventListener('click', async () => { const inst = await window.api.getInstance(currentDetailId); if (inst) openEdit(inst); }); el('p-edit').addEventListener('click', async () => { if (!currentDetailId) return; const inst = await window.api.getInstance(currentDetailId); if (inst) openEdit(inst); });
el('p-notes').addEventListener('click', async () => { const inst = await window.api.getInstance(currentDetailId); if (inst) openEdit(inst); }); el('p-notes').addEventListener('click', async () => { if (!currentDetailId) return; const inst = await window.api.getInstance(currentDetailId); if (inst) openEdit(inst); });
el('p-groupline').addEventListener('click', async () => { const inst = await window.api.getInstance(currentDetailId); if (inst) openEdit(inst); }); el('p-groupline').addEventListener('click', async () => { if (!currentDetailId) return; const inst = await window.api.getInstance(currentDetailId); if (inst) openEdit(inst); });
el('p-mods').addEventListener('click', openMods); el('p-mods').addEventListener('click', openMods);
el('p-worlds').addEventListener('click', openContent); el('p-worlds').addEventListener('click', openContent);
el('p-screens').addEventListener('click', openScreens); el('p-screens').addEventListener('click', openScreens);
@@ -2104,16 +2696,36 @@ function wire() {
await reload(); await selectInstance(copy.id); toast('Kopiert: ' + copy.name); await reload(); await selectInstance(copy.id); toast('Kopiert: ' + copy.name);
}); });
el('p-delete').addEventListener('click', async () => { el('p-delete').addEventListener('click', async () => {
const inst = await window.api.getInstance(currentDetailId); const ids = orderedSelectedInstanceIds();
if (!inst) return; if (!ids.length) return;
askConfirm('Instanz löschen', if (ids.length === 1) {
`${inst.name}" wird mit allen Dateien unwiderruflich gelöscht. Fortfahren?`, const inst = await window.api.getInstance(ids[0]);
if (!inst) return;
askConfirm('Instanz löschen',
`${inst.name}" wird mit allen Dateien unwiderruflich gelöscht. Fortfahren?`,
'Endgültig löschen',
async () => {
await window.api.deleteInstance(ids[0]);
selectedInstanceIds.clear();
await reload();
await selectInstance(instances.length ? instances[0].id : null);
toast('Gelöscht.');
});
return;
}
const names = instances.filter((inst) => selectedInstanceIds.has(inst.id)).map((inst) => inst.name);
const preview = names.slice(0, 3).map((name) => `${name}"`).join(', ');
const more = names.length > 3 ? ` und ${names.length - 3} weitere` : '';
askConfirm('Instanzen löschen',
`${ids.length} Instanzen (${preview}${more}) werden mit allen Dateien unwiderruflich gelöscht. Fortfahren?`,
'Endgültig löschen', 'Endgültig löschen',
async () => { async () => {
await window.api.deleteInstance(currentDetailId); for (const id of ids) await window.api.deleteInstance(id);
selectedInstanceIds.clear();
await reload(); await reload();
await selectInstance(instances.length ? instances[0].id : null); await selectInstance(instances.length ? instances[0].id : null);
toast('Gelöscht.'); toast(`${ids.length} Instanzen gelöscht.`);
}); });
}); });
@@ -2161,6 +2773,8 @@ function wire() {
if (e.target.value) { el('s-java').value = e.target.value; await refreshJavaStatus(e.target.value); } if (e.target.value) { el('s-java').value = e.target.value; await refreshJavaStatus(e.target.value); }
}); });
el('s-java').addEventListener('change', (e) => refreshJavaStatus(e.target.value.trim())); el('s-java').addEventListener('change', (e) => refreshJavaStatus(e.target.value.trim()));
el('s-backup-global').addEventListener('click', runGlobalBackup);
el('s-restore-global').addEventListener('click', promptGlobalRestore);
el('btn-save-settings').addEventListener('click', saveSettings); el('btn-save-settings').addEventListener('click', saveSettings);
document.querySelectorAll('.snav').forEach((b) => b.addEventListener('click', () => setSettingsPane(b.dataset.pane))); document.querySelectorAll('.snav').forEach((b) => b.addEventListener('click', () => setSettingsPane(b.dataset.pane)));
el('s-open-accounts').addEventListener('click', () => { hide('modal-settings'); openLogin(); }); el('s-open-accounts').addEventListener('click', () => { hide('modal-settings'); openLogin(); });
@@ -2228,6 +2842,7 @@ window.addEventListener('DOMContentLoaded', async () => {
} catch { applyTheme('dark'); } } catch { applyTheme('dark'); }
await applySkinBackground(); await applySkinBackground();
window.api.onLaunchProgress(onLaunchEvt); window.api.onLaunchProgress(onLaunchEvt);
window.api.onBackupProgress((p) => applyBackupProgressEvent(p.mode, p.percent || 0, p.detail || ''));
window.api.onAuthCode(onAuthCode); window.api.onAuthCode(onAuthCode);
window.api.onAuthRefreshed(async () => { await refreshAccountButton(); await applySkinBackground(); }); window.api.onAuthRefreshed(async () => { await refreshAccountButton(); await applySkinBackground(); });
window.api.onAuthHealth(showAuthHealth); window.api.onAuthHealth(showAuthHealth);

View File

@@ -125,8 +125,30 @@ function modsIndexFile(id) {
// ---------- installierte Mods (Metadaten je Instanz) ---------- // ---------- installierte Mods (Metadaten je Instanz) ----------
function scanModsDir(id) {
const dir = modsDir(id);
if (!fs.existsSync(dir)) return [];
return fs.readdirSync(dir, { withFileTypes: true })
.filter((entry) => entry.isFile() && /\.(jar|zip)$/i.test(entry.name))
.sort((a, b) => a.name.localeCompare(b.name))
.map((entry) => ({
projectId: null,
title: entry.name.replace(/\.(jar|zip)$/i, ''),
filename: entry.name,
versionNumber: 'Importiert',
}));
}
function listMods(id) { function listMods(id) {
return readJson(modsIndexFile(id), []); const indexed = readJson(modsIndexFile(id), []);
const merged = new Map();
for (const entry of indexed) {
if (entry && entry.filename) merged.set(entry.filename, entry);
}
for (const entry of scanModsDir(id)) {
if (!merged.has(entry.filename)) merged.set(entry.filename, entry);
}
return [...merged.values()];
} }
function addMod(id, entry) { function addMod(id, entry) {
@@ -230,6 +252,16 @@ function getInstance(id) {
return readJson(instanceFile(id), null); return readJson(instanceFile(id), null);
} }
function normalizeInstanceName(name) {
return String(name || '').trim().toLowerCase();
}
function findInstanceByName(name) {
const needle = normalizeInstanceName(name);
if (!needle) return null;
return listInstances().find((inst) => normalizeInstanceName(inst.name) === needle) || null;
}
// Ordnername aus dem Instanznamen ableiten (wie MultiMC), ungültige Zeichen raus // Ordnername aus dem Instanznamen ableiten (wie MultiMC), ungültige Zeichen raus
function slugForName(name) { function slugForName(name) {
const base = String(name || 'Instanz').trim() const base = String(name || 'Instanz').trim()
@@ -248,20 +280,43 @@ function uniqueFolderId(name) {
return id; return id;
} }
function createInstance(data) { function applyInstanceData(inst, data) {
const inst = defaultInstance(data && data.name); if (!data) return inst;
inst.id = uniqueFolderId(inst.name); // Ordner = Instanzname statt Zufalls-ID if (data.name) inst.name = data.name;
if (data) { if (data.icon) inst.icon = data.icon;
if (data.icon) inst.icon = data.icon; if (data.minecraft) Object.assign(inst.minecraft, data.minecraft);
if (data.minecraft) Object.assign(inst.minecraft, data.minecraft); if (data.notes !== undefined) inst.notes = data.notes;
if (data.notes) inst.notes = data.notes; if (data.group !== undefined) inst.group = data.group;
if (data.group !== undefined) inst.group = data.group; return inst;
} }
function writeInstance(inst) {
ensureDir(gameDir(inst.id)); ensureDir(gameDir(inst.id));
writeJson(instanceFile(inst.id), inst); writeJson(instanceFile(inst.id), inst);
return inst; return inst;
} }
function createInstance(data) {
const inst = applyInstanceData(defaultInstance(data && data.name), data);
inst.id = uniqueFolderId(inst.name); // Ordner = Instanzname statt Zufalls-ID
return writeInstance(inst);
}
function createOrReplaceImportedInstance(data) {
const existing = findInstanceByName(data && data.name);
if (!existing) return createInstance(data);
const inst = applyInstanceData(defaultInstance(data && data.name), data);
inst.id = existing.id;
inst.created = existing.created || inst.created;
inst.lastPlayed = existing.lastPlayed || null;
inst.totalPlaySeconds = existing.totalPlaySeconds || 0;
const p = instancePath(existing.id);
if (fs.existsSync(p)) fs.rmSync(p, { recursive: true, force: true });
return writeInstance(inst);
}
function updateInstance(id, patch) { function updateInstance(id, patch) {
const inst = getInstance(id); const inst = getInstance(id);
if (!inst) throw new Error('Instanz nicht gefunden: ' + id); if (!inst) throw new Error('Instanz nicht gefunden: ' + id);
@@ -313,7 +368,9 @@ module.exports = {
removeMod, removeMod,
listInstances, listInstances,
getInstance, getInstance,
findInstanceByName,
createInstance, createInstance,
createOrReplaceImportedInstance,
updateInstance, updateInstance,
deleteInstance, deleteInstance,
duplicateInstance, duplicateInstance,

View File

@@ -80,7 +80,6 @@ body{display:grid;grid-template-rows:auto 1fr auto;height:100vh;overflow:hidden}
/* farbige Toolbar-Icons wie in MultiMC */ /* farbige Toolbar-Icons wie in MultiMC */
#btn-new svg{color:#3a9c3a} #btn-new svg{color:#3a9c3a}
#btn-import svg{color:#3d7ab5} #btn-import svg{color:#3d7ab5}
#btn-modpack svg{color:#8e5bc7}
#btn-open-dir svg{color:#d8a72f} #btn-open-dir svg{color:#d8a72f}
#btn-console svg{color:#5a6470} #btn-console svg{color:#5a6470}
#btn-account svg{color:#3a9c3a} #btn-account svg{color:#3a9c3a}
@@ -112,6 +111,8 @@ body{display:grid;grid-template-rows:auto 1fr auto;height:100vh;overflow:hidden}
.hr{height:1px;background:var(--border);margin:6px 2px} .hr{height:1px;background:var(--border);margin:6px 2px}
.sbtn{display:block;width:100%;text-align:center;padding:5px 4px;border:1px solid transparent;border-radius:3px;background:none;cursor:pointer;font-family:inherit;font-size:12px;color:var(--text)} .sbtn{display:block;width:100%;text-align:center;padding:5px 4px;border:1px solid transparent;border-radius:3px;background:none;cursor:pointer;font-family:inherit;font-size:12px;color:var(--text)}
.sbtn:hover{background:#e2e6ec;border-color:var(--border2)} .sbtn:hover{background:#e2e6ec;border-color:var(--border2)}
.sbtn:disabled{opacity:.5;cursor:default}
.sbtn:disabled:hover{background:none;border-color:transparent}
.sbtn.play{font-weight:600} .sbtn.play{font-weight:600}
.sbtn.del:hover{color:var(--danger)} .sbtn.del:hover{color:var(--danger)}
@@ -177,6 +178,8 @@ body{display:grid;grid-template-rows:auto 1fr auto;height:100vh;overflow:hidden}
.field textarea{resize:vertical} .field textarea{resize:vertical}
.field-row{display:flex;gap:12px;flex-wrap:wrap}.field-row .field{flex:1 1 0;min-width:0} .field-row{display:flex;gap:12px;flex-wrap:wrap}.field-row .field{flex:1 1 0;min-width:0}
.field-icon{max-width:210px;min-width:0} .field-icon{max-width:210px;min-width:0}
.backup-select-field{margin-top:14px;margin-bottom:0}
.backup-select-field select{width:100%}
.hint{font-size:12px} .hint{font-size:12px}
.path-row{display:flex;gap:8px}.path-row input{flex:1} .path-row{display:flex;gap:8px}.path-row input{flex:1}
.advanced{border:1px solid var(--border);border-radius:3px;padding:12px 14px 2px;margin-top:4px;background:#fbfbfb} .advanced{border:1px solid var(--border);border-radius:3px;padding:12px 14px 2px;margin-top:4px;background:#fbfbfb}
@@ -247,6 +250,10 @@ body{display:grid;grid-template-rows:auto 1fr auto;height:100vh;overflow:hidden}
.mod-meta{font-size:11px;color:var(--dim);margin-top:2px} .mod-meta{font-size:11px;color:var(--dim);margin-top:2px}
.mod-action{flex:none} .mod-action{flex:none}
/* Import */
.import-pane{min-height:180px}
.import-copy{margin:0 0 10px;font-size:13px;line-height:1.5}
/* MultiMC-Import */ /* MultiMC-Import */
.mmc-bar{display:flex;gap:18px;align-items:center;flex-wrap:wrap;margin-bottom:10px} .mmc-bar{display:flex;gap:18px;align-items:center;flex-wrap:wrap;margin-bottom:10px}
.mmc-item{display:flex;gap:10px;align-items:center;padding:7px 8px;border-radius:3px} .mmc-item{display:flex;gap:10px;align-items:center;padding:7px 8px;border-radius:3px}
@@ -369,10 +376,21 @@ img.account-av{object-fit:cover;image-rendering:auto}
.upd-old{color:var(--dim)} .upd-old{color:var(--dim)}
.upd-arrow{color:var(--dim)} .upd-arrow{color:var(--dim)}
.upd-newv{font-weight:700;color:var(--accent)} .upd-newv{font-weight:700;color:var(--accent)}
.upd-channel{margin:-6px 0 12px;text-align:center}
.upd-notes{max-height:180px;overflow:auto;background:var(--input,#fff);border:1px solid var(--border);border-radius:3px;padding:10px 12px;font-size:12px;white-space:pre-wrap;margin-bottom:12px} .upd-notes{max-height:180px;overflow:auto;background:var(--input,#fff);border:1px solid var(--border);border-radius:3px;padding:10px 12px;font-size:12px;white-space:pre-wrap;margin-bottom:12px}
.upd-progress{margin:10px 0} .upd-progress{margin:10px 0}
.upd-bar{height:8px;background:var(--border2);border-radius:4px;overflow:hidden} .upd-bar{height:8px;background:var(--border2);border-radius:4px;overflow:hidden}
.upd-bar-fill{height:100%;width:0;background:var(--accent);transition:width .2s} .upd-bar-fill{height:100%;width:0;background:var(--accent);transition:width .2s}
.backup-progress{margin-top:12px}
.backup-progress-bar{height:10px;background:var(--border2);border-radius:999px;overflow:hidden}
.backup-progress-fill{height:100%;width:0;background:linear-gradient(90deg,#3a7bc8,#5aa1e6);transition:width .2s;background-size:220% 100%}
.backup-progress-fill-busy{animation:backup-progress-shift 1.2s linear infinite}
.backup-progress-text{margin-top:6px}
@keyframes backup-progress-shift{
from{background-position:0 0}
to{background-position:220% 0}
}
/* Kontextmenü (Rechtsklick auf Instanz) */ /* Kontextmenü (Rechtsklick auf Instanz) */
.ctx{ .ctx{

View File

@@ -17,6 +17,13 @@ const { spawn } = require('child_process');
const API = 'https://git.viper.ipv64.net/api/v1/repos/M_Viper/AeroMc-Launcher/releases'; const API = 'https://git.viper.ipv64.net/api/v1/repos/M_Viper/AeroMc-Launcher/releases';
const UA = 'AeroMC-Launcher'; const UA = 'AeroMC-Launcher';
function detectInstallKind() {
const portableExe = String(process.env.PORTABLE_EXECUTABLE_FILE || '').trim();
const execName = path.basename(portableExe || process.execPath || '').toLowerCase();
if (portableExe || execName.includes('portable')) return 'portable';
return 'setup';
}
// "1.2.3" -> [1,2,3]; führendes "v" und Zusätze wie "-beta" werden ignoriert // "1.2.3" -> [1,2,3]; führendes "v" und Zusätze wie "-beta" werden ignoriert
function parseVersion(v) { function parseVersion(v) {
const m = String(v || '').trim().replace(/^v/i, '').match(/^(\d+)(?:\.(\d+))?(?:\.(\d+))?/); const m = String(v || '').trim().replace(/^v/i, '').match(/^(\d+)(?:\.(\d+))?(?:\.(\d+))?/);
@@ -34,13 +41,21 @@ function compareVersions(a, b) {
return 0; return 0;
} }
// Windows-Installer im Release finden (.exe bevorzugt, sonst .zip) function isPortableAsset(name) {
function pickAsset(release) { return /portable/i.test(name) && /\.exe$/i.test(name);
}
function isSetupAsset(name) {
return /setup/i.test(name) && /\.exe$/i.test(name);
}
// passendes Windows-Artefakt im Release finden
function pickAsset(release, installKind) {
const assets = release.assets || []; const assets = release.assets || [];
return assets.find((a) => /\.exe$/i.test(a.name)) if (installKind === 'portable') {
|| assets.find((a) => /setup.*\.exe$/i.test(a.name)) return assets.find((a) => isPortableAsset(a.name)) || null;
|| assets.find((a) => /\.zip$/i.test(a.name)) }
|| null; return assets.find((a) => isSetupAsset(a.name)) || null;
} }
/* /*
@@ -48,6 +63,7 @@ function pickAsset(release) {
* -> { available, currentVersion, latestVersion, notes, asset, url, noReleases } * -> { available, currentVersion, latestVersion, notes, asset, url, noReleases }
*/ */
async function checkForUpdate(currentVersion) { async function checkForUpdate(currentVersion) {
const installKind = detectInstallKind();
const res = await fetch(API + '?limit=10', { headers: { 'User-Agent': UA, Accept: 'application/json' } }); const res = await fetch(API + '?limit=10', { headers: { 'User-Agent': UA, Accept: 'application/json' } });
if (!res.ok) throw new Error('Update-Server antwortet mit HTTP ' + res.status); if (!res.ok) throw new Error('Update-Server antwortet mit HTTP ' + res.status);
const list = await res.json(); const list = await res.json();
@@ -59,14 +75,15 @@ async function checkForUpdate(currentVersion) {
releases.sort((a, b) => compareVersions(b.tag_name, a.tag_name)); releases.sort((a, b) => compareVersions(b.tag_name, a.tag_name));
const latest = releases[0]; const latest = releases[0];
const available = compareVersions(latest.tag_name, currentVersion) > 0; const available = compareVersions(latest.tag_name, currentVersion) > 0;
const asset = pickAsset(latest); const asset = pickAsset(latest, installKind);
return { return {
available, available,
currentVersion, currentVersion,
latestVersion: String(latest.tag_name || '').replace(/^v/i, ''), latestVersion: String(latest.tag_name || '').replace(/^v/i, ''),
installKind,
notes: latest.body || '', notes: latest.body || '',
published: latest.published_at || latest.created_at || null, published: latest.published_at || latest.created_at || null,
asset: asset ? { name: asset.name, url: asset.browser_download_url, size: asset.size } : null, asset: asset ? { name: asset.name, url: asset.browser_download_url, size: asset.size, kind: installKind } : null,
url: latest.html_url, url: latest.html_url,
}; };
} }
@@ -77,6 +94,10 @@ async function checkForUpdate(currentVersion) {
*/ */
async function downloadAndInstall(asset, onProgress) { async function downloadAndInstall(asset, onProgress) {
if (!asset || !asset.url) throw new Error('Kein Installer im Release gefunden.'); if (!asset || !asset.url) throw new Error('Kein Installer im Release gefunden.');
const installKind = detectInstallKind();
if (asset.kind && asset.kind !== installKind) {
throw new Error('Das angebotene Update passt nicht zur laufenden Paketart.');
}
const dir = path.join(os.tmpdir(), 'aeromc-update'); const dir = path.join(os.tmpdir(), 'aeromc-update');
fs.mkdirSync(dir, { recursive: true }); fs.mkdirSync(dir, { recursive: true });
@@ -112,4 +133,4 @@ function runInstaller(installerPath) {
return true; return true;
} }
module.exports = { checkForUpdate, downloadAndInstall, runInstaller, compareVersions, parseVersion }; module.exports = { checkForUpdate, downloadAndInstall, runInstaller, compareVersions, parseVersion, detectInstallKind };