Upload via GUI (39 Dateien)

This commit is contained in:
Git Manager GUI
2026-08-02 17:01:18 +02:00
parent a803f297ea
commit eff6403dd5
26 changed files with 3536 additions and 134 deletions

View File

@@ -6,7 +6,7 @@
Ein Minecraft-Launcher für Windows mit getrennten Instanzen, Mod-Unterstützung und Microsoft-Anmeldung.
Aktueller Stand: Version 1.0.1 Beta
Aktueller Stand: Version 1.0.3
</div>
@@ -103,7 +103,7 @@ Der Bedrock-Bereich ist für bereits installierte Bedrock-Versionen unter Window
## Links
- Webseite: https://M_Viper.de
- Webseite: https://m-viper.de
- Gitseite: https://git.viper.ipv64.net
- Autor: M_Viper

View File

@@ -1,9 +1,9 @@
{
"name": "aeromc",
"productName": "AeroMC",
"version": "1.0.1-beta",
"version": "1.0.3",
"description": "AeroMC Instanz-basierter Minecraft-Launcher (à la MultiMC/Prism)",
"homepage": "https://M_Viper.de",
"homepage": "https://m-viper.de",
"main": "src/main.js",
"author": "M_Viper",
"repository": {
@@ -13,6 +13,8 @@
"license": "MIT",
"scripts": {
"start": "electron .",
"prebuild": "node scripts/write-build-config.js",
"prebuild:portable": "node scripts/write-build-config.js",
"build": "electron-builder --win nsis --x64 && node -e \"const fs=require('fs');const path=require('path');['dist/builder-debug.yml','dist/builder-effective-config.yaml','dist/win-unpacked'].forEach((target)=>fs.rmSync(target,{recursive:true,force:true}));if(fs.existsSync('dist')){const source=path.join('dist','AeroMC.exe');const target=path.join('dist','AeroMC-Setup.exe');if(fs.existsSync(target))fs.rmSync(target,{force:true});if(fs.existsSync(source))fs.renameSync(source,target);for(const name of fs.readdirSync('dist')){if((name.endsWith('.exe')&&name!=='AeroMC-Setup.exe'&&name!=='AeroMC-Portable.exe')||name.endsWith('.blockmap')){fs.rmSync(path.join('dist',name),{force:true})}}}\"",
"build:portable": "electron-builder --win portable --x64 && node -e \"const fs=require('fs');const path=require('path');['dist/builder-debug.yml','dist/builder-effective-config.yaml','dist/win-unpacked'].forEach((target)=>fs.rmSync(target,{recursive:true,force:true}));if(fs.existsSync('dist')){const source=path.join('dist','AeroMC.exe');const target=path.join('dist','AeroMC-Portable.exe');if(fs.existsSync(target))fs.rmSync(target,{force:true});if(fs.existsSync(source))fs.renameSync(source,target);for(const name of fs.readdirSync('dist')){if((name.endsWith('.exe')&&name!=='AeroMC-Setup.exe'&&name!=='AeroMC-Portable.exe')||name.endsWith('.blockmap')){fs.rmSync(path.join('dist',name),{force:true})}}}\""
},

View File

@@ -0,0 +1,43 @@
'use strict';
/*
* write-build-config.js Vorgaben aus der Umgebung ins Paket schreiben
* ---------------------------------------------------------------------
* Läuft automatisch vor jedem Build und erzeugt src/build-config.json.
*
* Damit landen Adresse und Token der CurseForge-Weiterleitung im fertigen
* Paket, ohne je im Quelltext oder in einem Repository zu stehen. Gesetzt
* werden sie beim Bauen als Umgebungsvariablen:
*
* PowerShell:
* $env:AEROMC_CF_PROXY_URL = "https://cf.example.de"
* $env:AEROMC_CF_PROXY_TOKEN = "..."
* npm run build
*
* bash:
* AEROMC_CF_PROXY_URL=https://cf.example.de AEROMC_CF_PROXY_TOKEN=... npm run build
*
* Ohne gesetzte Variablen wird eine leere Vorgabe geschrieben der Launcher
* verhält sich dann wie bisher und jeder Nutzer trägt selbst etwas ein.
*
* Zur Ehrlichkeit: Ein Token im ausgelieferten Paket ist auslesbar. Es hält
* Gelegenheitsnutzer ab, keine entschlossenen. Der API-Schlüssel selbst
* bleibt davon unberührt der liegt ausschließlich auf dem Server.
*/
const fs = require('fs');
const path = require('path');
const ziel = path.join(__dirname, '..', 'src', 'build-config.json');
const config = {
curseforgeProxyUrl: String(process.env.AEROMC_CF_PROXY_URL || '').trim().replace(/\/+$/, ''),
curseforgeProxyToken: String(process.env.AEROMC_CF_PROXY_TOKEN || '').trim(),
};
fs.writeFileSync(ziel, JSON.stringify(config, null, 2) + '\n', 'utf8');
// Den Token selbst nie ausgeben Build-Protokolle landen schnell woanders.
const wo = config.curseforgeProxyUrl || '(keine)';
const tok = config.curseforgeProxyToken ? 'gesetzt (' + config.curseforgeProxyToken.length + ' Zeichen)' : 'keins';
console.log(`build-config.json geschrieben Weiterleitung: ${wo}, Token: ${tok}`);

View File

@@ -0,0 +1,40 @@
# Vorlage. Kopieren nach .env und ausfüllen.
# .env gehört NICHT in ein Repository (steht bereits in der .gitignore).
# Pflicht: dein CurseForge-API-Key von console.curseforge.com
CF_API_KEY=hier-deinen-schluessel-eintragen
# Port und Adresse, auf denen der Dienst lauscht.
# Steht ein Reverse-Proxy davor, reicht 127.0.0.1 als HOST.
PORT=8420
HOST=0.0.0.0
# Gemeinsames Geheimnis zwischen Launcher und Server. Hält Scanner und
# Zufallsfunde zuverlässig ab deine Domain steht durch das TLS-Zertifikat
# öffentlich in den Certificate-Transparency-Listen und wird gefunden.
# Gegen jemanden, der gezielt die .exe auspackt, hilft es nicht.
#
# Mehrere Token durch Komma getrennt. Das brauchst du beim Wechseln:
# altes und neues eine Weile parallel eintragen, damit ältere Installationen
# weiterlaufen, bis alle das Update haben danach das alte streichen.
# CF_PROXY_TOKEN=neues-token,altes-token
#
# Leer lassen = keine Tokenprüfung.
CF_PROXY_TOKEN=
# Anfragen je Minute und Absender. 0 = keine Begrenzung.
RATE_LIMIT_PER_MIN=60
# Obergrenze für durchgereichte Dateien in MB. 0 = keine Grenze.
MAX_DOWNLOAD_MB=512
# Anzahl der Reverse-Proxy-Stufen davor:
# 0 = keine, der Dienst ist direkt erreichbar
# 1 = ein Proxy, z. B. Nginx Proxy Manager oder Caddy
# 2 = zwei, z. B. Cloudflare vor dem Nginx Proxy Manager
#
# Die Zahl zählt, nicht nur "an oder aus": nginx hängt die echte Adresse an
# X-Forwarded-For an, statt sie zu ersetzen. Gezählt wird deshalb von rechts.
# Zu hoch angesetzt, könnte ein Aufrufer eine Adresse erfinden und die
# Ratenbegrenzung umgehen im Zweifel lieber 0.
TRUST_PROXY=0

View File

@@ -0,0 +1,19 @@
FROM node:22-alpine
# keine Abhängigkeiten das Programm nutzt nur Node-Bordmittel
WORKDIR /app
COPY index.js ./
ENV PORT=8420 \
HOST=0.0.0.0 \
NODE_ENV=production
# nicht als root laufen
USER node
EXPOSE 8420
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
CMD node -e "fetch('http://127.0.0.1:'+(process.env.PORT||8420)+'/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
CMD ["node", "index.js"]

View File

@@ -0,0 +1,160 @@
# AeroMC CurseForge-Weiterleitung
Kleiner Dienst, der den CurseForge-API-Schlüssel hält und die Anfragen
stellvertretend für den Launcher stellt.
## Wozu das Ganze
CurseForge erlaubt es nicht, den API-Schlüssel weiterzugeben. In einer
verteilten `.exe` wäre er für jeden auslesbar das Entpacken einer
Electron-Anwendung dauert keine Minute. Deshalb bleibt der Schlüssel hier
auf dem Server, und der Launcher kennt nur die Adresse dieses Dienstes.
## Wege
| Weg | Zweck |
|---|---|
| `GET /health` | Lebenszeichen, verrät nichts über den Schlüssel |
| `POST /v1/mods/files` | Dateiliste auflösen, Rumpf `{ "fileIds": [...] }`, höchstens 100 je Anfrage |
| `GET /v1/download?url=…` | Mod-Datei durchreichen; das CurseForge-CDN verlangt seit Juli 2026 ebenfalls einen Schlüssel |
## Einrichten
```bash
cp .env.example .env
# CF_API_KEY eintragen
```
### Ohne Docker
Braucht nur Node 18 oder neuer, keine Pakete.
```bash
node index.js
```
Dauerhaft über systemd: `aeromc-cf-proxy.service` nach
`/etc/systemd/system/` kopieren, Pfade und Benutzer darin anpassen, dann
```bash
systemctl daemon-reload
systemctl enable --now aeromc-cf-proxy
```
### Mit pm2
`ecosystem.config.js` liegt bei und liest die `.env` selbst ein pm2 tut das
von sich aus nämlich nicht.
```bash
pm2 start ecosystem.config.js
pm2 save
pm2 startup # einmalig, für den Start nach einem Neustart
```
Nach jeder Änderung an der `.env`:
```bash
pm2 restart aeromc-cf-proxy --update-env
```
Ohne `--update-env` behält pm2 die alten Werte, und du suchst lange nach dem
Grund, warum der neue Schlüssel nicht greift.
Nachsehen, ob er läuft:
```bash
pm2 logs aeromc-cf-proxy --lines 20
```
**Nicht auf Cluster-Betrieb umstellen.** Die Konfiguration steht bewusst auf
`exec_mode: 'fork'` mit einem Prozess. Die Ratenbegrenzung zählt im
Arbeitsspeicher bei vier Arbeitern hätte jeder seine eigene Zählung, und
die Grenze wäre in Wahrheit viermal so hoch wie eingestellt.
### Mit Docker
```bash
docker compose up -d
```
## Launcher einstellen
In den Launcher-Einstellungen unter **Netzwerk → CurseForge** die Adresse
eintragen, zum Beispiel `https://cf.example.de`. Das Feld „Eigener API-Key"
bleibt leer wer dort einen Schlüssel einträgt, geht direkt zu CurseForge
und nutzt diesen Dienst nicht.
Soll die Adresse für alle Nutzer voreingestellt sein, trage sie in
`src/services.js` bei `STANDARD_WEITERLEITUNG` ein. Die Adresse ist kein
Geheimnis, nur der Schlüssel ist eines.
## Absicherung
Der Dienst steht offen im Netz. Eingebaut ist:
- **Host-Liste** durchgereicht wird nur an `edge.forgecdn.net`,
`mediafilez.forgecdn.net` und `media.forgecdn.net`. Ohne diese Prüfung
könnte jemand den Dienst Adressen in deinem Heimnetz abrufen lassen und
die Antwort mitlesen.
- **Umleitungen** werden selbst verfolgt und bei jedem Sprung erneut gegen
die Host-Liste geprüft, höchstens vier Sprünge.
- **Ratenbegrenzung** je Absender, einstellbar über `RATE_LIMIT_PER_MIN`.
- **Größengrenze** für durchgereichte Dateien über `MAX_DOWNLOAD_MB`.
- **Optionales Token** über `CF_PROXY_TOKEN`.
Zum Token eine ehrliche Einordnung: Es steckt dann auch im Launcher und ist
dort auslesbar. Es hält Gelegenheitsnutzer ab, keine entschlossenen. Die
Ratenbegrenzung ist der wirksamere Schutz für dein Kontingent.
### Reverse-Proxy: `TRUST_PROXY` richtig setzen
`TRUST_PROXY` ist keine Ja/Nein-Angabe, sondern die **Anzahl der Stufen** vor
dem Dienst: `0` ohne Proxy, `1` mit Nginx Proxy Manager, `2` mit Cloudflare
davor.
Der Grund: nginx **ergänzt** `X-Forwarded-For`, statt es zu ersetzen. Schickt
ein Aufrufer selbst `X-Forwarded-For: 1.2.3.4`, steht hinterher
`1.2.3.4, <echte-IP>` darin. Deshalb wird von rechts gezählt die eigenen
Proxy-Stufen sind die einzigen Einträge, die niemand von außen setzen kann.
Zu hoch angesetzt, greift der Dienst auf einen Eintrag zu, den der Aufrufer
selbst erfunden hat: Dann kann sich jeder mit jeder Anfrage eine neue Adresse
ausdenken und die Ratenbegrenzung ins Leere laufen lassen. Im Zweifel `0`.
### Nginx Proxy Manager
Im Proxy Host eintragen:
| Feld | Wert |
|---|---|
| Scheme | `http` |
| Forward Hostname / IP | Containername oder LAN-Adresse des Dienstes |
| Forward Port | `8420` |
| Block Common Exploits | an |
| Websockets Support | nicht nötig |
Im Reiter **SSL**: Let's-Encrypt-Zertifikat anfordern, „Force SSL" und
HTTP/2 aktivieren. Ohne HTTPS ginge das Token im Klartext über die Leitung.
Im Reiter **Advanced** empfiehlt sich für die durchgereichten Mod-Dateien:
```nginx
proxy_buffering off;
proxy_read_timeout 300s;
```
Ohne `proxy_buffering off` schreibt nginx große Dateien erst vollständig in
eine temporäre Datei, bevor der Launcher das erste Byte sieht bei
mehreren hundert MB fühlt sich das wie ein Hänger an.
Läuft der Nginx Proxy Manager selbst in Docker, gehören beide ins gleiche
Docker-Netz und der Port muss gar nicht nach außen gebunden werden: In der
`docker-compose.yml` dann den `ports`-Block weglassen. Sonst wenigstens auf
`127.0.0.1:8420:8420` einschränken, damit der Dienst nicht an der Absicherung
des Proxys vorbei erreichbar ist.
## Was der Dienst nicht tut
Er speichert nichts, protokolliert keine Adressen der Nutzer und gibt
Schlüsselfehler von CurseForge nicht im Wortlaut nach außen weiter.

View File

@@ -0,0 +1,25 @@
[Unit]
Description=AeroMC CurseForge-Weiterleitung
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
# Pfad und Benutzer anpassen
WorkingDirectory=/opt/aeromc-cf-proxy
EnvironmentFile=/opt/aeromc-cf-proxy/.env
ExecStart=/usr/bin/node /opt/aeromc-cf-proxy/index.js
User=aeromc
Group=aeromc
Restart=always
RestartSec=5
# Absicherung: der Dienst braucht keinen Schreibzugriff und keine neuen Rechte
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,14 @@
services:
aeromc-cf-proxy:
build: .
container_name: aeromc-cf-proxy
restart: unless-stopped
env_file: .env
ports:
# nach außen nur binden, wenn kein Reverse-Proxy davorsteht.
# Mit Reverse-Proxy besser: "127.0.0.1:8420:8420"
- "8420:8420"
# der Dienst braucht nichts zu schreiben
read_only: true
security_opt:
- no-new-privileges:true

View File

@@ -0,0 +1,54 @@
'use strict';
/*
* pm2-Konfiguration für die CurseForge-Weiterleitung.
*
* pm2 start ecosystem.config.js
* pm2 save
*
* pm2 liest .env-Dateien nicht von selbst, deshalb tut es diese Datei.
* So bleibt der Schlüssel in der .env und steht nicht in der Konfiguration,
* die man eher mal weitergibt oder eincheckt.
*/
const fs = require('fs');
const path = require('path');
// kleiner .env-Leser Kommentare, Anführungszeichen und Werte mit "=" inklusive
function leseEnv(datei) {
const werte = {};
if (!fs.existsSync(datei)) return werte;
for (const zeile of fs.readFileSync(datei, 'utf8').split(/\r?\n/)) {
const t = zeile.trim();
if (!t || t.startsWith('#')) continue;
const i = t.indexOf('=');
if (i < 1) continue;
const name = t.slice(0, i).trim();
let wert = t.slice(i + 1).trim();
const q = wert[0];
if ((q === '"' || q === "'") && wert.endsWith(q) && wert.length > 1) wert = wert.slice(1, -1);
werte[name] = wert;
}
return werte;
}
module.exports = {
apps: [{
name: 'aeromc-cf-proxy',
script: 'index.js',
cwd: __dirname,
// Fork-Betrieb mit genau einem Prozess. Im Cluster-Betrieb hätte jeder
// Arbeiter seine eigene Zählung die Ratenbegrenzung wäre dann bei
// vier Prozessen viermal so hoch wie eingestellt.
exec_mode: 'fork',
instances: 1,
autorestart: true,
max_restarts: 10,
restart_delay: 3000,
max_memory_restart: '256M',
env: leseEnv(path.join(__dirname, '.env')),
}],
};

View File

@@ -0,0 +1,344 @@
'use strict';
/*
* AeroMC CurseForge-Weiterleitung
* ---------------------------------
* Hält den CurseForge-API-Schlüssel und stellt die Anfragen stellvertretend
* für den Launcher. So bleibt der Schlüssel auf dem Server: in der verteilten
* .exe wäre er für jeden auslesbar, und CurseForge untersagt die Weitergabe
* an Dritte ausdrücklich.
*
* Wege:
* GET /health Lebenszeichen, ohne Schlüsselangaben
* POST /v1/mods/files Dateiliste auflösen (Rumpf: { fileIds: [...] })
* GET /v1/download?url=... Mod-Datei durchreichen (CDN verlangt seit
* Juli 2026 ebenfalls einen Schlüssel)
*
* Einstellungen über Umgebungsvariablen, siehe .env.example.
*/
const http = require('http');
const { Readable } = require('stream');
const KEY = (process.env.CF_API_KEY || '').trim();
const PORT = Number(process.env.PORT || 8420);
const HOST = process.env.HOST || '0.0.0.0';
/*
* Ein oder mehrere gültige Token, durch Komma getrennt.
*
* Mehrere deshalb, weil das Token in ausgelieferten Paketen steckt: Beim
* Wechsel trägst du eine Zeit lang altes und neues ein, damit ältere
* Installationen weiterlaufen, bis alle das Update haben. Danach das alte
* streichen.
*/
const TOKENS = new Set(
String(process.env.CF_PROXY_TOKEN || '')
.split(',').map((t) => t.trim()).filter(Boolean),
);
const LIMIT_PRO_MIN = Number(process.env.RATE_LIMIT_PER_MIN || 60);
const MAX_MB = Number(process.env.MAX_DOWNLOAD_MB || 512);
// Anzahl der Reverse-Proxies davor. 0 = keiner, 1 = z. B. Nginx Proxy Manager,
// 2 = z. B. Cloudflare vor dem Proxy Manager.
const PROXY_STUFEN = Math.max(0, Number(process.env.TRUST_PROXY || 0) || 0);
const CF_API = 'https://api.curseforge.com/v1';
// Ohne User-Agent weisen Dienste hinter Cloudflare Anfragen gerne mit 403 ab.
const UA = 'AeroMC-Launcher-Proxy/1.0 (+https://git.viper.ipv64.net)';
function cfKopfzeilen(weitere) {
return Object.assign({ 'x-api-key': KEY, Accept: 'application/json', 'User-Agent': UA }, weitere || {});
}
// Nur diese Hosts dürfen durchgereicht werden. Ohne diese Liste wäre der
// Server ein offenes Tor in dein Netz jemand könnte ihn Adressen im
// Heimnetz abrufen lassen und die Antwort mitlesen.
const ERLAUBTE_HOSTS = new Set([
'edge.forgecdn.net',
'mediafilez.forgecdn.net',
'media.forgecdn.net',
]);
if (!KEY) {
console.error('CF_API_KEY fehlt. Bitte setzen (siehe .env.example) und neu starten.');
process.exit(1);
}
// ---------------------------------------------------------------------------
// Ratenbegrenzung: einfaches Zeitfenster je Absender
// ---------------------------------------------------------------------------
const zaehler = new Map(); // ip -> { anzahl, bis }
/*
* Ermittelt den Absender die Grundlage der Ratenbegrenzung.
*
* nginx & Co. hängen die echte Adresse hinten an X-Forwarded-For an, statt
* den Kopfeintrag zu ersetzen. Alles davor kann der Aufrufer frei erfinden.
* Vertrauenswürdig ist deshalb nur der Eintrag, den die letzte eigene
* Proxy-Stufe geschrieben hat: von rechts gezählt.
*
* Aufrufer schickt "1.2.3.4", ein Proxy davor
* -> "1.2.3.4, <echte-IP>" -> mit TRUST_PROXY=1 zählt <echte-IP>
*/
function absender(req) {
if (PROXY_STUFEN > 0) {
const kette = String(req.headers['x-forwarded-for'] || '')
.split(',').map((s) => s.trim()).filter(Boolean);
const i = kette.length - PROXY_STUFEN;
if (i >= 0 && kette[i]) return kette[i];
// Kette kürzer als erwartet -> nicht dem Aufrufer glauben
}
return req.socket.remoteAddress || 'unbekannt';
}
function darfNoch(ip) {
if (LIMIT_PRO_MIN <= 0) return true;
const jetzt = Date.now();
const e = zaehler.get(ip);
if (!e || jetzt > e.bis) { zaehler.set(ip, { anzahl: 1, bis: jetzt + 60000 }); return true; }
e.anzahl++;
return e.anzahl <= LIMIT_PRO_MIN;
}
// alte Einträge regelmäßig wegräumen, sonst wächst die Liste unbegrenzt
setInterval(() => {
const jetzt = Date.now();
for (const [ip, e] of zaehler) if (jetzt > e.bis) zaehler.delete(ip);
}, 60000).unref();
// ---------------------------------------------------------------------------
// Hilfen
// ---------------------------------------------------------------------------
function sendeJson(res, code, daten) {
const koerper = JSON.stringify(daten);
res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8', 'Content-Length': Buffer.byteLength(koerper) });
res.end(koerper);
}
function tokenOk(req) {
if (!TOKENS.size) return true;
return TOKENS.has(String(req.headers['x-aeromc-token'] || '').trim());
}
function leseKoerper(req, maxBytes = 1_000_000) {
return new Promise((resolve, reject) => {
let daten = '';
let laenge = 0;
req.on('data', (d) => {
laenge += d.length;
if (laenge > maxBytes) { reject(new Error('Anfrage zu groß')); req.destroy(); return; }
daten += d;
});
req.on('end', () => resolve(daten));
req.on('error', reject);
});
}
// prüft die Zieladresse einer Download-Anfrage
function pruefeDownloadZiel(roh) {
let u;
try { u = new URL(roh); } catch { return null; }
if (u.protocol !== 'https:') return null;
if (!ERLAUBTE_HOSTS.has(u.hostname)) return null;
return u;
}
// ---------------------------------------------------------------------------
// Wege
// ---------------------------------------------------------------------------
async function dateilisteAufloesen(req, res) {
let rumpf;
try { rumpf = JSON.parse(await leseKoerper(req)); }
catch { return sendeJson(res, 400, { error: 'Ungültiger Rumpf.' }); }
const ids = Array.isArray(rumpf && rumpf.fileIds) ? rumpf.fileIds.map(Number).filter(Boolean) : [];
if (!ids.length) return sendeJson(res, 400, { error: 'fileIds fehlen.' });
if (ids.length > 100) return sendeJson(res, 400, { error: 'Höchstens 100 fileIds je Anfrage.' });
const antwort = await fetch(CF_API + '/mods/files', {
method: 'POST',
headers: cfKopfzeilen({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ fileIds: ids }),
});
if (!antwort.ok) {
// Schlüsselprobleme nicht im Wortlaut weiterreichen
const code = antwort.status === 401 || antwort.status === 403 ? 502 : antwort.status;
return sendeJson(res, code, { error: 'CurseForge antwortet mit HTTP ' + antwort.status });
}
sendeJson(res, 200, await antwort.json());
}
async function dateiDurchreichen(req, res, url) {
const ziel = pruefeDownloadZiel(url.searchParams.get('url') || '');
if (!ziel) return sendeJson(res, 400, { error: 'Adresse fehlt oder zeigt nicht auf das CurseForge-CDN.' });
// Weiterleitungen selbst verfolgen und jedes Mal den Host neu prüfen
let aktuell = ziel;
let antwort = null;
for (let sprung = 0; sprung < 4; sprung++) {
antwort = await fetch(aktuell, { headers: cfKopfzeilen(), redirect: 'manual' });
const weiter = antwort.headers.get('location');
if (antwort.status >= 300 && antwort.status < 400 && weiter) {
const naechste = pruefeDownloadZiel(new URL(weiter, aktuell).toString());
if (!naechste) return sendeJson(res, 502, { error: 'Weiterleitung führt aus dem CurseForge-CDN heraus.' });
aktuell = naechste;
continue;
}
break;
}
if (!antwort || !antwort.ok) {
return sendeJson(res, antwort ? antwort.status : 502, { error: 'Download fehlgeschlagen.' });
}
const laenge = Number(antwort.headers.get('content-length')) || 0;
if (MAX_MB > 0 && laenge > MAX_MB * 1024 * 1024) {
return sendeJson(res, 413, { error: `Datei größer als erlaubt (${MAX_MB} MB).` });
}
res.writeHead(200, {
'Content-Type': antwort.headers.get('content-type') || 'application/octet-stream',
...(laenge ? { 'Content-Length': String(laenge) } : {}),
});
Readable.fromWeb(antwort.body).pipe(res);
}
// ---------------------------------------------------------------------------
const server = http.createServer(async (req, res) => {
let url;
try { url = new URL(req.url, 'http://localhost'); }
catch { return sendeJson(res, 400, { error: 'Ungültige Anfrage.' }); }
if (url.pathname === '/health') {
return sendeJson(res, 200, { ok: true, dienst: 'aeromc-cf-proxy' });
}
if (!tokenOk(req)) return sendeJson(res, 401, { error: 'Nicht berechtigt.' });
/*
* Hilfe beim Einstellen von TRUST_PROXY: zeigt, was der Dienst von DIESER
* Anfrage sieht. Steht bewusst hinter der Token-Prüfung, damit Scanner
* nichts über den Aufbau erfahren. Aufruf von außen:
*
* curl -s -H 'x-aeromc-token: DEIN-TOKEN' https://deine-adresse/whoami
*
* erkannt == deine echte öffentliche IP -> TRUST_PROXY stimmt
* erkannt == eine interne Adresse -> zu niedrig
* erkannt == eine erfundene Adresse -> zu hoch
*
* Vor der Ratenbegrenzung, damit die Diagnose auch dann noch antwortet,
* wenn man sich gerade selbst ausgesperrt hat.
*/
if (url.pathname === '/whoami') {
const kette = String(req.headers['x-forwarded-for'] || '')
.split(',').map((s) => s.trim()).filter(Boolean);
return sendeJson(res, 200, {
erkannt: absender(req),
proxyStufen: PROXY_STUFEN,
kette,
verbindungVon: req.socket.remoteAddress || null,
hinweis: 'Stimmt "erkannt" mit deiner echten öffentlichen IP überein, ist TRUST_PROXY richtig gesetzt.',
});
}
/*
* Selbsttest: prüft den API-Schlüssel, den DIESER Prozess wirklich geladen
* hat, direkt gegen CurseForge. Beantwortet die zwei Fragen, die man von
* außen sonst nicht auseinanderhalten kann:
* - hat der Prozess überhaupt den neuen Schlüssel?
* - akzeptiert CurseForge ihn?
* Gibt nur Länge und die ersten Zeichen aus (bei CurseForge immer "$2a$10$"),
* niemals den Schlüssel selbst.
*/
if (url.pathname === '/selftest') {
const roh = process.env.CF_API_KEY || '';
const befund = {
schluessel: {
laenge: KEY.length,
beginntMit: KEY.slice(0, 7),
randzeichenEntfernt: roh !== KEY,
sahAusWieCurseForge: /^\$2[aby]\$\d\d\$/.test(KEY),
},
curseforge: null,
urteil: '',
};
/*
* Mehrere Zugänge nacheinander prüfen. Erst der Vergleich zeigt, ob der
* Schlüssel grundsätzlich abgelehnt wird oder nur für bestimmte Bereiche
* fehlt ein einzelner Aufruf kann das nicht unterscheiden.
*/
const PROBEN = [
{ name: 'games Liste aller Spiele', method: 'GET', pfad: '/games' },
{ name: 'games/432 Minecraft', method: 'GET', pfad: '/games/432' },
{ name: 'categories Minecraft-Kategorien', method: 'GET', pfad: '/categories?gameId=432' },
{ name: 'mods/search Modsuche', method: 'GET', pfad: '/mods/search?gameId=432&searchFilter=jei' },
{ name: 'mods/files was der Import braucht', method: 'POST', pfad: '/mods/files', body: { fileIds: [4959679] } },
];
befund.proben = [];
for (const p of PROBEN) {
try {
const a = await fetch(CF_API + p.pfad, {
method: p.method,
headers: cfKopfzeilen(p.body ? { 'Content-Type': 'application/json' } : undefined),
body: p.body ? JSON.stringify(p.body) : undefined,
});
let text = '';
try { text = (await a.text()).replace(/\s+/g, ' ').trim().slice(0, 160); } catch { /* egal */ }
befund.proben.push({
name: p.name,
status: a.status,
server: a.headers.get('server') || null,
antwortAnfang: a.ok ? '(ok)' : text,
});
} catch (err) {
befund.proben.push({ name: p.name, fehler: err.message });
}
}
const stati = befund.proben.map((p) => p.status);
const alleAbgelehnt = stati.every((s) => s === 401 || s === 403);
const alleGut = stati.every((s) => s >= 200 && s < 300);
const nurDateien = befund.proben.find((p) => p.name.startsWith('mods/files'));
if (alleGut) befund.urteil = 'Alle Zugänge offen der Schlüssel ist in Ordnung.';
else if (alleAbgelehnt) {
befund.urteil = 'Kein einziger Zugang offen: Dieser Schlüssel gilt bei CurseForge nicht. '
+ 'Entweder wurde er nach dem Kopieren erneut erzeugt („Regenerate" macht den alten sofort ungültig), '
+ 'oder das Organisationskonto ist nicht freigeschaltet. Kein Fehler im Launcher oder auf diesem Server.';
} else if (nurDateien && (nurDateien.status === 401 || nurDateien.status === 403)) {
befund.urteil = 'Der Schlüssel gilt grundsätzlich, aber nicht für die Dateiabfrage. '
+ 'Das deutet auf einen Schlüssel, der nur für ein einzelnes Spiel gilt für Minecraft-Modpacks '
+ 'wird der Organisationsschlüssel gebraucht.';
} else befund.urteil = 'Gemischtes Bild siehe die einzelnen Proben.';
// Kurzfassung für den schnellen Blick
befund.kurz = befund.proben.map((p) => `${p.name}: ${p.status ?? p.fehler}`);
return sendeJson(res, 200, befund);
}
if (!darfNoch(absender(req))) return sendeJson(res, 429, { error: 'Zu viele Anfragen, bitte kurz warten.' });
try {
if (req.method === 'POST' && url.pathname === '/v1/mods/files') return await dateilisteAufloesen(req, res);
if (req.method === 'GET' && url.pathname === '/v1/download') return await dateiDurchreichen(req, res, url);
sendeJson(res, 404, { error: 'Unbekannter Weg.' });
} catch (err) {
console.error('[Fehler]', err.message);
if (!res.headersSent) sendeJson(res, 500, { error: 'Interner Fehler.' });
else res.destroy();
}
});
server.listen(PORT, HOST, () => {
console.log(`AeroMC CurseForge-Weiterleitung läuft auf ${HOST}:${PORT}`);
console.log(` Ratenbegrenzung: ${LIMIT_PRO_MIN > 0 ? LIMIT_PRO_MIN + '/Minute je Absender' : 'aus'}`);
// niemals die Token selbst ausgeben Protokolle wandern schnell woanders hin
console.log(` Token verlangt : ${TOKENS.size ? 'ja (' + TOKENS.size + ' gültige)' : 'nein'}`);
console.log(` Proxy-Stufen : ${PROXY_STUFEN} (${PROXY_STUFEN ? 'Absender aus X-Forwarded-For' : 'Absender aus der Verbindung'})`);
});
module.exports = { server, pruefeDownloadZiel, darfNoch, absender, ERLAUBTE_HOSTS };

4
src/build-config.json Normal file
View File

@@ -0,0 +1,4 @@
{
"curseforgeProxyUrl": "",
"curseforgeProxyToken": "GetqSQ6K0z4OHet2SCUA5dHBqoKmFCl3a7LT6bZVVS0FAb07ZxVq7r7HS3dUbH6J"
}

180
src/crashreports.js Normal file
View File

@@ -0,0 +1,180 @@
'use strict';
/*
* crashreports.js Absturzberichte von Minecraft auswerten
* ----------------------------------------------------------
* Minecraft legt bei einem Absturz eine Datei unter
* <spielordner>/crash-reports/crash-<datum>-client.txt an. Die enthält
* hunderte Zeilen Systemangaben, aber nur wenige, die weiterhelfen.
*
* Dieses Modul sucht den neuesten Bericht und zieht daraus heraus:
* Beschreibung, Fehlerart, verdächtige Mods und die ersten Stellen im
* Aufrufverlauf.
*/
const fs = require('fs');
const path = require('path');
const zlib = require('zlib');
// größere Berichte gibt es kaum; die Grenze schützt vor Ausreißern
const MAX_BYTES = 2 * 1024 * 1024;
function berichtsOrdner(gameDir) {
return path.join(gameDir, 'crash-reports');
}
/*
* Neuester Bericht im Ordner. seit: nur Berichte ab diesem Zeitpunkt
* (Millisekunden) sonst zeigt man nach einem sauberen Start den Absturz
* von vorgestern an.
*/
function neuesterBericht(gameDir, seit) {
const dir = berichtsOrdner(gameDir);
let eintraege = [];
try { eintraege = fs.readdirSync(dir, { withFileTypes: true }); } catch { return null; }
let bester = null;
for (const e of eintraege) {
if (!e.isFile() || !/^crash-.*\.txt$/i.test(e.name)) continue;
const voll = path.join(dir, e.name);
let st;
try { st = fs.statSync(voll); } catch { continue; }
if (seit && st.mtimeMs < seit) continue;
if (!bester || st.mtimeMs > bester.mtimeMs) bester = { pfad: voll, name: e.name, mtimeMs: st.mtimeMs, groesse: st.size };
}
return bester;
}
/*
* Zieht die aussagekräftigen Zeilen aus einem Bericht.
* -> { beschreibung, fehler, verdaechtigeMods, verlauf: [] }
*/
function auswerten(text) {
const zeilen = String(text || '').split(/\r?\n/);
const befund = { beschreibung: '', fehler: '', verdaechtigeMods: '', verlauf: [] };
for (let i = 0; i < zeilen.length; i++) {
const z = zeilen[i];
const roh = z.trim();
if (!befund.beschreibung && /^Description:/i.test(roh)) {
befund.beschreibung = roh.replace(/^Description:\s*/i, '').trim();
// Die Fehlerzeile steht direkt darunter, nach einer Leerzeile.
for (let j = i + 1; j < Math.min(i + 6, zeilen.length); j++) {
const k = zeilen[j].trim();
if (!k) continue;
befund.fehler = k;
break;
}
continue;
}
// Forge/NeoForge nennen Verdächtige ausdrücklich
if (!befund.verdaechtigeMods && /^Suspected Mods?:/i.test(roh)) {
befund.verdaechtigeMods = roh.replace(/^Suspected Mods?:\s*/i, '').trim();
continue;
}
// die obersten Stellen im Aufrufverlauf dort steht meist der Verursacher
if (befund.verlauf.length < 8 && /^at\s+\S/.test(roh)) {
befund.verlauf.push(roh);
}
}
// Manche Berichte nennen keine Description, dafür eine Ausnahme weiter oben
if (!befund.fehler) {
const treffer = zeilen.find((z) => /^[\w.$]+(Exception|Error)(:|\s|$)/.test(z.trim()));
if (treffer) befund.fehler = treffer.trim();
}
return befund;
}
/*
* Sucht und liest den neuesten Bericht.
* -> { ok: false, grund } oder { ok: true, datei, zeitpunkt, ...auswertung, text }
*/
function lies(gameDir, seit) {
const bericht = neuesterBericht(gameDir, seit);
if (!bericht) return { ok: false, grund: 'kein-bericht' };
if (bericht.groesse > MAX_BYTES) return { ok: false, grund: 'zu-gross', datei: bericht.name };
let text = '';
try { text = fs.readFileSync(bericht.pfad, 'utf8'); }
catch (err) { return { ok: false, grund: 'nicht-lesbar', message: err.message }; }
return Object.assign({
ok: true,
datei: bericht.name,
pfad: bericht.pfad,
zeitpunkt: new Date(bericht.mtimeMs).toISOString(),
text,
}, auswerten(text));
}
/*
* Logdateien der Instanz auflisten. Minecraft schreibt latest.log und legt
* ältere Läufe gepackt als <datum>-<nr>.log.gz daneben.
* -> [{ name, groesse, geaendert, gepackt }] neueste zuerst
*/
function listeLogs(gameDir) {
const dir = path.join(gameDir, 'logs');
let eintraege = [];
try { eintraege = fs.readdirSync(dir, { withFileTypes: true }); } catch { return []; }
const logs = [];
for (const e of eintraege) {
if (!e.isFile()) continue;
const gepackt = /\.log\.gz$/i.test(e.name);
if (!gepackt && !/\.log$/i.test(e.name)) continue;
try {
const st = fs.statSync(path.join(dir, e.name));
logs.push({ name: e.name, groesse: st.size, geaendert: st.mtimeMs, gepackt });
} catch { /* überspringen */ }
}
// latest.log immer zuoberst, danach nach Datum
logs.sort((a, b) => {
if (a.name.toLowerCase() === 'latest.log') return -1;
if (b.name.toLowerCase() === 'latest.log') return 1;
return b.geaendert - a.geaendert;
});
return logs;
}
/*
* Inhalt einer Logdatei. Gepackte werden entpackt.
* maxZeilen begrenzt die Rückgabe ein Log kann zehntausende Zeilen haben,
* und die Oberfläche soll dabei nicht einfrieren.
*/
function liesLog(gameDir, name, maxZeilen = 5000) {
const dir = path.join(gameDir, 'logs');
const voll = path.join(dir, path.basename(String(name || '')));
if (path.dirname(voll) !== dir) return { ok: false, grund: 'bad-name' };
if (!fs.existsSync(voll)) return { ok: false, grund: 'nicht-gefunden' };
let text = '';
try {
if (/\.gz$/i.test(voll)) text = zlib.gunzipSync(fs.readFileSync(voll)).toString('utf8');
else text = fs.readFileSync(voll, 'utf8');
} catch (err) {
return { ok: false, grund: 'nicht-lesbar', message: err.message };
}
const zeilen = text.split(/\r?\n/);
const gekuerzt = zeilen.length > maxZeilen;
// bei langen Logs das Ende zeigen dort steht, was zuletzt passierte
const sichtbar = gekuerzt ? zeilen.slice(zeilen.length - maxZeilen) : zeilen;
return {
ok: true,
name: path.basename(voll),
zeilenGesamt: zeilen.length,
gekuerzt,
text: sichtbar.join('\n'),
};
}
function logOrdner(gameDir) {
return path.join(gameDir, 'logs');
}
module.exports = { lies, auswerten, neuesterBericht, berichtsOrdner, listeLogs, liesLog, logOrdner };

View File

@@ -51,6 +51,8 @@
<button id="p-mods" class="sbtn">Mods anzeigen</button>
<button id="p-worlds" class="sbtn">Welten verwalten</button>
<button id="p-screens" class="sbtn">Bildschirmfotos verwalten</button>
<button id="p-logs" class="sbtn">Logdateien ansehen</button>
<button id="p-servers" class="sbtn">Server verwalten</button>
<div class="hr"></div>
<button id="p-mcfolder" class="sbtn">Minecraft-Ordner</button>
<button id="p-configfolder" class="sbtn">Konfigurations-Ordner</button>
@@ -154,6 +156,7 @@
<input id="f-max-mem" type="number" min="512" step="256" placeholder="global" />
</label>
</div>
<small id="f-mem-hint" class="hint"></small>
<label class="field">
<span>Zusätzliche JVM-Argumente</span>
<input id="f-extra-args" type="text" placeholder="z. B. -XX:+UseG1GC" />
@@ -337,6 +340,10 @@
</div>
<small id="s-java-status" class="hint"></small>
</label>
<label class="checkbox-field">
<input id="s-auto-java" type="checkbox" /> <span>Fehlende Java-Version automatisch herunterladen</span>
</label>
<small class="hint">Holt beim Spielstart die passende Fassung von Eclipse Temurin, wenn keine geeignete Java-Installation gefunden wird. Ablage im Launcher-Ordner, rund 45 MB je Version.</small>
</section>
<section class="settings-card">
@@ -400,6 +407,26 @@
<label class="field"><span>Passwort <small class="opt">(optional)</small></span><input id="s-proxy-pass" type="password" /></label>
</div>
</section>
<section class="settings-card settings-card-wide">
<h5>CurseForge</h5>
<label class="field">
<span>Eigener API-Key <small class="opt">(optional)</small></span>
<input id="s-cf-key" type="password" placeholder="leer lassen, um die Weiterleitung unten zu nutzen" />
<small class="hint">Kostenlos unter console.curseforge.com. Ist hier ein Schlüssel eingetragen, geht der Launcher direkt zu CurseForge und nutzt keine Weiterleitung.</small>
</label>
<div class="field-row">
<label class="field">
<span>Weiterleitung <small class="opt">(optional)</small></span>
<input id="s-cf-proxy" type="text" placeholder="https://cf.example.de" />
</label>
<label class="field">
<span>Token <small class="opt">(falls verlangt)</small></span>
<input id="s-cf-proxy-token" type="password" />
</label>
</div>
<small class="hint">Ein Server, der den Schlüssel für dich hält. Ohne beides funktionieren Modrinth-Modpacks (.mrpack) weiterhin.</small>
</section>
</div>
</div>
@@ -439,6 +466,31 @@
</div>
</section>
<section class="settings-card settings-card-wide">
<h5>Automatisch sichern</h5>
<label class="checkbox-field">
<input id="s-autobackup" type="checkbox" /> <span>Sicherungen nach Zeitplan erstellen</span>
</label>
<div class="field-row">
<label class="field">
<span>Wie oft</span>
<select id="s-autobackup-every">
<option value="daily">Täglich</option>
<option value="weekly">Wöchentlich</option>
</select>
</label>
<label class="field">
<span>Wie viele behalten</span>
<input id="s-autobackup-keep" type="number" min="1" max="50" />
</label>
</div>
<label class="checkbox-field">
<input id="s-autobackup-launch" type="checkbox" /> <span>Zusätzlich vor jedem Spielstart sichern</span>
</label>
<small class="hint">Eine Sicherung umfasst <em>alle</em> Instanzen und kann je nach Umfang einige Minuten dauern. Vor dem Spielstart wird sie abgewartet der Start verzögert sich entsprechend. Ältere automatische Sicherungen werden gelöscht, sobald die eingestellte Anzahl überschritten ist.</small>
<small id="s-autobackup-last" class="hint">Noch keine automatische Sicherung.</small>
</section>
<section class="settings-card settings-card-wide">
<h5>Wiederherstellen</h5>
<small class="hint">Wähle ein Backup aus Dokumente/AeroMC Launcher/Backups aus. Achtung: Alle vorhandenen Instanzen werden beim Wiederherstellen mit dem Inhalt des Backups überschrieben. Launcher-Einstellungen werden ebenfalls wiederhergestellt. Konten und Tokens bleiben unverändert.</small>
@@ -473,7 +525,7 @@
<div id="s-appinfo" class="settings-kv info-meta"></div>
<div class="info-actions">
<button id="s-check-update" class="btn">Nach Updates suchen</button>
<span id="s-update-status" class="info-status">Kein Update-Check ausgefuehrt.</span>
<span id="s-update-status" class="info-status">Kein Update-Check ausgeführt.</span>
</div>
</section>
</div>
@@ -560,6 +612,11 @@
<button id="m-more" class="btn btn-mini hidden">Mehr laden</button>
</div>
<div id="m-pane-installed" class="hidden">
<div class="mmc-bar">
<button id="m-check-updates" class="btn btn-mini">Nach Updates suchen</button>
<button id="m-apply-updates" class="btn btn-primary btn-mini hidden">Alle aktualisieren</button>
<span id="m-update-status" class="hint"></span>
</div>
<div id="m-installed" class="mods-list"><div class="mods-empty">Noch keine Mods.</div></div>
</div>
</div>
@@ -589,9 +646,10 @@
<p id="login-status" class="hint">Warte auf Bestätigung …</p>
</div>
<p id="login-hint" class="hint">Melde dich mit deinem Microsoft-Konto an (Minecraft Java Edition nötig). Mehrere Konten sind möglich das aktive wird zum Spielen verwendet.</p>
<p id="login-hint" class="hint">Melde dich mit deinem Microsoft-Konto an (Minecraft Java Edition nötig). Das aktive Konto wird beim nächsten Start verwendet. Für zwei Konten gleichzeitig: eine Instanz starten, hier das zweite Konto aktivieren und eine weitere Instanz starten laufende Spiele behalten ihr Konto.</p>
</div>
<div class="modal-foot">
<button id="login-skin" class="btn">Skin ändern…</button>
<div class="spacer"></div>
<button id="login-add" class="btn btn-primary" data-ic="plus">Konto hinzufügen</button>
<button class="btn" data-close>Schließen</button>
@@ -662,6 +720,147 @@
</div>
</div>
<!-- ===================== Dialog: Serverliste ===================== -->
<div id="modal-servers" class="modal hidden">
<div class="modal-card modal-lg">
<div class="modal-head">
<h3 id="servers-title">Server</h3>
<button class="modal-close" data-close></button>
</div>
<div class="modal-body">
<p class="hint">Diese Liste steht in der Datei servers.dat der Instanz und erscheint im Spiel unter „Mehrspieler". Änderungen wirken erst beim nächsten Start.</p>
<div class="field-row" style="margin-top:12px">
<label class="field"><span>Name</span><input id="srv-name" type="text" placeholder="z. B. Mein Server" /></label>
<label class="field"><span>Adresse</span><input id="srv-ip" type="text" placeholder="mc.example.de oder 192.168.1.50:25566" /></label>
<button id="srv-add" class="btn btn-primary" style="align-self:flex-end">Hinzufügen</button>
</div>
<div id="srv-list" class="mods-list" style="margin-top:12px"></div>
</div>
<div class="modal-foot">
<div class="spacer"></div>
<button class="btn" data-close>Schließen</button>
</div>
</div>
</div>
<!-- ===================== Dialog: Server übertragen ===================== -->
<div id="modal-srv-copy" class="modal hidden">
<div class="modal-card modal-sm">
<div class="modal-head">
<h3>Server übertragen</h3>
<button class="modal-close" data-close></button>
</div>
<div class="modal-body">
<p id="srv-copy-sub" class="hint"></p>
<div id="srv-copy-list" class="mods-list"></div>
</div>
<div class="modal-foot">
<div class="spacer"></div>
<button class="btn" data-close>Abbrechen</button>
<button id="srv-copy-do" class="btn btn-primary">Übertragen</button>
</div>
</div>
</div>
<!-- ===================== Dialog: Skin ===================== -->
<div id="modal-skin" class="modal hidden">
<div class="modal-card modal-lg">
<div class="modal-head">
<h3>Skin ändern</h3>
<button class="modal-close" data-close></button>
</div>
<div class="modal-body">
<p id="skin-konto" class="hint"></p>
<div class="skin-vorschau">
<div class="skin-spalte">
<div class="skin-label">Aktuell</div>
<div class="skin-bild">
<img id="skin-aktuell" alt="" class="hidden" />
<span id="skin-aktuell-leer" class="hint">wird geladen …</span>
</div>
</div>
<div class="skin-spalte">
<div class="skin-label">Neu</div>
<div class="skin-bild">
<img id="skin-neu" alt="" class="hidden" />
<span id="skin-neu-leer" class="hint">noch keine Datei gewählt</span>
</div>
</div>
</div>
<small class="hint">Die Vorschau zeigt die Figur so, wie sie im Spiel aussieht. Die Armform wirkt sich auf die Darstellung aus.</small>
<div class="path-row" style="margin-top:12px">
<input id="skin-pfad" type="text" readonly placeholder="PNG mit 64×64 Pixeln" />
<button id="skin-pick" class="btn">Datei wählen…</button>
</div>
<label class="field" style="margin-top:10px">
<span>Armform</span>
<select id="skin-variante">
<option value="classic">Klassisch (breite Arme, Steve)</option>
<option value="slim">Schmal (Alex)</option>
</select>
</label>
<p id="skin-status" class="hint"></p>
</div>
<div class="modal-foot">
<button id="skin-reset" class="btn btn-danger">Auf Standard zurücksetzen</button>
<div class="spacer"></div>
<button class="btn" data-close>Schließen</button>
<button id="skin-apply" class="btn btn-primary" disabled>Skin setzen</button>
</div>
</div>
</div>
<!-- ===================== Dialog: Logdateien ===================== -->
<div id="modal-logs" class="modal hidden">
<div class="modal-card modal-lg">
<div class="modal-head">
<h3 id="logs-title">Logdateien</h3>
<button class="modal-close" data-close></button>
</div>
<div class="modal-body">
<div class="mmc-bar">
<label class="field" style="margin:0;flex:1">
<span>Datei</span>
<select id="logs-select"></select>
</label>
<span id="logs-info" class="hint"></span>
</div>
<pre id="logs-text" class="crash-voll">Wähle eine Datei …</pre>
</div>
<div class="modal-foot">
<button id="logs-folder" class="btn" data-ic="folder">Ordner öffnen</button>
<div class="spacer"></div>
<button class="btn" data-close>Schließen</button>
<button id="logs-copy" class="btn btn-primary">Log kopieren</button>
</div>
</div>
</div>
<!-- ===================== Dialog: Absturzbericht ===================== -->
<div id="modal-crash" class="modal hidden">
<div class="modal-card modal-lg">
<div class="modal-head">
<h3>Minecraft ist abgestürzt</h3>
<button class="modal-close" data-close></button>
</div>
<div class="modal-body">
<p id="crash-sub" class="hint"></p>
<div id="crash-kern" class="crash-kern"></div>
<details class="crash-details">
<summary>Vollständigen Bericht anzeigen</summary>
<pre id="crash-voll" class="crash-voll"></pre>
</details>
</div>
<div class="modal-foot">
<button id="crash-folder" class="btn" data-ic="folder">Ordner öffnen</button>
<button id="crash-console" class="btn">Konsole</button>
<div class="spacer"></div>
<button class="btn" data-close>Schließen</button>
<button id="crash-copy" class="btn btn-primary">Bericht kopieren</button>
</div>
</div>
</div>
<!-- ===================== Dialog: Update ===================== -->
<div id="modal-update" class="modal hidden">
<div class="modal-card modal-sm">
@@ -681,10 +880,11 @@
<div class="upd-bar"><div id="upd-bar-fill" class="upd-bar-fill"></div></div>
<div id="upd-status" class="hint">Wird heruntergeladen …</div>
</div>
<p class="hint">Der Launcher lädt das Update herunter und startet die Installation automatisch.</p>
<p id="upd-hint" class="hint">Der Launcher lädt das Update herunter und startet die Installation automatisch.</p>
</div>
<div class="modal-foot">
<button class="btn" data-close>Später</button>
<button id="upd-open" class="btn btn-primary hidden">Release öffnen</button>
<button id="upd-install" class="btn btn-primary">Installieren</button>
</div>
</div>
@@ -707,13 +907,13 @@
</div>
<div id="import-pane-zip" class="import-pane">
<p class="import-copy">Importiert eine exportierte AeroMC-Instanz aus einem ZIP-Archiv.</p>
<p class="hint">Wähle ein Archiv mit einer gültigen instance.json. Der Launcher legt daraus automatisch eine neue Instanz an.</p>
<p class="import-copy">Importiert eine einzelne Instanz aus einem ZIP-Archiv.</p>
<p class="hint">Erkannt werden AeroMC-Exporte (instance.json) und einzelne MultiMC- oder Prism-Instanzen (instance.cfg) letztere auch als gezippter Instanzordner. Der Launcher legt daraus automatisch eine neue Instanz an, samt Mods, Welten und Konfiguration.</p>
</div>
<div id="import-pane-modpack" class="import-pane hidden">
<p class="import-copy">Importiert ein Modrinth-Modpack als neue Instanz.</p>
<p class="hint">Unterstützt werden .mrpack und kompatible ZIP-Dateien mit modrinth.index.json.</p>
<p class="import-copy">Importiert ein Modpack als neue Instanz.</p>
<p class="hint">Unterstützt werden Modrinth (.mrpack oder ZIP mit modrinth.index.json) und CurseForge (ZIP mit manifest.json). Für CurseForge ist ein eigener API-Key in den Netzwerk-Einstellungen nötig.</p>
</div>
<div id="import-pane-mc" class="import-pane hidden">
@@ -860,6 +1060,8 @@
<button class="ctx-item" data-act="p-mods">Mods anzeigen</button>
<button class="ctx-item" data-act="p-worlds">Welten verwalten</button>
<button class="ctx-item" data-act="p-screens">Bildschirmfotos verwalten</button>
<button class="ctx-item" data-act="p-logs">Logdateien ansehen</button>
<button class="ctx-item" data-act="p-servers">Server verwalten</button>
<div class="ctx-sep"></div>
<button class="ctx-item" data-act="p-mcfolder">Minecraft-Ordner</button>
<button class="ctx-item" data-act="p-configfolder">Konfigurations-Ordner</button>

212
src/javadl.js Normal file
View File

@@ -0,0 +1,212 @@
'use strict';
/*
* javadl.js Java bei Bedarf selbst beschaffen
* ----------------------------------------------
* services.js sucht nur nach vorhandenen Java-Installationen. Wer keine hat,
* kam bisher nicht ins Spiel. Dieses Modul lädt die passende Laufzeit von
* Adoptium (Eclipse Temurin) und legt sie unter <userData>/java ab.
*
* Ablauf: Release suchen -> ZIP laden -> entpacken -> java.exe zurückgeben.
*/
const fs = require('fs');
const path = require('path');
const unzipper = require('unzipper');
const API = 'https://api.adoptium.net/v3';
const UA = 'AeroMC-Launcher';
let javaRoot = null;
// Ablageort festlegen (üblicherweise <userData>/java)
function setJavaDir(dir) {
javaRoot = dir;
return javaRoot;
}
function getJavaDir() { return javaRoot; }
/*
* Sucht das neueste Release für eine Major-Version.
* Bevorzugt die JRE (rund halb so groß), fällt sonst auf das JDK zurück.
*/
async function findRelease(major) {
for (const imageType of ['jre', 'jdk']) {
const url = new URL(`${API}/assets/latest/${major}/hotspot`);
url.searchParams.set('architecture', 'x64');
url.searchParams.set('image_type', imageType);
url.searchParams.set('os', 'windows');
url.searchParams.set('vendor', 'eclipse');
const res = await fetch(url, { headers: { 'User-Agent': UA, Accept: 'application/json' } });
if (!res.ok) continue;
const liste = await res.json();
const treffer = (Array.isArray(liste) ? liste : [])
.find((a) => a && a.binary && a.binary.package && a.binary.package.link);
if (!treffer) continue;
return {
major,
imageType,
releaseName: treffer.release_name || `temurin-${major}`,
url: treffer.binary.package.link,
size: Number(treffer.binary.package.size) || 0,
sha256: treffer.binary.package.checksum || '',
};
}
return null;
}
// Ordnername, unter dem eine Laufzeit abgelegt wird
function zielOrdner(release) {
const safe = String(release.releaseName).replace(/[^\w.+-]/g, '_');
return path.join(javaRoot, `${safe}-${release.imageType}`);
}
// java.exe innerhalb einer entpackten Laufzeit finden (bin/ liegt eine Ebene tiefer)
function findeJavaExe(wurzel) {
const direkt = path.join(wurzel, 'bin', 'java.exe');
if (fs.existsSync(direkt)) return direkt;
let eintraege = [];
try { eintraege = fs.readdirSync(wurzel, { withFileTypes: true }); } catch { return null; }
for (const e of eintraege) {
if (!e.isDirectory()) continue;
const p = path.join(wurzel, e.name, 'bin', 'java.exe');
if (fs.existsSync(p)) return p;
}
return null;
}
/*
* Bereits heruntergeladene Laufzeiten auflisten.
* -> [{ path, dir, major }]
*/
function listManaged() {
if (!javaRoot || !fs.existsSync(javaRoot)) return [];
const out = [];
for (const name of fs.readdirSync(javaRoot)) {
const dir = path.join(javaRoot, name);
let exe = null;
try { if (fs.statSync(dir).isDirectory()) exe = findeJavaExe(dir); } catch { continue; }
if (!exe) continue;
const m = name.match(/(?:jdk-?|temurin-?)(\d+)/i);
out.push({ path: exe, dir, major: m ? Number(m[1]) : null });
}
return out;
}
// schon eine passende Laufzeit da?
function findManaged(major) {
return listManaged().find((j) => j.major === major) || null;
}
async function ladeDatei(url, ziel, onProgress) {
const res = await fetch(url, { headers: { 'User-Agent': UA } });
if (!res.ok) throw new Error('Java-Download fehlgeschlagen: HTTP ' + res.status);
const gesamt = Number(res.headers.get('content-length')) || 0;
fs.mkdirSync(path.dirname(ziel), { recursive: true });
const out = fs.createWriteStream(ziel);
let geladen = 0;
let letzte = 0;
try {
for await (const stueck of res.body) {
const buf = Buffer.from(stueck);
geladen += buf.length;
if (!out.write(buf)) await new Promise((resolve, reject) => {
const onDrain = () => { out.off('error', onError); resolve(); };
const onError = (e) => { out.off('drain', onDrain); reject(e); };
out.once('drain', onDrain);
out.once('error', onError);
});
if (onProgress && Date.now() - letzte >= 250) {
letzte = Date.now();
onProgress({ phase: 'java-download', geladen, gesamt, percent: gesamt ? Math.round((geladen / gesamt) * 100) : 0 });
}
}
await new Promise((resolve, reject) => {
out.once('error', reject);
out.end((err) => (err ? reject(err) : resolve()));
});
} catch (err) {
out.destroy();
fs.rmSync(ziel, { force: true });
throw err;
}
return geladen;
}
// Pfade aus dem Archiv gegen Ausbrüche absichern
function saubererPfad(eintrag) {
const p = String(eintrag || '').replace(/\\/g, '/');
if (!p || p.startsWith('/') || /^[a-z]:/i.test(p)) return null;
if (p.split('/').includes('..')) return null;
return p;
}
async function entpacken(zipPfad, zielDir, onProgress) {
const archiv = await unzipper.Open.file(zipPfad);
const dateien = (archiv.files || []).filter((e) => e.type !== 'Directory');
fs.mkdirSync(zielDir, { recursive: true });
let fertig = 0;
let letzte = 0;
for (const eintrag of archiv.files || []) {
const rel = saubererPfad(eintrag.path);
if (!rel) continue;
const ziel = path.join(zielDir, rel);
if (eintrag.type === 'Directory') { fs.mkdirSync(ziel, { recursive: true }); continue; }
fs.mkdirSync(path.dirname(ziel), { recursive: true });
await new Promise((resolve, reject) => {
eintrag.stream()
.pipe(fs.createWriteStream(ziel))
.on('finish', resolve)
.on('error', reject);
});
fertig++;
if (onProgress && Date.now() - letzte >= 250) {
letzte = Date.now();
onProgress({ phase: 'java-entpacken', fertig, gesamt: dateien.length,
percent: dateien.length ? Math.round((fertig / dateien.length) * 100) : 0 });
}
}
return fertig;
}
/*
* Stellt sicher, dass eine Java-Laufzeit der gewünschten Major-Version da ist.
* -> { path, major, geladen } oder null
*/
async function ensureJava(major, onProgress) {
if (!javaRoot) throw new Error('Java-Ablageort ist nicht gesetzt.');
const vorhanden = findManaged(major);
if (vorhanden) return { path: vorhanden.path, major, geladen: false };
const release = await findRelease(major);
if (!release) throw new Error(`Für Java ${major} wird bei Adoptium keine Windows-Fassung angeboten.`);
const ziel = zielOrdner(release);
const tmpZip = path.join(javaRoot, `.download-${Date.now()}.zip`);
fs.mkdirSync(javaRoot, { recursive: true });
try {
if (onProgress) onProgress({ phase: 'java-download', geladen: 0, gesamt: release.size, percent: 0 });
await ladeDatei(release.url, tmpZip, onProgress);
// in einen Nebenordner entpacken und erst am Ende umbenennen -> kein
// halbfertiges Java, falls unterwegs etwas schiefgeht
const tmpDir = ziel + '.teil';
fs.rmSync(tmpDir, { recursive: true, force: true });
await entpacken(tmpZip, tmpDir, onProgress);
fs.rmSync(ziel, { recursive: true, force: true });
fs.renameSync(tmpDir, ziel);
} finally {
fs.rmSync(tmpZip, { force: true });
}
const exe = findeJavaExe(ziel);
if (!exe) throw new Error('Im heruntergeladenen Archiv wurde keine java.exe gefunden.');
return { path: exe, major, geladen: true, dir: ziel };
}
module.exports = { setJavaDir, getJavaDir, ensureJava, findRelease, listManaged, findManaged, findeJavaExe };

View File

@@ -373,7 +373,19 @@ async function prepareAndLaunch(opts, onProgress) {
// Java (VOR dem Loader Forge-Prozessoren brauchen Java)
emit('java', { detail: 'Java wählen …' });
const java = pickJava(vj, opts.javaPath);
let java = pickJava(vj, opts.javaPath);
// Nichts Passendes da? Dann darf der Launcher die richtige Fassung selbst
// holen opts.ensureJava wird vom Hauptprozess gestellt.
if ((!java || java.mismatch) && opts.ensureJava) {
const benoetigt = (vj.javaVersion && vj.javaVersion.majorVersion) || 8;
emit('java', { detail: `Java ${benoetigt} wird geladen …` });
try {
const geholt = await opts.ensureJava(benoetigt, (p) => emit('java', p));
if (geholt && geholt.path) java = { path: geholt.path, major: geholt.major };
} catch (err) {
emit('log', { line: '[Java] Automatischer Download fehlgeschlagen: ' + err.message + '\n' });
}
}
if (!java) return { ok: false, message: 'Keine Java-Installation gefunden. Bitte in den Einstellungen Java festlegen.' };
// Mod-Loader
@@ -489,7 +501,11 @@ async function prepareAndLaunch(opts, onProgress) {
const child = spawn(cmd, spawnArgs, { cwd: opts.gameDir, detached: false });
child.stdout.on('data', (d) => emit('log', { line: d.toString() }));
child.stderr.on('data', (d) => emit('log', { line: d.toString() }));
child.on('exit', (code) => {
// Ende genau einmal melden 'exit' und 'error' können beide kommen
let beendetGemeldet = false;
const meldeEnde = (code) => {
if (beendetGemeldet) return;
beendetGemeldet = true;
emit('exit', { code });
// Post-Exit-Befehl
if (opts.postExitCommand && opts.postExitCommand.trim()) {
@@ -500,6 +516,13 @@ async function prepareAndLaunch(opts, onProgress) {
if (po.stderr) emit('log', { line: po.stderr });
} catch { /* egal */ }
}
};
child.on('exit', (code) => meldeEnde(code));
// ohne diesen Listener würde ein Fehlstart (z. B. falscher Java-Pfad) als
// unbehandeltes 'error'-Ereignis den Hauptprozess beenden
child.on('error', (err) => {
emit('log', { line: '[Fehler] Minecraft konnte nicht gestartet werden: ' + err.message + '\n' });
meldeEnde(-1);
});
return { ok: true, pid: child.pid, java };

View File

@@ -9,6 +9,7 @@
const { app, BrowserWindow, ipcMain, dialog, shell, safeStorage } = require('electron');
const path = require('path');
const fs = require('fs');
const os = require('os');
const { execFile } = require('child_process');
const pkg = require('../package.json');
const store = require('./store');
@@ -24,6 +25,10 @@ const backup = require('./backup');
const mmcimport = require('./mmcimport');
const cfimport = require('./cfimport');
const mcimport = require('./mcimport');
const javadl = require('./javadl');
const crashreports = require('./crashreports');
const skins = require('./skins');
const servers = require('./servers');
// undici als globales fetch verwenden -> ermöglicht Proxy via setGlobalDispatcher
const undici = require('undici');
@@ -76,6 +81,48 @@ function applyProxy(s) {
let mainWindow = null;
// ---------------------------------------------------------------------------
// Verknüpfungen: "AeroMC.exe --launch <id>" startet eine Instanz direkt
// ---------------------------------------------------------------------------
// Nur eine Instanz des Launchers. Ein zweiter Aufruf (Doppelklick auf eine
// Verknüpfung) reicht seine Argumente an das laufende Fenster weiter.
const hatEinzelSperre = app.requestSingleInstanceLock();
if (!hatEinzelSperre) app.quit();
function instanzAusArgumenten(argv) {
const args = argv || [];
for (let i = 0; i < args.length; i++) {
const a = String(args[i]);
if (a === '--launch' && args[i + 1]) return String(args[i + 1]);
if (a.startsWith('--launch=')) return a.slice('--launch='.length);
}
return null;
}
// Startwunsch an die Oberfläche geben dort hängt die gesamte Rückmeldung dran
function starteInstanzImFenster(id) {
if (!id || !mainWindow || mainWindow.isDestroyed()) return;
mainWindow.webContents.send('instance:launch-request', id);
}
app.on('second-instance', (_e, argv) => {
if (mainWindow && !mainWindow.isDestroyed()) {
if (mainWindow.isMinimized()) mainWindow.restore();
mainWindow.focus();
}
starteInstanzImFenster(instanzAusArgumenten(argv));
});
// Ziel für Verknüpfungen: bei der Portable-Fassung zeigt process.execPath auf
// den entpackten Temp-Ordner eine Verknüpfung dorthin wäre morgen tot.
function verknuepfungsZiel() {
if (updater.detectInstallKind() === 'portable') {
return updater.portableTarget() || process.execPath;
}
return process.execPath;
}
function windowIcon() {
const png = path.join(__dirname, 'assets', 'logo.png');
const ico = path.join(__dirname, 'assets', 'icon.ico');
@@ -103,11 +150,20 @@ function createWindow() {
});
mainWindow.loadFile(path.join(__dirname, 'index.html'));
// mit "--launch <id>" gestartet -> Instanz anwerfen, sobald die Oberfläche steht
const startId = instanzAusArgumenten(process.argv);
if (startId) {
mainWindow.webContents.once('did-finish-load', () => starteInstanzImFenster(startId));
}
}
app.whenReady().then(() => {
if (!hatEinzelSperre) return; // zweite Instanz beendet sich selbst
store.init(app.getPath('userData'));
services.setCacheDir(app.getPath('userData')); // Java-Suche zwischenspeichern
// selbst geholte Laufzeiten: ablegen und bei der Java-Suche mitberücksichtigen
services.setManagedJavaDir(javadl.setJavaDir(path.join(app.getPath('userData'), 'java')));
// Konten verschlüsselt speichern (Windows-DPAPI, an das Benutzerkonto gebunden)
auth.init(app.getPath('userData'), {
available: () => { try { return safeStorage.isEncryptionAvailable(); } catch { return false; } },
@@ -128,6 +184,14 @@ app.whenReady().then(() => {
} catch { /* noch keine Version heruntergeladen */ }
}, 1500);
// Reste früherer Update-Downloads aufräumen (mehrere hundert MB im Temp)
setTimeout(() => { try { updater.cleanupUpdateCache(); } catch { /* egal */ } }, 6000);
// Sicherung nach Zeitplan: kurz nach dem Start prüfen, danach stündlich.
// Die Prüfung ist billig gesichert wird nur, wenn wirklich fällig.
setTimeout(() => { backupNachZeitplan('zeitplan').catch(() => {}); }, 60000);
setInterval(() => { backupNachZeitplan('zeitplan').catch(() => {}); }, 3600000);
// automatisch auf Updates prüfen (kurz nach dem Start)
setTimeout(async () => {
try {
@@ -255,6 +319,146 @@ ipcMain.handle('loaders:versions', async (_e, loader, mcVersion) => {
ipcMain.handle('mods:remove', (_e, id, filename) => store.removeMod(id, filename));
// Vorhandener Arbeitsspeicher Grundlage für die Prüfung der RAM-Angaben
function gesamtSpeicherMb() {
return Math.round(os.totalmem() / 1048576);
}
ipcMain.handle('system:memory', () => ({
totalMb: gesamtSpeicherMb(),
freeMb: Math.round(os.freemem() / 1048576),
}));
ipcMain.handle('system:checkMemory', (_e, minMb, maxMb) =>
services.pruefeSpeicher(minMb, maxMb, gesamtSpeicherMb()));
// Neuester Absturzbericht einer Instanz; seit = Startzeitpunkt in Millisekunden
ipcMain.handle('crash:latest', (_e, id, seit) => {
const inst = store.getInstance(id);
if (!inst) return { ok: false, grund: 'keine-instanz' };
return crashreports.lies(store.gameDir(id), Number(seit) || 0);
});
// ---- Serverliste (servers.dat) ----
ipcMain.handle('servers:list', (_e, id) => {
if (!store.getInstance(id)) return { ok: false, message: 'Instanz nicht gefunden.' };
return servers.lies(store.gameDir(id));
});
ipcMain.handle('servers:add', (_e, id, eintrag) => {
if (!store.getInstance(id)) return { ok: false, message: 'Instanz nicht gefunden.' };
return servers.fuegeHinzu(store.gameDir(id), eintrag || {});
});
ipcMain.handle('servers:remove', (_e, id, ip) => {
if (!store.getInstance(id)) return { ok: false, message: 'Instanz nicht gefunden.' };
return servers.entferne(store.gameDir(id), ip);
});
// denselben Server in weitere Instanzen übertragen
ipcMain.handle('servers:copyTo', (_e, zielIds, eintrag) => {
const erledigt = [];
const fehler = [];
for (const zielId of zielIds || []) {
const inst = store.getInstance(zielId);
if (!inst) { fehler.push({ id: zielId, message: 'Instanz nicht gefunden.' }); continue; }
const res = servers.uebertrage(store.gameDir(zielId), eintrag || {});
if (res.ok) erledigt.push(inst.name);
else fehler.push({ name: inst.name, message: res.message });
}
return { ok: true, erledigt, fehler };
});
// Logdateien der Instanz auch nach einem Neustart des Launchers
ipcMain.handle('logs:list', (_e, id) => {
if (!store.getInstance(id)) return [];
return crashreports.listeLogs(store.gameDir(id));
});
ipcMain.handle('logs:read', (_e, id, name) => {
if (!store.getInstance(id)) return { ok: false, grund: 'keine-instanz' };
return crashreports.liesLog(store.gameDir(id), name);
});
ipcMain.handle('logs:openFolder', (_e, id) => {
if (!store.getInstance(id)) return false;
const dir = crashreports.logOrdner(store.gameDir(id));
if (!fs.existsSync(dir)) return false;
shell.openPath(dir);
return true;
});
// Ordner mit den Absturzberichten öffnen
ipcMain.handle('crash:openFolder', (_e, id) => {
const inst = store.getInstance(id);
if (!inst) return false;
const dir = crashreports.berichtsOrdner(store.gameDir(id));
if (!fs.existsSync(dir)) return false;
shell.openPath(dir);
return true;
});
// Mod ab-/anschalten statt löschen (Datei wird nur umbenannt)
ipcMain.handle('mods:setEnabled', (_e, id, filename, enabled) => {
const res = store.setModEnabled(id, filename, !!enabled);
if (res.ok) return res;
const gruende = {
'not-found': 'Die Datei liegt nicht mehr im mods-Ordner.',
'both-exist': 'Es liegen beide Fassungen im mods-Ordner (an und abgeschaltet). Bitte eine davon von Hand entfernen.',
'bad-name': 'Ungültiger Dateiname.',
};
return { ok: false, message: gruende[res.reason] || res.reason };
});
// sucht für die installierten Mods nach neueren Dateien (lädt noch nichts)
ipcMain.handle('mods:checkUpdates', async (_e, id) => {
const inst = store.getInstance(id);
if (!inst) return { ok: false, message: 'Instanz nicht gefunden.' };
const gameVersion = inst.minecraft.version;
const loader = inst.minecraft.loader;
if (!gameVersion) return { ok: false, message: 'Instanz hat keine Minecraft-Version gesetzt.' };
try {
const res = await services.modrinthFindUpdates(store.listMods(id), gameVersion, loader, (p) => {
if (mainWindow && !mainWindow.isDestroyed()) mainWindow.webContents.send('mods:updateProgress', p);
});
return { ok: true, ...res };
} catch (err) {
return { ok: false, message: 'Update-Suche fehlgeschlagen: ' + err.message };
}
});
// lädt die gewählten Aktualisierungen und ersetzt die alten Dateien
ipcMain.handle('mods:applyUpdates', async (_e, id, projectIds) => {
const inst = store.getInstance(id);
if (!inst) return { ok: false, message: 'Instanz nicht gefunden.' };
const gameVersion = inst.minecraft.version;
const loader = inst.minecraft.loader;
const modsDir = store.modsDir(id);
const gewuenscht = new Set(projectIds || []);
const erneuert = [];
const fehler = [];
for (const mod of store.listMods(id)) {
if (!mod.projectId || !gewuenscht.has(mod.projectId)) continue;
try {
const res = await services.modrinthInstall({
projectId: mod.projectId, title: mod.title, gameVersion, loader, modsDir,
});
if (!res.ok) { fehler.push({ title: mod.title, message: res.message }); continue; }
store.addMod(id, res.entry);
// alte Datei erst weg, wenn die neue liegt und nie, wenn sie gleich heißt
if (mod.filename && mod.filename !== res.entry.filename) store.removeMod(id, mod.filename);
// war die Mod abgeschaltet, bleibt sie es auch nach dem Update
if (mod.enabled === false) store.setModEnabled(id, res.entry.filename, false);
erneuert.push({ title: mod.title, von: mod.versionNumber || '?', auf: res.entry.versionNumber });
} catch (err) {
fehler.push({ title: mod.title, message: err.message });
}
}
return { ok: true, erneuert, fehler };
});
// ---------------------------------------------------------------------------
// IPC Microsoft-Login (Phase 3)
// ---------------------------------------------------------------------------
@@ -262,8 +466,59 @@ ipcMain.handle('mods:remove', (_e, id, filename) => store.removeMod(id, filename
ipcMain.handle('auth:status', () => auth.currentAccount());
ipcMain.handle('auth:health', () => authHealth);
ipcMain.handle('auth:recheck', () => checkAuthServices());
ipcMain.handle('auth:accounts', () => auth.listAccounts());
// "playing" kommt aus den tatsächlich laufenden Spielen, nicht aus dem Aktiv-Merkmal
ipcMain.handle('auth:accounts', () => auth.listAccounts()
.map((a) => Object.assign({}, a, { playing: spielendeKonten.has(a.id) })));
ipcMain.handle('auth:setActive', (_e, id) => auth.setActive(id));
// ---- Skin des aktiven Kontos ----
// gültiges Zugangstoken des aktiven Kontos holen
async function aktivesToken() {
const clientId = store.getSettings().azureClientId || auth.DEFAULT_CLIENT_ID;
const acc = await auth.getValidAccount(clientId);
return acc && acc.mcAccessToken ? acc.mcAccessToken : null;
}
ipcMain.handle('skin:profile', async () => {
const token = await aktivesToken();
if (!token) return { ok: false, message: 'Kein angemeldetes Konto.' };
try { return await skins.holeProfil(token); }
catch (err) { return { ok: false, message: 'Profil konnte nicht geladen werden: ' + err.message }; }
});
ipcMain.handle('skin:choose', async () => {
const res = await dialog.showOpenDialog(mainWindow, {
title: 'Skin auswählen',
properties: ['openFile'],
filters: [{ name: 'PNG-Bild', extensions: ['png'] }],
});
if (res.canceled || !res.filePaths.length) return { ok: false, canceled: true };
const pfad = res.filePaths[0];
// sofort prüfen, damit der Nutzer nicht erst nach dem Hochladen erfährt,
// dass die Datei nicht passt
const geprueft = skins.pruefeDatei(pfad);
if (!geprueft.ok) return geprueft;
// gleich als Bilddaten mitgeben, damit die Vorschau ohne Dateizugriff
// im Renderer auskommt
let dataUri = '';
try { dataUri = 'data:image/png;base64,' + fs.readFileSync(pfad).toString('base64'); } catch { /* egal */ }
return { ok: true, pfad, breite: geprueft.breite, hoehe: geprueft.hoehe, dataUri };
});
ipcMain.handle('skin:apply', async (_e, dateiPfad, variante) => {
const token = await aktivesToken();
if (!token) return { ok: false, message: 'Kein angemeldetes Konto.' };
try { return await skins.setzeSkin(token, dateiPfad, variante); }
catch (err) { return { ok: false, message: 'Skin konnte nicht gesetzt werden: ' + err.message }; }
});
ipcMain.handle('skin:reset', async () => {
const token = await aktivesToken();
if (!token) return { ok: false, message: 'Kein angemeldetes Konto.' };
try { return await skins.setzeSkinZurueck(token); }
catch (err) { return { ok: false, message: 'Zurücksetzen fehlgeschlagen: ' + err.message }; }
});
ipcMain.handle('auth:remove', (_e, id) => auth.removeAccount(id));
ipcMain.handle('auth:logout', () => { auth.clearAccount(); return true; });
@@ -454,6 +709,86 @@ async function importMrpack(buf, zip) {
return { ok: true, instance: store.getInstance(created.id), fileCount: files.length };
}
// ---- Modpack-Import (CurseForge .zip mit manifest.json) ----
// "forge-47.2.0" / "fabric-0.15.7" -> { loader, loaderVersion }
function loaderAusManifest(manifest) {
const eintraege = (manifest.minecraft && manifest.minecraft.modLoaders) || [];
const primaer = eintraege.find((l) => l && l.primary) || eintraege[0];
const id = String((primaer && primaer.id) || '');
const m = id.match(/^(forge|neoforge|fabric|quilt)-(.+)$/i);
if (!m) return { loader: 'vanilla', loaderVersion: '' };
return { loader: m[1].toLowerCase(), loaderVersion: m[2] };
}
async function importCurseForge(buf, zip, zugang) {
const raw = forge.readZipEntry(buf, zip['manifest.json']).toString('utf8').replace(/^/, '');
const manifest = JSON.parse(raw);
const { loader, loaderVersion } = loaderAusManifest(manifest);
const created = store.createOrReplaceImportedInstance({
name: manifest.name || 'CurseForge-Modpack',
minecraft: {
version: (manifest.minecraft && manifest.minecraft.version) || '',
loader,
loaderVersion,
},
});
const gameDir = store.gameDir(created.id);
const inside = (dest) => dest.startsWith(gameDir + path.sep);
const melde = (detail, current, total) => {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('launch:progress', { phase: 'modpack', current, total, detail });
}
};
// 1) Dateiliste über die CurseForge-API auflösen
const dateien = (manifest.files || []).filter((f) => f && f.fileID);
melde('Dateiliste abrufen', 0, dateien.length);
const aufgeloest = await services.curseforgeResolveFiles(zugang, dateien,
(p) => melde('Dateiliste abrufen', p.aufgeloest, p.gesamt));
// 2) Mods herunterladen
const modsDir = path.join(gameDir, 'mods');
let geladen = 0;
const fehlend = [];
let i = 0;
for (const f of dateien) {
i++;
const info = aufgeloest.get(Number(f.fileID));
if (!info) { fehlend.push(f.fileID); continue; }
const dest = path.join(modsDir, path.basename(info.fileName));
if (!inside(dest)) continue;
melde('Mods laden', i, dateien.length);
try {
// Das CurseForge-CDN verlangt seit Juli 2026 auch beim reinen Dateiabruf
// einen gültigen Schlüssel. Bei der Weiterleitung setzt ihn der Server.
await services.downloadFile(info.url, dest, info.kopfzeilen);
geladen++;
} catch {
fehlend.push(f.fileID);
}
}
// 3) overrides einspielen
const prefix = (manifest.overrides || 'overrides').replace(/\/*$/, '') + '/';
for (const name of Object.keys(zip)) {
if (!name.startsWith(prefix) || name.endsWith('/')) continue;
const dest = path.join(gameDir, name.slice(prefix.length).replace(/\//g, path.sep));
if (!inside(dest)) continue;
fs.mkdirSync(path.dirname(dest), { recursive: true });
fs.writeFileSync(dest, forge.readZipEntry(buf, zip[name]));
}
return {
ok: true,
instance: store.getInstance(created.id),
fileCount: dateien.length,
geladen,
fehlend: fehlend.length,
};
}
ipcMain.handle('modpack:import', async () => {
const res = await dialog.showOpenDialog(mainWindow, {
title: 'Modpack importieren',
@@ -466,7 +801,12 @@ ipcMain.handle('modpack:import', async () => {
const zip = forge.readZipIndex(buf);
if (zip['modrinth.index.json']) return await importMrpack(buf, zip);
if (zip['manifest.json']) {
return { ok: false, message: 'CurseForge-Modpacks brauchen einen CurseForge-API-Key und werden noch nicht unterstützt. Bitte ein Modrinth-.mrpack verwenden.' };
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.' };
}
return await importCurseForge(buf, zip, zugang);
}
return { ok: false, message: 'Unbekanntes Modpack-Format (keine modrinth.index.json gefunden).' };
} catch (err) {
@@ -474,6 +814,32 @@ ipcMain.handle('modpack:import', async () => {
}
});
// Verknüpfung auf dem Desktop, die genau diese Instanz startet
ipcMain.handle('instances:shortcut', (_e, id) => {
const inst = store.getInstance(id);
if (!inst) return { ok: false, message: 'Instanz nicht gefunden.' };
if (process.platform !== 'win32') {
return { ok: false, message: 'Verknüpfungen werden nur unter Windows unterstützt.' };
}
try {
const name = String(inst.name || 'Instanz').replace(/[\\/:*?"<>|]/g, '_').trim() || 'Instanz';
const datei = path.join(app.getPath('desktop'), name + '.lnk');
const ziel = verknuepfungsZiel();
const ok = shell.writeShortcutLink(datei, fs.existsSync(datei) ? 'replace' : 'create', {
target: ziel,
args: `--launch "${id}"`,
cwd: path.dirname(ziel),
description: 'AeroMC ' + inst.name,
icon: 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 };
}
});
ipcMain.handle('instances:import', async () => {
const res = await dialog.showOpenDialog(mainWindow, {
title: 'Instanz importieren',
@@ -494,8 +860,15 @@ ipcMain.handle('instances:import', async () => {
.find((p) => { try { return fs.statSync(p).isDirectory() && fs.existsSync(path.join(p, 'instance.json')); } catch { return false; } });
if (sub) base = sub;
}
// Kein eigener Export? Dann auf eine MultiMC/Prism-Instanz prüfen
// die bringt statt instance.json eine instance.cfg mit.
if (!fs.existsSync(path.join(base, 'instance.json'))) {
throw new Error('Keine gültige Instanz im Archiv (instance.json fehlt).');
const mmc = mmcimport.scanSingle(tmp);
if (mmc.ok) {
const done = mmcimport.importOne(store, mmc.instance, true);
return { ok: true, instance: store.getInstance(done.id), quelle: 'multimc', copied: done.copied };
}
throw new Error('Keine gültige Instanz im Archiv (weder instance.json noch instance.cfg).');
}
const imported = JSON.parse(fs.readFileSync(path.join(base, 'instance.json'), 'utf8'));
const created = store.createOrReplaceImportedInstance({
@@ -522,7 +895,19 @@ ipcMain.handle('instances:import', async () => {
}
});
ipcMain.handle('backup:global', async () => {
// ---------------------------------------------------------------------------
// Sicherungen: von Hand und nach Zeitplan
// ---------------------------------------------------------------------------
let backupLaeuft = false;
/*
* Erstellt eine Sicherung aller Instanzen.
* still = kein Fortschritt an die Oberfläche (für den Zeitplan im Hintergrund)
*/
async function erstelleBackup({ still } = {}) {
if (backupLaeuft) return { ok: false, message: 'Es läuft bereits eine Sicherung.' };
backupLaeuft = true;
const destinationPath = backup.resolveAutoBackupPath(app.getPath('documents'));
try {
const result = await backup.createGlobalBackup({
@@ -531,17 +916,67 @@ ipcMain.handle('backup:global', async () => {
includeSettings: true,
destinationPath,
onProgress: (progress) => {
if (mainWindow && !mainWindow.isDestroyed()) mainWindow.webContents.send('backup:progress', progress);
if (!still && mainWindow && !mainWindow.isDestroyed()) mainWindow.webContents.send('backup:progress', progress);
},
});
if (mainWindow && !mainWindow.isDestroyed()) {
if (!still && mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('backup:progress', { mode: 'create', percent: 100, detail: 'Backup abgeschlossen' });
}
return result;
} catch (err) {
return { ok: false, message: 'Backup fehlgeschlagen: ' + err.message };
} finally {
backupLaeuft = false;
}
});
}
/*
* Alte automatische Sicherungen wegräumen. Ohne das läuft die Platte voll
* jede Sicherung enthält sämtliche Instanzen.
*/
function raeumeAlteBackups(behalten) {
const anzahl = Math.max(1, Number(behalten) || 5);
try {
const alle = backup.listGlobalBackups(app.getPath('documents')); // neueste zuerst
for (const b of alle.slice(anzahl)) {
try { fs.rmSync(b.path, { force: true }); } catch { /* egal */ }
}
return Math.max(0, alle.length - anzahl);
} catch { return 0; }
}
const BACKUP_ABSTAND = { daily: 24 * 3600 * 1000, weekly: 7 * 24 * 3600 * 1000 };
// Ist laut Einstellungen eine Sicherung fällig?
function backupFaellig(settings, jetzt = Date.now()) {
if (!settings.autoBackupEnabled) return false;
const abstand = BACKUP_ABSTAND[settings.autoBackupEvery] || BACKUP_ABSTAND.daily;
const zuletzt = Date.parse(settings.autoBackupLast || '') || 0;
return jetzt - zuletzt >= abstand;
}
async function backupNachZeitplan(anlass) {
const settings = store.getSettings();
if (!settings.autoBackupEnabled) return null;
if (anlass === 'zeitplan' && !backupFaellig(settings)) return null;
if (backupLaeuft) return null;
const res = await erstelleBackup({ still: true });
if (res && res.ok) {
store.saveSettings({ autoBackupLast: new Date().toISOString() });
const geloescht = raeumeAlteBackups(settings.autoBackupKeep);
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('backup:auto', {
ok: true, anlass, datei: res.path || res.destinationPath || '', aufgeraeumt: geloescht,
});
}
} else if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('backup:auto', { ok: false, anlass, message: res && res.message });
}
return res;
}
ipcMain.handle('backup:global', () => erstelleBackup({ still: false }));
ipcMain.handle('backup:list', async () => backup.listGlobalBackups(app.getPath('documents')));
@@ -571,6 +1006,32 @@ ipcMain.handle('backup:restore', async (_e, sourcePath) => {
});
// Phase 2: echter Vanilla-Download & -Start (Start selbst ist an Login gebunden).
/*
* Welche Konten spielen gerade? Konto-ID -> Anzahl laufender Spiele.
* Wie bei MultiMC wird das Konto erst beim Start gelesen; ein laufendes Spiel
* behält seines. So kann man das aktive Konto wechseln und mit einem zweiten
* Konto parallel starten.
*/
const spielendeKonten = new Map();
function meldeKontenAenderung() {
if (mainWindow && !mainWindow.isDestroyed()) mainWindow.webContents.send('accounts:changed');
}
function kontoSpieltJetzt(kontoId) {
if (!kontoId) return;
spielendeKonten.set(kontoId, (spielendeKonten.get(kontoId) || 0) + 1);
meldeKontenAenderung();
}
function kontoSpieltNichtMehr(kontoId) {
if (!kontoId) return;
const rest = (spielendeKonten.get(kontoId) || 1) - 1;
if (rest > 0) spielendeKonten.set(kontoId, rest);
else spielendeKonten.delete(kontoId);
meldeKontenAenderung();
}
ipcMain.handle('instances:launch', async (_e, id, accountId) => {
const inst = store.getInstance(id);
if (!inst) return { ok: false, message: 'Instanz nicht gefunden.' };
@@ -578,6 +1039,28 @@ ipcMain.handle('instances:launch', async (_e, id, accountId) => {
return { ok: false, message: 'Bitte zuerst eine Minecraft-Version wählen (Bearbeiten).' };
}
const settings = store.getSettings();
// Speicherangaben prüfen, bevor Java mit einer unverständlichen Meldung
// aussteigt. Greift auch bei von Hand bearbeiteten Instanzdateien.
const speicher = services.pruefeSpeicher(
inst.java.minMemMb || settings.defaultMinMemMb,
inst.java.maxMemMb || settings.defaultMaxMemMb,
gesamtSpeicherMb(),
);
if (!speicher.ok) {
return { ok: false, message: speicher.message + ' Bitte im Bearbeiten-Dialog oder in den Einstellungen anpassen.' };
}
// Auf Wunsch vor dem Start sichern. Bewusst abgewartet eine Sicherung,
// die nebenher läuft, während Minecraft dieselben Dateien anfasst, wäre
// nicht verlässlich.
if (settings.autoBackupEnabled && settings.autoBackupBeforeLaunch) {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('launch:progress', { id, phase: 'backup', detail: 'Sicherung vor dem Start …' });
}
await backupNachZeitplan('vor-dem-start').catch(() => {});
}
const opts = {
versionId: inst.minecraft.version,
loader: inst.minecraft.loader || 'vanilla',
@@ -596,6 +1079,8 @@ ipcMain.handle('instances:launch', async (_e, id, accountId) => {
mcWidth: settings.mcWidth || 854,
mcHeight: settings.mcHeight || 480,
auth: null,
// fehlende Java-Fassung bei Bedarf selbst beschaffen (abschaltbar)
ensureJava: settings.autoJava === false ? null : (major, onProgress) => javadl.ensureJava(major, onProgress),
};
// eingeloggtes Microsoft-Konto verwenden (gewähltes oder aktives) -> echter Start
@@ -612,22 +1097,38 @@ ipcMain.handle('instances:launch', async (_e, id, accountId) => {
};
}
// Konto dieses Starts merken nicht das aktive, denn das kann inzwischen
// gewechselt worden sein, während dieses Spiel noch läuft.
const kontoId = account && account.profile ? account.profile.id : null;
let gezaehlt = false;
try {
let launchStart = 0;
const res = await launcher.prepareAndLaunch(opts, (evt) => {
if (evt.phase === 'launch') launchStart = Date.now();
if (evt.phase === 'exit' && launchStart) {
if (evt.phase === 'launch') {
launchStart = Date.now();
if (!gezaehlt) { gezaehlt = true; kontoSpieltJetzt(kontoId); }
}
if (evt.phase === 'exit') {
if (gezaehlt) { gezaehlt = false; kontoSpieltNichtMehr(kontoId); }
if (launchStart) {
const secs = Math.max(0, Math.round((Date.now() - launchStart) / 1000));
launchStart = 0; // Spielzeit nur einmal gutschreiben
const cur = store.getInstance(id);
if (cur) store.updateInstance(id, { totalPlaySeconds: (cur.totalPlaySeconds || 0) + secs });
}
}
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('launch:progress', Object.assign({ id }, evt));
}
});
// Start fehlgeschlagen, obwohl schon gezählt -> Zählung zurücknehmen,
// sonst bliebe das Konto für immer als "spielt" stehen
if (!res.ok && gezaehlt) { gezaehlt = false; kontoSpieltNichtMehr(kontoId); }
if (res.ok) store.updateInstance(id, { lastPlayed: new Date().toISOString() });
return res;
} catch (err) {
if (gezaehlt) { gezaehlt = false; kontoSpieltNichtMehr(kontoId); }
return { ok: false, message: 'Start fehlgeschlagen: ' + err.message };
}
});
@@ -745,7 +1246,7 @@ ipcMain.handle('cf:import', (_e, entries, copyData) => {
ipcMain.handle('mc:pickFolder', async () => {
const res = await dialog.showOpenDialog(mainWindow, {
title: 'Minecraft-Launcher-Ordner waehlen (meist .minecraft)',
title: 'Minecraft-Launcher-Ordner wählen (meist .minecraft)',
properties: ['openDirectory'],
});
return res.canceled ? null : res.filePaths[0];
@@ -839,15 +1340,16 @@ ipcMain.handle('update:check', async () => {
catch (err) { return { available: false, error: err.message }; }
});
// Ein Klick: herunterladen, prüfen, Installer starten und Launcher beenden
// Ein Klick: herunterladen, prüfen, Update anwenden und Launcher beenden
ipcMain.handle('update:install', async (_e, asset) => {
try {
const file = await updater.downloadAndInstall(asset, (p) => {
if (mainWindow && !mainWindow.isDestroyed()) mainWindow.webContents.send('update:progress', p);
});
updater.runInstaller(file.path);
setTimeout(() => app.quit(), 1200); // Installer übernimmt, Launcher schließt sich
return { ok: true, path: file.path };
const res = updater.applyUpdate(file);
// Portable: der Tausch klappt erst, wenn der Launcher die EXE freigibt
setTimeout(() => app.quit(), 1200);
return { ok: true, path: file.path, kind: file.kind, mode: res.started };
} catch (err) {
return { ok: false, message: err.message };
}

View File

@@ -66,6 +66,38 @@ function resolveInstancesDir(inputPath) {
return fs.existsSync(inputPath) ? inputPath : null;
}
/*
* Liest einen einzelnen Instanzordner aus. null, wenn dort keine Instanz liegt.
* Wird sowohl beim Ordner-Import als auch beim ZIP-Import verwendet.
*/
function readInstance(dir, folderName, group) {
const cfgFile = path.join(dir, 'instance.cfg');
if (!fs.existsSync(cfgFile)) return null; // kein Instanzordner
const cfg = parseCfg(cfgFile);
const pack = readPack(dir);
// MultiMC nutzt .minecraft, Prism minecraft
const gameDir = ['.minecraft', 'minecraft']
.map((n) => path.join(dir, n))
.find((p) => fs.existsSync(p)) || null;
return {
folder: folderName,
dir,
name: cfg.name || folderName,
group: group || '',
notes: cfg.notes || '',
version: pack.version || cfg.IntendedVersion || '',
loader: pack.loader,
loaderVersion: pack.loaderVersion,
javaPath: cfg.OverrideJavaLocation === 'true' ? (cfg.JavaPath || '') : '',
minMemMb: cfg.OverrideMemory === 'true' ? Number(cfg.MinMemAlloc) || null : null,
maxMemMb: cfg.OverrideMemory === 'true' ? Number(cfg.MaxMemAlloc) || null : null,
gameDir,
hasGameData: !!gameDir,
};
}
/*
* Listet die gefundenen Instanzen auf (ohne etwas zu kopieren).
*/
@@ -77,37 +109,45 @@ function scan(inputPath) {
const list = [];
for (const entry of fs.readdirSync(instancesDir, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
const dir = path.join(instancesDir, entry.name);
const cfgFile = path.join(dir, 'instance.cfg');
if (!fs.existsSync(cfgFile)) continue; // kein Instanzordner
const cfg = parseCfg(cfgFile);
const pack = readPack(dir);
// MultiMC nutzt .minecraft, Prism minecraft
const gameDir = ['.minecraft', 'minecraft']
.map((n) => path.join(dir, n))
.find((p) => fs.existsSync(p)) || null;
list.push({
folder: entry.name,
dir,
name: cfg.name || entry.name,
group: groups[entry.name] || '',
notes: cfg.notes || '',
version: pack.version || cfg.IntendedVersion || '',
loader: pack.loader,
loaderVersion: pack.loaderVersion,
javaPath: cfg.OverrideJavaLocation === 'true' ? (cfg.JavaPath || '') : '',
minMemMb: cfg.OverrideMemory === 'true' ? Number(cfg.MinMemAlloc) || null : null,
maxMemMb: cfg.OverrideMemory === 'true' ? Number(cfg.MaxMemAlloc) || null : null,
gameDir,
hasGameData: !!gameDir,
});
const inst = readInstance(path.join(instancesDir, entry.name), entry.name, groups[entry.name]);
if (inst) list.push(inst);
}
list.sort((a, b) => a.name.localeCompare(b.name));
return { ok: true, instancesDir, count: list.length, instances: list };
}
/*
* Sucht einen Instanzordner unterhalb von root. Deckt beide Zip-Varianten ab:
* mit umgebendem Ordner ("26.2/instance.cfg") und ohne ("instance.cfg").
* Die Tiefe ist begrenzt, damit nicht das ganze .minecraft durchsucht wird.
*/
function findInstanceDir(root, tiefe = 2) {
if (!root || !fs.existsSync(root)) return null;
if (fs.existsSync(path.join(root, 'instance.cfg'))) return root;
if (tiefe <= 0) return null;
let eintraege = [];
try { eintraege = fs.readdirSync(root, { withFileTypes: true }); } catch { return null; }
for (const e of eintraege) {
if (!e.isDirectory()) continue;
// .minecraft enthält nie eine Instanz und ist der mit Abstand größte Ordner
if (e.name === '.minecraft' || e.name === 'minecraft') continue;
const treffer = findInstanceDir(path.join(root, e.name), tiefe - 1);
if (treffer) return treffer;
}
return null;
}
/*
* Liest eine einzelne, ausgepackte MultiMC/Prism-Instanz.
* -> { ok, instance } oder { ok: false, reason: 'not-found' }
*/
function scanSingle(root) {
const dir = findInstanceDir(root);
if (!dir) return { ok: false, reason: 'not-found' };
const inst = readInstance(dir, path.basename(dir), '');
return inst ? { ok: true, instance: inst } : { ok: false, reason: 'not-found' };
}
/*
* Übernimmt eine Instanz in den Launcher.
* store: unser Datenspeicher; copyData: Spielverzeichnis mitkopieren
@@ -145,4 +185,4 @@ function importOne(store, entry, copyData = true) {
return { id: created.id, name: created.name, copied };
}
module.exports = { scan, importOne, resolveInstancesDir };
module.exports = { scan, scanSingle, importOne, resolveInstancesDir, readInstance, findInstanceDir };

174
src/nbt.js Normal file
View File

@@ -0,0 +1,174 @@
'use strict';
/*
* nbt.js minimaler NBT-Leser und -Schreiber
* --------------------------------------------
* Nur so viel, wie servers.dat braucht. Diese Datei ist unkomprimiertes NBT
* mit einem Wurzel-Compound, darin eine Liste "servers" aus Compounds mit
* name, ip, icon und acceptTextures.
*
* Bewusst kein Fremdpaket: Der benötigte Ausschnitt ist klein und gut
* prüfbar, eine Abhängigkeit für zwei Datentypen wäre unverhältnismäßig.
*
* Alle Zahlen liegen in NBT als Big-Endian vor, Zeichenketten als
* modifiziertes UTF-8 mit vorangestellter Länge (2 Byte).
*/
const TAG = {
END: 0, BYTE: 1, SHORT: 2, INT: 3, LONG: 4, FLOAT: 5, DOUBLE: 6,
BYTE_ARRAY: 7, STRING: 8, LIST: 9, COMPOUND: 10, INT_ARRAY: 11, LONG_ARRAY: 12,
};
// ---------------------------------------------------------------------------
// Lesen
// ---------------------------------------------------------------------------
class Leser {
constructor(buf) { this.buf = buf; this.pos = 0; }
pruefe(n) { if (this.pos + n > this.buf.length) throw new Error('NBT endet unerwartet'); }
byte() { this.pruefe(1); return this.buf.readInt8(this.pos++); }
ubyte() { this.pruefe(1); return this.buf.readUInt8(this.pos++); }
short() { this.pruefe(2); const v = this.buf.readInt16BE(this.pos); this.pos += 2; return v; }
ushort() { this.pruefe(2); const v = this.buf.readUInt16BE(this.pos); this.pos += 2; return v; }
int() { this.pruefe(4); const v = this.buf.readInt32BE(this.pos); this.pos += 4; return v; }
long() { this.pruefe(8); const v = this.buf.readBigInt64BE(this.pos); this.pos += 8; return v; }
float() { this.pruefe(4); const v = this.buf.readFloatBE(this.pos); this.pos += 4; return v; }
double() { this.pruefe(8); const v = this.buf.readDoubleBE(this.pos); this.pos += 8; return v; }
text() {
const laenge = this.ushort();
this.pruefe(laenge);
const s = this.buf.toString('utf8', this.pos, this.pos + laenge);
this.pos += laenge;
return s;
}
bytes(n) { this.pruefe(n); const b = this.buf.subarray(this.pos, this.pos + n); this.pos += n; return Buffer.from(b); }
}
function leseWert(l, typ) {
switch (typ) {
case TAG.BYTE: return l.byte();
case TAG.SHORT: return l.short();
case TAG.INT: return l.int();
case TAG.LONG: return l.long();
case TAG.FLOAT: return l.float();
case TAG.DOUBLE: return l.double();
case TAG.STRING: return l.text();
case TAG.BYTE_ARRAY: return l.bytes(l.int());
case TAG.INT_ARRAY: { const n = l.int(); const a = []; for (let i = 0; i < n; i++) a.push(l.int()); return a; }
case TAG.LONG_ARRAY: { const n = l.int(); const a = []; for (let i = 0; i < n; i++) a.push(l.long()); return a; }
case TAG.LIST: {
const innerTyp = l.ubyte();
const n = l.int();
const a = [];
for (let i = 0; i < n; i++) a.push(leseWert(l, innerTyp));
a.__typ = innerTyp; // Typ merken, damit Schreiben ihn wiederherstellt
return a;
}
case TAG.COMPOUND: {
const o = {};
o.__typen = {};
for (;;) {
const t = l.ubyte();
if (t === TAG.END) break;
const name = l.text();
o.__typen[name] = t;
o[name] = leseWert(l, t);
}
return o;
}
default: throw new Error('Unbekannter NBT-Typ: ' + typ);
}
}
/*
* Liest unkomprimiertes NBT.
* -> { name, wert }
*/
function lese(buf) {
const l = new Leser(buf);
const typ = l.ubyte();
if (typ !== TAG.COMPOUND) throw new Error('NBT beginnt nicht mit einem Compound');
const name = l.text();
return { name, wert: leseWert(l, TAG.COMPOUND) };
}
// ---------------------------------------------------------------------------
// Schreiben
// ---------------------------------------------------------------------------
class Schreiber {
constructor() { this.teile = []; }
byte(v) { const b = Buffer.alloc(1); b.writeInt8(v, 0); this.teile.push(b); }
ubyte(v) { const b = Buffer.alloc(1); b.writeUInt8(v, 0); this.teile.push(b); }
short(v) { const b = Buffer.alloc(2); b.writeInt16BE(v, 0); this.teile.push(b); }
int(v) { const b = Buffer.alloc(4); b.writeInt32BE(v, 0); this.teile.push(b); }
long(v) { const b = Buffer.alloc(8); b.writeBigInt64BE(BigInt(v), 0); this.teile.push(b); }
float(v) { const b = Buffer.alloc(4); b.writeFloatBE(v, 0); this.teile.push(b); }
double(v) { const b = Buffer.alloc(8); b.writeDoubleBE(v, 0); this.teile.push(b); }
text(s) {
const d = Buffer.from(String(s), 'utf8');
const l = Buffer.alloc(2); l.writeUInt16BE(d.length, 0);
this.teile.push(l, d);
}
roh(b) { this.teile.push(b); }
fertig() { return Buffer.concat(this.teile); }
}
// Typ raten, wenn er nicht aus dem Einlesen bekannt ist
function typVon(wert) {
if (typeof wert === 'string') return TAG.STRING;
if (typeof wert === 'bigint') return TAG.LONG;
if (typeof wert === 'boolean') return TAG.BYTE;
if (Buffer.isBuffer(wert)) return TAG.BYTE_ARRAY;
if (Array.isArray(wert)) return TAG.LIST;
if (wert && typeof wert === 'object') return TAG.COMPOUND;
if (Number.isInteger(wert)) return TAG.INT;
return TAG.DOUBLE;
}
function schreibeWert(s, typ, wert) {
switch (typ) {
case TAG.BYTE: s.byte(wert ? Number(wert) : 0); break;
case TAG.SHORT: s.short(Number(wert) || 0); break;
case TAG.INT: s.int(Number(wert) || 0); break;
case TAG.LONG: s.long(wert || 0); break;
case TAG.FLOAT: s.float(Number(wert) || 0); break;
case TAG.DOUBLE: s.double(Number(wert) || 0); break;
case TAG.STRING: s.text(wert == null ? '' : wert); break;
case TAG.BYTE_ARRAY: { const b = Buffer.isBuffer(wert) ? wert : Buffer.alloc(0); s.int(b.length); s.roh(b); break; }
case TAG.INT_ARRAY: { s.int(wert.length); for (const v of wert) s.int(v); break; }
case TAG.LONG_ARRAY: { s.int(wert.length); for (const v of wert) s.long(v); break; }
case TAG.LIST: {
const liste = wert || [];
// leere Listen bekommen END als Typ so macht es Minecraft auch
const innerTyp = liste.__typ || (liste.length ? typVon(liste[0]) : TAG.END);
s.ubyte(innerTyp);
s.int(liste.length);
for (const v of liste) schreibeWert(s, innerTyp, v);
break;
}
case TAG.COMPOUND: {
const typen = (wert && wert.__typen) || {};
for (const schluessel of Object.keys(wert || {})) {
if (schluessel === '__typen' || schluessel === '__typ') continue;
const t = typen[schluessel] || typVon(wert[schluessel]);
s.ubyte(t);
s.text(schluessel);
schreibeWert(s, t, wert[schluessel]);
}
s.ubyte(TAG.END);
break;
}
default: throw new Error('Unbekannter NBT-Typ beim Schreiben: ' + typ);
}
}
function schreibe(name, wert) {
const s = new Schreiber();
s.ubyte(TAG.COMPOUND);
s.text(name || '');
schreibeWert(s, TAG.COMPOUND, wert);
return s.fertig();
}
module.exports = { lese, schreibe, TAG };

View File

@@ -24,6 +24,30 @@ contextBridge.exposeInMainWorld('api', {
modList: (id) => ipcRenderer.invoke('mods:list', id),
modInstall: (id, project) => ipcRenderer.invoke('mods:install', id, project),
modRemove: (id, filename) => ipcRenderer.invoke('mods:remove', id, filename),
modSetEnabled: (id, filename, enabled) => ipcRenderer.invoke('mods:setEnabled', id, filename, enabled),
systemMemory: () => ipcRenderer.invoke('system:memory'),
checkMemory: (minMb, maxMb) => ipcRenderer.invoke('system:checkMemory', minMb, maxMb),
crashLatest: (id, seit) => ipcRenderer.invoke('crash:latest', id, seit),
onAutoBackup: (cb) => {
const l = (_e, d) => cb(d);
ipcRenderer.on('backup:auto', l);
return () => ipcRenderer.removeListener('backup:auto', l);
},
crashOpenFolder: (id) => ipcRenderer.invoke('crash:openFolder', id),
serversList: (id) => ipcRenderer.invoke('servers:list', id),
serversAdd: (id, eintrag) => ipcRenderer.invoke('servers:add', id, eintrag),
serversRemove: (id, ip) => ipcRenderer.invoke('servers:remove', id, ip),
serversCopyTo: (zielIds, eintrag) => ipcRenderer.invoke('servers:copyTo', zielIds, eintrag),
logsList: (id) => ipcRenderer.invoke('logs:list', id),
logsRead: (id, name) => ipcRenderer.invoke('logs:read', id, name),
logsOpenFolder: (id) => ipcRenderer.invoke('logs:openFolder', id),
modCheckUpdates: (id) => ipcRenderer.invoke('mods:checkUpdates', id),
modApplyUpdates: (id, projectIds) => ipcRenderer.invoke('mods:applyUpdates', id, projectIds),
onModUpdateProgress: (cb) => {
const l = (_e, d) => cb(d);
ipcRenderer.on('mods:updateProgress', l);
return () => ipcRenderer.removeListener('mods:updateProgress', l);
},
// Instanzen
listInstances: () => ipcRenderer.invoke('instances:list'),
@@ -54,6 +78,13 @@ contextBridge.exposeInMainWorld('api', {
return () => ipcRenderer.removeListener('backup:progress', l);
},
importInstance: () => ipcRenderer.invoke('instances:import'),
createShortcut: (id) => ipcRenderer.invoke('instances:shortcut', id),
// Start über eine Desktop-Verknüpfung ("--launch <id>")
onLaunchRequest: (cb) => {
const l = (_e, id) => cb(id);
ipcRenderer.on('instance:launch-request', l);
return () => ipcRenderer.removeListener('instance:launch-request', l);
},
importModpack: () => ipcRenderer.invoke('modpack:import'),
onLaunchProgress: (cb) => {
const listener = (_e, data) => cb(data);
@@ -72,6 +103,10 @@ contextBridge.exposeInMainWorld('api', {
authStatus: () => ipcRenderer.invoke('auth:status'),
authAccounts: () => ipcRenderer.invoke('auth:accounts'),
authSetActive: (id) => ipcRenderer.invoke('auth:setActive', id),
skinProfile: () => ipcRenderer.invoke('skin:profile'),
skinChoose: () => ipcRenderer.invoke('skin:choose'),
skinApply: (pfad, variante) => ipcRenderer.invoke('skin:apply', pfad, variante),
skinReset: () => ipcRenderer.invoke('skin:reset'),
authRemove: (id) => ipcRenderer.invoke('auth:remove', id),
authLogin: () => ipcRenderer.invoke('auth:login'),
authLogout: () => ipcRenderer.invoke('auth:logout'),
@@ -82,6 +117,12 @@ contextBridge.exposeInMainWorld('api', {
ipcRenderer.on('auth:health', listener);
return () => ipcRenderer.removeListener('auth:health', listener);
},
// meldet sich, wenn ein Spiel startet oder endet -> Kontenliste auffrischen
onAccountsChanged: (cb) => {
const listener = () => cb();
ipcRenderer.on('accounts:changed', listener);
return () => ipcRenderer.removeListener('accounts:changed', listener);
},
onAuthRefreshed: (cb) => {
const listener = () => cb();
ipcRenderer.on('auth:refreshed', listener);

View File

@@ -608,9 +608,12 @@ async function runModpackImport() {
await reload();
hide('modal-import');
toast(`Modpack importiert: ${res.instance.name} (${res.fileCount} Dateien)`);
// CurseForge gibt nicht jede Datei heraus ehrlich benennen statt verschweigen
if (res.fehlend) toast(`${res.fehlend} Datei(en) konnten nicht geladen werden und fehlen in der Instanz.`, true);
return;
}
toast(res.message || 'Modpack-Import fehlgeschlagen.', true);
if (res.needsKey) { hide('modal-import'); openSettings('pane-network'); }
} finally {
status('Bereit'); el('status-right').textContent = '';
el('import-modpack-do').disabled = false;
@@ -960,19 +963,40 @@ function formatVersionLabel(version) {
// ---------- Automatische Updates ----------
let pendingUpdate = null;
const CHANNEL_LABEL = { portable: 'Portable', setup: 'Setup' };
function showUpdateDialog(info) {
pendingUpdate = info;
const channel = CHANNEL_LABEL[info.installKind] || null;
el('upd-current').textContent = formatVersionLabel(info.currentVersion);
el('upd-new').textContent = formatVersionLabel(info.latestVersion);
el('upd-channel').textContent = `Update-Kanal: ${info.installKind === 'portable' ? 'Portable' : 'Setup'}`;
el('upd-channel').textContent = channel
? `Update-Kanal: ${channel}`
: 'Update-Kanal: unbekannt automatisches Update nicht möglich';
el('upd-notes').textContent = (info.notes || '').trim() || 'Keine Änderungshinweise hinterlegt.';
el('upd-progress').classList.add('hidden');
el('upd-bar-fill').style.width = '0%';
el('upd-install').disabled = !info.asset;
el('upd-install').textContent = info.asset ? 'Installieren' : `Kein passendes ${info.installKind === 'portable' ? 'Portable' : 'Setup'}-Update im Release`;
// Ohne passendes Paket wird bewusst nichts installiert ein Portable-Launcher
// darf nie einen Installer bekommen und umgekehrt. Statt eines toten Knopfes
// führen wir dann zum Release, damit der Nutzer nicht in der Sackgasse steht.
const hatPaket = !!info.asset;
el('upd-install').classList.toggle('hidden', !hatPaket);
el('upd-install').disabled = !hatPaket;
el('upd-install').textContent = 'Installieren';
el('upd-open').classList.toggle('hidden', hatPaket || !info.url);
el('upd-hint').textContent = hatPaket
? 'Der Launcher lädt das Update herunter und startet die Installation automatisch.'
: (channel
? `Für den Kanal ${channel} liegt in diesem Release keine passende Datei bereit. Bitte im Release nachsehen und von Hand aktualisieren.`
: 'Die Paketart dieses Launchers lässt sich nicht sicher bestimmen. Bitte das Update von Hand aus dem Release laden.');
show('modal-update');
}
function openReleasePage() {
if (pendingUpdate && pendingUpdate.url) window.api.openExternal(pendingUpdate.url);
}
async function startUpdate() {
if (!pendingUpdate || !pendingUpdate.asset) return;
const btn = el('upd-install');
@@ -983,7 +1007,9 @@ async function startUpdate() {
const res = await window.api.updateInstall(pendingUpdate.asset);
if (res.ok) {
el('upd-status').textContent = 'Installation wird gestartet der Launcher schließt sich.';
el('upd-status').textContent = res.kind === 'portable'
? 'Launcher schließt sich, wird ersetzt und startet automatisch neu.'
: 'Installation wird gestartet der Launcher schließt sich.';
} else {
el('upd-status').textContent = '✖ ' + (res.message || 'Update fehlgeschlagen.');
btn.disabled = false;
@@ -1033,7 +1059,7 @@ async function applySkinBackground() {
// Jede Pose hat auch eine Entsprechung beim 3D-Dienst (Kamerawinkel/Ansicht),
// damit die Auswahl immer sichtbar wirkt auch wenn der Pose-Dienst offline ist.
// 1) Skin lokal in 3D rendern unabhängig von Render-Diensten
const local = await renderSkin3D(uuid, s.bgSkinPose || 'walking');
const local = await renderSkin3D(`https://crafthead.net/skin/${uuid}`, s.bgSkinPose || 'walking');
if (local) { root.style.setProperty('--bg-skin', `url("${local}")`); return; }
// 2) Rückfall: externe Render-Dienste
@@ -1108,13 +1134,22 @@ const SKIN_POSES = {
};
// Rendert den Skin lokal mit WebGL in der gewählten Pose
async function renderSkin3D(uuid, poseKey) {
/*
* Rendert eine Skin-Textur als Spielfigur und gibt ein Bild zurück.
* quelle: Adresse ODER data:-URI so lässt sich auch eine Datei anzeigen,
* die noch gar nicht hochgeladen ist.
* modell: 'default' (breite Arme) oder 'slim' (Alex)
*/
async function renderSkin3D(quelle, poseKey, opts = {}) {
if (typeof skinview3d === 'undefined') return null;
const canvas = el('skin-canvas');
const breite = opts.breite || 420;
const hoehe = opts.hoehe || 700;
try {
const viewer = new skinview3d.SkinViewer({
canvas, width: 420, height: 700,
skin: `https://crafthead.net/skin/${uuid}`,
canvas, width: breite, height: hoehe,
skin: quelle,
model: opts.modell === 'slim' ? 'slim' : 'default',
preserveDrawingBuffer: true,
});
// Auf das Laden der Skin-Textur warten
@@ -1179,7 +1214,7 @@ function applyIcons(root = document) {
// ---------- Instanz-Kacheln ----------
const SINGLE_SELECTION_BUTTON_IDS = [
'p-play', 'p-offline', 'p-edit', 'p-notes', 'p-mods', 'p-worlds', 'p-screens',
'p-play', 'p-offline', 'p-edit', 'p-notes', 'p-mods', 'p-worlds', 'p-screens', 'p-logs', 'p-servers',
'p-mcfolder', 'p-configfolder', 'p-instfolder', 'p-shortcut', 'p-export', 'p-copy',
];
@@ -1575,6 +1610,7 @@ async function openEdit(inst) {
}
el('f-min-mem').value = inst.java.minMemMb || '';
el('f-max-mem').value = inst.java.maxMemMb || '';
zeigeSpeicherHinweis();
el('f-extra-args').value = inst.java.extraArgs || '';
el('f-prelaunch').value = inst.preLaunchCommand || '';
el('f-postexit').value = inst.postExitCommand || '';
@@ -1604,12 +1640,21 @@ async function saveInstance() {
loaderVersion: el('f-loader-version').value.trim(),
},
};
// Speicherangaben prüfen, bevor gespeichert wird
const minMem = numOrNull(el('f-min-mem').value);
const maxMem = numOrNull(el('f-max-mem').value);
if (minMem || maxMem) {
const pruefung = await window.api.checkMemory(minMem, maxMem);
if (!pruefung.ok) { toast(pruefung.message, true); return; }
if (pruefung.warnung) toast(pruefung.warnung, false);
}
try {
let targetId = editingId;
if (editingId) {
payload.java = {
minMemMb: numOrNull(el('f-min-mem').value),
maxMemMb: numOrNull(el('f-max-mem').value),
minMemMb: minMem,
maxMemMb: maxMem,
extraArgs: el('f-extra-args').value.trim(),
};
payload.preLaunchCommand = el('f-prelaunch').value.trim();
@@ -1666,22 +1711,348 @@ async function launch(id, accountId) {
}
}
// ---------- Arbeitsspeicher ----------
let systemSpeicher = null;
async function zeigeSpeicherHinweis() {
const feld = el('f-mem-hint');
if (!feld) return;
try {
if (!systemSpeicher) systemSpeicher = await window.api.systemMemory();
} catch { return; }
const gesamt = systemSpeicher.totalMb;
const empfohlen = Math.floor(gesamt * 0.5 / 256) * 256;
feld.textContent = `Im Rechner stecken ${gesamt} MB. Leer lassen übernimmt die globale Vorgabe. `
+ `Für die meisten Modpacks reichen 40968192 MB; mehr als ${Math.floor(gesamt * 0.8 / 256) * 256} MB sollten es nicht sein `
+ `(Vorschlag: ${empfohlen} MB).`;
}
// ---------- Serverliste ----------
let zuUebertragenderServer = null;
async function openServers() {
if (!currentDetailId) return;
const inst = await window.api.getInstance(currentDetailId);
el('servers-title').textContent = 'Server ' + (inst ? inst.name : '');
el('srv-name').value = '';
el('srv-ip').value = '';
show('modal-servers');
await renderServers();
}
async function renderServers() {
const box = el('srv-list');
box.innerHTML = '<div class="mods-empty">Lade …</div>';
const res = await window.api.serversList(currentDetailId);
box.innerHTML = '';
if (!res.ok) { box.innerHTML = `<div class="mods-empty">${escapeHtml(res.message || 'Liste nicht lesbar.')}</div>`; return; }
if (!res.server.length) {
box.innerHTML = '<div class="mods-empty">Noch keine Server eingetragen.</div>';
return;
}
for (const s of res.server) {
const item = document.createElement('div');
item.className = 'mod-item';
item.innerHTML =
`<div class="mod-icon">🖥</div>
<div class="mod-info">
<div class="mod-name">${escapeHtml(s.name || s.ip)}</div>
<div class="mod-meta">${escapeHtml(s.ip)}</div>
</div>
<div class="mod-action"></div>`;
const aktionen = item.querySelector('.mod-action');
const kopieren = document.createElement('button');
kopieren.className = 'btn btn-mini';
kopieren.textContent = 'Übertragen…';
kopieren.title = 'Diesen Server in andere Instanzen eintragen';
kopieren.addEventListener('click', () => openServerCopy(s));
aktionen.appendChild(kopieren);
const weg = document.createElement('button');
weg.className = 'btn btn-danger btn-mini';
weg.textContent = 'Entfernen';
weg.addEventListener('click', async () => {
const r = await window.api.serversRemove(currentDetailId, s.ip);
if (r.ok) toast('Entfernt: ' + (s.name || s.ip));
else toast(r.message || 'Entfernen fehlgeschlagen.', true);
await renderServers();
});
aktionen.appendChild(weg);
box.appendChild(item);
}
}
async function addServer() {
const eintrag = { name: el('srv-name').value.trim(), ip: el('srv-ip').value.trim() };
const res = await window.api.serversAdd(currentDetailId, eintrag);
if (!res.ok) { toast(res.message || 'Hinzufügen fehlgeschlagen.', true); return; }
el('srv-name').value = '';
el('srv-ip').value = '';
toast('Server hinzugefügt.');
await renderServers();
}
async function openServerCopy(server) {
zuUebertragenderServer = server;
el('srv-copy-sub').textContent = `${server.name || server.ip}" (${server.ip}) in diese Instanzen eintragen:`;
const box = el('srv-copy-list');
box.innerHTML = '';
const alle = await window.api.listInstances();
const andere = alle.filter((i) => i.id !== currentDetailId);
if (!andere.length) {
box.innerHTML = '<div class="mods-empty">Es gibt keine weitere Instanz.</div>';
}
for (const i of andere) {
const zeile = document.createElement('label');
zeile.className = 'checkbox-field';
zeile.style.padding = '6px 2px';
zeile.innerHTML = `<input type="checkbox" value="${escapeHtml(i.id)}" /> <span>${escapeHtml(i.name)}</span>`;
box.appendChild(zeile);
}
show('modal-srv-copy');
}
async function doServerCopy() {
if (!zuUebertragenderServer) return;
const ids = [...el('srv-copy-list').querySelectorAll('input:checked')].map((c) => c.value);
if (!ids.length) { toast('Keine Instanz ausgewählt.', true); return; }
const res = await window.api.serversCopyTo(ids, zuUebertragenderServer);
hide('modal-srv-copy');
if (res.erledigt.length) toast(`Eingetragen in: ${res.erledigt.join(', ')}`);
for (const f of res.fehler) toast(`${f.name || f.id}: ${f.message}`, true);
}
// ---------- Skin ändern ----------
let gewaehlterSkin = null;
/*
* Bild nur zeigen, wenn es wirklich geladen wurde ein <img> ohne Quelle
* stellt sonst das Platzhalter-Symbol des Browsers dar.
*/
function zeigeSkinBild(bildId, leerId, quelle, leerText) {
const bild = el(bildId);
const leer = el(leerId);
leer.textContent = leerText;
if (!quelle) {
bild.classList.add('hidden');
bild.removeAttribute('src');
leer.classList.remove('hidden');
return;
}
bild.onload = () => { bild.classList.remove('hidden'); leer.classList.add('hidden'); };
bild.onerror = () => {
bild.classList.add('hidden');
bild.removeAttribute('src');
leer.textContent = 'Vorschau nicht ladbar';
leer.classList.remove('hidden');
};
bild.src = quelle;
}
// Textur einer Seite merken, damit bei einem Wechsel der Armform beide
// Figuren neu gerendert werden können
let skinTexturAktuell = '';
let skinTexturNeu = '';
// Skin-Textur als Figur rendern; klappt das nicht, wird die Textur gezeigt
async function zeigeSkinFigur(bildId, leerId, textur, leerText) {
if (!textur) { zeigeSkinBild(bildId, leerId, '', leerText); return; }
el(leerId).textContent = 'wird gerendert …';
const modell = el('skin-variante').value === 'slim' ? 'slim' : 'default';
const figur = await renderSkin3D(textur, 'standing', { modell, breite: 260, hoehe: 420 });
zeigeSkinBild(bildId, leerId, figur || textur, leerText);
}
async function openSkin() {
gewaehlterSkin = null;
skinTexturAktuell = '';
skinTexturNeu = '';
el('skin-pfad').value = '';
el('skin-apply').disabled = true;
el('skin-status').textContent = '';
zeigeSkinBild('skin-neu', 'skin-neu-leer', '', 'noch keine Datei gewählt');
zeigeSkinBild('skin-aktuell', 'skin-aktuell-leer', '', 'wird geladen …');
el('skin-konto').textContent = 'Profil wird geladen …';
show('modal-skin');
const p = await window.api.skinProfile();
if (!p.ok) {
el('skin-konto').textContent = p.message || 'Profil konnte nicht geladen werden.';
zeigeSkinBild('skin-aktuell', 'skin-aktuell-leer', '', 'kein Profil geladen');
return;
}
el('skin-konto').textContent = `Angemeldet als ${p.name}. Die Änderung gilt für dieses Konto.`;
// Armform zuerst setzen davon hängt ab, wie die Figur gerendert wird
if (p.variante) el('skin-variante').value = p.variante === 'slim' ? 'slim' : 'classic';
skinTexturAktuell = p.skinUrl || '';
await zeigeSkinFigur('skin-aktuell', 'skin-aktuell-leer', skinTexturAktuell,
'Standard-Skin (keine eigene Datei hinterlegt)');
}
// Armform gewechselt -> beide Figuren neu zeichnen
async function skinVarianteGewechselt() {
if (skinTexturAktuell) await zeigeSkinFigur('skin-aktuell', 'skin-aktuell-leer', skinTexturAktuell, '');
if (skinTexturNeu) await zeigeSkinFigur('skin-neu', 'skin-neu-leer', skinTexturNeu, '');
}
async function waehleSkinDatei() {
const res = await window.api.skinChoose();
if (res.canceled) return;
if (!res.ok) { el('skin-status').textContent = '✖ ' + res.message; return; }
gewaehlterSkin = res.pfad;
el('skin-pfad').value = res.pfad;
skinTexturNeu = res.dataUri || '';
await zeigeSkinFigur('skin-neu', 'skin-neu-leer', skinTexturNeu, 'Vorschau nicht verfügbar');
el('skin-status').textContent = `${res.breite}×${res.hoehe} Pixel sieht gut aus.`;
el('skin-apply').disabled = false;
}
async function setzeSkin() {
if (!gewaehlterSkin) return;
const btn = el('skin-apply');
btn.disabled = true;
el('skin-status').textContent = 'Wird hochgeladen …';
const res = await window.api.skinApply(gewaehlterSkin, el('skin-variante').value);
if (res.ok) {
el('skin-status').textContent = '✔ Skin gesetzt. Im Spiel kann es einen Moment dauern.';
toast('Skin geändert.');
await openSkin(); // Vorschau mit dem neuen Stand auffrischen
await applySkinBackground(); // auch der Hintergrund zeigt jetzt den neuen Skin
} else {
el('skin-status').textContent = '✖ ' + (res.message || 'Fehlgeschlagen.');
btn.disabled = false;
}
}
async function setzeSkinZurueck() {
const btn = el('skin-reset');
btn.disabled = true;
el('skin-status').textContent = 'Wird zurückgesetzt …';
const res = await window.api.skinReset();
if (res.ok) {
toast('Skin auf Standard zurückgesetzt.');
await openSkin();
await applySkinBackground();
} else {
el('skin-status').textContent = '✖ ' + (res.message || 'Fehlgeschlagen.');
}
btn.disabled = false;
}
// ---------- Logdateien ----------
let aktuellesLog = '';
async function openLogs() {
if (!currentDetailId) return;
const inst = await window.api.getInstance(currentDetailId);
el('logs-title').textContent = 'Logdateien ' + (inst ? inst.name : '');
const auswahl = el('logs-select');
auswahl.innerHTML = '';
el('logs-text').textContent = 'Lade …';
el('logs-info').textContent = '';
aktuellesLog = '';
show('modal-logs');
const dateien = await window.api.logsList(currentDetailId);
if (!dateien.length) {
el('logs-text').textContent = 'Für diese Instanz gibt es noch keine Logdateien. '
+ 'Sie entstehen beim ersten Spielstart.';
return;
}
for (const d of dateien) {
const o = document.createElement('option');
o.value = d.name;
const kb = Math.max(1, Math.round(d.groesse / 1024));
o.textContent = `${d.name} · ${kb} KB · ${new Date(d.geaendert).toLocaleString('de-DE')}`
+ (d.gepackt ? ' · gepackt' : '');
auswahl.appendChild(o);
}
await ladeLog(dateien[0].name);
}
async function ladeLog(name) {
el('logs-text').textContent = 'Lade …';
const res = await window.api.logsRead(currentDetailId, name);
if (!res.ok) {
el('logs-text').textContent = 'Datei konnte nicht gelesen werden.';
el('logs-info').textContent = '';
aktuellesLog = '';
return;
}
aktuellesLog = res.text;
el('logs-text').textContent = res.text || '(leer)';
el('logs-info').textContent = res.gekuerzt
? `${res.zeilenGesamt} Zeilen die letzten 5000 werden gezeigt`
: `${res.zeilenGesamt} Zeilen`;
// ans Ende springen: dort steht, was zuletzt passierte
const feld = el('logs-text');
feld.scrollTop = feld.scrollHeight;
}
// ---------- Absturzberichte ----------
let letzterStartMs = 0;
let aktuellerCrash = null;
async function zeigeAbsturzbericht(instanzId) {
const id = instanzId || currentDetailId;
if (!id) return;
// Minecraft schreibt den Bericht erst kurz nach dem Ende
await new Promise((r) => setTimeout(r, 900));
let res;
try { res = await window.api.crashLatest(id, letzterStartMs); }
catch { return; }
if (!res || !res.ok) return; // kein Bericht -> Konsole muss reichen
aktuellerCrash = res;
const inst = await window.api.getInstance(id);
el('crash-sub').textContent = `${inst ? inst.name + ' · ' : ''}${res.datei} · `
+ new Date(res.zeitpunkt).toLocaleString('de-DE');
const zeile = (titel, wert) => wert
? `<div class="crash-zeile"><div class="crash-titel">${titel}</div><div class="crash-wert">${escapeHtml(wert)}</div></div>`
: '';
let html = zeile('Beschreibung', res.beschreibung) + zeile('Fehler', res.fehler)
+ zeile('Verdächtige Mods', res.verdaechtigeMods);
if (res.verlauf && res.verlauf.length) {
html += `<div class="crash-zeile"><div class="crash-titel">Aufrufverlauf</div>`
+ `<div class="crash-wert"><div class="crash-verlauf">${escapeHtml(res.verlauf.join('\n'))}</div></div></div>`;
}
el('crash-kern').innerHTML = html || '<div class="hint">Der Bericht enthält keine der üblichen Kennzeichen.</div>';
el('crash-voll').textContent = res.text || '';
show('modal-crash');
}
// Fortschrittsmeldungen der Download-/Start-Pipeline
function onLaunchEvt(evt) {
const labels = { version: 'Version', client: 'Client', libraries: 'Libraries', assets: 'Assets', loader: 'Loader', java: 'Java', launch: 'Start', modpack: 'Modpack' };
const labels = { version: 'Version', client: 'Client', libraries: 'Libraries', assets: 'Assets', loader: 'Loader', java: 'Java', launch: 'Start', modpack: 'Modpack', backup: 'Sicherung' };
if (evt.phase === 'log') { appendConsole(evt.line); return; }
if (evt.phase === 'exit') {
status('Minecraft beendet (Code ' + evt.code + ')');
el('status-right').textContent = '';
appendConsole('\n[Prozess beendet, Code ' + evt.code + ']\n');
// Konsole je nach Einstellung schließen bzw. bei Absturz zeigen
window.api.getSettings().then((s) => {
const crashed = evt.code !== 0 && evt.code !== null;
// Absturzbericht suchen, bevor über die Konsole entschieden wird
// ein Bericht sagt mehr als der Java-Auswurf
if (crashed) zeigeAbsturzbericht(evt.id);
window.api.getSettings().then((s) => {
if (crashed && s.showConsoleOnCrash !== false) { openConsole(); return; }
if (s.autoCloseConsole) hide('modal-console');
}).catch(() => {});
return;
}
// Startzeitpunkt merken: nur Berichte ab hier gehören zu diesem Lauf
if (evt.phase === 'launch') letzterStartMs = Date.now();
const label = labels[evt.phase] || evt.phase;
if (evt.total) {
status(`${label}${evt.current}/${evt.total}`);
@@ -1815,6 +2186,11 @@ async function openMods() {
['m-query', 'm-search-btn', 'm-sort', 'm-category'].forEach((id) => { el(id).disabled = blocked; });
el('m-query').value = '';
el('m-more').classList.add('hidden');
// Ergebnisse der vorigen Instanz nicht mitschleppen
modUpdates = new Map();
setUpdateStatus('');
el('m-apply-updates').classList.add('hidden');
el('m-check-updates').disabled = blocked;
switchModTab('catalog');
await refreshInstalled();
@@ -1846,6 +2222,9 @@ function switchModTab(which) {
el('m-pane-installed').classList.toggle('hidden', cat);
}
// gefundene Aktualisierungen: projectId -> { title, alt, neu }
let modUpdates = new Map();
async function refreshInstalled() {
const list = await window.api.modList(currentDetailId);
currentModsInstalled = new Set(list.map((m) => m.projectId));
@@ -1854,15 +2233,46 @@ async function refreshInstalled() {
box.innerHTML = '';
if (!list.length) { box.innerHTML = '<div class="mods-empty">Noch keine Mods.</div>'; return; }
for (const m of list) {
const up = m.projectId ? modUpdates.get(m.projectId) : null;
const aus = m.enabled === false;
const item = document.createElement('div');
item.className = 'mod-item';
item.className = 'mod-item' + (aus ? ' mod-aus' : '');
const kennzeichen = [];
if (up) kennzeichen.push('<span class="mod-new">Update</span>');
if (aus) kennzeichen.push('<span class="mod-off">aus</span>');
if (m.fehlt) kennzeichen.push('<span class="mod-off">Datei fehlt</span>');
item.innerHTML =
`<div class="mod-icon">🧩</div>
`<div class="mod-icon">${aus ? '🚫' : '🧩'}</div>
<div class="mod-info">
<div class="mod-name">${escapeHtml(m.title)}</div>
<div class="mod-meta">${escapeHtml(m.versionNumber || '')} · ${escapeHtml(m.filename)}</div>
<div class="mod-name">${escapeHtml(m.title)}${kennzeichen.length ? ' ' + kennzeichen.join(' ') : ''}</div>
<div class="mod-meta">${up
? escapeHtml(up.alt.versionNumber) + ' → ' + escapeHtml(up.neu.versionNumber)
: escapeHtml(m.versionNumber || '') + ' · ' + escapeHtml(m.filename)}</div>
</div>
<div class="mod-action"></div>`;
const aktionen = item.querySelector('.mod-action');
// Ab-/Anschalten: die Datei bleibt liegen, Minecraft übergeht sie nur
if (!m.fehlt) {
const schalter = document.createElement('button');
schalter.className = 'btn btn-mini';
schalter.textContent = aus ? 'Einschalten' : 'Ausschalten';
schalter.title = aus
? 'Datei wieder auf .jar umbenennen'
: 'Datei auf .jar.disabled umbenennen Minecraft lädt sie dann nicht';
schalter.addEventListener('click', async () => {
schalter.disabled = true;
const res = await window.api.modSetEnabled(currentDetailId, m.filename, aus);
if (res.ok) toast((aus ? 'Eingeschaltet: ' : 'Ausgeschaltet: ') + m.title);
else toast(res.message || 'Umschalten fehlgeschlagen.', true);
await refreshInstalled();
});
aktionen.appendChild(schalter);
}
const btn = document.createElement('button');
btn.className = 'btn btn-danger btn-mini';
btn.textContent = 'Entfernen';
@@ -1871,11 +2281,61 @@ async function refreshInstalled() {
toast('Entfernt: ' + m.title);
await refreshInstalled();
});
item.querySelector('.mod-action').appendChild(btn);
aktionen.appendChild(btn);
box.appendChild(item);
}
}
// ---------- Mod-Aktualisierung ----------
function setUpdateStatus(text) { el('m-update-status').textContent = text || ''; }
async function checkModUpdates() {
const btn = el('m-check-updates');
btn.disabled = true;
el('m-apply-updates').classList.add('hidden');
setUpdateStatus('Suche …');
try {
const res = await window.api.modCheckUpdates(currentDetailId);
if (!res.ok) { setUpdateStatus('✖ ' + (res.message || 'Suche fehlgeschlagen.')); return; }
modUpdates = new Map((res.treffer || []).map((t) => [t.projectId, t]));
await refreshInstalled();
const teile = [];
if (!res.treffer.length) teile.push('Alles aktuell');
else teile.push(res.treffer.length + (res.treffer.length === 1 ? ' Update verfügbar' : ' Updates verfügbar'));
teile.push(res.geprueft + ' geprüft');
// von Hand abgelegte Dateien lassen sich nicht zuordnen
if (res.uebersprungen) teile.push(res.uebersprungen + ' ohne Quelle übersprungen');
setUpdateStatus(teile.join(' · '));
el('m-apply-updates').classList.toggle('hidden', !res.treffer.length);
} finally {
btn.disabled = false;
}
}
async function applyModUpdates() {
if (!modUpdates.size) return;
const btn = el('m-apply-updates');
btn.disabled = true;
setUpdateStatus('Wird aktualisiert …');
try {
const res = await window.api.modApplyUpdates(currentDetailId, [...modUpdates.keys()]);
if (!res.ok) { setUpdateStatus('✖ ' + (res.message || 'Aktualisierung fehlgeschlagen.')); return; }
modUpdates = new Map();
await refreshInstalled();
const n = res.erneuert.length;
setUpdateStatus(n + (n === 1 ? ' Mod aktualisiert' : ' Mods aktualisiert')
+ (res.fehler.length ? ' · ' + res.fehler.length + ' fehlgeschlagen' : ''));
if (n) toast(n + (n === 1 ? ' Mod aktualisiert.' : ' Mods aktualisiert.'));
for (const f of res.fehler) toast(f.title + ': ' + f.message, true);
btn.classList.add('hidden');
} finally {
btn.disabled = false;
}
}
let modOffset = 0;
async function loadCatalog(append) {
const inst = await window.api.getInstance(currentDetailId);
@@ -1973,8 +2433,9 @@ function setSettingsPane(paneId) {
document.querySelectorAll('.spane').forEach((pane) => pane.classList.toggle('hidden', pane.id !== paneId));
}
async function openSettings() {
setSettingsPane('pane-general');
// pane: optionaler Bereich, der direkt geöffnet werden soll (z. B. 'pane-network')
async function openSettings(pane) {
setSettingsPane(pane || 'pane-general');
const s = await window.api.getSettings();
el('s-theme').value = s.theme || 'dark';
@@ -1996,6 +2457,14 @@ async function openSettings() {
el('s-sortby').value = s.sortBy || 'lastPlayed';
el('s-iso3d').checked = s.iso3dIcons !== false;
el('s-bedrock').checked = s.bedrockEnabled !== false;
el('s-auto-java').checked = s.autoJava !== false;
el('s-autobackup').checked = !!s.autoBackupEnabled;
el('s-autobackup-every').value = s.autoBackupEvery || 'daily';
el('s-autobackup-keep').value = s.autoBackupKeep || 5;
el('s-autobackup-launch').checked = !!s.autoBackupBeforeLaunch;
el('s-autobackup-last').textContent = s.autoBackupLast
? 'Zuletzt automatisch gesichert: ' + new Date(s.autoBackupLast).toLocaleString('de-DE')
: 'Noch keine automatische Sicherung.';
el('s-console-launch').checked = !!s.showConsoleOnLaunch;
el('s-console-autoclose').checked = !!s.autoCloseConsole;
el('s-console-crash').checked = s.showConsoleOnCrash !== false;
@@ -2010,6 +2479,9 @@ async function openSettings() {
el('s-proxy-port').value = s.proxyPort || '';
el('s-proxy-user').value = s.proxyUser || '';
el('s-proxy-pass').value = s.proxyPass || '';
el('s-cf-key').value = s.curseforgeApiKey || '';
el('s-cf-proxy').value = s.curseforgeProxyUrl || '';
el('s-cf-proxy-token').value = s.curseforgeProxyToken || '';
el('s-backup-status').className = 'hint';
el('s-backup-status').textContent = 'Noch kein Backup erstellt.';
el('s-backup-global').disabled = false;
@@ -2086,6 +2558,13 @@ async function scanJava() {
}
async function saveSettings() {
// globale Speicher-Vorgaben genauso prüfen wie die je Instanz
const gMin = numOrNull(el('s-min-mem').value) || 2048;
const gMax = numOrNull(el('s-max-mem').value) || 4096;
const pruefung = await window.api.checkMemory(gMin, gMax);
if (!pruefung.ok) { toast(pruefung.message, true); setSettingsPane('pane-minecraft'); return; }
if (pruefung.warnung) toast(pruefung.warnung, false);
const patch = {
theme: el('s-theme').value,
globalPreLaunch: el('s-global-pre').value.trim(),
@@ -2095,6 +2574,11 @@ async function saveSettings() {
sortBy: el('s-sortby').value,
iso3dIcons: el('s-iso3d').checked,
bedrockEnabled: el('s-bedrock').checked,
autoJava: el('s-auto-java').checked,
autoBackupEnabled: el('s-autobackup').checked,
autoBackupEvery: el('s-autobackup-every').value,
autoBackupKeep: Math.min(50, Math.max(1, numOrNull(el('s-autobackup-keep').value) || 5)),
autoBackupBeforeLaunch: el('s-autobackup-launch').checked,
showConsoleOnLaunch: el('s-console-launch').checked,
autoCloseConsole: el('s-console-autoclose').checked,
showConsoleOnCrash: el('s-console-crash').checked,
@@ -2109,6 +2593,9 @@ async function saveSettings() {
proxyPort: el('s-proxy-port').value.trim(),
proxyUser: el('s-proxy-user').value.trim(),
proxyPass: el('s-proxy-pass').value,
curseforgeApiKey: el('s-cf-key').value.trim(),
curseforgeProxyUrl: el('s-cf-proxy').value.trim(),
curseforgeProxyToken: el('s-cf-proxy-token').value.trim(),
javaPath: el('s-java').value.trim(),
defaultMinMemMb: numOrNull(el('s-min-mem').value) || 2048,
defaultMaxMemMb: numOrNull(el('s-max-mem').value) || 4096,
@@ -2421,11 +2908,16 @@ async function renderAccounts(boxId = 'login-accounts') {
av.replaceWith(d);
});
// "spielt" nur, wenn wirklich ein Spiel dieses Kontos läuft; "aktiv" heißt
// lediglich: dieses Konto wird für den nächsten Start verwendet
const merkmale = [];
if (a.active) merkmale.push('aktiv');
if (a.playing) merkmale.push('spielt gerade');
const main = document.createElement('div');
main.className = 'account-main';
main.innerHTML =
`<div class="account-name">${escapeHtml(a.name)}</div>
<div class="account-tag">${a.active ? '● aktiv (spielt)' : ''}</div>`;
<div class="account-tag">${merkmale.length ? '● ' + merkmale.join(' · ') : ''}</div>`;
const actions = document.createElement('div');
actions.className = 'account-actions';
@@ -2457,10 +2949,16 @@ async function renderAccounts(boxId = 'login-accounts') {
}
}
// steht wortgleich in index.html von dort wird er beim Öffnen zurückgesetzt
const LOGIN_HINWEIS = 'Melde dich mit deinem Microsoft-Konto an (Minecraft Java Edition nötig). '
+ 'Das aktive Konto wird beim nächsten Start verwendet. Für zwei Konten gleichzeitig: '
+ 'eine Instanz starten, hier das zweite Konto aktivieren und eine weitere Instanz starten '
+ 'laufende Spiele behalten ihr Konto.';
function resetLoginHint() {
const h = el('login-hint');
h.classList.remove('login-error');
h.textContent = 'Melde dich mit deinem Microsoft-Konto an (Minecraft Java Edition nötig). Mehrere Konten sind möglich das aktive wird zum Spielen verwendet.';
h.textContent = LOGIN_HINWEIS;
}
async function openLogin() {
@@ -2517,7 +3015,8 @@ function askConfirm(title, text, okLabel, action) {
function wire() {
el('btn-new').addEventListener('click', openCreate);
el('btn-new-2').addEventListener('click', openCreate);
el('btn-settings').addEventListener('click', openSettings);
// ohne Wrapper käme das Klick-Ereignis als Bereichsname an
el('btn-settings').addEventListener('click', () => openSettings());
el('search').addEventListener('input', render);
el('btn-open-dir').addEventListener('click', () => {
@@ -2674,13 +3173,38 @@ function wire() {
// Rechte Aktionsleiste (Instanz-Aktionen)
el('p-play').addEventListener('click', () => currentDetailId && launch(currentDetailId));
el('p-offline').addEventListener('click', () => currentDetailId && launch(currentDetailId));
el('p-shortcut').addEventListener('click', () => toast('Verknüpfung erstellen folgt in einem Update.', false));
el('p-shortcut').addEventListener('click', async () => {
if (!currentDetailId) return;
const res = await window.api.createShortcut(currentDetailId);
if (res.ok) toast('Verknüpfung auf dem Desktop erstellt.');
else toast(res.message || 'Verknüpfung fehlgeschlagen.', true);
});
el('p-edit').addEventListener('click', async () => { if (!currentDetailId) return; const inst = await window.api.getInstance(currentDetailId); if (inst) openEdit(inst); });
el('p-notes').addEventListener('click', async () => { if (!currentDetailId) return; const inst = await window.api.getInstance(currentDetailId); if (inst) openEdit(inst); });
el('p-groupline').addEventListener('click', async () => { if (!currentDetailId) return; const inst = await window.api.getInstance(currentDetailId); if (inst) openEdit(inst); });
el('p-mods').addEventListener('click', openMods);
el('p-worlds').addEventListener('click', openContent);
el('p-screens').addEventListener('click', openScreens);
el('p-logs').addEventListener('click', openLogs);
el('p-servers').addEventListener('click', openServers);
el('srv-add').addEventListener('click', addServer);
el('srv-ip').addEventListener('keydown', (e) => { if (e.key === 'Enter') addServer(); });
el('srv-copy-do').addEventListener('click', doServerCopy);
el('login-skin').addEventListener('click', openSkin);
el('skin-pick').addEventListener('click', waehleSkinDatei);
el('skin-variante').addEventListener('change', skinVarianteGewechselt);
el('skin-apply').addEventListener('click', setzeSkin);
el('skin-reset').addEventListener('click', setzeSkinZurueck);
el('logs-select').addEventListener('change', (e) => ladeLog(e.target.value));
el('logs-folder').addEventListener('click', async () => {
const ok = await window.api.logsOpenFolder(currentDetailId);
if (!ok) toast('Log-Ordner nicht gefunden.', true);
});
el('logs-copy').addEventListener('click', async () => {
if (!aktuellesLog) return;
try { await navigator.clipboard.writeText(aktuellesLog); toast('Log kopiert.'); }
catch { toast('Kopieren fehlgeschlagen.', true); }
});
el('p-mcfolder').addEventListener('click', () => currentDetailId && window.api.openGameDir(currentDetailId));
el('p-configfolder').addEventListener('click', () => currentDetailId && window.api.openSubdir(currentDetailId, 'config'));
el('p-instfolder').addEventListener('click', () => currentDetailId && window.api.openInstanceFolder(currentDetailId));
@@ -2743,6 +3267,27 @@ function wire() {
el('m-tab-catalog').addEventListener('click', () => switchModTab('catalog'));
el('m-tab-installed').addEventListener('click', () => switchModTab('installed'));
el('m-open-folder').addEventListener('click', () => window.api.openGameDir(currentDetailId));
// Automatische Sicherung: unaufdringlich melden, Fehler deutlich
window.api.onAutoBackup((d) => {
if (d.ok) toast(d.anlass === 'vor-dem-start' ? 'Sicherung vor dem Start erstellt.' : 'Automatische Sicherung erstellt.');
else toast('Automatische Sicherung fehlgeschlagen: ' + (d.message || 'unbekannter Grund'), true);
});
// Absturzbericht
el('crash-copy').addEventListener('click', async () => {
if (!aktuellerCrash) return;
try { await navigator.clipboard.writeText(aktuellerCrash.text || ''); toast('Bericht kopiert.'); }
catch { toast('Kopieren fehlgeschlagen.', true); }
});
el('crash-folder').addEventListener('click', async () => {
const ok = await window.api.crashOpenFolder(currentDetailId);
if (!ok) toast('Ordner mit den Berichten nicht gefunden.', true);
});
el('crash-console').addEventListener('click', () => { hide('modal-crash'); openConsole(); });
el('m-check-updates').addEventListener('click', checkModUpdates);
el('m-apply-updates').addEventListener('click', applyModUpdates);
window.api.onModUpdateProgress((p) => setUpdateStatus(`Suche … ${p.geprueft}/${p.gesamt} (${p.titel})`));
// Einstellungen
el('s-pick-dir').addEventListener('click', async () => {
@@ -2782,6 +3327,7 @@ function wire() {
// Updates
el('upd-install').addEventListener('click', startUpdate);
el('upd-open').addEventListener('click', openReleasePage);
if (el('s-check-update')) {
el('s-check-update').addEventListener('click', async () => {
const st = el('s-update-status');
@@ -2846,6 +3392,13 @@ window.addEventListener('DOMContentLoaded', async () => {
window.api.onAuthCode(onAuthCode);
window.api.onAuthRefreshed(async () => { await refreshAccountButton(); await applySkinBackground(); });
window.api.onAuthHealth(showAuthHealth);
// Spiel gestartet oder beendet -> "spielt gerade" in den Kontenlisten nachziehen
window.api.onAccountsChanged(() => { refreshAllAccountLists(); });
// über eine Desktop-Verknüpfung gestartet -> Instanz auswählen und anwerfen
window.api.onLaunchRequest(async (id) => {
await selectInstance(id);
launch(id);
});
window.api.onUpdateAvailable(showUpdateDialog);
window.api.onUpdateProgress(onUpdateProgress);
try { showAuthHealth(await window.api.authHealth()); } catch { /* noch keine Prüfung */ }

146
src/servers.js Normal file
View File

@@ -0,0 +1,146 @@
'use strict';
/*
* servers.js Serverliste einer Instanz (servers.dat)
* -----------------------------------------------------
* Minecraft legt die Liste der gespeicherten Server als unkomprimiertes NBT
* unter <spielordner>/servers.dat ab. Aufbau:
*
* Compound "" Wurzel
* List "servers" Liste von Compounds
* String "name" Anzeigename
* String "ip" Adresse (optional mit :Port)
* String "icon" Base64-PNG des Serversymbols (optional)
* Byte "acceptTextures" Ressourcenpakete annehmen (optional)
*
* Beim Schreiben wird die vorhandene Datei zuerst gesichert eine kaputte
* servers.dat würde alle gespeicherten Server kosten.
*/
const fs = require('fs');
const path = require('path');
const nbt = require('./nbt');
function datei(gameDir) {
return path.join(gameDir, 'servers.dat');
}
/*
* Liest die Serverliste.
* -> { ok, server: [{ name, ip, icon, acceptTextures }] }
*/
function lies(gameDir) {
const p = datei(gameDir);
if (!fs.existsSync(p)) return { ok: true, server: [], leer: true };
let roh;
try { roh = fs.readFileSync(p); } catch (err) { return { ok: false, message: err.message }; }
if (!roh.length) return { ok: true, server: [], leer: true };
try {
const { wert } = nbt.lese(roh);
const liste = Array.isArray(wert.servers) ? wert.servers : [];
return {
ok: true,
server: liste.map((s) => ({
name: String(s.name || ''),
ip: String(s.ip || ''),
icon: typeof s.icon === 'string' ? s.icon : '',
acceptTextures: Number(s.acceptTextures || 0),
})),
};
} catch (err) {
return { ok: false, message: 'servers.dat konnte nicht gelesen werden: ' + err.message };
}
}
/*
* Schreibt die Serverliste zurück.
* Die alte Datei wird vorher als servers.dat.bak gesichert.
*/
function schreibe(gameDir, server) {
const p = datei(gameDir);
try {
fs.mkdirSync(gameDir, { recursive: true });
const eintraege = (server || []).map((s) => {
const c = {
__typen: { name: nbt.TAG.STRING, ip: nbt.TAG.STRING },
name: String(s.name || ''),
ip: String(s.ip || ''),
};
// icon und acceptTextures nur mitschreiben, wenn vorhanden
// so wie Minecraft es selbst tut
if (s.icon) { c.__typen.icon = nbt.TAG.STRING; c.icon = String(s.icon); }
if (s.acceptTextures) { c.__typen.acceptTextures = nbt.TAG.BYTE; c.acceptTextures = Number(s.acceptTextures); }
return c;
});
const liste = eintraege;
liste.__typ = nbt.TAG.COMPOUND;
const wurzel = { __typen: { servers: nbt.TAG.LIST }, servers: liste };
const daten = nbt.schreibe('', wurzel);
// erst sichern, dann über eine Zwischendatei ersetzen
if (fs.existsSync(p)) {
try { fs.copyFileSync(p, p + '.bak'); } catch { /* Sicherung ist Kür */ }
}
const tmp = p + '.neu';
fs.writeFileSync(tmp, daten);
fs.renameSync(tmp, p);
return { ok: true, anzahl: eintraege.length };
} catch (err) {
return { ok: false, message: 'servers.dat konnte nicht geschrieben werden: ' + err.message };
}
}
// Adresse normalisieren und grob prüfen
function pruefeAdresse(ip) {
const s = String(ip || '').trim();
if (!s) return { ok: false, message: 'Bitte eine Serveradresse angeben.' };
if (/\s/.test(s)) return { ok: false, message: 'Die Adresse darf keine Leerzeichen enthalten.' };
const m = s.match(/^([^:]+)(?::(\d+))?$/);
if (!m) return { ok: false, message: 'Die Adresse sieht nicht wie ein Server aus.' };
if (m[2] && (Number(m[2]) < 1 || Number(m[2]) > 65535)) {
return { ok: false, message: 'Der Port muss zwischen 1 und 65535 liegen.' };
}
return { ok: true, ip: s };
}
function fuegeHinzu(gameDir, eintrag) {
const geprueft = pruefeAdresse(eintrag && eintrag.ip);
if (!geprueft.ok) return geprueft;
const gelesen = lies(gameDir);
if (!gelesen.ok) return gelesen;
const liste = gelesen.server;
// dieselbe Adresse nicht doppelt aufnehmen
if (liste.some((s) => s.ip.toLowerCase() === geprueft.ip.toLowerCase())) {
return { ok: false, message: 'Dieser Server steht bereits in der Liste.' };
}
liste.push({
name: String((eintrag && eintrag.name) || geprueft.ip).trim() || geprueft.ip,
ip: geprueft.ip,
icon: '',
acceptTextures: 0,
});
return schreibe(gameDir, liste);
}
function entferne(gameDir, ip) {
const gelesen = lies(gameDir);
if (!gelesen.ok) return gelesen;
const vorher = gelesen.server.length;
const liste = gelesen.server.filter((s) => s.ip.toLowerCase() !== String(ip || '').toLowerCase());
if (liste.length === vorher) return { ok: false, message: 'Server nicht gefunden.' };
return schreibe(gameDir, liste);
}
/*
* Einen Server in eine andere Instanz übertragen der eigentliche Zweck:
* denselben Server nicht in jeder Instanz neu eintippen zu müssen.
*/
function uebertrage(zielGameDir, eintrag) {
return fuegeHinzu(zielGameDir, eintrag);
}
module.exports = { lies, schreibe, fuegeHinzu, entferne, uebertrage, pruefeAdresse, datei };

View File

@@ -19,10 +19,50 @@ const { execFileSync, spawnSync } = require('child_process');
const UA = 'AeroMC-Launcher/0.2 (Minecraft-Launcher; contact: viper)';
// ---------------------------------------------------------------------------
// Arbeitsspeicher
// ---------------------------------------------------------------------------
/*
* Prüft die Speicherangaben einer Instanz gegen den vorhandenen Arbeitsspeicher.
* Ohne diese Prüfung startet Java mit einem kryptischen Fehler, den niemand
* mit "zu viel RAM eingetragen" in Verbindung bringt.
*
* -> { ok, message } bei Fehlern, { ok: true, warnung } bei knappen Angaben
*/
function pruefeSpeicher(minMb, maxMb, gesamtMb) {
const min = Number(minMb) || 0;
const max = Number(maxMb) || 0;
const gesamt = Number(gesamtMb) || 0;
if (min && max && min > max) {
return { ok: false, message: `Der Mindestspeicher (${min} MB) ist größer als das Maximum (${max} MB).` };
}
if (max && max < 512) {
return { ok: false, message: `${max} MB sind zu wenig Minecraft braucht mindestens 512 MB.` };
}
if (gesamt && max && max > gesamt) {
return { ok: false, message: `${max} MB übersteigen den vorhandenen Arbeitsspeicher (${gesamt} MB). Minecraft würde nicht starten.` };
}
// Dem System muss etwas bleiben: Windows und der Launcher selbst brauchen auch Platz
if (gesamt && max && max > gesamt * 0.8) {
return {
ok: true,
warnung: `${max} MB sind mehr als 80 % des Arbeitsspeichers (${gesamt} MB). `
+ `Für Windows und den Launcher sollte etwas übrig bleiben üblich sind höchstens ${Math.floor(gesamt * 0.7 / 256) * 256} MB.`,
};
}
return { ok: true };
}
// ---------------------------------------------------------------------------
// Java-Erkennung
// ---------------------------------------------------------------------------
// vom Launcher selbst heruntergeladene Laufzeiten (wird beim Start gesetzt)
let managedJavaDir = null;
function setManagedJavaDir(dir) { managedJavaDir = dir; }
function javaCandidateDirs() {
const pf = process.env['ProgramFiles'] || 'C:\\Program Files';
const pfx = process.env['ProgramFiles(x86)'] || 'C:\\Program Files (x86)';
@@ -37,6 +77,8 @@ function javaCandidateDirs() {
// Runtimes der offiziellen Minecraft-Launcher
if (appdata) roots.push(path.join(appdata, '.minecraft', 'runtime'));
if (localappdata) roots.push(path.join(localappdata, 'Packages'));
// eigene, vom Launcher geholte Laufzeiten
if (managedJavaDir) roots.push(managedJavaDir);
return roots;
}
@@ -238,8 +280,9 @@ async function modrinthResolveFile({ projectId, gameVersion, loader }) {
};
}
async function downloadFile(url, destPath) {
const res = await fetch(url, { headers: { 'User-Agent': UA } });
// extraHeaders: z. B. der CurseForge-API-Key, den das CDN seit Juli 2026 verlangt
async function downloadFile(url, destPath, extraHeaders) {
const res = await fetch(url, { headers: Object.assign({ 'User-Agent': UA }, extraHeaders || {}) });
if (!res.ok) throw new Error('Download fehlgeschlagen: HTTP ' + res.status);
const buf = Buffer.from(await res.arrayBuffer());
fs.mkdirSync(path.dirname(destPath), { recursive: true });
@@ -273,6 +316,156 @@ async function modrinthInstall({ projectId, title, gameVersion, loader, modsDir
};
}
/*
* Prüft die installierten Mods auf neuere Dateien.
* Von Hand abgelegte .jar-Dateien (ohne projectId) bleiben unangetastet
* für die weiß niemand, woher sie stammen.
* -> [{ projectId, title, alt: {...}, neu: {...} }]
*/
async function modrinthFindUpdates(mods, gameVersion, loader, onProgress) {
const kandidaten = (mods || []).filter((m) => m && m.projectId);
const treffer = [];
let geprueft = 0;
// in kleinen Gruppen abfragen: schneller als nacheinander, ohne die API zu fluten
const GRUPPE = 4;
for (let i = 0; i < kandidaten.length; i += GRUPPE) {
await Promise.all(kandidaten.slice(i, i + GRUPPE).map(async (m) => {
let file = null;
try {
file = await modrinthResolveFile({ projectId: m.projectId, gameVersion, loader });
} catch { /* Netzfehler -> diese Mod überspringen */ }
geprueft++;
if (onProgress) onProgress({ geprueft, gesamt: kandidaten.length, titel: m.title || m.projectId });
if (!file || !file.versionId) return;
// schon aktuell?
if (m.versionId && file.versionId === m.versionId) return;
if (file.filename === m.filename) return;
treffer.push({
projectId: m.projectId,
title: m.title || m.projectId,
alt: { filename: m.filename, versionNumber: m.versionNumber || '?' },
neu: { filename: file.filename, versionNumber: file.versionNumber, versionId: file.versionId, size: file.size },
});
}));
}
treffer.sort((a, b) => String(a.title).localeCompare(String(b.title)));
return { geprueft: kandidaten.length, uebersprungen: (mods || []).length - kandidaten.length, treffer };
}
// ---------------------------------------------------------------------------
// CurseForge (Modpack-Dateien auflösen)
// ---------------------------------------------------------------------------
const CURSEFORGE = 'https://api.curseforge.com/v1';
/*
* Vorgaben, die beim Bauen gesetzt wurden (scripts/write-build-config.js).
* Fehlt die Datei etwa beim Start aus dem Quelltext , gilt: keine Vorgabe.
*/
let BUILD_VORGABEN = {};
try { BUILD_VORGABEN = require('./build-config.json'); } catch { /* nicht erzeugt */ }
/*
* Adresse der eigenen CurseForge-Weiterleitung. Die darf fest hier stehen
* sie ist kein Geheimnis, der API-Schlüssel liegt auf dem Server.
* Über AEROMC_CF_PROXY_URL beim Bauen überschreibbar.
*/
const STANDARD_WEITERLEITUNG = String(BUILD_VORGABEN.curseforgeProxyUrl || 'https://curse.viper.ipv64.net');
// Das Token dagegen kommt ausschließlich aus dem Build (AEROMC_CF_PROXY_TOKEN)
// und steht bewusst nicht im Quelltext.
const STANDARD_TOKEN = String(BUILD_VORGABEN.curseforgeProxyToken || '');
/*
* Entscheidet, wie CurseForge angesprochen wird.
* Eigener Schlüssel hat Vorrang: wer einen hat, geht direkt und belastet
* kein fremdes Kontingent.
* -> { modus, basis, kopfzeilen, downloadAdresse(url) } oder null
*/
function curseforgeZugang(settings) {
const s = settings || {};
const key = String(s.curseforgeApiKey || '').trim();
if (key) {
return {
modus: 'direkt',
basis: CURSEFORGE,
kopfzeilen: { 'x-api-key': key },
downloadAdresse: (url) => url,
downloadKopfzeilen: { 'x-api-key': key },
};
}
const eigeneAdresse = String(s.curseforgeProxyUrl || '').trim().replace(/\/+$/, '');
const weiter = eigeneAdresse || STANDARD_WEITERLEITUNG;
if (weiter) {
// Zur eigenen Adresse gehört auch das eigene Token sonst schickte der
// Launcher das Token unseres Servers an einen fremden.
const token = eigeneAdresse
? String(s.curseforgeProxyToken || '').trim()
: (String(s.curseforgeProxyToken || '').trim() || STANDARD_TOKEN);
const kopf = token ? { 'x-aeromc-token': token } : {};
return {
modus: 'weiterleitung',
basis: weiter + '/v1',
kopfzeilen: kopf,
downloadAdresse: (url) => `${weiter}/v1/download?url=${encodeURIComponent(url)}`,
downloadKopfzeilen: kopf,
};
}
return null;
}
/*
* Ausweichadresse auf dem CurseForge-CDN. Manche Autoren untersagen den
* Download über die API; dann liefert downloadUrl null, die Datei liegt aber
* unter diesem berechenbaren Pfad.
*/
function forgeCdnUrl(fileId, fileName) {
const s = String(fileId || '');
if (s.length < 5 || !fileName) return null;
return `https://edge.forgecdn.net/files/${s.slice(0, 4)}/${Number(s.slice(4))}/${encodeURIComponent(fileName)}`;
}
/*
* Löst die Dateien eines CurseForge-Modpacks auf.
* zugang: aus curseforgeZugang(); files: [{ projectID, fileID }]
* -> Map fileID -> { fileName, url, kopfzeilen }
*/
async function curseforgeResolveFiles(zugang, files, onProgress) {
if (!zugang) throw new Error('Kein Zugang zu CurseForge eingerichtet.');
const ids = (files || []).map((f) => Number(f.fileID)).filter(Boolean);
const map = new Map();
const PAKET = 100;
for (let i = 0; i < ids.length; i += PAKET) {
const teil = ids.slice(i, i + PAKET);
const res = await fetch(zugang.basis + '/mods/files', {
method: 'POST',
headers: Object.assign({ 'Content-Type': 'application/json', Accept: 'application/json', 'User-Agent': UA }, zugang.kopfzeilen),
body: JSON.stringify({ fileIds: teil }),
});
if (res.status === 401 || res.status === 403) {
throw new Error(zugang.modus === 'weiterleitung'
? 'Der CurseForge-Server weist die Anfrage zurück (HTTP ' + res.status + ').'
: 'CurseForge weist den API-Key zurück (HTTP ' + res.status + ').');
}
if (res.status === 429) throw new Error('Zu viele Anfragen bitte einen Moment warten.');
if (!res.ok) throw new Error('CurseForge-Abfrage fehlgeschlagen: HTTP ' + res.status);
const daten = await res.json();
for (const d of (daten && daten.data) || []) {
const roh = d.downloadUrl || forgeCdnUrl(d.id, d.fileName);
if (!d.fileName || !roh) continue;
map.set(Number(d.id), {
fileName: d.fileName,
url: zugang.downloadAdresse(roh),
kopfzeilen: zugang.downloadKopfzeilen,
});
}
if (onProgress) onProgress({ aufgeloest: Math.min(i + PAKET, ids.length), gesamt: ids.length });
}
return map;
}
// Projekttitel nachschlagen (für Abhängigkeiten, die nur eine ID liefern)
async function modrinthProjectTitle(projectId) {
try {
@@ -284,7 +477,9 @@ async function modrinthProjectTitle(projectId) {
}
module.exports = {
scanJava, probeJava, setCacheDir,
scanJava, probeJava, setCacheDir, setManagedJavaDir, pruefeSpeicher,
getMcVersions,
modrinthSearch, modrinthResolveFile, modrinthInstall, modrinthProjectTitle, downloadFile,
modrinthSearch, modrinthResolveFile, modrinthInstall, modrinthProjectTitle,
modrinthFindUpdates, downloadFile,
curseforgeResolveFiles, forgeCdnUrl, curseforgeZugang,
};

121
src/skins.js Normal file
View File

@@ -0,0 +1,121 @@
'use strict';
/*
* skins.js Skin und Umhang über die Minecraft-Dienste ändern
* -------------------------------------------------------------
* Der Launcher hält bereits gültige Zugangsdaten des angemeldeten Kontos.
* Damit lässt sich der Skin direkt setzen, ohne Umweg über den Browser.
*
* Verwendete Wege (api.minecraftservices.com):
* POST /minecraft/profile/skins Skin hochladen (Datei oder Adresse)
* DELETE /minecraft/profile/skins/active Skin zurücksetzen (Standard-Steve/Alex)
* GET /minecraft/profile aktuelles Profil samt Skin-Adresse
*/
const fs = require('fs');
const path = require('path');
const API = 'https://api.minecraftservices.com';
const UA = 'AeroMC-Launcher';
// Minecraft nimmt nur PNG, und die Vorlagen sind 64x64 bzw. 64x32
const MAX_BYTES = 512 * 1024;
function pruefeDatei(dateiPfad) {
if (!dateiPfad || !fs.existsSync(dateiPfad)) return { ok: false, message: 'Datei nicht gefunden.' };
if (!/\.png$/i.test(dateiPfad)) return { ok: false, message: 'Minecraft nimmt nur PNG-Dateien.' };
const st = fs.statSync(dateiPfad);
if (!st.isFile()) return { ok: false, message: 'Das ist keine Datei.' };
if (st.size > MAX_BYTES) return { ok: false, message: 'Die Datei ist zu groß (über 512 KB).' };
// PNG-Kennung und Maße aus dem Kopf lesen, bevor wir sie hochschicken
const kopf = Buffer.alloc(33);
const fd = fs.openSync(dateiPfad, 'r');
try { fs.readSync(fd, kopf, 0, 33, 0); } finally { fs.closeSync(fd); }
const PNG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
if (!kopf.subarray(0, 8).equals(PNG)) return { ok: false, message: 'Die Datei ist kein gültiges PNG.' };
const breite = kopf.readUInt32BE(16);
const hoehe = kopf.readUInt32BE(20);
// 64x64 ist heute üblich, 64x32 sind die alten Skins
if (!(breite === 64 && (hoehe === 64 || hoehe === 32))) {
return { ok: false, message: `Ein Skin muss 64×64 (oder 64×32) Pixel groß sein diese Datei ist ${breite}×${hoehe}.` };
}
return { ok: true, breite, hoehe };
}
function fehlerText(status) {
if (status === 401) return 'Die Anmeldung ist abgelaufen. Bitte das Konto neu verbinden.';
if (status === 403) return 'Minecraft lehnt die Änderung ab. Hat das Konto Minecraft Java Edition?';
if (status === 429) return 'Zu viele Änderungen in kurzer Zeit. Bitte einen Moment warten.';
return 'Minecraft-Dienste antworten mit HTTP ' + status + '.';
}
/*
* Skin setzen.
* variante: 'classic' (breite Arme) oder 'slim' (schmale Arme, Alex)
*/
async function setzeSkin(accessToken, dateiPfad, variante) {
const geprueft = pruefeDatei(dateiPfad);
if (!geprueft.ok) return geprueft;
const form = new FormData();
form.append('variant', variante === 'slim' ? 'slim' : 'classic');
const daten = fs.readFileSync(dateiPfad);
form.append('file', new Blob([daten], { type: 'image/png' }), path.basename(dateiPfad));
const res = await fetch(API + '/minecraft/profile/skins', {
method: 'POST',
headers: { Authorization: 'Bearer ' + accessToken, 'User-Agent': UA },
body: form,
});
if (!res.ok) return { ok: false, message: fehlerText(res.status) };
let profil = null;
try { profil = await res.json(); } catch { /* Antwort ohne Rumpf ist auch in Ordnung */ }
return { ok: true, variante: variante === 'slim' ? 'slim' : 'classic', profil };
}
// Skin auf den Standard zurücksetzen
async function setzeSkinZurueck(accessToken) {
const res = await fetch(API + '/minecraft/profile/skins/active', {
method: 'DELETE',
headers: { Authorization: 'Bearer ' + accessToken, 'User-Agent': UA },
});
if (!res.ok) return { ok: false, message: fehlerText(res.status) };
return { ok: true };
}
/*
* Aktuelles Profil abrufen liefert die Adresse des aktiven Skins,
* damit die Oberfläche vorher/nachher zeigen kann.
*/
/*
* Mojang liefert Skin-Adressen bis heute als "http://textures.minecraft.net/…".
* Die Oberfläche lässt nach ihrer Sicherheitsrichtlinie nur https zu, das Bild
* würde also blockiert. textures.minecraft.net beantwortet dieselbe Adresse
* auch über https daher heben wir sie an.
*/
function aufHttps(url) {
const s = String(url || '');
return s.startsWith('http://') ? 'https://' + s.slice('http://'.length) : s;
}
async function holeProfil(accessToken) {
const res = await fetch(API + '/minecraft/profile', {
headers: { Authorization: 'Bearer ' + accessToken, 'User-Agent': UA, Accept: 'application/json' },
});
if (!res.ok) return { ok: false, message: fehlerText(res.status) };
const daten = await res.json();
const aktiv = (daten.skins || []).find((s) => s.state === 'ACTIVE') || null;
return {
ok: true,
name: daten.name,
id: daten.id,
skinUrl: aktiv ? aufHttps(aktiv.url) : '',
variante: aktiv ? String(aktiv.variant || '').toLowerCase() : '',
};
}
module.exports = { setzeSkin, setzeSkinZurueck, holeProfil, pruefeDatei, aufHttps };

View File

@@ -50,9 +50,18 @@ function newId() {
const DEFAULT_SETTINGS = {
instancesDir: null, // null => Standard (userData/instances)
javaPath: '', // leer => "java" aus PATH
autoJava: true, // fehlende Java-Version bei Bedarf selbst herunterladen
autoBackupEnabled: false, // Sicherungen nach Zeitplan standardmäßig aus
autoBackupEvery: 'daily', // 'daily' | 'weekly'
autoBackupBeforeLaunch: false, // zusätzlich vor jedem Spielstart
autoBackupKeep: 5, // wie viele automatische Sicherungen behalten werden
autoBackupLast: '', // Zeitpunkt der letzten automatischen Sicherung
defaultMinMemMb: 2048,
defaultMaxMemMb: 4096,
azureClientId: '', // Azure-App-ID für den Microsoft-Login (Phase 3)
curseforgeApiKey: '', // eigener CurseForge-API-Key (hat Vorrang)
curseforgeProxyUrl: '', // sonst: Adresse der eigenen CurseForge-Weiterleitung
curseforgeProxyToken: '', // optionales Geheimnis für diese Weiterleitung
globalPreLaunch: '',
globalPostExit: '',
lastSelectedId: '', // zuletzt ausgewählte Instanz (beim Start wieder markieren)
@@ -125,32 +134,84 @@ function modsIndexFile(id) {
// ---------- installierte Mods (Metadaten je Instanz) ----------
/*
* Abgeschaltete Mods heißen "<name>.jar.disabled" so übergeht Minecraft sie,
* ohne dass die Datei verloren geht. In mods.json steht immer der eingeschaltete
* Name; ob eine Mod an ist, entscheidet allein die Datei auf der Platte.
*/
const MOD_ENDUNG = /\.(jar|zip)$/i;
const AUS_ENDUNG = /\.(jar|zip)\.disabled$/i;
function scanModsDir(id) {
const dir = modsDir(id);
if (!fs.existsSync(dir)) return [];
return fs.readdirSync(dir, { withFileTypes: true })
.filter((entry) => entry.isFile() && /\.(jar|zip)$/i.test(entry.name))
.sort((a, b) => a.name.localeCompare(b.name))
.map((entry) => ({
const gefunden = [];
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
if (!entry.isFile()) continue;
const aus = AUS_ENDUNG.test(entry.name);
if (!aus && !MOD_ENDUNG.test(entry.name)) continue;
const filename = aus ? entry.name.replace(/\.disabled$/i, '') : entry.name;
gefunden.push({
projectId: null,
title: entry.name.replace(/\.(jar|zip)$/i, ''),
filename: entry.name,
title: filename.replace(MOD_ENDUNG, ''),
filename,
versionNumber: 'Importiert',
}));
enabled: !aus,
});
}
gefunden.sort((a, b) => a.filename.localeCompare(b.filename));
return gefunden;
}
function listMods(id) {
const aufDerPlatte = new Map(scanModsDir(id).map((m) => [m.filename, m]));
const indexed = readJson(modsIndexFile(id), []);
const merged = new Map();
for (const entry of indexed) {
if (entry && entry.filename) merged.set(entry.filename, entry);
if (!entry || !entry.filename) continue;
const platte = aufDerPlatte.get(entry.filename);
merged.set(entry.filename, Object.assign({}, entry, {
enabled: platte ? platte.enabled : true,
fehlt: !platte, // steht im Verzeichnis, Datei ist aber weg
}));
}
for (const entry of scanModsDir(id)) {
if (!merged.has(entry.filename)) merged.set(entry.filename, entry);
for (const [name, entry] of aufDerPlatte) {
if (!merged.has(name)) merged.set(name, Object.assign({ fehlt: false }, entry));
}
return [...merged.values()];
}
/*
* Schaltet eine Mod an oder aus, indem die Datei umbenannt wird.
* -> { ok, enabled } oder { ok: false, reason }
*/
function setModEnabled(id, filename, enabled) {
const dir = modsDir(id);
const anPfad = path.join(dir, filename);
const ausPfad = anPfad + '.disabled';
// Pfad darf den mods-Ordner nicht verlassen
if (path.dirname(anPfad) !== dir) return { ok: false, reason: 'bad-name' };
const vonPfad = enabled ? ausPfad : anPfad;
const nachPfad = enabled ? anPfad : ausPfad;
if (fs.existsSync(anPfad) && fs.existsSync(ausPfad)) {
// beide Fassungen da nichts anfassen, sonst geht eine verloren
return { ok: false, reason: 'both-exist' };
}
if (!fs.existsSync(vonPfad)) {
if (fs.existsSync(nachPfad)) return { ok: true, enabled, unveraendert: true };
return { ok: false, reason: 'not-found' };
}
try {
fs.renameSync(vonPfad, nachPfad);
return { ok: true, enabled };
} catch (err) {
return { ok: false, reason: err.message };
}
}
function addMod(id, entry) {
const list = listMods(id).filter((m) => m.filename !== entry.filename);
list.push(entry);
@@ -161,9 +222,11 @@ function addMod(id, entry) {
function removeMod(id, filename) {
const list = listMods(id).filter((m) => m.filename !== filename);
writeJson(modsIndexFile(id), list);
// zugehörige .jar löschen
// zugehörige Datei löschen auch, wenn sie gerade abgeschaltet ist
const jar = path.join(modsDir(id), filename);
try { if (fs.existsSync(jar)) fs.unlinkSync(jar); } catch { /* egal */ }
for (const p of [jar, jar + '.disabled']) {
try { if (fs.existsSync(p)) fs.unlinkSync(p); } catch { /* egal */ }
}
return list;
}
@@ -366,6 +429,7 @@ module.exports = {
listMods,
addMod,
removeMod,
setModEnabled,
listInstances,
getInstance,
findInstanceByName,

View File

@@ -248,6 +248,26 @@ body{display:grid;grid-template-rows:auto 1fr auto;height:100vh;overflow:hidden}
.mod-name{font-weight:600;font-size:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.mod-desc{font-size:12px;color:var(--dim);display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
.mod-meta{font-size:11px;color:var(--dim);margin-top:2px}
.mod-new{display:inline-block;margin-left:6px;padding:1px 6px;border-radius:9px;font-size:10px;font-weight:600;background:#2f7d32;color:#fff;vertical-align:middle}
.mod-off{display:inline-block;margin-left:6px;padding:1px 6px;border-radius:9px;font-size:10px;font-weight:600;background:#6b6b6b;color:#fff;vertical-align:middle}
.crash-kern{margin:10px 0;padding:10px 12px;border-left:3px solid #c0392b;background:rgba(192,57,43,.08);border-radius:0 4px 4px 0}
.crash-zeile{display:flex;gap:8px;margin-bottom:5px;font-size:12px;line-height:1.45}
.crash-zeile:last-child{margin-bottom:0}
.crash-titel{flex:0 0 132px;color:var(--dim)}
.crash-wert{flex:1;word-break:break-word}
.crash-verlauf{margin:0;font-family:var(--mono);font-size:11px;line-height:1.5;color:var(--dim);white-space:pre-wrap;word-break:break-all}
.skin-vorschau{display:flex;gap:14px;margin-top:12px}
.skin-spalte{flex:1;display:flex;flex-direction:column;gap:4px;min-width:0}
.skin-label{font-size:11px;color:var(--dim)}
.skin-bild{height:300px;display:flex;align-items:center;justify-content:center;border:1px solid var(--border);border-radius:6px;background:rgba(0,0,0,.15);padding:6px;text-align:center;overflow:hidden}
/* Die gerenderte Figur soll das Feld ausfüllen; eine rohe 64x64-Textur wird
ohne Weichzeichnen vergrößert, damit sie nicht verschwommen wirkt. */
.skin-bild img{max-width:100%;max-height:100%;image-rendering:pixelated}
.crash-details{margin-top:12px}
.crash-details summary{cursor:pointer;font-size:12px;color:var(--dim)}
.crash-voll{max-height:320px;overflow:auto;margin-top:8px;padding:10px;background:rgba(0,0,0,.25);border-radius:4px;font-family:var(--mono);font-size:11px;line-height:1.45;white-space:pre-wrap;word-break:break-word}
.mod-aus .mod-name,.mod-aus .mod-meta{opacity:.55}
.mod-aus .mod-icon{filter:grayscale(1);opacity:.6}
.mod-action{flex:none}
/* Import */

View File

@@ -17,20 +17,117 @@ const { spawn } = require('child_process');
const API = 'https://git.viper.ipv64.net/api/v1/repos/M_Viper/AeroMc-Launcher/releases';
const UA = 'AeroMC-Launcher';
/*
* Paketart der laufenden Anwendung: 'portable', 'setup' oder 'unknown'.
*
* Wichtig: es wird nie geraten. Portable-Nutzer dürfen niemals einen Installer
* bekommen und Setup-Nutzer niemals eine Portable-Datei im Zweifel lieber
* 'unknown' und gar kein automatisches Update anbieten.
*/
let cachedKind = null;
function detectInstallKind() {
const portableExe = String(process.env.PORTABLE_EXECUTABLE_FILE || '').trim();
const execName = path.basename(portableExe || process.execPath || '').toLowerCase();
if (portableExe || execName.includes('portable')) return 'portable';
return 'setup';
if (cachedKind) return cachedKind;
cachedKind = computeInstallKind();
return cachedKind;
}
// "1.2.3" -> [1,2,3]; führendes "v" und Zusätze wie "-beta" werden ignoriert
function computeInstallKind() {
// 1. Portable: diese Variablen setzt ausschließlich der Portable-Build von
// electron-builder das ist das verlässlichste Signal überhaupt.
if (String(process.env.PORTABLE_EXECUTABLE_FILE || '').trim()) return 'portable';
if (String(process.env.PORTABLE_EXECUTABLE_DIR || '').trim()) return 'portable';
if (String(process.env.PORTABLE_EXECUTABLE_APP_FILENAME || '').trim()) return 'portable';
const exe = process.execPath || '';
// 2. Notnagel über den Dateinamen, falls die Variablen einmal fehlen
if (/portable/i.test(path.basename(exe))) return 'portable';
// 3. Setup: NSIS legt neben der EXE immer einen Uninstaller ab. Nur wenn der
// da ist, ist es wirklich eine Installation.
if (hasUninstaller(path.dirname(exe))) return 'setup';
// 4. Alles andere (Entwicklungsmodus, entpackter Ordner, unbekannte Kopie)
return 'unknown';
}
function hasUninstaller(dir) {
try {
return fs.readdirSync(dir).some((n) => /^unins(tall)?.*\.exe$/i.test(n));
} catch {
return false;
}
}
// Pfad der laufenden Portable-EXE (die Datei, die der Nutzer selbst gestartet hat)
function portableTarget() {
const p = String(process.env.PORTABLE_EXECUTABLE_FILE || '').trim();
if (p) return p;
// Fallback: nur brauchbar, wenn die EXE nicht entpackt ausgeführt wird
const exe = process.execPath || '';
return path.basename(exe).toLowerCase().includes('portable') ? exe : '';
}
function updateDir() {
return path.join(os.tmpdir(), 'aeromc-update');
}
/*
* Räumt Reste alter Update-Vorgänge weg (Installer sind mehrere hundert MB groß).
* Dateien, die jünger als 10 Minuten sind, bleiben liegen sie könnten gerade
* von einem laufenden Update benutzt werden.
*/
function cleanupUpdateCache() {
try {
const dir = updateDir();
if (!fs.existsSync(dir)) return;
for (const name of fs.readdirSync(dir)) {
const p = path.join(dir, name);
try {
if (Date.now() - fs.statSync(p).mtimeMs < 600000) continue;
fs.rmSync(p, { force: true, recursive: true });
} catch { /* gesperrt beim nächsten Start erneut versuchen */ }
}
} catch { /* egal */ }
}
// "v1.2.3-beta.2" -> [1,2,3]; führendes "v" wird ignoriert
function parseVersion(v) {
const m = String(v || '').trim().replace(/^v/i, '').match(/^(\d+)(?:\.(\d+))?(?:\.(\d+))?/);
if (!m) return null;
return [Number(m[1] || 0), Number(m[2] || 0), Number(m[3] || 0)];
}
// Vorabkennung hinter dem ersten "-", z. B. "beta" oder "beta.2"
function parsePreRelease(v) {
const m = String(v || '').trim().replace(/^v/i, '').match(/^\d+(?:\.\d+){0,2}-([0-9A-Za-z.\-]+)/);
return m ? m[1].split('.') : null;
}
/*
* Vergleicht die Vorabkennungen zweier gleicher Versionsnummern.
*
* Bewusst nicht streng nach SemVer: trägt ein Release-Tag die Vorabkennung
* nicht, die die App selbst meldet (Tag "1.0.1" gegen App "1.0.1-beta"),
* würde "1.0.1" als neuer gelten der Launcher böte dasselbe Update nach
* jeder Installation erneut an, eine Endlosschleife. Deshalb zählt eine
* Vorabkennung nur, wenn sie auf beiden Seiten vorhanden ist (beta.1 -> beta.2).
*/
function comparePreRelease(a, b) {
if (!a || !b) return 0;
for (let i = 0; i < Math.max(a.length, b.length); i++) {
const x = a[i]; const y = b[i];
if (x === undefined) return -1;
if (y === undefined) return 1;
const nx = /^\d+$/.test(x) ? Number(x) : null;
const ny = /^\d+$/.test(y) ? Number(y) : null;
if (nx !== null && ny !== null) { if (nx !== ny) return nx - ny; continue; }
if (nx !== null) return -1; // Zahlen zählen weniger als Text
if (ny !== null) return 1;
if (x !== y) return x < y ? -1 : 1;
}
return 0;
}
// >0 wenn a neuer als b
function compareVersions(a, b) {
const x = parseVersion(a); const y = parseVersion(b);
@@ -38,24 +135,31 @@ function compareVersions(a, b) {
for (let i = 0; i < 3; i++) {
if (x[i] !== y[i]) return x[i] - y[i];
}
return 0;
return comparePreRelease(parsePreRelease(a), parsePreRelease(b));
}
function isPortableAsset(name) {
return /portable/i.test(name) && /\.exe$/i.test(name);
}
function isSetupAsset(name) {
return /setup/i.test(name) && /\.exe$/i.test(name);
/*
* Paketart einer Release-Datei am Namen erkennen: 'portable', 'setup' oder null.
* Namen, die auf beides passen (z. B. "AeroMC-Portable-Setup.exe"), gelten als
* mehrdeutig und werden bewusst niemandem angeboten lieber kein Update als
* das falsche.
*/
function assetKind(name) {
const n = String(name || '');
if (!/\.exe$/i.test(n)) return null;
const portable = /portable/i.test(n);
const setup = /setup|install(er)?/i.test(n);
if (portable && setup) return null;
if (portable) return 'portable';
if (setup) return 'setup';
return null; // ohne Kennzeichnung im Namen nicht zuzuordnen
}
// passendes Windows-Artefakt im Release finden
function pickAsset(release, installKind) {
if (installKind !== 'portable' && installKind !== 'setup') return null;
const assets = release.assets || [];
if (installKind === 'portable') {
return assets.find((a) => isPortableAsset(a.name)) || null;
}
return assets.find((a) => isSetupAsset(a.name)) || null;
return assets.find((a) => assetKind(a.name) === installKind) || null;
}
/*
@@ -88,6 +192,25 @@ async function checkForUpdate(currentVersion) {
};
}
/*
* Wirft, sobald eine Datei nicht zweifelsfrei zur laufenden Paketart gehört.
* Wird vor dem Download und noch einmal direkt vor dem Ausführen aufgerufen.
*/
function assertMatchingKind(fileName, installKind) {
if (installKind !== 'portable' && installKind !== 'setup') {
throw new Error('Die Paketart des Launchers lässt sich nicht sicher bestimmen bitte manuell aktualisieren.');
}
const kind = assetKind(fileName);
if (!kind) {
throw new Error(`"${fileName}" lässt sich keiner Paketart zuordnen bitte manuell aktualisieren.`);
}
if (kind !== installKind) {
const soll = installKind === 'portable' ? 'eine Portable-Datei' : 'einen Installer';
const ist = kind === 'portable' ? 'eine Portable-Datei' : 'ein Installer';
throw new Error(`Falsches Paket: Dieser Launcher braucht ${soll}, angeboten wurde ${ist}.`);
}
}
/*
* Lädt den Installer herunter (mit Fortschritt) und startet ihn.
* onProgress: ({ phase, loaded, total, percent })
@@ -95,42 +218,143 @@ async function checkForUpdate(currentVersion) {
async function downloadAndInstall(asset, onProgress) {
if (!asset || !asset.url) throw new Error('Kein Installer im Release gefunden.');
const installKind = detectInstallKind();
if (asset.kind && asset.kind !== installKind) {
throw new Error('Das angebotene Update passt nicht zur laufenden Paketart.');
// Paketart am echten Dateinamen prüfen, nicht am mitgelieferten Feld
// sonst kontrolliert sich die Angabe nur selbst.
assertMatchingKind(asset.name, installKind);
// Portable-Updates ersetzen später die laufende EXE das geht nur, wenn wir sie kennen
const target = installKind === 'portable' ? portableTarget() : '';
if (installKind === 'portable' && !target) {
throw new Error('Der Pfad der laufenden Portable-Datei ist unbekannt bitte manuell aktualisieren.');
}
const dir = path.join(os.tmpdir(), 'aeromc-update');
const dir = updateDir();
fs.mkdirSync(dir, { recursive: true });
const dest = path.join(dir, asset.name.replace(/[^\w.\-]/g, '_'));
fs.rmSync(dest, { force: true });
const res = await fetch(asset.url, { headers: { 'User-Agent': UA } });
if (!res.ok) throw new Error('Download fehlgeschlagen: HTTP ' + res.status);
const total = Number(res.headers.get('content-length')) || asset.size || 0;
const chunks = [];
// direkt auf die Platte schreiben statt alles im Speicher zu sammeln
const out = fs.createWriteStream(dest);
const hash = crypto.createHash('sha256');
let loaded = 0;
let lastReport = 0;
try {
for await (const chunk of res.body) {
chunks.push(Buffer.from(chunk));
loaded += chunk.length;
if (onProgress) {
const buf = Buffer.from(chunk);
hash.update(buf);
loaded += buf.length;
// auf den Puffer warten; der jeweils andere Listener wird mit abgeräumt,
// sonst sammeln sich über den ganzen Download hunderte davon an
if (!out.write(buf)) await new Promise((resolve, reject) => {
const onDrain = () => { out.off('error', onError); resolve(); };
const onError = (e) => { out.off('drain', onDrain); reject(e); };
out.once('drain', onDrain);
out.once('error', onError);
});
// höchstens viermal pro Sekunde melden, sonst flutet es die Oberfläche
if (onProgress && Date.now() - lastReport >= 250) {
lastReport = Date.now();
onProgress({ phase: 'download', loaded, total, percent: total ? Math.round((loaded / total) * 100) : 0 });
}
}
const buf = Buffer.concat(chunks);
if (total && buf.length !== total) throw new Error('Download unvollständig.');
fs.writeFileSync(dest, buf);
await new Promise((resolve, reject) => {
out.once('error', reject);
out.end((err) => (err ? reject(err) : resolve()));
});
} catch (err) {
out.destroy();
fs.rmSync(dest, { force: true });
throw err;
}
const sha256 = crypto.createHash('sha256').update(buf).digest('hex');
if (onProgress) onProgress({ phase: 'ready', loaded: buf.length, total: buf.length, percent: 100 });
if (total && loaded !== total) {
fs.rmSync(dest, { force: true });
throw new Error('Download unvollständig.');
}
return { path: dest, bytes: buf.length, sha256 };
if (onProgress) onProgress({ phase: 'ready', loaded, total: loaded, percent: 100 });
return { path: dest, bytes: loaded, sha256: hash.digest('hex'), kind: installKind, target };
}
// Startet den Installer und löst sich vom Launcher (damit er sich selbst ersetzen kann)
function runInstaller(installerPath) {
const child = spawn(installerPath, [], { detached: true, stdio: 'ignore' });
/*
* Hilfsskript für Portable-Updates: wartet, bis der Launcher die eigene EXE
* freigibt, tauscht sie gegen die neue aus und startet sie wieder.
* "ping" statt "timeout", weil timeout ohne Konsole abbricht.
*/
const SWAP_SCRIPT = `@echo off
setlocal
set "SRC=%~1"
set "DST=%~2"
set "LOG=%~dp0update.log"
set TRIES=0
:loop
ping -n 2 127.0.0.1 >nul
copy /y "%SRC%" "%DST%" >nul 2>&1
if not errorlevel 1 goto done
set /a TRIES+=1
if %TRIES% GEQ 60 goto fail
goto loop
:done
del /f /q "%SRC%" >nul 2>&1
start "" "%DST%"
exit /b 0
:fail
echo [%DATE% %TIME%] Konnte "%DST%" nicht ersetzen - Datei gesperrt oder schreibgeschuetzt.>>"%LOG%"
exit /b 1
`;
/*
* Wendet das geladene Update an.
* setup -> NSIS-Installer starten, er überschreibt die Installation selbst
* portable -> Skript starten, das die laufende EXE nach dem Beenden ersetzt
* Der Launcher muss sich danach beenden.
*/
function applyUpdate(file) {
const installKind = detectInstallKind();
// letzte Kontrolle unmittelbar vor dem Start: passt die Datei auf der Platte
// wirklich zu dieser Paketart?
assertMatchingKind(path.basename(file.path || ''), installKind);
const kind = installKind;
if (kind !== 'portable') {
const child = spawn(file.path, [], { detached: true, stdio: 'ignore' });
child.unref();
return true;
return { started: 'installer' };
}
const target = file.target || portableTarget();
if (!target) throw new Error('Der Pfad der laufenden Portable-Datei ist unbekannt.');
const script = path.join(updateDir(), 'aeromc-swap.cmd');
fs.writeFileSync(script, SWAP_SCRIPT, 'ascii');
// über "start /min", damit kein Konsolenfenster im Vordergrund stehen bleibt
const child = spawn(process.env.COMSPEC || 'cmd.exe',
['/c', 'start', '', '/min', script, file.path, target],
{ detached: true, stdio: 'ignore', windowsHide: true });
child.unref();
return { started: 'swap', target };
}
module.exports = { checkForUpdate, downloadAndInstall, runInstaller, compareVersions, parseVersion, detectInstallKind };
// Rückwärtskompatibel: nur für die Setup-Variante
function runInstaller(installerPath) {
return applyUpdate({ path: installerPath, kind: 'setup' });
}
module.exports = {
checkForUpdate,
downloadAndInstall,
applyUpdate,
runInstaller,
cleanupUpdateCache,
compareVersions,
parseVersion,
detectInstallKind,
assetKind,
portableTarget,
};