diff --git a/package-lock.json b/package-lock.json index 5e2b9a2..4be30b2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "aeromc", - "version": "1.0.6", + "version": "1.0.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "aeromc", - "version": "1.0.6", + "version": "1.0.7", "license": "MIT", "dependencies": { "archiver": "^7.0.1", diff --git a/package.json b/package.json index a3f3bd9..64cf6da 100644 --- a/package.json +++ b/package.json @@ -1,15 +1,12 @@ { "name": "aeromc", "productName": "AeroMC", - "version": "1.0.6", + "version": "1.0.7", "description": "AeroMC – Instanz-basierter Minecraft-Launcher (à la MultiMC/Prism)", "homepage": "https://m-viper.de", + "launcherWebsite": "https://aeromc.viper.ipv64.net", "main": "src/main.js", "author": "M_Viper", - "repository": { - "type": "git", - "url": "https://git.viper.ipv64.net" - }, "license": "MIT", "scripts": { "start": "electron .", diff --git a/src/index.html b/src/index.html index fd7cd5d..34f63f1 100644 --- a/src/index.html +++ b/src/index.html @@ -265,6 +265,14 @@ Erscheint nur, während das Launcher-Fenster nicht im Vordergrund ist. + +
+
Autostart
+ + Startet AeroMC beim Anmelden automatisch im Hintergrund. Solange aktiv, landet der Launcher beim Schließen (X) nur im Infobereich neben der Uhr, statt sich zu beenden – über das Symbol dort lässt er sich jederzeit wieder öffnen oder wirklich beenden. +
diff --git a/src/main.js b/src/main.js index 2cad665..a6c7866 100644 --- a/src/main.js +++ b/src/main.js @@ -6,7 +6,7 @@ * (Vanilla-Start, Microsoft-Login und Mod-Loader folgen in späteren Phasen.) */ -const { app, BrowserWindow, ipcMain, dialog, shell, safeStorage, Notification } = require('electron'); +const { app, BrowserWindow, ipcMain, dialog, shell, safeStorage, Notification, Tray, Menu, nativeImage } = require('electron'); const path = require('path'); const fs = require('fs'); const os = require('os'); @@ -124,18 +124,29 @@ function instanzAusArgumenten(argv) { return null; } +// wie instanzAusArgumenten, nur für die optionale Server-Adresse einer +// Server-Verknüpfung ("--server=") – bleibt aus demselben Grund als ein +// einziges "--server=..."-Argument zusammen +function serverAusArgumenten(argv) { + const args = (argv || []).map(String); + for (const a of args) { + if (a.startsWith('--server=')) return a.slice('--server='.length); + } + return null; +} + // Startwunsch an die Oberfläche geben – dort hängt die gesamte Rückmeldung dran -function starteInstanzImFenster(id) { +function starteInstanzImFenster(id, serverIp) { if (!id || !mainWindow || mainWindow.isDestroyed()) return; - mainWindow.webContents.send('instance:launch-request', id); + mainWindow.webContents.send('instance:launch-request', { id, serverIp: serverIp || null }); } app.on('second-instance', (_e, argv) => { - if (mainWindow && !mainWindow.isDestroyed()) { - if (mainWindow.isMinimized()) mainWindow.restore(); - mainWindow.focus(); - } - starteInstanzImFenster(instanzAusArgumenten(argv)); + // showMainWindow() statt nur restore()+focus(): bei aktivem Autostart kann + // das Fenster echt versteckt sein (hide(), nicht nur minimiert) – das + // braucht ein show(), sonst bleibt es beim zweiten Start unsichtbar + showMainWindow(); + starteInstanzImFenster(instanzAusArgumenten(argv), serverAusArgumenten(argv)); }); // Ziel für Verknüpfungen: bei der Portable-Fassung zeigt process.execPath auf @@ -147,6 +158,31 @@ function verknuepfungsZiel() { return process.execPath; } +/* + * Baut die Argumente für eine Verknüpfung zusammen. + * Im Entwicklungsmodus (npm start) zeigt process.execPath nur auf die nackte + * electron.exe – die weiß ohne Pfadangabe nicht, welche App sie laden soll, + * und zeigt stattdessen ihren eigenen Startbildschirm statt AeroMC. Im + * gepackten Build (Setup/Portable) ist die App fest eingebaut, daher dort + * kein Zusatz nötig. + */ +function verknuepfungsArgs(teile) { + const praefix = app.isPackaged ? '' : `"${path.join(__dirname, '..')}" `; + return praefix + teile.join(' '); +} + +/* + * Icon für eine Verknüpfung. Im gepackten Build trägt die EXE selbst schon + * das richtige Icon als Ressource (funktioniert auch aus dem asar heraus). + * Im Entwicklungsmodus zeigt "ziel" auf die nackte electron.exe (Atom-Logo) – + * dort stattdessen direkt die eigene .ico-Datei verwenden, die im + * Entwicklungsordner als normale Datei vorliegt. + */ +function verknuepfungsIcon(ziel) { + if (app.isPackaged) return ziel; + return windowIcon() || ziel; +} + function windowIcon() { const png = path.join(__dirname, 'assets', 'logo.png'); const ico = path.join(__dirname, 'assets', 'icon.ico'); @@ -155,7 +191,7 @@ function windowIcon() { return undefined; // Electron-Standard, solange kein Logo hinterlegt ist } -function createWindow() { +function createWindow(startHidden) { mainWindow = new BrowserWindow({ width: 1200, height: 820, @@ -165,6 +201,7 @@ function createWindow() { title: 'AeroMC', icon: windowIcon(), autoHideMenuBar: true, + show: !startHidden, webPreferences: { preload: path.join(__dirname, 'preload.js'), contextIsolation: true, @@ -175,13 +212,69 @@ function createWindow() { mainWindow.loadFile(path.join(__dirname, 'index.html')); - // mit "--launch " gestartet -> Instanz anwerfen, sobald die Oberfläche steht + // Bei aktivem Autostart landet das Fenster beim Schließen (X) nur im + // Infobereich, statt AeroMC wirklich zu beenden – sonst wäre der + // Autostart witzlos, weil man ihn beim ersten Wegklicken gleich wieder los ist. + mainWindow.on('close', (e) => { + if (appIsQuitting || !store.getSettings().autostart) return; + e.preventDefault(); + mainWindow.hide(); + }); + + // mit "--launch " gestartet -> Instanz anwerfen, sobald die Oberfläche + // ihren Empfänger dafür registriert hat. WICHTIG: nicht auf "did-finish-load" + // verlassen – das feuert, sobald die Seite geladen ist, aber die Oberfläche + // registriert ihren IPC-Listener erst nach eigenem asynchronem Vorlauf + // (u. a. Skin-Hintergrund laden). Kommt der Startwunsch früher an als diese + // Registrierung, geht er kommentarlos verloren – ein Wettlauf, der je nach + // Netzwerk-/Ladegeschwindigkeit mal gewinnt, mal nicht. Deshalb wartet der + // Hauptprozess hier auf ein explizites Bereit-Signal der Oberfläche. const startId = instanzAusArgumenten(process.argv); + const startServer = serverAusArgumenten(process.argv); if (startId) { - mainWindow.webContents.once('did-finish-load', () => starteInstanzImFenster(startId)); + ipcMain.once('renderer:ready', () => starteInstanzImFenster(startId, startServer)); } } +let tray = null; +let appIsQuitting = false; + +function showMainWindow() { + if (!mainWindow || mainWindow.isDestroyed()) { createWindow(false); return; } + if (mainWindow.isMinimized()) mainWindow.restore(); + mainWindow.show(); + mainWindow.focus(); +} + +function createTray() { + if (tray) return; + try { + const iconPfad = windowIcon(); + const bild = iconPfad ? nativeImage.createFromPath(iconPfad) : nativeImage.createEmpty(); + tray = new Tray(bild.isEmpty() ? nativeImage.createEmpty() : bild.resize({ width: 16, height: 16 })); + tray.setToolTip('AeroMC'); + tray.setContextMenu(Menu.buildFromTemplate([ + { label: 'Öffnen', click: () => showMainWindow() }, + { type: 'separator' }, + { label: 'Beenden', click: () => { appIsQuitting = true; app.quit(); } }, + ])); + tray.on('click', () => showMainWindow()); + } catch { /* z. B. wenn kein Icon vorhanden ist – Autostart läuft dann ohne Tray-Symbol weiter */ } +} + +function destroyTray() { + if (tray) { tray.destroy(); tray = null; } +} + +// Windows-Autostart-Eintrag setzen/entfernen + Tray-Symbol passend ein-/ausblenden +function applyAutostart(enabled) { + try { + app.setLoginItemSettings({ openAtLogin: !!enabled, args: enabled ? ['--hidden'] : [] }); + } catch { /* z. B. in portabler Umgebung ohne Schreibrechte auf die Registry */ } + if (enabled) createTray(); + else destroyTray(); +} + app.whenReady().then(() => { if (!hatEinzelSperre) return; // zweite Instanz beendet sich selbst store.init(app.getPath('userData')); @@ -195,7 +288,12 @@ app.whenReady().then(() => { decrypt: (b) => safeStorage.decryptString(Buffer.from(b, 'base64')), }); applyProxy(store.getSettings()); - createWindow(); + const autostartAn = !!store.getSettings().autostart; + // Autostart-Eintrag/Tray mit der Einstellung synchron halten (falls z. B. + // von Hand an der Registry gedreht wurde oder nach einem Update) + applyAutostart(autostartAn); + const startHidden = autostartAn && process.argv.includes('--hidden'); + createWindow(startHidden); // Java-Suche im Hintergrund vorbereiten -> erster Spielstart ohne Verzögerung setTimeout(() => { try { services.scanJava(); } catch { /* egal */ } }, 800); @@ -255,6 +353,8 @@ app.on('window-all-closed', () => { if (process.platform !== 'darwin') app.quit(); }); +app.on('before-quit', () => { appIsQuitting = true; }); + // --------------------------------------------------------------------------- // Java-Erkennung (für Anzeige in den Einstellungen) // --------------------------------------------------------------------------- @@ -277,7 +377,12 @@ function detectJava(javaPath) { ipcMain.handle('settings:get', () => store.getSettings()); -ipcMain.handle('settings:save', (_e, patch) => { const s = store.saveSettings(patch); applyProxy(s); return s; }); +ipcMain.handle('settings:save', (_e, patch) => { + const s = store.saveSettings(patch); + applyProxy(s); + if (patch && Object.prototype.hasOwnProperty.call(patch, 'autostart')) applyAutostart(s.autostart); + return s; +}); ipcMain.handle('settings:instancesDir', () => store.getInstancesDir()); @@ -1251,24 +1356,62 @@ ipcMain.handle('modpack:import', async () => { }); // Verknüpfung auf dem Desktop, die genau diese Instanz startet -ipcMain.handle('instances:shortcut', (_e, id) => { +// wunschName: optionaler, vom Nutzer selbst vergebener Dateiname (ohne .lnk) +ipcMain.handle('instances:shortcut', (_e, id, wunschName) => { const inst = store.getInstance(id); if (!inst) return { ok: false, message: 'Instanz nicht gefunden.' }; if (process.platform !== 'win32') { return { ok: false, message: 'Verknüpfungen werden nur unter Windows unterstützt.' }; } try { - const name = String(inst.name || 'Instanz').replace(/[\\/:*?"<>|]/g, '_').trim() || 'Instanz'; + const roh = (wunschName && wunschName.trim()) || inst.name || 'Instanz'; + const name = String(roh).replace(/[\\/:*?"<>|]/g, '_').trim() || 'Instanz'; const datei = path.join(app.getPath('desktop'), name + '.lnk'); const ziel = verknuepfungsZiel(); const ok = shell.writeShortcutLink(datei, fs.existsSync(datei) ? 'replace' : 'create', { target: ziel, // Gleichheitsform, damit die Kennung beim Umsortieren durch Chromium // an ihrem Schalter bleibt (siehe instanzAusArgumenten) - args: `--launch="${id}"`, + args: verknuepfungsArgs([`--launch="${id}"`]), cwd: path.dirname(ziel), description: 'AeroMC – ' + inst.name, - icon: ziel, + icon: verknuepfungsIcon(ziel), + iconIndex: 0, + }); + if (!ok) return { ok: false, message: 'Verknüpfung konnte nicht geschrieben werden.' }; + return { ok: true, path: datei }; + } catch (err) { + return { ok: false, message: 'Verknüpfung fehlgeschlagen: ' + err.message }; + } +}); + +/* + * Desktop-Verknüpfung, die eine Instanz startet UND direkt mit einem + * bestimmten Server verbindet (Quick Play, ab Minecraft 1.20). Technisch + * dasselbe wie instances:shortcut, nur mit zusätzlichem "--server="-Argument. + * wunschName: optionaler, vom Nutzer selbst vergebener Dateiname (ohne .lnk) + */ +ipcMain.handle('servers:shortcut', (_e, id, ip, name, wunschName) => { + const inst = store.getInstance(id); + if (!inst) return { ok: false, message: 'Instanz nicht gefunden.' }; + if (!ip) return { ok: false, message: 'Keine Serveradresse angegeben.' }; + if (process.platform !== 'win32') { + return { ok: false, message: 'Verknüpfungen werden nur unter Windows unterstützt.' }; + } + try { + const rohName = (wunschName && wunschName.trim()) + || `${inst.name || 'Instanz'} - ${name || ip}`; + const dateiName = String(rohName).replace(/[\\/:*?"<>|]/g, '_').trim() || 'Server'; + const datei = path.join(app.getPath('desktop'), `${dateiName}.lnk`); + const ziel = verknuepfungsZiel(); + const ok = shell.writeShortcutLink(datei, fs.existsSync(datei) ? 'replace' : 'create', { + target: ziel, + // Gleichheitsform für beide Kennungen, damit sie beim Umsortieren durch + // Chromium zusammenbleiben (siehe instanzAusArgumenten) + args: verknuepfungsArgs([`--launch="${id}"`, `--server="${ip}"`]), + cwd: path.dirname(ziel), + description: `AeroMC – ${inst.name} → ${name || ip}`, + icon: verknuepfungsIcon(ziel), iconIndex: 0, }); if (!ok) return { ok: false, message: 'Verknüpfung konnte nicht geschrieben werden.' }; @@ -1936,5 +2079,5 @@ ipcMain.handle('app:info', () => ({ version: app.getVersion(), author: pkg.author || '', homepage: pkg.homepage || '', - gitpage: typeof pkg.repository === 'string' ? pkg.repository : (pkg.repository?.url || ''), + launcherWebsite: pkg.launcherWebsite || '', })); diff --git a/src/preload.js b/src/preload.js index d13abed..66ea33b 100644 --- a/src/preload.js +++ b/src/preload.js @@ -87,13 +87,18 @@ contextBridge.exposeInMainWorld('api', { return () => ipcRenderer.removeListener('backup:progress', l); }, importInstance: () => ipcRenderer.invoke('instances:import'), - createShortcut: (id) => ipcRenderer.invoke('instances:shortcut', id), - // Start über eine Desktop-Verknüpfung ("--launch ") + createShortcut: (id, customName) => ipcRenderer.invoke('instances:shortcut', id, customName), + serversShortcut: (id, ip, name, customName) => ipcRenderer.invoke('servers:shortcut', id, ip, name, customName), + // Start über eine Desktop-Verknüpfung ("--launch=", optional "--server=") onLaunchRequest: (cb) => { - const l = (_e, id) => cb(id); + const l = (_e, payload) => cb(payload); ipcRenderer.on('instance:launch-request', l); return () => ipcRenderer.removeListener('instance:launch-request', l); }, + // Signalisiert dem Hauptprozess, dass onLaunchRequest jetzt registriert ist – + // verhindert, dass ein Start-Wunsch aus einer Verknüpfung verloren geht, + // weil er ankommt, bevor die Oberfläche fertig eingerichtet ist + notifyReady: () => ipcRenderer.send('renderer:ready'), importModpack: () => ipcRenderer.invoke('modpack:import'), // Datei per Drag&Drop aufs Fenster gezogen (.jar/.mrpack/.zip) filesDrop: (filePath, targetId, targetArt) => ipcRenderer.invoke('files:drop', filePath, targetId, targetArt), diff --git a/src/renderer.js b/src/renderer.js index 6dcc8b2..819befb 100644 --- a/src/renderer.js +++ b/src/renderer.js @@ -2155,6 +2155,27 @@ async function renderServers() { }); aktionen.appendChild(starten); + const favorit = document.createElement('button'); + favorit.className = 'btn btn-mini'; + favorit.textContent = '★ Verknüpfung'; + favorit.title = 'Desktop-Verknüpfung erstellen, die Minecraft startet und direkt mit diesem Server verbindet (Quick Play, ab MC 1.20)'; + favorit.addEventListener('click', async () => { + const inst = await window.api.getInstance(currentDetailId); + const vorschlag = `${inst ? inst.name : 'Instanz'} - ${s.name || s.ip}`; + const eingabe = await askPrompt({ + title: 'Server-Verknüpfung erstellen', + text: 'Name der Desktop-Verknüpfung (ohne .lnk):', + label: 'Name', + value: vorschlag, + okLabel: 'Erstellen', + }); + if (eingabe == null) return; // abgebrochen + const res = await window.api.serversShortcut(currentDetailId, s.ip, s.name, eingabe.trim()); + if (res.ok) toast('Verknüpfung auf dem Desktop erstellt: ' + (s.name || s.ip)); + else toast(res.message || 'Verknüpfung fehlgeschlagen.', true); + }); + aktionen.appendChild(favorit); + const umbenennen = document.createElement('button'); umbenennen.className = 'btn btn-mini'; umbenennen.textContent = 'Umbenennen'; @@ -3054,6 +3075,7 @@ async function openSettings(pane) { el('s-iso3d').checked = s.iso3dIcons !== false; el('s-bedrock').checked = s.bedrockEnabled !== false; el('s-notify').checked = s.desktopNotifications !== false; + el('s-autostart').checked = !!s.autostart; el('s-auto-java').checked = s.autoJava !== false; el('s-autobackup').checked = !!s.autoBackupEnabled; el('s-autobackup-every').value = s.autoBackupEvery || 'daily'; @@ -3100,7 +3122,7 @@ async function openSettings(pane) { ['Version', formatVersionLabel(info.version)], ['Autor', info.author || 'Nicht hinterlegt'], ['Webseite', info.homepage || 'Nicht hinterlegt', info.homepage || ''], - ['Gitseite', info.gitpage || 'Nicht hinterlegt', info.gitpage || ''], + ['Launcher-Webseite', info.launcherWebsite || 'Nicht hinterlegt', info.launcherWebsite || ''], ].map(([label, value, url]) => { const renderedValue = url ? `${escapeHtml(value)}` @@ -3174,6 +3196,7 @@ async function saveSettings() { iso3dIcons: el('s-iso3d').checked, bedrockEnabled: el('s-bedrock').checked, desktopNotifications: el('s-notify').checked, + autostart: el('s-autostart').checked, autoJava: el('s-auto-java').checked, autoBackupEnabled: el('s-autobackup').checked, autoBackupEvery: el('s-autobackup-every').value, @@ -3855,7 +3878,16 @@ function wire() { el('p-offline').addEventListener('click', () => currentDetailId && launch(currentDetailId)); el('p-shortcut').addEventListener('click', async () => { if (!currentDetailId) return; - const res = await window.api.createShortcut(currentDetailId); + const inst = await window.api.getInstance(currentDetailId); + const eingabe = await askPrompt({ + title: 'Verknüpfung erstellen', + text: 'Name der Desktop-Verknüpfung (ohne .lnk):', + label: 'Name', + value: inst ? inst.name : '', + okLabel: 'Erstellen', + }); + if (eingabe == null) return; // abgebrochen + const res = await window.api.createShortcut(currentDetailId, eingabe.trim()); if (res.ok) toast('Verknüpfung auf dem Desktop erstellt.'); else toast(res.message || 'Verknüpfung fehlgeschlagen.', true); }); @@ -4252,10 +4284,16 @@ window.addEventListener('DOMContentLoaded', async () => { // Spiel gestartet oder beendet -> "spielt gerade" in den Kontenlisten nachziehen window.api.onAccountsChanged(() => { refreshAllAccountLists(); }); // über eine Desktop-Verknüpfung gestartet -> Instanz auswählen und anwerfen - window.api.onLaunchRequest(async (id) => { + // (bei einer Server-Verknüpfung zusätzlich direkt mit dem Server verbinden) + window.api.onLaunchRequest(async (payload) => { + const id = payload && typeof payload === 'object' ? payload.id : payload; + const serverIp = payload && typeof payload === 'object' ? payload.serverIp : null; await selectInstance(id); - launch(id); + launch(id, null, serverIp ? { quickPlayServer: serverIp } : null); }); + // Empfänger steht jetzt – dem Hauptprozess Bescheid geben, damit ein über + // eine Verknüpfung mitgegebener Startwunsch sicher zugestellt werden kann + window.api.notifyReady(); window.api.onUpdateAvailable(showUpdateDialog); window.api.onUpdateProgress(onUpdateProgress); window.api.onUpdateChecked(renderUpdateStatus); diff --git a/src/store.js b/src/store.js index 5bf1765..ebb881b 100644 --- a/src/store.js +++ b/src/store.js @@ -76,6 +76,7 @@ const DEFAULT_SETTINGS = { iso3dIcons: true, // Block-Symbole räumlich darstellen bedrockEnabled: true, // Bedrock-Bereich anzeigen (nur wenn auch installiert) desktopNotifications: true, // Windows-Meldung bei fertigem Hintergrund-Download/-Backup + autostart: false, // mit Windows starten, im Infobereich statt als Fenster bgSkin: '', // '' = aus, 'active' = aktives Konto, sonst Konto-UUID bgSkinOpacity: 55, // Deckkraft des Hintergrund-Skins in % bgSkinPose: 'walking', // Pose des 3D-Renders (falls der Pose-Dienst erreichbar ist)