Upload via GUI (19 Dateien)
This commit is contained in:
42
README.md
42
README.md
@@ -1,17 +1,17 @@
|
||||
<div align="center">
|
||||
|
||||
<img src="src/assets/logo.png" alt="Viper-Network Logo" width="140" />
|
||||
<img src="src/assets/logo.png" alt="AeroMC Logo" width="140" />
|
||||
|
||||
# Viper-Network Launcher
|
||||
# AeroMC
|
||||
|
||||
**Ein instanz-basierter Minecraft-Launcher für Windows – à la MultiMC / Prism.**
|
||||
|
||||
Verwalte beliebig viele isolierte Minecraft-Instanzen mit eigener Version, eigenen Mods und eigenem Java – lade Mods per Ein-Klick von Modrinth, melde dich mit deinem Microsoft-Konto an und spiele.
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
</div>
|
||||
|
||||
@@ -29,18 +29,21 @@ Verwalte beliebig viele isolierte Minecraft-Instanzen mit eigener Version, eigen
|
||||
| **Mods** | Modrinth-Browser mit **Ein-Klick-Installation** in die Instanz | ✅ |
|
||||
| **Login** | Offizieller Microsoft-Login (Device-Code-Flow) mit **Mehrkonten-Verwaltung** | ✅ |
|
||||
| **Start** | Vanilla-Download (Client, Libraries, Natives, Assets) & Start | ✅ |
|
||||
| **Loader** | **Fabric** & **Quilt** installieren & starten | ✅ |
|
||||
| **Loader** | **Forge** & **NeoForge** | ⏳ in Arbeit |
|
||||
| **Loader** | **Fabric, Quilt, Forge & NeoForge** installieren & starten | ✅ |
|
||||
| **Mods** | Katalog mit Kategorien/Sortierung + automatische Abhängigkeiten | ✅ |
|
||||
| **Modpacks** | Import von Modrinth-Modpacks (.mrpack) | ✅ |
|
||||
| **Instanzen** | Gruppen, Drag & Drop, Import/Export, Screenshots, Welten | ✅ |
|
||||
| **Launcher** | Dark/Light-Theme, Konsole mit Log-Highlighting, Spielzeit-Statistik | ✅ |
|
||||
|
||||
---
|
||||
|
||||
## 🎮 Bedienung
|
||||
|
||||
1. **+ Neue Instanz** → Name, Minecraft-Version und optional einen Mod-Loader (Fabric/Quilt) wählen.
|
||||
2. Instanz anklicken → Detailansicht mit **▶ Spielen**, **🧩 Mods**, Bearbeiten, Ordner, Duplizieren, Löschen.
|
||||
3. **🧩 Mods** → Modrinth durchsuchen und mit einem Klick installieren (passend zu Version + Loader).
|
||||
4. **👤 Anmelden** (oben rechts) → Microsoft-Konto hinzufügen (mehrere möglich, aktives Konto spielt).
|
||||
5. **▶ Spielen** → Der Launcher lädt alles Nötige und startet Minecraft mit deinem Konto.
|
||||
2. Instanz anklicken → rechts erscheinen alle Aktionen (Starten, Bearbeiten, Mods, Welten …). Doppelklick startet direkt.
|
||||
3. **Mods anzeigen** → Katalog mit beliebten Mods; ein Klick installiert die Mod inklusive aller Abhängigkeiten.
|
||||
4. **Konten** (oben rechts) → Microsoft-Konto hinzufügen (mehrere möglich, aktives Konto spielt).
|
||||
5. **Starten** → Der Launcher lädt alles Nötige und startet Minecraft mit deinem Konto.
|
||||
|
||||
> 💡 **Parallel spielen:** Konto A aktivieren → starten → Konto B aktivieren → starten. Jeder Start ist ein eigener Prozess, so laufen mehrere Clients mit verschiedenen Accounts gleichzeitig.
|
||||
|
||||
@@ -52,7 +55,7 @@ Der Launcher bringt bereits eine **eingebaute Azure-App-ID** mit – Endnutzer m
|
||||
|
||||
> ⚠️ **Wichtiger Hinweis zur Freischaltung:** Seit 2024 müssen **neu registrierte** Azure-Apps von Microsoft/Mojang freigeschaltet werden, bevor sie sich bei den Minecraft-Diensten anmelden dürfen. Bis zur Freigabe endet der Login mit `Invalid app registration (403)`. Die Freischaltung wird über das offizielle Formular **[aka.ms/mce-reviewappid](https://aka.ms/mce-reviewappid)** beantragt. Der Launcher-Code ist vollständig – sobald die App-ID freigeschaltet ist, funktioniert der Login ohne weitere Änderungen.
|
||||
|
||||
**Eigene Azure-App verwenden (optional):** In den ⚙ Einstellungen lässt sich eine eigene Client-ID hinterlegen. Anforderungen:
|
||||
**Eigene Azure-App verwenden (optional):** In den Optionen lässt sich eine eigene Client-ID hinterlegen. Anforderungen:
|
||||
- Kontotyp: **Nur persönliche Microsoft-Konten**
|
||||
- Authentifizierung → **„Öffentliche Clientflows zulassen" = Ja**
|
||||
- Freischaltung über [aka.ms/mce-reviewappid](https://aka.ms/mce-reviewappid)
|
||||
@@ -63,7 +66,7 @@ Der Login nutzt ausschließlich den offiziellen Microsoft-OAuth-Flow (echtes Kon
|
||||
|
||||
## 🗂️ Datenspeicherorte
|
||||
|
||||
Alle Nutzerdaten liegen unter `%APPDATA%\Viper-Network\` (im Dev-Modus `%APPDATA%\viper-network\`):
|
||||
Alle Nutzerdaten liegen unter `%APPDATA%\AeroMC\` (im Dev-Modus `%APPDATA%\aeromc\`):
|
||||
|
||||
| Pfad | Inhalt |
|
||||
|---|---|
|
||||
@@ -79,12 +82,9 @@ Alle Nutzerdaten liegen unter `%APPDATA%\Viper-Network\` (im Dev-Modus `%APPDATA
|
||||
|
||||
## 🗺️ Roadmap
|
||||
|
||||
- [ ] Forge & NeoForge (Installer mit Prozessoren)
|
||||
- [ ] „Starten als [Konto]" direkt an der Instanz
|
||||
- [ ] Loader-Versionsauswahl im Dialog
|
||||
- [ ] Skins/Avatare in der Kontoliste
|
||||
- [ ] Instanz-Gruppen & Sortierung
|
||||
- [ ] Import/Export von Instanzen (Modpacks)
|
||||
- [ ] Shader- und Ressourcenpakete über den Katalog
|
||||
- [ ] CurseForge-Modpacks (braucht API-Key)
|
||||
- [ ] Automatische Mod-Updates
|
||||
|
||||
---
|
||||
|
||||
@@ -95,4 +95,4 @@ Dieses Projekt steht in **keiner** Verbindung zu Mojang, Microsoft oder Xbox und
|
||||
|
||||
## 📄 Lizenz
|
||||
|
||||
MIT © Viper-Network
|
||||
MIT
|
||||
|
||||
4185
package-lock.json
generated
Normal file
4185
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
36
package.json
Normal file
36
package.json
Normal file
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"name": "aeromc",
|
||||
"version": "0.29.0",
|
||||
"description": "AeroMC – Instanz-basierter Minecraft-Launcher (à la MultiMC/Prism)",
|
||||
"main": "src/main.js",
|
||||
"author": "AeroMC",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"start": "electron .",
|
||||
"build": "electron-builder --win --x64"
|
||||
},
|
||||
"build": {
|
||||
"appId": "com.aeromc.launcher",
|
||||
"productName": "AeroMC",
|
||||
"files": [
|
||||
"src/**/*",
|
||||
"package.json"
|
||||
],
|
||||
"win": {
|
||||
"target": "nsis",
|
||||
"icon": "src/assets/icon.ico"
|
||||
},
|
||||
"nsis": {
|
||||
"oneClick": false,
|
||||
"allowToChangeInstallationDirectory": true
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"electron": "^28.0.0",
|
||||
"electron-builder": "^24.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"skinview3d": "^3.4.2",
|
||||
"undici": "^6.28.0"
|
||||
}
|
||||
}
|
||||
1
smoke.err.txt
Normal file
1
smoke.err.txt
Normal file
@@ -0,0 +1 @@
|
||||
[23964:0801/065610.401:ERROR:network_service_instance_impl.cc(601)] Network service crashed, restarting service.
|
||||
1
smoke.out.txt
Normal file
1
smoke.out.txt
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
BIN
src/assets/logo.png
Normal file
BIN
src/assets/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 44 KiB |
38
src/assets/logo.svg
Normal file
38
src/assets/logo.svg
Normal file
@@ -0,0 +1,38 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512" role="img" aria-label="AeroMC">
|
||||
<defs>
|
||||
<linearGradient id="card" x1="0.12" y1="0" x2="0.88" y2="1">
|
||||
<stop offset="0" stop-color="#202d50"/><stop offset="1" stop-color="#0a0e1a"/>
|
||||
</linearGradient>
|
||||
<radialGradient id="glow" cx="0.29" cy="0.23" r="0.85">
|
||||
<stop offset="0" stop-color="#4a70c0" stop-opacity="0.32"/>
|
||||
<stop offset="1" stop-color="#4a70c0" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
<linearGradient id="top" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stop-color="#96e2ff"/><stop offset="1" stop-color="#68c4fa"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="left" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stop-color="#407ee8"/><stop offset="1" stop-color="#2658be"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="right" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stop-color="#224cac"/><stop offset="1" stop-color="#16327c"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="rim" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stop-color="#ffffff" stop-opacity="0.28"/>
|
||||
<stop offset="0.45" stop-color="#ffffff" stop-opacity="0"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<path id="card-shape" d="M256 20 C400 20 492 112 492 256 C492 400 400 492 256 492
|
||||
C112 492 20 400 20 256 C20 112 112 20 256 20 Z" fill="url(#card)"/>
|
||||
<use href="#card-shape" fill="url(#glow)"/>
|
||||
|
||||
<!-- isometrischer Block -->
|
||||
<path d="M256 100 L386 175 L256 250 L126 175 Z" fill="url(#top)"/>
|
||||
<path d="M126 175 L256 250 L256 400 L126 325 Z" fill="url(#left)"/>
|
||||
<path d="M386 175 L256 250 L256 400 L386 325 Z" fill="url(#right)"/>
|
||||
|
||||
<!-- Play-Symbol auf der Vorderseite -->
|
||||
<path d="M218 284 L218 380 L312 332 Z" fill="#ffffff"/>
|
||||
|
||||
<use href="#card-shape" fill="none" stroke="url(#rim)" stroke-width="3"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.8 KiB |
333
src/auth.js
Normal file
333
src/auth.js
Normal file
@@ -0,0 +1,333 @@
|
||||
'use strict';
|
||||
|
||||
/*
|
||||
* auth.js – offizieller Microsoft-Login für Minecraft (Phase 3)
|
||||
* -------------------------------------------------------------
|
||||
* Ablauf (Device-Code-Flow, wie bei Prism/MultiMC):
|
||||
* 1. Microsoft OAuth Device-Code -> MS access_token + refresh_token
|
||||
* 2. Xbox-Live-Authentifizierung -> XBL-Token + userhash
|
||||
* 3. XSTS-Autorisierung -> XSTS-Token
|
||||
* 4. Minecraft-Login -> MC access_token
|
||||
* 5. Minecraft-Profil -> UUID + Name (prüft zugleich den Besitz)
|
||||
*
|
||||
* Erfordert eine kostenlose Azure-App-ID (öffentlicher Client, Device-Flow).
|
||||
* Wir bauen KEINEN Offline-/Crack-Login – ohne echtes Konto kein Start.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// Fest eingebaute Azure-App-ID des Launchers (öffentlicher Client – kein Geheimnis).
|
||||
// Jeder Nutzer meldet sich damit über SEIN eigenes Microsoft-Konto an.
|
||||
// Über die Einstellungen lässt sich bei Bedarf eine eigene ID hinterlegen.
|
||||
const DEFAULT_CLIENT_ID = '0625f4a8-dfff-4eab-8124-a573b00b0319';
|
||||
|
||||
const SCOPE = 'XboxLive.signin offline_access';
|
||||
// Manche Endpunkte (v. a. api.minecraftservices.com hinter Cloudflare) lehnen
|
||||
// Anfragen ohne User-Agent mit HTTP 403 ab -> überall mitsenden.
|
||||
const UA = 'AeroMC-Launcher/0.5';
|
||||
const DEVICECODE_URL = 'https://login.microsoftonline.com/consumers/oauth2/v2.0/devicecode';
|
||||
const TOKEN_URL = 'https://login.microsoftonline.com/consumers/oauth2/v2.0/token';
|
||||
const XBL_URL = 'https://user.auth.xboxlive.com/user/authenticate';
|
||||
const XSTS_URL = 'https://xsts.auth.xboxlive.com/xsts/authorize';
|
||||
const MC_LOGIN_URL = 'https://api.minecraftservices.com/authentication/login_with_xbox';
|
||||
const MC_PROFILE_URL = 'https://api.minecraftservices.com/minecraft/profile';
|
||||
|
||||
let USER_DATA = null;
|
||||
let SECURE = null; // optionaler Verschlüsseler (Electron safeStorage)
|
||||
|
||||
function init(userDataPath, secure) {
|
||||
USER_DATA = userDataPath;
|
||||
SECURE = secure && secure.available && secure.available() ? secure : null;
|
||||
migrateLegacy();
|
||||
if (SECURE) { try { writeStore(readStore()); } catch { /* Migration best effort */ } }
|
||||
}
|
||||
|
||||
// sensible Felder verschlüsselt ablegen (an das Windows-Benutzerkonto gebunden)
|
||||
const SENSITIVE = ['mcAccessToken', 'msRefreshToken'];
|
||||
const ENC = 'enc:v1:';
|
||||
function protect(acc) {
|
||||
if (!SECURE) return acc;
|
||||
const out = Object.assign({}, acc);
|
||||
for (const k of SENSITIVE) {
|
||||
const v = out[k];
|
||||
if (typeof v === 'string' && v && !v.startsWith(ENC)) {
|
||||
try { out[k] = ENC + SECURE.encrypt(v); } catch { /* unverschlüsselt lassen */ }
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
function unprotect(acc) {
|
||||
const out = Object.assign({}, acc);
|
||||
for (const k of SENSITIVE) {
|
||||
const v = out[k];
|
||||
if (typeof v === 'string' && v.startsWith(ENC)) {
|
||||
try { out[k] = SECURE ? SECURE.decrypt(v.slice(ENC.length)) : null; } catch { out[k] = null; }
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
function accountsFile() { return path.join(USER_DATA, 'accounts.json'); }
|
||||
function legacyFile() { return path.join(USER_DATA, 'account.json'); }
|
||||
|
||||
// Speicherform: { activeId, accounts: [ {profile,mcAccessToken,mcExpiresAt,msRefreshToken,updatedAt}, ... ] }
|
||||
function readStore() {
|
||||
try {
|
||||
const s = JSON.parse(fs.readFileSync(accountsFile(), 'utf8'));
|
||||
if (s && Array.isArray(s.accounts)) {
|
||||
return { activeId: s.activeId, accounts: s.accounts.map(unprotect) };
|
||||
}
|
||||
} catch { /* leer */ }
|
||||
return { activeId: null, accounts: [] };
|
||||
}
|
||||
function writeStore(s) {
|
||||
const safe = { activeId: s.activeId, accounts: (s.accounts || []).map(protect) };
|
||||
fs.writeFileSync(accountsFile(), JSON.stringify(safe, null, 2));
|
||||
}
|
||||
|
||||
// altes Einzelkonto (account.json) übernehmen
|
||||
function migrateLegacy() {
|
||||
try {
|
||||
if (fs.existsSync(accountsFile())) return;
|
||||
const old = JSON.parse(fs.readFileSync(legacyFile(), 'utf8'));
|
||||
if (old && old.profile) {
|
||||
writeStore({ activeId: old.profile.id, accounts: [old] });
|
||||
try { fs.unlinkSync(legacyFile()); } catch { /* egal */ }
|
||||
}
|
||||
} catch { /* nichts zu migrieren */ }
|
||||
}
|
||||
|
||||
function upsertAccount(acc) {
|
||||
const s = readStore();
|
||||
s.accounts = s.accounts.filter((a) => a.profile.id !== acc.profile.id);
|
||||
s.accounts.push(acc);
|
||||
s.activeId = acc.profile.id; // frisch eingeloggtes/erneuertes Konto wird aktiv
|
||||
writeStore(s);
|
||||
return acc;
|
||||
}
|
||||
|
||||
function listAccounts() {
|
||||
const s = readStore();
|
||||
return s.accounts.map((a) => ({ id: a.profile.id, name: a.profile.name, active: a.profile.id === s.activeId }));
|
||||
}
|
||||
|
||||
function setActive(id) {
|
||||
const s = readStore();
|
||||
if (s.accounts.some((a) => a.profile.id === id)) { s.activeId = id; writeStore(s); }
|
||||
return listAccounts();
|
||||
}
|
||||
|
||||
function removeAccount(id) {
|
||||
const s = readStore();
|
||||
s.accounts = s.accounts.filter((a) => a.profile.id !== id);
|
||||
if (s.activeId === id) s.activeId = s.accounts[0] ? s.accounts[0].profile.id : null;
|
||||
writeStore(s);
|
||||
return listAccounts();
|
||||
}
|
||||
|
||||
function getAccountById(id) {
|
||||
return readStore().accounts.find((a) => a.profile.id === id) || null;
|
||||
}
|
||||
function getActiveRaw() {
|
||||
const s = readStore();
|
||||
return s.accounts.find((a) => a.profile.id === s.activeId) || null;
|
||||
}
|
||||
function clearAccount() { try { fs.unlinkSync(accountsFile()); } catch { /* egal */ } }
|
||||
|
||||
async function postForm(url, params) {
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'User-Agent': UA, Accept: 'application/json' },
|
||||
body: new URLSearchParams(params).toString(),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
return { status: res.status, data };
|
||||
}
|
||||
|
||||
async function postJson(url, body, headers = {}) {
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: Object.assign({ 'Content-Type': 'application/json', Accept: 'application/json', 'User-Agent': UA }, headers),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
return { status: res.status, data };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1. Device-Code anfordern
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function requestDeviceCode(clientId) {
|
||||
if (!clientId) throw new Error('Keine Azure-Client-ID gesetzt (Einstellungen → Microsoft-Login).');
|
||||
const { status, data } = await postForm(DEVICECODE_URL, { client_id: clientId, scope: SCOPE });
|
||||
if (status !== 200) {
|
||||
throw new Error('Device-Code fehlgeschlagen: ' + (data.error_description || data.error || status));
|
||||
}
|
||||
return data; // { user_code, device_code, verification_uri, expires_in, interval, message }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 2. Auf Autorisierung warten (pollt Token-Endpoint)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function pollForToken(clientId, deviceCode, intervalSec, onTick) {
|
||||
let interval = Math.max(intervalSec || 5, 3);
|
||||
const deadline = Date.now() + 15 * 60 * 1000;
|
||||
while (Date.now() < deadline) {
|
||||
await sleep(interval * 1000);
|
||||
const { data } = await postForm(TOKEN_URL, {
|
||||
grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
|
||||
client_id: clientId,
|
||||
device_code: deviceCode,
|
||||
});
|
||||
if (data.access_token) return data; // { access_token, refresh_token, expires_in }
|
||||
if (data.error === 'authorization_pending') { onTick && onTick(); continue; }
|
||||
if (data.error === 'slow_down') { interval += 5; continue; }
|
||||
if (data.error === 'authorization_declined') throw new Error('Anmeldung abgelehnt.');
|
||||
if (data.error === 'expired_token') throw new Error('Code abgelaufen – bitte erneut anmelden.');
|
||||
throw new Error('Login-Fehler: ' + (data.error_description || data.error));
|
||||
}
|
||||
throw new Error('Zeitüberschreitung – Code nicht rechtzeitig bestätigt.');
|
||||
}
|
||||
|
||||
function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); }
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 3.–5. Xbox -> XSTS -> Minecraft -> Profil
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function xboxLogin(msAccessToken) {
|
||||
const { status, data } = await postJson(XBL_URL, {
|
||||
Properties: { AuthMethod: 'RPS', SiteName: 'user.auth.xboxlive.com', RpsTicket: 'd=' + msAccessToken },
|
||||
RelyingParty: 'http://auth.xboxlive.com',
|
||||
TokenType: 'JWT',
|
||||
});
|
||||
if (status !== 200 || !data.Token) {
|
||||
throw new Error('Xbox-Live-Login fehlgeschlagen (' + status + ')' + (data && data.Message ? ': ' + data.Message : '') + '.');
|
||||
}
|
||||
const uhs = data.DisplayClaims && data.DisplayClaims.xui && data.DisplayClaims.xui[0] && data.DisplayClaims.xui[0].uhs;
|
||||
return { token: data.Token, uhs };
|
||||
}
|
||||
|
||||
async function xstsAuth(xblToken) {
|
||||
const { status, data } = await postJson(XSTS_URL, {
|
||||
Properties: { SandboxId: 'RETAIL', UserTokens: [xblToken] },
|
||||
RelyingParty: 'rp://api.minecraftservices.com/',
|
||||
TokenType: 'JWT',
|
||||
});
|
||||
if (status === 401) {
|
||||
const xerr = data.XErr;
|
||||
if (xerr === 2148916233) throw new Error('Dieses Microsoft-Konto hat kein Xbox-Profil. Bitte einmal auf xbox.com anmelden.');
|
||||
if (xerr === 2148916238) throw new Error('Kinderkonto – muss einer Familie hinzugefügt werden.');
|
||||
throw new Error('XSTS-Autorisierung abgelehnt (XErr ' + xerr + ').');
|
||||
}
|
||||
if (status !== 200 || !data.Token) throw new Error('XSTS-Autorisierung fehlgeschlagen (' + status + ').');
|
||||
return { token: data.Token };
|
||||
}
|
||||
|
||||
async function minecraftLogin(uhs, xstsToken) {
|
||||
const { status, data } = await postJson(MC_LOGIN_URL, {
|
||||
identityToken: `XBL3.0 x=${uhs};${xstsToken}`,
|
||||
});
|
||||
if (status !== 200 || !data.access_token) {
|
||||
const detail = data && (data.errorMessage || data.error || data.path) ? ': ' + (data.errorMessage || data.error || data.path) : '';
|
||||
throw new Error('Minecraft-Login fehlgeschlagen (' + status + ')' + detail + '.');
|
||||
}
|
||||
return { accessToken: data.access_token, expiresIn: data.expires_in || 86400 };
|
||||
}
|
||||
|
||||
async function minecraftProfile(mcAccessToken) {
|
||||
const res = await fetch(MC_PROFILE_URL, {
|
||||
headers: { Authorization: 'Bearer ' + mcAccessToken, 'User-Agent': UA, Accept: 'application/json' },
|
||||
});
|
||||
if (res.status === 404) throw new Error('Dieses Konto besitzt kein Minecraft (Java Edition).');
|
||||
if (!res.ok) throw new Error('Profil laden fehlgeschlagen (' + res.status + ').');
|
||||
const data = await res.json();
|
||||
return { id: data.id, name: data.name }; // id = UUID ohne Bindestriche
|
||||
}
|
||||
|
||||
// vollständige Kette ab MS-Token -> gespeichertes Konto
|
||||
async function completeFromMsToken(msToken) {
|
||||
const xbl = await xboxLogin(msToken.access_token);
|
||||
const xsts = await xstsAuth(xbl.token);
|
||||
const mc = await minecraftLogin(xbl.uhs, xsts.token);
|
||||
const profile = await minecraftProfile(mc.accessToken);
|
||||
|
||||
return {
|
||||
profile, // { id, name }
|
||||
mcAccessToken: mc.accessToken,
|
||||
mcExpiresAt: Date.now() + (mc.expiresIn - 60) * 1000,
|
||||
msRefreshToken: msToken.refresh_token || null,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
// ersetzt ein Konto in der Liste, ohne das aktive Konto zu ändern (für Token-Refresh)
|
||||
function updateAccount(acc) {
|
||||
const s = readStore();
|
||||
s.accounts = s.accounts.filter((a) => a.profile.id !== acc.profile.id);
|
||||
s.accounts.push(acc);
|
||||
writeStore(s);
|
||||
return acc;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Öffentliche Login-Orchestrierung
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/*
|
||||
* startLogin: fordert Device-Code an und ruft danach den Callback mit dem Code.
|
||||
* Läuft dann im Hintergrund bis zur Bestätigung. Gibt am Ende das Konto zurück.
|
||||
*/
|
||||
async function login(clientId, onCode, onTick) {
|
||||
const dc = await requestDeviceCode(clientId);
|
||||
onCode && onCode({
|
||||
userCode: dc.user_code,
|
||||
verificationUri: dc.verification_uri,
|
||||
message: dc.message,
|
||||
expiresIn: dc.expires_in,
|
||||
});
|
||||
const msToken = await pollForToken(clientId, dc.device_code, dc.interval, onTick);
|
||||
const account = await completeFromMsToken(msToken);
|
||||
return upsertAccount(account); // neues Konto speichern + aktiv setzen
|
||||
}
|
||||
|
||||
// ein Konto gültig machen (Token ggf. per Refresh erneuern)
|
||||
async function refreshAccount(clientId, acc) {
|
||||
if (acc.mcAccessToken && acc.mcExpiresAt && Date.now() < acc.mcExpiresAt) return acc;
|
||||
if (acc.msRefreshToken && clientId) {
|
||||
const { data } = await postForm(TOKEN_URL, {
|
||||
grant_type: 'refresh_token',
|
||||
client_id: clientId,
|
||||
refresh_token: acc.msRefreshToken,
|
||||
scope: SCOPE,
|
||||
});
|
||||
if (data.access_token) {
|
||||
try {
|
||||
const fresh = await completeFromMsToken(data);
|
||||
if (!fresh.msRefreshToken) fresh.msRefreshToken = acc.msRefreshToken;
|
||||
return updateAccount(fresh);
|
||||
} catch { return null; }
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// aktives Konto gültig holen (null wenn kein Login)
|
||||
async function getValidAccount(clientId) {
|
||||
const acc = getActiveRaw();
|
||||
return acc ? refreshAccount(clientId, acc) : null;
|
||||
}
|
||||
// bestimmtes Konto gültig holen (für „starten als …")
|
||||
async function getValidAccountById(clientId, id) {
|
||||
const acc = getAccountById(id);
|
||||
return acc ? refreshAccount(clientId, acc) : null;
|
||||
}
|
||||
|
||||
function currentAccount() { return getActiveRaw(); }
|
||||
|
||||
module.exports = {
|
||||
init, login, getValidAccount, getValidAccountById, currentAccount, clearAccount,
|
||||
listAccounts, setActive, removeAccount, DEFAULT_CLIENT_ID,
|
||||
};
|
||||
232
src/forge.js
Normal file
232
src/forge.js
Normal file
@@ -0,0 +1,232 @@
|
||||
'use strict';
|
||||
|
||||
/*
|
||||
* forge.js – Forge/NeoForge-Installation (Installer + Prozessoren)
|
||||
* ----------------------------------------------------------------
|
||||
* Anders als Fabric/Quilt liefern Forge/NeoForge einen Installer-JAR mit:
|
||||
* - install_profile.json (processors, data, libraries für die Installation)
|
||||
* - version.json (Laufzeit-Profil: mainClass, libraries, arguments)
|
||||
* - data/…, maven/… (gebündelte Dateien/Bibliotheken)
|
||||
*
|
||||
* Ablauf: Installer laden → Profile extrahieren → Libraries laden/entpacken →
|
||||
* data-Tokens auflösen → Prozessoren als Java-Prozesse ausführen (patchen den
|
||||
* Client) → version.json zurückgeben (wird mit der Vanilla-Version gemerged).
|
||||
*
|
||||
* Die Prozessoren sind reine Java-Subprozesse – daher ohne Login testbar.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const zlib = require('zlib');
|
||||
const { spawnSync } = require('child_process');
|
||||
|
||||
const UA = 'AeroMC-Launcher/0.8';
|
||||
|
||||
function ensureDir(d) { fs.mkdirSync(d, { recursive: true }); }
|
||||
|
||||
// ---------- ZIP-Leser (liest gezielt Einträge aus dem Installer) ----------
|
||||
|
||||
function readZipIndex(buf) {
|
||||
let eocd = -1;
|
||||
for (let i = buf.length - 22; i >= 0 && i > buf.length - 22 - 65536; i--) {
|
||||
if (buf.readUInt32LE(i) === 0x06054b50) { eocd = i; break; }
|
||||
}
|
||||
if (eocd < 0) throw new Error('Installer ist kein gültiges ZIP.');
|
||||
const count = buf.readUInt16LE(eocd + 10);
|
||||
let off = buf.readUInt32LE(eocd + 16);
|
||||
const entries = {};
|
||||
for (let n = 0; n < count; n++) {
|
||||
if (buf.readUInt32LE(off) !== 0x02014b50) break;
|
||||
const method = buf.readUInt16LE(off + 10);
|
||||
const compSize = buf.readUInt32LE(off + 20);
|
||||
const nameLen = buf.readUInt16LE(off + 28);
|
||||
const extraLen = buf.readUInt16LE(off + 30);
|
||||
const commentLen = buf.readUInt16LE(off + 32);
|
||||
const localOff = buf.readUInt32LE(off + 42);
|
||||
const name = buf.toString('utf8', off + 46, off + 46 + nameLen).replace(/\\/g, '/');
|
||||
entries[name] = { method, compSize, localOff };
|
||||
off += 46 + nameLen + extraLen + commentLen;
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
function readZipEntry(buf, e) {
|
||||
const lNameLen = buf.readUInt16LE(e.localOff + 26);
|
||||
const lExtraLen = buf.readUInt16LE(e.localOff + 28);
|
||||
const start = e.localOff + 30 + lNameLen + lExtraLen;
|
||||
const raw = buf.subarray(start, start + e.compSize);
|
||||
return e.method === 0 ? Buffer.from(raw) : zlib.inflateRawSync(raw);
|
||||
}
|
||||
|
||||
// ---------- Maven-Koordinaten -> Pfad ----------
|
||||
|
||||
// "group:artifact:version[:classifier][@ext]"
|
||||
function mavenPath(coord) {
|
||||
let ext = 'jar';
|
||||
let c = coord;
|
||||
const at = c.indexOf('@');
|
||||
if (at >= 0) { ext = c.slice(at + 1); c = c.slice(0, at); }
|
||||
const parts = c.split(':');
|
||||
const group = parts[0].replace(/\./g, '/');
|
||||
const artifact = parts[1];
|
||||
const version = parts[2];
|
||||
const classifier = parts[3] ? '-' + parts[3] : '';
|
||||
return `${group}/${artifact}/${version}/${artifact}-${version}${classifier}.${ext}`;
|
||||
}
|
||||
|
||||
async function fetchBuffer(url) {
|
||||
const res = await fetch(url, { headers: { 'User-Agent': UA } });
|
||||
if (!res.ok) throw new Error('Download HTTP ' + res.status + ': ' + url);
|
||||
return Buffer.from(await res.arrayBuffer());
|
||||
}
|
||||
|
||||
function installerUrl(loader, mcVersion, loaderVersion) {
|
||||
if (loader === 'forge') {
|
||||
const v = `${mcVersion}-${loaderVersion}`;
|
||||
return `https://maven.minecraftforge.net/net/minecraftforge/forge/${v}/forge-${v}-installer.jar`;
|
||||
}
|
||||
return `https://maven.neoforged.net/releases/net/neoforged/neoforge/${loaderVersion}/neoforge-${loaderVersion}-installer.jar`;
|
||||
}
|
||||
|
||||
// Main-Class aus dem MANIFEST einer JAR lesen
|
||||
function jarMainClass(jarPath) {
|
||||
try {
|
||||
const buf = fs.readFileSync(jarPath);
|
||||
const idx = readZipIndex(buf);
|
||||
const e = idx['META-INF/MANIFEST.MF'];
|
||||
if (!e) return null;
|
||||
const mf = readZipEntry(buf, e).toString('utf8').replace(/\r?\n /g, '');
|
||||
const m = mf.match(/Main-Class:\s*(\S+)/);
|
||||
return m ? m[1] : null;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
// ---------- Hauptfunktion ----------
|
||||
|
||||
/*
|
||||
* opts: { loader, mcVersion, loaderVersion, sharedDir, javaPath, clientJar, onProgress }
|
||||
* Gibt das Laufzeit-version.json (mit inheritsFrom = Vanilla) zurück.
|
||||
*/
|
||||
async function installForge(opts) {
|
||||
const { loader, mcVersion, loaderVersion, sharedDir, javaPath, clientJar } = opts;
|
||||
const onProgress = opts.onProgress || (() => {});
|
||||
const libDir = path.join(sharedDir, 'libraries');
|
||||
const java = javaPath && javaPath.trim() ? javaPath : 'java';
|
||||
|
||||
onProgress({ phase: 'loader', detail: `${loader} ${loaderVersion} – Installer laden …` });
|
||||
const buf = await fetchBuffer(installerUrl(loader, mcVersion, loaderVersion));
|
||||
const zip = readZipIndex(buf);
|
||||
|
||||
if (!zip['install_profile.json']) throw new Error('install_profile.json fehlt im Installer.');
|
||||
const profile = JSON.parse(readZipEntry(buf, zip['install_profile.json']).toString('utf8'));
|
||||
const versionEntryName = (profile.json || '/version.json').replace(/^\//, '');
|
||||
const vjEntry = zip[versionEntryName] || zip['version.json'];
|
||||
if (!vjEntry) throw new Error('version.json fehlt im Installer.');
|
||||
const versionJson = JSON.parse(readZipEntry(buf, vjEntry).toString('utf8'));
|
||||
|
||||
// version.json ablegen
|
||||
const vjDir = path.join(sharedDir, 'versions', versionJson.id);
|
||||
ensureDir(vjDir);
|
||||
fs.writeFileSync(path.join(vjDir, versionJson.id + '.json'), JSON.stringify(versionJson));
|
||||
|
||||
// Installer-JAR ablegen (Prozessoren referenzieren ihn evtl. als {INSTALLER})
|
||||
const installerPath = path.join(vjDir, 'installer.jar');
|
||||
fs.writeFileSync(installerPath, buf);
|
||||
|
||||
// --- Libraries laden: aus install_profile UND version.json ---
|
||||
const allLibs = [...(profile.libraries || []), ...(versionJson.libraries || [])];
|
||||
let li = 0;
|
||||
for (const lib of allLibs) {
|
||||
li++;
|
||||
onProgress({ phase: 'loader', current: li, total: allLibs.length, detail: lib.name });
|
||||
const art = lib.downloads && lib.downloads.artifact;
|
||||
const rel = (art && art.path) || mavenPath(lib.name);
|
||||
const dest = path.join(libDir, rel.replace(/\//g, path.sep));
|
||||
if (fs.existsSync(dest)) continue;
|
||||
if (art && art.url) {
|
||||
const data = await fetchBuffer(art.url);
|
||||
ensureDir(path.dirname(dest));
|
||||
fs.writeFileSync(dest, data);
|
||||
} else {
|
||||
// im Installer gebündelt: maven/<rel>
|
||||
const bundled = zip['maven/' + rel];
|
||||
if (bundled) {
|
||||
ensureDir(path.dirname(dest));
|
||||
fs.writeFileSync(dest, readZipEntry(buf, bundled));
|
||||
}
|
||||
// sonst: wird von den Prozessoren erzeugt
|
||||
}
|
||||
}
|
||||
|
||||
// --- data-Tokens auflösen ---
|
||||
const extractDir = path.join(vjDir, 'installer-data');
|
||||
ensureDir(extractDir);
|
||||
const data = {};
|
||||
for (const [key, val] of Object.entries(profile.data || {})) {
|
||||
let cv = val.client;
|
||||
if (cv == null) continue;
|
||||
if (cv.startsWith('[') && cv.endsWith(']')) {
|
||||
data[key] = path.join(libDir, mavenPath(cv.slice(1, -1)).replace(/\//g, path.sep));
|
||||
} else if (cv.startsWith('/')) {
|
||||
const entryName = cv.slice(1);
|
||||
const e = zip[entryName];
|
||||
if (e) {
|
||||
const out = path.join(extractDir, entryName.replace(/\//g, path.sep));
|
||||
ensureDir(path.dirname(out));
|
||||
fs.writeFileSync(out, readZipEntry(buf, e));
|
||||
data[key] = out;
|
||||
} else { data[key] = cv; }
|
||||
} else {
|
||||
data[key] = cv;
|
||||
}
|
||||
}
|
||||
|
||||
// Client-Mappings von Mojang bereitstellen (Forge-Prozessoren brauchen sie)
|
||||
if (data.MAPPINGS && !fs.existsSync(data.MAPPINGS) && opts.clientMappingsUrl) {
|
||||
ensureDir(path.dirname(data.MAPPINGS));
|
||||
fs.writeFileSync(data.MAPPINGS, await fetchBuffer(opts.clientMappingsUrl));
|
||||
}
|
||||
|
||||
// --- Token-Auflösung für Prozessor-Argumente ---
|
||||
const resolveArg = (a) => {
|
||||
if (a.startsWith('[') && a.endsWith(']')) {
|
||||
return path.join(libDir, mavenPath(a.slice(1, -1)).replace(/\//g, path.sep));
|
||||
}
|
||||
return a.replace(/\{([^}]+)\}/g, (_, k) => {
|
||||
if (k === 'SIDE') return 'client';
|
||||
if (k === 'MINECRAFT_JAR') return clientJar;
|
||||
if (k === 'ROOT') return sharedDir;
|
||||
if (k === 'INSTALLER') return installerPath;
|
||||
if (k === 'LIBRARY_DIR') return libDir;
|
||||
if (k in data) return data[k];
|
||||
return '{' + k + '}';
|
||||
});
|
||||
};
|
||||
|
||||
// --- Prozessoren ausführen ---
|
||||
const processors = (profile.processors || []).filter(
|
||||
(p) => !p.sides || p.sides.includes('client'),
|
||||
);
|
||||
let pi = 0;
|
||||
for (const proc of processors) {
|
||||
pi++;
|
||||
onProgress({ phase: 'loader', current: pi, total: processors.length, detail: 'Prozessor ' + pi + '/' + processors.length });
|
||||
|
||||
const cpCoords = [proc.jar, ...(proc.classpath || [])];
|
||||
const cpPaths = cpCoords.map((c) => path.join(libDir, mavenPath(c).replace(/\//g, path.sep)));
|
||||
const mainJar = cpPaths[0];
|
||||
const mainClass = jarMainClass(mainJar);
|
||||
if (!mainClass) throw new Error('Kein Main-Class im Prozessor-JAR: ' + proc.jar);
|
||||
|
||||
const args = ['-cp', cpPaths.join(path.delimiter), mainClass, ...(proc.args || []).map(resolveArg)];
|
||||
const r = spawnSync(java, args, { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
|
||||
if (r.status !== 0) {
|
||||
const tail = ((r.stderr || '') + (r.stdout || '')).split('\n').slice(-8).join('\n');
|
||||
throw new Error(`Forge-Prozessor ${pi} fehlgeschlagen (Exit ${r.status}).\n${tail}`);
|
||||
}
|
||||
}
|
||||
|
||||
return versionJson;
|
||||
}
|
||||
|
||||
module.exports = { installForge, mavenPath, jarMainClass, readZipIndex, readZipEntry };
|
||||
514
src/index.html
Normal file
514
src/index.html
Normal file
@@ -0,0 +1,514 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta http-equiv="Content-Security-Policy"
|
||||
content="default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self'; img-src 'self' data: https:; connect-src 'self';" />
|
||||
<title>AeroMC</title>
|
||||
<link rel="stylesheet" href="styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<!-- Obere Werkzeugleiste (MultiMC-Stil: Icon + Text) -->
|
||||
<header class="toolbar">
|
||||
<button id="btn-new" class="tbtn" data-ic="plus">Instanz hinzufügen</button>
|
||||
<button id="btn-import" class="tbtn" data-ic="import" title="Instanz aus .zip importieren">Importieren</button>
|
||||
<button id="btn-modpack" class="tbtn" data-ic="package" title="Modrinth-Modpack (.mrpack)">Modpack</button>
|
||||
<button id="btn-open-dir" class="tbtn" data-ic="folder" title="Ordner öffnen">Ordner</button>
|
||||
<span class="tbsep"></span>
|
||||
<button id="btn-console" class="tbtn" data-ic="terminal" title="Konsole / Log">Konsole</button>
|
||||
<button id="btn-skin" class="tbtn" data-ic="user" title="Skin-Hintergrund ein/aus">Skin</button>
|
||||
<span class="tbgrow"></span>
|
||||
<div class="search-wrap">
|
||||
<input id="search" type="text" placeholder="Suchen…" autocomplete="off" />
|
||||
</div>
|
||||
<button id="btn-account" class="tbtn acct-btn" title="Microsoft-Konto"><span id="account-av" class="acct-av"></span><span id="account-name">Konten</span></button>
|
||||
<button id="btn-settings" class="tbtn" data-ic="settings" title="Einstellungen">Optionen</button>
|
||||
</header>
|
||||
|
||||
<!-- Arbeitsbereich: Instanzliste + rechte Aktionsleiste -->
|
||||
<main class="workspace">
|
||||
<div class="list-area">
|
||||
<div id="grid" class="list"></div>
|
||||
<section id="empty" class="empty hidden">
|
||||
<h2>Noch keine Instanzen</h2>
|
||||
<p>Lege deine erste Minecraft-Instanz an – eigene Version, eigene Mods.</p>
|
||||
<button id="btn-new-2" class="btn btn-primary btn-lg" data-ic="plus">Erste Instanz anlegen</button>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<aside id="inst-panel" class="side">
|
||||
<div id="p-empty" class="side-empty">Keine Instanz ausgewählt</div>
|
||||
<div id="p-content" class="hidden">
|
||||
<div id="p-icon" class="side-ico">🌱</div>
|
||||
<div id="p-name" class="side-title">Instanz</div>
|
||||
<div id="p-groupline" class="side-grp">Gruppe ändern</div>
|
||||
<div class="hr"></div>
|
||||
<button id="p-play" class="sbtn play">Starten</button>
|
||||
<button id="p-offline" class="sbtn">Offline starten</button>
|
||||
<div class="hr"></div>
|
||||
<button id="p-edit" class="sbtn">Instanz bearbeiten</button>
|
||||
<button id="p-notes" class="sbtn">Notizen bearbeiten</button>
|
||||
<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>
|
||||
<div class="hr"></div>
|
||||
<button id="p-mcfolder" class="sbtn">Minecraft-Ordner</button>
|
||||
<button id="p-configfolder" class="sbtn">Konfigurations-Ordner</button>
|
||||
<button id="p-instfolder" class="sbtn">Instanz-Ordner</button>
|
||||
<div class="hr"></div>
|
||||
<button id="p-shortcut" class="sbtn">Verknüpfung erstellen</button>
|
||||
<button id="p-export" class="sbtn">Instanz exportieren</button>
|
||||
<button id="p-delete" class="sbtn del">Löschen</button>
|
||||
<button id="p-copy" class="sbtn">Instanz kopieren</button>
|
||||
</div>
|
||||
</aside>
|
||||
</main>
|
||||
|
||||
<!-- Statusleiste (zwei Zeilen, MultiMC-Stil) -->
|
||||
<footer class="statusbar">
|
||||
<div class="st-row"><span id="status-left">Bereit</span><span id="status-right"></span></div>
|
||||
<div class="st-row st-row2">
|
||||
<span id="status-play">Keine Instanz ausgewählt</span>
|
||||
<span id="status-auth" class="auth-warn hidden" title="Erneut prüfen"></span>
|
||||
<span id="status-total"></span>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<!-- ===================== Dialog: Neue/Bearbeiten Instanz ===================== -->
|
||||
<div id="modal-instance" class="modal hidden">
|
||||
<div class="modal-card">
|
||||
<div class="modal-head">
|
||||
<h3 id="instance-modal-title">Neue Instanz</h3>
|
||||
<button class="modal-close" data-close>✕</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<label class="field">
|
||||
<span>Name</span>
|
||||
<input id="f-name" type="text" placeholder="z. B. Survival 1.21" />
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<span>Gruppe <small class="opt">(optional)</small></span>
|
||||
<input id="f-group" type="text" list="group-list" placeholder="z. B. Modpacks, Server, Tests" autocomplete="off" />
|
||||
<datalist id="group-list"></datalist>
|
||||
</label>
|
||||
|
||||
<div class="field-row">
|
||||
<label class="field">
|
||||
<span>Minecraft-Version</span>
|
||||
<div class="version-row">
|
||||
<select id="f-version-type">
|
||||
<option value="release">Releases</option>
|
||||
<option value="snapshot">Snapshots</option>
|
||||
<option value="all">Alle</option>
|
||||
</select>
|
||||
<select id="f-version"></select>
|
||||
</div>
|
||||
<small id="f-version-hint" class="hint">Lade Versionsliste …</small>
|
||||
</label>
|
||||
<label class="field field-icon">
|
||||
<span>Icon</span>
|
||||
<select id="f-icon"></select>
|
||||
<div id="f-icon-grid" class="icon-grid"></div>
|
||||
<div class="icon-custom">
|
||||
<span id="f-icon-preview" class="icon-preview hidden"></span>
|
||||
<button type="button" id="f-pick-image" class="btn btn-mini">🖼 Bild…</button>
|
||||
<button type="button" id="f-clear-image" class="btn btn-mini hidden">✕</button>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="field-row">
|
||||
<label class="field">
|
||||
<span>Mod-Loader</span>
|
||||
<select id="f-loader">
|
||||
<option value="vanilla">Vanilla (ohne Loader)</option>
|
||||
<option value="fabric">Fabric</option>
|
||||
<option value="forge">Forge</option>
|
||||
<option value="neoforge">NeoForge</option>
|
||||
<option value="quilt">Quilt</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Loader-Version <small class="opt">(optional)</small></span>
|
||||
<select id="f-loader-version"><option value="">neueste (empfohlen)</option></select>
|
||||
<small id="f-loader-hint" class="hint"></small>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label class="field">
|
||||
<span>Notizen <small class="opt">(optional)</small></span>
|
||||
<textarea id="f-notes" rows="2" placeholder="Modliste, Server-IP, To-dos …"></textarea>
|
||||
</label>
|
||||
|
||||
<!-- erweiterte Java-Einstellungen nur im Bearbeiten-Modus sichtbar -->
|
||||
<fieldset id="f-advanced" class="advanced hidden">
|
||||
<legend>Java (nur diese Instanz – leer = globale Einstellung)</legend>
|
||||
<div class="field-row">
|
||||
<label class="field">
|
||||
<span>RAM min. (MB)</span>
|
||||
<input id="f-min-mem" type="number" min="512" step="256" placeholder="global" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>RAM max. (MB)</span>
|
||||
<input id="f-max-mem" type="number" min="512" step="256" placeholder="global" />
|
||||
</label>
|
||||
</div>
|
||||
<label class="field">
|
||||
<span>Zusätzliche JVM-Argumente</span>
|
||||
<input id="f-extra-args" type="text" placeholder="z. B. -XX:+UseG1GC" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Pre-Launch-Befehl <small class="opt">(läuft vor dem Start)</small></span>
|
||||
<input id="f-prelaunch" type="text" placeholder="z. B. echo Start >> spielstart.log" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Post-Exit-Befehl <small class="opt">(nach dem Beenden)</small></span>
|
||||
<input id="f-postexit" type="text" placeholder="optional" />
|
||||
</label>
|
||||
</fieldset>
|
||||
</div>
|
||||
<div class="modal-foot">
|
||||
<button class="btn" data-close>Abbrechen</button>
|
||||
<button id="btn-save-instance" class="btn btn-primary">Speichern</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===================== Dialog: Einstellungen ===================== -->
|
||||
<div id="modal-settings" class="modal hidden">
|
||||
<div class="modal-card modal-lg">
|
||||
<div class="modal-head">
|
||||
<h3>Einstellungen</h3>
|
||||
<button class="modal-close" data-close>✕</button>
|
||||
</div>
|
||||
<div class="modal-body settings-body">
|
||||
<nav class="settings-nav">
|
||||
<button class="snav on" data-pane="pane-launcher">Launcher</button>
|
||||
<button class="snav" data-pane="pane-minecraft">Minecraft</button>
|
||||
<button class="snav" data-pane="pane-java">Java</button>
|
||||
<button class="snav" data-pane="pane-commands">Befehle</button>
|
||||
<button class="snav" data-pane="pane-external">Externe Programme</button>
|
||||
<button class="snav" data-pane="pane-proxy">Proxy</button>
|
||||
<button class="snav" data-pane="pane-accounts">Konten</button>
|
||||
<button class="snav" data-pane="pane-info">Info</button>
|
||||
</nav>
|
||||
<div class="settings-content">
|
||||
<div id="pane-launcher" class="spane">
|
||||
<h4>Launcher</h4>
|
||||
<label class="field">
|
||||
<span>Design</span>
|
||||
<select id="s-theme">
|
||||
<option value="dark">Dunkel</option>
|
||||
<option value="light">Hell</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Instanz-Verzeichnis</span>
|
||||
<div class="path-row">
|
||||
<input id="s-instances-dir" type="text" readonly />
|
||||
<button id="s-pick-dir" class="btn">Wählen…</button>
|
||||
</div>
|
||||
<small class="hint">Hier werden alle Instanzen als Unterordner (mit ihrem Namen) gespeichert.</small>
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<span>Skin-Hintergrund <small class="opt">(optional)</small></span>
|
||||
<select id="s-bgskin"><option value="">Aus</option></select>
|
||||
<small class="hint">Zeigt den Minecraft-Skin des gewählten Kontos als Hintergrund im Instanzbereich.</small>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Pose</span>
|
||||
<select id="s-bgskin-pose">
|
||||
<option value="walking">Laufend</option>
|
||||
<option value="standing">Stehend</option>
|
||||
<option value="legends">Heldenhaft</option>
|
||||
<option value="bedwars">Kampfhaltung</option>
|
||||
<option value="hoverboard">Schwebend</option>
|
||||
<option value="sitting">Sitzend</option>
|
||||
<option value="spooky">Unheimlich</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Deckkraft: <span id="s-bgskin-val">55</span> %</span>
|
||||
<input id="s-bgskin-opacity" type="range" min="10" max="100" step="5" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div id="pane-minecraft" class="spane hidden">
|
||||
<h4>Minecraft</h4>
|
||||
<div class="field-row">
|
||||
<label class="field">
|
||||
<span>Standard-RAM min. (MB)</span>
|
||||
<input id="s-min-mem" type="number" min="512" step="256" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Standard-RAM max. (MB)</span>
|
||||
<input id="s-max-mem" type="number" min="512" step="256" />
|
||||
</label>
|
||||
</div>
|
||||
<small class="hint">Gilt für alle Instanzen ohne eigene RAM-Einstellung.</small>
|
||||
</div>
|
||||
|
||||
<div id="pane-java" class="spane hidden">
|
||||
<h4>Java</h4>
|
||||
<label class="field">
|
||||
<span>Java (java.exe)</span>
|
||||
<div class="path-row">
|
||||
<input id="s-java" type="text" placeholder="leer = java aus PATH" />
|
||||
<button id="s-pick-java" class="btn">Wählen…</button>
|
||||
</div>
|
||||
<div class="path-row">
|
||||
<select id="s-java-detected"><option value="">– gefundene Java-Installationen –</option></select>
|
||||
<button id="s-scan-java" class="btn">Erkennen</button>
|
||||
</div>
|
||||
<small id="s-java-status" class="hint">–</small>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div id="pane-commands" class="spane hidden">
|
||||
<h4>Benutzerdefinierte Befehle</h4>
|
||||
<label class="field">
|
||||
<span>Pre-Launch-Befehl (global)</span>
|
||||
<input id="s-global-pre" type="text" placeholder="läuft vor jedem Spielstart" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Post-Exit-Befehl (global)</span>
|
||||
<input id="s-global-post" type="text" placeholder="läuft nach jedem Beenden" />
|
||||
</label>
|
||||
<small class="hint">Gilt für alle Instanzen. Eine Instanz mit eigenem Befehl (im Bearbeiten-Dialog) hat Vorrang.</small>
|
||||
</div>
|
||||
|
||||
<div id="pane-external" class="spane hidden">
|
||||
<h4>Externe Programme</h4>
|
||||
<label class="field">
|
||||
<span>Wrapper-Befehl</span>
|
||||
<input id="s-wrapper" type="text" placeholder="z. B. prime-run (leer = kein Wrapper)" />
|
||||
</label>
|
||||
<small class="hint">Startet Java über dieses Programm (z. B. GPU-Auswahl). Java-Pfad und Argumente werden automatisch angehängt.</small>
|
||||
</div>
|
||||
|
||||
<div id="pane-proxy" class="spane hidden">
|
||||
<h4>Proxy</h4>
|
||||
<label class="checkbox-field">
|
||||
<input id="s-proxy-enabled" type="checkbox" /> <span>HTTP-Proxy für Downloads verwenden</span>
|
||||
</label>
|
||||
<div class="field-row">
|
||||
<label class="field"><span>Host</span><input id="s-proxy-host" type="text" placeholder="proxy.firma.de" /></label>
|
||||
<label class="field field-port"><span>Port</span><input id="s-proxy-port" type="text" placeholder="8080" /></label>
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<label class="field"><span>Benutzer <small class="opt">(optional)</small></span><input id="s-proxy-user" type="text" /></label>
|
||||
<label class="field"><span>Passwort <small class="opt">(optional)</small></span><input id="s-proxy-pass" type="password" /></label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="pane-accounts" class="spane hidden">
|
||||
<h4>Konten</h4>
|
||||
<p class="hint">Microsoft-Konten verwaltest du über den Konten-Button in der Toolbar.</p>
|
||||
<button id="s-open-accounts" class="btn">Konten öffnen</button>
|
||||
</div>
|
||||
|
||||
<div id="pane-info" class="spane hidden">
|
||||
<h4>Über AeroMC</h4>
|
||||
<div class="about-brand">
|
||||
<img src="assets/logo.png" alt="" class="about-logo" />
|
||||
<div>
|
||||
<div class="about-name">AeroMC</div>
|
||||
<div class="hint">Instanz-basierter Minecraft-Launcher</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="s-appinfo" class="appinfo"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-foot">
|
||||
<button class="btn" data-close>Schließen</button>
|
||||
<button id="btn-save-settings" class="btn btn-primary">Speichern</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===================== Dialog: Bestätigung ===================== -->
|
||||
<div id="modal-confirm" class="modal hidden">
|
||||
<div class="modal-card modal-sm">
|
||||
<div class="modal-head">
|
||||
<h3 id="confirm-title">Sicher?</h3>
|
||||
<button class="modal-close" data-close>✕</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p id="confirm-text"></p>
|
||||
</div>
|
||||
<div class="modal-foot">
|
||||
<button class="btn" data-close>Abbrechen</button>
|
||||
<button id="confirm-ok" class="btn btn-danger">Löschen</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===================== Dialog: Mods (Modrinth) ===================== -->
|
||||
<div id="modal-mods" class="modal hidden">
|
||||
<div class="modal-card modal-lg">
|
||||
<div class="modal-head">
|
||||
<div class="detail-head">
|
||||
<span class="detail-icon">🧩</span>
|
||||
<div>
|
||||
<h3 id="m-title">Mods</h3>
|
||||
<div id="m-sub" class="detail-sub"></div>
|
||||
</div>
|
||||
</div>
|
||||
<button class="modal-close" data-close>✕</button>
|
||||
</div>
|
||||
<div class="modal-body mods-body">
|
||||
<div id="m-warn" class="mods-warn hidden"></div>
|
||||
|
||||
<div class="mods-search">
|
||||
<input id="m-query" type="text" placeholder="Mods durchsuchen … (z. B. Sodium, JEI, Create)" />
|
||||
<button id="m-search-btn" class="btn btn-primary">Suchen</button>
|
||||
</div>
|
||||
|
||||
<div class="mods-filters">
|
||||
<select id="m-sort" title="Sortierung">
|
||||
<option value="downloads">Beliebt</option>
|
||||
<option value="relevance">Relevanz</option>
|
||||
<option value="follows">Follower</option>
|
||||
<option value="newest">Neu</option>
|
||||
<option value="updated">Aktualisiert</option>
|
||||
</select>
|
||||
<select id="m-category" title="Kategorie">
|
||||
<option value="">Alle Kategorien</option>
|
||||
<option value="optimization">Optimierung</option>
|
||||
<option value="utility">Werkzeuge</option>
|
||||
<option value="adventure">Adventure</option>
|
||||
<option value="technology">Technik</option>
|
||||
<option value="magic">Magie</option>
|
||||
<option value="worldgen">Weltgenerierung</option>
|
||||
<option value="decoration">Dekoration</option>
|
||||
<option value="storage">Lagerung</option>
|
||||
<option value="food">Essen</option>
|
||||
<option value="mobs">Kreaturen</option>
|
||||
<option value="equipment">Ausrüstung</option>
|
||||
<option value="library">Bibliothek</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="mods-tabs">
|
||||
<button id="m-tab-catalog" class="mtab on">Katalog</button>
|
||||
<button id="m-tab-installed" class="mtab">Installiert (<span id="m-installed-count">0</span>)</button>
|
||||
</div>
|
||||
|
||||
<div id="m-pane-catalog">
|
||||
<div id="m-results" class="mods-list"><div class="mods-empty">Lade beliebte Mods …</div></div>
|
||||
<button id="m-more" class="btn btn-mini hidden">Mehr laden</button>
|
||||
</div>
|
||||
<div id="m-pane-installed" class="hidden">
|
||||
<div id="m-installed" class="mods-list"><div class="mods-empty">Noch keine Mods.</div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-foot">
|
||||
<button id="m-open-folder" class="btn" data-ic="folder">mods-Ordner</button>
|
||||
<div class="spacer"></div>
|
||||
<button class="btn" data-close>Schließen</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===================== Dialog: Microsoft-Login ===================== -->
|
||||
<div id="modal-login" class="modal hidden">
|
||||
<div class="modal-card modal-sm">
|
||||
<div class="modal-head">
|
||||
<h3>Microsoft-Login</h3>
|
||||
<button class="modal-close" data-close>✕</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div id="login-accounts" class="account-list"></div>
|
||||
|
||||
<div id="login-code" class="hidden login-code">
|
||||
<p>Öffne diese Seite und gib den Code ein:</p>
|
||||
<a id="login-url" class="login-link" href="#">microsoft.com/link</a>
|
||||
<div id="login-usercode" class="login-usercode">– – – –</div>
|
||||
<button id="login-copy" class="btn btn-mini" data-ic="copy">Code kopieren</button>
|
||||
<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>
|
||||
</div>
|
||||
<div class="modal-foot">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===================== Dialog: Konsole / Log ===================== -->
|
||||
<div id="modal-console" class="modal hidden">
|
||||
<div class="modal-card modal-lg">
|
||||
<div class="modal-head">
|
||||
<h3>Konsole / Log</h3>
|
||||
<button class="modal-close" data-close>✕</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<pre id="console-out" class="console-out"></pre>
|
||||
</div>
|
||||
<div class="modal-foot">
|
||||
<button id="console-clear" class="btn">Leeren</button>
|
||||
<button id="console-copy" class="btn">Kopieren</button>
|
||||
<div class="spacer"></div>
|
||||
<button class="btn" data-close>Schließen</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===================== Dialog: Welten & Ressourcenpakete ===================== -->
|
||||
<div id="modal-content" class="modal hidden">
|
||||
<div class="modal-card modal-lg">
|
||||
<div class="modal-head">
|
||||
<h3 id="content-title">Welten & Ressourcenpakete</h3>
|
||||
<button class="modal-close" data-close>✕</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="mods-columns">
|
||||
<div class="mods-col">
|
||||
<h4>Welten <button id="content-open-worlds" class="btn btn-mini" data-ic="folder"></button></h4>
|
||||
<div id="content-worlds" class="mods-list"></div>
|
||||
</div>
|
||||
<div class="mods-col">
|
||||
<h4>Ressourcenpakete <button id="content-open-rp" class="btn btn-mini" data-ic="folder"></button></h4>
|
||||
<div id="content-rp" class="mods-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-foot">
|
||||
<div class="spacer"></div>
|
||||
<button class="btn" data-close>Schließen</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===================== Dialog: Screenshots ===================== -->
|
||||
<div id="modal-screens" class="modal hidden">
|
||||
<div class="modal-card modal-lg">
|
||||
<div class="modal-head">
|
||||
<h3 id="screens-title">Screenshots</h3>
|
||||
<button class="modal-close" data-close>✕</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div id="screens-grid" class="screens-grid"></div>
|
||||
</div>
|
||||
<div class="modal-foot">
|
||||
<button id="screens-open" class="btn" data-ic="folder">Ordner</button>
|
||||
<div class="spacer"></div>
|
||||
<button class="btn" data-close>Schließen</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Toast -->
|
||||
<div id="toast" class="toast hidden"></div>
|
||||
|
||||
<canvas id="skin-canvas" width="420" height="700" style="display:none"></canvas>
|
||||
<script src="vendor/skinview3d.js"></script>
|
||||
<script src="renderer.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
455
src/launcher.js
Normal file
455
src/launcher.js
Normal file
@@ -0,0 +1,455 @@
|
||||
'use strict';
|
||||
|
||||
/*
|
||||
* launcher.js – Vanilla-Start-Pipeline (Phase 2)
|
||||
* ----------------------------------------------
|
||||
* Lädt Version-JSON, Client-JAR, Libraries, Natives und Assets herunter,
|
||||
* wählt eine passende Java-Version und baut das Start-Kommando.
|
||||
*
|
||||
* Der eigentliche Prozess-Start ist an ein Konto gebunden (opts.auth):
|
||||
* Ohne gültigen Microsoft-Login (Phase 3) wird alles vorbereitet, aber
|
||||
* NICHT gestartet – wir bauen bewusst keinen Offline-/Crack-Bypass.
|
||||
*
|
||||
* Gemeinsame Spieldateien (versions/libraries/assets) liegen geteilt unter
|
||||
* <sharedDir>, damit mehrere Instanzen sie sich teilen (spart Platz).
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
const { spawn, spawnSync } = require('child_process');
|
||||
const services = require('./services');
|
||||
const loaders = require('./loaders');
|
||||
const forge = require('./forge');
|
||||
|
||||
const UA = 'AeroMC-Launcher/0.2';
|
||||
const RESOURCES = 'https://resources.download.minecraft.net';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// kleine Helfer
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function ensureDir(d) { fs.mkdirSync(d, { recursive: true }); }
|
||||
|
||||
function sha1File(file) {
|
||||
try {
|
||||
const h = crypto.createHash('sha1');
|
||||
h.update(fs.readFileSync(file));
|
||||
return h.digest('hex');
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
async function fetchJson(url) {
|
||||
const res = await fetch(url, { headers: { 'User-Agent': UA } });
|
||||
if (!res.ok) throw new Error('HTTP ' + res.status + ' bei ' + url);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// lädt eine Datei, überspringt sie bei passender sha1/Existenz
|
||||
async function downloadIfNeeded(url, dest, sha1) {
|
||||
if (fs.existsSync(dest)) {
|
||||
if (!sha1 || sha1File(dest) === sha1) return { skipped: true, bytes: 0 };
|
||||
}
|
||||
ensureDir(path.dirname(dest));
|
||||
const res = await fetch(url, { headers: { 'User-Agent': UA } });
|
||||
if (!res.ok) throw new Error('Download HTTP ' + res.status + ': ' + url);
|
||||
const buf = Buffer.from(await res.arrayBuffer());
|
||||
fs.writeFileSync(dest, buf);
|
||||
return { skipped: false, bytes: buf.length };
|
||||
}
|
||||
|
||||
// begrenzte Parallelität für viele kleine Downloads (Assets)
|
||||
async function runPool(items, worker, concurrency, onEach) {
|
||||
let idx = 0, done = 0;
|
||||
const total = items.length;
|
||||
async function next() {
|
||||
while (idx < total) {
|
||||
const i = idx++;
|
||||
await worker(items[i]);
|
||||
done++;
|
||||
if (onEach) onEach(done, total);
|
||||
}
|
||||
}
|
||||
const runners = [];
|
||||
for (let c = 0; c < Math.min(concurrency, total); c++) runners.push(next());
|
||||
await Promise.all(runners);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Regeln (Libraries & Argumente)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const OS_NAME = 'windows';
|
||||
|
||||
function rulesAllow(rules) {
|
||||
if (!rules || !rules.length) return true;
|
||||
let action = 'disallow';
|
||||
for (const r of rules) {
|
||||
let matches = true;
|
||||
if (r.os && r.os.name && r.os.name !== OS_NAME) matches = false;
|
||||
if (r.features) matches = false; // Demo-/Auflösungs-Features aus
|
||||
if (matches) action = r.action;
|
||||
}
|
||||
return action === 'allow';
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Version-JSON
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function getVersionJson(versionId, userDataDir, sharedDir) {
|
||||
const dest = path.join(sharedDir, 'versions', versionId, versionId + '.json');
|
||||
if (fs.existsSync(dest)) {
|
||||
try { return JSON.parse(fs.readFileSync(dest, 'utf8')); } catch { /* neu laden */ }
|
||||
}
|
||||
// URL aus dem Manifest suchen (bei Bedarf frisch laden, falls url fehlt)
|
||||
let manifest = await services.getMcVersions(userDataDir);
|
||||
let entry = (manifest.versions || []).find((v) => v.id === versionId);
|
||||
if (!entry || !entry.url) {
|
||||
manifest = await services.getMcVersions(userDataDir, 0); // erzwinge Refresh
|
||||
entry = (manifest.versions || []).find((v) => v.id === versionId);
|
||||
}
|
||||
if (!entry || !entry.url) throw new Error('Version nicht im Mojang-Manifest gefunden: ' + versionId);
|
||||
const vj = await fetchJson(entry.url);
|
||||
ensureDir(path.dirname(dest));
|
||||
fs.writeFileSync(dest, JSON.stringify(vj));
|
||||
return vj;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Libraries + Natives -> Classpath
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function prepareLibraries(vj, sharedDir, nativesDir, onProgress) {
|
||||
const libDir = path.join(sharedDir, 'libraries');
|
||||
const classpath = [];
|
||||
const libs = (vj.libraries || []).filter((l) => rulesAllow(l.rules));
|
||||
let i = 0;
|
||||
for (const lib of libs) {
|
||||
i++;
|
||||
onProgress && onProgress({ phase: 'libraries', current: i, total: libs.length, detail: lib.name });
|
||||
|
||||
const dl = lib.downloads || {};
|
||||
// normales Artefakt -> Classpath
|
||||
if (dl.artifact && dl.artifact.path) {
|
||||
const dest = path.join(libDir, dl.artifact.path.replace(/\//g, path.sep));
|
||||
await downloadIfNeeded(dl.artifact.url, dest, dl.artifact.sha1);
|
||||
classpath.push(dest);
|
||||
}
|
||||
// klassische Natives (vor 1.19) -> in nativesDir extrahieren
|
||||
if (lib.natives && lib.natives[OS_NAME] && dl.classifiers) {
|
||||
const key = lib.natives[OS_NAME].replace('${arch}', '64');
|
||||
const nat = dl.classifiers[key];
|
||||
if (nat) {
|
||||
const dest = path.join(libDir, nat.path.replace(/\//g, path.sep));
|
||||
await downloadIfNeeded(nat.url, dest, nat.sha1);
|
||||
await extractNatives(dest, nativesDir, lib.extract);
|
||||
}
|
||||
}
|
||||
}
|
||||
return classpath;
|
||||
}
|
||||
|
||||
// entpackt eine Natives-JAR (ZIP) nach nativesDir (ohne externe Abhängigkeit)
|
||||
async function extractNatives(jarPath, nativesDir, extractRule) {
|
||||
ensureDir(nativesDir);
|
||||
const exclude = (extractRule && extractRule.exclude) || ['META-INF/'];
|
||||
const data = fs.readFileSync(jarPath);
|
||||
// ZIP End-of-Central-Directory finden
|
||||
let eocd = -1;
|
||||
for (let i = data.length - 22; i >= 0 && i > data.length - 22 - 65536; i--) {
|
||||
if (data.readUInt32LE(i) === 0x06054b50) { eocd = i; break; }
|
||||
}
|
||||
if (eocd < 0) return;
|
||||
const count = data.readUInt16LE(eocd + 10);
|
||||
let off = data.readUInt32LE(eocd + 16);
|
||||
const zlib = require('zlib');
|
||||
for (let n = 0; n < count; n++) {
|
||||
if (data.readUInt32LE(off) !== 0x02014b50) break;
|
||||
const method = data.readUInt16LE(off + 10);
|
||||
const compSize = data.readUInt32LE(off + 20);
|
||||
const nameLen = data.readUInt16LE(off + 28);
|
||||
const extraLen = data.readUInt16LE(off + 30);
|
||||
const commentLen = data.readUInt16LE(off + 32);
|
||||
const localOff = data.readUInt32LE(off + 42);
|
||||
const name = data.toString('utf8', off + 46, off + 46 + nameLen);
|
||||
off += 46 + nameLen + extraLen + commentLen;
|
||||
|
||||
if (name.endsWith('/')) continue;
|
||||
if (exclude.some((ex) => name.startsWith(ex))) continue;
|
||||
|
||||
// lokalen Header lesen, um Datenoffset zu bestimmen
|
||||
const lNameLen = data.readUInt16LE(localOff + 26);
|
||||
const lExtraLen = data.readUInt16LE(localOff + 28);
|
||||
const dataStart = localOff + 30 + lNameLen + lExtraLen;
|
||||
const raw = data.subarray(dataStart, dataStart + compSize);
|
||||
let out;
|
||||
try { out = method === 0 ? raw : zlib.inflateRawSync(raw); } catch { continue; }
|
||||
const target = path.join(nativesDir, path.basename(name));
|
||||
fs.writeFileSync(target, out);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Assets
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function prepareAssets(vj, sharedDir, onProgress) {
|
||||
const ai = vj.assetIndex;
|
||||
if (!ai) return;
|
||||
const indexFile = path.join(sharedDir, 'assets', 'indexes', ai.id + '.json');
|
||||
await downloadIfNeeded(ai.url, indexFile, ai.sha1);
|
||||
const index = JSON.parse(fs.readFileSync(indexFile, 'utf8'));
|
||||
const objects = index.objects || {};
|
||||
const entries = Object.values(objects);
|
||||
const objDir = path.join(sharedDir, 'assets', 'objects');
|
||||
|
||||
await runPool(entries, async (o) => {
|
||||
const sub = o.hash.slice(0, 2);
|
||||
const dest = path.join(objDir, sub, o.hash);
|
||||
if (fs.existsSync(dest)) return;
|
||||
await downloadIfNeeded(`${RESOURCES}/${sub}/${o.hash}`, dest, o.hash);
|
||||
}, 16, (done, total) => onProgress && onProgress({ phase: 'assets', current: done, total, detail: 'Assets' }));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Java-Auswahl
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function pickJava(vj, preferredPath) {
|
||||
if (preferredPath && preferredPath.trim()) {
|
||||
const info = services.probeJava(preferredPath);
|
||||
if (info) return { path: preferredPath, major: info.major, forced: true };
|
||||
}
|
||||
const required = (vj.javaVersion && vj.javaVersion.majorVersion) || 8;
|
||||
const jvms = services.scanJava();
|
||||
// exakt passende oder nächsthöhere 64-bit-Version
|
||||
const x64 = jvms.filter((j) => j.arch === 'x64');
|
||||
const exact = x64.find((j) => j.major === required);
|
||||
if (exact) return { path: exact.path, major: exact.major };
|
||||
const higher = x64.filter((j) => j.major >= required).sort((a, b) => a.major - b.major)[0];
|
||||
if (higher) return { path: higher.path, major: higher.major };
|
||||
const any = jvms[0];
|
||||
return any ? { path: any.path, major: any.major, mismatch: required } : null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Argumente
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function substitute(str, map) {
|
||||
return str.replace(/\$\{([^}]+)\}/g, (m, k) => (k in map ? map[k] : m));
|
||||
}
|
||||
|
||||
function collectArgs(argList, map) {
|
||||
const out = [];
|
||||
for (const a of argList || []) {
|
||||
if (typeof a === 'string') { out.push(substitute(a, map)); continue; }
|
||||
if (a && a.rules && !rulesAllow(a.rules)) continue;
|
||||
const val = a && a.value;
|
||||
if (typeof val === 'string') out.push(substitute(val, map));
|
||||
else if (Array.isArray(val)) val.forEach((v) => out.push(substitute(v, map)));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// führt Vanilla-Version + Loader-Profil zusammen (mainClass + Argumente)
|
||||
function mergeLoader(vj, lp) {
|
||||
const merged = Object.assign({}, vj, { mainClass: lp.mainClass || vj.mainClass });
|
||||
merged.arguments = {
|
||||
jvm: [...((vj.arguments && vj.arguments.jvm) || []), ...((lp.arguments && lp.arguments.jvm) || [])],
|
||||
game: [...((vj.arguments && vj.arguments.game) || []), ...((lp.arguments && lp.arguments.game) || [])],
|
||||
};
|
||||
return merged;
|
||||
}
|
||||
|
||||
function buildArgs(vj, ctx) {
|
||||
const map = {
|
||||
auth_player_name: ctx.auth.name,
|
||||
version_name: vj.id,
|
||||
library_directory: path.join(ctx.sharedDir, 'libraries'),
|
||||
classpath_separator: path.delimiter,
|
||||
game_directory: ctx.gameDir,
|
||||
assets_root: path.join(ctx.sharedDir, 'assets'),
|
||||
assets_index_name: vj.assets || (vj.assetIndex && vj.assetIndex.id) || 'legacy',
|
||||
auth_uuid: ctx.auth.uuid,
|
||||
auth_access_token: ctx.auth.accessToken,
|
||||
clientid: ctx.auth.clientId || '',
|
||||
auth_xuid: ctx.auth.xuid || '',
|
||||
user_type: ctx.auth.userType || 'msa',
|
||||
version_type: vj.type || 'release',
|
||||
natives_directory: ctx.nativesDir,
|
||||
launcher_name: 'AeroMC',
|
||||
launcher_version: '0.2',
|
||||
classpath: ctx.classpath.join(path.delimiter),
|
||||
game_assets: path.join(ctx.sharedDir, 'assets', 'virtual', 'legacy'),
|
||||
auth_session: 'token:' + ctx.auth.accessToken,
|
||||
user_properties: '{}',
|
||||
};
|
||||
|
||||
let jvm, game;
|
||||
if (vj.arguments) {
|
||||
jvm = collectArgs(vj.arguments.jvm, map);
|
||||
game = collectArgs(vj.arguments.game, map);
|
||||
} else {
|
||||
// altes Format (< 1.13)
|
||||
jvm = ['-Djava.library.path=' + ctx.nativesDir, '-cp', ctx.classpath.join(path.delimiter)];
|
||||
game = (vj.minecraftArguments || '').split(' ').filter(Boolean).map((a) => substitute(a, map));
|
||||
}
|
||||
// RAM
|
||||
jvm = ['-Xms' + ctx.minMemMb + 'M', '-Xmx' + ctx.maxMemMb + 'M', ...jvm];
|
||||
if (ctx.extraArgs) jvm = [...ctx.extraArgs.split(' ').filter(Boolean), ...jvm];
|
||||
|
||||
return [...jvm, vj.mainClass, ...game];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hauptfunktion
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/*
|
||||
* opts: { versionId, gameDir, sharedDir, userDataDir, javaPath, minMemMb, maxMemMb, extraArgs, auth }
|
||||
* auth: null (kein Login) ODER { name, uuid, accessToken, userType }
|
||||
* onProgress: (evt) => void
|
||||
*/
|
||||
async function prepareAndLaunch(opts, onProgress) {
|
||||
const emit = (phase, extra) => onProgress && onProgress(Object.assign({ phase }, extra));
|
||||
|
||||
emit('version', { detail: 'Version-Daten laden …' });
|
||||
const vj = await getVersionJson(opts.versionId, opts.userDataDir, opts.sharedDir);
|
||||
|
||||
const nativesDir = path.join(opts.sharedDir, 'versions', vj.id, 'natives');
|
||||
ensureDir(nativesDir);
|
||||
|
||||
// Client-JAR
|
||||
emit('client', { detail: 'Client herunterladen …' });
|
||||
const clientJar = path.join(opts.sharedDir, 'versions', vj.id, vj.id + '.jar');
|
||||
if (vj.downloads && vj.downloads.client) {
|
||||
await downloadIfNeeded(vj.downloads.client.url, clientJar, vj.downloads.client.sha1);
|
||||
}
|
||||
|
||||
// Libraries + Natives
|
||||
const classpath = await prepareLibraries(vj, opts.sharedDir, nativesDir, onProgress);
|
||||
classpath.push(clientJar);
|
||||
|
||||
// Assets
|
||||
await prepareAssets(vj, opts.sharedDir, onProgress);
|
||||
|
||||
// Java (VOR dem Loader – Forge-Prozessoren brauchen Java)
|
||||
emit('java', { detail: 'Java wählen …' });
|
||||
const java = pickJava(vj, opts.javaPath);
|
||||
if (!java) return { ok: false, message: 'Keine Java-Installation gefunden. Bitte in den Einstellungen Java festlegen.' };
|
||||
|
||||
// Mod-Loader
|
||||
let effectiveVj = vj;
|
||||
const libDir = path.join(opts.sharedDir, 'libraries');
|
||||
|
||||
if (opts.loader === 'fabric' || opts.loader === 'quilt') {
|
||||
emit('loader', { detail: opts.loader + ' vorbereiten …' });
|
||||
let lp;
|
||||
try { lp = (await loaders.resolveLoaderProfile(opts.loader, vj.id, opts.loaderVersion)).profile; }
|
||||
catch (err) { return { ok: false, message: err.message }; }
|
||||
const loaderLibs = lp.libraries || [];
|
||||
let li = 0;
|
||||
for (const lib of loaderLibs) {
|
||||
li++;
|
||||
onProgress && onProgress({ phase: 'loader', current: li, total: loaderLibs.length, detail: lib.name });
|
||||
const { relPath, url } = loaders.mavenToUrl(lib.url, lib.name);
|
||||
const dest = path.join(libDir, relPath.replace(/\//g, path.sep));
|
||||
await downloadIfNeeded(url, dest, lib.sha1 || null);
|
||||
classpath.unshift(dest);
|
||||
}
|
||||
effectiveVj = mergeLoader(vj, lp);
|
||||
|
||||
} else if (opts.loader === 'forge' || opts.loader === 'neoforge') {
|
||||
let lv = opts.loaderVersion;
|
||||
if (!lv) {
|
||||
const list = await loaders.listLoaderVersions(opts.loader, vj.id);
|
||||
if (!list.length) return { ok: false, message: `Keine ${opts.loader}-Version für ${vj.id} gefunden.` };
|
||||
lv = list[0].version;
|
||||
}
|
||||
let fvj;
|
||||
try {
|
||||
fvj = await forge.installForge({
|
||||
loader: opts.loader, mcVersion: vj.id, loaderVersion: lv,
|
||||
sharedDir: opts.sharedDir, javaPath: java.path, clientJar,
|
||||
clientMappingsUrl: vj.downloads && vj.downloads.client_mappings && vj.downloads.client_mappings.url,
|
||||
onProgress,
|
||||
});
|
||||
} catch (err) {
|
||||
return { ok: false, message: (opts.loader === 'forge' ? 'Forge' : 'NeoForge') + '-Installation fehlgeschlagen: ' + err.message };
|
||||
}
|
||||
for (const lib of fvj.libraries || []) {
|
||||
const art = lib.downloads && lib.downloads.artifact;
|
||||
const rel = (art && art.path) || forge.mavenPath(lib.name);
|
||||
classpath.unshift(path.join(libDir, rel.replace(/\//g, path.sep)));
|
||||
}
|
||||
effectiveVj = mergeLoader(vj, fvj);
|
||||
}
|
||||
|
||||
// Ohne Login: alles vorbereitet, aber kein Start (kein Crack-Bypass)
|
||||
if (!opts.auth) {
|
||||
return {
|
||||
ok: false,
|
||||
needAuth: true,
|
||||
prepared: true,
|
||||
java,
|
||||
requiredJava: (vj.javaVersion && vj.javaVersion.majorVersion) || 8,
|
||||
message: 'Alles heruntergeladen und startklar ✔ – bitte oben rechts über „👤 Anmelden" mit deinem Microsoft-Konto einloggen, dann startet Minecraft.',
|
||||
};
|
||||
}
|
||||
|
||||
// Start
|
||||
const args = buildArgs(effectiveVj, {
|
||||
auth: opts.auth,
|
||||
gameDir: opts.gameDir,
|
||||
sharedDir: opts.sharedDir,
|
||||
nativesDir,
|
||||
classpath,
|
||||
minMemMb: opts.minMemMb || 2048,
|
||||
maxMemMb: opts.maxMemMb || 4096,
|
||||
extraArgs: opts.extraArgs || '',
|
||||
});
|
||||
|
||||
ensureDir(opts.gameDir);
|
||||
|
||||
// Pre-Launch-Befehl (bricht bei Fehler ab)
|
||||
if (opts.preLaunchCommand && opts.preLaunchCommand.trim()) {
|
||||
emit('log', { line: '[Pre-Launch] ' + opts.preLaunchCommand + '\n' });
|
||||
const pr = spawnSync(opts.preLaunchCommand, { cwd: opts.gameDir, shell: true, encoding: 'utf8' });
|
||||
if (pr.stdout) emit('log', { line: pr.stdout });
|
||||
if (pr.stderr) emit('log', { line: pr.stderr });
|
||||
if (pr.status !== 0) return { ok: false, message: 'Pre-Launch-Befehl fehlgeschlagen (Exit ' + pr.status + ').' };
|
||||
}
|
||||
|
||||
emit('launch', { detail: 'Starte Minecraft …' });
|
||||
let cmd = java.path;
|
||||
let spawnArgs = args;
|
||||
if (opts.wrapperCommand && opts.wrapperCommand.trim()) {
|
||||
const w = opts.wrapperCommand.trim().split(/\s+/);
|
||||
cmd = w[0];
|
||||
spawnArgs = [...w.slice(1), java.path, ...args];
|
||||
}
|
||||
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) => {
|
||||
emit('exit', { code });
|
||||
// Post-Exit-Befehl
|
||||
if (opts.postExitCommand && opts.postExitCommand.trim()) {
|
||||
emit('log', { line: '[Post-Exit] ' + opts.postExitCommand + '\n' });
|
||||
try {
|
||||
const po = spawnSync(opts.postExitCommand, { cwd: opts.gameDir, shell: true, encoding: 'utf8' });
|
||||
if (po.stdout) emit('log', { line: po.stdout });
|
||||
if (po.stderr) emit('log', { line: po.stderr });
|
||||
} catch { /* egal */ }
|
||||
}
|
||||
});
|
||||
|
||||
return { ok: true, pid: child.pid, java };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
prepareAndLaunch, getVersionJson, buildArgs, pickJava, rulesAllow, prepareLibraries, prepareAssets,
|
||||
downloadIfNeeded, extractNatives,
|
||||
};
|
||||
103
src/loaders.js
Normal file
103
src/loaders.js
Normal file
@@ -0,0 +1,103 @@
|
||||
'use strict';
|
||||
|
||||
/*
|
||||
* loaders.js – Mod-Loader-Profile auflösen
|
||||
* ----------------------------------------
|
||||
* Fabric & Quilt liefern über ihre Meta-API ein fertiges Profil-JSON
|
||||
* (mainClass, zusätzliche Libraries als Maven-Koordinaten, Argumente,
|
||||
* inheritsFrom = Vanilla-Version). Das mergen wir beim Start mit der Vanilla-Version.
|
||||
*
|
||||
* Forge & NeoForge liefern KEIN einfaches Profil, sondern einen Installer,
|
||||
* der Prozessoren ausführt und den Client patcht -> deutlich aufwändiger,
|
||||
* daher hier noch als „in Arbeit" gekennzeichnet.
|
||||
*/
|
||||
|
||||
const UA = 'AeroMC-Launcher/0.5';
|
||||
|
||||
const META = {
|
||||
fabric: 'https://meta.fabricmc.net/v2',
|
||||
quilt: 'https://meta.quiltmc.org/v3',
|
||||
};
|
||||
|
||||
async function fetchJson(url) {
|
||||
const res = await fetch(url, { headers: { 'User-Agent': UA, Accept: 'application/json' } });
|
||||
if (!res.ok) throw new Error('HTTP ' + res.status + ' bei ' + url);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function fetchText(url) {
|
||||
const res = await fetch(url, { headers: { 'User-Agent': UA } });
|
||||
if (!res.ok) throw new Error('HTTP ' + res.status + ' bei ' + url);
|
||||
return res.text();
|
||||
}
|
||||
|
||||
const FORGE_META = 'https://maven.minecraftforge.net/net/minecraftforge/forge/maven-metadata.xml';
|
||||
const NEOFORGE_META = 'https://maven.neoforged.net/releases/net/neoforged/neoforge/maven-metadata.xml';
|
||||
|
||||
// NeoForge kodiert die MC-Version im Loader-Namen: 1.21.4 -> 21.4.x
|
||||
function neoforgePrefix(mcVersion) {
|
||||
const p = mcVersion.split('.');
|
||||
return `${p[1]}.${p[2] || '0'}.`;
|
||||
}
|
||||
|
||||
// "group:artifact:version[:classifier]" -> { relPath, url }
|
||||
function mavenToUrl(base, name) {
|
||||
const parts = name.split(':');
|
||||
const group = parts[0].replace(/\./g, '/');
|
||||
const artifact = parts[1];
|
||||
const version = parts[2];
|
||||
const classifier = parts[3] ? '-' + parts[3] : '';
|
||||
const file = `${artifact}-${version}${classifier}.jar`;
|
||||
const relPath = `${group}/${artifact}/${version}/${file}`;
|
||||
const root = (base || 'https://libraries.minecraft.net/').replace(/\/$/, '');
|
||||
return { relPath, url: `${root}/${relPath}` };
|
||||
}
|
||||
|
||||
// verfügbare Loader-Versionen (für die Anzeige/Auswahl)
|
||||
async function listLoaderVersions(loader, mcVersion) {
|
||||
if (loader === 'fabric' || loader === 'quilt') {
|
||||
const list = await fetchJson(`${META[loader]}/versions/loader/${mcVersion}`);
|
||||
return list.map((l) => ({ version: l.loader.version, stable: !!l.loader.stable }));
|
||||
}
|
||||
if (loader === 'forge') {
|
||||
const xml = await fetchText(FORGE_META);
|
||||
const all = [...xml.matchAll(/<version>([^<]+)<\/version>/g)].map((m) => m[1]);
|
||||
const prefix = mcVersion + '-';
|
||||
const versions = all.filter((v) => v.startsWith(prefix)).map((v) => v.slice(prefix.length)).reverse();
|
||||
return versions.map((v, i) => ({ version: v, stable: i === 0 }));
|
||||
}
|
||||
if (loader === 'neoforge') {
|
||||
const xml = await fetchText(NEOFORGE_META);
|
||||
const all = [...xml.matchAll(/<version>([^<]+)<\/version>/g)].map((m) => m[1]);
|
||||
const prefix = neoforgePrefix(mcVersion);
|
||||
const versions = all.filter((v) => v.startsWith(prefix)).reverse();
|
||||
return versions.map((v, i) => ({ version: v, stable: i === 0 && !/beta/i.test(v) }));
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
// löst das Profil (mainClass, libraries, arguments) auf
|
||||
async function resolveLoaderProfile(loader, mcVersion, loaderVersion) {
|
||||
if (loader === 'fabric' || loader === 'quilt') {
|
||||
let lv = loaderVersion;
|
||||
if (!lv) {
|
||||
const list = await fetchJson(`${META[loader]}/versions/loader/${mcVersion}`);
|
||||
if (!list.length) throw new Error(`Kein ${loader}-Loader für Minecraft ${mcVersion} verfügbar.`);
|
||||
const stable = list.find((l) => l.loader && l.loader.stable) || list[0];
|
||||
lv = stable.loader.version;
|
||||
}
|
||||
const profile = await fetchJson(`${META[loader]}/versions/loader/${mcVersion}/${lv}/profile/json`);
|
||||
return { profile, loaderVersion: lv };
|
||||
}
|
||||
if (loader === 'forge' || loader === 'neoforge') {
|
||||
const e = new Error(
|
||||
`${loader === 'forge' ? 'Forge' : 'NeoForge'}-Installation ist noch in Arbeit ` +
|
||||
'(braucht Installer + Prozessoren). Fabric und Quilt funktionieren bereits vollständig.',
|
||||
);
|
||||
e.notImplemented = true;
|
||||
throw e;
|
||||
}
|
||||
throw new Error('Unbekannter Loader: ' + loader);
|
||||
}
|
||||
|
||||
module.exports = { resolveLoaderProfile, listLoaderVersions, mavenToUrl };
|
||||
614
src/main.js
Normal file
614
src/main.js
Normal file
@@ -0,0 +1,614 @@
|
||||
'use strict';
|
||||
|
||||
/*
|
||||
* main.js – Electron Hauptprozess für ViperCraft
|
||||
* Phase 1: Fenster, Instanz-Verwaltung, Einstellungen.
|
||||
* (Vanilla-Start, Microsoft-Login und Mod-Loader folgen in späteren Phasen.)
|
||||
*/
|
||||
|
||||
const { app, BrowserWindow, ipcMain, dialog, shell, safeStorage } = require('electron');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const { execFile } = require('child_process');
|
||||
const store = require('./store');
|
||||
const services = require('./services');
|
||||
const launcher = require('./launcher');
|
||||
const loaders = require('./loaders');
|
||||
const forge = require('./forge');
|
||||
const auth = require('./auth');
|
||||
|
||||
// undici als globales fetch verwenden -> ermöglicht Proxy via setGlobalDispatcher
|
||||
const undici = require('undici');
|
||||
globalThis.fetch = undici.fetch;
|
||||
globalThis.Headers = undici.Headers;
|
||||
globalThis.Request = undici.Request;
|
||||
globalThis.Response = undici.Response;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Automatische Überwachung der Anmelde-Dienste
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const AUTH_ENDPOINTS = [
|
||||
{ name: 'Microsoft-Login', url: 'https://login.microsoftonline.com/consumers/oauth2/v2.0/devicecode' },
|
||||
{ name: 'Xbox Live', url: 'https://user.auth.xboxlive.com/user/authenticate' },
|
||||
{ name: 'Minecraft-Dienste', url: 'https://api.minecraftservices.com/minecraft/profile' },
|
||||
];
|
||||
|
||||
let authHealth = { ok: true, checked: null, down: [] };
|
||||
|
||||
async function checkAuthServices() {
|
||||
const down = [];
|
||||
for (const ep of AUTH_ENDPOINTS) {
|
||||
try {
|
||||
const ctrl = new AbortController();
|
||||
const t = setTimeout(() => ctrl.abort(), 8000);
|
||||
// Antwort-Status ist egal (401/404 = Dienst lebt); nur Ausfall/5xx zählt als "down"
|
||||
const res = await fetch(ep.url, { method: 'GET', signal: ctrl.signal, headers: { 'User-Agent': 'AeroMC-Launcher/0.28' } });
|
||||
clearTimeout(t);
|
||||
if (res.status >= 500) down.push(ep.name);
|
||||
} catch {
|
||||
down.push(ep.name);
|
||||
}
|
||||
}
|
||||
authHealth = { ok: down.length === 0, checked: new Date().toISOString(), down };
|
||||
if (mainWindow && !mainWindow.isDestroyed()) mainWindow.webContents.send('auth:health', authHealth);
|
||||
return authHealth;
|
||||
}
|
||||
|
||||
function applyProxy(s) {
|
||||
try {
|
||||
if (s.proxyEnabled && s.proxyHost && String(s.proxyPort).trim()) {
|
||||
const cred = s.proxyUser ? `${encodeURIComponent(s.proxyUser)}:${encodeURIComponent(s.proxyPass || '')}@` : '';
|
||||
undici.setGlobalDispatcher(new undici.ProxyAgent(`http://${cred}${s.proxyHost}:${s.proxyPort}`));
|
||||
} else {
|
||||
undici.setGlobalDispatcher(new undici.Agent());
|
||||
}
|
||||
} catch { /* ungültige Proxy-Konfig ignorieren */ }
|
||||
}
|
||||
|
||||
let mainWindow = null;
|
||||
|
||||
function windowIcon() {
|
||||
const png = path.join(__dirname, 'assets', 'logo.png');
|
||||
const ico = path.join(__dirname, 'assets', 'icon.ico');
|
||||
if (fs.existsSync(ico)) return ico;
|
||||
if (fs.existsSync(png)) return png;
|
||||
return undefined; // Electron-Standard, solange kein Logo hinterlegt ist
|
||||
}
|
||||
|
||||
function createWindow() {
|
||||
mainWindow = new BrowserWindow({
|
||||
width: 1100,
|
||||
height: 720,
|
||||
minWidth: 880,
|
||||
minHeight: 560,
|
||||
backgroundColor: '#1b1e24',
|
||||
title: 'AeroMC',
|
||||
icon: windowIcon(),
|
||||
autoHideMenuBar: true,
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, 'preload.js'),
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
sandbox: false,
|
||||
},
|
||||
});
|
||||
|
||||
mainWindow.loadFile(path.join(__dirname, 'index.html'));
|
||||
}
|
||||
|
||||
app.whenReady().then(() => {
|
||||
store.init(app.getPath('userData'));
|
||||
// Konten verschlüsselt speichern (Windows-DPAPI, an das Benutzerkonto gebunden)
|
||||
auth.init(app.getPath('userData'), {
|
||||
available: () => { try { return safeStorage.isEncryptionAvailable(); } catch { return false; } },
|
||||
encrypt: (s) => safeStorage.encryptString(s).toString('base64'),
|
||||
decrypt: (b) => safeStorage.decryptString(Buffer.from(b, 'base64')),
|
||||
});
|
||||
applyProxy(store.getSettings());
|
||||
createWindow();
|
||||
|
||||
// Anmelde-Dienste automatisch überwachen (sofort + alle 2 Minuten)
|
||||
setTimeout(() => checkAuthServices(), 2500);
|
||||
setInterval(() => checkAuthServices(), 120000);
|
||||
|
||||
// Anmeldung im Hintergrund auffrischen -> kein erneuter Login beim Start nötig
|
||||
const cid = store.getSettings().azureClientId || auth.DEFAULT_CLIENT_ID;
|
||||
auth.getValidAccount(cid)
|
||||
.then((acc) => {
|
||||
if (acc && mainWindow && !mainWindow.isDestroyed()) mainWindow.webContents.send('auth:refreshed');
|
||||
})
|
||||
.catch(() => { /* offline o. Ä. – Konto bleibt gespeichert */ });
|
||||
|
||||
app.on('activate', () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) createWindow();
|
||||
});
|
||||
});
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
if (process.platform !== 'darwin') app.quit();
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Java-Erkennung (für Anzeige in den Einstellungen)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function detectJava(javaPath) {
|
||||
return new Promise((resolve) => {
|
||||
const cmd = javaPath && javaPath.trim() ? javaPath : 'java';
|
||||
execFile(cmd, ['-version'], (err, stdout, stderr) => {
|
||||
if (err) return resolve({ ok: false, path: cmd, version: null, error: err.message });
|
||||
// "java -version" schreibt nach stderr
|
||||
const out = (stderr || stdout || '').split('\n')[0].trim();
|
||||
resolve({ ok: true, path: cmd, version: out });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// IPC – Einstellungen
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
ipcMain.handle('settings:get', () => store.getSettings());
|
||||
|
||||
ipcMain.handle('settings:save', (_e, patch) => { const s = store.saveSettings(patch); applyProxy(s); return s; });
|
||||
|
||||
ipcMain.handle('settings:instancesDir', () => store.getInstancesDir());
|
||||
|
||||
ipcMain.handle('java:detect', (_e, javaPath) => detectJava(javaPath));
|
||||
|
||||
// automatische Java-Suche über das System
|
||||
ipcMain.handle('java:scan', () => services.scanJava());
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// IPC – Minecraft-Versionen (Mojang)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
ipcMain.handle('mc:versions', () => services.getMcVersions(app.getPath('userData')));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// IPC – Mods (Modrinth)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
ipcMain.handle('mods:search', (_e, opts) => services.modrinthSearch(opts));
|
||||
|
||||
ipcMain.handle('mods:list', (_e, id) => store.listMods(id));
|
||||
|
||||
ipcMain.handle('mods:install', async (_e, id, project) => {
|
||||
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.' };
|
||||
if (!loader || loader === 'vanilla') return { ok: false, message: 'Mods brauchen einen Mod-Loader (Fabric/Forge/NeoForge/Quilt).' };
|
||||
|
||||
const modsDir = store.modsDir(id);
|
||||
const done = new Set(store.listMods(id).map((m) => m.projectId));
|
||||
const added = [];
|
||||
let firstError = null;
|
||||
|
||||
// installiert ein Projekt und rekursiv seine PFLICHT-Abhängigkeiten
|
||||
async function installOne(proj, isDep) {
|
||||
if (done.has(proj.projectId)) return;
|
||||
done.add(proj.projectId);
|
||||
try {
|
||||
const res = await services.modrinthInstall({
|
||||
projectId: proj.projectId, title: proj.title, gameVersion, loader, modsDir,
|
||||
});
|
||||
if (!res.ok) {
|
||||
if (!isDep && !firstError) firstError = res.message;
|
||||
return;
|
||||
}
|
||||
store.addMod(id, res.entry);
|
||||
added.push(res.entry);
|
||||
for (const dep of res.dependencies || []) {
|
||||
if (dep.dependency_type === 'required' && dep.project_id && !done.has(dep.project_id)) {
|
||||
const title = await services.modrinthProjectTitle(dep.project_id);
|
||||
await installOne({ projectId: dep.project_id, title }, true);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (!isDep && !firstError) firstError = err.message;
|
||||
}
|
||||
}
|
||||
|
||||
await installOne(project, false);
|
||||
if (!added.length) return { ok: false, message: firstError || 'Installation fehlgeschlagen.' };
|
||||
return { ok: true, entry: added[0], added, depCount: added.length - 1 };
|
||||
});
|
||||
|
||||
// verfügbare Loader-Versionen (Fabric/Quilt) für eine MC-Version
|
||||
ipcMain.handle('loaders:versions', async (_e, loader, mcVersion) => {
|
||||
try { return await loaders.listLoaderVersions(loader, mcVersion); }
|
||||
catch { return []; }
|
||||
});
|
||||
|
||||
ipcMain.handle('mods:remove', (_e, id, filename) => store.removeMod(id, filename));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// IPC – Microsoft-Login (Phase 3)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
ipcMain.handle('auth:status', () => auth.currentAccount());
|
||||
ipcMain.handle('auth:health', () => authHealth);
|
||||
ipcMain.handle('auth:recheck', () => checkAuthServices());
|
||||
ipcMain.handle('auth:accounts', () => auth.listAccounts());
|
||||
ipcMain.handle('auth:setActive', (_e, id) => auth.setActive(id));
|
||||
ipcMain.handle('auth:remove', (_e, id) => auth.removeAccount(id));
|
||||
ipcMain.handle('auth:logout', () => { auth.clearAccount(); return true; });
|
||||
|
||||
ipcMain.handle('auth:login', async () => {
|
||||
const clientId = store.getSettings().azureClientId || auth.DEFAULT_CLIENT_ID;
|
||||
try {
|
||||
const account = await auth.login(
|
||||
clientId,
|
||||
(code) => {
|
||||
if (mainWindow && !mainWindow.isDestroyed()) mainWindow.webContents.send('auth:code', code);
|
||||
},
|
||||
() => {},
|
||||
);
|
||||
return { ok: true, account };
|
||||
} catch (err) {
|
||||
return { ok: false, message: err.message };
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// IPC – Instanzen
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
ipcMain.handle('instances:list', () => store.listInstances());
|
||||
|
||||
ipcMain.handle('instances:get', (_e, id) => store.getInstance(id));
|
||||
|
||||
ipcMain.handle('instances:create', (_e, data) => store.createInstance(data));
|
||||
|
||||
ipcMain.handle('instances:update', (_e, id, patch) => store.updateInstance(id, patch));
|
||||
|
||||
ipcMain.handle('instances:delete', (_e, id) => store.deleteInstance(id));
|
||||
|
||||
ipcMain.handle('instances:duplicate', (_e, id) => store.duplicateInstance(id));
|
||||
|
||||
ipcMain.handle('instances:openFolder', (_e, id) => {
|
||||
const p = store.instancePath(id);
|
||||
if (fs.existsSync(p)) shell.openPath(p);
|
||||
return true;
|
||||
});
|
||||
|
||||
ipcMain.handle('instances:openGameDir', (_e, id) => {
|
||||
const p = store.gameDir(id);
|
||||
if (!fs.existsSync(p)) fs.mkdirSync(p, { recursive: true });
|
||||
shell.openPath(p);
|
||||
return true;
|
||||
});
|
||||
|
||||
// ---- Welten & Ressourcenpakete ----
|
||||
|
||||
function listDir(dir, dirsOnly) {
|
||||
if (!fs.existsSync(dir)) return [];
|
||||
return fs.readdirSync(dir, { withFileTypes: true })
|
||||
.filter((e) => (dirsOnly ? e.isDirectory() : true) && !e.name.startsWith('.'))
|
||||
.map((e) => {
|
||||
let size = 0;
|
||||
try { size = fs.statSync(path.join(dir, e.name)).size; } catch { /* egal */ }
|
||||
return { name: e.name, isDir: e.isDirectory(), size };
|
||||
});
|
||||
}
|
||||
|
||||
ipcMain.handle('instance:worlds', (_e, id) => listDir(path.join(store.gameDir(id), 'saves'), true));
|
||||
ipcMain.handle('instance:resourcepacks', (_e, id) => listDir(path.join(store.gameDir(id), 'resourcepacks'), false));
|
||||
|
||||
function safeSub(id, sub, name) {
|
||||
const base = path.join(store.gameDir(id), sub);
|
||||
const target = path.join(base, name);
|
||||
// Pfad-Ausbruch verhindern
|
||||
if (!target.startsWith(base + path.sep)) return null;
|
||||
return target;
|
||||
}
|
||||
|
||||
ipcMain.handle('instance:deleteContent', (_e, id, sub, name) => {
|
||||
const target = safeSub(id, sub, name);
|
||||
if (target && fs.existsSync(target)) fs.rmSync(target, { recursive: true, force: true });
|
||||
return true;
|
||||
});
|
||||
|
||||
ipcMain.handle('instance:openSubdir', (_e, id, sub) => {
|
||||
const p = path.join(store.gameDir(id), sub);
|
||||
if (!fs.existsSync(p)) fs.mkdirSync(p, { recursive: true });
|
||||
shell.openPath(p);
|
||||
return true;
|
||||
});
|
||||
|
||||
// ---- Screenshots ----
|
||||
|
||||
ipcMain.handle('instance:screenshots', (_e, id) =>
|
||||
listDir(path.join(store.gameDir(id), 'screenshots'), false)
|
||||
.filter((f) => /\.(png|jpe?g|gif|webp)$/i.test(f.name))
|
||||
.sort((a, b) => b.name.localeCompare(a.name)));
|
||||
|
||||
ipcMain.handle('instance:screenshotData', (_e, id, name) => {
|
||||
const target = safeSub(id, 'screenshots', name);
|
||||
try {
|
||||
if (!target || !fs.existsSync(target)) return null;
|
||||
if (fs.statSync(target).size > 12 * 1024 * 1024) return null;
|
||||
let ext = path.extname(target).slice(1).toLowerCase();
|
||||
if (ext === 'jpg') ext = 'jpeg';
|
||||
return `data:image/${ext};base64,` + fs.readFileSync(target).toString('base64');
|
||||
} catch { return null; }
|
||||
});
|
||||
|
||||
ipcMain.handle('instance:openScreenshot', (_e, id, name) => {
|
||||
const target = safeSub(id, 'screenshots', name);
|
||||
if (target && fs.existsSync(target)) shell.openPath(target);
|
||||
return true;
|
||||
});
|
||||
|
||||
// ---- Instanz Import/Export (ZIP über Windows-Bordmittel) ----
|
||||
|
||||
function runPwsh(command, env) {
|
||||
return new Promise((resolve, reject) => {
|
||||
execFile('powershell', ['-NoProfile', '-NonInteractive', '-Command', command],
|
||||
{ env: Object.assign({}, process.env, env), windowsHide: true, maxBuffer: 64 * 1024 * 1024 },
|
||||
(err, stdout, stderr) => (err ? reject(new Error(stderr || err.message)) : resolve(stdout)));
|
||||
});
|
||||
}
|
||||
|
||||
ipcMain.handle('instances:export', async (_e, id) => {
|
||||
const inst = store.getInstance(id);
|
||||
if (!inst) return { ok: false, message: 'Instanz nicht gefunden.' };
|
||||
const safe = (inst.name || 'instanz').replace(/[^\w.-]+/g, '_');
|
||||
const res = await dialog.showSaveDialog(mainWindow, {
|
||||
title: 'Instanz exportieren',
|
||||
defaultPath: safe + '.zip',
|
||||
filters: [{ name: 'ZIP-Archiv', extensions: ['zip'] }],
|
||||
});
|
||||
if (res.canceled) return { ok: false, canceled: true };
|
||||
try {
|
||||
if (fs.existsSync(res.filePath)) fs.unlinkSync(res.filePath);
|
||||
await runPwsh("Compress-Archive -Path (Join-Path $env:VN_SRC '*') -DestinationPath $env:VN_DST -Force",
|
||||
{ VN_SRC: store.instancePath(id), VN_DST: res.filePath });
|
||||
return { ok: true, path: res.filePath };
|
||||
} catch (err) {
|
||||
return { ok: false, message: 'Export fehlgeschlagen: ' + err.message };
|
||||
}
|
||||
});
|
||||
|
||||
// ---- Modpack-Import (Modrinth .mrpack) ----
|
||||
|
||||
async function importMrpack(buf, zip) {
|
||||
const raw = forge.readZipEntry(buf, zip['modrinth.index.json']).toString('utf8').replace(/^/, '');
|
||||
const index = JSON.parse(raw);
|
||||
const deps = index.dependencies || {};
|
||||
let loader = 'vanilla', loaderVersion = '';
|
||||
if (deps['fabric-loader']) { loader = 'fabric'; loaderVersion = deps['fabric-loader']; }
|
||||
else if (deps['quilt-loader']) { loader = 'quilt'; loaderVersion = deps['quilt-loader']; }
|
||||
else if (deps.forge) { loader = 'forge'; loaderVersion = deps.forge; }
|
||||
else if (deps.neoforge) { loader = 'neoforge'; loaderVersion = deps.neoforge; }
|
||||
|
||||
const created = store.createInstance({
|
||||
name: index.name || 'Modpack',
|
||||
minecraft: { version: deps.minecraft || '', loader, loaderVersion },
|
||||
});
|
||||
const gameDir = store.gameDir(created.id);
|
||||
const inside = (dest) => dest.startsWith(gameDir + path.sep);
|
||||
|
||||
const files = index.files || [];
|
||||
let i = 0;
|
||||
for (const f of files) {
|
||||
i++;
|
||||
if (f.env && f.env.client === 'unsupported') continue;
|
||||
const url = f.downloads && f.downloads[0];
|
||||
if (!url || !f.path) continue;
|
||||
const dest = path.join(gameDir, f.path.replace(/\//g, path.sep));
|
||||
if (!inside(dest)) continue;
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
mainWindow.webContents.send('launch:progress', { phase: 'modpack', current: i, total: files.length, detail: 'Modpack-Dateien' });
|
||||
}
|
||||
const res = await fetch(url, { headers: { 'User-Agent': 'AeroMC-Launcher/0.11' } });
|
||||
if (!res.ok) continue;
|
||||
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
||||
fs.writeFileSync(dest, Buffer.from(await res.arrayBuffer()));
|
||||
}
|
||||
|
||||
// overrides einspielen
|
||||
for (const prefix of ['overrides/', 'client-overrides/']) {
|
||||
for (const name of Object.keys(zip)) {
|
||||
if (name.startsWith(prefix) && !name.endsWith('/')) {
|
||||
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: files.length };
|
||||
}
|
||||
|
||||
ipcMain.handle('modpack:import', async () => {
|
||||
const res = await dialog.showOpenDialog(mainWindow, {
|
||||
title: 'Modpack importieren',
|
||||
properties: ['openFile'],
|
||||
filters: [{ name: 'Modpacks', extensions: ['mrpack', 'zip'] }],
|
||||
});
|
||||
if (res.canceled) return { ok: false, canceled: true };
|
||||
try {
|
||||
const buf = fs.readFileSync(res.filePaths[0]);
|
||||
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.' };
|
||||
}
|
||||
return { ok: false, message: 'Unbekanntes Modpack-Format (keine modrinth.index.json gefunden).' };
|
||||
} catch (err) {
|
||||
return { ok: false, message: 'Modpack-Import fehlgeschlagen: ' + err.message };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('instances:import', async () => {
|
||||
const res = await dialog.showOpenDialog(mainWindow, {
|
||||
title: 'Instanz importieren',
|
||||
properties: ['openFile'],
|
||||
filters: [{ name: 'ZIP-Archiv', extensions: ['zip'] }],
|
||||
});
|
||||
if (res.canceled) return { ok: false, canceled: true };
|
||||
const tmp = path.join(app.getPath('temp'), 'vn-import-' + Date.now());
|
||||
try {
|
||||
fs.mkdirSync(tmp, { recursive: true });
|
||||
await runPwsh('Expand-Archive -Path $env:VN_SRC -DestinationPath $env:VN_DST -Force',
|
||||
{ VN_SRC: res.filePaths[0], VN_DST: tmp });
|
||||
|
||||
// instance.json finden (auch in Unterordner)
|
||||
let base = tmp;
|
||||
if (!fs.existsSync(path.join(base, 'instance.json'))) {
|
||||
const sub = fs.readdirSync(tmp).map((n) => path.join(tmp, n))
|
||||
.find((p) => { try { return fs.statSync(p).isDirectory() && fs.existsSync(path.join(p, 'instance.json')); } catch { return false; } });
|
||||
if (sub) base = sub;
|
||||
}
|
||||
if (!fs.existsSync(path.join(base, 'instance.json'))) {
|
||||
throw new Error('Keine gültige Instanz im Archiv (instance.json fehlt).');
|
||||
}
|
||||
const imported = JSON.parse(fs.readFileSync(path.join(base, 'instance.json'), 'utf8'));
|
||||
const created = store.createInstance({
|
||||
name: (imported.name || 'Import') + ' (Import)',
|
||||
icon: imported.icon || 'grass',
|
||||
notes: imported.notes || '',
|
||||
minecraft: imported.minecraft || {},
|
||||
});
|
||||
if (imported.java) store.updateInstance(created.id, { java: imported.java });
|
||||
|
||||
const mcSrc = path.join(base, 'minecraft');
|
||||
if (fs.existsSync(mcSrc)) fs.cpSync(mcSrc, store.gameDir(created.id), { recursive: true });
|
||||
const modsSrc = path.join(base, 'mods.json');
|
||||
if (fs.existsSync(modsSrc)) fs.copyFileSync(modsSrc, path.join(store.instancePath(created.id), 'mods.json'));
|
||||
if (imported.customImage && fs.existsSync(path.join(base, imported.customImage))) {
|
||||
fs.copyFileSync(path.join(base, imported.customImage), path.join(store.instancePath(created.id), imported.customImage));
|
||||
store.updateInstance(created.id, { customImage: imported.customImage });
|
||||
}
|
||||
return { ok: true, instance: store.getInstance(created.id) };
|
||||
} catch (err) {
|
||||
return { ok: false, message: 'Import fehlgeschlagen: ' + err.message };
|
||||
} finally {
|
||||
try { fs.rmSync(tmp, { recursive: true, force: true }); } catch { /* egal */ }
|
||||
}
|
||||
});
|
||||
|
||||
// Phase 2: echter Vanilla-Download & -Start (Start selbst ist an Login gebunden).
|
||||
ipcMain.handle('instances:launch', async (_e, id, accountId) => {
|
||||
const inst = store.getInstance(id);
|
||||
if (!inst) return { ok: false, message: 'Instanz nicht gefunden.' };
|
||||
if (!inst.minecraft.version) {
|
||||
return { ok: false, message: 'Bitte zuerst eine Minecraft-Version wählen (Bearbeiten).' };
|
||||
}
|
||||
const settings = store.getSettings();
|
||||
const opts = {
|
||||
versionId: inst.minecraft.version,
|
||||
loader: inst.minecraft.loader || 'vanilla',
|
||||
loaderVersion: inst.minecraft.loaderVersion || '',
|
||||
gameDir: store.gameDir(id),
|
||||
sharedDir: path.join(app.getPath('userData'), 'shared'),
|
||||
userDataDir: app.getPath('userData'),
|
||||
javaPath: inst.java.path || settings.javaPath || '',
|
||||
minMemMb: inst.java.minMemMb || settings.defaultMinMemMb,
|
||||
maxMemMb: inst.java.maxMemMb || settings.defaultMaxMemMb,
|
||||
extraArgs: inst.java.extraArgs || '',
|
||||
preLaunchCommand: inst.preLaunchCommand || settings.globalPreLaunch || '',
|
||||
postExitCommand: inst.postExitCommand || settings.globalPostExit || '',
|
||||
wrapperCommand: settings.wrapperCommand || '',
|
||||
auth: null,
|
||||
};
|
||||
|
||||
// eingeloggtes Microsoft-Konto verwenden (gewähltes oder aktives) -> echter Start
|
||||
const clientId = settings.azureClientId || auth.DEFAULT_CLIENT_ID;
|
||||
const account = accountId
|
||||
? await auth.getValidAccountById(clientId, accountId)
|
||||
: await auth.getValidAccount(clientId);
|
||||
if (account && account.profile) {
|
||||
opts.auth = {
|
||||
name: account.profile.name,
|
||||
uuid: account.profile.id,
|
||||
accessToken: account.mcAccessToken,
|
||||
userType: 'msa',
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
let launchStart = 0;
|
||||
const res = await launcher.prepareAndLaunch(opts, (evt) => {
|
||||
if (evt.phase === 'launch') launchStart = Date.now();
|
||||
if (evt.phase === 'exit' && launchStart) {
|
||||
const secs = Math.max(0, Math.round((Date.now() - launchStart) / 1000));
|
||||
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));
|
||||
}
|
||||
});
|
||||
if (res.ok) store.updateInstance(id, { lastPlayed: new Date().toISOString() });
|
||||
return res;
|
||||
} catch (err) {
|
||||
return { ok: false, message: 'Start fehlgeschlagen: ' + err.message };
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// IPC – Dialoge
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
ipcMain.handle('dialog:pickFolder', async (_e, title) => {
|
||||
const res = await dialog.showOpenDialog(mainWindow, {
|
||||
title: title || 'Ordner wählen',
|
||||
properties: ['openDirectory', 'createDirectory'],
|
||||
});
|
||||
return res.canceled ? null : res.filePaths[0];
|
||||
});
|
||||
|
||||
ipcMain.handle('dialog:pickImage', async () => {
|
||||
const res = await dialog.showOpenDialog(mainWindow, {
|
||||
title: 'Instanz-Bild wählen',
|
||||
properties: ['openFile'],
|
||||
filters: [{ name: 'Bilder', extensions: ['png', 'jpg', 'jpeg', 'gif', 'webp'] }],
|
||||
});
|
||||
return res.canceled ? null : res.filePaths[0];
|
||||
});
|
||||
|
||||
// eigenes Bild einer Instanz zuweisen / entfernen
|
||||
ipcMain.handle('instances:setImage', (_e, id, sourcePath) => {
|
||||
store.setCustomImage(id, sourcePath);
|
||||
return { meta: store.getInstance(id), iconData: store.iconDataUri(id) };
|
||||
});
|
||||
ipcMain.handle('instances:clearImage', (_e, id) => store.clearCustomImage(id));
|
||||
ipcMain.handle('instances:icon', (_e, id) => store.iconDataUri(id));
|
||||
|
||||
// beliebige Bilddatei als data:-URI (für Vorschau vor dem Speichern)
|
||||
ipcMain.handle('file:dataUri', (_e, filePath) => {
|
||||
try {
|
||||
const stat = fs.statSync(filePath);
|
||||
if (stat.size > 8 * 1024 * 1024) return null; // >8 MB nicht als Vorschau
|
||||
let ext = path.extname(filePath).slice(1).toLowerCase();
|
||||
if (ext === 'jpg') ext = 'jpeg';
|
||||
const buf = fs.readFileSync(filePath);
|
||||
return `data:image/${ext};base64,` + buf.toString('base64');
|
||||
} catch { return null; }
|
||||
});
|
||||
|
||||
ipcMain.handle('dialog:pickJava', async () => {
|
||||
const res = await dialog.showOpenDialog(mainWindow, {
|
||||
title: 'java.exe wählen',
|
||||
properties: ['openFile'],
|
||||
filters: [{ name: 'Java', extensions: ['exe'] }],
|
||||
});
|
||||
return res.canceled ? null : res.filePaths[0];
|
||||
});
|
||||
|
||||
ipcMain.handle('app:openExternal', (_e, url) => {
|
||||
if (typeof url === 'string' && /^https?:\/\//.test(url)) shell.openExternal(url);
|
||||
return true;
|
||||
});
|
||||
|
||||
ipcMain.handle('app:info', () => ({
|
||||
version: app.getVersion(),
|
||||
electron: process.versions.electron,
|
||||
node: process.versions.node,
|
||||
userData: app.getPath('userData'),
|
||||
}));
|
||||
93
src/preload.js
Normal file
93
src/preload.js
Normal file
@@ -0,0 +1,93 @@
|
||||
'use strict';
|
||||
|
||||
/*
|
||||
* preload.js – stellt dem Renderer eine schmale, sichere API bereit.
|
||||
* contextIsolation ist aktiv; der Renderer hat KEINEN direkten Node-Zugriff.
|
||||
*/
|
||||
|
||||
const { contextBridge, ipcRenderer } = require('electron');
|
||||
|
||||
contextBridge.exposeInMainWorld('api', {
|
||||
// Einstellungen
|
||||
getSettings: () => ipcRenderer.invoke('settings:get'),
|
||||
saveSettings: (patch) => ipcRenderer.invoke('settings:save', patch),
|
||||
getInstancesDir: () => ipcRenderer.invoke('settings:instancesDir'),
|
||||
detectJava: (javaPath) => ipcRenderer.invoke('java:detect', javaPath),
|
||||
scanJava: () => ipcRenderer.invoke('java:scan'),
|
||||
|
||||
// Minecraft-Versionen (Mojang)
|
||||
mcVersions: () => ipcRenderer.invoke('mc:versions'),
|
||||
loaderVersions: (loader, mcVersion) => ipcRenderer.invoke('loaders:versions', loader, mcVersion),
|
||||
|
||||
// Mods (Modrinth)
|
||||
modSearch: (opts) => ipcRenderer.invoke('mods:search', opts),
|
||||
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),
|
||||
|
||||
// Instanzen
|
||||
listInstances: () => ipcRenderer.invoke('instances:list'),
|
||||
getInstance: (id) => ipcRenderer.invoke('instances:get', id),
|
||||
createInstance: (data) => ipcRenderer.invoke('instances:create', data),
|
||||
updateInstance: (id, patch) => ipcRenderer.invoke('instances:update', id, patch),
|
||||
deleteInstance: (id) => ipcRenderer.invoke('instances:delete', id),
|
||||
duplicateInstance: (id) => ipcRenderer.invoke('instances:duplicate', id),
|
||||
openInstanceFolder: (id) => ipcRenderer.invoke('instances:openFolder', id),
|
||||
openGameDir: (id) => ipcRenderer.invoke('instances:openGameDir', id),
|
||||
|
||||
// Welten & Ressourcenpakete
|
||||
worlds: (id) => ipcRenderer.invoke('instance:worlds', id),
|
||||
resourcepacks: (id) => ipcRenderer.invoke('instance:resourcepacks', id),
|
||||
deleteContent: (id, sub, name) => ipcRenderer.invoke('instance:deleteContent', id, sub, name),
|
||||
openSubdir: (id, sub) => ipcRenderer.invoke('instance:openSubdir', id, sub),
|
||||
screenshots: (id) => ipcRenderer.invoke('instance:screenshots', id),
|
||||
screenshotData: (id, name) => ipcRenderer.invoke('instance:screenshotData', id, name),
|
||||
openScreenshot: (id, name) => ipcRenderer.invoke('instance:openScreenshot', id, name),
|
||||
launchInstance: (id, accountId) => ipcRenderer.invoke('instances:launch', id, accountId),
|
||||
exportInstance: (id) => ipcRenderer.invoke('instances:export', id),
|
||||
importInstance: () => ipcRenderer.invoke('instances:import'),
|
||||
importModpack: () => ipcRenderer.invoke('modpack:import'),
|
||||
onLaunchProgress: (cb) => {
|
||||
const listener = (_e, data) => cb(data);
|
||||
ipcRenderer.on('launch:progress', listener);
|
||||
return () => ipcRenderer.removeListener('launch:progress', listener);
|
||||
},
|
||||
|
||||
// eigenes Instanz-Bild
|
||||
pickImage: () => ipcRenderer.invoke('dialog:pickImage'),
|
||||
setInstanceImage: (id, sourcePath) => ipcRenderer.invoke('instances:setImage', id, sourcePath),
|
||||
clearInstanceImage: (id) => ipcRenderer.invoke('instances:clearImage', id),
|
||||
instanceIcon: (id) => ipcRenderer.invoke('instances:icon', id),
|
||||
fileDataUri: (p) => ipcRenderer.invoke('file:dataUri', p),
|
||||
|
||||
// Microsoft-Login (Phase 3) + Mehrkonten
|
||||
authStatus: () => ipcRenderer.invoke('auth:status'),
|
||||
authAccounts: () => ipcRenderer.invoke('auth:accounts'),
|
||||
authSetActive: (id) => ipcRenderer.invoke('auth:setActive', id),
|
||||
authRemove: (id) => ipcRenderer.invoke('auth:remove', id),
|
||||
authLogin: () => ipcRenderer.invoke('auth:login'),
|
||||
authLogout: () => ipcRenderer.invoke('auth:logout'),
|
||||
authHealth: () => ipcRenderer.invoke('auth:health'),
|
||||
authRecheck: () => ipcRenderer.invoke('auth:recheck'),
|
||||
onAuthHealth: (cb) => {
|
||||
const listener = (_e, data) => cb(data);
|
||||
ipcRenderer.on('auth:health', listener);
|
||||
return () => ipcRenderer.removeListener('auth:health', listener);
|
||||
},
|
||||
onAuthRefreshed: (cb) => {
|
||||
const listener = () => cb();
|
||||
ipcRenderer.on('auth:refreshed', listener);
|
||||
return () => ipcRenderer.removeListener('auth:refreshed', listener);
|
||||
},
|
||||
onAuthCode: (cb) => {
|
||||
const listener = (_e, data) => cb(data);
|
||||
ipcRenderer.on('auth:code', listener);
|
||||
return () => ipcRenderer.removeListener('auth:code', listener);
|
||||
},
|
||||
|
||||
// Dialoge / App
|
||||
openExternal: (url) => ipcRenderer.invoke('app:openExternal', url),
|
||||
pickFolder: (title) => ipcRenderer.invoke('dialog:pickFolder', title),
|
||||
pickJava: () => ipcRenderer.invoke('dialog:pickJava'),
|
||||
appInfo: () => ipcRenderer.invoke('app:info'),
|
||||
});
|
||||
1488
src/renderer.js
Normal file
1488
src/renderer.js
Normal file
File diff suppressed because it is too large
Load Diff
263
src/services.js
Normal file
263
src/services.js
Normal file
@@ -0,0 +1,263 @@
|
||||
'use strict';
|
||||
|
||||
/*
|
||||
* services.js – externe Dienste & Systemabfragen für AeroMC
|
||||
* ----------------------------------------------------------------
|
||||
* Bewusst frei von Electron-Abhängigkeiten, damit headless (mit `node`)
|
||||
* testbar. Nutzt globales fetch (Node 18+ / Electron 28).
|
||||
*
|
||||
* - scanJava() : findet installierte Java-Runtimes
|
||||
* - getMcVersions() : Mojang-Versionsliste (mit Datei-Cache)
|
||||
* - modrinthSearch() : Mod-Suche
|
||||
* - modrinthInstall() : passende Mod-Version in einen Ordner laden
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const { execFileSync, spawnSync } = require('child_process');
|
||||
|
||||
const UA = 'AeroMC-Launcher/0.2 (Minecraft-Launcher; contact: viper)';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Java-Erkennung
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function javaCandidateDirs() {
|
||||
const pf = process.env['ProgramFiles'] || 'C:\\Program Files';
|
||||
const pfx = process.env['ProgramFiles(x86)'] || 'C:\\Program Files (x86)';
|
||||
const appdata = process.env['APPDATA'] || '';
|
||||
const localappdata = process.env['LOCALAPPDATA'] || '';
|
||||
const vendors = ['Java', 'Eclipse Adoptium', 'Eclipse Foundation', 'Zulu', 'Amazon Corretto',
|
||||
'BellSoft', 'Microsoft', 'AdoptOpenJDK', 'Semeru', 'JetBrains'];
|
||||
const roots = [];
|
||||
for (const base of [pf, pfx]) {
|
||||
for (const v of vendors) roots.push(path.join(base, v));
|
||||
}
|
||||
// Runtimes der offiziellen Minecraft-Launcher
|
||||
if (appdata) roots.push(path.join(appdata, '.minecraft', 'runtime'));
|
||||
if (localappdata) roots.push(path.join(localappdata, 'Packages'));
|
||||
return roots;
|
||||
}
|
||||
|
||||
// findet rekursiv (begrenzte Tiefe) alle java.exe unter einem Startordner
|
||||
function findJavaExes(root, depth, acc) {
|
||||
if (depth < 0 || !fs.existsSync(root)) return;
|
||||
let entries;
|
||||
try { entries = fs.readdirSync(root, { withFileTypes: true }); } catch { return; }
|
||||
for (const e of entries) {
|
||||
const full = path.join(root, e.name);
|
||||
if (e.isFile() && /^javaw?\.exe$/i.test(e.name)) {
|
||||
// bevorzugt java.exe (Konsole) statt javaw.exe
|
||||
if (/^java\.exe$/i.test(e.name)) acc.add(full);
|
||||
} else if (e.isDirectory()) {
|
||||
// Tiefe begrenzen; typische Struktur .../<jdk>/bin/java.exe
|
||||
findJavaExes(full, depth - 1, acc);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function probeJava(javaPath) {
|
||||
// `java -version` schreibt nach stderr; spawnSync liefert stdout UND stderr.
|
||||
try {
|
||||
const r = spawnSync(javaPath, ['-version'], { encoding: 'utf8', timeout: 5000 });
|
||||
const out = (r.stderr || '') + (r.stdout || '');
|
||||
if (!out.trim()) return null;
|
||||
return parseJavaVersion(javaPath, out);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function parseJavaVersion(javaPath, out) {
|
||||
const m = out.match(/version "([^"]+)"/i);
|
||||
const raw = m ? m[1] : '';
|
||||
let major = 0;
|
||||
if (raw) {
|
||||
const parts = raw.split('.');
|
||||
major = raw.startsWith('1.') ? parseInt(parts[1], 10) : parseInt(parts[0], 10);
|
||||
}
|
||||
const arch = /64-bit/i.test(out) ? 'x64' : (/32-bit|Client VM/i.test(out) ? 'x86' : '?');
|
||||
const vendorLine = out.split('\n').map((l) => l.trim()).find((l) => /runtime|openjdk|hotspot|jre|jdk/i.test(l)) || '';
|
||||
return { path: javaPath, version: raw || 'unbekannt', major: major || 0, arch, info: vendorLine };
|
||||
}
|
||||
|
||||
function scanJava() {
|
||||
const found = new Set();
|
||||
|
||||
// 1) JAVA_HOME
|
||||
if (process.env.JAVA_HOME) {
|
||||
const p = path.join(process.env.JAVA_HOME, 'bin', 'java.exe');
|
||||
if (fs.existsSync(p)) found.add(p);
|
||||
}
|
||||
// 2) java aus PATH
|
||||
try {
|
||||
const where = execFileSync('where', ['java'], { encoding: 'utf8', timeout: 4000 });
|
||||
where.split(/\r?\n/).forEach((l) => { l = l.trim(); if (l && /java\.exe$/i.test(l) && fs.existsSync(l)) found.add(l); });
|
||||
} catch { /* nix */ }
|
||||
// 3) bekannte Vendor-Ordner
|
||||
for (const root of javaCandidateDirs()) findJavaExes(root, 4, found);
|
||||
|
||||
// prüfen & dedupen nach Version+Pfad
|
||||
const results = [];
|
||||
for (const p of found) {
|
||||
const info = probeJava(p);
|
||||
if (info) results.push(info);
|
||||
}
|
||||
// sortieren: 64-bit vor 32-bit, höhere Major-Version zuerst
|
||||
results.sort((a, b) => {
|
||||
if ((b.arch === 'x64') - (a.arch === 'x64') !== 0) return (b.arch === 'x64') - (a.arch === 'x64');
|
||||
return b.major - a.major;
|
||||
});
|
||||
return results;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mojang-Versionsliste (mit Cache)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const MC_MANIFEST_URL = 'https://piston-meta.mojang.com/mc/game/version_manifest_v2.json';
|
||||
|
||||
async function getMcVersions(cacheDir, maxAgeMs = 6 * 3600 * 1000) {
|
||||
const cacheFile = path.join(cacheDir, 'mc-versions.json');
|
||||
// Cache frisch genug?
|
||||
try {
|
||||
const st = fs.statSync(cacheFile);
|
||||
if (Date.now() - st.mtimeMs < maxAgeMs) {
|
||||
return Object.assign({ cached: true }, JSON.parse(fs.readFileSync(cacheFile, 'utf8')));
|
||||
}
|
||||
} catch { /* kein Cache */ }
|
||||
|
||||
try {
|
||||
const res = await fetch(MC_MANIFEST_URL, { headers: { 'User-Agent': UA } });
|
||||
if (!res.ok) throw new Error('HTTP ' + res.status);
|
||||
const data = await res.json();
|
||||
const slim = {
|
||||
latest: data.latest,
|
||||
versions: data.versions.map((v) => ({ id: v.id, type: v.type, releaseTime: v.releaseTime, url: v.url, sha1: v.sha1 })),
|
||||
fetchedAt: new Date().toISOString(),
|
||||
};
|
||||
try { fs.mkdirSync(cacheDir, { recursive: true }); fs.writeFileSync(cacheFile, JSON.stringify(slim)); } catch { /* egal */ }
|
||||
return Object.assign({ cached: false }, slim);
|
||||
} catch (err) {
|
||||
// offline: alten Cache nehmen, falls vorhanden
|
||||
try {
|
||||
const old = JSON.parse(fs.readFileSync(cacheFile, 'utf8'));
|
||||
return Object.assign({ cached: true, offline: true }, old);
|
||||
} catch {
|
||||
return { offline: true, error: err.message, latest: {}, versions: [] };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Modrinth (Mod-Suche & Installation)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const MODRINTH = 'https://api.modrinth.com/v2';
|
||||
|
||||
const MODRINTH_SORTS = ['relevance', 'downloads', 'follows', 'newest', 'updated'];
|
||||
|
||||
async function modrinthSearch({ query = '', gameVersion = '', loader = '', category = '', sort = 'relevance', limit = 30, offset = 0 } = {}) {
|
||||
const facets = [['project_type:mod']];
|
||||
if (gameVersion) facets.push(['versions:' + gameVersion]);
|
||||
if (loader && loader !== 'vanilla') facets.push(['categories:' + loader]);
|
||||
if (category) facets.push(['categories:' + category]);
|
||||
const url = new URL(MODRINTH + '/search');
|
||||
url.searchParams.set('query', query);
|
||||
url.searchParams.set('limit', String(limit));
|
||||
url.searchParams.set('offset', String(offset));
|
||||
url.searchParams.set('index', MODRINTH_SORTS.includes(sort) ? sort : 'relevance');
|
||||
url.searchParams.set('facets', JSON.stringify(facets));
|
||||
|
||||
const res = await fetch(url, { headers: { 'User-Agent': UA } });
|
||||
if (!res.ok) throw new Error('Modrinth-Suche fehlgeschlagen: HTTP ' + res.status);
|
||||
const data = await res.json();
|
||||
return {
|
||||
total: data.total_hits,
|
||||
hits: (data.hits || []).map((h) => ({
|
||||
projectId: h.project_id,
|
||||
slug: h.slug,
|
||||
title: h.title,
|
||||
description: h.description,
|
||||
author: h.author,
|
||||
downloads: h.downloads,
|
||||
iconUrl: h.icon_url || '',
|
||||
categories: h.categories || [],
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
// findet die passende Datei-Version für Spielversion + Loader
|
||||
async function modrinthResolveFile({ projectId, gameVersion, loader }) {
|
||||
const url = new URL(MODRINTH + '/project/' + projectId + '/version');
|
||||
if (gameVersion) url.searchParams.set('game_versions', JSON.stringify([gameVersion]));
|
||||
if (loader && loader !== 'vanilla') url.searchParams.set('loaders', JSON.stringify([loader]));
|
||||
const res = await fetch(url, { headers: { 'User-Agent': UA } });
|
||||
if (!res.ok) throw new Error('Versionsabruf fehlgeschlagen: HTTP ' + res.status);
|
||||
const versions = await res.json();
|
||||
if (!versions.length) return null;
|
||||
const v = versions[0]; // neueste passende
|
||||
const file = v.files.find((f) => f.primary) || v.files[0];
|
||||
if (!file) return null;
|
||||
return {
|
||||
versionId: v.id,
|
||||
versionNumber: v.version_number,
|
||||
filename: file.filename,
|
||||
url: file.url,
|
||||
sha1: file.hashes && file.hashes.sha1,
|
||||
size: file.size,
|
||||
dependencies: v.dependencies || [],
|
||||
};
|
||||
}
|
||||
|
||||
async function downloadFile(url, destPath) {
|
||||
const res = await fetch(url, { headers: { 'User-Agent': UA } });
|
||||
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 });
|
||||
fs.writeFileSync(destPath, buf);
|
||||
return buf.length;
|
||||
}
|
||||
|
||||
// lädt eine Mod in <modsDir>; gibt Metadaten für die Instanz zurück
|
||||
async function modrinthInstall({ projectId, title, gameVersion, loader, modsDir }) {
|
||||
const file = await modrinthResolveFile({ projectId, gameVersion, loader });
|
||||
if (!file) {
|
||||
return { ok: false, reason: 'no-match',
|
||||
message: `Keine passende Version für ${gameVersion} / ${loader} gefunden.` };
|
||||
}
|
||||
const dest = path.join(modsDir, file.filename);
|
||||
const bytes = await downloadFile(file.url, dest);
|
||||
return {
|
||||
ok: true,
|
||||
dependencies: file.dependencies || [],
|
||||
entry: {
|
||||
projectId,
|
||||
title: title || projectId,
|
||||
filename: file.filename,
|
||||
versionId: file.versionId,
|
||||
versionNumber: file.versionNumber,
|
||||
gameVersion,
|
||||
loader,
|
||||
installedAt: new Date().toISOString(),
|
||||
bytes,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Projekttitel nachschlagen (für Abhängigkeiten, die nur eine ID liefern)
|
||||
async function modrinthProjectTitle(projectId) {
|
||||
try {
|
||||
const res = await fetch(`${MODRINTH}/project/${projectId}`, { headers: { 'User-Agent': UA } });
|
||||
if (!res.ok) return projectId;
|
||||
const data = await res.json();
|
||||
return data.title || projectId;
|
||||
} catch { return projectId; }
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
scanJava, probeJava,
|
||||
getMcVersions,
|
||||
modrinthSearch, modrinthResolveFile, modrinthInstall, modrinthProjectTitle, downloadFile,
|
||||
};
|
||||
308
src/store.js
Normal file
308
src/store.js
Normal file
@@ -0,0 +1,308 @@
|
||||
'use strict';
|
||||
|
||||
/*
|
||||
* store.js – Persistenz für AeroMC
|
||||
* ------------------------------------
|
||||
* Jede Instanz ist ein isolierter Ordner (MultiMC-Prinzip):
|
||||
* <instancesDir>/<id>/instance.json – Metadaten
|
||||
* <instancesDir>/<id>/minecraft/ – das eigentliche Spielverzeichnis (.minecraft-Ersatz)
|
||||
*
|
||||
* Globale Einstellungen liegen in <userData>/settings.json.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
|
||||
let USER_DATA = null; // wird von main.js via init() gesetzt
|
||||
|
||||
function init(userDataPath) {
|
||||
USER_DATA = userDataPath;
|
||||
ensureDir(USER_DATA);
|
||||
ensureDir(getInstancesDir());
|
||||
}
|
||||
|
||||
// ---------- kleine Helfer ----------
|
||||
|
||||
function ensureDir(dir) {
|
||||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
function readJson(file, fallback) {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function writeJson(file, data) {
|
||||
ensureDir(path.dirname(file));
|
||||
fs.writeFileSync(file, JSON.stringify(data, null, 2), 'utf8');
|
||||
}
|
||||
|
||||
function newId() {
|
||||
return crypto.randomBytes(8).toString('hex');
|
||||
}
|
||||
|
||||
// ---------- Einstellungen ----------
|
||||
|
||||
const DEFAULT_SETTINGS = {
|
||||
instancesDir: null, // null => Standard (userData/instances)
|
||||
javaPath: '', // leer => "java" aus PATH
|
||||
defaultMinMemMb: 2048,
|
||||
defaultMaxMemMb: 4096,
|
||||
azureClientId: '', // Azure-App-ID für den Microsoft-Login (Phase 3)
|
||||
globalPreLaunch: '',
|
||||
globalPostExit: '',
|
||||
lastSelectedId: '', // zuletzt ausgewählte Instanz (beim Start wieder markieren)
|
||||
bgSkin: '', // '' = aus, 'active' = aktives Konto, sonst Konto-UUID
|
||||
bgSkinOpacity: 55, // Deckkraft des Hintergrund-Skins in %
|
||||
bgSkinPose: 'walking', // Pose des 3D-Renders (falls der Pose-Dienst erreichbar ist)
|
||||
wrapperCommand: '', // Externe Programme: Startprogramm, das Java umschließt
|
||||
proxyEnabled: false, // HTTP-Proxy für Downloads/Netzwerk
|
||||
proxyHost: '',
|
||||
proxyPort: '',
|
||||
proxyUser: '',
|
||||
proxyPass: '',
|
||||
theme: 'dark',
|
||||
};
|
||||
|
||||
function settingsFile() {
|
||||
return path.join(USER_DATA, 'settings.json');
|
||||
}
|
||||
|
||||
function getSettings() {
|
||||
return Object.assign({}, DEFAULT_SETTINGS, readJson(settingsFile(), {}));
|
||||
}
|
||||
|
||||
function saveSettings(patch) {
|
||||
const merged = Object.assign({}, getSettings(), patch || {});
|
||||
writeJson(settingsFile(), merged);
|
||||
// Falls sich das Instanzverzeichnis geändert hat: sicherstellen, dass es existiert
|
||||
ensureDir(getInstancesDir());
|
||||
return merged;
|
||||
}
|
||||
|
||||
// ---------- Instanzen ----------
|
||||
|
||||
function getInstancesDir() {
|
||||
const s = getSettings();
|
||||
return s.instancesDir && s.instancesDir.trim()
|
||||
? s.instancesDir
|
||||
: path.join(USER_DATA, 'instances');
|
||||
}
|
||||
|
||||
function instancePath(id) {
|
||||
return path.join(getInstancesDir(), id);
|
||||
}
|
||||
|
||||
function instanceFile(id) {
|
||||
return path.join(instancePath(id), 'instance.json');
|
||||
}
|
||||
|
||||
function gameDir(id) {
|
||||
return path.join(instancePath(id), 'minecraft');
|
||||
}
|
||||
|
||||
function modsDir(id) {
|
||||
return path.join(gameDir(id), 'mods');
|
||||
}
|
||||
|
||||
function modsIndexFile(id) {
|
||||
return path.join(instancePath(id), 'mods.json');
|
||||
}
|
||||
|
||||
// ---------- installierte Mods (Metadaten je Instanz) ----------
|
||||
|
||||
function listMods(id) {
|
||||
return readJson(modsIndexFile(id), []);
|
||||
}
|
||||
|
||||
function addMod(id, entry) {
|
||||
const list = listMods(id).filter((m) => m.filename !== entry.filename);
|
||||
list.push(entry);
|
||||
writeJson(modsIndexFile(id), list);
|
||||
return list;
|
||||
}
|
||||
|
||||
function removeMod(id, filename) {
|
||||
const list = listMods(id).filter((m) => m.filename !== filename);
|
||||
writeJson(modsIndexFile(id), list);
|
||||
// zugehörige .jar löschen
|
||||
const jar = path.join(modsDir(id), filename);
|
||||
try { if (fs.existsSync(jar)) fs.unlinkSync(jar); } catch { /* egal */ }
|
||||
return list;
|
||||
}
|
||||
|
||||
function defaultInstance(name) {
|
||||
const now = new Date().toISOString();
|
||||
return {
|
||||
id: newId(),
|
||||
name: name || 'Neue Instanz',
|
||||
icon: 'grass', // Schlüssel für ein Emoji/Icon im Renderer
|
||||
customImage: null, // Dateiname eines eigenen Bildes im Instanzordner (überschreibt Emoji)
|
||||
group: '', // optionale Gruppe (MultiMC-artig)
|
||||
preLaunchCommand: '', // Befehl vor dem Start
|
||||
postExitCommand: '', // Befehl nach dem Beenden
|
||||
created: now,
|
||||
lastPlayed: null,
|
||||
totalPlaySeconds: 0,
|
||||
minecraft: {
|
||||
version: '', // z. B. "1.21.4" – wird in Phase 2 aus dem Mojang-Manifest befüllt
|
||||
loader: 'vanilla', // vanilla | fabric | forge | neoforge | quilt
|
||||
loaderVersion: '',
|
||||
},
|
||||
java: {
|
||||
path: '', // leer => globale Einstellung
|
||||
minMemMb: null, // null => globale Einstellung
|
||||
maxMemMb: null,
|
||||
extraArgs: '',
|
||||
},
|
||||
notes: '',
|
||||
};
|
||||
}
|
||||
|
||||
// liest ein eigenes Instanz-Bild als data:-URI (für die Anzeige im Renderer)
|
||||
function iconDataUri(id) {
|
||||
const meta = readJson(instanceFile(id), null);
|
||||
if (!meta || !meta.customImage) return null;
|
||||
try {
|
||||
const buf = fs.readFileSync(path.join(instancePath(id), meta.customImage));
|
||||
let ext = path.extname(meta.customImage).slice(1).toLowerCase();
|
||||
if (ext === 'jpg') ext = 'jpeg';
|
||||
return `data:image/${ext};base64,` + buf.toString('base64');
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
function setCustomImage(id, sourcePath) {
|
||||
let ext = path.extname(sourcePath).toLowerCase();
|
||||
if (!ext) ext = '.png';
|
||||
const dest = 'icon' + ext;
|
||||
fs.copyFileSync(sourcePath, path.join(instancePath(id), dest));
|
||||
return updateInstance(id, { customImage: dest });
|
||||
}
|
||||
|
||||
function clearCustomImage(id) {
|
||||
const meta = getInstance(id);
|
||||
if (meta && meta.customImage) {
|
||||
try { fs.unlinkSync(path.join(instancePath(id), meta.customImage)); } catch { /* egal */ }
|
||||
}
|
||||
return updateInstance(id, { customImage: null });
|
||||
}
|
||||
|
||||
function listInstances() {
|
||||
const dir = getInstancesDir();
|
||||
ensureDir(dir);
|
||||
const result = [];
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const meta = readJson(path.join(dir, entry.name, 'instance.json'), null);
|
||||
if (meta && meta.id) {
|
||||
// eigenes Bild direkt für die Anzeige mitliefern (nicht persistiert)
|
||||
if (meta.customImage) meta.iconData = iconDataUri(meta.id);
|
||||
result.push(meta);
|
||||
}
|
||||
}
|
||||
// zuletzt gespielt zuerst, dann alphabetisch
|
||||
result.sort((a, b) => {
|
||||
if (a.lastPlayed && b.lastPlayed) return b.lastPlayed.localeCompare(a.lastPlayed);
|
||||
if (a.lastPlayed) return -1;
|
||||
if (b.lastPlayed) return 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
function getInstance(id) {
|
||||
return readJson(instanceFile(id), null);
|
||||
}
|
||||
|
||||
// Ordnername aus dem Instanznamen ableiten (wie MultiMC), ungültige Zeichen raus
|
||||
function slugForName(name) {
|
||||
const base = String(name || 'Instanz').trim()
|
||||
.replace(/[<>:"/\\|?*\u0000-\u001f]/g, '')
|
||||
.replace(/[.\s]+$/, '')
|
||||
.trim();
|
||||
return (base || 'Instanz').slice(0, 60);
|
||||
}
|
||||
// eindeutiger Ordnername (bei Kollision "Name 2", "Name 3", …)
|
||||
function uniqueFolderId(name) {
|
||||
const dir = getInstancesDir();
|
||||
const base = slugForName(name);
|
||||
let id = base;
|
||||
let n = 2;
|
||||
while (fs.existsSync(path.join(dir, id))) { id = base + ' ' + n; n += 1; }
|
||||
return id;
|
||||
}
|
||||
|
||||
function createInstance(data) {
|
||||
const inst = defaultInstance(data && data.name);
|
||||
inst.id = uniqueFolderId(inst.name); // Ordner = Instanzname statt Zufalls-ID
|
||||
if (data) {
|
||||
if (data.icon) inst.icon = data.icon;
|
||||
if (data.minecraft) Object.assign(inst.minecraft, data.minecraft);
|
||||
if (data.notes) inst.notes = data.notes;
|
||||
if (data.group !== undefined) inst.group = data.group;
|
||||
}
|
||||
ensureDir(gameDir(inst.id));
|
||||
writeJson(instanceFile(inst.id), inst);
|
||||
return inst;
|
||||
}
|
||||
|
||||
function updateInstance(id, patch) {
|
||||
const inst = getInstance(id);
|
||||
if (!inst) throw new Error('Instanz nicht gefunden: ' + id);
|
||||
// flaches Merge mit Sonderbehandlung der verschachtelten Objekte
|
||||
const next = Object.assign({}, inst, patch);
|
||||
if (patch.minecraft) next.minecraft = Object.assign({}, inst.minecraft, patch.minecraft);
|
||||
if (patch.java) next.java = Object.assign({}, inst.java, patch.java);
|
||||
writeJson(instanceFile(id), next);
|
||||
return next;
|
||||
}
|
||||
|
||||
function deleteInstance(id) {
|
||||
const p = instancePath(id);
|
||||
if (fs.existsSync(p)) fs.rmSync(p, { recursive: true, force: true });
|
||||
return true;
|
||||
}
|
||||
|
||||
function duplicateInstance(id) {
|
||||
const src = getInstance(id);
|
||||
if (!src) throw new Error('Instanz nicht gefunden: ' + id);
|
||||
const copy = JSON.parse(JSON.stringify(src));
|
||||
copy.name = src.name + ' (Kopie)';
|
||||
copy.id = uniqueFolderId(copy.name);
|
||||
copy.created = new Date().toISOString();
|
||||
copy.lastPlayed = null;
|
||||
copy.totalPlaySeconds = 0;
|
||||
ensureDir(gameDir(copy.id));
|
||||
// Spielinhalte mitkopieren (Mods/Configs) – ohne Fehler wenn leer
|
||||
try {
|
||||
fs.cpSync(gameDir(id), gameDir(copy.id), { recursive: true });
|
||||
} catch { /* Quelle evtl. leer */ }
|
||||
writeJson(instanceFile(copy.id), copy);
|
||||
return copy;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
init,
|
||||
getSettings,
|
||||
saveSettings,
|
||||
getInstancesDir,
|
||||
instancePath,
|
||||
gameDir,
|
||||
modsDir,
|
||||
iconDataUri,
|
||||
setCustomImage,
|
||||
clearCustomImage,
|
||||
listMods,
|
||||
addMod,
|
||||
removeMod,
|
||||
listInstances,
|
||||
getInstance,
|
||||
createInstance,
|
||||
updateInstance,
|
||||
deleteInstance,
|
||||
duplicateInstance,
|
||||
};
|
||||
302
src/styles.css
Normal file
302
src/styles.css
Normal file
@@ -0,0 +1,302 @@
|
||||
/* AeroMC – heller MultiMC-Native-Look */
|
||||
:root{
|
||||
--win:#f0f0f0; --area:#e2e2e2; --bar:#e9eaed;
|
||||
--border:#c4c7cc; --border2:#d6d8dc;
|
||||
--text:#1c1c1c; --dim:#6b7280;
|
||||
--sel:#dce8f6; --sel-br:#3a6ea5; --link:#2c5aa0; --accent:#3a6ea5;
|
||||
--danger:#c0392b; --play:#3a9c3a;
|
||||
--font:"Segoe UI",Tahoma,system-ui,sans-serif;
|
||||
--mono:Consolas,"Cascadia Mono",monospace;
|
||||
}
|
||||
*{box-sizing:border-box}
|
||||
html,body{margin:0;height:100%;font-family:var(--font);font-size:12px;color:var(--text);background:var(--win)}
|
||||
body{display:grid;grid-template-rows:auto 1fr auto;height:100vh;overflow:hidden}
|
||||
.hidden{display:none!important}
|
||||
.mono{font-family:var(--mono);font-size:12px}
|
||||
.spacer{flex:1}
|
||||
.opt,.hint{color:var(--dim)}
|
||||
[data-ic] svg,.ic svg{width:16px;height:16px}
|
||||
|
||||
/* ===================== DUNKLER MODUS (native, dunkelgrau) ===================== */
|
||||
:root[data-theme="dark"]{
|
||||
--win:#2d2d30; --area:#242426; --bar:#333337;
|
||||
--border:#3f3f46; --border2:#3a3a40;
|
||||
--text:#e4e4e6; --dim:#9a9aa2;
|
||||
--sel:#0e639c; --sel-br:#1177bb; --link:#4ea6ff; --accent:#1177bb;
|
||||
--danger:#e06c6c; --play:#5cb85c;
|
||||
}
|
||||
:root[data-theme="dark"] body{background:var(--win)}
|
||||
:root[data-theme="dark"] .search-wrap input,
|
||||
:root[data-theme="dark"] .field input,
|
||||
:root[data-theme="dark"] .field select,
|
||||
:root[data-theme="dark"] .field textarea,
|
||||
:root[data-theme="dark"] .mods-search input,
|
||||
:root[data-theme="dark"] .login-usercode{background:#1e1e1e;color:var(--text);border-color:var(--border)}
|
||||
:root[data-theme="dark"] .modal-card{background:#2d2d30;border-color:#161618}
|
||||
:root[data-theme="dark"] .modal-head{background:#333337}
|
||||
:root[data-theme="dark"] .modal-body{background:#2d2d30}
|
||||
:root[data-theme="dark"] .modal-foot{background:#2a2a2e}
|
||||
:root[data-theme="dark"] .advanced{background:#242426}
|
||||
:root[data-theme="dark"] .mods-list,
|
||||
:root[data-theme="dark"] .account-item,
|
||||
:root[data-theme="dark"] .screen-thumb{background:#1e1e1e}
|
||||
:root[data-theme="dark"] .mod-icon,
|
||||
:root[data-theme="dark"] .account-av,
|
||||
:root[data-theme="dark"] .icon-preview{background:#3a3a40}
|
||||
:root[data-theme="dark"] .mod-item+.mod-item{border-color:#333}
|
||||
:root[data-theme="dark"] .mod-item:hover{background:#2a2f36}
|
||||
:root[data-theme="dark"] .btn{background:linear-gradient(#3b3b40,#303034);color:var(--text);border-color:var(--border)}
|
||||
:root[data-theme="dark"] .btn:hover{background:linear-gradient(#44444b,#35353b);border-color:#5a5a63}
|
||||
:root[data-theme="dark"] .btn:active{background:#2a2a2e}
|
||||
:root[data-theme="dark"] .btn-primary{background:linear-gradient(#2a7ac0,#1f66a8);border-color:#1a5a95;color:#fff}
|
||||
:root[data-theme="dark"] .btn-primary:hover{background:linear-gradient(#3286cc,#2470b4)}
|
||||
:root[data-theme="dark"] .btn-danger{color:#e88;border-color:#6a3a3a}
|
||||
:root[data-theme="dark"] .btn-danger:hover{background:#3a2626}
|
||||
:root[data-theme="dark"] .tbtn:hover,
|
||||
:root[data-theme="dark"] .sbtn:hover,
|
||||
:root[data-theme="dark"] .card:hover{background:#37373d;border-color:var(--border)}
|
||||
:root[data-theme="dark"] .card.sel{background:#0e639c;border-color:#1177bb}
|
||||
:root[data-theme="dark"] .card.sel .card-name{background:#1177bb;color:#fff}
|
||||
:root[data-theme="dark"] .st-row2{background:#2a2a2e}
|
||||
:root[data-theme="dark"] .st-row,
|
||||
:root[data-theme="dark"] .group-header,
|
||||
:root[data-theme="dark"] .empty h2,
|
||||
:root[data-theme="dark"] .mods-col h4,
|
||||
:root[data-theme="dark"] .field>span{color:var(--text)}
|
||||
:root[data-theme="dark"] #btn-console svg{color:#c8c8cc}
|
||||
:root[data-theme="dark"] .acct-av svg{color:#c8c8cc}
|
||||
:root[data-theme="dark"] .mods-filters select{background:#1e1e1e;color:var(--text);border-color:var(--border)}
|
||||
:root[data-theme="dark"] .modal-close{color:#aaa}
|
||||
:root[data-theme="dark"] .modal-close:hover{color:#fff}
|
||||
:root[data-theme="dark"] ::-webkit-scrollbar-thumb{background:#4a4a52;border-color:var(--area)}
|
||||
|
||||
/* ---------- Toolbar ---------- */
|
||||
.toolbar{display:flex;align-items:center;gap:2px;padding:5px 8px;background:var(--win);border-bottom:1px solid var(--border)}
|
||||
.tbtn{display:inline-flex;align-items:center;gap:7px;padding:6px 10px;border:1px solid transparent;border-radius:3px;background:none;color:var(--text);cursor:pointer;font-family:inherit;font-size:12px}
|
||||
.tbtn:hover{background:#e2e6ec;border-color:var(--border2)}
|
||||
.tbtn:active{background:#d7dde5}
|
||||
.tbtn svg{width:18px;height:18px;color:#4b5563}
|
||||
/* farbige Toolbar-Icons wie in MultiMC */
|
||||
#btn-new svg{color:#3a9c3a}
|
||||
#btn-import svg{color:#3d7ab5}
|
||||
#btn-modpack svg{color:#8e5bc7}
|
||||
#btn-open-dir svg{color:#d8a72f}
|
||||
#btn-console svg{color:#5a6470}
|
||||
#btn-account svg{color:#3a9c3a}
|
||||
#btn-settings svg{color:#3d7ab5}
|
||||
.tbtn.big{font-size:13px;padding:8px 16px;border:1px solid var(--border);background:linear-gradient(#fdfdfd,#e9e9e9)}
|
||||
.tbsep{width:1px;height:24px;background:var(--border);margin:0 5px}
|
||||
.tbgrow{flex:1}
|
||||
.search-wrap input{padding:5px 9px;border:1px solid var(--border);border-radius:3px;background:#fff;color:var(--text);font-size:12px;width:150px}
|
||||
.search-wrap input:focus{outline:none;border-color:var(--accent)}
|
||||
|
||||
/* ---------- Arbeitsbereich ---------- */
|
||||
.workspace{display:grid;grid-template-columns:1fr 172px;overflow:hidden;background:var(--area)}
|
||||
.list-area{position:relative;overflow:auto}
|
||||
/* Skin-Hintergrund (wie MultiMCs Katze) – liegt hinter den Instanzen */
|
||||
.list-area::after{
|
||||
content:'';position:absolute;right:24px;bottom:8px;width:52%;height:88%;
|
||||
background-image:var(--bg-skin,none);background-repeat:no-repeat;
|
||||
background-position:right bottom;background-size:contain;
|
||||
opacity:var(--bg-skin-opacity,.55);pointer-events:none;z-index:0;
|
||||
}
|
||||
.list{position:relative;z-index:1}
|
||||
.list{padding:4px 8px}
|
||||
.side{background:var(--win);border-left:1px solid var(--border);overflow:auto;padding:12px 8px}
|
||||
.side-empty{text-align:center;color:var(--dim);padding:24px 8px}
|
||||
.side-ico{width:56px;height:56px;margin:2px auto 6px;display:grid;place-items:center;font-size:40px}
|
||||
.side-ico img{width:56px;height:56px;object-fit:cover;border-radius:3px}
|
||||
.side-title{text-align:center;font-weight:600;font-size:14px;word-break:break-word}
|
||||
.side-grp{text-align:center;color:var(--link);font-size:11px;margin-top:2px;cursor:pointer}
|
||||
.side-grp:hover{text-decoration:underline}
|
||||
.hr{height:1px;background:var(--border);margin:8px 2px}
|
||||
.sbtn{display:block;width:100%;text-align:center;padding:6px;border:1px solid transparent;border-radius:3px;background:none;cursor:pointer;font-family:inherit;font-size:12px;color:var(--text)}
|
||||
.sbtn:hover{background:#e2e6ec;border-color:var(--border2)}
|
||||
.sbtn.play{font-weight:600}
|
||||
.sbtn.del:hover{color:var(--danger)}
|
||||
|
||||
/* ---------- Instanzliste / Gruppen / Kacheln ---------- */
|
||||
.group-section{margin-bottom:4px}
|
||||
.group-header{display:flex;align-items:center;gap:7px;padding:7px 4px 5px;color:#333;font-weight:600;font-size:12px;cursor:pointer;user-select:none}
|
||||
.group-header::after{content:'';flex:1;height:1px;background:var(--border);margin-left:6px}
|
||||
.group-header .chev{font-size:9px;color:#888;width:10px;text-align:center}
|
||||
.group-header.collapsed .chev{transform:none}
|
||||
.group-count{color:var(--dim);font-weight:400}
|
||||
.group-grid{display:grid;grid-template-columns:repeat(auto-fill,86px);gap:2px;padding:2px 4px 8px;justify-content:start}
|
||||
.group-section.collapsed .group-grid{display:none}
|
||||
.card{display:flex;flex-direction:column;align-items:center;gap:4px;padding:8px 4px 5px;border:1px solid transparent;border-radius:2px;cursor:pointer;position:relative}
|
||||
.card:hover{background:#e3ecf7}
|
||||
.card.sel{background:var(--sel);border-color:var(--sel-br)}
|
||||
.card-icon{width:44px;height:44px;display:grid;place-items:center;font-size:30px}
|
||||
.card-icon svg,.side-ico svg{display:block;image-rendering:pixelated}
|
||||
.card-icon-img{width:44px;height:44px;object-fit:cover;border-radius:3px}
|
||||
.card-name{font-size:11px;text-align:center;line-height:1.2;max-width:82px;padding:1px 4px;word-break:break-word}
|
||||
.card.sel .card-name{background:var(--sel-br);color:#fff;border-radius:2px}
|
||||
.card-badges{display:none}
|
||||
.card-play{display:none}
|
||||
.group-header.drop-hover,.group-section.drop-hover{outline:1px dashed var(--accent);outline-offset:1px}
|
||||
.card.dragging{opacity:.4}
|
||||
|
||||
/* ---------- Leerer Zustand ---------- */
|
||||
.empty{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;color:var(--dim);gap:8px}
|
||||
.empty h2{color:#333;margin:0}
|
||||
.empty p{max-width:340px;margin:0 0 8px}
|
||||
|
||||
/* ---------- Statusleiste (zwei Zeilen) ---------- */
|
||||
.statusbar{display:flex;flex-direction:column;background:var(--win);border-top:1px solid var(--border)}
|
||||
.st-row{display:flex;justify-content:space-between;padding:3px 10px;font-size:11px;color:#333}
|
||||
.st-row2{background:#e4e4e4;border-top:1px solid var(--border2);color:#444}
|
||||
.auth-warn{
|
||||
display:inline-flex;align-items:center;gap:6px;
|
||||
color:#fff;background:var(--danger);
|
||||
padding:1px 10px;border-radius:3px;font-weight:600;cursor:pointer;
|
||||
animation:authpulse 2s ease-in-out infinite;
|
||||
}
|
||||
.auth-warn::before{content:'⚠';font-size:12px}
|
||||
.auth-warn:hover{filter:brightness(1.12)}
|
||||
@keyframes authpulse{0%,100%{opacity:1}50%{opacity:.72}}
|
||||
|
||||
/* ---------- Modals ---------- */
|
||||
.modal{position:fixed;inset:0;background:rgba(0,0,0,.35);display:flex;align-items:center;justify-content:center;z-index:50}
|
||||
.modal-card{width:560px;max-width:calc(100vw - 40px);max-height:calc(100vh - 60px);background:#f6f6f6;border:1px solid #a9a9a9;border-radius:4px;box-shadow:0 10px 30px rgba(0,0,0,.3);display:flex;flex-direction:column;overflow:hidden}
|
||||
.modal-sm{width:420px}.modal-lg{width:820px}
|
||||
.modal-head{display:flex;align-items:center;justify-content:space-between;padding:10px 14px;background:#e8e8e8;border-bottom:1px solid var(--border)}
|
||||
.modal-head h3{margin:0;font-size:14px;font-weight:600}
|
||||
.modal-close{background:none;border:none;color:#666;font-size:15px;cursor:pointer;line-height:1}
|
||||
.modal-close:hover{color:#000}
|
||||
.modal-body{padding:16px;overflow:auto;background:#f6f6f6}
|
||||
.modal-foot{display:flex;justify-content:flex-end;gap:8px;padding:10px 14px;border-top:1px solid var(--border);background:#efefef}
|
||||
|
||||
/* Formular */
|
||||
.field{display:flex;flex-direction:column;gap:5px;margin-bottom:12px}
|
||||
.field>span{font-size:12px;color:#333}
|
||||
.field input,.field select,.field textarea{padding:6px 8px;border:1px solid var(--border);border-radius:3px;background:#fff;color:var(--text);font-family:inherit;font-size:13px}
|
||||
.field input:focus,.field select:focus,.field textarea:focus{outline:none;border-color:var(--accent)}
|
||||
.field textarea{resize:vertical}
|
||||
.field-row{display:flex;gap:12px}.field-row .field{flex:1}
|
||||
.field-icon{max-width:150px}
|
||||
.hint{font-size:12px}
|
||||
.path-row{display:flex;gap:8px}.path-row input{flex:1}
|
||||
.advanced{border:1px solid var(--border);border-radius:3px;padding:12px 14px 2px;margin-top:4px;background:#fbfbfb}
|
||||
.advanced legend{color:var(--dim);font-size:12px;padding:0 6px}
|
||||
.version-row{display:flex;gap:8px}.version-row #f-version-type{max-width:120px}.version-row #f-version{flex:1}
|
||||
.icon-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:3px;margin-top:6px;max-width:100%}
|
||||
.icon-choice{display:flex;align-items:center;justify-content:center;padding:3px;border:1px solid transparent;border-radius:3px;background:none;cursor:pointer}
|
||||
.icon-choice:hover{background:rgba(128,128,128,.18)}
|
||||
.icon-choice.on{border-color:var(--accent);background:var(--sel)}
|
||||
.icon-choice svg{display:block}
|
||||
.icon-custom{display:flex;align-items:center;gap:6px;margin-top:6px;flex-wrap:wrap}
|
||||
.icon-preview{width:34px;height:34px;border-radius:3px;overflow:hidden;background:#e4e4e4;border:1px solid var(--border)}
|
||||
.icon-preview img{width:100%;height:100%;object-fit:cover}
|
||||
.appinfo{margin-top:8px;font-size:12px;color:var(--dim);line-height:1.6}
|
||||
.about-brand{display:flex;align-items:center;gap:14px;margin-bottom:12px}
|
||||
.about-logo{width:56px;height:56px;border-radius:12px}
|
||||
.about-name{font-size:18px;font-weight:600}
|
||||
|
||||
/* Buttons in Dialogen */
|
||||
.btn{display:inline-flex;align-items:center;gap:6px;padding:6px 12px;border:1px solid var(--border);border-radius:3px;background:linear-gradient(#fdfdfd,#e9e9e9);color:var(--text);cursor:pointer;font-family:inherit;font-size:12px}
|
||||
.btn:hover{background:linear-gradient(#ffffff,#e2e2e2);border-color:#9aa0a8}
|
||||
.btn:active{background:#dcdcdc}
|
||||
.btn:disabled{opacity:.5;cursor:default}
|
||||
.btn svg{width:15px;height:15px}
|
||||
.btn-primary{background:linear-gradient(#5a9be0,#3a7bc8);border-color:#2f6bb0;color:#fff}
|
||||
.btn-primary:hover{background:linear-gradient(#69a8ea,#3f83d2)}
|
||||
.btn-danger{color:var(--danger);border-color:#cbb0ac}
|
||||
.btn-danger:hover{background:#f7e9e7;border-color:#c0392b}
|
||||
.btn-lg{padding:9px 18px;font-size:13px}
|
||||
.btn-mini{padding:4px 8px;font-size:11px}
|
||||
|
||||
/* Badges (dezent) */
|
||||
.badge{font-size:10px;padding:1px 6px;border-radius:2px;background:#e2e2e2;color:#555;border:1px solid var(--border)}
|
||||
|
||||
/* Detail-Kopf (Mods/Login) */
|
||||
.detail-head{display:flex;align-items:center;gap:12px}
|
||||
.detail-icon{font-size:28px}
|
||||
.detail-sub{color:var(--dim);font-size:12px;margin-top:2px}
|
||||
|
||||
/* Mods */
|
||||
.mods-body{min-height:420px}
|
||||
.mods-warn{background:#fdecea;border:1px solid #e0b4b0;color:#a5342a;border-radius:3px;padding:10px 12px;margin-bottom:12px}
|
||||
.mods-search{display:flex;gap:8px;margin-bottom:10px}
|
||||
.mods-filters{display:flex;gap:8px;margin-bottom:12px}
|
||||
.mods-filters select{flex:1;padding:6px 8px;border:1px solid var(--border);border-radius:3px;background:var(--input,#fff);color:var(--text);font-family:inherit;font-size:12px}
|
||||
#m-more{display:block;width:100%;margin-top:8px}
|
||||
.mods-search input{flex:1;padding:7px 10px;border:1px solid var(--border);border-radius:3px;background:#fff;color:var(--text)}
|
||||
.mods-search input:focus{outline:none;border-color:var(--accent)}
|
||||
.mods-columns{display:grid;grid-template-columns:1fr 1fr;gap:14px}
|
||||
.mods-col h4{margin:0 0 8px;font-size:12px;color:#333;font-weight:600;display:flex;align-items:center;gap:6px}
|
||||
.mods-tabs{display:flex;gap:2px;margin-bottom:10px;border-bottom:1px solid var(--border)}
|
||||
.mtab{padding:7px 14px;border:1px solid transparent;border-bottom:none;border-radius:3px 3px 0 0;background:none;color:var(--dim);cursor:pointer;font-family:inherit;font-size:12px;margin-bottom:-1px}
|
||||
.mtab:hover{color:var(--text)}
|
||||
.mtab.on{color:var(--text);background:var(--bar);border-color:var(--border);font-weight:600}
|
||||
.mods-list{height:340px;overflow-y:auto;overflow-x:hidden;border:1px solid var(--border);border-radius:3px;background:#fff;padding:6px}
|
||||
.mods-empty{color:var(--dim);text-align:center;padding:24px 8px;font-size:12px}
|
||||
.mod-item{display:flex;gap:10px;align-items:center;padding:8px;border-radius:3px;max-width:100%;overflow:hidden}
|
||||
.mod-item.clickable{cursor:pointer}
|
||||
.mod-item.installing{opacity:.6}
|
||||
.mod-item+.mod-item{border-top:1px solid #eee}
|
||||
.mod-item:hover{background:#f0f4f8}
|
||||
.mod-icon{width:38px;height:38px;border-radius:3px;flex:none;background:#e4e4e4;object-fit:cover;display:flex;align-items:center;justify-content:center;font-size:18px}
|
||||
.mod-info{flex:1;min-width:0}
|
||||
.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-action{flex:none}
|
||||
|
||||
/* Login / Konten */
|
||||
.login-code{text-align:center}
|
||||
.login-link{color:var(--link);font-size:15px;display:inline-block;margin-bottom:12px;cursor:pointer;text-decoration:none}
|
||||
.login-link:hover{text-decoration:underline}
|
||||
.login-usercode{font-family:var(--mono);font-size:28px;font-weight:700;letter-spacing:3px;color:#222;background:#fff;border:1px solid var(--border);border-radius:4px;padding:12px;margin:6px 0 12px}
|
||||
.login-error{color:var(--danger)!important;font-weight:600}
|
||||
.account-list{display:flex;flex-direction:column;gap:8px;margin-bottom:10px}
|
||||
.account-item{display:flex;align-items:center;gap:10px;padding:10px 12px;border-radius:3px;background:#fff;border:1px solid var(--border)}
|
||||
.account-item.active{border-color:var(--accent)}
|
||||
.account-av{width:34px;height:34px;border-radius:3px;flex:none;background:#e4e4e4;display:flex;align-items:center;justify-content:center;font-size:18px}
|
||||
img.account-av{object-fit:cover;image-rendering:pixelated}
|
||||
.account-main{flex:1;min-width:0}
|
||||
.account-name{font-weight:600}
|
||||
.account-tag{font-size:11px;color:var(--link)}
|
||||
.account-actions{display:flex;gap:6px}
|
||||
.account-empty{color:var(--dim);text-align:center;padding:10px}
|
||||
|
||||
/* Screenshots */
|
||||
.screens-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:12px;max-height:460px;overflow:auto}
|
||||
.screen-thumb{border:1px solid var(--border);border-radius:3px;overflow:hidden;background:#fff;cursor:pointer;position:relative}
|
||||
.screen-thumb img{width:100%;height:100px;object-fit:cover;display:block}
|
||||
.screen-thumb .screen-name{font-size:11px;color:var(--dim);padding:4px 6px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.screen-thumb .screen-del{position:absolute;top:4px;right:4px;opacity:0;background:var(--danger);color:#fff;border:none;border-radius:3px;width:24px;height:24px;cursor:pointer}
|
||||
.screen-thumb:hover .screen-del{opacity:1}
|
||||
.screens-empty{color:var(--dim);text-align:center;padding:30px;grid-column:1/-1}
|
||||
|
||||
/* Konsole (dunkel wie ein Log) */
|
||||
.console-out{height:440px;overflow:auto;margin:0;background:#1e1e1e;border:1px solid var(--border);border-radius:3px;padding:10px;font-family:var(--mono);font-size:12px;color:#d4d4d4;white-space:pre-wrap;word-break:break-word;line-height:1.5}
|
||||
.log-line{white-space:pre-wrap}
|
||||
.log-error{color:#f48771}.log-warn{color:#dcdcaa}.log-info{color:#569cd6}
|
||||
|
||||
/* Einstellungen (MultiMC-Stil: linke Sektions-Navigation) */
|
||||
.settings-body{display:flex;padding:0;min-height:360px}
|
||||
.settings-nav{width:170px;flex:none;background:var(--bar);border-right:1px solid var(--border);padding:8px;display:flex;flex-direction:column;gap:2px}
|
||||
.snav{text-align:left;padding:8px 11px;border:1px solid transparent;border-radius:3px;background:none;color:var(--text);cursor:pointer;font-family:inherit;font-size:13px}
|
||||
.snav:hover{background:rgba(128,128,128,.16)}
|
||||
.snav.on{background:var(--sel);border-color:var(--sel-br);color:var(--text)}
|
||||
.settings-content{flex:1;padding:16px 18px;overflow:auto}
|
||||
.spane h4{margin:0 0 14px;font-size:14px;font-weight:600}
|
||||
.checkbox-field{display:flex;align-items:center;gap:8px;margin-bottom:14px;cursor:pointer;font-size:13px}
|
||||
.checkbox-field input{width:16px;height:16px}
|
||||
.field-port{max-width:110px;flex:none}
|
||||
|
||||
/* Konto-Button mit 3D-Skin-Kopf */
|
||||
.acct-btn{gap:8px}
|
||||
.acct-av{display:inline-flex;align-items:center;justify-content:center}
|
||||
.acct-av svg{width:18px;height:18px;color:#4b5563}
|
||||
.acct-head{width:24px;height:26px;object-fit:contain;image-rendering:pixelated;margin-top:-2px;margin-bottom:-2px}
|
||||
|
||||
/* Toast */
|
||||
.toast{position:fixed;bottom:42px;left:50%;transform:translateX(-50%);background:#333;color:#fff;padding:10px 16px;border-radius:4px;box-shadow:0 6px 20px rgba(0,0,0,.4);z-index:100;font-size:13px}
|
||||
.toast.err{background:#a5342a}
|
||||
|
||||
/* Scrollbars (hell) */
|
||||
::-webkit-scrollbar{width:14px;height:14px}
|
||||
::-webkit-scrollbar-thumb{background:#c1c1c1;border:3px solid var(--area);border-radius:7px}
|
||||
::-webkit-scrollbar-thumb:hover{background:#a8a8a8}
|
||||
1996
src/vendor/skinview3d.js
vendored
Normal file
1996
src/vendor/skinview3d.js
vendored
Normal file
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user