Upload via GUI (123 Dateien)

This commit is contained in:
2026-10-03 07:09:08 +00:00
parent bc68395437
commit 10ee45cbc2
11 changed files with 1148 additions and 16 deletions
+211 -8
View File
@@ -29,6 +29,7 @@ const bedrock = require('./bedrock');
const backup = require('./backup');
const mmcimport = require('./mmcimport');
const cfimport = require('./cfimport');
const mrimport = require('./mrimport');
const mcimport = require('./mcimport');
const technicimport = require('./technicimport');
const technicdiscover = require('./technicdiscover');
@@ -112,6 +113,7 @@ const POPOUT_SPEC = {
screens: { title: 'AeroMC – Bildschirmfotos', width: 860, height: 680, minWidth: 640, minHeight: 480 },
servers: { title: 'AeroMC – Server', width: 860, height: 680, minWidth: 640, minHeight: 480 },
skin: { title: 'AeroMC – Skin', width: 860, height: 680, minWidth: 640, minHeight: 480 },
skineditor: { title: 'AeroMC – Skin-Editor', width: 1180, height: 800, minWidth: 920, minHeight: 600 },
bedrock: { title: 'AeroMC – Bedrock', width: 860, height: 680, minWidth: 640, minHeight: 480 },
login: { title: 'AeroMC – Konten', width: 460, height: 460, minWidth: 420, minHeight: 380 },
import: { title: 'AeroMC – Import', width: 860, height: 680, minWidth: 640, minHeight: 480 },
@@ -171,7 +173,7 @@ function openPopout(modalId, params) {
minWidth: spec.minWidth,
minHeight: spec.minHeight,
title: spec.title || POPOUT_DEFAULT.title,
backgroundColor: '#1b1e24',
backgroundColor: fensterFarbe(),
icon: windowIcon(),
frame: false, // eigener dunkler Kopfbereich (Titel + ✕) ersetzt die native Titelleiste
show: false,
@@ -211,6 +213,12 @@ ipcMain.on('recording:guard', (_e, active) => { recordingGuardActive = !!active;
ipcMain.on('debug:rendererError', (_e, msg) => console.error('[Renderer-Fehler]', msg));
ipcMain.on('popout:open', (_e, modalId, params) => openPopout(modalId, params));
// Design gewechselt -> Fensterknöpfe oben rechts umfärben (nur Hauptfenster,
// die Popup-Fenster haben ihren eigenen Kopf)
ipcMain.on('window:titleBar', (e, design) => {
if (!mainWindow || mainWindow.isDestroyed() || e.sender !== mainWindow.webContents) return;
try { mainWindow.setTitleBarOverlay(titelleistenFarben(design)); } catch { /* ältere Windows-Fassung */ }
});
ipcMain.on('popout:hide', (e) => {
const w = BrowserWindow.fromWebContents(e.sender);
if (w && !w.isDestroyed()) w.hide();
@@ -714,6 +722,28 @@ function verknuepfungsIcon(ziel) {
return windowIcon() || ziel;
}
// Eigene Titelleiste im Hauptfenster: Minimieren/Maximieren/Schließen bleiben
// echte Windows-Knöpfe (Andocken, Fensterlayouts …), Windows zeichnet sie aber
// in den Farben des Designs. Die Farben müssen zu --titel-bg/--titel-fg in
// styles.css bzw. designs.css passen.
const TITELLEISTE = {
dark: { color: '#2d2d30', symbolColor: '#e4e4e6' },
light: { color: '#f0f0f0', symbolColor: '#1c1c1c' },
aero: { color: '#0a0e1a', symbolColor: '#e8ecfb' },
lila: { color: '#0f0a1a', symbolColor: '#f0eafb' },
minecraft: { color: '#1e1e1f', symbolColor: '#ffffff' },
};
function titelleistenFarben(design) {
return Object.assign({ height: 32 }, TITELLEISTE[design] || TITELLEISTE.dark);
}
// Grundfarbe eines neuen Fensters passend zum Design – sonst blitzt beim
// Öffnen kurz die falsche Farbe auf, bis die Oberfläche gezeichnet ist
function fensterFarbe() {
const design = (store.getSettings() || {}).theme;
return { light: '#f0f0f0', aero: '#05070d', lila: '#08050f', minecraft: '#313233' }[design] || '#1b1e24';
}
function windowIcon() {
const png = path.join(__dirname, 'assets', 'logo.png');
const ico = path.join(__dirname, 'assets', 'icon.ico');
@@ -728,10 +758,12 @@ function createWindow(startHidden) {
height: 820,
minWidth: 940,
minHeight: 640,
backgroundColor: '#1b1e24',
backgroundColor: fensterFarbe(),
title: 'AeroMC',
icon: windowIcon(),
autoHideMenuBar: true,
titleBarStyle: 'hidden',
titleBarOverlay: titelleistenFarben((store.getSettings() || {}).theme),
show: !startHidden,
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
@@ -800,7 +832,7 @@ function oeffneVorbereitetenImport() {
try {
const daten = JSON.parse(fs.readFileSync(datei, 'utf8').replace(/^/, ''));
quellen = (Array.isArray(daten.sources) ? daten.sources : [])
.filter((q) => q && ['mc', 'cf', 'mmc', 'technic'].includes(q.kind))
.filter((q) => q && ['mc', 'cf', 'mr', 'mmc', 'technic'].includes(q.kind))
.map((q) => ({ kind: q.kind, path: typeof q.path === 'string' ? q.path : '' }));
} catch { /* beschädigt -> verwerfen */ }
try { fs.unlinkSync(datei); } catch { /* egal */ }
@@ -1028,9 +1060,8 @@ ipcMain.handle('settings:save', (_e, patch) => {
// (die speichert bei jedem Klick auf eine Instanz mit) – sonst baut das
// Hauptfenster bei jedem Klick seine ganze Liste neu auf und die flackert.
const nurLetzteInstanz = patch && Object.keys(patch).every((k) => k === 'lastSelectedId');
if (!nurLetzteInstanz && mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('settings:changed', s);
}
// an alle Fenster: das Design muss auch in offenen Popup-Fenstern wechseln
if (!nurLetzteInstanz) broadcast('settings:changed', s);
return s;
});
@@ -1733,6 +1764,30 @@ async function aktivesToken() {
return acc && acc.mcAccessToken ? acc.mcAccessToken : null;
}
// Armform (breit/schmal) eines beliebigen Kontos – steht in den Textur-Angaben
// des öffentlichen Profils bei Mojang (SKIN.metadata.model = "slim").
// Ergebnis 10 Minuten merken, nach eigenen Skin-Änderungen sofort verwerfen.
const armformCache = new Map(); // uuid -> { modell, zeit }
ipcMain.handle('skin:modelFor', async (_e, uuid) => {
const id = String(uuid || '').replace(/-/g, '').toLowerCase();
if (!/^[0-9a-f]{32}$/.test(id)) return 'classic';
const gemerkt = armformCache.get(id);
if (gemerkt && Date.now() - gemerkt.zeit < 10 * 60 * 1000) return gemerkt.modell;
try {
const res = await fetch('https://sessionserver.mojang.com/session/minecraft/profile/' + id, { headers: { 'User-Agent': 'AeroMC-Launcher' } });
if (!res.ok) return gemerkt ? gemerkt.modell : 'classic';
const profil = await res.json();
const eigenschaft = (profil.properties || []).find((p) => p.name === 'textures');
const texturen = eigenschaft ? JSON.parse(Buffer.from(eigenschaft.value, 'base64').toString('utf8')) : {};
const skin = texturen.textures && texturen.textures.SKIN;
const modell = skin && skin.metadata && skin.metadata.model === 'slim' ? 'slim' : 'classic';
armformCache.set(id, { modell, zeit: Date.now() });
return modell;
} catch {
return gemerkt ? gemerkt.modell : 'classic';
}
});
ipcMain.handle('skin:profile', async () => {
const token = await aktivesToken();
if (!token) return { ok: false, message: 'Kein angemeldetes Konto.' };
@@ -1764,7 +1819,7 @@ ipcMain.handle('skin:apply', async (_e, dateiPfad, variante) => {
if (!token) return { ok: false, message: 'Kein angemeldetes Konto.' };
try {
const res = await skins.setzeSkin(token, dateiPfad, variante);
if (res && res.ok) broadcast('skin:changed');
if (res && res.ok) { armformCache.clear(); broadcast('skin:changed'); }
return res;
}
catch (err) { return { ok: false, message: 'Skin konnte nicht gesetzt werden: ' + err.message }; }
@@ -1775,11 +1830,78 @@ ipcMain.handle('skin:reset', async () => {
if (!token) return { ok: false, message: 'Kein angemeldetes Konto.' };
try {
const res = await skins.setzeSkinZurueck(token);
if (res && res.ok) broadcast('skin:changed');
if (res && res.ok) { armformCache.clear(); broadcast('skin:changed'); }
return res;
}
catch (err) { return { ok: false, message: 'Zurücksetzen fehlgeschlagen: ' + err.message }; }
});
// ---------- Skin-Editor ----------
// Der Editor malt in eine Canvas – fremde Bildadressen (textures.minecraft.net)
// würden sie "verunreinigen" und das Auslesen der Pixel verbieten. Deshalb holt
// der Hauptprozess die Datei und reicht sie als data:-Adresse weiter.
const PNG_KENNUNG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
function skinPngAusBase64(base64) {
const daten = Buffer.from(String(base64 || ''), 'base64');
if (daten.length < 33 || !daten.subarray(0, 8).equals(PNG_KENNUNG)) return null;
if (daten.readUInt32BE(16) !== 64 || daten.readUInt32BE(20) !== 64) return null;
return daten;
}
ipcMain.handle('skin:currentData', async () => {
const token = await aktivesToken();
if (!token) return { ok: false, message: 'Kein angemeldetes Konto.' };
try {
const p = await skins.holeProfil(token);
if (!p.ok) return p;
if (!p.skinUrl) return { ok: false, standard: true, variante: p.variante, message: 'Das Konto nutzt den Standard-Skin.' };
const res = await fetch(p.skinUrl);
if (!res.ok) return { ok: false, message: 'Skin-Datei nicht abrufbar (HTTP ' + res.status + ').' };
const daten = Buffer.from(await res.arrayBuffer());
return { ok: true, name: p.name, variante: p.variante, dataUri: 'data:image/png;base64,' + daten.toString('base64') };
} catch (err) {
return { ok: false, message: 'Skin konnte nicht geladen werden: ' + err.message };
}
});
ipcMain.handle('skin:readData', (_e, dateiPfad) => {
const geprueft = skins.pruefeDatei(dateiPfad);
if (!geprueft.ok) return geprueft;
try { return { ok: true, dataUri: 'data:image/png;base64,' + fs.readFileSync(dateiPfad).toString('base64') }; }
catch (err) { return { ok: false, message: 'Datei nicht lesbar: ' + err.message }; }
});
ipcMain.handle('skin:saveData', async (e, base64) => {
const daten = skinPngAusBase64(base64);
if (!daten) return { ok: false, message: 'Ungültige Skin-Daten.' };
const res = await dialog.showSaveDialog(BrowserWindow.fromWebContents(e.sender) || mainWindow, {
title: 'Skin speichern',
defaultPath: path.join(app.getPath('pictures'), 'skin.png'),
filters: [{ name: 'PNG-Bild', extensions: ['png'] }],
});
if (res.canceled || !res.filePath) return { ok: false, canceled: true };
try { fs.writeFileSync(res.filePath, daten); return { ok: true, pfad: res.filePath }; }
catch (err) { return { ok: false, message: 'Speichern fehlgeschlagen: ' + err.message }; }
});
ipcMain.handle('skin:applyData', async (_e, base64, variante) => {
const daten = skinPngAusBase64(base64);
if (!daten) return { ok: false, message: 'Ungültige Skin-Daten.' };
const token = await aktivesToken();
if (!token) return { ok: false, message: 'Kein angemeldetes Konto.' };
const tmp = path.join(app.getPath('temp'), 'aeromc-skin-editor.png');
try {
fs.writeFileSync(tmp, daten);
const res = await skins.setzeSkin(token, tmp, variante);
if (res && res.ok) { armformCache.clear(); broadcast('skin:changed', { quelle: 'editor' }); }
return res;
} catch (err) {
return { ok: false, message: 'Skin konnte nicht gesetzt werden: ' + err.message };
} finally {
try { fs.unlinkSync(tmp); } catch { /* egal */ }
}
});
ipcMain.handle('auth:remove', (_e, id) => auth.removeAccount(id));
ipcMain.handle('auth:logout', () => { auth.clearAccount(); return true; });
@@ -3732,6 +3854,87 @@ ipcMain.handle('cf:import', (_e, entries, copyData) => {
return { ok: true, imported: done, failed };
});
// ---------- Modrinth App ----------
ipcMain.handle('mr:pickFolder', async (e) => {
const res = await dialog.showOpenDialog(BrowserWindow.fromWebContents(e.sender) || mainWindow, {
title: 'Datenordner der Modrinth App wählen (meist %APPDATA%\\ModrinthApp)',
defaultPath: mrimport.standardOrdner(),
properties: ['openDirectory'],
});
return res.canceled ? null : res.filePaths[0];
});
ipcMain.handle('mr:scan', async (_e, folder) => {
try { return await mrimport.scan(folder); }
catch (err) { return { ok: false, reason: err.message }; }
});
/*
* War die Instanz in der Modrinth App mit einem Modrinth-Modpack verknüpft,
* übernehmen wir die Verknüpfung – samt der Liste der Dateien, die zum Paket
* gehören. Ohne diese Liste würde "Modpack aktualisieren" alte Mods liegen
* lassen; deshalb wird die Paketdatei einmal geladen und nur ihr Inhaltsverzeichnis
* gelesen. Klappt das nicht (offline …), bleibt die Instanz ohne Verknüpfung.
*/
async function verknuepfeModrinthModpack(instId, link) {
const kopf = { headers: { 'User-Agent': 'AeroMC-Launcher', Accept: 'application/json' } };
const vres = await fetch(`https://api.modrinth.com/v2/version/${encodeURIComponent(link.versionId)}`, kopf);
if (!vres.ok) return false;
const version = await vres.json();
const datei = (version.files || []).find((f) => f.primary) || (version.files || [])[0];
if (!datei || !datei.url) return false;
const dl = await fetch(datei.url, { headers: { 'User-Agent': 'AeroMC-Launcher' } });
if (!dl.ok) return false;
const buf = Buffer.from(await dl.arrayBuffer());
const zip = forge.readZipIndex(buf);
if (!zip['modrinth.index.json']) return false;
const index = JSON.parse(forge.readZipEntry(buf, zip['modrinth.index.json']).toString('utf8').replace(/^\uFEFF/, ''));
const dateien = (index.files || [])
.filter((f) => f.path && !(f.env && f.env.client === 'unsupported'))
.map((f) => String(f.path).replace(/\\/g, '/'));
for (const prefix of ['overrides/', 'client-overrides/']) {
for (const name of Object.keys(zip)) if (name.startsWith(prefix) && !name.endsWith('/')) dateien.push(name.slice(prefix.length));
}
let titel = index.name || '';
try {
const pres = await fetch(`https://api.modrinth.com/v2/project/${encodeURIComponent(link.projectId)}`, kopf);
if (pres.ok) titel = (await pres.json()).title || titel;
} catch { /* Titel ist nur Zierde */ }
store.updateInstance(instId, {
modpack: {
quelle: 'modrinth',
name: index.name || titel,
title: titel,
projectId: link.projectId,
versionId: link.versionId,
versionNummer: version.version_number || '',
dateien,
},
});
return true;
}
ipcMain.handle('mr:import', async (_e, entries, copyData) => {
const done = [];
const failed = [];
const liste = entries || [];
for (const entry of liste) {
try {
const imported = mrimport.importOne(store, entry, copyData !== false);
if (entry.modpack && entry.modpack.projectId && entry.modpack.versionId) {
broadcast('mr:progress', { current: done.length + failed.length, total: liste.length, detail: 'Modpack-Verknüpfung für ' + entry.name });
try { imported.modpack = await verknuepfeModrinthModpack(imported.id, entry.modpack); }
catch { imported.modpack = false; }
}
done.push(imported);
}
catch (err) { failed.push({ name: entry.name, message: err.message }); }
broadcast('mr:progress', { current: done.length + failed.length, total: liste.length });
}
if (done.length) broadcast('instances:changed');
return { ok: true, imported: done, failed };
});
ipcMain.handle('mc:pickFolder', async (e) => {
const res = await dialog.showOpenDialog(BrowserWindow.fromWebContents(e.sender) || mainWindow, {
title: 'Minecraft-Launcher-Ordner wählen (meist .minecraft)',