10 Commits
Author SHA1 Message Date
M_Viper 425b94fd93 Upload via GUI (44 Dateien) 2026-08-14 08:17:05 +00:00
M_Viper e0bdcb01fb Edit README.md via Git Manager GUI 2026-08-09 08:07:12 +00:00
M_Viper c840a49f75 Edit README.md via Git Manager GUI 2026-08-09 08:07:11 +00:00
M_Viper c535d8b830 Edit README.md via Git Manager GUI 2026-08-09 08:07:07 +00:00
Git Manager GUI 248a387f01 Upload via GUI (45 Dateien) 2026-08-09 10:06:39 +02:00
Git Manager GUI 87fded4cb6 Upload via GUI (45 Dateien) 2026-08-08 22:09:57 +02:00
M_Viper 33e533009e README.md aktualisiert 2026-08-08 20:00:10 +00:00
M_Viper 17f8726075 Dateien nach "/" hochladen 2026-08-08 19:06:36 +00:00
M_Viper deec93e5fa Dateien nach "/" hochladen 2026-08-08 19:06:00 +00:00
Git Manager GUI fc57dfa186 Upload via GUI (42 Dateien) 2026-08-06 21:26:45 +02:00
17 changed files with 1667 additions and 1015 deletions
+8
View File
@@ -0,0 +1,8 @@
node_modules/
dist/
# beim Bauen erzeugt, enthält Adresse und Token der CurseForge-Weiterleitung
src/build-config.json
# Zugangsdaten des Weiterleitungs-Servers
server/curseforge-proxy/.env
+38
View File
@@ -0,0 +1,38 @@
AeroMC Lizenz- und Nutzungsbedingungen
Copyright (c) M_Viper. Alle Rechte vorbehalten.
Diese Software („AeroMC") sowie sämtlicher zugehöriger Quellcode, Ressourcen und
Dokumentation sind urheberrechtlich geschützt. Mit der Nutzung dieser Software
erkennst du die folgenden Bedingungen an.
1. Erlaubte Nutzung
Die Nutzung von AeroMC ist ausschließlich für den privaten, nicht-kommerziellen
Gebrauch gestattet.
2. Untersagt ist insbesondere
- Die Weitergabe dieses Launchers oder von Teilen davon an Dritte
- Das Verändern, Dekompilieren, Zurückentwickeln (Reverse Engineering) oder
Bearbeiten des Codes
- Die Veröffentlichung auch in veränderter oder abgeleiteter Form
an anderer Stelle als den offiziellen Kanälen des Autors
- Der Verkauf sowie jede sonstige kommerzielle Nutzung
3. Keine Gewährleistung
Die Software wird ohne jegliche Gewährleistung bereitgestellt, weder
ausdrücklich noch stillschweigend, einschließlich, aber nicht beschränkt auf
die Gewährleistung der Marktgängigkeit, der Eignung für einen bestimmten
Zweck und der Nichtverletzung von Rechten Dritter. Der Autor haftet nicht für
Schäden, die aus der Nutzung oder Unmöglichkeit der Nutzung der Software
entstehen.
4. Rechtsfolgen bei Verstoß
Wer gegen diese Bedingungen verstößt, macht sich strafbar und kann rechtlich
belangt werden. Alle Rechte, die hier nicht ausdrücklich eingeräumt werden,
bleiben beim Autor vorbehalten.
5. Markenhinweis
AeroMC ist kein offizielles Produkt von Mojang oder Microsoft. Minecraft ist
eine Marke von Mojang AB.
Kontakt: https://m-viper.de
+6 -4
View File
@@ -6,7 +6,7 @@
Ein Minecraft-Launcher für Windows mit getrennten Instanzen, Mod-Unterstützung und Microsoft-Anmeldung. Ein Minecraft-Launcher für Windows mit getrennten Instanzen, Mod-Unterstützung und Microsoft-Anmeldung.
Aktueller Stand: Version 1.0.6 Aktueller Stand: Version 1.0.9
</div> </div>
@@ -33,7 +33,7 @@ Der Launcher konzentriert sich auf den Alltag: Instanz anlegen, Konto auswählen
- Warnt beim Entfernen eines Mods, falls ein anderer installierter Mod ihn noch als Pflicht-Abhängigkeit braucht - Warnt beim Entfernen eines Mods, falls ein anderer installierter Mod ihn noch als Pflicht-Abhängigkeit braucht
- Globales Backup inkl. automatischer Zeitplan-Sicherungen, dazu Schnellsicherung einzelner Welten - Globales Backup inkl. automatischer Zeitplan-Sicherungen, dazu Schnellsicherung einzelner Welten
- Welten, Ressourcenpakete, Datenpakete, Screenshots, Serverliste und Logdateien verwalten - Welten, Ressourcenpakete, Datenpakete, Screenshots, Serverliste und Logdateien verwalten
- Serverliste mit Live-Status (Spieleranzahl, MOTD, Version) und Übernahme einzelner Einträge in andere Instanzen - Serverliste mit Live-Status (Spieleranzahl, MOTD, Version), Übernahme einzelner Einträge in andere Instanzen sowie Desktop-Verknüpfung pro Server (startet die Instanz und verbindet direkt, ab MC 1.20)
- Online- und Offline-Start - Online- und Offline-Start
- Eingebaute Konsole/Log-Ansicht inkl. Absturzberichte pro Instanz - Eingebaute Konsole/Log-Ansicht inkl. Absturzberichte pro Instanz
- Windows-Benachrichtigung, wenn ein großer Download oder ein Backup im Hintergrund fertig wird - Windows-Benachrichtigung, wenn ein großer Download oder ein Backup im Hintergrund fertig wird
@@ -45,6 +45,7 @@ Der Launcher konzentriert sich auf den Alltag: Instanz anlegen, Konto auswählen
- Optionaler Wrapper-Befehl und HTTP-Proxy für Downloads - Optionaler Wrapper-Befehl und HTTP-Proxy für Downloads
- Bedrock-Bereich für vorhandene Bedrock-Installation unter Windows, inkl. Installation zusätzlicher Add-ons - Bedrock-Bereich für vorhandene Bedrock-Installation unter Windows, inkl. Installation zusätzlicher Add-ons
- Update-Suche läuft automatisch beim Start, dazu manuelle Prüfung in den Einstellungen - Update-Suche läuft automatisch beim Start, dazu manuelle Prüfung in den Einstellungen
- Optionaler Autostart mit Windows, dann im Infobereich statt als Fenster
- Design Hell/Dunkel und Sortierung der Instanzen - Design Hell/Dunkel und Sortierung der Instanzen
## Installation ## Installation
@@ -88,7 +89,7 @@ Jede Instanz hat:
- Instanz und Notizen bearbeiten - Instanz und Notizen bearbeiten
- Mods, Ressourcenpakete und Shader - Mods, Ressourcenpakete und Shader
- Welten (inkl. Schnellsicherung einzelner Welten), Datenpakete und Bildschirmfotos - Welten (inkl. Schnellsicherung einzelner Welten), Datenpakete und Bildschirmfotos
- Server verwalten (Live-Status, Übernahme eines Eintrags in andere Instanzen) - Server verwalten (Live-Status, Übernahme eines Eintrags in andere Instanzen, Desktop-Verknüpfung pro Server)
- Logdateien und Absturzberichte ansehen - Logdateien und Absturzberichte ansehen
- Minecraft-, Konfigurations- und Instanz-Ordner öffnen - Minecraft-, Konfigurations- und Instanz-Ordner öffnen
- Desktop-Verknüpfung erstellen - Desktop-Verknüpfung erstellen
@@ -174,6 +175,7 @@ Speicherort: `Dokumente\AeroMC Launcher\Backups`.
- Instanz-Verzeichnis - Instanz-Verzeichnis
- Bedrock-Bereich anzeigen - Bedrock-Bereich anzeigen
- Windows-Benachrichtigung bei fertigem Hintergrund-Download/-Backup (nur wenn das Fenster nicht im Vordergrund ist) - Windows-Benachrichtigung bei fertigem Hintergrund-Download/-Backup (nur wenn das Fenster nicht im Vordergrund ist)
- Autostart: mit Windows starten, im Infobereich statt als Fenster solange aktiv, versteckt „Schließen" (X) den Launcher nur, statt ihn zu beenden (über das Symbol im Infobereich wieder öffnen oder wirklich beenden)
### Darstellung ### Darstellung
- Block-Symbole räumlich (3D) - Block-Symbole räumlich (3D)
@@ -222,7 +224,7 @@ Nutzerdaten liegen lokal unter `%APPDATA%\aeromc`.
## Links ## Links
- Webseite: https://m-viper.de - Webseite: https://m-viper.de
- Gitseite: https://git.viper.ipv64.net - Launcher-Webseite: https://aeromc.viper.ipv64.net
- Autor: M_Viper - Autor: M_Viper
## Rechtliches ## Rechtliches
+3 -3
View File
@@ -1,13 +1,13 @@
{ {
"name": "aeromc", "name": "aeromc",
"version": "1.0.6", "version": "1.0.9",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "aeromc", "name": "aeromc",
"version": "1.0.6", "version": "1.0.9",
"license": "MIT", "license": "SEE LICENSE IN LICENSE",
"dependencies": { "dependencies": {
"archiver": "^7.0.1", "archiver": "^7.0.1",
"skinview3d": "^3.4.2", "skinview3d": "^3.4.2",
+6 -7
View File
@@ -1,16 +1,14 @@
{ {
"name": "aeromc", "name": "aeromc",
"productName": "AeroMC", "productName": "AeroMC",
"version": "1.0.6", "version": "1.0.9",
"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",
"launcherWebsite": "https://aeromc.viper.ipv64.net",
"main": "src/main.js", "main": "src/main.js",
"author": "M_Viper", "author": "M_Viper",
"repository": { "license": "SEE LICENSE IN LICENSE",
"type": "git", "private": true,
"url": "https://git.viper.ipv64.net"
},
"license": "MIT",
"scripts": { "scripts": {
"start": "electron .", "start": "electron .",
"prebuild": "node scripts/write-build-config.js", "prebuild": "node scripts/write-build-config.js",
@@ -25,7 +23,8 @@
"compression": "store", "compression": "store",
"files": [ "files": [
"src/**/*", "src/**/*",
"package.json" "package.json",
"LICENSE"
], ],
"win": { "win": {
"target": "nsis", "target": "nsis",
+451 -439
View File
@@ -1,440 +1,452 @@
'use strict'; 'use strict';
const archiver = require('archiver'); const archiver = require('archiver');
const fs = require('fs'); const fs = require('fs');
const os = require('os'); const os = require('os');
const path = require('path'); const path = require('path');
const { pipeline } = require('stream/promises'); const { pipeline } = require('stream/promises');
const unzipper = require('unzipper'); const unzipper = require('unzipper');
function pad2(value) { function pad2(value) {
return String(value).padStart(2, '0'); return String(value).padStart(2, '0');
} }
function defaultBackupFileName(date = new Date()) { function defaultBackupFileName(date = new Date()) {
return 'AeroMC-Backup-' + return 'AeroMC-Backup-' +
date.getFullYear() + '-' + date.getFullYear() + '-' +
pad2(date.getMonth() + 1) + '-' + pad2(date.getMonth() + 1) + '-' +
pad2(date.getDate()) + '_' + pad2(date.getDate()) + '_' +
pad2(date.getHours()) + '-' + pad2(date.getHours()) + '-' +
pad2(date.getMinutes()) + '-' + pad2(date.getMinutes()) + '-' +
pad2(date.getSeconds()) + pad2(date.getSeconds()) +
'.zip'; '.zip';
} }
function defaultBackupDir(documentsDir) { function defaultBackupDir(documentsDir) {
return path.join(documentsDir, 'AeroMC Launcher', 'Backups'); return path.join(documentsDir, 'AeroMC Launcher', 'Backups');
} }
function resolveAutoBackupPath(documentsDir, date = new Date()) { function resolveAutoBackupPath(documentsDir, date = new Date()) {
const dir = defaultBackupDir(documentsDir); const dir = defaultBackupDir(documentsDir);
ensureDir(dir); ensureDir(dir);
const baseName = defaultBackupFileName(date); const baseName = defaultBackupFileName(date);
const ext = path.extname(baseName); const ext = path.extname(baseName);
const stem = baseName.slice(0, -ext.length); const stem = baseName.slice(0, -ext.length);
let attempt = 0; let attempt = 0;
let candidate = path.join(dir, baseName); let candidate = path.join(dir, baseName);
while (fs.existsSync(candidate)) { while (fs.existsSync(candidate)) {
attempt += 1; attempt += 1;
candidate = path.join(dir, `${stem}-${attempt}${ext}`); candidate = path.join(dir, `${stem}-${attempt}${ext}`);
} }
return candidate; return candidate;
} }
function listGlobalBackups(documentsDir) { function listGlobalBackups(documentsDir) {
const dir = defaultBackupDir(documentsDir); const dir = defaultBackupDir(documentsDir);
ensureDir(dir); ensureDir(dir);
return fs.readdirSync(dir, { withFileTypes: true }) return fs.readdirSync(dir, { withFileTypes: true })
.filter((entry) => entry.isFile() && /\.zip$/i.test(entry.name) && !/\.partial\.zip$/i.test(entry.name)) .filter((entry) => entry.isFile() && /\.zip$/i.test(entry.name) && !/\.partial\.zip$/i.test(entry.name))
.map((entry) => { .map((entry) => {
const filePath = path.join(dir, entry.name); const filePath = path.join(dir, entry.name);
const stat = fs.statSync(filePath); const stat = fs.statSync(filePath);
return { return {
name: entry.name, name: entry.name,
path: filePath, path: filePath,
size: stat.size, size: stat.size,
modifiedAt: stat.mtime.toISOString(), modifiedAt: stat.mtime.toISOString(),
}; };
}) })
.sort((a, b) => { .sort((a, b) => {
const timeDiff = new Date(b.modifiedAt).getTime() - new Date(a.modifiedAt).getTime(); const timeDiff = new Date(b.modifiedAt).getTime() - new Date(a.modifiedAt).getTime();
return timeDiff || a.name.localeCompare(b.name, 'de'); return timeDiff || a.name.localeCompare(b.name, 'de');
}); });
} }
function ensureDir(dir) { function ensureDir(dir) {
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
} }
function countInstanceDirs(instancesDir) { function countInstanceDirs(instancesDir) {
if (!fs.existsSync(instancesDir)) return 0; if (!fs.existsSync(instancesDir)) return 0;
return fs.readdirSync(instancesDir, { withFileTypes: true }).filter((entry) => entry.isDirectory()).length; return fs.readdirSync(instancesDir, { withFileTypes: true }).filter((entry) => entry.isDirectory()).length;
} }
function readJson(file, fallback) { function readJson(file, fallback) {
try { try {
return JSON.parse(fs.readFileSync(file, 'utf8').replace(/^\uFEFF/, '')); return JSON.parse(fs.readFileSync(file, 'utf8').replace(/^\uFEFF/, ''));
} catch { } catch {
return fallback; return fallback;
} }
} }
function writeJson(file, data) { function writeJson(file, data) {
ensureDir(path.dirname(file)); ensureDir(path.dirname(file));
fs.writeFileSync(file, JSON.stringify(data, null, 2), 'utf8'); fs.writeFileSync(file, JSON.stringify(data, null, 2), 'utf8');
} }
function emitProgress(cb, mode, percent, detail) { function emitProgress(cb, mode, percent, detail) {
if (!cb) return; if (!cb) return;
cb({ mode, percent: Math.max(0, Math.min(100, Math.round(percent))), detail: detail || '' }); cb({ mode, percent: Math.max(0, Math.min(100, Math.round(percent))), detail: detail || '' });
} }
function normalizeZipEntryPath(entryPath) { function normalizeZipEntryPath(entryPath) {
const parts = String(entryPath || '') const parts = String(entryPath || '')
.replace(/\\/g, '/') .replace(/\\/g, '/')
.split('/') .split('/')
.filter(Boolean); .filter(Boolean);
if (!parts.length) return ''; if (!parts.length) return '';
if (parts.some((part) => part === '.' || part === '..')) { if (parts.some((part) => part === '.' || part === '..')) {
throw new Error('Das Backup-Archiv enthält ungültige Pfade.'); throw new Error('Das Backup-Archiv enthält ungültige Pfade.');
} }
return path.join(...parts); return path.join(...parts);
} }
async function extractBackupArchive(sourcePath, destinationDir, options) { async function extractBackupArchive(sourcePath, destinationDir, options) {
const opts = options || {}; const opts = options || {};
const onProgress = opts.onProgress; const onProgress = opts.onProgress;
const mode = opts.mode || 'restore'; const mode = opts.mode || 'restore';
const startPercent = opts.startPercent ?? 5; const startPercent = opts.startPercent ?? 5;
const endPercent = opts.endPercent ?? 18; const endPercent = opts.endPercent ?? 18;
const detail = opts.detail || 'Entpacke Backup'; const detail = opts.detail || 'Entpacke Backup';
const directory = await unzipper.Open.file(sourcePath); const directory = await unzipper.Open.file(sourcePath);
const files = directory.files || []; const files = directory.files || [];
const fileEntries = files.filter((entry) => entry.type !== 'Directory'); const fileEntries = files.filter((entry) => entry.type !== 'Directory');
const totalBytes = fileEntries.reduce((sum, entry) => sum + Number(entry.uncompressedSize || 0), 0); const totalBytes = fileEntries.reduce((sum, entry) => sum + Number(entry.uncompressedSize || 0), 0);
let processedBytes = 0; let processedBytes = 0;
let processedEntries = 0; let processedEntries = 0;
ensureDir(destinationDir); ensureDir(destinationDir);
if (!files.length) { if (!files.length) {
emitProgress(onProgress, mode, endPercent, detail + ' abgeschlossen'); emitProgress(onProgress, mode, endPercent, detail + ' abgeschlossen');
return; return;
} }
for (const entry of files) { for (const entry of files) {
const relativePath = normalizeZipEntryPath(entry.path); const relativePath = normalizeZipEntryPath(entry.path);
if (!relativePath) continue; if (!relativePath) continue;
const targetPath = path.join(destinationDir, relativePath); const targetPath = path.join(destinationDir, relativePath);
if (entry.type === 'Directory') { if (entry.type === 'Directory') {
ensureDir(targetPath); ensureDir(targetPath);
continue; continue;
} }
ensureDir(path.dirname(targetPath)); ensureDir(path.dirname(targetPath));
await pipeline(entry.stream(), fs.createWriteStream(targetPath)); await pipeline(entry.stream(), fs.createWriteStream(targetPath));
processedBytes += Number(entry.uncompressedSize || 0); processedBytes += Number(entry.uncompressedSize || 0);
processedEntries += 1; processedEntries += 1;
const ratio = totalBytes > 0 const ratio = totalBytes > 0
? Math.max(0, Math.min(1, processedBytes / totalBytes)) ? Math.max(0, Math.min(1, processedBytes / totalBytes))
: Math.max(0, Math.min(1, processedEntries / Math.max(1, fileEntries.length))); : Math.max(0, Math.min(1, processedEntries / Math.max(1, fileEntries.length)));
const percent = startPercent + ((endPercent - startPercent) * ratio); const percent = startPercent + ((endPercent - startPercent) * ratio);
emitProgress(onProgress, mode, percent, detail + ' ...'); emitProgress(onProgress, mode, percent, detail + ' ...');
} }
} }
function createBackupArchive(options) { function createBackupArchive(options) {
const opts = options || {}; const opts = options || {};
const instancesDir = opts.instancesDir; const instancesDir = opts.instancesDir;
const settingsFile = opts.settingsFile; const settingsFile = opts.settingsFile;
const includeSettings = opts.includeSettings !== false; const includeSettings = opts.includeSettings !== false;
const backupInfo = opts.backupInfo || {}; const backupInfo = opts.backupInfo || {};
const destinationPath = opts.destinationPath; const destinationPath = opts.destinationPath;
const onProgress = opts.onProgress; const onProgress = opts.onProgress;
const mode = opts.mode || 'create'; const mode = opts.mode || 'create';
const startPercent = opts.startPercent ?? 15; const startPercent = opts.startPercent ?? 15;
const endPercent = opts.endPercent ?? 98; const endPercent = opts.endPercent ?? 98;
const detail = opts.detail || 'Packe ZIP-Archiv'; const detail = opts.detail || 'Packe ZIP-Archiv';
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
ensureDir(path.dirname(destinationPath)); ensureDir(path.dirname(destinationPath));
const output = fs.createWriteStream(destinationPath); const output = fs.createWriteStream(destinationPath);
const archive = archiver('zip', { store: true }); const archive = archiver('zip', { store: true });
let done = false; let done = false;
const fail = (err) => { const fail = (err) => {
if (done) return; if (done) return;
done = true; done = true;
try { archive.destroy(); } catch { /* ignore */ } try { archive.destroy(); } catch { /* ignore */ }
try { output.destroy(); } catch { /* ignore */ } try { output.destroy(); } catch { /* ignore */ }
reject(err); reject(err);
}; };
output.on('close', () => { output.on('close', () => {
if (done) return; if (done) return;
done = true; done = true;
resolve({ bytes: archive.pointer() }); resolve({ bytes: archive.pointer() });
}); });
output.on('error', fail); output.on('error', fail);
archive.on('error', fail); archive.on('error', fail);
archive.on('warning', (err) => { archive.on('warning', (err) => {
if (err && err.code === 'ENOENT') return; if (err && err.code === 'ENOENT') return;
fail(err); fail(err);
}); });
archive.on('progress', (progress) => { archive.on('progress', (progress) => {
const totalBytes = progress && progress.fs ? progress.fs.totalBytes : 0; const totalBytes = progress && progress.fs ? progress.fs.totalBytes : 0;
const processedBytes = progress && progress.fs ? progress.fs.processedBytes : 0; const processedBytes = progress && progress.fs ? progress.fs.processedBytes : 0;
if (!totalBytes) return; if (!totalBytes) return;
const ratio = Math.max(0, Math.min(1, processedBytes / totalBytes)); const ratio = Math.max(0, Math.min(1, processedBytes / totalBytes));
const percent = startPercent + ((endPercent - startPercent) * ratio); const percent = startPercent + ((endPercent - startPercent) * ratio);
emitProgress(onProgress, mode, percent, detail + ' ...'); emitProgress(onProgress, mode, percent, detail + ' ...');
}); });
archive.pipe(output); archive.pipe(output);
if (fs.existsSync(instancesDir)) archive.directory(instancesDir, 'instances'); if (fs.existsSync(instancesDir)) archive.directory(instancesDir, 'instances');
else archive.append('', { name: 'instances/.keep' }); else archive.append('', { name: 'instances/.keep' });
if (includeSettings && settingsFile && fs.existsSync(settingsFile)) { if (includeSettings && settingsFile && fs.existsSync(settingsFile)) {
archive.file(settingsFile, { name: 'settings.json' }); archive.file(settingsFile, { name: 'settings.json' });
} }
archive.append(`${JSON.stringify(backupInfo, null, 2)}\n`, { name: 'backup-info.json' }); archive.append(`${JSON.stringify(backupInfo, null, 2)}\n`, { name: 'backup-info.json' });
try { try {
const finalizeResult = archive.finalize(); const finalizeResult = archive.finalize();
if (finalizeResult && typeof finalizeResult.catch === 'function') finalizeResult.catch(fail); if (finalizeResult && typeof finalizeResult.catch === 'function') finalizeResult.catch(fail);
} catch (err) { } catch (err) {
fail(err); fail(err);
} }
}); });
} }
function copyDirContentsTracked(sourceDir, targetDir, options) { function copyDirContentsTracked(sourceDir, targetDir, options) {
const opts = options || {}; const opts = options || {};
const mode = opts.mode || 'create'; const mode = opts.mode || 'create';
const onProgress = opts.onProgress; const onProgress = opts.onProgress;
const startPercent = opts.startPercent ?? 0; const startPercent = opts.startPercent ?? 0;
const endPercent = opts.endPercent ?? 100; const endPercent = opts.endPercent ?? 100;
const detailPrefix = opts.detailPrefix || 'Kopiere'; const detailPrefix = opts.detailPrefix || 'Kopiere';
ensureDir(targetDir); ensureDir(targetDir);
if (!fs.existsSync(sourceDir)) { if (!fs.existsSync(sourceDir)) {
emitProgress(onProgress, mode, endPercent, detailPrefix + ' abgeschlossen'); emitProgress(onProgress, mode, endPercent, detailPrefix + ' abgeschlossen');
return; return;
} }
const entries = fs.readdirSync(sourceDir, { withFileTypes: true }); const entries = fs.readdirSync(sourceDir, { withFileTypes: true });
if (!entries.length) { if (!entries.length) {
emitProgress(onProgress, mode, endPercent, detailPrefix + ' abgeschlossen'); emitProgress(onProgress, mode, endPercent, detailPrefix + ' abgeschlossen');
return; return;
} }
entries.forEach((entry, index) => { entries.forEach((entry, index) => {
fs.cpSync(path.join(sourceDir, entry.name), path.join(targetDir, entry.name), { recursive: true }); fs.cpSync(path.join(sourceDir, entry.name), path.join(targetDir, entry.name), { recursive: true });
const ratio = (index + 1) / entries.length; const ratio = (index + 1) / entries.length;
const percent = startPercent + ((endPercent - startPercent) * ratio); const percent = startPercent + ((endPercent - startPercent) * ratio);
emitProgress(onProgress, mode, percent, `${detailPrefix}: ${entry.name}`); emitProgress(onProgress, mode, percent, `${detailPrefix}: ${entry.name}`);
}); });
} }
function moveDirTracked(sourceDir, targetDir, options) { function moveDirTracked(sourceDir, targetDir, options) {
const opts = options || {}; const opts = options || {};
const mode = opts.mode || 'restore'; const mode = opts.mode || 'restore';
const onProgress = opts.onProgress; const onProgress = opts.onProgress;
const startPercent = opts.startPercent ?? 0; const startPercent = opts.startPercent ?? 0;
const endPercent = opts.endPercent ?? 100; const endPercent = opts.endPercent ?? 100;
const detailPrefix = opts.detailPrefix || 'Verschiebe'; const detailPrefix = opts.detailPrefix || 'Verschiebe';
if (!fs.existsSync(sourceDir)) { if (!fs.existsSync(sourceDir)) {
emitProgress(onProgress, mode, endPercent, detailPrefix + ' abgeschlossen'); emitProgress(onProgress, mode, endPercent, detailPrefix + ' abgeschlossen');
return; return;
} }
ensureDir(path.dirname(targetDir)); ensureDir(path.dirname(targetDir));
try { try {
fs.renameSync(sourceDir, targetDir); fs.renameSync(sourceDir, targetDir);
emitProgress(onProgress, mode, endPercent, detailPrefix + ' abgeschlossen'); emitProgress(onProgress, mode, endPercent, detailPrefix + ' abgeschlossen');
return; return;
} catch (err) { } catch (err) {
if (!err || !['EXDEV', 'EPERM', 'EACCES'].includes(err.code)) throw err; if (!err || !['EXDEV', 'EPERM', 'EACCES'].includes(err.code)) throw err;
} }
copyDirContentsTracked(sourceDir, targetDir, opts); copyDirContentsTracked(sourceDir, targetDir, opts);
try { fs.rmSync(sourceDir, { recursive: true, force: true }); } catch { /* ignore */ } try { fs.rmSync(sourceDir, { recursive: true, force: true }); } catch { /* ignore */ }
} }
function resolveExtractedBackupRoot(extractDir) { function resolveExtractedBackupRoot(extractDir) {
if (fs.existsSync(path.join(extractDir, 'instances'))) return extractDir; if (fs.existsSync(path.join(extractDir, 'instances'))) return extractDir;
const entries = fs.readdirSync(extractDir, { withFileTypes: true }).filter((entry) => entry.isDirectory()); const entries = fs.readdirSync(extractDir, { withFileTypes: true }).filter((entry) => entry.isDirectory());
if (entries.length === 1) { if (entries.length === 1) {
const nested = path.join(extractDir, entries[0].name); const nested = path.join(extractDir, entries[0].name);
if (fs.existsSync(path.join(nested, 'instances'))) return nested; if (fs.existsSync(path.join(nested, 'instances'))) return nested;
} }
return null; return null;
} }
async function createGlobalBackup(options) { async function createGlobalBackup(options) {
const instancesDir = options && options.instancesDir; const instancesDir = options && options.instancesDir;
const userDataDir = options && options.userDataDir; const userDataDir = options && options.userDataDir;
const destinationPath = options && options.destinationPath; const destinationPath = options && options.destinationPath;
const includeSettings = !options || options.includeSettings !== false; const includeSettings = !options || options.includeSettings !== false;
const onProgress = options && options.onProgress; const onProgress = options && options.onProgress;
if (!instancesDir || !userDataDir || !destinationPath) throw new Error('Backup-Parameter unvollständig.'); if (!instancesDir || !userDataDir || !destinationPath) throw new Error('Backup-Parameter unvollständig.');
const settingsFile = path.join(userDataDir, 'settings.json'); const settingsFile = path.join(userDataDir, 'settings.json');
const workingArchivePath = destinationPath.replace(/\.zip$/i, '') + '.partial.zip'; const workingArchivePath = destinationPath.replace(/\.zip$/i, '') + '.partial.zip';
const backupInfo = { const backupInfo = {
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
instanceCount: countInstanceDirs(instancesDir), instanceCount: countInstanceDirs(instancesDir),
includeSettings, includeSettings,
app: 'AeroMC', app: 'AeroMC',
version: 1, version: 1,
}; };
try { try {
emitProgress(onProgress, 'create', 5, 'Bereite Backup vor'); emitProgress(onProgress, 'create', 5, 'Bereite Backup vor');
emitProgress(onProgress, 'create', 10, 'Ermittle Backup-Inhalt'); emitProgress(onProgress, 'create', 10, 'Ermittle Backup-Inhalt');
if (fs.existsSync(destinationPath)) fs.unlinkSync(destinationPath); if (fs.existsSync(destinationPath)) fs.unlinkSync(destinationPath);
if (fs.existsSync(workingArchivePath)) fs.unlinkSync(workingArchivePath); if (fs.existsSync(workingArchivePath)) fs.unlinkSync(workingArchivePath);
emitProgress(onProgress, 'create', 90, 'Packe ZIP-Archiv'); emitProgress(onProgress, 'create', 90, 'Packe ZIP-Archiv');
await createBackupArchive({ await createBackupArchive({
instancesDir, instancesDir,
settingsFile, settingsFile,
includeSettings, includeSettings,
backupInfo, backupInfo,
destinationPath: workingArchivePath, destinationPath: workingArchivePath,
mode: 'create', mode: 'create',
onProgress, onProgress,
startPercent: 15, startPercent: 15,
endPercent: 98, endPercent: 98,
detail: 'Packe ZIP-Archiv', detail: 'Packe ZIP-Archiv',
}); });
emitProgress(onProgress, 'create', 99, 'Finalisiere Backup'); emitProgress(onProgress, 'create', 99, 'Finalisiere Backup');
fs.renameSync(workingArchivePath, destinationPath); fs.renameSync(workingArchivePath, destinationPath);
emitProgress(onProgress, 'create', 100, 'Backup abgeschlossen'); emitProgress(onProgress, 'create', 100, 'Backup abgeschlossen');
return { return {
ok: true, ok: true,
path: destinationPath, path: destinationPath,
instanceCount: countInstanceDirs(instancesDir), instanceCount: countInstanceDirs(instancesDir),
includedSettings: includeSettings && fs.existsSync(settingsFile), includedSettings: includeSettings && fs.existsSync(settingsFile),
}; };
} finally { } finally {
try { try {
if (fs.existsSync(workingArchivePath)) fs.rmSync(workingArchivePath, { force: true }); if (fs.existsSync(workingArchivePath)) fs.rmSync(workingArchivePath, { force: true });
} catch { /* ignore */ } } catch { /* ignore */ }
} }
} }
async function restoreGlobalBackup(options) { async function restoreGlobalBackup(options) {
const instancesDir = options && options.instancesDir; const instancesDir = options && options.instancesDir;
const userDataDir = options && options.userDataDir; const userDataDir = options && options.userDataDir;
const sourcePath = options && options.sourcePath; const sourcePath = options && options.sourcePath;
const includeSettings = !!(options && options.includeSettings); const includeSettings = !!(options && options.includeSettings);
const onProgress = options && options.onProgress; const onProgress = options && options.onProgress;
const instancesDirSetting = options && Object.prototype.hasOwnProperty.call(options, 'instancesDirSetting') const instancesDirSetting = options && Object.prototype.hasOwnProperty.call(options, 'instancesDirSetting')
? options.instancesDirSetting ? options.instancesDirSetting
: null; : null;
if (!instancesDir || !userDataDir || !sourcePath) throw new Error('Restore-Parameter unvollständig.'); if (!instancesDir || !userDataDir || !sourcePath) throw new Error('Restore-Parameter unvollständig.');
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'aeromc-restore-')); const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'aeromc-restore-'));
const extractDir = path.join(tmp, 'extract'); const extractDir = path.join(tmp, 'extract');
const rollbackDir = path.join(tmp, 'rollback'); const rollbackDir = path.join(tmp, 'rollback');
const rollbackInstances = path.join(rollbackDir, 'instances'); const rollbackInstances = path.join(rollbackDir, 'instances');
const settingsFile = path.join(userDataDir, 'settings.json'); const settingsFile = path.join(userDataDir, 'settings.json');
const rollbackSettings = path.join(rollbackDir, 'settings.json'); const rollbackSettings = path.join(rollbackDir, 'settings.json');
let settingsPreviouslyExisted = false; let settingsPreviouslyExisted = false;
// wird erst true, sobald der aktuelle Stand tatsächlich weggesichert wurde -
try { // vorher darf der Rollback nichts anfassen, sonst löscht ein früher Fehler
ensureDir(extractDir); // (z. B. ungültiges Archiv) den unveränderten, intakten Originalbestand
emitProgress(onProgress, 'restore', 5, 'Entpacke Backup'); let originalMovedAway = false;
await extractBackupArchive(sourcePath, extractDir, {
mode: 'restore', try {
onProgress, ensureDir(extractDir);
startPercent: 5, emitProgress(onProgress, 'restore', 5, 'Entpacke Backup');
endPercent: 18, await extractBackupArchive(sourcePath, extractDir, {
detail: 'Entpacke Backup', mode: 'restore',
}); onProgress,
startPercent: 5,
const backupRoot = resolveExtractedBackupRoot(extractDir); endPercent: 18,
if (!backupRoot) throw new Error('Das Archiv enthält kein gültiges AeroMC-Backup.'); detail: 'Entpacke Backup',
emitProgress(onProgress, 'restore', 18, 'Backup geprüft'); });
const sourceInstances = path.join(backupRoot, 'instances'); const backupRoot = resolveExtractedBackupRoot(extractDir);
const sourceSettings = path.join(backupRoot, 'settings.json'); if (!backupRoot) throw new Error('Das Archiv enthält kein gültiges AeroMC-Backup.');
ensureDir(rollbackDir); emitProgress(onProgress, 'restore', 18, 'Backup geprüft');
emitProgress(onProgress, 'restore', 25, 'Sichere aktuellen Stand'); const sourceInstances = path.join(backupRoot, 'instances');
moveDirTracked(instancesDir, rollbackInstances, { const sourceSettings = path.join(backupRoot, 'settings.json');
mode: 'restore', ensureDir(rollbackDir);
onProgress,
startPercent: 28, emitProgress(onProgress, 'restore', 25, 'Sichere aktuellen Stand');
endPercent: 40, moveDirTracked(instancesDir, rollbackInstances, {
detailPrefix: 'Sichere aktuelle Instanzen', mode: 'restore',
}); onProgress,
settingsPreviouslyExisted = fs.existsSync(settingsFile); startPercent: 28,
if (settingsPreviouslyExisted) fs.copyFileSync(settingsFile, rollbackSettings); endPercent: 40,
detailPrefix: 'Sichere aktuelle Instanzen',
ensureDir(path.dirname(instancesDir)); });
moveDirTracked(sourceInstances, instancesDir, { originalMovedAway = true;
mode: 'restore', settingsPreviouslyExisted = fs.existsSync(settingsFile);
onProgress, if (settingsPreviouslyExisted) fs.copyFileSync(settingsFile, rollbackSettings);
startPercent: 45,
endPercent: 78, ensureDir(path.dirname(instancesDir));
detailPrefix: 'Stelle Instanzen wieder her', moveDirTracked(sourceInstances, instancesDir, {
}); mode: 'restore',
onProgress,
let restoredSettings = false; startPercent: 45,
if (includeSettings && fs.existsSync(sourceSettings)) { endPercent: 78,
const restored = readJson(sourceSettings, {}); detailPrefix: 'Stelle Instanzen wieder her',
restored.instancesDir = instancesDirSetting; });
writeJson(settingsFile, restored);
restoredSettings = true; let restoredSettings = false;
emitProgress(onProgress, 'restore', 90, 'Stelle Einstellungen wieder her'); if (includeSettings && fs.existsSync(sourceSettings)) {
} else { const restored = readJson(sourceSettings, {});
emitProgress(onProgress, 'restore', 90, 'Einstellungen übersprungen'); restored.instancesDir = instancesDirSetting;
} writeJson(settingsFile, restored);
restoredSettings = true;
emitProgress(onProgress, 'restore', 100, 'Wiederherstellen abgeschlossen'); emitProgress(onProgress, 'restore', 90, 'Stelle Einstellungen wieder her');
} else {
return { emitProgress(onProgress, 'restore', 90, 'Einstellungen übersprungen');
ok: true, }
path: sourcePath,
instanceCount: countInstanceDirs(instancesDir), emitProgress(onProgress, 'restore', 100, 'Wiederherstellen abgeschlossen');
restoredSettings,
}; return {
} catch (err) { ok: true,
try { path: sourcePath,
if (fs.existsSync(instancesDir)) fs.rmSync(instancesDir, { recursive: true, force: true }); instanceCount: countInstanceDirs(instancesDir),
if (fs.existsSync(rollbackInstances)) fs.cpSync(rollbackInstances, instancesDir, { recursive: true }); restoredSettings,
else ensureDir(instancesDir); };
} catch (err) {
if (includeSettings) { // Nur zurückrollen, wenn wir den ursprünglichen Bestand überhaupt schon
if (fs.existsSync(rollbackSettings)) fs.copyFileSync(rollbackSettings, settingsFile); // angefasst haben (siehe originalMovedAway oben). Schlägt die
else if (!settingsPreviouslyExisted && fs.existsSync(settingsFile)) fs.rmSync(settingsFile, { force: true }); // Wiederherstellung vorher fehl (ungültiges/beschädigtes Archiv o. Ä.),
} // ist instancesDir noch der unveränderte Originalbestand - den lassen wir
} catch { /* ignore rollback errors */ } // dann bewusst in Ruhe, statt ihn zu löschen.
throw err; if (originalMovedAway) {
} finally { try {
try { fs.rmSync(tmp, { recursive: true, force: true }); } catch { /* ignore */ } if (fs.existsSync(instancesDir)) fs.rmSync(instancesDir, { recursive: true, force: true });
} if (fs.existsSync(rollbackInstances)) fs.cpSync(rollbackInstances, instancesDir, { recursive: true });
} else ensureDir(instancesDir);
module.exports = { if (includeSettings) {
createGlobalBackup, if (fs.existsSync(rollbackSettings)) fs.copyFileSync(rollbackSettings, settingsFile);
restoreGlobalBackup, else if (!settingsPreviouslyExisted && fs.existsSync(settingsFile)) fs.rmSync(settingsFile, { force: true });
defaultBackupFileName, }
defaultBackupDir, } catch { /* ignore rollback errors */ }
listGlobalBackups, }
resolveAutoBackupPath, throw err;
} finally {
try { fs.rmSync(tmp, { recursive: true, force: true }); } catch { /* ignore */ }
}
}
module.exports = {
createGlobalBackup,
restoreGlobalBackup,
defaultBackupFileName,
defaultBackupDir,
listGlobalBackups,
resolveAutoBackupPath,
}; };
+195 -195
View File
@@ -1,196 +1,196 @@
'use strict'; 'use strict';
/* /*
* cfimport.js Instanzen aus dem CurseForge-Launcher übernehmen * cfimport.js Instanzen aus dem CurseForge-Launcher übernehmen
* --------------------------------------------------------------- * ---------------------------------------------------------------
* Unterstützt sowohl den Launcher-Wurzelordner als auch direkt den * Unterstützt sowohl den Launcher-Wurzelordner als auch direkt den
* Instances-Ordner. Gelesen werden nach Möglichkeit minecraftinstance.json * Instances-Ordner. Gelesen werden nach Möglichkeit minecraftinstance.json
* und die vorhandenen Spieldaten im Profilordner. * und die vorhandenen Spieldaten im Profilordner.
*/ */
const fs = require('fs'); const fs = require('fs');
const os = require('os'); const os = require('os');
const path = require('path'); const path = require('path');
const META_FILES = ['minecraftinstance.json', 'instance.json']; const META_FILES = ['minecraftinstance.json', 'instance.json'];
function readJson(file, fallback) { function readJson(file, fallback) {
try { try {
return JSON.parse(fs.readFileSync(file, 'utf8').replace(/^\uFEFF/, '')); return JSON.parse(fs.readFileSync(file, 'utf8').replace(/^\uFEFF/, ''));
} catch { } catch {
return fallback; return fallback;
} }
} }
function existingMetaFile(dir) { function existingMetaFile(dir) {
return META_FILES.map((name) => path.join(dir, name)).find((file) => fs.existsSync(file)) || null; return META_FILES.map((name) => path.join(dir, name)).find((file) => fs.existsSync(file)) || null;
} }
function metaVersion(meta) { function metaVersion(meta) {
return String( return String(
(meta && ( (meta && (
meta.gameVersion || meta.minecraftVersion || meta.mcVersion || meta.gameVersion || meta.minecraftVersion || meta.mcVersion ||
(meta.installedModpack && meta.installedModpack.gameVersion) (meta.installedModpack && meta.installedModpack.gameVersion)
)) || '' )) || ''
).trim(); ).trim();
} }
function resolveInstancesDir(inputPath) { function resolveInstancesDir(inputPath) {
if (!inputPath || !fs.existsSync(inputPath)) return null; if (!inputPath || !fs.existsSync(inputPath)) return null;
const directNames = new Set(['instances', 'minecraftinstances']); const directNames = new Set(['instances', 'minecraftinstances']);
if (directNames.has(path.basename(inputPath).toLowerCase())) return inputPath; if (directNames.has(path.basename(inputPath).toLowerCase())) return inputPath;
const candidates = [ const candidates = [
path.join(inputPath, 'Instances'), path.join(inputPath, 'Instances'),
path.join(inputPath, 'instances'), path.join(inputPath, 'instances'),
path.join(inputPath, 'minecraftInstances'), path.join(inputPath, 'minecraftInstances'),
path.join(inputPath, 'Minecraft', 'Instances'), path.join(inputPath, 'Minecraft', 'Instances'),
path.join(inputPath, 'minecraft', 'Instances'), path.join(inputPath, 'minecraft', 'Instances'),
]; ];
const looksLikeWindowsInstall = const looksLikeWindowsInstall =
/curseforge windows/i.test(inputPath) || /curseforge windows/i.test(inputPath) ||
(fs.existsSync(path.join(inputPath, 'CurseForge.exe')) && fs.existsSync(path.join(inputPath, 'resources'))); (fs.existsSync(path.join(inputPath, 'CurseForge.exe')) && fs.existsSync(path.join(inputPath, 'resources')));
if (looksLikeWindowsInstall) { if (looksLikeWindowsInstall) {
candidates.push( candidates.push(
path.join(os.homedir(), 'curseforge', 'minecraft', 'Instances'), path.join(os.homedir(), 'curseforge', 'minecraft', 'Instances'),
path.join(os.homedir(), 'CurseForge', 'minecraft', 'Instances'), path.join(os.homedir(), 'CurseForge', 'minecraft', 'Instances'),
); );
} }
return candidates.find((candidate) => fs.existsSync(candidate)) || null; return candidates.find((candidate) => fs.existsSync(candidate)) || null;
} }
function normalizeLoaderVersion(loader, rawValue, mcVersion) { function normalizeLoaderVersion(loader, rawValue, mcVersion) {
let value = String(rawValue || '').trim(); let value = String(rawValue || '').trim();
if (!value) return ''; if (!value) return '';
if (loader === 'forge') { if (loader === 'forge') {
value = value.replace(/^forge-/i, ''); value = value.replace(/^forge-/i, '');
if (mcVersion && value.startsWith(mcVersion + '-')) value = value.slice(mcVersion.length + 1); if (mcVersion && value.startsWith(mcVersion + '-')) value = value.slice(mcVersion.length + 1);
return value; return value;
} }
if (loader === 'neoforge') { if (loader === 'neoforge') {
return value.replace(/^neoforge-/i, ''); return value.replace(/^neoforge-/i, '');
} }
if (loader === 'fabric') { if (loader === 'fabric') {
value = value.replace(/^fabric(?:-loader)?-/i, ''); value = value.replace(/^fabric(?:-loader)?-/i, '');
if (mcVersion && value.endsWith('-' + mcVersion)) value = value.slice(0, -1 * (mcVersion.length + 1)); if (mcVersion && value.endsWith('-' + mcVersion)) value = value.slice(0, -1 * (mcVersion.length + 1));
return value; return value;
} }
if (loader === 'quilt') { if (loader === 'quilt') {
value = value.replace(/^quilt(?:-loader)?-/i, ''); value = value.replace(/^quilt(?:-loader)?-/i, '');
if (mcVersion && value.endsWith('-' + mcVersion)) value = value.slice(0, -1 * (mcVersion.length + 1)); if (mcVersion && value.endsWith('-' + mcVersion)) value = value.slice(0, -1 * (mcVersion.length + 1));
return value; return value;
} }
return value; return value;
} }
function detectLoader(meta, mcVersion) { function detectLoader(meta, mcVersion) {
const raw = meta && ( const raw = meta && (
meta.baseModLoader || meta.modLoader || meta.modloader || meta.loader || meta.modLoaderId meta.baseModLoader || meta.modLoader || meta.modloader || meta.loader || meta.modLoaderId
); );
const value = typeof raw === 'object' const value = typeof raw === 'object'
? (raw.name || raw.id || raw.value || raw.version || '') ? (raw.name || raw.id || raw.value || raw.version || '')
: (raw || ''); : (raw || '');
const lower = String(value).toLowerCase(); const lower = String(value).toLowerCase();
const objectVersion = raw && typeof raw === 'object' const objectVersion = raw && typeof raw === 'object'
? (raw.version || raw.name || raw.id || '') ? (raw.version || raw.name || raw.id || '')
: ''; : '';
if (!lower) return { loader: 'vanilla', loaderVersion: '' }; if (!lower) return { loader: 'vanilla', loaderVersion: '' };
if (lower.includes('neoforge')) return { loader: 'neoforge', loaderVersion: normalizeLoaderVersion('neoforge', objectVersion || value, mcVersion) }; 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('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('quilt')) return { loader: 'quilt', loaderVersion: normalizeLoaderVersion('quilt', objectVersion || value, mcVersion) };
if (lower.includes('forge')) return { loader: 'forge', loaderVersion: normalizeLoaderVersion('forge', objectVersion || value, mcVersion) }; if (lower.includes('forge')) return { loader: 'forge', loaderVersion: normalizeLoaderVersion('forge', objectVersion || value, mcVersion) };
return { loader: 'vanilla', loaderVersion: '' }; return { loader: 'vanilla', loaderVersion: '' };
} }
function detectVersion(meta, loaderVersion) { function detectVersion(meta, loaderVersion) {
return metaVersion(meta) || (loaderVersion && loaderVersion.includes('-') ? loaderVersion.split('-')[0] : ''); return metaVersion(meta) || (loaderVersion && loaderVersion.includes('-') ? loaderVersion.split('-')[0] : '');
} }
function findGameDir(dir) { function findGameDir(dir) {
const nested = ['minecraft', '.minecraft'] const nested = ['minecraft', '.minecraft']
.map((name) => path.join(dir, name)) .map((name) => path.join(dir, name))
.find((candidate) => fs.existsSync(candidate)); .find((candidate) => fs.existsSync(candidate));
if (nested) return nested; if (nested) return nested;
const markers = ['mods', 'config', 'resourcepacks', 'shaderpacks', 'saves', 'options.txt']; const markers = ['mods', 'config', 'resourcepacks', 'shaderpacks', 'saves', 'options.txt'];
return markers.some((name) => fs.existsSync(path.join(dir, name))) ? dir : null; return markers.some((name) => fs.existsSync(path.join(dir, name))) ? dir : null;
} }
function scan(inputPath) { function scan(inputPath) {
const instancesDir = resolveInstancesDir(inputPath); const instancesDir = resolveInstancesDir(inputPath);
if (!instancesDir) return { ok: false, reason: 'not-found' }; if (!instancesDir) return { ok: false, reason: 'not-found' };
const list = []; const list = [];
for (const entry of fs.readdirSync(instancesDir, { withFileTypes: true })) { for (const entry of fs.readdirSync(instancesDir, { withFileTypes: true })) {
if (!entry.isDirectory()) continue; if (!entry.isDirectory()) continue;
const dir = path.join(instancesDir, entry.name); const dir = path.join(instancesDir, entry.name);
const metaFile = existingMetaFile(dir); const metaFile = existingMetaFile(dir);
const meta = metaFile ? readJson(metaFile, {}) : {}; const meta = metaFile ? readJson(metaFile, {}) : {};
const gameDir = findGameDir(dir); const gameDir = findGameDir(dir);
if (!metaFile && !gameDir) continue; if (!metaFile && !gameDir) continue;
const versionHint = metaVersion(meta); const versionHint = metaVersion(meta);
const loaderInfo = detectLoader(meta, versionHint); const loaderInfo = detectLoader(meta, versionHint);
list.push({ list.push({
folder: entry.name, folder: entry.name,
dir, dir,
name: meta.name || meta.displayName || entry.name, name: meta.name || meta.displayName || entry.name,
group: '', group: '',
notes: meta.notes || meta.summary || '', notes: meta.notes || meta.summary || '',
version: versionHint || detectVersion(meta, loaderInfo.loaderVersion), version: versionHint || detectVersion(meta, loaderInfo.loaderVersion),
loader: loaderInfo.loader, loader: loaderInfo.loader,
loaderVersion: loaderInfo.loaderVersion, loaderVersion: loaderInfo.loaderVersion,
javaPath: String(meta.javaPath || meta.javaExecutable || '').trim(), javaPath: String(meta.javaPath || meta.javaExecutable || '').trim(),
minMemMb: Number(meta.minimumMemory || meta.minMemory || meta.minMemAlloc) || null, minMemMb: Number(meta.minimumMemory || meta.minMemory || meta.minMemAlloc) || null,
maxMemMb: Number(meta.maximumMemory || meta.maxMemory || meta.maxMemAlloc || meta.allocatedMemory) || null, maxMemMb: Number(meta.maximumMemory || meta.maxMemory || meta.maxMemAlloc || meta.allocatedMemory) || null,
gameDir: gameDir || dir, gameDir: gameDir || dir,
hasGameData: !!gameDir, hasGameData: !!gameDir,
}); });
} }
list.sort((a, b) => a.name.localeCompare(b.name)); list.sort((a, b) => a.name.localeCompare(b.name));
return { ok: true, instancesDir, count: list.length, instances: list }; return { ok: true, instancesDir, count: list.length, instances: list };
} }
function copyInto(sourceDir, targetDir) { function copyInto(sourceDir, targetDir) {
fs.mkdirSync(targetDir, { recursive: true }); fs.mkdirSync(targetDir, { recursive: true });
for (const name of fs.readdirSync(sourceDir)) { for (const name of fs.readdirSync(sourceDir)) {
if (META_FILES.includes(name)) continue; if (META_FILES.includes(name)) continue;
fs.cpSync(path.join(sourceDir, name), path.join(targetDir, name), { recursive: true }); fs.cpSync(path.join(sourceDir, name), path.join(targetDir, name), { recursive: true });
} }
} }
function importOne(store, entry, copyData = true) { function importOne(store, entry, copyData = true) {
const created = store.createOrReplaceImportedInstance({ const created = store.createOrReplaceImportedInstance({
name: entry.name, name: entry.name,
group: entry.group, group: entry.group,
notes: entry.notes, notes: entry.notes,
minecraft: { minecraft: {
version: entry.version, version: entry.version,
loader: entry.loader, loader: entry.loader,
loaderVersion: entry.loaderVersion, loaderVersion: entry.loaderVersion,
}, },
}); });
const patch = {}; const patch = {};
if (entry.javaPath || entry.minMemMb || entry.maxMemMb) { if (entry.javaPath || entry.minMemMb || entry.maxMemMb) {
patch.java = { patch.java = {
path: entry.javaPath || '', path: entry.javaPath || '',
minMemMb: entry.minMemMb || null, minMemMb: entry.minMemMb || null,
maxMemMb: entry.maxMemMb || null, maxMemMb: entry.maxMemMb || null,
extraArgs: '', extraArgs: '',
}; };
} }
if (Object.keys(patch).length) store.updateInstance(created.id, patch); if (Object.keys(patch).length) store.updateInstance(created.id, patch);
let copied = false; let copied = false;
if (copyData && entry.gameDir && fs.existsSync(entry.gameDir)) { if (copyData && entry.gameDir && fs.existsSync(entry.gameDir)) {
copyInto(entry.gameDir, store.gameDir(created.id)); copyInto(entry.gameDir, store.gameDir(created.id));
copied = true; copied = true;
} }
return { id: created.id, name: created.name, copied }; return { id: created.id, name: created.name, copied };
} }
module.exports = { scan, importOne, resolveInstancesDir }; module.exports = { scan, importOne, resolveInstancesDir };
+52 -2
View File
@@ -130,6 +130,7 @@
<option value="forge">Forge</option> <option value="forge">Forge</option>
<option value="neoforge">NeoForge</option> <option value="neoforge">NeoForge</option>
<option value="quilt">Quilt</option> <option value="quilt">Quilt</option>
<option value="optifine">OptiFine</option>
</select> </select>
</label> </label>
<label class="field"> <label class="field">
@@ -139,6 +140,23 @@
</label> </label>
</div> </div>
<div id="f-optifine-tools" class="field-row hidden" style="align-items:flex-start">
<div class="field" style="flex:1">
<span>OptiFine einrichten</span>
<p class="hint" style="margin:2px 0 8px">
OptiFine bietet kein automatisches Download-API (Downloads laufen bei OptiFine bewusst über eine Werbeseite).
Lade den Installer zuerst selbst von <b>optifine.net</b> herunter, wähle ihn dann hier aus AeroMC bereitet
die passende Minecraft-Version vor und startet den echten OptiFine-Installer. Wähle darin als Zielordner den
automatisch in die Zwischenablage kopierten Pfad, klicke „Install" und bestätige danach hier.
</p>
<div class="path-row">
<button type="button" id="f-optifine-install" class="btn">Installer wählen und starten…</button>
<button type="button" id="f-optifine-confirm" class="btn btn-primary hidden">Fertig Version übernehmen</button>
</div>
<small id="f-optifine-status" class="hint"></small>
</div>
</div>
<label class="field"> <label class="field">
<span>Notizen <small class="opt">(optional)</small></span> <span>Notizen <small class="opt">(optional)</small></span>
<textarea id="f-notes" rows="2" placeholder="Modliste, Server-IP, To-dos …"></textarea> <textarea id="f-notes" rows="2" placeholder="Modliste, Server-IP, To-dos …"></textarea>
@@ -265,6 +283,14 @@
</label> </label>
<small class="hint">Erscheint nur, während das Launcher-Fenster nicht im Vordergrund ist.</small> <small class="hint">Erscheint nur, während das Launcher-Fenster nicht im Vordergrund ist.</small>
</section> </section>
<section class="settings-card">
<h5>Autostart</h5>
<label class="checkbox-field">
<input id="s-autostart" type="checkbox" /> <span>Mit Windows starten (im Infobereich)</span>
</label>
<small class="hint">Startet AeroMC beim Anmelden automatisch im Hintergrund. Solange aktiv, landet der Launcher beim Schließen (X) nur im Infobereich neben der Uhr, statt sich zu beenden über das Symbol dort lässt er sich jederzeit wieder öffnen oder wirklich beenden.</small>
</section>
</div> </div>
</div> </div>
@@ -634,6 +660,7 @@
<option value="mod">Mods</option> <option value="mod">Mods</option>
<option value="resourcepack">Ressourcenpakete</option> <option value="resourcepack">Ressourcenpakete</option>
<option value="shader">Shader</option> <option value="shader">Shader</option>
<option value="datapack">Datenpakete</option>
</select> </select>
<input id="m-query" type="text" placeholder="Mods durchsuchen … (z. B. Sodium, JEI, Create)" /> <input id="m-query" type="text" placeholder="Mods durchsuchen … (z. B. Sodium, JEI, Create)" />
<button id="m-search-btn" class="btn btn-primary">Suchen</button> <button id="m-search-btn" class="btn btn-primary">Suchen</button>
@@ -958,6 +985,22 @@
</div> </div>
</div> </div>
<!-- ===================== Dialog: Lizenz ===================== -->
<div id="modal-license" class="modal hidden">
<div class="modal-card modal-lg">
<div class="modal-head">
<h3>Lizenz</h3>
<button class="modal-close" data-close></button>
</div>
<div class="modal-body">
<pre id="license-text" class="crash-voll">Lade …</pre>
</div>
<div class="modal-foot">
<button class="btn" data-close>Schließen</button>
</div>
</div>
</div>
<!-- ===================== Dialog: Update ===================== --> <!-- ===================== Dialog: Update ===================== -->
<div id="modal-update" class="modal hidden"> <div id="modal-update" class="modal hidden">
<div class="modal-card modal-sm"> <div class="modal-card modal-sm">
@@ -1011,11 +1054,18 @@
<div id="import-pane-modpack" class="import-pane hidden"> <div id="import-pane-modpack" class="import-pane hidden">
<p class="import-copy">Modpack aus dem Katalog installieren oder als Datei importieren.</p> <p class="import-copy">Modpack aus dem Katalog installieren oder als Datei importieren.</p>
<p class="hint">Suche läuft über Modrinth. Datei-Import unterstützt zusätzlich CurseForge-ZIP (manifest.json) dafür ist ein API-Key oder eine Weiterleitung in den Netzwerk-Einstellungen nötig.</p> <p class="hint">Katalog-Suche läuft über Modrinth oder CurseForge. Für CurseForge wird ein eigener API-Key in den Netzwerk-Einstellungen gebraucht (die Weiterleitung reicht nur für den Datei-Import). Datei-Import unterstützt zusätzlich CurseForge-ZIP (manifest.json).</p>
<div class="field-row" style="margin-top:12px;align-items:flex-end"> <div class="field-row" style="margin-top:12px;align-items:flex-end">
<label class="field" style="width:150px">
<span>Quelle</span>
<select id="mp-source">
<option value="modrinth">Modrinth</option>
<option value="curseforge">CurseForge</option>
</select>
</label>
<label class="field" style="flex:1"> <label class="field" style="flex:1">
<span>Suche (Modrinth)</span> <span>Suche</span>
<input id="mp-query" type="text" placeholder="z. B. Fabulously Optimized, Create …" autocomplete="off" /> <input id="mp-query" type="text" placeholder="z. B. Fabulously Optimized, Create …" autocomplete="off" />
</label> </label>
<label class="field" style="width:140px"> <label class="field" style="width:140px">
+31
View File
@@ -464,6 +464,37 @@ async function prepareAndLaunch(opts, onProgress) {
classpath.unshift(path.join(libDir, rel.replace(/\//g, path.sep))); classpath.unshift(path.join(libDir, rel.replace(/\//g, path.sep)));
} }
effectiveVj = mergeLoader(vj, fvj); effectiveVj = mergeLoader(vj, fvj);
} else if (opts.loader === 'optifine') {
/*
* OptiFine hat keine öffentliche Installations-API (Downloads laufen bewusst
* über eine Werbeseite). AeroMC lädt hier nichts selbst herunter der Nutzer
* hat den offiziellen Installer bereits einmal sichtbar laufen lassen (siehe
* optifine:setup/optifine:confirm in main.js), der dabei ein normales
* Versions-Profil unter <sharedDir>/versions/<loaderVersion>/ abgelegt hat,
* genau wie Fabric/Forge es tun. Wir lesen es nur noch ein und mergen es.
*/
const ofId = opts.loaderVersion;
if (!ofId) {
return { ok: false, message: 'Für diese Instanz ist keine OptiFine-Version hinterlegt. Bitte im Bearbeiten-Dialog einrichten.' };
}
const ofJsonPath = path.join(opts.sharedDir, 'versions', ofId, ofId + '.json');
if (!fs.existsSync(ofJsonPath)) {
return { ok: false, message: `OptiFine-Profil "${ofId}" wurde nicht gefunden. Bitte die Einrichtung im Bearbeiten-Dialog wiederholen.` };
}
let ofvj;
try { ofvj = JSON.parse(fs.readFileSync(ofJsonPath, 'utf8')); }
catch (err) { return { ok: false, message: 'OptiFine-Profil ist beschädigt: ' + err.message }; }
for (const lib of ofvj.libraries || []) {
const art = lib.downloads && lib.downloads.artifact;
const rel = (art && art.path) || forge.mavenPath(lib.name);
const dest = path.join(libDir, rel.replace(/\//g, path.sep));
if (!fs.existsSync(dest) && art && art.url) {
await downloadIfNeeded(art.url, dest, art.sha1 || null);
}
classpath.unshift(dest);
}
effectiveVj = mergeLoader(vj, ofvj);
} }
const finalClasspath = dedupeClasspath(classpath, opts.sharedDir); const finalClasspath = dedupeClasspath(classpath, opts.sharedDir);
+338 -20
View File
@@ -6,11 +6,11 @@
* (Vanilla-Start, Microsoft-Login und Mod-Loader folgen in späteren Phasen.) * (Vanilla-Start, Microsoft-Login und Mod-Loader folgen in späteren Phasen.)
*/ */
const { app, BrowserWindow, ipcMain, dialog, shell, safeStorage, Notification } = require('electron'); const { app, BrowserWindow, ipcMain, dialog, shell, safeStorage, Notification, Tray, Menu, nativeImage, clipboard } = require('electron');
const path = require('path'); const path = require('path');
const fs = require('fs'); const fs = require('fs');
const os = require('os'); const os = require('os');
const { execFile } = require('child_process'); const { execFile, spawn } = require('child_process');
const pkg = require('../package.json'); const pkg = require('../package.json');
const store = require('./store'); const store = require('./store');
const services = require('./services'); const services = require('./services');
@@ -124,18 +124,29 @@ function instanzAusArgumenten(argv) {
return null; return null;
} }
// wie instanzAusArgumenten, nur für die optionale Server-Adresse einer
// Server-Verknüpfung ("--server=<ip>") bleibt aus demselben Grund als ein
// einziges "--server=..."-Argument zusammen
function serverAusArgumenten(argv) {
const args = (argv || []).map(String);
for (const a of args) {
if (a.startsWith('--server=')) return a.slice('--server='.length);
}
return null;
}
// Startwunsch an die Oberfläche geben dort hängt die gesamte Rückmeldung dran // Startwunsch an die Oberfläche geben dort hängt die gesamte Rückmeldung dran
function starteInstanzImFenster(id) { function starteInstanzImFenster(id, serverIp) {
if (!id || !mainWindow || mainWindow.isDestroyed()) return; if (!id || !mainWindow || mainWindow.isDestroyed()) return;
mainWindow.webContents.send('instance:launch-request', id); mainWindow.webContents.send('instance:launch-request', { id, serverIp: serverIp || null });
} }
app.on('second-instance', (_e, argv) => { app.on('second-instance', (_e, argv) => {
if (mainWindow && !mainWindow.isDestroyed()) { // showMainWindow() statt nur restore()+focus(): bei aktivem Autostart kann
if (mainWindow.isMinimized()) mainWindow.restore(); // das Fenster echt versteckt sein (hide(), nicht nur minimiert) das
mainWindow.focus(); // braucht ein show(), sonst bleibt es beim zweiten Start unsichtbar
} showMainWindow();
starteInstanzImFenster(instanzAusArgumenten(argv)); starteInstanzImFenster(instanzAusArgumenten(argv), serverAusArgumenten(argv));
}); });
// Ziel für Verknüpfungen: bei der Portable-Fassung zeigt process.execPath auf // Ziel für Verknüpfungen: bei der Portable-Fassung zeigt process.execPath auf
@@ -147,6 +158,31 @@ function verknuepfungsZiel() {
return process.execPath; return process.execPath;
} }
/*
* Baut die Argumente für eine Verknüpfung zusammen.
* Im Entwicklungsmodus (npm start) zeigt process.execPath nur auf die nackte
* electron.exe die weiß ohne Pfadangabe nicht, welche App sie laden soll,
* und zeigt stattdessen ihren eigenen Startbildschirm statt AeroMC. Im
* gepackten Build (Setup/Portable) ist die App fest eingebaut, daher dort
* kein Zusatz nötig.
*/
function verknuepfungsArgs(teile) {
const praefix = app.isPackaged ? '' : `"${path.join(__dirname, '..')}" `;
return praefix + teile.join(' ');
}
/*
* Icon für eine Verknüpfung. Im gepackten Build trägt die EXE selbst schon
* das richtige Icon als Ressource (funktioniert auch aus dem asar heraus).
* Im Entwicklungsmodus zeigt "ziel" auf die nackte electron.exe (Atom-Logo)
* dort stattdessen direkt die eigene .ico-Datei verwenden, die im
* Entwicklungsordner als normale Datei vorliegt.
*/
function verknuepfungsIcon(ziel) {
if (app.isPackaged) return ziel;
return windowIcon() || ziel;
}
function windowIcon() { function windowIcon() {
const png = path.join(__dirname, 'assets', 'logo.png'); const png = path.join(__dirname, 'assets', 'logo.png');
const ico = path.join(__dirname, 'assets', 'icon.ico'); const ico = path.join(__dirname, 'assets', 'icon.ico');
@@ -155,7 +191,7 @@ function windowIcon() {
return undefined; // Electron-Standard, solange kein Logo hinterlegt ist return undefined; // Electron-Standard, solange kein Logo hinterlegt ist
} }
function createWindow() { function createWindow(startHidden) {
mainWindow = new BrowserWindow({ mainWindow = new BrowserWindow({
width: 1200, width: 1200,
height: 820, height: 820,
@@ -165,6 +201,7 @@ function createWindow() {
title: 'AeroMC', title: 'AeroMC',
icon: windowIcon(), icon: windowIcon(),
autoHideMenuBar: true, autoHideMenuBar: true,
show: !startHidden,
webPreferences: { webPreferences: {
preload: path.join(__dirname, 'preload.js'), preload: path.join(__dirname, 'preload.js'),
contextIsolation: true, contextIsolation: true,
@@ -175,13 +212,78 @@ function createWindow() {
mainWindow.loadFile(path.join(__dirname, 'index.html')); mainWindow.loadFile(path.join(__dirname, 'index.html'));
// mit "--launch <id>" gestartet -> Instanz anwerfen, sobald die Oberfläche steht // Bei aktivem Autostart landet das Fenster beim Schließen (X) nur im
// Infobereich, statt AeroMC wirklich zu beenden sonst wäre der
// Autostart witzlos, weil man ihn beim ersten Wegklicken gleich wieder los ist.
mainWindow.on('close', (e) => {
if (appIsQuitting || !store.getSettings().autostart) return;
e.preventDefault();
mainWindow.hide();
});
// mit "--launch <id>" gestartet -> Instanz anwerfen, sobald die Oberfläche
// ihren Empfänger dafür registriert hat. WICHTIG: nicht auf "did-finish-load"
// verlassen das feuert, sobald die Seite geladen ist, aber die Oberfläche
// registriert ihren IPC-Listener erst nach eigenem asynchronem Vorlauf
// (u. a. Skin-Hintergrund laden). Kommt der Startwunsch früher an als diese
// Registrierung, geht er kommentarlos verloren ein Wettlauf, der je nach
// Netzwerk-/Ladegeschwindigkeit mal gewinnt, mal nicht. Deshalb wartet der
// Hauptprozess hier auf ein explizites Bereit-Signal der Oberfläche.
const startId = instanzAusArgumenten(process.argv); const startId = instanzAusArgumenten(process.argv);
const startServer = serverAusArgumenten(process.argv);
if (startId) { if (startId) {
mainWindow.webContents.once('did-finish-load', () => starteInstanzImFenster(startId)); ipcMain.once('renderer:ready', () => starteInstanzImFenster(startId, startServer));
} }
} }
let tray = null;
let appIsQuitting = false;
function showMainWindow() {
if (!mainWindow || mainWindow.isDestroyed()) { createWindow(false); return; }
if (mainWindow.isMinimized()) mainWindow.restore();
mainWindow.show();
mainWindow.focus();
}
function createTray() {
if (tray) return;
try {
const iconPfad = windowIcon();
const bild = iconPfad ? nativeImage.createFromPath(iconPfad) : nativeImage.createEmpty();
tray = new Tray(bild.isEmpty() ? nativeImage.createEmpty() : bild.resize({ width: 16, height: 16 }));
tray.setToolTip('AeroMC');
tray.setContextMenu(Menu.buildFromTemplate([
{ label: 'Öffnen', click: () => showMainWindow() },
{ type: 'separator' },
{ label: 'Beenden', click: () => { appIsQuitting = true; app.quit(); } },
]));
tray.on('click', () => showMainWindow());
} catch { /* z. B. wenn kein Icon vorhanden ist Autostart läuft dann ohne Tray-Symbol weiter */ }
}
function destroyTray() {
if (tray) { tray.destroy(); tray = null; }
}
// Windows-Autostart-Eintrag setzen/entfernen + Tray-Symbol passend ein-/ausblenden
function applyAutostart(enabled) {
try {
// Gleiches Problem wie bei den Desktop-Verknüpfungen (siehe verknuepfungsArgs
// weiter oben): im Entwicklungsmodus zeigt process.execPath nur auf die nackte
// electron.exe, die ohne Pfadangabe ihren eigenen leeren Startbildschirm zeigt
// statt AeroMC zu laden. Zusätzlich: bei der Portable-Fassung zeigt
// process.execPath auf den entpackten Temp-Ordner der wäre morgen tot,
// deshalb wie bei den Verknüpfungen über verknuepfungsZiel() auflösen.
const args = enabled
? (app.isPackaged ? ['--hidden'] : [path.join(__dirname, '..'), '--hidden'])
: [];
app.setLoginItemSettings({ openAtLogin: !!enabled, path: verknuepfungsZiel(), args });
} catch { /* z. B. in portabler Umgebung ohne Schreibrechte auf die Registry */ }
if (enabled) createTray();
else destroyTray();
}
app.whenReady().then(() => { app.whenReady().then(() => {
if (!hatEinzelSperre) return; // zweite Instanz beendet sich selbst if (!hatEinzelSperre) return; // zweite Instanz beendet sich selbst
store.init(app.getPath('userData')); store.init(app.getPath('userData'));
@@ -195,7 +297,12 @@ app.whenReady().then(() => {
decrypt: (b) => safeStorage.decryptString(Buffer.from(b, 'base64')), decrypt: (b) => safeStorage.decryptString(Buffer.from(b, 'base64')),
}); });
applyProxy(store.getSettings()); applyProxy(store.getSettings());
createWindow(); const autostartAn = !!store.getSettings().autostart;
// Autostart-Eintrag/Tray mit der Einstellung synchron halten (falls z. B.
// von Hand an der Registry gedreht wurde oder nach einem Update)
applyAutostart(autostartAn);
const startHidden = autostartAn && process.argv.includes('--hidden');
createWindow(startHidden);
// Java-Suche im Hintergrund vorbereiten -> erster Spielstart ohne Verzögerung // Java-Suche im Hintergrund vorbereiten -> erster Spielstart ohne Verzögerung
setTimeout(() => { try { services.scanJava(); } catch { /* egal */ } }, 800); setTimeout(() => { try { services.scanJava(); } catch { /* egal */ } }, 800);
@@ -255,6 +362,8 @@ app.on('window-all-closed', () => {
if (process.platform !== 'darwin') app.quit(); if (process.platform !== 'darwin') app.quit();
}); });
app.on('before-quit', () => { appIsQuitting = true; });
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Java-Erkennung (für Anzeige in den Einstellungen) // Java-Erkennung (für Anzeige in den Einstellungen)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -277,7 +386,12 @@ function detectJava(javaPath) {
ipcMain.handle('settings:get', () => store.getSettings()); ipcMain.handle('settings:get', () => store.getSettings());
ipcMain.handle('settings:save', (_e, patch) => { const s = store.saveSettings(patch); applyProxy(s); return s; }); ipcMain.handle('settings:save', (_e, patch) => {
const s = store.saveSettings(patch);
applyProxy(s);
if (patch && Object.prototype.hasOwnProperty.call(patch, 'autostart')) applyAutostart(s.autostart);
return s;
});
ipcMain.handle('settings:instancesDir', () => store.getInstancesDir()); ipcMain.handle('settings:instancesDir', () => store.getInstancesDir());
@@ -451,6 +565,111 @@ ipcMain.handle('loaders:versions', async (_e, loader, mcVersion) => {
catch { return []; } catch { return []; }
}); });
// ---------------------------------------------------------------------------
// OptiFine kein offizielles Download-API (Downloads laufen bei OptiFine
// bewusst über eine Werbeseite, das umgehen wir nicht). Stattdessen: Nutzer
// lädt den Installer selbst von optifine.net, AeroMC bereitet Vanilla vor,
// startet den echten Installer sichtbar mit dem passenden Zielordner in der
// Zwischenablage und übernimmt danach die neu entstandene Profil-Version.
// ---------------------------------------------------------------------------
const optifineSetupState = new Map(); // instanceId -> { sharedDir, before: Set<string> }
function scanOptifineVersions(sharedDir, mcVersion) {
const versionsDir = path.join(sharedDir, 'versions');
if (!fs.existsSync(versionsDir)) return [];
const prefix = String(mcVersion || '');
return fs.readdirSync(versionsDir, { withFileTypes: true })
.filter((e) => e.isDirectory())
.map((e) => e.name)
.filter((name) => /optifine/i.test(name) && (!prefix || name.startsWith(prefix)))
.filter((name) => fs.existsSync(path.join(versionsDir, name, name + '.json')))
.sort();
}
ipcMain.handle('optifine:find', (_e, mcVersion) => {
const sharedDir = path.join(app.getPath('userData'), 'shared');
return scanOptifineVersions(sharedDir, mcVersion);
});
ipcMain.handle('optifine:setup', async (_e, id) => {
const inst = store.getInstance(id);
if (!inst) return { ok: false, message: 'Instanz nicht gefunden.' };
if (!inst.minecraft.version) return { ok: false, message: 'Bitte zuerst eine Minecraft-Version wählen.' };
const picked = await dialog.showOpenDialog(mainWindow, {
title: 'OptiFine-Installer wählen (zuvor von optifine.net heruntergeladen)',
properties: ['openFile'],
filters: [{ name: 'OptiFine-Installer', extensions: ['jar'] }],
});
if (picked.canceled || !picked.filePaths.length) return { ok: false, canceled: true };
const installerPath = picked.filePaths[0];
const settings = store.getSettings();
const sharedDir = path.join(app.getPath('userData'), 'shared');
const opts = {
versionId: inst.minecraft.version,
loader: 'vanilla',
loaderVersion: '',
gameDir: store.gameDir(id),
sharedDir,
userDataDir: app.getPath('userData'),
javaPath: inst.java.path || settings.javaPath || '',
minMemMb: inst.java.minMemMb || settings.defaultMinMemMb,
maxMemMb: inst.java.maxMemMb || settings.defaultMaxMemMb,
extraArgs: '',
auth: null,
ensureJava: settings.autoJava === false ? null : (major, onProgress) => javadl.ensureJava(major, onProgress),
};
// Vanilla-Dateien sicherstellen (kein echter Start, da kein Konto übergeben wird)
let prep;
try {
prep = await launcher.prepareAndLaunch(opts, (evt) => {
if (mainWindow && !mainWindow.isDestroyed()) mainWindow.webContents.send('launch:progress', Object.assign({ id }, evt));
});
} catch (err) {
return { ok: false, message: 'Vorbereitung fehlgeschlagen: ' + err.message };
}
if (!prep.needAuth && !prep.ok) return { ok: false, message: prep.message || 'Vorbereitung fehlgeschlagen.' };
const javaExe = (prep.java && prep.java.path) || opts.javaPath || 'java';
optifineSetupState.set(id, {
sharedDir,
before: new Set(scanOptifineVersions(sharedDir, inst.minecraft.version)),
});
try {
const child = spawn(javaExe, ['-jar', installerPath], { detached: true, stdio: 'ignore' });
child.unref();
} catch (err) {
return { ok: false, message: 'Installer konnte nicht gestartet werden: ' + err.message };
}
try { clipboard.writeText(sharedDir); } catch { /* egal */ }
return { ok: true, sharedDir };
});
ipcMain.handle('optifine:confirm', (_e, id) => {
const inst = store.getInstance(id);
if (!inst) return { ok: false, message: 'Instanz nicht gefunden.' };
const state = optifineSetupState.get(id);
const sharedDir = (state && state.sharedDir) || path.join(app.getPath('userData'), 'shared');
const nachher = scanOptifineVersions(sharedDir, inst.minecraft.version);
const vorher = (state && state.before) || new Set();
const neu = nachher.filter((v) => !vorher.has(v));
const treffer = neu[0] || nachher[0];
optifineSetupState.delete(id);
if (!treffer) {
return {
ok: false,
message: 'Keine OptiFine-Version für ' + (inst.minecraft.version || '?') + ' gefunden. '
+ 'Wurde die Installation im OptiFine-Fenster abgeschlossen und dabei der kopierte Ordner ausgewählt?',
};
}
return { ok: true, versionId: treffer };
});
ipcMain.handle('mods:remove', (_e, id, filename, art) => store.removeMod(id, filename, art || 'mod')); ipcMain.handle('mods:remove', (_e, id, filename, art) => store.removeMod(id, filename, art || 'mod'));
// prüft, ob andere installierte Mods diesen hier als Pflicht-Abhängigkeit brauchen // prüft, ob andere installierte Mods diesen hier als Pflicht-Abhängigkeit brauchen
ipcMain.handle('mods:dependents', (_e, id, filename, art) => store.findDependents(id, filename, art || 'mod')); ipcMain.handle('mods:dependents', (_e, id, filename, art) => store.findDependents(id, filename, art || 'mod'));
@@ -1148,8 +1367,13 @@ async function importCurseForge(buf, zip, zugang) {
} }
ipcMain.handle('modpack:search', async (_e, opts) => { ipcMain.handle('modpack:search', async (_e, opts) => {
const o = opts || {};
try { try {
const q = Object.assign({}, opts || {}, { art: 'modpack' }); if (o.source === 'curseforge') {
const zugang = services.curseforgeZugang(store.getSettings());
return await services.curseforgeSearch(zugang, Object.assign({}, o, { art: 'modpack' }));
}
const q = Object.assign({}, o, { art: 'modpack' });
return await services.modrinthSearch(q); return await services.modrinthSearch(q);
} catch (err) { } catch (err) {
return { total: 0, hits: [], error: err.message }; return { total: 0, hits: [], error: err.message };
@@ -1208,6 +1432,52 @@ ipcMain.handle('modpack:installFromModrinth', async (_e, project) => {
} }
}); });
ipcMain.handle('modpack:installFromCurseForge', async (_e, project) => {
try {
const zugang = services.curseforgeZugang(store.getSettings());
if (!zugang) {
return { ok: false, needsKey: true,
message: 'CurseForge-Modpacks brauchen einen Zugang. Trage in den Netzwerk-Einstellungen entweder deinen eigenen API-Key ein (kostenlos über console.curseforge.com) oder die Adresse einer CurseForge-Weiterleitung.' };
}
const rawId = String((project && (project.cfId || project.projectId || project.id)) || '').replace(/^cf-/, '');
const modId = Number(rawId);
if (!modId) return { ok: false, message: 'Kein Modpack gewählt.' };
const gameVersion = (project && project.gameVersion) || '';
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('launch:progress', {
phase: 'modpack', detail: 'Modpack-Version wird ermittelt …',
});
}
const file = await services.curseforgeResolveModpackFile(zugang, modId, gameVersion);
if (!file || !file.url) {
return {
ok: false,
message: gameVersion
? ('Keine Modpack-Version für Minecraft ' + gameVersion + ' gefunden.')
: 'Keine passende Modpack-Datei gefunden.',
};
}
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('launch:progress', {
phase: 'modpack', detail: 'Lade ' + file.filename + ' …',
});
}
const dlUrl = zugang.downloadAdresse ? zugang.downloadAdresse(file.url) : file.url;
const buf = await services.downloadBuffer(dlUrl, zugang.downloadKopfzeilen);
const zip = forge.readZipIndex(buf);
if (!zip['manifest.json']) {
return { ok: false, message: 'Die geladene Datei enthält kein manifest.json kein gültiges CurseForge-Modpack.' };
}
const result = await importCurseForge(buf, zip, zugang);
if (result && result.ok && result.instance) {
notify('Modpack installiert', `${result.instance.name} ist einsatzbereit.`);
}
return result;
} catch (err) {
return { ok: false, message: 'Modpack-Installation fehlgeschlagen: ' + err.message };
}
});
/* /*
* Kern des Modpack-Datei-Imports, unabhängig davon ob der Pfad aus einem * Kern des Modpack-Datei-Imports, unabhängig davon ob der Pfad aus einem
* Dateidialog oder per Drag&Drop aufs Fenster kommt. * Dateidialog oder per Drag&Drop aufs Fenster kommt.
@@ -1251,24 +1521,62 @@ ipcMain.handle('modpack:import', async () => {
}); });
// Verknüpfung auf dem Desktop, die genau diese Instanz startet // Verknüpfung auf dem Desktop, die genau diese Instanz startet
ipcMain.handle('instances:shortcut', (_e, id) => { // wunschName: optionaler, vom Nutzer selbst vergebener Dateiname (ohne .lnk)
ipcMain.handle('instances:shortcut', (_e, id, wunschName) => {
const inst = store.getInstance(id); const inst = store.getInstance(id);
if (!inst) return { ok: false, message: 'Instanz nicht gefunden.' }; if (!inst) return { ok: false, message: 'Instanz nicht gefunden.' };
if (process.platform !== 'win32') { if (process.platform !== 'win32') {
return { ok: false, message: 'Verknüpfungen werden nur unter Windows unterstützt.' }; return { ok: false, message: 'Verknüpfungen werden nur unter Windows unterstützt.' };
} }
try { try {
const name = String(inst.name || 'Instanz').replace(/[\\/:*?"<>|]/g, '_').trim() || 'Instanz'; const roh = (wunschName && wunschName.trim()) || inst.name || 'Instanz';
const name = String(roh).replace(/[\\/:*?"<>|]/g, '_').trim() || 'Instanz';
const datei = path.join(app.getPath('desktop'), name + '.lnk'); const datei = path.join(app.getPath('desktop'), name + '.lnk');
const ziel = verknuepfungsZiel(); const ziel = verknuepfungsZiel();
const ok = shell.writeShortcutLink(datei, fs.existsSync(datei) ? 'replace' : 'create', { const ok = shell.writeShortcutLink(datei, fs.existsSync(datei) ? 'replace' : 'create', {
target: ziel, target: ziel,
// Gleichheitsform, damit die Kennung beim Umsortieren durch Chromium // Gleichheitsform, damit die Kennung beim Umsortieren durch Chromium
// an ihrem Schalter bleibt (siehe instanzAusArgumenten) // an ihrem Schalter bleibt (siehe instanzAusArgumenten)
args: `--launch="${id}"`, args: verknuepfungsArgs([`--launch="${id}"`]),
cwd: path.dirname(ziel), cwd: path.dirname(ziel),
description: 'AeroMC ' + inst.name, description: 'AeroMC ' + inst.name,
icon: ziel, icon: verknuepfungsIcon(ziel),
iconIndex: 0,
});
if (!ok) return { ok: false, message: 'Verknüpfung konnte nicht geschrieben werden.' };
return { ok: true, path: datei };
} catch (err) {
return { ok: false, message: 'Verknüpfung fehlgeschlagen: ' + err.message };
}
});
/*
* Desktop-Verknüpfung, die eine Instanz startet UND direkt mit einem
* bestimmten Server verbindet (Quick Play, ab Minecraft 1.20). Technisch
* dasselbe wie instances:shortcut, nur mit zusätzlichem "--server="-Argument.
* wunschName: optionaler, vom Nutzer selbst vergebener Dateiname (ohne .lnk)
*/
ipcMain.handle('servers:shortcut', (_e, id, ip, name, wunschName) => {
const inst = store.getInstance(id);
if (!inst) return { ok: false, message: 'Instanz nicht gefunden.' };
if (!ip) return { ok: false, message: 'Keine Serveradresse angegeben.' };
if (process.platform !== 'win32') {
return { ok: false, message: 'Verknüpfungen werden nur unter Windows unterstützt.' };
}
try {
const rohName = (wunschName && wunschName.trim())
|| `${inst.name || 'Instanz'} - ${name || ip}`;
const dateiName = String(rohName).replace(/[\\/:*?"<>|]/g, '_').trim() || 'Server';
const datei = path.join(app.getPath('desktop'), `${dateiName}.lnk`);
const ziel = verknuepfungsZiel();
const ok = shell.writeShortcutLink(datei, fs.existsSync(datei) ? 'replace' : 'create', {
target: ziel,
// Gleichheitsform für beide Kennungen, damit sie beim Umsortieren durch
// Chromium zusammenbleiben (siehe instanzAusArgumenten)
args: verknuepfungsArgs([`--launch="${id}"`, `--server="${ip}"`]),
cwd: path.dirname(ziel),
description: `AeroMC ${inst.name}${name || ip}`,
icon: verknuepfungsIcon(ziel),
iconIndex: 0, iconIndex: 0,
}); });
if (!ok) return { ok: false, message: 'Verknüpfung konnte nicht geschrieben werden.' }; if (!ok) return { ok: false, message: 'Verknüpfung konnte nicht geschrieben werden.' };
@@ -1936,5 +2244,15 @@ ipcMain.handle('app:info', () => ({
version: app.getVersion(), version: app.getVersion(),
author: pkg.author || '', author: pkg.author || '',
homepage: pkg.homepage || '', homepage: pkg.homepage || '',
gitpage: typeof pkg.repository === 'string' ? pkg.repository : (pkg.repository?.url || ''), launcherWebsite: pkg.launcherWebsite || '',
})); }));
// Lizenztext für die Info-Seite in den Einstellungen. Die LICENSE-Datei liegt
// im App-Root und wird über "files" im electron-builder-Config mitgepackt.
ipcMain.handle('app:license', () => {
try {
return fs.readFileSync(path.join(app.getAppPath(), 'LICENSE'), 'utf8');
} catch {
return 'Lizenztext konnte nicht geladen werden.';
}
});
+216 -216
View File
@@ -1,217 +1,217 @@
'use strict'; 'use strict';
const fs = require('fs'); const fs = require('fs');
const os = require('os'); const os = require('os');
const path = require('path'); const path = require('path');
const PROFILE_FILES = [ const PROFILE_FILES = [
'launcher_profiles.json', 'launcher_profiles.json',
'launcher_profiles_microsoft_store.json', 'launcher_profiles_microsoft_store.json',
'launcher_profiles_microsoft_store_2.json', 'launcher_profiles_microsoft_store_2.json',
]; ];
const ROOT_COPY_DIRS = new Set([ const ROOT_COPY_DIRS = new Set([
'config', 'defaultconfigs', 'kubejs', 'mods', 'resourcepacks', 'screenshots', 'shaderpacks', 'saves', 'config', 'defaultconfigs', 'kubejs', 'mods', 'resourcepacks', 'screenshots', 'shaderpacks', 'saves',
]); ]);
const ROOT_COPY_FILE_PATTERNS = [ const ROOT_COPY_FILE_PATTERNS = [
/^options.*\.(txt|of)$/i, /^options.*\.(txt|of)$/i,
/^servers\.dat(?:_old)?$/i, /^servers\.dat(?:_old)?$/i,
/^usercache\.json$/i, /^usercache\.json$/i,
/^tl_skin_cape\.json$/i, /^tl_skin_cape\.json$/i,
/^journeymap.*\.(json|txt|cfg)$/i, /^journeymap.*\.(json|txt|cfg)$/i,
]; ];
function readJson(file, fallback) { function readJson(file, fallback) {
try { try {
return JSON.parse(fs.readFileSync(file, 'utf8').replace(/^\uFEFF/, '')); return JSON.parse(fs.readFileSync(file, 'utf8').replace(/^\uFEFF/, ''));
} catch { } catch {
return fallback; return fallback;
} }
} }
function resolveLauncherRoot(inputPath) { function resolveLauncherRoot(inputPath) {
const appData = process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'); const appData = process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming');
const defaultRoot = path.join(appData, '.minecraft'); const defaultRoot = path.join(appData, '.minecraft');
const candidates = []; const candidates = [];
if (inputPath && fs.existsSync(inputPath)) candidates.push(inputPath); if (inputPath && fs.existsSync(inputPath)) candidates.push(inputPath);
candidates.push(defaultRoot); candidates.push(defaultRoot);
for (const candidate of candidates) { for (const candidate of candidates) {
for (const profileFile of PROFILE_FILES) { for (const profileFile of PROFILE_FILES) {
if (fs.existsSync(path.join(candidate, profileFile))) return candidate; if (fs.existsSync(path.join(candidate, profileFile))) return candidate;
} }
} }
return null; return null;
} }
function resolveProfileFile(rootDir) { function resolveProfileFile(rootDir) {
return PROFILE_FILES.map((name) => path.join(rootDir, name)).find((file) => fs.existsSync(file)) || null; return PROFILE_FILES.map((name) => path.join(rootDir, name)).find((file) => fs.existsSync(file)) || null;
} }
function normalizeLoaderVersion(loader, rawValue, mcVersion) { function normalizeLoaderVersion(loader, rawValue, mcVersion) {
let value = String(rawValue || '').trim(); let value = String(rawValue || '').trim();
if (!value) return ''; if (!value) return '';
if (loader === 'forge') { if (loader === 'forge') {
value = value.replace(/^forge-/i, ''); value = value.replace(/^forge-/i, '');
if (mcVersion && value.startsWith(mcVersion + '-')) value = value.slice(mcVersion.length + 1); if (mcVersion && value.startsWith(mcVersion + '-')) value = value.slice(mcVersion.length + 1);
return value; return value;
} }
if (loader === 'neoforge') return value.replace(/^neoforge-/i, ''); if (loader === 'neoforge') return value.replace(/^neoforge-/i, '');
if (loader === 'fabric') { if (loader === 'fabric') {
value = value.replace(/^fabric(?:-loader)?-/i, ''); value = value.replace(/^fabric(?:-loader)?-/i, '');
if (mcVersion && value.endsWith('-' + mcVersion)) value = value.slice(0, -1 * (mcVersion.length + 1)); if (mcVersion && value.endsWith('-' + mcVersion)) value = value.slice(0, -1 * (mcVersion.length + 1));
return value; return value;
} }
if (loader === 'quilt') { if (loader === 'quilt') {
value = value.replace(/^quilt(?:-loader)?-/i, ''); value = value.replace(/^quilt(?:-loader)?-/i, '');
if (mcVersion && value.endsWith('-' + mcVersion)) value = value.slice(0, -1 * (mcVersion.length + 1)); if (mcVersion && value.endsWith('-' + mcVersion)) value = value.slice(0, -1 * (mcVersion.length + 1));
return value; return value;
} }
return value; return value;
} }
function parseLastVersionId(lastVersionId) { function parseLastVersionId(lastVersionId) {
const raw = String(lastVersionId || '').trim(); const raw = String(lastVersionId || '').trim();
if (!raw) return { version: '', loader: 'vanilla', loaderVersion: '' }; if (!raw) return { version: '', loader: 'vanilla', loaderVersion: '' };
let match = raw.match(/^fabric-loader-([^-]+(?:\.[^-]+)*)-(.+)$/i); let match = raw.match(/^fabric-loader-([^-]+(?:\.[^-]+)*)-(.+)$/i);
if (match) return { version: match[2], loader: 'fabric', loaderVersion: normalizeLoaderVersion('fabric', match[1], match[2]) }; if (match) return { version: match[2], loader: 'fabric', loaderVersion: normalizeLoaderVersion('fabric', match[1], match[2]) };
match = raw.match(/^quilt-loader-([^-]+(?:\.[^-]+)*)-(.+)$/i); match = raw.match(/^quilt-loader-([^-]+(?:\.[^-]+)*)-(.+)$/i);
if (match) return { version: match[2], loader: 'quilt', loaderVersion: normalizeLoaderVersion('quilt', match[1], match[2]) }; if (match) return { version: match[2], loader: 'quilt', loaderVersion: normalizeLoaderVersion('quilt', match[1], match[2]) };
match = raw.match(/^(.+)-forge-([\w.-]+)$/i); match = raw.match(/^(.+)-forge-([\w.-]+)$/i);
if (match) return { version: match[1], loader: 'forge', loaderVersion: normalizeLoaderVersion('forge', match[2], match[1]) }; if (match) return { version: match[1], loader: 'forge', loaderVersion: normalizeLoaderVersion('forge', match[2], match[1]) };
match = raw.match(/^forge-(.+)-([\w.-]+)$/i); match = raw.match(/^forge-(.+)-([\w.-]+)$/i);
if (match) return { version: match[1], loader: 'forge', loaderVersion: normalizeLoaderVersion('forge', match[2], match[1]) }; if (match) return { version: match[1], loader: 'forge', loaderVersion: normalizeLoaderVersion('forge', match[2], match[1]) };
match = raw.match(/^(.+)-neoforge-([\w.-]+)$/i); match = raw.match(/^(.+)-neoforge-([\w.-]+)$/i);
if (match) return { version: match[1], loader: 'neoforge', loaderVersion: normalizeLoaderVersion('neoforge', match[2], match[1]) }; 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: '' }; if (/^\d+(?:\.\d+)+(?:-[\w.]+)?$/i.test(raw)) return { version: raw, loader: 'vanilla', loaderVersion: '' };
return { version: raw, loader: 'vanilla', loaderVersion: '' }; return { version: raw, loader: 'vanilla', loaderVersion: '' };
} }
function parseJavaSettings(javaDir, javaArgs) { function parseJavaSettings(javaDir, javaArgs) {
const args = String(javaArgs || '').trim(); const args = String(javaArgs || '').trim();
const minMatch = args.match(/(?:^|\s)-Xms(\d+)([mMgG])/); const minMatch = args.match(/(?:^|\s)-Xms(\d+)([mMgG])/);
const maxMatch = args.match(/(?:^|\s)-Xmx(\d+)([mMgG])/); const maxMatch = args.match(/(?:^|\s)-Xmx(\d+)([mMgG])/);
const toMb = (match) => { const toMb = (match) => {
if (!match) return null; if (!match) return null;
const num = Number(match[1]); const num = Number(match[1]);
if (!Number.isFinite(num)) return null; if (!Number.isFinite(num)) return null;
return match[2].toLowerCase() === 'g' ? num * 1024 : num; return match[2].toLowerCase() === 'g' ? num * 1024 : num;
}; };
return { return {
path: String(javaDir || '').trim(), path: String(javaDir || '').trim(),
minMemMb: toMb(minMatch), minMemMb: toMb(minMatch),
maxMemMb: toMb(maxMatch), maxMemMb: toMb(maxMatch),
extraArgs: args extraArgs: args
.replace(/(?:^|\s)-Xms\d+[mMgG]/g, ' ') .replace(/(?:^|\s)-Xms\d+[mMgG]/g, ' ')
.replace(/(?:^|\s)-Xmx\d+[mMgG]/g, ' ') .replace(/(?:^|\s)-Xmx\d+[mMgG]/g, ' ')
.replace(/\s+/g, ' ') .replace(/\s+/g, ' ')
.trim(), .trim(),
}; };
} }
function isImportableProfile(profile) { function isImportableProfile(profile) {
const type = String((profile && profile.type) || '').trim().toLowerCase(); const type = String((profile && profile.type) || '').trim().toLowerCase();
if (type === 'latest-release' || type === 'latest-snapshot') return false; if (type === 'latest-release' || type === 'latest-snapshot') return false;
const name = String((profile && profile.name) || '').trim(); const name = String((profile && profile.name) || '').trim();
const lastVersionId = String((profile && profile.lastVersionId) || '').trim(); const lastVersionId = String((profile && profile.lastVersionId) || '').trim();
return !!(name || lastVersionId); return !!(name || lastVersionId);
} }
function shouldCopyRootEntry(name) { function shouldCopyRootEntry(name) {
if (ROOT_COPY_DIRS.has(name)) return true; if (ROOT_COPY_DIRS.has(name)) return true;
return ROOT_COPY_FILE_PATTERNS.some((pattern) => pattern.test(name)); return ROOT_COPY_FILE_PATTERNS.some((pattern) => pattern.test(name));
} }
function scan(inputPath) { function scan(inputPath) {
const rootDir = resolveLauncherRoot(inputPath); const rootDir = resolveLauncherRoot(inputPath);
if (!rootDir) return { ok: false, reason: 'not-found' }; if (!rootDir) return { ok: false, reason: 'not-found' };
const profileFile = resolveProfileFile(rootDir); const profileFile = resolveProfileFile(rootDir);
const json = readJson(profileFile, {}); const json = readJson(profileFile, {});
const profiles = json.profiles || {}; const profiles = json.profiles || {};
const list = []; const list = [];
const defaultRoot = path.normalize(rootDir).toLowerCase(); const defaultRoot = path.normalize(rootDir).toLowerCase();
for (const [id, profile] of Object.entries(profiles)) { for (const [id, profile] of Object.entries(profiles)) {
if (!isImportableProfile(profile)) continue; if (!isImportableProfile(profile)) continue;
const name = String(profile.name || '').trim() || String(profile.lastVersionId || '').trim() || id; const name = String(profile.name || '').trim() || String(profile.lastVersionId || '').trim() || id;
const parsed = parseLastVersionId(profile.lastVersionId); const parsed = parseLastVersionId(profile.lastVersionId);
const gameDir = path.normalize(String(profile.gameDir || rootDir)); const gameDir = path.normalize(String(profile.gameDir || rootDir));
const java = parseJavaSettings(profile.javaDir, profile.javaArgs); const java = parseJavaSettings(profile.javaDir, profile.javaArgs);
list.push({ list.push({
id, id,
name, name,
group: '', group: '',
notes: 'Importiert aus dem Minecraft Launcher', notes: 'Importiert aus dem Minecraft Launcher',
version: parsed.version, version: parsed.version,
loader: parsed.loader, loader: parsed.loader,
loaderVersion: parsed.loaderVersion, loaderVersion: parsed.loaderVersion,
javaPath: java.path, javaPath: java.path,
minMemMb: java.minMemMb, minMemMb: java.minMemMb,
maxMemMb: java.maxMemMb, maxMemMb: java.maxMemMb,
extraJavaArgs: java.extraArgs, extraJavaArgs: java.extraArgs,
gameDir, gameDir,
hasGameData: fs.existsSync(gameDir), hasGameData: fs.existsSync(gameDir),
usesDefaultGameDir: gameDir.toLowerCase() === defaultRoot, usesDefaultGameDir: gameDir.toLowerCase() === defaultRoot,
rootDir, rootDir,
}); });
} }
list.sort((a, b) => a.name.localeCompare(b.name)); list.sort((a, b) => a.name.localeCompare(b.name));
return { ok: true, rootDir, count: list.length, instances: list }; return { ok: true, rootDir, count: list.length, instances: list };
} }
function copyInto(sourceDir, targetDir, usesDefaultGameDir) { function copyInto(sourceDir, targetDir, usesDefaultGameDir) {
fs.mkdirSync(targetDir, { recursive: true }); fs.mkdirSync(targetDir, { recursive: true });
for (const name of fs.readdirSync(sourceDir)) { for (const name of fs.readdirSync(sourceDir)) {
if (/^launcher_profiles.*\.json$/i.test(name)) continue; if (/^launcher_profiles.*\.json$/i.test(name)) continue;
if (name === 'versions' || name === 'libraries' || name === 'assets' || name === 'runtime' || name === 'webcache2') continue; if (name === 'versions' || name === 'libraries' || name === 'assets' || name === 'runtime' || name === 'webcache2') continue;
if (usesDefaultGameDir && !shouldCopyRootEntry(name)) continue; if (usesDefaultGameDir && !shouldCopyRootEntry(name)) continue;
fs.cpSync(path.join(sourceDir, name), path.join(targetDir, name), { recursive: true }); fs.cpSync(path.join(sourceDir, name), path.join(targetDir, name), { recursive: true });
} }
} }
function importOne(store, entry, copyData = true) { function importOne(store, entry, copyData = true) {
const created = store.createOrReplaceImportedInstance({ const created = store.createOrReplaceImportedInstance({
name: entry.name, name: entry.name,
group: entry.group, group: entry.group,
notes: entry.notes, notes: entry.notes,
minecraft: { minecraft: {
version: entry.version, version: entry.version,
loader: entry.loader, loader: entry.loader,
loaderVersion: entry.loaderVersion, loaderVersion: entry.loaderVersion,
}, },
}); });
const patch = {}; const patch = {};
if (entry.javaPath || entry.minMemMb || entry.maxMemMb || entry.extraJavaArgs) { if (entry.javaPath || entry.minMemMb || entry.maxMemMb || entry.extraJavaArgs) {
patch.java = { patch.java = {
path: entry.javaPath || '', path: entry.javaPath || '',
minMemMb: entry.minMemMb || null, minMemMb: entry.minMemMb || null,
maxMemMb: entry.maxMemMb || null, maxMemMb: entry.maxMemMb || null,
extraArgs: entry.extraJavaArgs || '', extraArgs: entry.extraJavaArgs || '',
}; };
} }
if (Object.keys(patch).length) store.updateInstance(created.id, patch); if (Object.keys(patch).length) store.updateInstance(created.id, patch);
let copied = false; let copied = false;
if (copyData && entry.gameDir && fs.existsSync(entry.gameDir)) { if (copyData && entry.gameDir && fs.existsSync(entry.gameDir)) {
copyInto(entry.gameDir, store.gameDir(created.id), !!entry.usesDefaultGameDir); copyInto(entry.gameDir, store.gameDir(created.id), !!entry.usesDefaultGameDir);
copied = true; copied = true;
} }
return { id: created.id, name: created.name, copied }; return { id: created.id, name: created.name, copied };
} }
module.exports = { scan, importOne, parseLastVersionId, resolveLauncherRoot }; module.exports = { scan, importOne, parseLastVersionId, resolveLauncherRoot };
+13 -3
View File
@@ -19,6 +19,9 @@ contextBridge.exposeInMainWorld('api', {
// Minecraft-Versionen (Mojang) // Minecraft-Versionen (Mojang)
mcVersions: () => ipcRenderer.invoke('mc:versions'), mcVersions: () => ipcRenderer.invoke('mc:versions'),
loaderVersions: (loader, mcVersion) => ipcRenderer.invoke('loaders:versions', loader, mcVersion), loaderVersions: (loader, mcVersion) => ipcRenderer.invoke('loaders:versions', loader, mcVersion),
optifineFind: (mcVersion) => ipcRenderer.invoke('optifine:find', mcVersion),
optifineSetup: (id) => ipcRenderer.invoke('optifine:setup', id),
optifineConfirm: (id) => ipcRenderer.invoke('optifine:confirm', id),
// Mods (Modrinth) // Mods (Modrinth)
modSearch: (opts) => ipcRenderer.invoke('mods:search', opts), modSearch: (opts) => ipcRenderer.invoke('mods:search', opts),
@@ -87,18 +90,24 @@ contextBridge.exposeInMainWorld('api', {
return () => ipcRenderer.removeListener('backup:progress', l); return () => ipcRenderer.removeListener('backup:progress', l);
}, },
importInstance: () => ipcRenderer.invoke('instances:import'), importInstance: () => ipcRenderer.invoke('instances:import'),
createShortcut: (id) => ipcRenderer.invoke('instances:shortcut', id), createShortcut: (id, customName) => ipcRenderer.invoke('instances:shortcut', id, customName),
// Start über eine Desktop-Verknüpfung ("--launch <id>") serversShortcut: (id, ip, name, customName) => ipcRenderer.invoke('servers:shortcut', id, ip, name, customName),
// Start über eine Desktop-Verknüpfung ("--launch=<id>", optional "--server=<ip>")
onLaunchRequest: (cb) => { onLaunchRequest: (cb) => {
const l = (_e, id) => cb(id); const l = (_e, payload) => cb(payload);
ipcRenderer.on('instance:launch-request', l); ipcRenderer.on('instance:launch-request', l);
return () => ipcRenderer.removeListener('instance:launch-request', l); return () => ipcRenderer.removeListener('instance:launch-request', l);
}, },
// Signalisiert dem Hauptprozess, dass onLaunchRequest jetzt registriert ist
// verhindert, dass ein Start-Wunsch aus einer Verknüpfung verloren geht,
// weil er ankommt, bevor die Oberfläche fertig eingerichtet ist
notifyReady: () => ipcRenderer.send('renderer:ready'),
importModpack: () => ipcRenderer.invoke('modpack:import'), importModpack: () => ipcRenderer.invoke('modpack:import'),
// Datei per Drag&Drop aufs Fenster gezogen (.jar/.mrpack/.zip) // Datei per Drag&Drop aufs Fenster gezogen (.jar/.mrpack/.zip)
filesDrop: (filePath, targetId, targetArt) => ipcRenderer.invoke('files:drop', filePath, targetId, targetArt), filesDrop: (filePath, targetId, targetArt) => ipcRenderer.invoke('files:drop', filePath, targetId, targetArt),
modpackSearch: (opts) => ipcRenderer.invoke('modpack:search', opts), modpackSearch: (opts) => ipcRenderer.invoke('modpack:search', opts),
modpackInstallFromModrinth: (project) => ipcRenderer.invoke('modpack:installFromModrinth', project), modpackInstallFromModrinth: (project) => ipcRenderer.invoke('modpack:installFromModrinth', project),
modpackInstallFromCurseForge: (project) => ipcRenderer.invoke('modpack:installFromCurseForge', project),
modpackCheck: (id) => ipcRenderer.invoke('modpack:check', id), modpackCheck: (id) => ipcRenderer.invoke('modpack:check', id),
modpackUpdate: (id, versionId) => ipcRenderer.invoke('modpack:update', id, versionId), modpackUpdate: (id, versionId) => ipcRenderer.invoke('modpack:update', id, versionId),
onLaunchProgress: (cb) => { onLaunchProgress: (cb) => {
@@ -231,4 +240,5 @@ contextBridge.exposeInMainWorld('api', {
pickFolder: (title) => ipcRenderer.invoke('dialog:pickFolder', title), pickFolder: (title) => ipcRenderer.invoke('dialog:pickFolder', title),
pickJava: () => ipcRenderer.invoke('dialog:pickJava'), pickJava: () => ipcRenderer.invoke('dialog:pickJava'),
appInfo: () => ipcRenderer.invoke('app:info'), appInfo: () => ipcRenderer.invoke('app:info'),
appLicense: () => ipcRenderer.invoke('app:license'),
}); });
+101 -101
View File
@@ -1,101 +1,101 @@
'use strict'; 'use strict';
const DEFAULT_GRACE_MS = 15000; const DEFAULT_GRACE_MS = 15000;
const CLEAN_SHUTDOWN = /\[(?:Render thread|Client thread)\/INFO\](?:\s+\([^)]*\))?:\s*Stopping!\s*$/; const CLEAN_SHUTDOWN = /\[(?:Render thread|Client thread)\/INFO\](?:\s+\([^)]*\))?:\s*Stopping!\s*$/;
const FATAL_OUTPUT = [ const FATAL_OUTPUT = [
/---- Minecraft Crash Report ----/i, /---- Minecraft Crash Report ----/i,
/\b(?:Encountered an unexpected exception|Unreported exception thrown|Reported exception thrown)\b/i, /\b(?:Encountered an unexpected exception|Unreported exception thrown|Reported exception thrown)\b/i,
/\b(?:This crash report has been saved to|Saving crash report to)\b/i, /\b(?:This crash report has been saved to|Saving crash report to)\b/i,
/\bA fatal error has been detected by the Java Runtime Environment\b/i, /\bA fatal error has been detected by the Java Runtime Environment\b/i,
/Exception in thread "(?:Render thread|Client thread|main)"/i, /Exception in thread "(?:Render thread|Client thread|main)"/i,
/\[(?:Render thread|Client thread|main)\/FATAL\]/i, /\[(?:Render thread|Client thread|main)\/FATAL\]/i,
]; ];
function createProcessWatchdog(options) { function createProcessWatchdog(options) {
const graceMs = options.graceMs || DEFAULT_GRACE_MS; const graceMs = options.graceMs || DEFAULT_GRACE_MS;
const schedule = options.setTimer || setTimeout; const schedule = options.setTimer || setTimeout;
const cancel = options.clearTimer || clearTimeout; const cancel = options.clearTimer || clearTimeout;
const buffers = { stdout: '', stderr: '' }; const buffers = { stdout: '', stderr: '' };
let timer = null; let timer = null;
let armed = false; let armed = false;
let crashed = false; let crashed = false;
let ended = false; let ended = false;
let forced = false; let forced = false;
// bleibt true, sobald einmal ein regulärer Shutdown ("Stopping!") gesehen // bleibt true, sobald einmal ein regulärer Shutdown ("Stopping!") gesehen
// wurde im Gegensatz zu `armed` wird das NICHT durch markCrash() // wurde im Gegensatz zu `armed` wird das NICHT durch markCrash()
// zurückgesetzt, damit ein später vom internen Watchdog erzwungener Halt // zurückgesetzt, damit ein später vom internen Watchdog erzwungener Halt
// weiterhin als "war ein regulärer Shutdown" erkennbar bleibt // weiterhin als "war ein regulärer Shutdown" erkennbar bleibt
let sawCleanShutdown = false; let sawCleanShutdown = false;
function clear() { function clear() {
if (timer !== null) cancel(timer); if (timer !== null) cancel(timer);
timer = null; timer = null;
armed = false; armed = false;
} }
function markCrash() { function markCrash() {
crashed = true; crashed = true;
clear(); clear();
} }
function scheduleKill() { function scheduleKill() {
if (timer !== null) cancel(timer); if (timer !== null) cancel(timer);
timer = schedule(() => { timer = schedule(() => {
timer = null; timer = null;
if (ended || crashed || !armed || !options.isRunning()) return; if (ended || crashed || !armed || !options.isRunning()) return;
try { try {
forced = options.kill() === true; forced = options.kill() === true;
} catch { } catch {
forced = false; forced = false;
} }
if (forced && options.onForce) options.onForce(graceMs); if (forced && options.onForce) options.onForce(graceMs);
else if (!forced && options.isRunning() && options.onError) options.onError(); else if (!forced && options.isRunning() && options.onError) options.onError();
}, graceMs); }, graceMs);
if (timer && typeof timer.unref === 'function') timer.unref(); if (timer && typeof timer.unref === 'function') timer.unref();
} }
function inspectLine(line) { function inspectLine(line) {
const isCleanShutdown = CLEAN_SHUTDOWN.test(line); const isCleanShutdown = CLEAN_SHUTDOWN.test(line);
if (isCleanShutdown) sawCleanShutdown = true; if (isCleanShutdown) sawCleanShutdown = true;
if (FATAL_OUTPUT.some((pattern) => pattern.test(line))) { if (FATAL_OUTPUT.some((pattern) => pattern.test(line))) {
markCrash(); markCrash();
return; return;
} }
if (ended || crashed || armed || !isCleanShutdown) return; if (ended || crashed || armed || !isCleanShutdown) return;
armed = true; armed = true;
scheduleKill(); scheduleKill();
} }
function feed(chunk, stream) { function feed(chunk, stream) {
if (ended) return; if (ended) return;
const key = stream === 'stderr' ? 'stderr' : 'stdout'; const key = stream === 'stderr' ? 'stderr' : 'stdout';
const text = buffers[key] + String(chunk || ''); const text = buffers[key] + String(chunk || '');
const lines = text.split(/\r?\n/); const lines = text.split(/\r?\n/);
buffers[key] = lines.pop() || ''; buffers[key] = lines.pop() || '';
for (const line of lines) inspectLine(line); for (const line of lines) inspectLine(line);
if (armed && !crashed && !ended && lines.length) scheduleKill(); if (armed && !crashed && !ended && lines.length) scheduleKill();
} }
function end() { function end() {
ended = true; ended = true;
clear(); clear();
} }
return { return {
feed, feed,
end, end,
get armed() { return armed; }, get armed() { return armed; },
get crashed() { return crashed; }, get crashed() { return crashed; },
get forced() { return forced; }, get forced() { return forced; },
get sawCleanShutdown() { return sawCleanShutdown; }, get sawCleanShutdown() { return sawCleanShutdown; },
}; };
} }
module.exports = { module.exports = {
DEFAULT_GRACE_MS, DEFAULT_GRACE_MS,
CLEAN_SHUTDOWN, CLEAN_SHUTDOWN,
FATAL_OUTPUT, FATAL_OUTPUT,
createProcessWatchdog, createProcessWatchdog,
}; };
+174 -19
View File
@@ -467,7 +467,7 @@ function iconOf(key, size = 44) {
const LOADER_LABEL = { const LOADER_LABEL = {
vanilla: 'Vanilla', fabric: 'Fabric', forge: 'Forge', vanilla: 'Vanilla', fabric: 'Fabric', forge: 'Forge',
neoforge: 'NeoForge', quilt: 'Quilt', neoforge: 'NeoForge', quilt: 'Quilt', optifine: 'OptiFine',
}; };
// Bewährte Garbage-Collector-Flags (bekannt als "Aikar's Flags"), reduzieren // Bewährte Garbage-Collector-Flags (bekannt als "Aikar's Flags"), reduzieren
@@ -645,17 +645,28 @@ async function searchModpacks() {
const query = el('mp-query').value.trim(); const query = el('mp-query').value.trim();
const gameVersion = el('mp-mc').value.trim(); const gameVersion = el('mp-mc').value.trim();
const sort = el('mp-sort').value || 'relevance'; const sort = el('mp-sort').value || 'relevance';
const source = (el('mp-source') && el('mp-source').value) || 'modrinth';
const box = el('mp-results'); const box = el('mp-results');
const st = el('mp-status'); const st = el('mp-status');
st.textContent = 'Suche …'; st.textContent = 'Suche …';
box.innerHTML = '<div class="mods-empty">Suche läuft …</div>'; box.innerHTML = '<div class="mods-empty">Suche läuft …</div>';
try { try {
const res = await window.api.modpackSearch({ const res = await window.api.modpackSearch({
query, gameVersion, sort, limit: 30, offset: 0, art: 'modpack', query, gameVersion, sort, limit: 30, offset: 0, art: 'modpack', source,
}); });
if (res.error) { if (res.error) {
st.textContent = 'Fehler: ' + res.error; st.textContent = 'Fehler: ' + res.error;
box.innerHTML = '<div class="mods-empty">Suche fehlgeschlagen.</div>'; box.innerHTML = '<div class="mods-empty">Suche fehlgeschlagen.</div>';
if (res.needKey) {
const wrap = document.createElement('div');
wrap.className = 'mods-empty';
const btn = document.createElement('button');
btn.className = 'btn btn-mini';
btn.textContent = 'Zu den Netzwerk-Einstellungen';
btn.addEventListener('click', () => { hide('modal-import'); openSettings('pane-network'); });
wrap.appendChild(btn);
box.appendChild(wrap);
}
return; return;
} }
const hits = res.hits || []; const hits = res.hits || [];
@@ -703,11 +714,15 @@ async function installModpackFromSearch(hit, btn) {
if (btn) { btn.disabled = true; btn.textContent = '…'; } if (btn) { btn.disabled = true; btn.textContent = '…'; }
status('Installiere Modpack: ' + (hit.title || hit.slug || '') + ' …'); status('Installiere Modpack: ' + (hit.title || hit.slug || '') + ' …');
try { try {
const res = await window.api.modpackInstallFromModrinth({ const res = hit.source === 'curseforge'
projectId: hit.projectId, ? await window.api.modpackInstallFromCurseForge({
title: hit.title || hit.slug, cfId: hit.cfId, projectId: hit.projectId, title: hit.title || hit.slug, gameVersion,
gameVersion, })
}); : await window.api.modpackInstallFromModrinth({
projectId: hit.projectId,
title: hit.title || hit.slug,
gameVersion,
});
status('Bereit'); el('status-right').textContent = ''; status('Bereit'); el('status-right').textContent = '';
if (res.ok) { if (res.ok) {
await reload(); await reload();
@@ -1786,7 +1801,44 @@ async function updateLoaderVersions(preselect) {
const hint = el('f-loader-hint'); const hint = el('f-loader-hint');
sel.innerHTML = '<option value="">neueste (empfohlen)</option>'; sel.innerHTML = '<option value="">neueste (empfohlen)</option>';
const ofTools = el('f-optifine-tools');
if (ofTools) ofTools.classList.toggle('hidden', loader !== 'optifine');
if (loader === 'vanilla') { sel.disabled = true; hint.textContent = 'Vanilla hat keinen Loader.'; return; } if (loader === 'vanilla') { sel.disabled = true; hint.textContent = 'Vanilla hat keinen Loader.'; return; }
if (loader === 'optifine') {
// Kein Loader-API bei OptiFine die Liste zeigt bereits eingerichtete
// Fassungen (siehe f-optifine-tools weiter unten), keine automatisch
// ladbaren Versionen.
sel.innerHTML = '<option value=""> keine gewählt </option>';
sel.disabled = false;
if (el('f-optifine-status')) el('f-optifine-status').textContent = '';
if (el('f-optifine-confirm')) el('f-optifine-confirm').classList.add('hidden');
if (!editingId) {
hint.textContent = 'Erst speichern, dann lässt sich OptiFine im Bearbeiten-Dialog einrichten.';
if (el('f-optifine-install')) el('f-optifine-install').disabled = true;
return;
}
if (el('f-optifine-install')) el('f-optifine-install').disabled = false;
if (!mc) { hint.textContent = 'Erst Minecraft-Version wählen.'; return; }
hint.textContent = 'Suche bereits eingerichtete OptiFine-Fassungen …';
try {
const found = await window.api.optifineFind(mc);
for (const v of found) {
const o = document.createElement('option');
o.value = v; o.textContent = v;
if (v === preselect) o.selected = true;
sel.appendChild(o);
}
hint.textContent = found.length
? `${found.length} eingerichtete OptiFine-Fassung(en) für ${mc}.`
: 'Noch keine OptiFine-Installation für diese Version über den Knopf unten einrichten.';
} catch {
hint.textContent = 'Suche fehlgeschlagen.';
}
return;
}
sel.disabled = false; sel.disabled = false;
if (!mc) { hint.textContent = 'Erst Minecraft-Version wählen.'; return; } if (!mc) { hint.textContent = 'Erst Minecraft-Version wählen.'; return; }
@@ -2155,6 +2207,27 @@ async function renderServers() {
}); });
aktionen.appendChild(starten); aktionen.appendChild(starten);
const favorit = document.createElement('button');
favorit.className = 'btn btn-mini';
favorit.textContent = '★ Verknüpfung';
favorit.title = 'Desktop-Verknüpfung erstellen, die Minecraft startet und direkt mit diesem Server verbindet (Quick Play, ab MC 1.20)';
favorit.addEventListener('click', async () => {
const inst = await window.api.getInstance(currentDetailId);
const vorschlag = `${inst ? inst.name : 'Instanz'} - ${s.name || s.ip}`;
const eingabe = await askPrompt({
title: 'Server-Verknüpfung erstellen',
text: 'Name der Desktop-Verknüpfung (ohne .lnk):',
label: 'Name',
value: vorschlag,
okLabel: 'Erstellen',
});
if (eingabe == null) return; // abgebrochen
const res = await window.api.serversShortcut(currentDetailId, s.ip, s.name, eingabe.trim());
if (res.ok) toast('Verknüpfung auf dem Desktop erstellt: ' + (s.name || s.ip));
else toast(res.message || 'Verknüpfung fehlgeschlagen.', true);
});
aktionen.appendChild(favorit);
const umbenennen = document.createElement('button'); const umbenennen = document.createElement('button');
umbenennen.className = 'btn btn-mini'; umbenennen.className = 'btn btn-mini';
umbenennen.textContent = 'Umbenennen'; umbenennen.textContent = 'Umbenennen';
@@ -2688,14 +2761,23 @@ function renderContentList(boxId, items, sub, emoji) {
// Welche Inhaltsart wird gerade gezeigt: mod | resourcepack | shader // Welche Inhaltsart wird gerade gezeigt: mod | resourcepack | shader
let modArt = 'mod'; let modArt = 'mod';
const ART_LABEL = { mod: 'Mods', resourcepack: 'Ressourcenpakete', shader: 'Shader' }; const ART_LABEL = {
mod: 'Mods', resourcepack: 'Ressourcenpakete', shader: 'Shader', datapack: 'Datenpakete',
};
const ART_SUCHE = { const ART_SUCHE = {
mod: 'Mods durchsuchen … (z. B. Sodium, JEI, Create)', mod: 'Mods durchsuchen … (z. B. Sodium, JEI, Create)',
resourcepack: 'Ressourcenpakete durchsuchen … (z. B. Faithful, Bare Bones)', resourcepack: 'Ressourcenpakete durchsuchen … (z. B. Faithful, Bare Bones)',
shader: 'Shader durchsuchen … (z. B. Complementary, BSL)', shader: 'Shader durchsuchen … (z. B. Complementary, BSL)',
datapack: 'Datenpakete durchsuchen … (z. B. Terralith, Vanilla Tweaks)',
}; };
// Nur Mods brauchen einen Loader Ressourcenpakete und Shader laufen auch in Vanilla // Nur Mods brauchen einen Loader Ressourcenpakete, Shader und Datenpakete laufen auch in Vanilla
const ART_BRAUCHT_LOADER = { mod: true, resourcepack: false, shader: false }; const ART_BRAUCHT_LOADER = {
mod: true, resourcepack: false, shader: false, datapack: false,
};
// CurseForge hat für Datenpakete keinen verlässlich zugeordneten API-Klassencode in
// diesem Launcher dort deshalb nur Modrinth als Quelle anbieten, statt falsche
// Treffer (z. B. Mods) unter "Datenpakete" anzuzeigen.
const ART_NUR_MODRINTH = { datapack: true };
async function openMods() { async function openMods() {
const inst = await window.api.getInstance(currentDetailId); const inst = await window.api.getInstance(currentDetailId);
@@ -2733,6 +2815,10 @@ async function zeigeModAnsicht(inst) {
['m-query', 'm-search-btn', 'm-sort', 'm-category'].forEach((id) => { el(id).disabled = blocked; }); ['m-query', 'm-search-btn', 'm-sort', 'm-category'].forEach((id) => { el(id).disabled = blocked; });
// Kategorien gelten nur für Mods // Kategorien gelten nur für Mods
el('m-category').disabled = blocked || modArt !== 'mod'; el('m-category').disabled = blocked || modArt !== 'mod';
// Manche Quellen decken nicht jede Inhaltsart ab (z. B. CurseForge <-> Datenpakete)
const cfOption = el('m-source').querySelector('option[value="curseforge"]');
if (cfOption) cfOption.disabled = !!ART_NUR_MODRINTH[modArt];
if (ART_NUR_MODRINTH[modArt] && el('m-source').value === 'curseforge') el('m-source').value = 'modrinth';
el('m-query').value = ''; el('m-query').value = '';
el('m-more').classList.add('hidden'); el('m-more').classList.add('hidden');
// Ergebnisse der vorigen Ansicht nicht mitschleppen // Ergebnisse der vorigen Ansicht nicht mitschleppen
@@ -2792,9 +2878,13 @@ async function refreshInstalled() {
if (up) kennzeichen.push('<span class="mod-new">Update</span>'); if (up) kennzeichen.push('<span class="mod-new">Update</span>');
if (aus) kennzeichen.push('<span class="mod-off">aus</span>'); if (aus) kennzeichen.push('<span class="mod-off">aus</span>');
if (m.fehlt) kennzeichen.push('<span class="mod-off">Datei fehlt</span>'); if (m.fehlt) kennzeichen.push('<span class="mod-off">Datei fehlt</span>');
// OptiFine lässt sich nicht über Modrinth/CurseForge installieren (kein API-Zugriff auf
// deren Downloads) von Hand hinzugefügt taucht es sonst wie ein beliebiger Mod auf.
const istOptiFine = modArt === 'mod' && /^optifine[_-]/i.test(m.filename || '');
if (istOptiFine) kennzeichen.push('<span class="mod-off" style="opacity:.8">OptiFine</span>');
item.innerHTML = item.innerHTML =
`<div class="mod-icon">${aus ? '🚫' : '🧩'}</div> `<div class="mod-icon">${aus ? '🚫' : istOptiFine ? '⚡' : '🧩'}</div>
<div class="mod-info"> <div class="mod-info">
<div class="mod-name">${escapeHtml(m.title)}${kennzeichen.length ? ' ' + kennzeichen.join(' ') : ''}</div> <div class="mod-name">${escapeHtml(m.title)}${kennzeichen.length ? ' ' + kennzeichen.join(' ') : ''}</div>
<div class="mod-meta">${up <div class="mod-meta">${up
@@ -3054,6 +3144,7 @@ async function openSettings(pane) {
el('s-iso3d').checked = s.iso3dIcons !== false; el('s-iso3d').checked = s.iso3dIcons !== false;
el('s-bedrock').checked = s.bedrockEnabled !== false; el('s-bedrock').checked = s.bedrockEnabled !== false;
el('s-notify').checked = s.desktopNotifications !== false; el('s-notify').checked = s.desktopNotifications !== false;
el('s-autostart').checked = !!s.autostart;
el('s-auto-java').checked = s.autoJava !== false; el('s-auto-java').checked = s.autoJava !== false;
el('s-autobackup').checked = !!s.autoBackupEnabled; el('s-autobackup').checked = !!s.autoBackupEnabled;
el('s-autobackup-every').value = s.autoBackupEvery || 'daily'; el('s-autobackup-every').value = s.autoBackupEvery || 'daily';
@@ -3100,18 +3191,25 @@ async function openSettings(pane) {
['Version', formatVersionLabel(info.version)], ['Version', formatVersionLabel(info.version)],
['Autor', info.author || 'Nicht hinterlegt'], ['Autor', info.author || 'Nicht hinterlegt'],
['Webseite', info.homepage || 'Nicht hinterlegt', info.homepage || ''], ['Webseite', info.homepage || 'Nicht hinterlegt', info.homepage || ''],
['Gitseite', info.gitpage || 'Nicht hinterlegt', info.gitpage || ''], ['Launcher-Webseite', info.launcherWebsite || 'Nicht hinterlegt', info.launcherWebsite || ''],
].map(([label, value, url]) => { ['Lizenz', 'Nur privater, nicht-kommerzieller Gebrauch Details anzeigen', null, 'license'],
].map(([label, value, url, action]) => {
const renderedValue = url const renderedValue = url
? `<a href="#" class="info-meta-link" data-url="${escapeHtml(url)}">${escapeHtml(value)}</a>` ? `<a href="#" class="info-meta-link" data-url="${escapeHtml(url)}">${escapeHtml(value)}</a>`
: escapeHtml(value || ''); : action
? `<a href="#" class="info-meta-link" data-action="${escapeHtml(action)}">${escapeHtml(value)}</a>`
: escapeHtml(value || '');
return `<div class="settings-kv-row"><span class="settings-kv-label">${escapeHtml(label)}</span><span class="settings-kv-value">${renderedValue}</span></div>`; return `<div class="settings-kv-row"><span class="settings-kv-label">${escapeHtml(label)}</span><span class="settings-kv-value">${renderedValue}</span></div>`;
}).join(''); }).join('');
for (const link of el('s-appinfo').querySelectorAll('.info-meta-link')) { for (const link of el('s-appinfo').querySelectorAll('.info-meta-link')) {
link.addEventListener('click', (evt) => { link.addEventListener('click', async (evt) => {
evt.preventDefault(); evt.preventDefault();
const url = link.dataset.url; const url = link.dataset.url;
if (url) window.api.openExternal(url); if (url) { window.api.openExternal(url); return; }
if (link.dataset.action === 'license') {
show('modal-license');
el('license-text').textContent = await window.api.appLicense();
}
}); });
} }
show('modal-settings'); show('modal-settings');
@@ -3174,6 +3272,7 @@ async function saveSettings() {
iso3dIcons: el('s-iso3d').checked, iso3dIcons: el('s-iso3d').checked,
bedrockEnabled: el('s-bedrock').checked, bedrockEnabled: el('s-bedrock').checked,
desktopNotifications: el('s-notify').checked, desktopNotifications: el('s-notify').checked,
autostart: el('s-autostart').checked,
autoJava: el('s-auto-java').checked, autoJava: el('s-auto-java').checked,
autoBackupEnabled: el('s-autobackup').checked, autoBackupEnabled: el('s-autobackup').checked,
autoBackupEvery: el('s-autobackup-every').value, autoBackupEvery: el('s-autobackup-every').value,
@@ -3836,6 +3935,47 @@ function wire() {
el('f-version-type').addEventListener('change', () => fillVersionSelect(el('f-version').value)); el('f-version-type').addEventListener('change', () => fillVersionSelect(el('f-version').value));
el('f-loader').addEventListener('change', () => updateLoaderVersions()); el('f-loader').addEventListener('change', () => updateLoaderVersions());
el('f-version').addEventListener('change', () => updateLoaderVersions()); el('f-version').addEventListener('change', () => updateLoaderVersions());
el('f-optifine-install').addEventListener('click', async () => {
if (!editingId) return;
const btn = el('f-optifine-install');
const status = el('f-optifine-status');
btn.disabled = true;
status.textContent = 'Minecraft wird vorbereitet …';
try {
const res = await window.api.optifineSetup(editingId);
if (res.canceled) { status.textContent = ''; return; }
if (!res.ok) { status.textContent = '✖ ' + (res.message || 'Fehlgeschlagen.'); toast(res.message || 'OptiFine-Installer konnte nicht gestartet werden.', true); return; }
status.textContent = `Installer gestartet Zielordner wurde in die Zwischenablage kopiert: ${res.sharedDir} `
+ 'im OptiFine-Fenster als Minecraft-Ordner einfügen, „Install" klicken, danach hier bestätigen.';
el('f-optifine-confirm').classList.remove('hidden');
} finally {
btn.disabled = false;
}
});
el('f-optifine-confirm').addEventListener('click', async () => {
if (!editingId) return;
const btn = el('f-optifine-confirm');
const status = el('f-optifine-status');
btn.disabled = true;
try {
const res = await window.api.optifineConfirm(editingId);
if (!res.ok) { status.textContent = '✖ ' + (res.message || 'Nicht gefunden.'); toast(res.message || 'OptiFine-Version wurde nicht gefunden.', true); return; }
toast('OptiFine ' + res.versionId + ' übernommen.');
status.textContent = '✔ Übernommen: ' + res.versionId;
const sel = el('f-loader-version');
if (![...sel.options].some((o) => o.value === res.versionId)) {
const o = document.createElement('option');
o.value = res.versionId; o.textContent = res.versionId;
sel.appendChild(o);
}
sel.value = res.versionId;
btn.classList.add('hidden');
} finally {
btn.disabled = false;
}
});
el('f-pick-image').addEventListener('click', async () => { el('f-pick-image').addEventListener('click', async () => {
const p = await window.api.pickImage(); const p = await window.api.pickImage();
if (!p) return; if (!p) return;
@@ -3855,7 +3995,16 @@ function wire() {
el('p-offline').addEventListener('click', () => currentDetailId && launch(currentDetailId)); el('p-offline').addEventListener('click', () => currentDetailId && launch(currentDetailId));
el('p-shortcut').addEventListener('click', async () => { el('p-shortcut').addEventListener('click', async () => {
if (!currentDetailId) return; if (!currentDetailId) return;
const res = await window.api.createShortcut(currentDetailId); const inst = await window.api.getInstance(currentDetailId);
const eingabe = await askPrompt({
title: 'Verknüpfung erstellen',
text: 'Name der Desktop-Verknüpfung (ohne .lnk):',
label: 'Name',
value: inst ? inst.name : '',
okLabel: 'Erstellen',
});
if (eingabe == null) return; // abgebrochen
const res = await window.api.createShortcut(currentDetailId, eingabe.trim());
if (res.ok) toast('Verknüpfung auf dem Desktop erstellt.'); if (res.ok) toast('Verknüpfung auf dem Desktop erstellt.');
else toast(res.message || 'Verknüpfung fehlgeschlagen.', true); else toast(res.message || 'Verknüpfung fehlgeschlagen.', true);
}); });
@@ -4252,10 +4401,16 @@ window.addEventListener('DOMContentLoaded', async () => {
// Spiel gestartet oder beendet -> "spielt gerade" in den Kontenlisten nachziehen // Spiel gestartet oder beendet -> "spielt gerade" in den Kontenlisten nachziehen
window.api.onAccountsChanged(() => { refreshAllAccountLists(); }); window.api.onAccountsChanged(() => { refreshAllAccountLists(); });
// über eine Desktop-Verknüpfung gestartet -> Instanz auswählen und anwerfen // über eine Desktop-Verknüpfung gestartet -> Instanz auswählen und anwerfen
window.api.onLaunchRequest(async (id) => { // (bei einer Server-Verknüpfung zusätzlich direkt mit dem Server verbinden)
window.api.onLaunchRequest(async (payload) => {
const id = payload && typeof payload === 'object' ? payload.id : payload;
const serverIp = payload && typeof payload === 'object' ? payload.serverIp : null;
await selectInstance(id); await selectInstance(id);
launch(id); launch(id, null, serverIp ? { quickPlayServer: serverIp } : null);
}); });
// Empfänger steht jetzt dem Hauptprozess Bescheid geben, damit ein über
// eine Verknüpfung mitgegebener Startwunsch sicher zugestellt werden kann
window.api.notifyReady();
window.api.onUpdateAvailable(showUpdateDialog); window.api.onUpdateAvailable(showUpdateDialog);
window.api.onUpdateProgress(onUpdateProgress); window.api.onUpdateProgress(onUpdateProgress);
window.api.onUpdateChecked(renderUpdateStatus); window.api.onUpdateChecked(renderUpdateStatus);
+17 -2
View File
@@ -236,6 +236,7 @@ const INHALTSARTEN = {
mod: { projectType: 'mod', ordner: 'mods', label: 'Mods', loaderNoetig: true }, mod: { projectType: 'mod', ordner: 'mods', label: 'Mods', loaderNoetig: true },
resourcepack: { projectType: 'resourcepack', ordner: 'resourcepacks', label: 'Ressourcenpakete', loaderNoetig: false }, resourcepack: { projectType: 'resourcepack', ordner: 'resourcepacks', label: 'Ressourcenpakete', loaderNoetig: false },
shader: { projectType: 'shader', ordner: 'shaderpacks', label: 'Shader', loaderNoetig: false }, shader: { projectType: 'shader', ordner: 'shaderpacks', label: 'Shader', loaderNoetig: false },
datapack: { projectType: 'datapack', ordner: 'datapacks', label: 'Datenpakete', loaderNoetig: false },
modpack: { projectType: 'modpack', ordner: null, label: 'Modpacks', loaderNoetig: false }, modpack: { projectType: 'modpack', ordner: null, label: 'Modpacks', loaderNoetig: false },
}; };
@@ -515,7 +516,9 @@ function forgeCdnUrl(fileId, fileName) {
} }
const CF_LOADER = { forge: 1, fabric: 4, quilt: 5, neoforge: 6 }; const CF_LOADER = { forge: 1, fabric: 4, quilt: 5, neoforge: 6 };
const CF_CLASS = { mod: 6, resourcepack: 12, shader: 6552 }; // classId je Inhaltsart bei gameId 432 (Minecraft). modpack=4471 ist die auf
// CurseForge selbst verwendete Kategorie-ID (siehe curseforge.com/minecraft/modpacks).
const CF_CLASS = { mod: 6, resourcepack: 12, shader: 6552, modpack: 4471 };
function cfLoaderType(loader) { function cfLoaderType(loader) {
return CF_LOADER[String(loader || '').toLowerCase()] || 0; return CF_LOADER[String(loader || '').toLowerCase()] || 0;
@@ -598,7 +601,12 @@ async function curseforgeSearch(zugang, {
+ 'Die Weiterleitung reicht für Downloads, nicht für den Katalog.', + 'Die Weiterleitung reicht für Downloads, nicht für den Katalog.',
}; };
} }
const classId = CF_CLASS[art] || CF_CLASS.mod; if (!CF_CLASS[art]) {
// Kein zugeordneter CurseForge-Klassencode für diese Inhaltsart -> lieber gar
// keine Treffer als falsch einsortierte (z. B. Mods unter "Datenpakete").
return { total: 0, hits: [], error: 'CurseForge unterstützt diese Inhaltsart hier nicht bitte Modrinth nutzen.' };
}
const classId = CF_CLASS[art];
const url = new URL(CURSEFORGE + '/mods/search'); const url = new URL(CURSEFORGE + '/mods/search');
url.searchParams.set('gameId', '432'); url.searchParams.set('gameId', '432');
url.searchParams.set('classId', String(classId)); url.searchParams.set('classId', String(classId));
@@ -713,6 +721,12 @@ async function curseforgePickFile(zugang, modId, gameVersion, loader, art) {
}; };
} }
// Beste Modpack-Datei eines CurseForge-Projekts auflösen (für den Katalog-Import,
// analog zu modrinthResolveModpack).
async function curseforgeResolveModpackFile(zugang, modId, gameVersion) {
return curseforgePickFile(zugang, modId, gameVersion, '', 'modpack');
}
async function curseforgeInstall({ zugang, projectId, title, gameVersion, loader, modsDir, art = 'mod' }) { async function curseforgeInstall({ zugang, projectId, title, gameVersion, loader, modsDir, art = 'mod' }) {
if (!zugang) return { ok: false, message: 'Kein CurseForge-Zugang eingerichtet.' }; if (!zugang) return { ok: false, message: 'Kein CurseForge-Zugang eingerichtet.' };
const rawId = String(projectId || '').replace(/^cf-/, ''); const rawId = String(projectId || '').replace(/^cf-/, '');
@@ -814,4 +828,5 @@ module.exports = {
modrinthSearch, modrinthResolveFile, modrinthResolveModpack, modrinthInstall, modrinthProjectTitle, modrinthSearch, modrinthResolveFile, modrinthResolveModpack, modrinthInstall, modrinthProjectTitle,
modrinthFindUpdates, downloadFile, downloadBuffer, inhaltsArt, INHALTSARTEN, modrinthFindUpdates, downloadFile, downloadBuffer, inhaltsArt, INHALTSARTEN,
curseforgeResolveFiles, curseforgeSearch, curseforgeInstall, curseforgeTestKey, forgeCdnUrl, curseforgeZugang, curseforgeResolveFiles, curseforgeSearch, curseforgeInstall, curseforgeTestKey, forgeCdnUrl, curseforgeZugang,
curseforgeResolveModpackFile,
}; };
+7 -2
View File
@@ -76,6 +76,7 @@ const DEFAULT_SETTINGS = {
iso3dIcons: true, // Block-Symbole räumlich darstellen iso3dIcons: true, // Block-Symbole räumlich darstellen
bedrockEnabled: true, // Bedrock-Bereich anzeigen (nur wenn auch installiert) bedrockEnabled: true, // Bedrock-Bereich anzeigen (nur wenn auch installiert)
desktopNotifications: true, // Windows-Meldung bei fertigem Hintergrund-Download/-Backup desktopNotifications: true, // Windows-Meldung bei fertigem Hintergrund-Download/-Backup
autostart: false, // mit Windows starten, im Infobereich statt als Fenster
bgSkin: '', // '' = aus, 'active' = aktives Konto, sonst Konto-UUID bgSkin: '', // '' = aus, 'active' = aktives Konto, sonst Konto-UUID
bgSkinOpacity: 55, // Deckkraft des Hintergrund-Skins in % bgSkinOpacity: 55, // Deckkraft des Hintergrund-Skins in %
bgSkinPose: 'walking', // Pose des 3D-Renders (falls der Pose-Dienst erreichbar ist) bgSkinPose: 'walking', // Pose des 3D-Renders (falls der Pose-Dienst erreichbar ist)
@@ -133,8 +134,12 @@ function gameDir(id) {
* "mod" behält die bisherigen Namen, damit vorhandene Instanzen unverändert * "mod" behält die bisherigen Namen, damit vorhandene Instanzen unverändert
* weiterlaufen. * weiterlaufen.
*/ */
const INHALT_ORDNER = { mod: 'mods', resourcepack: 'resourcepacks', shader: 'shaderpacks' }; const INHALT_ORDNER = {
const INHALT_INDEX = { mod: 'mods.json', resourcepack: 'resourcepacks.json', shader: 'shaders.json' }; mod: 'mods', resourcepack: 'resourcepacks', shader: 'shaderpacks', datapack: 'datapacks',
};
const INHALT_INDEX = {
mod: 'mods.json', resourcepack: 'resourcepacks.json', shader: 'shaders.json', datapack: 'datapacks.json',
};
function inhaltsDir(id, art = 'mod') { function inhaltsDir(id, art = 'mod') {
return path.join(gameDir(id), INHALT_ORDNER[art] || INHALT_ORDNER.mod); return path.join(gameDir(id), INHALT_ORDNER[art] || INHALT_ORDNER.mod);
+11 -2
View File
@@ -230,8 +230,17 @@ async function downloadAndInstall(asset, onProgress) {
const dir = updateDir(); const dir = updateDir();
fs.mkdirSync(dir, { recursive: true }); fs.mkdirSync(dir, { recursive: true });
const dest = path.join(dir, asset.name.replace(/[^\w.\-]/g, '_')); let dest = path.join(dir, asset.name.replace(/[^\w.\-]/g, '_'));
fs.rmSync(dest, { force: true }); try {
fs.rmSync(dest, { force: true });
} catch {
// Alte Datei ist gesperrt (Virenscanner prüft sie gerade, oder ein vorheriger
// Installer-Versuch läuft noch) - dann eben unter neuem Namen laden, statt mit
// EPERM abzubrechen. Der Rest wird bei nächster Gelegenheit von
// cleanupUpdateCache() aufgeräumt (ab 10 Minuten Alter).
const parsed = path.parse(dest);
dest = path.join(parsed.dir, `${parsed.name}-${Date.now()}${parsed.ext}`);
}
const res = await fetch(asset.url, { headers: { 'User-Agent': UA } }); const res = await fetch(asset.url, { headers: { 'User-Agent': UA } });
if (!res.ok) throw new Error('Download fehlgeschlagen: HTTP ' + res.status); if (!res.ok) throw new Error('Download fehlgeschlagen: HTTP ' + res.status);