Upload via GUI (39 Dateien)
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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})}}}\""
|
||||
},
|
||||
|
||||
43
scripts/write-build-config.js
Normal file
43
scripts/write-build-config.js
Normal 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}`);
|
||||
40
server/curseforge-proxy/.env.example
Normal file
40
server/curseforge-proxy/.env.example
Normal 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
|
||||
19
server/curseforge-proxy/Dockerfile
Normal file
19
server/curseforge-proxy/Dockerfile
Normal 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"]
|
||||
160
server/curseforge-proxy/README.md
Normal file
160
server/curseforge-proxy/README.md
Normal 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.
|
||||
25
server/curseforge-proxy/aeromc-cf-proxy.service
Normal file
25
server/curseforge-proxy/aeromc-cf-proxy.service
Normal 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
|
||||
14
server/curseforge-proxy/docker-compose.yml
Normal file
14
server/curseforge-proxy/docker-compose.yml
Normal 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
|
||||
54
server/curseforge-proxy/ecosystem.config.js
Normal file
54
server/curseforge-proxy/ecosystem.config.js
Normal 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')),
|
||||
}],
|
||||
};
|
||||
344
server/curseforge-proxy/index.js
Normal file
344
server/curseforge-proxy/index.js
Normal 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
4
src/build-config.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"curseforgeProxyUrl": "",
|
||||
"curseforgeProxyToken": "GetqSQ6K0z4OHet2SCUA5dHBqoKmFCl3a7LT6bZVVS0FAb07ZxVq7r7HS3dUbH6J"
|
||||
}
|
||||
180
src/crashreports.js
Normal file
180
src/crashreports.js
Normal 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 };
|
||||
216
src/index.html
216
src/index.html
@@ -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
212
src/javadl.js
Normal 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 };
|
||||
@@ -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 };
|
||||
|
||||
528
src/main.js
528
src/main.js
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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
174
src/nbt.js
Normal 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 };
|
||||
@@ -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);
|
||||
|
||||
603
src/renderer.js
603
src/renderer.js
@@ -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 4096–8192 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
146
src/servers.js
Normal 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 };
|
||||
203
src/services.js
203
src/services.js
@@ -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
121
src/skins.js
Normal 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 };
|
||||
88
src/store.js
88
src/store.js
@@ -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,
|
||||
|
||||
@@ -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 */
|
||||
|
||||
296
src/updater.js
296
src/updater.js
@@ -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);
|
||||
|
||||
const sha256 = crypto.createHash('sha256').update(buf).digest('hex');
|
||||
if (onProgress) onProgress({ phase: 'ready', loaded: buf.length, total: buf.length, percent: 100 });
|
||||
|
||||
return { path: dest, bytes: buf.length, sha256 };
|
||||
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;
|
||||
}
|
||||
|
||||
// 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' });
|
||||
if (total && loaded !== total) {
|
||||
fs.rmSync(dest, { force: true });
|
||||
throw new Error('Download unvollständig.');
|
||||
}
|
||||
|
||||
if (onProgress) onProgress({ phase: 'ready', loaded, total: loaded, percent: 100 });
|
||||
return { path: dest, bytes: loaded, sha256: hash.digest('hex'), kind: installKind, target };
|
||||
}
|
||||
|
||||
/*
|
||||
* 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' };
|
||||
}
|
||||
|
||||
module.exports = { checkForUpdate, downloadAndInstall, runInstaller, compareVersions, parseVersion, detectInstallKind };
|
||||
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 };
|
||||
}
|
||||
|
||||
// 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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user