6 Commits
Author SHA1 Message Date
M_Viper de81575bcf Upload via GUI (143 Dateien) 2026-10-04 18:46:18 +00:00
M_Viper 76762ab567 Upload via GUI (143 Dateien) 2026-10-04 18:46:08 +00:00
M_Viper 6a0e92eb0f Upload via GUI (143 Dateien) 2026-10-04 18:46:00 +00:00
M_Viper 7e1fb616aa Upload via GUI (138 Dateien) 2026-10-04 14:49:45 +00:00
M_Viper 642d229d7e Upload via GUI (138 Dateien) 2026-10-04 14:49:44 +00:00
M_Viper e782cc3058 Upload via GUI (138 Dateien) 2026-10-04 14:49:43 +00:00
30 changed files with 3947 additions and 2734 deletions
+34
View File
@@ -2,6 +2,40 @@
Alle nennenswerten Änderungen an AeroMC werden hier festgehalten. Alle nennenswerten Änderungen an AeroMC werden hier festgehalten.
## [1.2.0] - 2026-10-04
### Neu
- Untergruppen (optional): im Feld „Gruppe“ mit „/“ trennen, z. B. „Modpacks/Survival“ – auch mehrere Ebenen tief. Untergruppen erscheinen eingerückt unter ihrer Gruppe, lassen sich einzeln einklappen und per Drag & Drop befüllen; die Anzahl an einer Gruppe zählt ihre Untergruppen mit
- Importe aus anderen Launchern zeigen jetzt genauen Fortschritt: Instanz x von y, übertragene GB, Prozent, Tempo, Restzeit und die gerade kopierte Datei
- Import abbrechen: fertig übernommene Instanzen bleiben erhalten, die gerade halb kopierte wird wieder entfernt
- Vor dem Kopieren prüft AeroMC, ob genug Speicherplatz frei ist, und nennt sonst benötigte und freie GB
- Konto-Menü oben rechts (wie in MultiMC): Ein Klick auf den Kontonamen klappt alle Konten mit Spielerkopf auf – das aktive ist markiert, ein Klick wechselt sofort. Dazu „Konto hinzufügen…“, „Skin ändern…“ und „Konten verwalten…“
### Geändert
- Plugin „AeroMC Freunde“ 1.5.1: merkt sich den von Mojang bestätigten Namen schon bei der Anmeldung und beim Senden einer Anfrage – Spieler sind damit per Name auffindbar, bevor sie zum ersten Mal über AeroMC gespielt haben (Server-Update empfohlen, der Launcher funktioniert auch mit 1.5.0)
### Behoben
- Freundschaftsanfragen zeigten statt des Namens nur eine lange ID (UUID), wenn der Absender noch nie über AeroMC gespielt hatte – fehlende Namen holt der Launcher jetzt direkt bei Mojang (auch in der Freundesliste und bei gesendeten Anfragen)
- Große Importe (viele GB, z. B. 60 GB aus MultiMC/Prism, CurseForge, Modrinth App, Minecraft Launcher oder Technic) froren den Launcher komplett ein – kopiert wird jetzt im Hintergrund, der Launcher bleibt bedienbar
- Einzelne gesperrte Dateien (z. B. eine gerade geöffnete Welt) brechen den Import nicht mehr ab, sie werden übersprungen und gemeldet
- „Instanz kopieren“, „Löschen“ und das Ersetzen einer vorhandenen Instanz beim erneuten Import blockierten bei großen Instanzen den Launcher – das läuft jetzt im Hintergrund
- Der Spielerkopf oben neben dem Kontonamen und in der Kontoliste zeigte die 2. Skin-Ebene (Mütze, Haare, Kragen, Ärmel) nicht sichtbar – er wird jetzt selbst in 3D gezeichnet, die 2. Ebene steht wie im Spiel plastisch ab
## [1.1.9] - 2026-10-04
### Neu
- Startanimation: Beim Öffnen dreht sich das AeroMC-Logo mit Schriftzug, danach baut sich der Launcher von der Mitte nach außen auf – Schriftzug passend zum Design (Minecraft mit Pixel-Schrift), nicht beim Start im Infobereich
- Lebendige Skin-Figur im Hintergrund: atmet, wiegt sich leicht, der Umhang schwingt, und der Kopf folgt dem Mauszeiger – pausiert, solange der Launcher minimiert oder im Hintergrund ist
- Fenster wie Optionen, Mods, Skin und Freunde wachsen beim Öffnen aus dem angeklickten Knopf heraus
- Instanzen blenden beim Öffnen des Launchers nacheinander ein; beim Darüberfahren hebt sich das Block-Symbol und dreht sich ein Stück
- Designwechsel blenden weich über statt hart umzuschalten
- Optionen → Darstellung → Animationen: „Startanimation“ und „Bewegte Oberfläche“ einzeln abschaltbar; ist in Windows „Animationen anzeigen“ aus, bleibt die Oberfläche ruhig
## [1.1.8] - 2026-10-04
### Behoben
- Das Skin-Fenster und „Aktuellen Skin laden“ im Skin-Editor zeigten bei manchen Konten einen Standard-Skin (z. B. Efe) statt des eigenen Skins – angezeigt wird jetzt der Skin, den andere Spieler im Spiel sehen; direkt nach einer eigenen Skin-Änderung noch kurz der neue Stand aus dem Konto, bis Mojang ihn überall verteilt hat
## [1.1.7] - 2026-10-03 ## [1.1.7] - 2026-10-03
### Neu ### Neu
+9 -3
View File
@@ -6,7 +6,7 @@
Ein Minecraft-Launcher für Windows mit getrennten Instanzen, Mod-Unterstützung, Spielaufzeichnung, Freundesystem und Microsoft-Anmeldung. Ein Minecraft-Launcher für Windows mit getrennten Instanzen, Mod-Unterstützung, Spielaufzeichnung, Freundesystem und Microsoft-Anmeldung.
Aktueller Stand: Version 1.1.7 Aktueller Stand: Version 1.2.0
<img src="docs/aeromc.png" alt="AeroMC – Instanzliste mit Seitenleiste und Skin-Vorschau" width="820" /> <img src="docs/aeromc.png" alt="AeroMC – Instanzliste mit Seitenleiste und Skin-Vorschau" width="820" />
@@ -62,7 +62,7 @@ Der Launcher konzentriert sich auf den Alltag: Instanz anlegen, Konto auswählen
- Eigener Server auf Wunsch verborgen, Freundesystem komplett abschaltbar - Eigener Server auf Wunsch verborgen, Freundesystem komplett abschaltbar
**Konten, Welten und Backups** **Konten, Welten und Backups**
- Microsoft-Login mit mehreren Konten - Microsoft-Login mit mehreren Konten – Wechsel mit einem Klick über das Konto-Menü oben rechts
- Eigenen Skin im Launcher hochladen, mit Live-Vorschau anpassen (klassisch/schlank) und zurücksetzen - Eigenen Skin im Launcher hochladen, mit Live-Vorschau anpassen (klassisch/schlank) und zurücksetzen
- **Skin-Editor mit zwei Schichten**: Skins selbst pixeln – auf der Vorlage oder direkt auf der 3D-Figur, mit Spiegeln, Füllen, Pipette und Rückgängig - **Skin-Editor mit zwei Schichten**: Skins selbst pixeln – auf der Vorlage oder direkt auf der 3D-Figur, mit Spiegeln, Füllen, Pipette und Rückgängig
- **Eigene Umhänge, auch HD bis 1024×512**: im Skin-Editor gestalten, im Spiel in jeder Version und mit jedem Loader sichtbar – für Spieler mit anderen Launchern gibt es den Mod „AeroMC Umhänge“ - **Eigene Umhänge, auch HD bis 1024×512**: im Skin-Editor gestalten, im Spiel in jeder Version und mit jedem Loader sichtbar – für Spieler mit anderen Launchern gibt es den Mod „AeroMC Umhänge“
@@ -132,7 +132,7 @@ Automatische Updates bleiben getrennt: Setup erhält nur Setup-Updates, Portable
Jede Instanz hat: Jede Instanz hat:
- Namen und optionale Gruppe - Namen und optionale Gruppe – Untergruppen mit „/“ trennen, z. B. `Modpacks/Survival` (eingerückt, einzeln einklappbar, per Drag & Drop befüllbar)
- Minecraft-Version und optionalen Mod-Loader - Minecraft-Version und optionalen Mod-Loader
- optionales Icon (vorgegeben, aus Minecraft extrahiert oder eigenes Bild) und Notizen - optionales Icon (vorgegeben, aus Minecraft extrahiert oder eigenes Bild) und Notizen
- eigene Mods, Ressourcenpakete, Shader, Welten, Datenpakete, Screenshots, Aufzeichnungen und Serverliste - eigene Mods, Ressourcenpakete, Shader, Welten, Datenpakete, Screenshots, Aufzeichnungen und Serverliste
@@ -197,6 +197,8 @@ Im Spiel (solange ein über AeroMC gestartetes Minecraft läuft):
Existiert bereits eine Instanz mit demselben Namen, wird sie ersetzt. Existiert bereits eine Instanz mit demselben Namen, wird sie ersetzt.
**Große Importe:** Auch viele Gigabyte werden im Hintergrund kopiert – der Launcher bleibt bedienbar. Vorher prüft AeroMC, ob genug Speicherplatz frei ist. Oben im Import-Fenster stehen Instanz, übertragene GB, Prozent, Tempo, Restzeit und die aktuelle Datei; **Abbrechen** behält fertig übernommene Instanzen und entfernt die halb kopierte. Einzelne gesperrte Dateien werden übersprungen und gemeldet.
Beim offiziellen Minecraft Launcher werden nur benutzerdefinierte Profile angeboten (keine „Latest Release“/„Latest Snapshot“). Bei gemeinsamen Standard-Spielordnern übernimmt AeroMC spielrelevante Daten (Mods, Welten, Konfiguration), nicht den kompletten Cache. Beim offiziellen Minecraft Launcher werden nur benutzerdefinierte Profile angeboten (keine „Latest Release“/„Latest Snapshot“). Bei gemeinsamen Standard-Spielordnern übernimmt AeroMC spielrelevante Daten (Mods, Welten, Konfiguration), nicht den kompletten Cache.
**Modrinth App:** Übernommen werden Name, Gruppe, Symbol, Minecraft-Version, Loader, Arbeitsspeicher, Java-Einstellungen, Spielzeit sowie Mods, Welten und Einstellungen. Modrinth-Modpacks bleiben mit ihrem Paket verknüpft und lassen sich weiter aktualisieren. Ein in der App verlegter Datenordner wird automatisch gefunden, und die Modrinth App darf dabei geöffnet bleiben – AeroMC liest auch ihr Schreibprotokoll mit. An den Daten der Modrinth App wird nichts verändert. **Modrinth App:** Übernommen werden Name, Gruppe, Symbol, Minecraft-Version, Loader, Arbeitsspeicher, Java-Einstellungen, Spielzeit sowie Mods, Welten und Einstellungen. Modrinth-Modpacks bleiben mit ihrem Paket verknüpft und lassen sich weiter aktualisieren. Ein in der App verlegter Datenordner wird automatisch gefunden, und die Modrinth App darf dabei geöffnet bleiben – AeroMC liest auch ihr Schreibprotokoll mit. An den Daten der Modrinth App wird nichts verändert.
@@ -337,6 +339,10 @@ Speicherort: `Dokumente\AeroMC Launcher\Backups`.
### Darstellung ### Darstellung
- Design: Klassisch Dunkel, Klassisch Hell, AeroMC, Nachtlila oder Minecraft – mit Vorschau-Kacheln, gilt nach dem Speichern in allen Fenstern - Design: Klassisch Dunkel, Klassisch Hell, AeroMC, Nachtlila oder Minecraft – mit Vorschau-Kacheln, gilt nach dem Speichern in allen Fenstern
- Block-Symbole räumlich (3D) - Block-Symbole räumlich (3D)
- Animationen:
- **Startanimation**: Beim Öffnen dreht sich das AeroMC-Logo, danach baut sich das Fenster von der Mitte aus auf (nicht beim Start im Infobereich)
- **Bewegte Oberfläche**: Die Skin-Figur im Hintergrund atmet und schaut dem Mauszeiger nach, Fenster wachsen aus dem angeklickten Knopf, Instanzen blenden beim Öffnen ein und heben sich beim Darüberfahren, Designs blenden weich über
- Beides einzeln abschaltbar; ist in Windows „Animationen anzeigen“ aus, bleibt die Oberfläche ruhig
- Skin-Hintergrund: Quelle, Pose, Armform (automatisch vom Konto, normal oder schmal), Deckkraft, optional automatischer Wechsel zwischen mehreren Konten - Skin-Hintergrund: Quelle, Pose, Armform (automatisch vom Konto, normal oder schmal), Deckkraft, optional automatischer Wechsel zwischen mehreren Konten
### Spiel ### Spiel
@@ -5,7 +5,7 @@
}, },
"projects": { "projects": {
"C:\\Users\\Viper\\Desktop\\AeroMC-Launcher\\installer\\AeroMCSetup.csproj": { "C:\\Users\\Viper\\Desktop\\AeroMC-Launcher\\installer\\AeroMCSetup.csproj": {
"version": "0.0.0", "version": "1.2.0",
"restore": { "restore": {
"projectUniqueName": "C:\\Users\\Viper\\Desktop\\AeroMC-Launcher\\installer\\AeroMCSetup.csproj", "projectUniqueName": "C:\\Users\\Viper\\Desktop\\AeroMC-Launcher\\installer\\AeroMCSetup.csproj",
"projectName": "AeroMC-Setup", "projectName": "AeroMC-Setup",
Binary file not shown.
@@ -14,11 +14,11 @@ using System.Reflection;
[assembly: System.Reflection.AssemblyConfigurationAttribute("Release")] [assembly: System.Reflection.AssemblyConfigurationAttribute("Release")]
[assembly: System.Reflection.AssemblyCopyrightAttribute("Copyright (c) M_Viper. Alle Rechte vorbehalten.")] [assembly: System.Reflection.AssemblyCopyrightAttribute("Copyright (c) M_Viper. Alle Rechte vorbehalten.")]
[assembly: System.Reflection.AssemblyDescriptionAttribute("Installiert AeroMC")] [assembly: System.Reflection.AssemblyDescriptionAttribute("Installiert AeroMC")]
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.1.7.0")] [assembly: System.Reflection.AssemblyFileVersionAttribute("1.2.0.0")]
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.1.7")] [assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.2.0")]
[assembly: System.Reflection.AssemblyProductAttribute("AeroMC Setup")] [assembly: System.Reflection.AssemblyProductAttribute("AeroMC Setup")]
[assembly: System.Reflection.AssemblyTitleAttribute("AeroMC Setup")] [assembly: System.Reflection.AssemblyTitleAttribute("AeroMC Setup")]
[assembly: System.Reflection.AssemblyVersionAttribute("1.1.7.0")] [assembly: System.Reflection.AssemblyVersionAttribute("1.2.0.0")]
[assembly: System.Resources.NeutralResourcesLanguageAttribute("de-DE")] [assembly: System.Resources.NeutralResourcesLanguageAttribute("de-DE")]
// Von der MSBuild WriteCodeFragment-Klasse generiert. // Von der MSBuild WriteCodeFragment-Klasse generiert.
@@ -1 +1 @@
acf80df582ccd59574a6332950a42ff0628e88f4288eb303a60e8577b37c7bef e1b134ff0a2fb2387ee195d0721c9af9546a7821126b2fd5bae3f90099e4d686
+1 -1
View File
@@ -418,7 +418,7 @@
"C:\\Users\\Viper\\.nuget\\packages\\": {} "C:\\Users\\Viper\\.nuget\\packages\\": {}
}, },
"project": { "project": {
"version": "0.0.0", "version": "1.2.0",
"restore": { "restore": {
"projectUniqueName": "C:\\Users\\Viper\\Desktop\\AeroMC-Launcher\\installer\\AeroMCSetup.csproj", "projectUniqueName": "C:\\Users\\Viper\\Desktop\\AeroMC-Launcher\\installer\\AeroMCSetup.csproj",
"projectName": "AeroMC-Setup", "projectName": "AeroMC-Setup",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"version": 2, "version": 2,
"dgSpecHash": "FnRoda2hVew=", "dgSpecHash": "IwFtzT9tvgw=",
"success": true, "success": true,
"projectFilePath": "C:\\Users\\Viper\\Desktop\\AeroMC-Launcher\\installer\\AeroMCSetup.csproj", "projectFilePath": "C:\\Users\\Viper\\Desktop\\AeroMC-Launcher\\installer\\AeroMCSetup.csproj",
"expectedPackageFiles": [ "expectedPackageFiles": [
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "aeromc", "name": "aeromc",
"version": "1.1.7", "version": "1.2.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "aeromc", "name": "aeromc",
"version": "1.1.7", "version": "1.2.0",
"license": "SEE LICENSE IN LICENSE", "license": "SEE LICENSE IN LICENSE",
"dependencies": { "dependencies": {
"@xhayper/discord-rpc": "^1.3.4", "@xhayper/discord-rpc": "^1.3.4",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "aeromc", "name": "aeromc",
"productName": "AeroMC", "productName": "AeroMC",
"version": "1.1.7", "version": "1.2.0",
"description": "AeroMC – Instanz-basierter Minecraft-Launcher (à la MultiMC/Prism)", "description": "AeroMC – Instanz-basierter Minecraft-Launcher (à la MultiMC/Prism)",
"homepage": "https://m-viper.de", "homepage": "https://m-viper.de",
"launcherWebsite": "https://aeromc.viper.ipv64.net", "launcherWebsite": "https://aeromc.viper.ipv64.net",
+51
View File
@@ -0,0 +1,51 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta http-equiv="Content-Security-Policy"
content="default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'" />
<title>AeroMC</title>
<style>
html, body { margin: 0; height: 100%; overflow: hidden; background: transparent; }
#bild {
position: absolute; display: none; transform-origin: 0 0; will-change: transform, opacity;
border-radius: 6px; box-shadow: 0 18px 48px rgba(0, 0, 0, .45);
}
</style>
</head>
<body>
<img id="bild" alt="" />
<script>
// Hilfsfenster für "Fenster wachsen aus dem Knopf" (siehe fensteraufbau.js) – wird nach einmaligem Gebrauch geschlossen
const bild = document.getElementById('bild');
let start = 1;
// Bild an die Zielstelle legen und auf Knopfgröße zusammenschrumpfen (noch unsichtbar)
window.vorbereiten = (adresse, nach, von) => new Promise((fertig) => {
bild.onerror = () => fertig(false);
bild.onload = () => {
Object.assign(bild.style, { left: nach.x + 'px', top: nach.y + 'px', width: nach.width + 'px', height: nach.height + 'px' });
// wächst aus der Mitte des angeklickten Knopfs heraus (darf außerhalb liegen)
bild.style.transformOrigin = `${von.x + von.width / 2 - nach.x}px ${von.y + von.height / 2 - nach.y}px`;
start = Math.max(0.06, Math.min(0.5, Math.max(von.width / nach.width, von.height / nach.height)));
bild.style.transform = `scale(${start})`;
bild.style.opacity = '0';
bild.style.display = 'block';
// zwei Bildtakte abwarten, damit der Anfangszustand gezeichnet ist (höchstens 120 ms)
let erledigt = false;
const fertigEinmal = () => { if (!erledigt) { erledigt = true; fertig(true); } };
requestAnimationFrame(() => requestAnimationFrame(fertigEinmal));
setTimeout(fertigEinmal, 120);
};
bild.src = adresse;
});
window.abspielen = (ms) => bild.animate([
{ transform: `scale(${start})`, opacity: 0 },
{ opacity: 1, offset: 0.3 },
{ transform: 'scale(1)', opacity: 1 },
], { duration: ms, easing: 'cubic-bezier(.2, .8, .2, 1)', fill: 'forwards' }).finished.then(() => true, () => false);
</script>
</body>
</html>
+16 -3
View File
@@ -154,14 +154,27 @@ function scan(inputPath) {
return { ok: true, instancesDir, count: list.length, instances: list }; return { ok: true, instancesDir, count: list.length, instances: list };
} }
function kopierNamen(sourceDir) {
return fs.readdirSync(sourceDir).filter((name) => !META_FILES.includes(name));
}
function copyInto(sourceDir, targetDir) { function copyInto(sourceDir, targetDir) {
fs.mkdirSync(targetDir, { recursive: true }); fs.mkdirSync(targetDir, { recursive: true });
for (const name of fs.readdirSync(sourceDir)) { for (const name of kopierNamen(sourceDir)) {
if (META_FILES.includes(name)) continue;
fs.cpSync(path.join(sourceDir, name), path.join(targetDir, name), { recursive: true }); fs.cpSync(path.join(sourceDir, name), path.join(targetDir, name), { recursive: true });
} }
} }
/*
* Was beim Übernehmen kopiert wird: [{ von, name }] – `name` ist der Zielname
* im Spielordner der neuen Instanz. Das Kopieren selbst erledigt kopieren.js
* asynchron mit Fortschritt (main.js → fuehreImportAus).
*/
function kopierQuellen(entry) {
if (!entry.gameDir || !fs.existsSync(entry.gameDir)) return [];
return kopierNamen(entry.gameDir).map((name) => ({ von: path.join(entry.gameDir, name), name }));
}
function importOne(store, entry, copyData = true) { function importOne(store, entry, copyData = true) {
const created = store.createOrReplaceImportedInstance({ const created = store.createOrReplaceImportedInstance({
name: entry.name, name: entry.name,
@@ -193,4 +206,4 @@ function importOne(store, entry, copyData = true) {
return { id: created.id, name: created.name, copied }; return { id: created.id, name: created.name, copied };
} }
module.exports = { scan, importOne, resolveInstancesDir }; module.exports = { scan, importOne, kopierQuellen, resolveInstancesDir };
+2
View File
@@ -308,6 +308,8 @@
:root[data-design="minecraft"] .list{image-rendering:auto} :root[data-design="minecraft"] .list{image-rendering:auto}
:root[data-design="minecraft"] .group-header{font-family:var(--pixel);font-size:16px;font-weight:400;color:#fff;text-shadow:var(--mc-schatten);padding:8px 4px 4px} :root[data-design="minecraft"] .group-header{font-family:var(--pixel);font-size:16px;font-weight:400;color:#fff;text-shadow:var(--mc-schatten);padding:8px 4px 4px}
:root[data-design="minecraft"] .group-count{color:#b8b9bc} :root[data-design="minecraft"] .group-count{color:#b8b9bc}
:root[data-design="minecraft"] .group-sub > .group-header{font-size:14px}
:root[data-design="minecraft"] .group-sub{border-left:2px solid rgba(0,0,0,.45)}
:root[data-design="minecraft"] .group-header .chev{color:#fff;font-family:"Segoe UI",sans-serif;font-size:10px} :root[data-design="minecraft"] .group-header .chev{color:#fff;font-family:"Segoe UI",sans-serif;font-size:10px}
:root[data-design="minecraft"] .group-header::after{height:2px;background:rgba(0,0,0,.55);box-shadow:0 1px 0 rgba(255,255,255,.08)} :root[data-design="minecraft"] .group-header::after{height:2px;background:rgba(0,0,0,.55);box-shadow:0 1px 0 rgba(255,255,255,.08)}
:root[data-design="minecraft"] .card{border-radius:0} :root[data-design="minecraft"] .card{border-radius:0}
+85
View File
@@ -0,0 +1,85 @@
/*
* "Fenster wachsen aus dem Knopf": Ein Popup-Fenster (Optionen, Mods, Skin,
* Freunde …) erscheint nicht schlagartig, sondern wächst aus dem angeklickten
* Knopf heraus. Dafür wird das noch unsichtbare Fenster abfotografiert und das
* Bild in einem durchsichtigen Hilfsfenster vom Knopf aus groß gezogen; am Ende
* übernimmt das echte Fenster an exakt derselben Stelle.
*
* Das Hilfsfenster entsteht jedes Mal neu und gleich in der richtigen Größe:
* Unter Windows färbt sich ein durchsichtiges Fenster schwarz, sobald es
* nachträglich vergrößert wird, und ein verstecktes Fenster zeichnet nicht mehr.
* Abschaltbar unter Optionen → Darstellung → Animationen („Bewegte Oberfläche“).
*/
const path = require('path');
const { BrowserWindow } = require('electron');
const DAUER_MS = 280;
const HOECHSTENS_MS = 2000; // spätestens dann ist das Fenster in jedem Fall zu sehen
const warte = (ms) => new Promise((r) => setTimeout(r, ms));
function vereinigung(a, b) {
const x = Math.min(a.x, b.x);
const y = Math.min(a.y, b.y);
return {
x, y,
width: Math.max(a.x + a.width, b.x + b.width) - x,
height: Math.max(a.y + a.height, b.y + b.height) - y,
};
}
// Rechteck in Koordinaten des Hilfsfensters umrechnen
const relativ = (r, box) => ({ x: r.x - box.x, y: r.y - box.y, width: r.width, height: r.height });
async function abspielen(ziel, von, merke) {
// unsichtbar zeigen, damit Chromium den Inhalt zeichnet, dann abfotografieren
ziel.setOpacity(0);
ziel.setIgnoreMouseEvents(true);
ziel.show();
await warte(90);
if (ziel.isDestroyed()) return;
const bild = await ziel.webContents.capturePage();
if (bild.isEmpty() || ziel.isDestroyed()) return;
const nach = ziel.getBounds();
const box = vereinigung(von, nach);
const hilfe = new BrowserWindow({
x: box.x, y: box.y, width: box.width, height: box.height,
show: false, frame: false, transparent: true, resizable: false, movable: false,
minimizable: false, maximizable: false, fullscreenable: false, focusable: false,
skipTaskbar: true, hasShadow: false, alwaysOnTop: true, backgroundColor: '#00000000',
title: 'AeroMC',
webPreferences: { contextIsolation: true, nodeIntegration: false, sandbox: true },
});
merke(hilfe);
hilfe.setIgnoreMouseEvents(true);
await hilfe.loadFile(path.join(__dirname, 'aufbau.html'));
const ok = await hilfe.webContents.executeJavaScript(
`vorbereiten(${JSON.stringify(bild.toDataURL())}, ${JSON.stringify(relativ(nach, box))}, ${JSON.stringify(relativ(von, box))})`);
if (!ok || hilfe.isDestroyed()) return;
hilfe.showInactive();
await hilfe.webContents.executeJavaScript(`abspielen(${DAUER_MS})`);
}
/**
* Zeigt `ziel` (geladen, aber noch nicht sichtbar) mit Wachs-Animation an.
* `von` ist das Rechteck des angeklickten Knopfs in Bildschirmkoordinaten.
*/
async function wachsen(ziel, von) {
let hilfe = null;
try {
await Promise.race([abspielen(ziel, von, (h) => { hilfe = h; }), warte(HOECHSTENS_MS)]);
} catch {
// Animation ist nur Zierde – im Fehlerfall das Fenster einfach zeigen
} finally {
if (!ziel.isDestroyed()) {
ziel.setOpacity(1);
ziel.setIgnoreMouseEvents(false);
if (!ziel.isVisible()) ziel.show();
ziel.focus();
}
// Hilfsfenster erst nach dem Umschalten schließen, sonst blitzt der Hintergrund durch
setTimeout(() => { if (hilfe && !hilfe.isDestroyed()) hilfe.destroy(); }, 60);
}
}
module.exports = { wachsen };
+27 -1
View File
@@ -31,13 +31,14 @@
<button id="btn-search" class="tbtn tb-lupe" data-ic="search" title="Instanzen suchen (Strg+F)"></button> <button id="btn-search" class="tbtn tb-lupe" data-ic="search" title="Instanzen suchen (Strg+F)"></button>
<input id="search" type="text" placeholder="Suchen… (Esc schließt)" autocomplete="off" title="Instanzen nach Name, Version oder Gruppe filtern" /> <input id="search" type="text" placeholder="Suchen… (Esc schließt)" autocomplete="off" title="Instanzen nach Name, Version oder Gruppe filtern" />
</div> </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-account" class="tbtn acct-btn" title="Konto wechseln, hinzufügen oder verwalten"><span id="account-av" class="acct-av"></span><span id="account-name">Konten</span><span class="acct-pfeil">▾</span></button>
<button id="btn-settings" class="tbtn" data-ic="settings" title="Einstellungen">Optionen</button> <button id="btn-settings" class="tbtn" data-ic="settings" title="Einstellungen">Optionen</button>
</header> </header>
<!-- Arbeitsbereich: Instanzliste + rechte Aktionsleiste --> <!-- Arbeitsbereich: Instanzliste + rechte Aktionsleiste -->
<main class="workspace"> <main class="workspace">
<div class="list-area"> <div class="list-area">
<canvas id="skin-live" class="skin-live hidden"></canvas>
<div id="grid" class="list"></div> <div id="grid" class="list"></div>
<section id="empty" class="empty hidden"> <section id="empty" class="empty hidden">
<h2>Noch keine Instanzen</h2> <h2>Noch keine Instanzen</h2>
@@ -106,6 +107,7 @@
<span>Gruppe <small class="opt">(optional)</small></span> <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" /> <input id="f-group" type="text" list="group-list" placeholder="z. B. Modpacks, Server, Tests" autocomplete="off" />
<datalist id="group-list"></datalist> <datalist id="group-list"></datalist>
<small class="hint">Untergruppe: mit „/“ trennen, z. B. „Modpacks/Survival“.</small>
</label> </label>
<div class="field-row"> <div class="field-row">
@@ -362,6 +364,17 @@
</label> </label>
</section> </section>
<section class="settings-card">
<h5>Animationen</h5>
<label class="checkbox-field">
<input id="s-start-animation" type="checkbox" /> <span>Startanimation beim Öffnen des Launchers</span>
</label>
<label class="checkbox-field">
<input id="s-ui-animationen" type="checkbox" /> <span>Bewegte Oberfläche</span>
</label>
<small class="hint">Startanimation: Das Logo dreht sich, danach baut sich das Fenster von der Mitte aus auf (ab dem nächsten Start, nicht im Infobereich). Bewegte Oberfläche: Die Skin-Figur atmet und schaut dem Mauszeiger nach, Fenster wachsen aus dem angeklickten Knopf, Instanzen blenden ein, Designs blenden weich über.</small>
</section>
<section class="settings-card settings-card-wide"> <section class="settings-card settings-card-wide">
<h5>Skin-Hintergrund</h5> <h5>Skin-Hintergrund</h5>
<label class="field"> <label class="field">
@@ -1548,6 +1561,16 @@
<button id="import-tab-code" class="mtab" data-kind="code">Per Code</button> <button id="import-tab-code" class="mtab" data-kind="code">Per Code</button>
</div> </div>
<div id="import-lauf" class="import-lauf hidden">
<div class="import-lauf-kopf">
<b id="import-lauf-titel">Wird vorbereitet …</b>
<button id="import-lauf-abbrechen" class="btn btn-mini btn-danger" title="Bricht den Import ab – die gerade halb kopierte Instanz wird wieder entfernt, fertig übernommene bleiben">Abbrechen</button>
</div>
<div class="import-balken"><i id="import-lauf-balken"></i></div>
<div id="import-lauf-zeile" class="import-lauf-zeile"></div>
<div id="import-lauf-datei" class="hint import-lauf-datei"></div>
</div>
<div id="import-pane-zip" class="import-pane"> <div id="import-pane-zip" class="import-pane">
<p class="import-copy">Importiert eine einzelne Instanz aus einem ZIP-Archiv.</p> <p class="import-copy">Importiert eine einzelne Instanz aus einem ZIP-Archiv.</p>
<p class="hint">Erkannt werden AeroMC-Exporte (instance.json) und einzelne MultiMC- oder Prism-Instanzen (instance.cfg) – letztere auch als gezippter Instanzordner. Der Launcher legt daraus automatisch eine neue Instanz an, samt Mods, Welten und Konfiguration.</p> <p class="hint">Erkannt werden AeroMC-Exporte (instance.json) und einzelne MultiMC- oder Prism-Instanzen (instance.cfg) – letztere auch als gezippter Instanzordner. Der Launcher legt daraus automatisch eine neue Instanz an, samt Mods, Welten und Konfiguration.</p>
@@ -1828,6 +1851,9 @@
</div> </div>
</div> </div>
<!-- Konto-Menü (Klick auf den Kontoknopf oben rechts) -->
<div id="konto-menu" class="ctx konto-menu hidden" role="menu"></div>
<div id="ctx-menu" class="ctx hidden"> <div id="ctx-menu" class="ctx hidden">
<button class="ctx-item" data-act="p-edit">Symbol ändern</button> <button class="ctx-item" data-act="p-edit">Symbol ändern</button>
<button class="ctx-item" data-act="p-edit">Umbenennen</button> <button class="ctx-item" data-act="p-edit">Umbenennen</button>
+182
View File
@@ -0,0 +1,182 @@
/*
* Große Ordner kopieren, ohne den Launcher einzufrieren.
*
* Früher liefen Importe mit fs.cpSync – synchron im Hauptprozess. Bei
* Spielständen mit vielen Gigabyte stand dann der komplette Launcher
* minutenlang still (keine Anzeige, kein Abbrechen, „Keine Rückmeldung“).
*
* Hier wird asynchron gearbeitet: erst Dateien und Größe zählen, dann mit
* mehreren Dateien gleichzeitig kopieren. Große Dateien gehen stückweise, damit
* der Fortschritt auch bei einer einzelnen 4-GB-Datei weiterläuft. Gesperrte
* Einzeldateien werden übersprungen und gemeldet statt alles abzubrechen.
*/
const fs = require('fs');
const fsp = fs.promises;
const path = require('path');
const { pipeline } = require('stream/promises');
const GLEICHZEITIG = 8; // Dateien parallel
const STUECKWEISE_AB = 64 * 1024 * 1024; // ab hier mit Zwischenständen kopieren
const MAX_TIEFE = 64; // Schutz vor Verknüpfungs-Schleifen
const MELDE_ALLE_MS = 200;
class Abgebrochen extends Error {
constructor() { super('Vom Nutzer abgebrochen.'); this.abgebrochen = true; }
}
// Ordner-/Dateieintrag auflösen (Verknüpfungen werden wie ihr Ziel behandelt –
// die Modrinth App verlinkt z. B. Dateien aus ihrem gemeinsamen Speicher)
async function art(voll, eintrag) {
if (!eintrag.isSymbolicLink()) return { ordner: eintrag.isDirectory(), datei: eintrag.isFile() };
const st = await fsp.stat(voll).catch(() => null);
return st ? { ordner: st.isDirectory(), datei: st.isFile() } : { ordner: false, datei: false };
}
/** Zählt Dateien und Bytes unter allen Quellen (Dateien oder Ordner). */
async function zaehle(quellen, job, melde) {
const summe = { dateien: 0, bytes: 0 };
let zuletzt = 0;
async function lauf(voll, tiefe) {
if (job && job.abgebrochen) throw new Abgebrochen();
const st = await fsp.stat(voll).catch(() => null);
if (!st) return;
if (st.isFile()) { summe.dateien++; summe.bytes += st.size; }
else if (st.isDirectory() && tiefe < MAX_TIEFE) {
const eintraege = await fsp.readdir(voll, { withFileTypes: true }).catch(() => []);
for (const e of eintraege) {
const p = path.join(voll, e.name);
const a = await art(p, e);
if (a.ordner) await lauf(p, tiefe + 1);
else if (a.datei) {
const s = await fsp.stat(p).catch(() => null);
if (s) { summe.dateien++; summe.bytes += s.size; }
}
}
}
if (melde && Date.now() - zuletzt > MELDE_ALLE_MS) { zuletzt = Date.now(); melde({ ...summe }); }
}
for (const q of quellen) await lauf(q, 0);
if (melde) melde({ ...summe });
return summe;
}
/** Freier Platz (Bytes) auf dem Laufwerk von `ordner`, null wenn unbekannt. */
async function freierPlatz(ordner) {
try {
let pruefe = ordner;
while (!fs.existsSync(pruefe) && path.dirname(pruefe) !== pruefe) pruefe = path.dirname(pruefe);
const s = await fsp.statfs(pruefe);
return s.bavail * s.bsize;
} catch { return null; }
}
/**
* Fortschritt über mehrere Kopiervorgänge hinweg (z. B. alle Instanzen eines
* Imports). `melde` bekommt höchstens alle 200 ms einen Zwischenstand.
*/
function neuerFortschritt(gesamt, melde) {
const f = {
gesamtBytes: gesamt.bytes || 0, gesamtDateien: gesamt.dateien || 0,
bytes: 0, dateien: 0, datei: '', uebersprungen: [],
beginn: Date.now(), zuletzt: 0, verlauf: [],
};
f.melden = (sofort) => {
const jetzt = Date.now();
if (!melde || (!sofort && jetzt - f.zuletzt < MELDE_ALLE_MS)) return;
f.zuletzt = jetzt;
// Tempo über die letzten ~5 Sekunden glätten
f.verlauf.push([jetzt, f.bytes]);
while (f.verlauf.length > 2 && jetzt - f.verlauf[0][0] > 5000) f.verlauf.shift();
const [t0, b0] = f.verlauf[0];
const tempo = jetzt > t0 ? ((f.bytes - b0) * 1000) / (jetzt - t0) : 0;
// erst nach 1,5 s schätzen – davor gibt es noch kein brauchbares Tempo
const eingeschwungen = jetzt - f.beginn > 1500;
const rest = eingeschwungen && tempo > 0 && f.gesamtBytes > f.bytes ? Math.round((f.gesamtBytes - f.bytes) / tempo) : null;
melde({
bytes: f.bytes, gesamtBytes: f.gesamtBytes, dateien: f.dateien, gesamtDateien: f.gesamtDateien,
datei: f.datei, tempo: eingeschwungen ? Math.round(tempo) : 0, restSekunden: rest, uebersprungen: f.uebersprungen.length,
});
};
return f;
}
async function kopiereDatei(von, nach, groesse, fortschritt, job) {
if (groesse >= STUECKWEISE_AB) {
// große Datei: stückweise, damit der Balken weiterläuft
const lesen = fs.createReadStream(von, { highWaterMark: 4 * 1024 * 1024 });
lesen.on('data', (stueck) => {
if (job && job.abgebrochen) { lesen.destroy(new Abgebrochen()); return; }
if (fortschritt) { fortschritt.bytes += stueck.length; fortschritt.melden(); }
});
await pipeline(lesen, fs.createWriteStream(nach));
} else {
await fsp.copyFile(von, nach);
if (fortschritt) fortschritt.bytes += groesse;
}
}
/**
* Kopiert `von` (Datei oder Ordner) nach `nach`. Läuft asynchron, meldet über
* `fortschritt` (siehe neuerFortschritt) und bricht ab, sobald job.abgebrochen.
* Gesperrte Einzeldateien landen in fortschritt.uebersprungen; volle Platte
* (ENOSPC) und Abbruch beenden den ganzen Vorgang mit Fehler.
*/
async function kopiere(von, nach, fortschritt, job) {
const laufend = new Set();
let schwererFehler = null;
async function platz(aufgabe) {
while (laufend.size >= GLEICHZEITIG) await Promise.race(laufend);
if (schwererFehler) throw schwererFehler;
const p = aufgabe().catch((err) => {
if (err && (err.abgebrochen || err.code === 'ENOSPC')) schwererFehler = schwererFehler || err;
else if (fortschritt) fortschritt.uebersprungen.push({ datei: err && err.path ? err.path : '?', grund: err && err.code ? err.code : String(err && err.message) });
}).finally(() => laufend.delete(p));
laufend.add(p);
}
const basis = von;
async function datei(q, z) {
if (job && job.abgebrochen) throw new Abgebrochen();
const st = await fsp.stat(q);
if (fortschritt) fortschritt.datei = path.relative(path.dirname(basis), q);
await kopiereDatei(q, z, st.size, fortschritt, job);
if (fortschritt) { fortschritt.dateien++; fortschritt.melden(); }
}
async function ordner(q, z, tiefe) {
if (job && job.abgebrochen) throw new Abgebrochen();
if (schwererFehler) throw schwererFehler;
await fsp.mkdir(z, { recursive: true });
if (tiefe >= MAX_TIEFE) return;
const eintraege = await fsp.readdir(q, { withFileTypes: true });
for (const e of eintraege) {
const qq = path.join(q, e.name);
const zz = path.join(z, e.name);
const a = await art(qq, e);
if (a.ordner) await ordner(qq, zz, tiefe + 1);
else if (a.datei) await platz(() => datei(qq, zz));
}
}
let fehler = null;
try {
const st = await fsp.stat(von);
if (st.isDirectory()) await ordner(von, nach, 0);
else {
await fsp.mkdir(path.dirname(nach), { recursive: true });
await platz(() => datei(von, nach));
}
} catch (err) {
fehler = err;
}
// erst zurückkehren, wenn keine Datei mehr geschrieben wird – sonst würde
// das Aufräumen nach einem Abbruch mit noch laufenden Kopien kollidieren
while (laufend.size) await Promise.allSettled([...laufend]);
if (fehler) throw fehler;
if (schwererFehler) throw schwererFehler;
if (job && job.abgebrochen) throw new Abgebrochen();
if (fortschritt) fortschritt.melden(true);
}
module.exports = { zaehle, kopiere, freierPlatz, neuerFortschritt, Abgebrochen };
+176 -85
View File
@@ -18,6 +18,8 @@ let ffmpegPfad = null;
try { ffmpegPfad = require('ffmpeg-static'); } catch { /* egal, siehe Rückfall in recording:save */ } try { ffmpegPfad = require('ffmpeg-static'); } catch { /* egal, siehe Rückfall in recording:save */ }
const pkg = require('../package.json'); const pkg = require('../package.json');
const store = require('./store'); const store = require('./store');
const startanimation = require('./startanimation');
const fensteraufbau = require('./fensteraufbau');
const services = require('./services'); const services = require('./services');
const launcher = require('./launcher'); const launcher = require('./launcher');
const loaders = require('./loaders'); const loaders = require('./loaders');
@@ -28,6 +30,7 @@ const icons = require('./icons');
const bedrock = require('./bedrock'); const bedrock = require('./bedrock');
const backup = require('./backup'); const backup = require('./backup');
const mmcimport = require('./mmcimport'); const mmcimport = require('./mmcimport');
const kopieren = require('./kopieren');
const cfimport = require('./cfimport'); const cfimport = require('./cfimport');
const mrimport = require('./mrimport'); const mrimport = require('./mrimport');
const mcimport = require('./mcimport'); const mcimport = require('./mcimport');
@@ -154,14 +157,18 @@ function broadcast(channel, payload) {
} }
} }
function openPopout(modalId, params) { // von: Rechteck des angeklickten Knopfs (Bildschirmkoordinaten) – dann wächst
// das Fenster daraus hervor (Optionen → Darstellung → Animationen)
function openPopout(modalId, params, von) {
const wachsen = !!von && store.getSettings().uiAnimationen !== false;
let win = popoutWindows.get(modalId); let win = popoutWindows.get(modalId);
if (win && !win.isDestroyed()) { if (win && !win.isDestroyed()) {
win.show();
win.focus();
// erneut mit (evtl. neuen) Parametern anzeigen, ohne die Seite neu zu // erneut mit (evtl. neuen) Parametern anzeigen, ohne die Seite neu zu
// laden – sonst ginge z. B. der bisher gesammelte Konsolentext verloren // laden – sonst ginge z. B. der bisher gesammelte Konsolentext verloren
win.webContents.send('popout:reopen', params || {}); win.webContents.send('popout:reopen', params || {});
if (wachsen && !win.isVisible()) { fensteraufbau.wachsen(win, von); return; }
win.show();
win.focus();
return; return;
} }
const query = { popout: modalId }; const query = { popout: modalId };
@@ -189,7 +196,7 @@ function openPopout(modalId, params) {
}); });
popoutWindows.set(modalId, win); popoutWindows.set(modalId, win);
win.loadFile(path.join(__dirname, 'index.html'), { query }); win.loadFile(path.join(__dirname, 'index.html'), { query });
win.once('ready-to-show', () => win.show()); win.once('ready-to-show', () => { if (wachsen) fensteraufbau.wachsen(win, von); else win.show(); });
win.webContents.on('did-fail-load', (_e, code, desc) => { win.webContents.on('did-fail-load', (_e, code, desc) => {
console.error('Popup-Fenster "' + modalId + '" konnte nicht laden:', code, desc); console.error('Popup-Fenster "' + modalId + '" konnte nicht laden:', code, desc);
}); });
@@ -212,7 +219,18 @@ let recordingGuardActive = false;
ipcMain.on('recording:guard', (_e, active) => { recordingGuardActive = !!active; }); ipcMain.on('recording:guard', (_e, active) => { recordingGuardActive = !!active; });
ipcMain.on('debug:rendererError', (_e, msg) => console.error('[Renderer-Fehler]', msg)); ipcMain.on('debug:rendererError', (_e, msg) => console.error('[Renderer-Fehler]', msg));
ipcMain.on('popout:open', (_e, modalId, params) => openPopout(modalId, params)); ipcMain.on('popout:open', (e, modalId, params, knopf) => {
// Herkunft fürs Aufwachsen: angeklickter Knopf, sonst die Mitte des Fensters
let von = null;
const quelle = BrowserWindow.fromWebContents(e.sender);
if (quelle && !quelle.isDestroyed() && quelle.isVisible()) {
const cb = quelle.getContentBounds();
von = knopf && Number.isFinite(knopf.x) && Number.isFinite(knopf.y)
? { x: Math.round(cb.x + knopf.x), y: Math.round(cb.y + knopf.y), width: Math.max(8, Math.round(knopf.width)), height: Math.max(8, Math.round(knopf.height)) }
: { x: Math.round(cb.x + cb.width / 2 - 20), y: Math.round(cb.y + cb.height / 2 - 20), width: 40, height: 40 };
}
openPopout(modalId, params, von);
});
// Design gewechselt -> Fensterknöpfe oben rechts umfärben (nur Hauptfenster, // Design gewechselt -> Fensterknöpfe oben rechts umfärben (nur Hauptfenster,
// die Popup-Fenster haben ihren eigenen Kopf) // die Popup-Fenster haben ihren eigenen Kopf)
ipcMain.on('window:titleBar', (e, design) => { ipcMain.on('window:titleBar', (e, design) => {
@@ -752,7 +770,7 @@ function windowIcon() {
return undefined; // Electron-Standard, solange kein Logo hinterlegt ist return undefined; // Electron-Standard, solange kein Logo hinterlegt ist
} }
function createWindow(startHidden) { function createWindow(startHidden, mitStartanimation) {
mainWindow = new BrowserWindow({ mainWindow = new BrowserWindow({
width: 1200, width: 1200,
height: 820, height: 820,
@@ -773,7 +791,7 @@ function createWindow(startHidden) {
}, },
}); });
mainWindow.loadFile(path.join(__dirname, 'index.html')); mainWindow.loadFile(path.join(__dirname, 'index.html'), mitStartanimation ? { query: { startanimation: '1' } } : undefined);
// Bei aktivem Autostart landet das Fenster beim Schließen (X) nur im // Bei aktivem Autostart landet das Fenster beim Schließen (X) nur im
// Infobereich, statt AeroMC wirklich zu beenden – sonst wäre der // Infobereich, statt AeroMC wirklich zu beenden – sonst wäre der
@@ -939,8 +957,13 @@ app.whenReady().then(() => {
// Autostart-Eintrag/Tray mit der Einstellung synchron halten (falls z. B. // Autostart-Eintrag/Tray mit der Einstellung synchron halten (falls z. B.
// von Hand an der Registry gedreht wurde oder nach einem Update) // von Hand an der Registry gedreht wurde oder nach einem Update)
applyAutostart(autostartAn); applyAutostart(autostartAn);
// Reste von im Hintergrund gelöschten Instanzen wegräumen (siehe store.entsorgeOrdner)
store.raeumePapierkorbAuf();
const startHidden = autostartAn && process.argv.includes('--hidden'); const startHidden = autostartAn && process.argv.includes('--hidden');
createWindow(startHidden); // Startanimation: Fenster erst verdeckt anlegen, die Animation zeigt es dann
const mitAnimation = !startHidden && store.getSettings().startAnimation !== false;
createWindow(startHidden || mitAnimation, mitAnimation);
if (mitAnimation) startanimation.spielen(mainWindow, hauptfensterBereit, store.getSettings().theme);
// Fenstererfassung für die Spielaufzeichnung ohne nativen Auswahldialog: // Fenstererfassung für die Spielaufzeichnung ohne nativen Auswahldialog:
// das vom Renderer per recording:setCaptureTarget gewählte Fenster wird // das vom Renderer per recording:setCaptureTarget gewählte Fenster wird
@@ -1788,10 +1811,25 @@ ipcMain.handle('skin:modelFor', async (_e, uuid) => {
} }
}); });
// Zeitpunkt der letzten eigenen Skin-Änderung: danach hinkt der Sitzungsserver
// (= was im Spiel zu sehen ist) noch kurz hinterher
let letzterSkinWechsel = 0;
const SKIN_NACHLAUF_MS = 3 * 60 * 1000;
// Kontoprofil + der Skin, den andere im Spiel sehen (siehe skins.waehleAnzeigeSkin)
async function profilMitSpielSkin(token) {
const p = await skins.holeProfil(token);
if (!p.ok) return p;
const kuerzlich = Date.now() - letzterSkinWechsel < SKIN_NACHLAUF_MS;
let spiel = null;
if (!kuerzlich) { try { spiel = await skins.holeSpielTexturen(p.id); } catch { /* dann eben das Kontoprofil */ } }
return skins.waehleAnzeigeSkin(p, spiel, kuerzlich);
}
ipcMain.handle('skin:profile', async () => { ipcMain.handle('skin:profile', async () => {
const token = await aktivesToken(); const token = await aktivesToken();
if (!token) return { ok: false, message: 'Kein angemeldetes Konto.' }; if (!token) return { ok: false, message: 'Kein angemeldetes Konto.' };
try { return await skins.holeProfil(token); } try { return await profilMitSpielSkin(token); }
catch (err) { return { ok: false, message: 'Profil konnte nicht geladen werden: ' + err.message }; } catch (err) { return { ok: false, message: 'Profil konnte nicht geladen werden: ' + err.message }; }
}); });
@@ -1819,7 +1857,7 @@ ipcMain.handle('skin:apply', async (_e, dateiPfad, variante) => {
if (!token) return { ok: false, message: 'Kein angemeldetes Konto.' }; if (!token) return { ok: false, message: 'Kein angemeldetes Konto.' };
try { try {
const res = await skins.setzeSkin(token, dateiPfad, variante); const res = await skins.setzeSkin(token, dateiPfad, variante);
if (res && res.ok) { armformCache.clear(); broadcast('skin:changed'); } if (res && res.ok) { letzterSkinWechsel = Date.now(); armformCache.clear(); broadcast('skin:changed'); }
return res; return res;
} }
catch (err) { return { ok: false, message: 'Skin konnte nicht gesetzt werden: ' + err.message }; } catch (err) { return { ok: false, message: 'Skin konnte nicht gesetzt werden: ' + err.message }; }
@@ -1830,7 +1868,7 @@ ipcMain.handle('skin:reset', async () => {
if (!token) return { ok: false, message: 'Kein angemeldetes Konto.' }; if (!token) return { ok: false, message: 'Kein angemeldetes Konto.' };
try { try {
const res = await skins.setzeSkinZurueck(token); const res = await skins.setzeSkinZurueck(token);
if (res && res.ok) { armformCache.clear(); broadcast('skin:changed'); } if (res && res.ok) { letzterSkinWechsel = Date.now(); armformCache.clear(); broadcast('skin:changed'); }
return res; return res;
} }
catch (err) { return { ok: false, message: 'Zurücksetzen fehlgeschlagen: ' + err.message }; } catch (err) { return { ok: false, message: 'Zurücksetzen fehlgeschlagen: ' + err.message }; }
@@ -1852,7 +1890,7 @@ ipcMain.handle('skin:currentData', async () => {
const token = await aktivesToken(); const token = await aktivesToken();
if (!token) return { ok: false, message: 'Kein angemeldetes Konto.' }; if (!token) return { ok: false, message: 'Kein angemeldetes Konto.' };
try { try {
const p = await skins.holeProfil(token); const p = await profilMitSpielSkin(token);
if (!p.ok) return p; if (!p.ok) return p;
if (!p.skinUrl) return { ok: false, standard: true, variante: p.variante, message: 'Das Konto nutzt den Standard-Skin.' }; if (!p.skinUrl) return { ok: false, standard: true, variante: p.variante, message: 'Das Konto nutzt den Standard-Skin.' };
const res = await fetch(p.skinUrl); const res = await fetch(p.skinUrl);
@@ -2063,7 +2101,7 @@ ipcMain.handle('skin:applyData', async (_e, base64, variante) => {
try { try {
fs.writeFileSync(tmp, daten); fs.writeFileSync(tmp, daten);
const res = await skins.setzeSkin(token, tmp, variante); const res = await skins.setzeSkin(token, tmp, variante);
if (res && res.ok) { armformCache.clear(); broadcast('skin:changed', { quelle: 'editor' }); } if (res && res.ok) { letzterSkinWechsel = Date.now(); armformCache.clear(); broadcast('skin:changed', { quelle: 'editor' }); }
return res; return res;
} catch (err) { } catch (err) {
return { ok: false, message: 'Skin konnte nicht gesetzt werden: ' + err.message }; return { ok: false, message: 'Skin konnte nicht gesetzt werden: ' + err.message };
@@ -2645,18 +2683,48 @@ function aktivesFreundeKonto() {
ipcMain.handle('friends:enabled', () => store.getSettings().friendsEnabled !== false); ipcMain.handle('friends:enabled', () => store.getSettings().friendsEnabled !== false);
/*
* Der Server kennt den Namen eines Spielers nur, wenn dieser schon einmal über
* AeroMC gespielt hat (Herzschlag). Wer vorher eine Anfrage schickt, käme beim
* Empfänger nur als UUID an – deshalb fehlende Namen bei Mojang nachschlagen
* (öffentliches Profil, 1 Stunde gemerkt).
*/
const spielerNamen = new Map(); // uuid -> { name, zeit }
async function spielerName(uuid) {
const id = String(uuid || '').replace(/-/g, '').toLowerCase();
if (!/^[0-9a-f]{32}$/.test(id)) return null;
const gemerkt = spielerNamen.get(id);
if (gemerkt && Date.now() - gemerkt.zeit < 60 * 60 * 1000) return gemerkt.name;
try {
const res = await fetch('https://sessionserver.mojang.com/session/minecraft/profile/' + id, { headers: { 'User-Agent': 'AeroMC-Launcher' } });
if (!res.ok) return gemerkt ? gemerkt.name : null;
const name = (await res.json()).name || null;
spielerNamen.set(id, { name, zeit: Date.now() });
return name;
} catch {
return gemerkt ? gemerkt.name : null;
}
}
async function mitNamen(liste) {
const eintraege = Array.isArray(liste) ? liste : [];
await Promise.all(eintraege.map(async (e) => {
if (e && !e.username && e.uuid) e.username = await spielerName(e.uuid);
}));
return eintraege;
}
ipcMain.handle('friends:list', async () => { ipcMain.handle('friends:list', async () => {
const konto = aktivesFreundeKonto(); const konto = aktivesFreundeKonto();
if (!konto) return { ok: false, message: 'Kein Konto angemeldet.' }; if (!konto) return { ok: false, message: 'Kein Konto angemeldet.' };
const res = await friends.getFriends(konto.uuid); const res = await friends.getFriends(konto.uuid);
return res.ok ? { ok: true, list: res.data || [] } : res; return res.ok ? { ok: true, list: await mitNamen(res.data) } : res;
}); });
ipcMain.handle('friends:requests', async () => { ipcMain.handle('friends:requests', async () => {
const konto = aktivesFreundeKonto(); const konto = aktivesFreundeKonto();
if (!konto) return { ok: false, message: 'Kein Konto angemeldet.' }; if (!konto) return { ok: false, message: 'Kein Konto angemeldet.' };
const res = await friends.getRequests(konto.uuid); const res = await friends.getRequests(konto.uuid);
return res.ok ? { ok: true, list: res.data || [] } : res; return res.ok ? { ok: true, list: await mitNamen(res.data) } : res;
}); });
ipcMain.handle('friends:add', async (_e, targetUsername) => { ipcMain.handle('friends:add', async (_e, targetUsername) => {
@@ -2670,7 +2738,7 @@ ipcMain.handle('friends:sentRequests', async () => {
const konto = aktivesFreundeKonto(); const konto = aktivesFreundeKonto();
if (!konto) return { ok: false, message: 'Kein Konto angemeldet.' }; if (!konto) return { ok: false, message: 'Kein Konto angemeldet.' };
const res = await friends.getSentRequests(konto.uuid); const res = await friends.getSentRequests(konto.uuid);
return res.ok ? { ok: true, list: res.data || [] } : res; return res.ok ? { ok: true, list: await mitNamen(res.data) } : res;
}); });
ipcMain.handle('friends:cancel', async (_e, otherUuid) => { ipcMain.handle('friends:cancel', async (_e, otherUuid) => {
@@ -2743,7 +2811,12 @@ ipcMain.handle('instances:update', (_e, id, patch) => {
ipcMain.handle('instances:delete', (_e, id) => store.deleteInstance(id)); ipcMain.handle('instances:delete', (_e, id) => store.deleteInstance(id));
ipcMain.handle('instances:duplicate', (_e, id) => store.duplicateInstance(id)); ipcMain.handle('instances:duplicate', async (_e, id) => {
const kopie = store.duplicateInstance(id, true);
try { await kopieren.kopiere(store.gameDir(id), store.gameDir(kopie.id), null, null); }
catch { /* Quelle evtl. leer */ }
return kopie;
});
ipcMain.handle('instances:openFolder', (_e, id) => { ipcMain.handle('instances:openFolder', (_e, id) => {
const p = store.instancePath(id); const p = store.instancePath(id);
@@ -3982,20 +4055,91 @@ ipcMain.handle('mmc:scan', (_e, folder) => {
catch (err) { return { ok: false, reason: err.message }; } catch (err) { return { ok: false, reason: err.message }; }
}); });
ipcMain.handle('mmc:import', (_e, entries, copyData) => { // ---------- Instanzen aus anderen Launchern übernehmen ----------
/*
* Gemeinsamer Ablauf für alle Launcher-Importe. Früher wurde synchron kopiert –
* bei vielen Gigabyte stand der Launcher dann komplett still. Jetzt:
* 1. Größe aller ausgewählten Instanzen ermitteln (asynchron)
* 2. prüfen, ob auf dem Laufwerk genug Platz frei ist
* 3. Instanz für Instanz anlegen und im Hintergrund kopieren
* Fortschritt geht als 'import:fortschritt' an alle Fenster; 'import:abbrechen'
* stoppt den Lauf, die gerade halb kopierte Instanz wird wieder entfernt.
*/
let laufenderImport = null;
const GB = 1024 * 1024 * 1024;
const zuGb = (b) => (b / GB).toLocaleString('de-DE', { maximumFractionDigits: 1 });
ipcMain.handle('import:abbrechen', () => {
if (laufenderImport) laufenderImport.abgebrochen = true;
return !!laufenderImport;
});
async function fuehreImportAus(modul, entries, copyData, nachher) {
if (laufenderImport) return { ok: false, message: 'Es läuft bereits ein Import – bitte warten oder ihn abbrechen.' };
const job = { abgebrochen: false };
laufenderImport = job;
const liste = entries || [];
const done = []; const done = [];
const failed = []; const failed = [];
for (const entry of entries || []) { const melde = (daten) => broadcast('import:fortschritt', daten);
let fortschritt = null;
try { try {
const imported = mmcimport.importOne(store, entry, copyData !== false); // 1) Was wird kopiert und wie viel?
done.push(imported); const plaene = liste.map((entry) => ({ entry, quellen: copyData ? modul.kopierQuellen(entry) : [] }));
let gesamt = { bytes: 0, dateien: 0 };
if (copyData) {
melde({ phase: 'zaehlen', dateien: 0, bytes: 0, instanzen: liste.length });
try {
gesamt = await kopieren.zaehle(plaene.flatMap((p) => p.quellen.map((q) => q.von)), job,
(s) => melde({ phase: 'zaehlen', dateien: s.dateien, bytes: s.bytes, instanzen: liste.length }));
} catch (err) {
if (err.abgebrochen) return { ok: true, imported: [], failed: [], abgebrochen: true, uebersprungen: [] };
throw err;
} }
catch (err) { failed.push({ name: entry.name, message: err.message }); } // 2) Reicht der Platz? (etwas Luft für Windows lassen)
broadcast('mmc:progress', { current: done.length + failed.length, total: entries.length }); const frei = await kopieren.freierPlatz(store.getInstancesDir());
if (frei !== null && gesamt.bytes > frei - 512 * 1024 * 1024) {
return { ok: false, message: `Nicht genug Speicherplatz: Die Instanzen brauchen ${zuGb(gesamt.bytes)} GB, auf dem Laufwerk sind nur ${zuGb(frei)} GB frei. Weniger Instanzen auswählen oder unter Optionen → Allgemein einen anderen Instanz-Ordner wählen.` };
}
}
// 3) Instanz für Instanz – ein gemeinsamer Zähler über alle Instanzen
let kopf = { phase: 'kopieren' };
fortschritt = kopieren.neuerFortschritt(gesamt, (p) => melde(Object.assign({}, kopf, p)));
const f = fortschritt;
for (let i = 0; i < plaene.length; i++) {
if (job.abgebrochen) break;
const { entry, quellen } = plaene[i];
kopf = { phase: 'kopieren', instanz: entry.name, nr: i + 1, von: plaene.length };
f.melden(true);
let angelegt = null;
try {
angelegt = modul.importOne(store, entry, false); // nur Angaben – kopiert wird hier
for (const q of quellen) {
await kopieren.kopiere(q.von, path.join(store.gameDir(angelegt.id), q.name), f, job);
}
angelegt.copied = quellen.length > 0;
if (nachher) await nachher(entry, angelegt, kopf);
done.push(angelegt);
} catch (err) {
// halb kopierte Instanz nicht liegen lassen
if (angelegt) { try { store.deleteInstance(angelegt.id); } catch { /* Aufräumen ist kein Muss */ } }
if (!err.abgebrochen) {
failed.push({ name: entry.name, message: err.code === 'ENOSPC' ? 'Speicherplatz voll' : err.message });
if (err.code === 'ENOSPC') break;
}
}
}
} finally {
laufenderImport = null;
} }
if (done.length) broadcast('instances:changed'); if (done.length) broadcast('instances:changed');
return { ok: true, imported: done, failed }; return {
}); ok: true, imported: done, failed, abgebrochen: job.abgebrochen,
uebersprungen: fortschritt ? fortschritt.uebersprungen.slice(0, 50) : [],
};
}
ipcMain.handle('mmc:import', (_e, entries, copyData) => fuehreImportAus(mmcimport, entries, copyData !== false));
ipcMain.handle('cf:pickFolder', async (e) => { ipcMain.handle('cf:pickFolder', async (e) => {
const res = await dialog.showOpenDialog(BrowserWindow.fromWebContents(e.sender) || mainWindow, { const res = await dialog.showOpenDialog(BrowserWindow.fromWebContents(e.sender) || mainWindow, {
@@ -4010,20 +4154,7 @@ ipcMain.handle('cf:scan', (_e, folder) => {
catch (err) { return { ok: false, reason: err.message }; } catch (err) { return { ok: false, reason: err.message }; }
}); });
ipcMain.handle('cf:import', (_e, entries, copyData) => { ipcMain.handle('cf:import', (_e, entries, copyData) => fuehreImportAus(cfimport, entries, copyData !== false));
const done = [];
const failed = [];
for (const entry of entries || []) {
try {
const imported = cfimport.importOne(store, entry, copyData !== false);
done.push(imported);
}
catch (err) { failed.push({ name: entry.name, message: err.message }); }
broadcast('cf:progress', { current: done.length + failed.length, total: entries.length });
}
if (done.length) broadcast('instances:changed');
return { ok: true, imported: done, failed };
});
// ---------- Modrinth App ---------- // ---------- Modrinth App ----------
ipcMain.handle('mr:pickFolder', async (e) => { ipcMain.handle('mr:pickFolder', async (e) => {
@@ -4085,26 +4216,12 @@ async function verknuepfeModrinthModpack(instId, link) {
return true; return true;
} }
ipcMain.handle('mr:import', async (_e, entries, copyData) => { ipcMain.handle('mr:import', (_e, entries, copyData) => fuehreImportAus(mrimport, entries, copyData !== false, async (entry, imported, kopf) => {
const done = []; if (!(entry.modpack && entry.modpack.projectId && entry.modpack.versionId)) return;
const failed = []; broadcast('import:fortschritt', Object.assign({}, kopf, { phase: 'modpack' }));
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); } try { imported.modpack = await verknuepfeModrinthModpack(imported.id, entry.modpack); }
catch { imported.modpack = false; } 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) => { ipcMain.handle('mc:pickFolder', async (e) => {
const res = await dialog.showOpenDialog(BrowserWindow.fromWebContents(e.sender) || mainWindow, { const res = await dialog.showOpenDialog(BrowserWindow.fromWebContents(e.sender) || mainWindow, {
@@ -4119,20 +4236,7 @@ ipcMain.handle('mc:scan', (_e, folder) => {
catch (err) { return { ok: false, reason: err.message }; } catch (err) { return { ok: false, reason: err.message }; }
}); });
ipcMain.handle('mc:import', (_e, entries, copyData) => { ipcMain.handle('mc:import', (_e, entries, copyData) => fuehreImportAus(mcimport, entries, copyData !== false));
const done = [];
const failed = [];
for (const entry of entries || []) {
try {
const imported = mcimport.importOne(store, entry, copyData !== false);
done.push(imported);
}
catch (err) { failed.push({ name: entry.name, message: err.message }); }
broadcast('mc:progress', { current: done.length + failed.length, total: entries.length });
}
if (done.length) broadcast('instances:changed');
return { ok: true, imported: done, failed };
});
ipcMain.handle('technic:pickFolder', async (e) => { ipcMain.handle('technic:pickFolder', async (e) => {
const res = await dialog.showOpenDialog(BrowserWindow.fromWebContents(e.sender) || mainWindow, { const res = await dialog.showOpenDialog(BrowserWindow.fromWebContents(e.sender) || mainWindow, {
@@ -4147,20 +4251,7 @@ ipcMain.handle('technic:scan', (_e, folder) => {
catch (err) { return { ok: false, reason: err.message }; } catch (err) { return { ok: false, reason: err.message }; }
}); });
ipcMain.handle('technic:import', (_e, entries, copyData) => { ipcMain.handle('technic:import', (_e, entries, copyData) => fuehreImportAus(technicimport, entries, copyData !== false));
const done = [];
const failed = [];
for (const entry of entries || []) {
try {
const imported = technicimport.importOne(store, entry, copyData !== false);
done.push(imported);
}
catch (err) { failed.push({ name: entry.name, message: err.message }); }
broadcast('technic:progress', { current: done.length + failed.length, total: entries.length });
}
if (done.length) broadcast('instances:changed');
return { ok: true, imported: done, failed };
});
// ---- Technic-Plattform: Modpack-Katalog durchsuchen und direkt installieren ---- // ---- Technic-Plattform: Modpack-Katalog durchsuchen und direkt installieren ----
+20 -5
View File
@@ -173,16 +173,31 @@ function scan(inputPath) {
return { ok: true, rootDir, count: list.length, instances: list }; return { ok: true, rootDir, count: list.length, instances: list };
} }
function kopierNamen(sourceDir, usesDefaultGameDir) {
return fs.readdirSync(sourceDir).filter((name) => {
if (/^launcher_profiles.*\.json$/i.test(name)) return false;
if (name === 'versions' || name === 'libraries' || name === 'assets' || name === 'runtime' || name === 'webcache2') return false;
return !(usesDefaultGameDir && !shouldCopyRootEntry(name));
});
}
function copyInto(sourceDir, targetDir, usesDefaultGameDir) { function copyInto(sourceDir, targetDir, usesDefaultGameDir) {
fs.mkdirSync(targetDir, { recursive: true }); fs.mkdirSync(targetDir, { recursive: true });
for (const name of fs.readdirSync(sourceDir)) { for (const name of kopierNamen(sourceDir, usesDefaultGameDir)) {
if (/^launcher_profiles.*\.json$/i.test(name)) continue;
if (name === 'versions' || name === 'libraries' || name === 'assets' || name === 'runtime' || name === 'webcache2') continue;
if (usesDefaultGameDir && !shouldCopyRootEntry(name)) continue;
fs.cpSync(path.join(sourceDir, name), path.join(targetDir, name), { recursive: true }); fs.cpSync(path.join(sourceDir, name), path.join(targetDir, name), { recursive: true });
} }
} }
/*
* Was beim Übernehmen kopiert wird: [{ von, name }] – `name` ist der Zielname
* im Spielordner der neuen Instanz. Das Kopieren selbst erledigt kopieren.js
* asynchron mit Fortschritt (main.js → fuehreImportAus).
*/
function kopierQuellen(entry) {
if (!entry.gameDir || !fs.existsSync(entry.gameDir)) return [];
return kopierNamen(entry.gameDir, !!entry.usesDefaultGameDir).map((name) => ({ von: path.join(entry.gameDir, name), name }));
}
function importOne(store, entry, copyData = true) { function importOne(store, entry, copyData = true) {
const created = store.createOrReplaceImportedInstance({ const created = store.createOrReplaceImportedInstance({
name: entry.name, name: entry.name,
@@ -214,4 +229,4 @@ function importOne(store, entry, copyData = true) {
return { id: created.id, name: created.name, copied }; return { id: created.id, name: created.name, copied };
} }
module.exports = { scan, importOne, parseLastVersionId, resolveLauncherRoot }; module.exports = { scan, importOne, kopierQuellen, parseLastVersionId, resolveLauncherRoot };
+12 -1
View File
@@ -185,4 +185,15 @@ function importOne(store, entry, copyData = true) {
return { id: created.id, name: created.name, copied }; return { id: created.id, name: created.name, copied };
} }
module.exports = { scan, scanSingle, importOne, resolveInstancesDir, readInstance, findInstanceDir };
/*
* Was beim Übernehmen kopiert wird: [{ von, name }] – `name` ist der Zielname
* im Spielordner der neuen Instanz. Das Kopieren selbst erledigt kopieren.js
* asynchron mit Fortschritt (main.js → fuehreImportAus).
*/
function kopierQuellen(entry) {
if (!entry.gameDir || !fs.existsSync(entry.gameDir)) return [];
return fs.readdirSync(entry.gameDir).map((name) => ({ von: path.join(entry.gameDir, name), name }));
}
module.exports = { scan, scanSingle, importOne, kopierQuellen, resolveInstancesDir, readInstance, findInstanceDir };
+12 -1
View File
@@ -395,6 +395,17 @@ async function scan(eingabe) {
// gehört zur Modrinth App bzw. wird ohnehin neu erzeugt // gehört zur Modrinth App bzw. wird ohnehin neu erzeugt
const NICHT_KOPIEREN = new Set(['profile.json', 'logs', '.fabric', '.quilt']); const NICHT_KOPIEREN = new Set(['profile.json', 'logs', '.fabric', '.quilt']);
/*
* Was beim Übernehmen kopiert wird: [{ von, name }] – `name` ist der Zielname
* im Spielordner der neuen Instanz. Das Kopieren selbst erledigt kopieren.js
* asynchron mit Fortschritt (main.js → fuehreImportAus).
*/
function kopierQuellen(entry) {
if (!entry.gameDir || !fs.existsSync(entry.gameDir)) return [];
return fs.readdirSync(entry.gameDir).filter((name) => !NICHT_KOPIEREN.has(name.toLowerCase()))
.map((name) => ({ von: path.join(entry.gameDir, name), name }));
}
function copyInto(quelle, ziel) { function copyInto(quelle, ziel) {
fs.mkdirSync(ziel, { recursive: true }); fs.mkdirSync(ziel, { recursive: true });
for (const name of fs.readdirSync(quelle)) { for (const name of fs.readdirSync(quelle)) {
@@ -434,4 +445,4 @@ function importOne(store, entry, copyData = true) {
return { id: created.id, name: created.name, copied }; return { id: created.id, name: created.name, copied };
} }
module.exports = { scan, importOne, standardOrdner }; module.exports = { scan, importOne, kopierQuellen, standardOrdner };
+23 -1
View File
@@ -16,6 +16,21 @@ window.addEventListener('unhandledrejection', (e) => {
ipcRenderer.send('debug:rendererError', 'unhandledrejection: ' + ((e.reason && e.reason.stack) || e.reason)); ipcRenderer.send('debug:rendererError', 'unhandledrejection: ' + ((e.reason && e.reason.stack) || e.reason));
}); });
// Zuletzt angeklickter Knopf: Popup-Fenster wachsen daraus hervor
// (main.js → openPopout). Gilt nur kurz, sonst kämen sie aus einem alten Klick.
let letzterKlick = null;
window.addEventListener('pointerdown', (e) => {
const t = e.target && e.target.closest ? (e.target.closest('button, a, [role="button"], .card, .snav') || e.target) : null;
if (!t || !t.getBoundingClientRect) return;
const r = t.getBoundingClientRect();
letzterKlick = { x: r.left, y: r.top, width: r.width, height: r.height, zeit: Date.now() };
}, true);
function frischerKlick() {
if (!letzterKlick || Date.now() - letzterKlick.zeit > 2000) return null;
const { x, y, width, height } = letzterKlick;
return { x, y, width, height };
}
contextBridge.exposeInMainWorld('api', { contextBridge.exposeInMainWorld('api', {
// Einstellungen // Einstellungen
getSettings: () => ipcRenderer.invoke('settings:get'), getSettings: () => ipcRenderer.invoke('settings:get'),
@@ -196,6 +211,13 @@ contextBridge.exposeInMainWorld('api', {
// Dialoge / App // Dialoge / App
// MultiMC-Import // MultiMC-Import
// gemeinsamer Fortschritt aller Launcher-Importe (main.js → fuehreImportAus)
onImportFortschritt: (cb) => {
const l = (_e, d) => cb(d);
ipcRenderer.on('import:fortschritt', l);
return () => ipcRenderer.removeListener('import:fortschritt', l);
},
importAbbrechen: () => ipcRenderer.invoke('import:abbrechen'),
mmcPickFolder: () => ipcRenderer.invoke('mmc:pickFolder'), mmcPickFolder: () => ipcRenderer.invoke('mmc:pickFolder'),
mmcScan: (folder) => ipcRenderer.invoke('mmc:scan', folder), mmcScan: (folder) => ipcRenderer.invoke('mmc:scan', folder),
mmcImport: (entries, copyData) => ipcRenderer.invoke('mmc:import', entries, copyData), mmcImport: (entries, copyData) => ipcRenderer.invoke('mmc:import', entries, copyData),
@@ -288,7 +310,7 @@ contextBridge.exposeInMainWorld('api', {
appLicense: () => ipcRenderer.invoke('app:license'), appLicense: () => ipcRenderer.invoke('app:license'),
// Einstellungen/Konsole als eigenes, frei verschiebbares Fenster // Einstellungen/Konsole als eigenes, frei verschiebbares Fenster
popoutOpen: (modalId, params) => ipcRenderer.send('popout:open', modalId, params), popoutOpen: (modalId, params) => ipcRenderer.send('popout:open', modalId, params, frischerKlick()),
popoutHide: () => ipcRenderer.send('popout:hide'), popoutHide: () => ipcRenderer.send('popout:hide'),
setTitleBarDesign: (design) => ipcRenderer.send('window:titleBar', design), setTitleBarDesign: (design) => ipcRenderer.send('window:titleBar', design),
onPopoutReopen: (cb) => { onPopoutReopen: (cb) => {
+415 -45
View File
@@ -541,6 +541,31 @@ function numOrNull(v) { const n = parseInt(v, 10); return Number.isFinite(n) ? n
// 'lila' = Aero in dunklem Lila, 'minecraft' = Block-Optik. Alle außer Klassisch // 'lila' = Aero in dunklem Lila, 'minecraft' = Block-Optik. Alle außer Klassisch
// bauen auf den dunklen Regeln auf (data-theme="dark") und überschreiben sie über data-design. // bauen auf den dunklen Regeln auf (data-theme="dark") und überschreiben sie über data-design.
const DESIGNS = ['dark', 'light', 'aero', 'lila', 'minecraft']; const DESIGNS = ['dark', 'light', 'aero', 'lila', 'minecraft'];
// ---------- Bewegte Oberfläche (Optionen → Darstellung → Animationen) ----------
let uiAnimationen = true;
const MIT_STARTANIMATION = new URLSearchParams(location.search).get('startanimation') === '1';
function bewegungErlaubt() {
return uiAnimationen && !window.matchMedia('(prefers-reduced-motion: reduce)').matches;
}
function setzeUiAnimationen(an) {
uiAnimationen = an !== false;
document.documentElement.classList.toggle('ui-animiert', bewegungErlaubt());
if (!bewegungErlaubt()) beendeSkinLive();
}
// Design weich überblenden statt hart umzuschalten (View Transitions von Chromium)
function designUeberblenden(theme) {
const root = document.documentElement;
const vorher = root.getAttribute('data-design') + '/' + root.getAttribute('data-theme');
const t = DESIGNS.includes(theme) ? theme : 'dark';
const nachher = (['aero', 'lila', 'minecraft'].includes(t) ? t : 'klassisch') + '/' + (t === 'light' ? 'light' : 'dark');
// versteckte Fenster (z. B. Hauptfenster im Infobereich) zeichnen nicht –
// dort liefe der Übergang nie an, also gleich umschalten
if (vorher === nachher || !bewegungErlaubt() || !document.startViewTransition || document.hidden) { applyTheme(theme); return; }
document.startViewTransition(() => applyTheme(theme));
setTimeout(() => applyTheme(theme), 600); // Sicherheitsnetz, falls der Übergang hängt
}
function applyTheme(theme) { function applyTheme(theme) {
const t = DESIGNS.includes(theme) ? theme : 'dark'; const t = DESIGNS.includes(theme) ? theme : 'dark';
const root = document.documentElement; const root = document.documentElement;
@@ -561,6 +586,73 @@ let importKind = 'zip';
let importWarteschlange = []; let importWarteschlange = [];
// Nach einem Import nicht gleich schließen, solange weitere vorbereitete Launcher warten // Nach einem Import nicht gleich schließen, solange weitere vorbereitete Launcher warten
// ---------- Fortschritt großer Importe ----------
function zuGroesse(b) {
const gb = b / 1073741824;
if (gb >= 1) return gb.toLocaleString('de-DE', { maximumFractionDigits: 1, minimumFractionDigits: 1 }) + ' GB';
return Math.max(0, Math.round(b / 1048576)).toLocaleString('de-DE') + ' MB';
}
function zuDauer(s) {
if (s == null || !isFinite(s)) return '';
if (s < 60) return 'noch wenige Sekunden';
const m = Math.round(s / 60);
if (m < 60) return `noch ca. ${m} Min.`;
return `noch ca. ${Math.floor(m / 60)} Std. ${m % 60} Min.`;
}
function zeigeImportFortschritt(p) {
const balken = el('import-lauf-balken');
const leiste = balken.parentElement;
el('import-lauf').classList.remove('hidden');
if (p.phase === 'zaehlen') {
el('import-lauf-titel').textContent = 'Größe wird ermittelt …';
leiste.classList.add('unbestimmt');
el('import-lauf-zeile').textContent = `${p.dateien.toLocaleString('de-DE')} ${p.dateien === 1 ? 'Datei' : 'Dateien'} · ${zuGroesse(p.bytes)} gefunden`;
el('import-lauf-datei').textContent = '';
return;
}
leiste.classList.remove('unbestimmt');
const titel = p.von > 1 ? `Instanz ${p.nr} von ${p.von}: ${p.instanz}` : `Übernehme ${p.instanz}`;
if (p.phase === 'modpack') {
el('import-lauf-titel').textContent = `${titel} – Modpack-Verknüpfung …`;
return;
}
el('import-lauf-titel').textContent = titel;
const anteil = p.gesamtBytes > 0 ? Math.min(1, p.bytes / p.gesamtBytes) : (p.gesamtDateien ? p.dateien / p.gesamtDateien : 0);
balken.style.width = (anteil * 100).toFixed(1) + '%';
const teile = [`${zuGroesse(p.bytes)} von ${zuGroesse(p.gesamtBytes)} (${Math.round(anteil * 100)} %)`];
if (p.tempo > 0) teile.push(zuGroesse(p.tempo) + '/s');
const rest = zuDauer(p.restSekunden);
if (rest) teile.push(rest);
el('import-lauf-zeile').textContent = teile.join(' · ');
el('import-lauf-datei').textContent = p.datei ? `${p.dateien.toLocaleString('de-DE')} / ${p.gesamtDateien.toLocaleString('de-DE')} Dateien · ${p.datei}` : '';
}
/*
* Führt einen Launcher-Import mit Fortschrittsanzeige aus. Gibt das Ergebnis
* zurück – oder null, wenn er gar nicht erst lief (z. B. zu wenig Platz).
*/
async function importMitFortschritt(aufruf) {
el('import-lauf-titel').textContent = 'Wird vorbereitet …';
el('import-lauf-zeile').textContent = '';
el('import-lauf-datei').textContent = '';
el('import-lauf-balken').style.width = '0';
el('import-lauf-abbrechen').disabled = false;
el('import-lauf').classList.remove('hidden');
let res;
try { res = await aufruf(); }
catch (err) { res = { ok: false, message: err.message }; }
el('import-lauf').classList.add('hidden');
if (!res || !res.ok) {
toast(res && res.message ? res.message : 'Import fehlgeschlagen.', true);
return null;
}
if (res.uebersprungen && res.uebersprungen.length) {
// nach der Zusammenfassung zeigen, sonst überschreibt diese den Hinweis
setTimeout(() => toast(`${res.uebersprungen.length} Datei${res.uebersprungen.length === 1 ? '' : 'en'} konnte${res.uebersprungen.length === 1 ? '' : 'n'} nicht kopiert werden (z. B. gerade geöffnet) und wurde${res.uebersprungen.length === 1 ? '' : 'n'} übersprungen.`, true), 3000);
}
return res;
}
function importAbgeschlossen(kind) { function importAbgeschlossen(kind) {
importWarteschlange = importWarteschlange.filter((k) => k !== kind); importWarteschlange = importWarteschlange.filter((k) => k !== kind);
if (importWarteschlange.length) { if (importWarteschlange.length) {
@@ -864,12 +956,13 @@ async function runMcImport() {
el('mc-progress').textContent = 'Übernehme …'; el('mc-progress').textContent = 'Übernehme …';
el('mc-progress').classList.remove('hidden'); el('mc-progress').classList.remove('hidden');
const res = await window.api.mcImport(entries, copy); const res = await importMitFortschritt(() => window.api.mcImport(entries, copy));
if (!res) { el('mc-do').disabled = false; el('mc-progress').classList.add('hidden'); return; }
await reload(); await reload();
importAbgeschlossen('mc'); importAbgeschlossen('mc');
const n = res.imported.length; const n = res.imported.length;
const f = res.failed.length; const f = res.failed.length;
toast(`${n} Profil${n === 1 ? '' : 'e'} aus dem Minecraft Launcher übernommen${f ? ` · ${f} fehlgeschlagen` : ''}.`, f > 0); toast(`${n} Profil${n === 1 ? '' : 'e'} aus dem Minecraft Launcher übernommen${f ? ` · ${f} fehlgeschlagen` : ''}${res.abgebrochen ? ' – Rest abgebrochen' : ''}.`, f > 0);
if (instances.length) await selectInstance(instances[0].id); if (instances.length) await selectInstance(instances[0].id);
} }
@@ -934,12 +1027,13 @@ async function runCfImport() {
el('cf-progress').textContent = 'Übernehme …'; el('cf-progress').textContent = 'Übernehme …';
el('cf-progress').classList.remove('hidden'); el('cf-progress').classList.remove('hidden');
const res = await window.api.cfImport(entries, copy); const res = await importMitFortschritt(() => window.api.cfImport(entries, copy));
if (!res) { el('cf-do').disabled = false; el('cf-progress').classList.add('hidden'); return; }
await reload(); await reload();
importAbgeschlossen('cf'); importAbgeschlossen('cf');
const n = res.imported.length; const n = res.imported.length;
const f = res.failed.length; const f = res.failed.length;
toast(`${n} CurseForge-Instanz${n === 1 ? '' : 'en'} übernommen${f ? ` · ${f} fehlgeschlagen` : ''}.`, f > 0); toast(`${n} CurseForge-Instanz${n === 1 ? '' : 'en'} übernommen${f ? ` · ${f} fehlgeschlagen` : ''}${res.abgebrochen ? ' – Rest abgebrochen' : ''}.`, f > 0);
if (instances.length) await selectInstance(instances[0].id); if (instances.length) await selectInstance(instances[0].id);
} }
@@ -1008,13 +1102,14 @@ async function runMrImport() {
el('mr-progress').textContent = 'Übernehme …'; el('mr-progress').textContent = 'Übernehme …';
el('mr-progress').classList.remove('hidden'); el('mr-progress').classList.remove('hidden');
const res = await window.api.mrImport(entries, copy); const res = await importMitFortschritt(() => window.api.mrImport(entries, copy));
if (!res) { el('mr-do').disabled = false; el('mr-progress').classList.add('hidden'); return; }
await reload(); await reload();
importAbgeschlossen('mr'); importAbgeschlossen('mr');
const n = res.imported.length; const n = res.imported.length;
const f = res.failed.length; const f = res.failed.length;
const ohneLink = res.imported.filter((i) => i.modpack === false).length; const ohneLink = res.imported.filter((i) => i.modpack === false).length;
toast(`${n} Instanz${n === 1 ? '' : 'en'} aus der Modrinth App übernommen${f ? ` · ${f} fehlgeschlagen` : ''}` toast(`${n} Instanz${n === 1 ? '' : 'en'} aus der Modrinth App übernommen${f ? ` · ${f} fehlgeschlagen` : ''}${res.abgebrochen ? ' – Rest abgebrochen' : ''}`
+ (ohneLink ? ` · ${ohneLink}× Modpack-Verknüpfung nicht möglich (offline?)` : '') + '.', f > 0); + (ohneLink ? ` · ${ohneLink}× Modpack-Verknüpfung nicht möglich (offline?)` : '') + '.', f > 0);
if (instances.length) await selectInstance(instances[0].id); if (instances.length) await selectInstance(instances[0].id);
} }
@@ -1080,12 +1175,13 @@ async function runMmcImport() {
el('mmc-progress').textContent = 'Übernehme …'; el('mmc-progress').textContent = 'Übernehme …';
el('mmc-progress').classList.remove('hidden'); el('mmc-progress').classList.remove('hidden');
const res = await window.api.mmcImport(entries, copy); const res = await importMitFortschritt(() => window.api.mmcImport(entries, copy));
if (!res) { el('mmc-do').disabled = false; el('mmc-progress').classList.add('hidden'); return; }
await reload(); await reload();
importAbgeschlossen('mmc'); importAbgeschlossen('mmc');
const n = res.imported.length; const n = res.imported.length;
const f = res.failed.length; const f = res.failed.length;
toast(`${n} Instanz${n === 1 ? '' : 'en'} übernommen${f ? ` · ${f} fehlgeschlagen` : ''}.`, f > 0); toast(`${n} Instanz${n === 1 ? '' : 'en'} übernommen${f ? ` · ${f} fehlgeschlagen` : ''}${res.abgebrochen ? ' – Rest abgebrochen' : ''}.`, f > 0);
if (instances.length) await selectInstance(instances[0].id); if (instances.length) await selectInstance(instances[0].id);
} }
@@ -1206,12 +1302,13 @@ async function runTechnicImport() {
el('technic-progress').textContent = 'Übernehme …'; el('technic-progress').textContent = 'Übernehme …';
el('technic-progress').classList.remove('hidden'); el('technic-progress').classList.remove('hidden');
const res = await window.api.technicImport(entries, copy); const res = await importMitFortschritt(() => window.api.technicImport(entries, copy));
if (!res) { el('technic-do').disabled = false; el('technic-progress').classList.add('hidden'); return; }
await reload(); await reload();
importAbgeschlossen('technic'); importAbgeschlossen('technic');
const n = res.imported.length; const n = res.imported.length;
const f = res.failed.length; const f = res.failed.length;
toast(`${n} Technic-Modpack${n === 1 ? '' : 's'} übernommen${f ? ` · ${f} fehlgeschlagen` : ''}.`, f > 0); toast(`${n} Technic-Modpack${n === 1 ? '' : 's'} übernommen${f ? ` · ${f} fehlgeschlagen` : ''}${res.abgebrochen ? ' – Rest abgebrochen' : ''}.`, f > 0);
if (instances.length) await selectInstance(instances[0].id); if (instances.length) await selectInstance(instances[0].id);
} }
@@ -1427,6 +1524,55 @@ function closeContextMenu() {
el('ctx-menu').classList.add('hidden'); el('ctx-menu').classList.add('hidden');
} }
// ---------- Konto-Menü (Klick auf den Kontoknopf) ----------
function schliesseKontoMenue() {
el('konto-menu').classList.add('hidden');
}
async function oeffneKontoMenue() {
const menu = el('konto-menu');
if (!menu.classList.contains('hidden')) { schliesseKontoMenue(); return; }
closeContextMenu();
// feste, alphabetische Reihenfolge – sonst springt das aktive Konto nach oben
const konten = (await window.api.authAccounts()).slice().sort((a, b) => a.name.localeCompare(b.name, 'de', { sensitivity: 'base' }));
menu.innerHTML = '';
const eintrag = (inhalt, aktion, klasse = '') => {
const b = document.createElement('button');
b.className = 'ctx-item konto-eintrag ' + klasse;
b.innerHTML = inhalt;
b.addEventListener('click', async () => { schliesseKontoMenue(); await aktion(); });
menu.appendChild(b);
return b;
};
for (const k of konten) {
const b = eintrag(
`<span class="konto-haken">${k.active ? '✓' : ''}</span><img alt="" /><span class="konto-name">${escapeHtml(k.name)}</span>${k.playing ? '<span class="konto-spielt">spielt</span>' : ''}`,
async () => {
if (k.active) return;
await window.api.authSetActive(k.id);
await refreshAccountButton();
await refreshAllAccountLists();
await refreshSkinBackground();
toast('Aktives Konto: ' + k.name);
},
k.active ? 'aktiv' : '');
b.title = k.active ? 'Wird beim nächsten Start verwendet' : 'Für den nächsten Start verwenden';
const img = b.querySelector('img');
kontoKopf(k.id).then((src) => { img.src = src; });
}
if (konten.length) menu.appendChild(Object.assign(document.createElement('div'), { className: 'ctx-sep' }));
eintrag(`${svgIcon('plus')}<span class="konto-name">Konto hinzufügen…</span>`, () => openLogin({ addAccount: true }));
if (konten.some((k) => k.active)) eintrag(`${svgIcon('user')}<span class="konto-name">Skin ändern…</span>`, () => openSkin());
eintrag(`${svgIcon('users')}<span class="konto-name">Konten verwalten…</span>`, () => openLogin());
// unter dem Knopf, rechtsbündig – aber im Fenster bleiben
const r = el('btn-account').getBoundingClientRect();
menu.classList.remove('hidden');
const breite = menu.offsetWidth;
menu.style.left = Math.max(6, Math.min(r.right - breite, innerWidth - breite - 6)) + 'px';
menu.style.top = (r.bottom + 4) + 'px';
}
function formatVersionLabel(version) { function formatVersionLabel(version) {
return String(version || '') return String(version || '')
.replace(/^v/i, '') .replace(/^v/i, '')
@@ -1553,7 +1699,7 @@ async function applySkinBackground(uuidOverride) {
} else if (s.bgSkin) { } else if (s.bgSkin) {
uuid = s.bgSkin; uuid = s.bgSkin;
} }
if (!uuid) { root.style.setProperty('--bg-skin', 'none'); return; } if (!uuid) { beendeSkinLive(); root.style.setProperty('--bg-skin', 'none'); return; }
root.style.setProperty('--bg-skin-opacity', String((s.bgSkinOpacity ?? 55) / 100)); root.style.setProperty('--bg-skin-opacity', String((s.bgSkinOpacity ?? 55) / 100));
@@ -1566,6 +1712,11 @@ async function applySkinBackground(uuidOverride) {
if (!modell) { try { modell = await window.api.skinModelFor(uuid); } catch { modell = 'classic'; } } if (!modell) { try { modell = await window.api.skinModelFor(uuid); } catch { modell = 'classic'; } }
let umhang = null; let umhang = null;
try { const r = await window.api.capeForUuid(uuid); umhang = r ? r.dataUri : null; } catch { /* ohne Umhang */ } try { const r = await window.api.capeForUuid(uuid); umhang = r ? r.dataUri : null; } catch { /* ohne Umhang */ }
// 1a) bewegt (Atmen, Kopf folgt der Maus) – sonst wie bisher als Standbild
if (await zeigeLebendigeFigur(`https://crafthead.net/skin/${uuid}`, s.bgSkinPose || 'walking', { modell, umhang })) {
root.style.setProperty('--bg-skin', 'none');
return;
}
const local = await renderSkin3D(`https://crafthead.net/skin/${uuid}`, s.bgSkinPose || 'walking', { modell, umhang }); const local = await renderSkin3D(`https://crafthead.net/skin/${uuid}`, s.bgSkinPose || 'walking', { modell, umhang });
if (local) { root.style.setProperty('--bg-skin', `url("${local}")`); return; } if (local) { root.style.setProperty('--bg-skin', `url("${local}")`); return; }
@@ -1586,6 +1737,119 @@ async function applySkinBackground(uuidOverride) {
root.style.setProperty('--bg-skin', 'none'); root.style.setProperty('--bg-skin', 'none');
} }
// ---------- Lebendige Skin-Figur im Hintergrund ----------
// Statt eines starren Bildes läuft eine echte 3D-Figur in der gewählten Pose:
// sie atmet, wiegt sich leicht, der Umhang schwingt, und der Kopf folgt dem
// Mauszeiger. Pausiert, solange das Fenster minimiert/im Hintergrund ist.
let skinLive = null;
function skinLiveGroesse() {
const r = document.querySelector('.list-area').getBoundingClientRect();
// wie der frühere Hintergrund: 52 % Breite × 88 % Höhe, Seitenverhältnis 420:700
const w = Math.max(60, Math.min(r.width * 0.52, r.height * 0.88 * 0.6));
return { w: Math.round(w), h: Math.round(w / 0.6) };
}
function beendeSkinLive() {
if (!skinLive) return;
const { viewer, abmelden } = skinLive;
skinLive = null;
abmelden();
try { viewer.dispose(); } catch { /* egal */ }
const c = el('skin-live');
if (c) c.classList.add('hidden');
const flaeche = document.querySelector('.list-area');
if (flaeche) flaeche.classList.remove('skin-lebt');
}
async function zeigeLebendigeFigur(quelle, poseKey, opts = {}) {
beendeSkinLive();
const canvas = el('skin-live');
if (POPOUT || !canvas || typeof skinview3d === 'undefined' || !bewegungErlaubt()) return false;
const { w, h } = skinLiveGroesse();
let viewer;
try {
viewer = new skinview3d.SkinViewer({ canvas, width: w, height: h, skin: quelle, model: opts.modell === 'slim' ? 'slim' : 'default' });
await new Promise((resolve, reject) => {
const t = setTimeout(reject, 8000);
const check = () => {
if (viewer.playerObject && viewer.playerObject.skin && viewer.playerObject.skin.map) { clearTimeout(t); resolve(); }
else setTimeout(check, 60);
};
check();
});
} catch {
try { if (viewer) viewer.dispose(); } catch { /* egal */ }
return false;
}
const pose = SKIN_POSES[poseKey] || SKIN_POSES.walking;
viewer.fov = pose.fov;
viewer.zoom = pose.zoom;
viewer.autoRotate = false;
viewer.controls.enabled = false;
if (opts.umhang) { try { await viewer.loadCape(opts.umhang); } catch { /* ohne Umhang */ } }
// Grundhaltung merken: Pose einmal anwenden und Kopf/Umhang/Höhe festhalten
const p = viewer.playerObject;
pose.apply(p);
const kopfBasis = p.skin.head.rotation.clone();
const umhangBasis = p.cape ? p.cape.rotation.x : 0;
const hoeheBasis = p.position.y;
const koerperBasis = p.rotation.y;
const ziel = { yaw: 0, pitch: 0 };
const kopf = { yaw: 0, pitch: 0 };
const maus = (e) => {
const r = canvas.getBoundingClientRect();
const kx = r.left + r.width * 0.5;
const ky = r.top + r.height * 0.22; // Kopf sitzt im oberen Fünftel der Figur
ziel.yaw = Math.max(-0.75, Math.min(0.75, (e.clientX - kx) / 520));
ziel.pitch = Math.max(-0.4, Math.min(0.45, (e.clientY - ky) / 650));
};
const weg = () => { ziel.yaw = 0; ziel.pitch = 0; };
viewer.animation = new skinview3d.FunctionAnimation((pl, t) => {
pose.apply(pl); // Arme/Beine jedes Mal frisch aus der Pose
const atem = Math.sin(t * 1.8);
pl.position.y = hoeheBasis + atem * 0.18; // Brustkorb hebt und senkt sich
pl.rotation.y = koerperBasis + Math.sin(t * 0.55) * 0.05;
pl.skin.leftArm.rotation.z += atem * 0.03;
pl.skin.rightArm.rotation.z -= atem * 0.03;
if (pl.cape) pl.cape.rotation.x = umhangBasis + 0.06 + Math.sin(t * 1.3) * 0.05;
kopf.yaw += (ziel.yaw - kopf.yaw) * 0.07; // weich nachführen
kopf.pitch += (ziel.pitch - kopf.pitch) * 0.07;
// ohne Maus schaut die Figur halb zum Betrachter, sonst dem Zeiger hinterher
pl.skin.head.rotation.set(kopfBasis.x + kopf.pitch, kopfBasis.y - koerperBasis * 0.45 + kopf.yaw, kopfBasis.z);
});
const sicht = () => { viewer.renderPaused = document.hidden || !document.hasFocus(); };
const ro = new ResizeObserver(() => {
const g = skinLiveGroesse();
viewer.setSize(g.w, g.h);
});
ro.observe(document.querySelector('.list-area'));
window.addEventListener('mousemove', maus);
document.documentElement.addEventListener('mouseleave', weg);
document.addEventListener('visibilitychange', sicht);
window.addEventListener('blur', sicht);
window.addEventListener('focus', sicht);
sicht();
skinLive = {
viewer,
abmelden: () => {
ro.disconnect();
window.removeEventListener('mousemove', maus);
document.documentElement.removeEventListener('mouseleave', weg);
document.removeEventListener('visibilitychange', sicht);
window.removeEventListener('blur', sicht);
window.removeEventListener('focus', sicht);
},
};
canvas.classList.remove('hidden');
document.querySelector('.list-area').classList.add('skin-lebt');
return true;
}
// ---------- Skin-Hintergrund: automatischer Kontenwechsel ---------- // ---------- Skin-Hintergrund: automatischer Kontenwechsel ----------
let skinCycleTimer = null; let skinCycleTimer = null;
let skinCycleIndex = 0; let skinCycleIndex = 0;
@@ -1668,6 +1932,15 @@ const SKIN_POSES = {
p.skin.leftLeg.rotation.set(-1.5, 0, 0.12); // Beine angewinkelt p.skin.leftLeg.rotation.set(-1.5, 0, 0.12); // Beine angewinkelt
p.skin.rightLeg.rotation.set(-1.5, 0, -0.12); p.skin.rightLeg.rotation.set(-1.5, 0, -0.12);
} }, } },
kopf: { label: 'Büste', fov: 28, zoom: 2.15, apply: (p) => {
p.rotation.set(0.08, -0.5, 0);
p.position.y = -9.5; // Kopf in die Bildmitte
p.skin.head.rotation.set(0.05, 0.22, 0); // Blick leicht zum Betrachter
p.skin.leftArm.rotation.set(0, 0, 0.06);
p.skin.rightArm.rotation.set(0, 0, -0.06);
p.skin.leftLeg.rotation.set(0, 0, 0);
p.skin.rightLeg.rotation.set(0, 0, 0);
} },
spooky: { label: 'Unheimlich', fov: 32, zoom: 0.6, apply: (p) => { spooky: { label: 'Unheimlich', fov: 32, zoom: 0.6, apply: (p) => {
p.rotation.set(-0.06, -0.45, 0); // gedreht + leichte Untersicht p.rotation.set(-0.06, -0.45, 0); // gedreht + leichte Untersicht
p.skin.head.rotation.set(0.22, -0.2, 0.28); // Kopf schief gelegt p.skin.head.rotation.set(0.22, -0.2, 0.28); // Kopf schief gelegt
@@ -1685,7 +1958,15 @@ const SKIN_POSES = {
* die noch gar nicht hochgeladen ist. * die noch gar nicht hochgeladen ist.
* modell: 'default' (breite Arme) oder 'slim' (Alex) * modell: 'default' (breite Arme) oder 'slim' (Alex)
*/ */
async function renderSkin3D(quelle, poseKey, opts = {}) { let renderWarteschlange = Promise.resolve();
function renderSkin3D(quelle, poseKey, opts = {}) {
// nacheinander: alle Aufrufe zeichnen auf dieselbe Leinwand (#skin-canvas)
const auftrag = renderWarteschlange.then(() => renderSkin3DJetzt(quelle, poseKey, opts));
renderWarteschlange = auftrag.catch(() => null);
return auftrag;
}
async function renderSkin3DJetzt(quelle, poseKey, opts = {}) {
if (typeof skinview3d === 'undefined') return null; if (typeof skinview3d === 'undefined') return null;
const canvas = el('skin-canvas'); const canvas = el('skin-canvas');
const breite = opts.breite || 420; const breite = opts.breite || 420;
@@ -1848,7 +2129,7 @@ async function refreshSelectionPanel() {
if (iconData) el('p-icon').innerHTML = `<img src="${iconData}" alt="" />`; if (iconData) el('p-icon').innerHTML = `<img src="${iconData}" alt="" />`;
else { el('p-icon').innerHTML = iconOf(inst.icon, 60); } else { el('p-icon').innerHTML = iconOf(inst.icon, 60); }
el('p-name').textContent = inst.name; el('p-name').textContent = inst.name;
el('p-groupline').textContent = inst.group ? `Gruppe: ${inst.group}` : 'Ohne Gruppe'; el('p-groupline').textContent = inst.group ? `Gruppe: ${gruppenAnzeige(inst.group)}` : 'Ohne Gruppe';
const loaderTxt = inst.minecraft.loader && inst.minecraft.loader !== 'vanilla' const loaderTxt = inst.minecraft.loader && inst.minecraft.loader !== 'vanilla'
? ' · ' + (LOADER_LABEL[inst.minecraft.loader] || inst.minecraft.loader) : ''; ? ' · ' + (LOADER_LABEL[inst.minecraft.loader] || inst.minecraft.loader) : '';
@@ -1924,6 +2205,8 @@ function sucheSchliessen() {
if (document.activeElement === el('search')) el('search').blur(); if (document.activeElement === el('search')) el('search').blur();
} }
let instanzenEingeblendet = false;
function render() { function render() {
const q = el('search').value.trim().toLowerCase(); const q = el('search').value.trim().toLowerCase();
const grid = el('grid'); const grid = el('grid');
@@ -1935,59 +2218,113 @@ function render() {
el('empty').classList.toggle('hidden', instances.length !== 0); el('empty').classList.toggle('hidden', instances.length !== 0);
grid.classList.toggle('hidden', instances.length === 0); grid.classList.toggle('hidden', instances.length === 0);
// nach Gruppen bündeln // nach Gruppen (und Untergruppen, "Modpacks/Survival") bündeln
const groups = new Map(); const baum = baueGruppenbaum(filtered);
for (const inst of filtered) { const showHeaders = baum.kinder.size > 0;
const g = (inst.group && inst.group.trim()) || ''; for (const kind of sortierteKinder(baum)) grid.appendChild(baueGruppenSektion(kind, 0, showHeaders));
if (!groups.has(g)) groups.set(g, []); // Instanzen ohne Gruppe zuletzt
groups.get(g).push(inst); if (baum.instanzen.length) {
grid.appendChild(baueGruppenSektion({ name: 'Ohne Gruppe', pfad: '', kinder: new Map(), instanzen: baum.instanzen }, 0, showHeaders));
} }
// benannte Gruppen alphabetisch, "ohne Gruppe" zuletzt // Beim ersten Anzeigen kurz nacheinander einblenden – nicht nach der
const names = [...groups.keys()].sort((a, b) => (a === '' ? 1 : b === '' ? -1 : a.localeCompare(b))); // Startanimation (da ist der Fensteraufbau schon der Auftritt)
const showHeaders = names.some((n) => n !== ''); if (!instanzenEingeblendet && instances.length) {
instanzenEingeblendet = true;
if (bewegungErlaubt() && !MIT_STARTANIMATION && !POPOUT) {
grid.querySelectorAll('.group-header, .card').forEach((k, i) => k.style.setProperty('--i', String(Math.min(i, 24))));
grid.classList.add('einblenden');
setTimeout(() => grid.classList.remove('einblenden'), 1400);
}
}
status(`${instances.length} Instanz${instances.length === 1 ? '' : 'en'}`);
}
for (const g of names) { // ---------- Gruppen und Untergruppen ----------
const list = groups.get(g); // Eine Untergruppe steckt im Gruppennamen, getrennt mit "/": "Modpacks/Survival".
// Ohne "/" bleibt alles wie bisher (eine Ebene).
function gruppenPfad(g) {
return String(g || '').split(/[\/\\]/).map((t) => t.trim()).filter(Boolean).join('/');
}
function gruppenAnzeige(g) {
return gruppenPfad(g).split('/').join(' › ');
}
function baueGruppenbaum(liste) {
const wurzel = { name: '', pfad: '', kinder: new Map(), instanzen: [] };
for (const inst of liste) {
let knoten = wurzel;
const pfad = gruppenPfad(inst.group);
if (pfad) {
for (const teil of pfad.split('/')) {
if (!knoten.kinder.has(teil)) {
knoten.kinder.set(teil, { name: teil, pfad: knoten.pfad ? knoten.pfad + '/' + teil : teil, kinder: new Map(), instanzen: [] });
}
knoten = knoten.kinder.get(teil);
}
}
knoten.instanzen.push(inst);
}
return wurzel;
}
const anzahlImZweig = (k) => k.instanzen.length + [...k.kinder.values()].reduce((s, c) => s + anzahlImZweig(c), 0);
const sortierteKinder = (k) => [...k.kinder.values()].sort((a, b) => a.name.localeCompare(b.name, 'de', { numeric: true }));
// Abschnitt für eine Gruppe samt Untergruppen (rekursiv)
function baueGruppenSektion(knoten, tiefe, showHeaders) {
const g = knoten.pfad;
const zu = collapsedGroups.has(g);
const section = document.createElement('div'); const section = document.createElement('div');
section.className = 'group-section' + (collapsedGroups.has(g) ? ' collapsed' : ''); section.className = 'group-section' + (tiefe ? ' group-sub' : '') + (zu ? ' collapsed' : '');
if (showHeaders) { if (showHeaders) {
const header = document.createElement('div'); const header = document.createElement('div');
header.className = 'group-header' + (collapsedGroups.has(g) ? ' collapsed' : ''); header.className = 'group-header' + (zu ? ' collapsed' : '');
header.innerHTML = `<span class="chev">${collapsedGroups.has(g) ? '▸' : '▾'}</span> ${escapeHtml(g || 'Ohne Gruppe')} <span class="group-count">(${list.length})</span>`; header.title = g ? gruppenAnzeige(g) : 'Instanzen ohne Gruppe';
header.innerHTML = `<span class="chev">${zu ? '▸' : '▾'}</span> ${escapeHtml(knoten.name)} <span class="group-count">(${anzahlImZweig(knoten)})</span>`;
header.addEventListener('click', () => { header.addEventListener('click', () => {
if (collapsedGroups.has(g)) collapsedGroups.delete(g); else collapsedGroups.add(g); if (collapsedGroups.has(g)) collapsedGroups.delete(g); else collapsedGroups.add(g);
render(); render();
}); });
section.appendChild(header); section.appendChild(header);
} }
const inhalt = document.createElement('div');
inhalt.className = 'group-body';
if (knoten.instanzen.length) {
const gridEl = document.createElement('div'); const gridEl = document.createElement('div');
gridEl.className = 'group-grid'; gridEl.className = 'group-grid';
for (const inst of list) gridEl.appendChild(buildCard(inst)); for (const inst of knoten.instanzen) gridEl.appendChild(buildCard(inst));
section.appendChild(gridEl); inhalt.appendChild(gridEl);
}
for (const kind of sortierteKinder(knoten)) inhalt.appendChild(baueGruppenSektion(kind, tiefe + 1, showHeaders));
section.appendChild(inhalt);
// Drop-Ziel: Instanz per Drag&Drop in diese Gruppe verschieben // Drop-Ziel: Instanz per Drag&Drop in diese (Unter-)Gruppe verschieben –
section.addEventListener('dragover', (e) => { e.preventDefault(); section.classList.add('drop-hover'); }); // stopPropagation, damit die innerste Gruppe gewinnt, nicht die äußere
section.addEventListener('dragover', (e) => { e.preventDefault(); e.stopPropagation(); section.classList.add('drop-hover'); });
section.addEventListener('dragleave', (e) => { if (!section.contains(e.relatedTarget)) section.classList.remove('drop-hover'); }); section.addEventListener('dragleave', (e) => { if (!section.contains(e.relatedTarget)) section.classList.remove('drop-hover'); });
section.addEventListener('drop', async (e) => { section.addEventListener('drop', async (e) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation();
section.classList.remove('drop-hover'); section.classList.remove('drop-hover');
const dropId = e.dataTransfer.getData('text/plain'); const dropId = e.dataTransfer.getData('text/plain');
const dragged = instances.find((i) => i.id === dropId); const dragged = instances.find((i) => i.id === dropId);
if (dragged && (dragged.group || '') !== g) { if (dragged && gruppenPfad(dragged.group) !== g) {
await window.api.updateInstance(dropId, { group: g }); await window.api.updateInstance(dropId, { group: g });
await reload(); await reload();
toast('Verschoben nach: ' + (g || 'Ohne Gruppe')); toast('Verschoben nach: ' + (g ? gruppenAnzeige(g) : 'Ohne Gruppe'));
} }
}); });
return section;
grid.appendChild(section);
}
status(`${instances.length} Instanz${instances.length === 1 ? '' : 'en'}`);
} }
function fillGroupDatalist() { function fillGroupDatalist() {
const groups = [...new Set(instances.map((i) => i.group).filter(Boolean))].sort(); // alle Gruppen samt ihrer übergeordneten Ebenen vorschlagen
const alle = new Set();
for (const i of instances) {
const teile = gruppenPfad(i.group).split('/').filter(Boolean);
for (let n = 1; n <= teile.length; n++) alle.add(teile.slice(0, n).join('/'));
}
const groups = [...alle].sort((a, b) => a.localeCompare(b, 'de', { numeric: true }));
el('group-list').innerHTML = groups.map((g) => `<option value="${escapeHtml(g)}"></option>`).join(''); el('group-list').innerHTML = groups.map((g) => `<option value="${escapeHtml(g)}"></option>`).join('');
} }
@@ -2246,7 +2583,7 @@ async function saveInstance() {
const payload = { const payload = {
name, name,
group: el('f-group').value.trim(), group: gruppenPfad(el('f-group').value),
icon: el('f-icon').value, icon: el('f-icon').value,
notes: el('f-notes').value.trim(), notes: el('f-notes').value.trim(),
minecraft: { minecraft: {
@@ -4993,6 +5330,8 @@ async function openSettings(pane) {
sel.disabled = !!s.bgSkinCycle; sel.disabled = !!s.bgSkinCycle;
el('s-sortby').value = s.sortBy || 'lastPlayed'; el('s-sortby').value = s.sortBy || 'lastPlayed';
el('s-iso3d').checked = s.iso3dIcons !== false; el('s-iso3d').checked = s.iso3dIcons !== false;
el('s-start-animation').checked = s.startAnimation !== false;
el('s-ui-animationen').checked = s.uiAnimationen !== false;
el('s-bedrock').checked = s.bedrockEnabled !== false; el('s-bedrock').checked = s.bedrockEnabled !== false;
el('s-notify').checked = s.desktopNotifications !== false; el('s-notify').checked = s.desktopNotifications !== false;
el('s-friends').checked = s.friendsEnabled !== false; el('s-friends').checked = s.friendsEnabled !== false;
@@ -5127,6 +5466,8 @@ async function saveSettings() {
bgSkinCycleSeconds: Math.max(5, numOrNull(el('s-bgskin-cycle-seconds').value) || 30), bgSkinCycleSeconds: Math.max(5, numOrNull(el('s-bgskin-cycle-seconds').value) || 30),
sortBy: el('s-sortby').value, sortBy: el('s-sortby').value,
iso3dIcons: el('s-iso3d').checked, iso3dIcons: el('s-iso3d').checked,
startAnimation: el('s-start-animation').checked,
uiAnimationen: el('s-ui-animationen').checked,
bedrockEnabled: el('s-bedrock').checked, bedrockEnabled: el('s-bedrock').checked,
desktopNotifications: el('s-notify').checked, desktopNotifications: el('s-notify').checked,
friendsEnabled: el('s-friends').checked, friendsEnabled: el('s-friends').checked,
@@ -5166,6 +5507,7 @@ async function saveSettings() {
// Theme hier direkt anwenden (für dieses Fenster selbst); alles, was das // Theme hier direkt anwenden (für dieses Fenster selbst); alles, was das
// Hauptfenster betrifft (Bedrock-Sichtbarkeit, Skin-Hintergrund, // Hauptfenster betrifft (Bedrock-Sichtbarkeit, Skin-Hintergrund,
// Instanzliste), zieht das Hauptfenster über "settings:changed" selbst nach // Instanzliste), zieht das Hauptfenster über "settings:changed" selbst nach
setzeUiAnimationen(patch.uiAnimationen);
applyTheme(patch.theme); applyTheme(patch.theme);
iso3dEnabled = patch.iso3dIcons; iso3dEnabled = patch.iso3dIcons;
hide('modal-settings'); hide('modal-settings');
@@ -5424,6 +5766,22 @@ function promptGlobalRestore() {
} }
// ---------- Microsoft-Login / Kontoverwaltung ---------- // ---------- Microsoft-Login / Kontoverwaltung ----------
// Kopf/Büste eines Kontos selbst in 3D zeichnen – so steht die 2. Ebene
// (Mütze, Haare, Kragen, Ärmel) wie im Spiel plastisch ab. Fertige Bilder von
// crafthead zeigen sie nur flach aufgemalt; die dienen nur noch als Rückfall.
const kontoKoepfe = new Map(); // uuid -> data:-Bild
async function kontoKopf(uuid) {
if (kontoKoepfe.has(uuid)) return kontoKoepfe.get(uuid);
const auftrag = (async () => {
let modell = 'classic';
try { modell = await window.api.skinModelFor(uuid); } catch { /* breite Arme */ }
const bild = await renderSkin3D(`https://crafthead.net/skin/${uuid}`, 'kopf', { modell, breite: 128, hoehe: 128 });
return bild || `https://crafthead.net/armor/bust/${uuid}?size=64`;
})();
kontoKoepfe.set(uuid, auftrag);
return auftrag;
}
async function refreshAccountButton() { async function refreshAccountButton() {
const acc = await window.api.authStatus(); const acc = await window.api.authStatus();
const av = el('account-av'); const av = el('account-av');
@@ -5431,7 +5789,7 @@ async function refreshAccountButton() {
av.innerHTML = ''; av.innerHTML = '';
const img = document.createElement('img'); const img = document.createElement('img');
img.className = 'acct-head'; img.className = 'acct-head';
img.src = `https://crafthead.net/bust/${acc.profile.id}?size=64`; img.src = await kontoKopf(acc.profile.id);
img.addEventListener('error', () => { av.innerHTML = svgIcon('user'); }); img.addEventListener('error', () => { av.innerHTML = svgIcon('user'); });
av.appendChild(img); av.appendChild(img);
el('account-name').textContent = acc.profile.name; el('account-name').textContent = acc.profile.name;
@@ -5462,7 +5820,7 @@ async function renderAccounts(boxId = 'login-accounts') {
const av = document.createElement('img'); const av = document.createElement('img');
av.className = 'account-av'; av.className = 'account-av';
av.src = `https://crafthead.net/avatar/${a.id}?size=64`; av.src = await kontoKopf(a.id);
av.addEventListener('error', () => { av.addEventListener('error', () => {
const d = document.createElement('div'); const d = document.createElement('div');
d.className = 'account-av'; d.textContent = '🎮'; d.className = 'account-av'; d.textContent = '🎮';
@@ -5662,7 +6020,9 @@ function wire() {
// schließen bei Klick daneben, Rechtsklick ins Leere, Scrollen oder ESC // schließen bei Klick daneben, Rechtsklick ins Leere, Scrollen oder ESC
document.addEventListener('click', (e) => { document.addEventListener('click', (e) => {
if (!e.target.closest('#ctx-menu')) closeContextMenu(); if (!e.target.closest('#ctx-menu')) closeContextMenu();
if (!e.target.closest('#konto-menu') && !e.target.closest('#btn-account')) schliesseKontoMenue();
}); });
window.addEventListener('blur', schliesseKontoMenue);
document.addEventListener('contextmenu', (e) => { document.addEventListener('contextmenu', (e) => {
if (!e.target.closest('.card') && !e.target.closest('#ctx-menu')) closeContextMenu(); if (!e.target.closest('.card') && !e.target.closest('#ctx-menu')) closeContextMenu();
}); });
@@ -5771,6 +6131,12 @@ function wire() {
}); });
el('technic-list').addEventListener('change', updateTechnicButton); el('technic-list').addEventListener('change', updateTechnicButton);
el('technic-do').addEventListener('click', runTechnicImport); el('technic-do').addEventListener('click', runTechnicImport);
window.api.onImportFortschritt(zeigeImportFortschritt);
el('import-lauf-abbrechen').addEventListener('click', async () => {
el('import-lauf-abbrechen').disabled = true;
el('import-lauf-titel').textContent = 'Wird abgebrochen …';
await window.api.importAbbrechen();
});
window.api.onMcProgress((p) => { window.api.onMcProgress((p) => {
el('mc-progress').textContent = `Übernehme … ${p.current}/${p.total}`; el('mc-progress').textContent = `Übernehme … ${p.current}/${p.total}`;
el('mc-progress').classList.remove('hidden'); el('mc-progress').classList.remove('hidden');
@@ -6212,7 +6578,7 @@ function wire() {
// Design-Kacheln setzen die (versteckte) Auswahl und zeigen es sofort an // Design-Kacheln setzen die (versteckte) Auswahl und zeigen es sofort an
document.querySelectorAll('.design-wahl').forEach((b) => b.addEventListener('click', () => { document.querySelectorAll('.design-wahl').forEach((b) => b.addEventListener('click', () => {
el('s-theme').value = b.dataset.design; el('s-theme').value = b.dataset.design;
applyTheme(b.dataset.design); designUeberblenden(b.dataset.design);
})); }));
el('s-bgskin-opacity').addEventListener('input', (e) => { el('s-bgskin-opacity').addEventListener('input', (e) => {
el('s-bgskin-val').textContent = e.target.value; el('s-bgskin-val').textContent = e.target.value;
@@ -6298,7 +6664,7 @@ function wire() {
} }
// Microsoft-Login / Kontoverwaltung // Microsoft-Login / Kontoverwaltung
el('btn-account').addEventListener('click', openLogin); el('btn-account').addEventListener('click', (e) => { e.stopPropagation(); oeffneKontoMenue(); });
el('login-add').addEventListener('click', addAccount); el('login-add').addEventListener('click', addAccount);
el('login-url').addEventListener('click', (e) => { el('login-url').addEventListener('click', (e) => {
e.preventDefault(); e.preventDefault();
@@ -6355,6 +6721,7 @@ function wire() {
document.addEventListener('keydown', (e) => { document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') { if (e.key === 'Escape') {
if (!el('ctx-menu').classList.contains('hidden')) { closeContextMenu(); return; } if (!el('ctx-menu').classList.contains('hidden')) { closeContextMenu(); return; }
if (!el('konto-menu').classList.contains('hidden')) { schliesseKontoMenue(); return; }
if (!el('modal-prompt').classList.contains('hidden')) { closePrompt(null); return; } if (!el('modal-prompt').classList.contains('hidden')) { closePrompt(null); return; }
const open = [...document.querySelectorAll('.modal:not(.hidden)')].pop(); const open = [...document.querySelectorAll('.modal:not(.hidden)')].pop();
if (open && open.id === 'modal-record' && recordingActive()) { confirmStopAndCloseRecording(); return; } if (open && open.id === 'modal-record' && recordingActive()) { confirmStopAndCloseRecording(); return; }
@@ -6533,6 +6900,7 @@ window.addEventListener('DOMContentLoaded', async () => {
applyIcons(); applyIcons();
try { try {
const s = await window.api.getSettings(); const s = await window.api.getSettings();
setzeUiAnimationen(s.uiAnimationen);
applyTheme(s.theme); applyTheme(s.theme);
iso3dEnabled = s.iso3dIcons !== false; iso3dEnabled = s.iso3dIcons !== false;
consoleMaxChars = Math.max(50000, (s.consoleMaxLines || 100000) * 3); consoleMaxChars = Math.max(50000, (s.consoleMaxLines || 100000) * 3);
@@ -6615,7 +6983,8 @@ window.addEventListener('DOMContentLoaded', async () => {
// unsichtbar (Sidebar/Symbolleiste sind dort ausgeblendet) und daher nur // unsichtbar (Sidebar/Symbolleiste sind dort ausgeblendet) und daher nur
// im Hauptfenster sinnvoll // im Hauptfenster sinnvoll
window.api.onSettingsChanged((s) => { window.api.onSettingsChanged((s) => {
applyTheme(s.theme); setzeUiAnimationen(s.uiAnimationen);
designUeberblenden(s.theme);
iso3dEnabled = s.iso3dIcons !== false; iso3dEnabled = s.iso3dIcons !== false;
schnellstartBelegung = s.schnellstart || {}; schnellstartBelegung = s.schnellstart || {};
if (!POPOUT) { if (!POPOUT) {
@@ -6629,7 +6998,8 @@ window.addEventListener('DOMContentLoaded', async () => {
// Fenster (Skin-/Import-Popup) betrifft ebenfalls nur die Anzeige im // Fenster (Skin-/Import-Popup) betrifft ebenfalls nur die Anzeige im
// Hauptfenster (Skin-Hintergrund, Seitenleiste) – Popups zeigen beides nicht an // Hauptfenster (Skin-Hintergrund, Seitenleiste) – Popups zeigen beides nicht an
window.api.onSkinChanged((info) => { window.api.onSkinChanged((info) => {
if (!POPOUT) refreshSkinBackground(); kontoKoepfe.clear();
if (!POPOUT) { refreshSkinBackground(); refreshAccountButton(); }
// im Editor gesetzt -> offenes Skin-Fenster zeigt sonst noch den alten Stand // im Editor gesetzt -> offenes Skin-Fenster zeigt sonst noch den alten Stand
if (POPOUT === 'skin' && (info.quelle === 'editor' || info.quelle === 'umhang') && !el('modal-skin').classList.contains('hidden')) openSkin(); if (POPOUT === 'skin' && (info.quelle === 'editor' || info.quelle === 'umhang') && !el('modal-skin').classList.contains('hidden')) openSkin();
}); });
+36 -1
View File
@@ -141,4 +141,39 @@ async function setzeUmhang(accessToken, capeId) {
return { ok: true }; return { ok: true };
} }
module.exports = { setzeSkin, setzeSkinZurueck, holeProfil, pruefeDatei, aufHttps, setzeUmhang }; /*
* Texturen, die andere Spieler im Spiel sehen (öffentliches Profil beim
* Sitzungsserver). Das kann vom Kontoprofil oben abweichen: Dort liefert
* Mojang bei manchen Konten einen Standard-Skin (z. B. Efe) als "ACTIVE",
* obwohl im Spiel längst ein eigener Skin getragen wird.
*/
async function holeSpielTexturen(uuid) {
const id = String(uuid || '').replace(/-/g, '').toLowerCase();
if (!/^[0-9a-f]{32}$/.test(id)) return null;
const res = await fetch('https://sessionserver.mojang.com/session/minecraft/profile/' + id, {
headers: { 'User-Agent': UA, Accept: 'application/json' },
});
if (!res.ok) return null;
const profil = await res.json();
const eigenschaft = (profil.properties || []).find((p) => p.name === 'textures');
if (!eigenschaft) return null;
const texturen = JSON.parse(Buffer.from(eigenschaft.value, 'base64').toString('utf8')).textures || {};
const skin = texturen.SKIN;
return {
skinUrl: skin && skin.url ? aufHttps(skin.url) : '',
variante: skin && skin.metadata && skin.metadata.model === 'slim' ? 'slim' : 'classic',
};
}
/*
* Welcher Skin wird als "aktuell" gezeigt? Grundsätzlich der aus dem Spiel –
* nur direkt nach einer eigenen Änderung das Kontoprofil, weil der
* Sitzungsserver dann noch kurz den alten Stand liefert.
*/
function waehleAnzeigeSkin(kontoProfil, spiel, kuerzlichGeaendert) {
if (!kontoProfil || !kontoProfil.ok || kuerzlichGeaendert) return kontoProfil;
if (!spiel || !spiel.skinUrl || spiel.skinUrl === kontoProfil.skinUrl) return kontoProfil;
return Object.assign({}, kontoProfil, { skinUrl: spiel.skinUrl, variante: spiel.variante, skinAusSpiel: true });
}
module.exports = { setzeSkin, setzeSkinZurueck, holeProfil, pruefeDatei, aufHttps, setzeUmhang, holeSpielTexturen, waehleAnzeigeSkin };
+79
View File
@@ -0,0 +1,79 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta http-equiv="Content-Security-Policy"
content="default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; font-src 'self'" />
<title>AeroMC</title>
<style>
@font-face { font-family: 'AeroPixel'; src: url('assets/aeropixel.ttf'); }
html, body { margin: 0; height: 100%; overflow: hidden; background: transparent; user-select: none; cursor: default; }
body { font-family: 'Segoe UI', system-ui, sans-serif; }
/* Logo + Schriftzug in der Mitte */
.marke {
position: fixed; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 14px;
transition: opacity .28s ease, transform .32s ease;
}
.buehne { perspective: 700px; animation: rein .45s cubic-bezier(.2, .9, .3, 1.2) both; }
.logo {
width: 132px; height: 132px; display: block;
filter: drop-shadow(0 10px 26px rgba(0, 0, 0, .55)) drop-shadow(0 0 22px rgba(90, 160, 255, .35));
animation: drehen 1.3s cubic-bezier(.45, .05, .25, 1) .15s infinite;
}
.name {
font-size: 34px; font-weight: 600; letter-spacing: .06em; color: #fff;
text-shadow: 0 2px 10px rgba(0, 0, 0, .75), 0 0 2px rgba(0, 0, 0, .9);
animation: schrift .5s ease .25s both;
}
@keyframes rein { from { opacity: 0; transform: scale(.6); } to { opacity: 1; transform: none; } }
@keyframes drehen { from { transform: rotateY(0deg); } to { transform: rotateY(360deg); } }
@keyframes schrift { from { opacity: 0; transform: translateY(8px); letter-spacing: .3em; } to { opacity: 1; transform: none; letter-spacing: .06em; } }
/* Schriftzug je Design */
body[data-design="aero"] .name, body[data-design="lila"] .name {
background: linear-gradient(90deg, #7fe3ff, #6c9cff); -webkit-background-clip: text; background-clip: text; color: transparent;
text-shadow: none; filter: drop-shadow(0 2px 8px rgba(0, 0, 0, .8));
}
body[data-design="lila"] .name { background-image: linear-gradient(90deg, #e3b8ff, #9d7bff); }
body[data-design="lila"] .logo { filter: drop-shadow(0 10px 26px rgba(0, 0, 0, .55)) drop-shadow(0 0 22px rgba(170, 120, 255, .4)); }
body[data-design="minecraft"] .name {
font-family: 'AeroPixel', 'Segoe UI', sans-serif; font-weight: 400; font-size: 30px; letter-spacing: .04em;
text-shadow: 3px 3px 0 #3f3f3f, 0 0 10px rgba(0, 0, 0, .8);
}
/* das Launcher-Fenster als Bild, wächst von der Mitte nach außen */
#fenster { position: fixed; inset: 0; width: 100%; height: 100%; visibility: hidden; }
body.aufbau .marke { opacity: 0; transform: scale(.82); }
</style>
</head>
<body>
<div class="marke">
<div class="buehne"><img class="logo" src="assets/logo.png" alt="" /></div>
<div class="name">AeroMC</div>
</div>
<img id="fenster" alt="" />
<script>
document.body.dataset.design = new URLSearchParams(location.search).get('design') || 'dark';
// vom Hauptprozess aufgerufen: Bild des fertigen Launchers von der Mitte aus aufziehen
window.aufbauen = (bildAdresse) => new Promise((fertig) => {
const bild = document.getElementById('fenster');
bild.onerror = () => fertig(false);
bild.onload = () => {
const v = Math.max(0, innerHeight / 2 - 66);
const h = Math.max(0, innerWidth / 2 - 66);
bild.style.visibility = 'visible';
document.body.classList.add('aufbau');
bild.animate([
{ clipPath: `inset(${v}px ${h}px round 30px)`, opacity: 0.2 },
{ opacity: 1, offset: 0.25 },
{ clipPath: 'inset(0px 0px round 0px)', opacity: 1 },
], { duration: 560, easing: 'cubic-bezier(.2, .8, .2, 1)', fill: 'forwards' })
.finished.then(() => fertig(true), () => fertig(false));
};
bild.src = bildAdresse;
});
</script>
</body>
</html>
+76
View File
@@ -0,0 +1,76 @@
/*
* Startanimation des Hauptfensters (abschaltbar unter Optionen → Darstellung).
*
* Ablauf: Ein randloses, durchsichtiges Fenster liegt genau dort, wo der
* Launcher erscheinen wird, und zeigt das sich drehende Logo samt Schriftzug.
* Ist die Oberfläche fertig (renderer:ready), wird das noch unsichtbare
* Hauptfenster abfotografiert und dieses Bild im Startfenster von der Mitte
* nach außen aufgezogen. Am Ende übernimmt das echte Fenster an exakt
* derselben Stelle – dadurch wirkt es, als baue sich der Launcher auf.
*/
const path = require('path');
const { BrowserWindow } = require('electron');
const MINDESTENS_DREHEN_MS = 1600; // so lange dreht sich das Logo mindestens
const HOECHSTENS_WARTEN_MS = 8000; // spätestens dann wird der Launcher gezeigt
const warte = (ms) => new Promise((r) => setTimeout(r, ms));
// "renderer:ready" kommt, bevor Instanzliste und Kontoname gezeichnet sind –
// erst darauf warten, sonst wächst ein halbleeres Fenster auf
async function warteAufGezeichneteOberflaeche(wc) {
const pruefe = (ausdruck) => wc.executeJavaScript(ausdruck, true).catch(() => true);
// Liste gezeichnet – oder es gibt (noch) keine Instanzen, dann steht der Leer-Hinweis da
for (let i = 0; i < 30 && !(await pruefe("document.querySelectorAll('#grid > *').length > 0 || !document.getElementById('empty').classList.contains('hidden')")); i++) await warte(100);
for (let i = 0; i < 12 && !(await pruefe("(document.getElementById('account-name') || {}).textContent !== 'Konten'")); i++) await warte(100);
await warte(200);
}
async function spielen(hauptfenster, oberflaecheBereit, design) {
const b = hauptfenster.getContentBounds();
const start = new BrowserWindow({
x: b.x, y: b.y, width: b.width, height: b.height,
frame: false, transparent: true, resizable: false, movable: false,
minimizable: false, maximizable: false, fullscreenable: false,
skipTaskbar: true, hasShadow: false, focusable: false, show: false,
backgroundColor: '#00000000', title: 'AeroMC',
webPreferences: { contextIsolation: true, nodeIntegration: false, sandbox: true },
});
const launcherZeigen = () => {
if (hauptfenster.isDestroyed()) return;
hauptfenster.setOpacity(1);
hauptfenster.setIgnoreMouseEvents(false);
if (!hauptfenster.isVisible()) hauptfenster.show();
hauptfenster.focus();
};
try {
await start.loadFile(path.join(__dirname, 'splash.html'), { query: { design: design || 'dark' } });
start.showInactive();
const beginn = Date.now();
await Promise.race([oberflaecheBereit, warte(HOECHSTENS_WARTEN_MS)]);
const rest = MINDESTENS_DREHEN_MS - (Date.now() - beginn);
if (rest > 0) await warte(rest);
if (hauptfenster.isDestroyed()) return;
// Unsichtbar zeigen, damit Chromium die Oberfläche zeichnet, dann abfotografieren
hauptfenster.setOpacity(0);
hauptfenster.setIgnoreMouseEvents(true); // unsichtbar: Klicks gehen nicht versehentlich hinein
hauptfenster.show();
await warteAufGezeichneteOberflaeche(hauptfenster.webContents);
if (hauptfenster.isDestroyed()) return;
const bild = await hauptfenster.webContents.capturePage();
if (!bild.isEmpty() && !start.isDestroyed()) {
// höchstens 2,5 s – das Hauptfenster darf nie unsichtbar hängen bleiben
await Promise.race([start.webContents.executeJavaScript(`aufbauen(${JSON.stringify(bild.toDataURL())})`), warte(2500)]);
}
} catch {
// Animation ist nur Zierde – im Fehlerfall einfach den Launcher zeigen
} finally {
launcherZeigen();
setTimeout(() => { if (!start.isDestroyed()) start.destroy(); }, 80);
}
}
module.exports = { spielen };
+37 -5
View File
@@ -88,6 +88,8 @@ const DEFAULT_SETTINGS = {
bgSkin: '', // '' = aus, 'active' = aktives Konto, sonst Konto-UUID bgSkin: '', // '' = aus, 'active' = aktives Konto, sonst Konto-UUID
bgSkinOpacity: 55, // Deckkraft des Hintergrund-Skins in % bgSkinOpacity: 55, // Deckkraft des Hintergrund-Skins in %
bgSkinPose: 'walking', // Pose des 3D-Renders (falls der Pose-Dienst erreichbar ist) bgSkinPose: 'walking', // Pose des 3D-Renders (falls der Pose-Dienst erreichbar ist)
startAnimation: true, // drehendes Logo + Fensteraufbau beim Öffnen des Launchers
uiAnimationen: true, // bewegte Oberfläche: lebende Skin-Figur, aufwachsende Fenster, Einblenden, Überblenden
bgSkinModel: 'auto', // Armform der Figur: 'auto' (vom Konto), 'classic' (Steve), 'slim' (Alex) bgSkinModel: 'auto', // Armform der Figur: 'auto' (vom Konto), 'classic' (Steve), 'slim' (Alex)
bgSkinCycle: false, // bei 2+ angemeldeten Konten automatisch durchwechseln bgSkinCycle: false, // bei 2+ angemeldeten Konten automatisch durchwechseln
bgSkinCycleSeconds: 30, // Sekunden zwischen zwei Kontowechseln bgSkinCycleSeconds: 30, // Sekunden zwischen zwei Kontowechseln
@@ -424,6 +426,35 @@ function createInstance(data) {
return writeInstance(inst); return writeInstance(inst);
} }
/*
* Große Instanzordner nicht synchron löschen (das fror den Launcher bei vielen
* GB minutenlang ein): sofort in einen versteckten Papierkorb im Instanzordner
* verschieben – das ist nur eine Umbenennung – und dort im Hintergrund löschen.
* Reste (z. B. nach einem Absturz) räumt raeumePapierkorbAuf() beim Start weg.
*/
const PAPIERKORB = '.aeromc-papierkorb';
function entsorgeOrdner(p) {
if (!fs.existsSync(p)) return;
const korb = path.join(path.dirname(p), PAPIERKORB);
const ziel = path.join(korb, path.basename(p) + '-' + Date.now());
try {
ensureDir(korb);
fs.renameSync(p, ziel);
} catch {
// Umbenennen klappt nicht (z. B. Datei gerade geöffnet): instance.json sofort
// weg, damit die Instanz aus der Liste verschwindet – den Rest im Hintergrund
try { fs.rmSync(path.join(p, 'instance.json'), { force: true }); } catch { /* egal */ }
fs.promises.rm(p, { recursive: true, force: true }).catch(() => { /* bleibt liegen, stört nicht */ });
return;
}
fs.promises.rm(ziel, { recursive: true, force: true }).catch(() => { /* beim nächsten Start erneut */ });
}
function raeumePapierkorbAuf() {
const korb = path.join(getInstancesDir(), PAPIERKORB);
if (fs.existsSync(korb)) fs.promises.rm(korb, { recursive: true, force: true }).catch(() => {});
}
function createOrReplaceImportedInstance(data) { function createOrReplaceImportedInstance(data) {
const existing = findInstanceByName(data && data.name); const existing = findInstanceByName(data && data.name);
if (!existing) return createInstance(data); if (!existing) return createInstance(data);
@@ -435,8 +466,7 @@ function createOrReplaceImportedInstance(data) {
inst.totalPlaySeconds = existing.totalPlaySeconds || 0; inst.totalPlaySeconds = existing.totalPlaySeconds || 0;
inst.launchCount = existing.launchCount || 0; inst.launchCount = existing.launchCount || 0;
const p = instancePath(existing.id); entsorgeOrdner(instancePath(existing.id));
if (fs.existsSync(p)) fs.rmSync(p, { recursive: true, force: true });
return writeInstance(inst); return writeInstance(inst);
} }
@@ -452,12 +482,11 @@ function updateInstance(id, patch) {
} }
function deleteInstance(id) { function deleteInstance(id) {
const p = instancePath(id); entsorgeOrdner(instancePath(id));
if (fs.existsSync(p)) fs.rmSync(p, { recursive: true, force: true });
return true; return true;
} }
function duplicateInstance(id) { function duplicateInstance(id, ohneSpieldaten) {
const src = getInstance(id); const src = getInstance(id);
if (!src) throw new Error('Instanz nicht gefunden: ' + id); if (!src) throw new Error('Instanz nicht gefunden: ' + id);
const copy = JSON.parse(JSON.stringify(src)); const copy = JSON.parse(JSON.stringify(src));
@@ -469,9 +498,11 @@ function duplicateInstance(id) {
copy.launchCount = 0; copy.launchCount = 0;
ensureDir(gameDir(copy.id)); ensureDir(gameDir(copy.id));
// Spielinhalte mitkopieren (Mods/Configs) – ohne Fehler wenn leer // Spielinhalte mitkopieren (Mods/Configs) – ohne Fehler wenn leer
if (!ohneSpieldaten) {
try { try {
fs.cpSync(gameDir(id), gameDir(copy.id), { recursive: true }); fs.cpSync(gameDir(id), gameDir(copy.id), { recursive: true });
} catch { /* Quelle evtl. leer */ } } catch { /* Quelle evtl. leer */ }
}
writeJson(instanceFile(copy.id), copy); writeJson(instanceFile(copy.id), copy);
return copy; return copy;
} }
@@ -500,5 +531,6 @@ module.exports = {
createOrReplaceImportedInstance, createOrReplaceImportedInstance,
updateInstance, updateInstance,
deleteInstance, deleteInstance,
raeumePapierkorbAuf,
duplicateInstance, duplicateInstance,
}; };
+38
View File
@@ -123,6 +123,20 @@ body.is-popout>.titelleiste{display:none}
opacity:var(--bg-skin-opacity,.55);pointer-events:none;z-index:0; opacity:var(--bg-skin-opacity,.55);pointer-events:none;z-index:0;
} }
.list{position:relative;z-index:1;height:100%;min-height:100%;overflow:auto;padding:4px 8px} .list{position:relative;z-index:1;height:100%;min-height:100%;overflow:auto;padding:4px 8px}
/* lebende Skin-Figur ersetzt das Standbild (gleiche Stelle und Größe) */
.skin-live{position:absolute;right:24px;bottom:8px;opacity:var(--bg-skin-opacity,.55);pointer-events:none;z-index:0}
.list-area.skin-lebt::after{display:none}
/* ---------- Bewegte Oberfläche (nur mit Klasse ui-animiert) ---------- */
/* Instanzen beim ersten Anzeigen nacheinander einblenden */
.ui-animiert .list.einblenden .card,
.ui-animiert .list.einblenden .group-header{animation:karte-rein .42s cubic-bezier(.2,.8,.2,1) both;animation-delay:calc(var(--i,0) * 28ms)}
@keyframes karte-rein{from{opacity:0;transform:translateY(12px) scale(.94)}}
/* Block-Symbol hebt sich beim Darüberfahren und dreht sich ein Stück */
.ui-animiert .card-icon,.ui-animiert .card-icon-img{transition:transform .22s cubic-bezier(.2,.8,.2,1),filter .22s}
.ui-animiert .card:hover .card-icon,.ui-animiert .card:hover .card-icon-img{transform:translateY(-4px) rotate(-7deg) scale(1.06);filter:drop-shadow(0 6px 6px rgba(0,0,0,.28))}
/* Designwechsel weich überblenden */
::view-transition-old(root),::view-transition-new(root){animation-duration:.45s;animation-timing-function:ease}
.side{background:var(--win);border-left:1px solid var(--border);overflow-y:auto;overflow-x:hidden;padding:10px 8px} .side{background:var(--win);border-left:1px solid var(--border);overflow-y:auto;overflow-x:hidden;padding:10px 8px}
.side-empty{text-align:center;color:var(--dim);padding:24px 8px} .side-empty{text-align:center;color:var(--dim);padding:24px 8px}
.side-ico{width:52px;height:52px;margin:0 auto 5px;display:grid;place-items:center;font-size:38px} .side-ico{width:52px;height:52px;margin:0 auto 5px;display:grid;place-items:center;font-size:38px}
@@ -147,6 +161,10 @@ body.is-popout>.titelleiste{display:none}
.group-count{color:var(--dim);font-weight:400} .group-count{color:var(--dim);font-weight:400}
.group-grid{display:grid;grid-template-columns:repeat(auto-fill,104px);gap:2px;padding:0 4px 4px;justify-content:start} .group-grid{display:grid;grid-template-columns:repeat(auto-fill,104px);gap:2px;padding:0 4px 4px;justify-content:start}
.group-section.collapsed .group-grid{display:none} .group-section.collapsed .group-grid{display:none}
/* Untergruppen: eingerückt, mit feiner Linie links */
.group-section.collapsed > .group-body{display:none}
.group-sub{margin-left:10px;padding-left:8px;border-left:1px solid var(--border)}
.group-sub > .group-header{font-size:11.5px;padding-top:4px}
.card{display:flex;flex-direction:column;align-items:center;gap:3px;padding:6px 4px 4px;border:1px solid transparent;border-radius:2px;cursor:pointer;position:relative} .card{display:flex;flex-direction:column;align-items:center;gap:3px;padding:6px 4px 4px;border:1px solid transparent;border-radius:2px;cursor:pointer;position:relative}
.card:hover{background:#e3ecf7} .card:hover{background:#e3ecf7}
.card.sel{background:var(--sel);border-color:var(--sel-br)} .card.sel{background:var(--sel);border-color:var(--sel-br)}
@@ -359,6 +377,16 @@ body.is-popout>header.toolbar,body.is-popout>main.workspace,body.is-popout>foote
/* Import */ /* Import */
.import-pane{min-height:180px} .import-pane{min-height:180px}
/* Fortschritt großer Importe (viele GB) */
.import-lauf{margin:0 0 12px;padding:10px 12px;border:1px solid var(--border);border-radius:6px;background:var(--input,rgba(0,0,0,.15))}
.import-lauf-kopf{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-bottom:8px}
.import-lauf-kopf b{font-size:13px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.import-balken{height:8px;border-radius:999px;background:rgba(127,127,127,.25);overflow:hidden}
.import-balken i{display:block;height:100%;width:0;border-radius:inherit;background:var(--accent);transition:width .25s ease}
.import-balken.unbestimmt i{width:30%;animation:import-schieben 1.2s ease-in-out infinite}
@keyframes import-schieben{0%{transform:translateX(-100%)}100%{transform:translateX(340%)}}
.import-lauf-zeile{margin-top:7px;font-size:12px}
.import-lauf-datei{margin-top:3px;font-size:11px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.import-copy{margin:0 0 10px;font-size:13px;line-height:1.5} .import-copy{margin:0 0 10px;font-size:13px;line-height:1.5}
/* MultiMC-Import */ /* MultiMC-Import */
@@ -654,6 +682,16 @@ img.account-av{object-fit:cover;image-rendering:auto}
.ctx-strong{font-weight:600} .ctx-strong{font-weight:600}
.ctx-danger:hover{background:var(--danger);color:#fff} .ctx-danger:hover{background:var(--danger);color:#fff}
.ctx-sep{height:1px;background:var(--border);margin:4px 6px} .ctx-sep{height:1px;background:var(--border);margin:4px 6px}
/* Konto-Menü oben rechts (wie in MultiMC) */
.acct-pfeil{font-size:9px;opacity:.7;margin-left:-2px}
.konto-menu{min-width:220px}
.konto-eintrag{display:flex;align-items:center;gap:9px}
.konto-eintrag img{width:22px;height:24px;object-fit:contain;flex:none}
.konto-eintrag .konto-name{flex:1;overflow:hidden;text-overflow:ellipsis}
.konto-eintrag .konto-haken{width:14px;text-align:center;color:var(--accent);font-weight:700}
.konto-eintrag .konto-spielt{font-size:10px;color:var(--dim)}
.konto-eintrag.aktiv .konto-name{font-weight:600}
.konto-menu .ctx-item svg{width:16px;height:16px;flex:none;color:var(--dim)}
/* Toast */ /* 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{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}
+12 -1
View File
@@ -149,6 +149,17 @@ function copyInto(sourceDir, targetDir) {
} }
} }
/*
* Was beim Übernehmen kopiert wird: [{ von, name }] – `name` ist der Zielname
* im Spielordner der neuen Instanz. Das Kopieren selbst erledigt kopieren.js
* asynchron mit Fortschritt (main.js → fuehreImportAus).
*/
function kopierQuellen(entry) {
if (!entry.gameDir || !fs.existsSync(entry.gameDir)) return [];
return fs.readdirSync(entry.gameDir).filter((name) => !SKIP_DIRS.has(name))
.map((name) => ({ von: path.join(entry.gameDir, name), name }));
}
function importOne(store, entry, copyData = true) { function importOne(store, entry, copyData = true) {
const created = store.createOrReplaceImportedInstance({ const created = store.createOrReplaceImportedInstance({
name: entry.name, name: entry.name,
@@ -169,4 +180,4 @@ function importOne(store, entry, copyData = true) {
return { id: created.id, name: created.name, copied }; return { id: created.id, name: created.name, copied };
} }
module.exports = { scan, importOne, resolveModpacksDir, technicRoot, detectVersion }; module.exports = { scan, importOne, kopierQuellen, resolveModpacksDir, technicRoot, detectVersion };
@@ -2,7 +2,7 @@
/** /**
* Plugin Name: AeroMC Freunde * Plugin Name: AeroMC Freunde
* Description: Backend für das Freundesystem des AeroMC Launchers - meldet, wer gerade online/im Spiel ist, verwaltet Freundschaftsanfragen, Einladungen, Kurznachrichten, per Code geteilte Instanzen und Fehlerberichte aus dem Absturz-Assistenten sowie eigene Umhänge (auch HD) aus dem Skin-Editor. Anmeldung über Mojangs Sitzungsprüfung (wie bei Minecraft-Servern) - ohne eigenes Passwort. * Description: Backend für das Freundesystem des AeroMC Launchers - meldet, wer gerade online/im Spiel ist, verwaltet Freundschaftsanfragen, Einladungen, Kurznachrichten, per Code geteilte Instanzen und Fehlerberichte aus dem Absturz-Assistenten sowie eigene Umhänge (auch HD) aus dem Skin-Editor. Anmeldung über Mojangs Sitzungsprüfung (wie bei Minecraft-Servern) - ohne eigenes Passwort.
* Version: 1.5.0 * Version: 1.5.1
* Author: M_Viper * Author: M_Viper
*/ */
@@ -10,7 +10,7 @@ if (!defined('ABSPATH')) {
exit; // kein direkter Aufruf der Datei exit; // kein direkter Aufruf der Datei
} }
define('AEROMC_FRIENDS_VERSION', '1.5.0'); define('AEROMC_FRIENDS_VERSION', '1.5.1');
define('AEROMC_FRIENDS_PRESENCE_TABLE', 'aeromc_presence'); define('AEROMC_FRIENDS_PRESENCE_TABLE', 'aeromc_presence');
define('AEROMC_FRIENDS_FRIENDS_TABLE', 'aeromc_friends'); define('AEROMC_FRIENDS_FRIENDS_TABLE', 'aeromc_friends');
define('AEROMC_FRIENDS_INVITES_TABLE', 'aeromc_invites'); define('AEROMC_FRIENDS_INVITES_TABLE', 'aeromc_invites');
@@ -461,6 +461,20 @@ function aeromc_friends_route_request($request) {
$presence_table = aeromc_friends_presence_table(); $presence_table = aeromc_friends_presence_table();
$friends_table = aeromc_friends_friends_table(); $friends_table = aeromc_friends_friends_table();
// Namen des Absenders merken: Bisher kannte der Server ihn erst nach dem
// ersten Spielen über AeroMC (Herzschlag) – vorher sah der Empfänger nur
// die UUID. Bestehende Einträge behalten ihren Status, neue gelten als
// offline (last_seen in der Vergangenheit), damit niemand fälschlich
// "online" erscheint.
$eigener_name = ($sitzung && !empty($sitzung->username)) ? $sitzung->username : $request->get_param('username');
if (aeromc_friends_valid_username($eigener_name)) {
$wpdb->query($wpdb->prepare(
"INSERT INTO $presence_table (uuid, username, last_seen) VALUES (%s, %s, %s)
ON DUPLICATE KEY UPDATE username = %s",
$uuid, $eigener_name, '2000-01-01 00:00:00', $eigener_name
));
}
$ziel = $wpdb->get_row($wpdb->prepare( $ziel = $wpdb->get_row($wpdb->prepare(
"SELECT uuid FROM $presence_table WHERE username = %s", $targetUsername "SELECT uuid FROM $presence_table WHERE username = %s", $targetUsername
)); ));
@@ -708,6 +722,15 @@ function aeromc_friends_route_auth_verify($request) {
'created_at' => $jetzt, 'created_at' => $jetzt,
'last_used' => $jetzt, 'last_used' => $jetzt,
]); ]);
// Bestätigten Namen gleich merken – so ist ein Spieler per Name auffindbar,
// sobald er AeroMC mit Freundesystem nutzt, nicht erst nach dem ersten
// Spielen. Neue Einträge gelten als offline (last_seen in der Vergangenheit).
$presence_table = aeromc_friends_presence_table();
$wpdb->query($wpdb->prepare(
"INSERT INTO $presence_table (uuid, username, last_seen) VALUES (%s, %s, %s)
ON DUPLICATE KEY UPDATE username = %s",
$uuid, $profil['name'], '2000-01-01 00:00:00', $profil['name']
));
return ['token' => $token, 'username' => $profil['name']]; return ['token' => $token, 'username' => $profil['name']];
} }