Upload via GUI (123 Dateien)
This commit is contained in:
+211
-8
@@ -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)',
|
||||
|
||||
Reference in New Issue
Block a user