diff --git a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/installer/ForgeInstaller.java b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/installer/ForgeInstaller.java index b6ff086..b015493 100644 --- a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/installer/ForgeInstaller.java +++ b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/installer/ForgeInstaller.java @@ -48,7 +48,16 @@ public class ForgeInstaller { LauncherLogger.info("Downloading Forge Installer..."); downloadFileWithProgress(installerUrl, installerJar); - // Step 4: Run Forge Installer and show its output + // Step 4: Pre-download the libraries the installer subprocess would fetch from + // maven.minecraftforge.net directly. On blocked networks that subprocess cannot + // reach the maven host, so we seed every library (version.json + install_profile.json) + // through the launcher smart proxy first. The installer reuses files whose checksums + // validate ("File exists: Checksum validated"), so no direct downloads are needed. + LauncherLogger.info("Prefetching Forge libraries via smart proxy..."); + int prefetched = ZHttpClient.prefetchInstallerLibraries(installerJar, instance.getPath()); + LauncherLogger.info("Prefetched " + prefetched + " Forge libraries"); + + // Step 5: Run Forge Installer and show its output LauncherLogger.info("Running Forge Installer..."); LauncherLogger.info("This may take a few minutes. Please wait..."); @@ -105,6 +114,13 @@ public class ForgeInstaller { while (attempt <= maxRetries) { LauncherLogger.info("Attempt " + attempt + " of " + maxRetries); + // Re-seed libraries each attempt: the installer may have left partial files, + // and prefetch deletes any with a bad checksum before re-downloading via proxy. + int seeded = ZHttpClient.prefetchInstallerLibraries(installerJar, instance.getPath()); + if (seeded > 0) { + LauncherLogger.info(" Prefetched " + seeded + " libraries for this attempt"); + } + ProcessBuilder pb = new ProcessBuilder( JavaResolver.getJavaExecutable(), "-jar", @@ -175,19 +191,8 @@ public class ForgeInstaller { if (attempt < maxRetries) { LauncherLogger.warn("Install error. Retrying in 5 seconds..."); Thread.sleep(5000); - - // Clean temp files before retry - Path librariesDir = instance.getPath().resolve("libraries"); - if (Files.exists(librariesDir)) { - // Удаляем только частично скачанные библиотеки Forge - try (var stream = Files.walk(librariesDir)) { - stream.filter(p -> p.toString().contains("asm") && p.toString().endsWith(".jar")) - .forEach(p -> { - try { Files.deleteIfExists(p); } - catch (IOException e) { /* ignore */ } - }); - } - } + // Prefetch at the top of the next attempt already re-seeds any library that + // was left partial or corrupted (SHA-1 mismatch -> delete + re-download via proxy). } else { LauncherLogger.error("Forge Installer exited with error code: " + exitCode); diff --git a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/installer/NeoForgeInstaller.java b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/installer/NeoForgeInstaller.java index 183c2f0..70d89d7 100644 --- a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/installer/NeoForgeInstaller.java +++ b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/installer/NeoForgeInstaller.java @@ -49,6 +49,10 @@ public class NeoForgeInstaller { LauncherLogger.info("Downloading NeoForge Installer..."); downloadFileWithProgress(installerUrl, installerJar); + LauncherLogger.info("Prefetching NeoForge libraries via smart proxy..."); + int prefetched = ZHttpClient.prefetchInstallerLibraries(installerJar, instance.getPath()); + LauncherLogger.info("Prefetched " + prefetched + " NeoForge libraries"); + LauncherLogger.info("Running NeoForge Installer..."); LauncherLogger.info("This may take a few minutes. Please wait..."); diff --git a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/ui/jfx/JFXLauncher.java b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/ui/jfx/JFXLauncher.java index 29f443e..06029e9 100644 --- a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/ui/jfx/JFXLauncher.java +++ b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/ui/jfx/JFXLauncher.java @@ -206,6 +206,77 @@ public class JFXLauncher extends Application { launch(args); } + private static final byte[] PNG_SIGNATURE = {(byte) 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}; + + private static javafx.scene.image.Image loadWindowIcon() { + try { + byte[] ico = readAssetBytes("assets/ui/icons/zernmc.ico"); + if (ico != null) { + byte[] png = extractIcoPngFrame(ico); + if (png != null) { + return new javafx.scene.image.Image(new java.io.ByteArrayInputStream(png)); + } + } + byte[] png = readAssetBytes("assets/ui/icons/zernmc.png"); + if (png != null) { + return new javafx.scene.image.Image(new java.io.ByteArrayInputStream(png)); + } + } catch (Exception e) { + log("Window icon load error: " + e.getMessage()); + } + return null; + } + + private static byte[] readAssetBytes(String resourcePath) { + try (InputStream in = JFXLauncher.class.getResourceAsStream("/" + resourcePath)) { + if (in != null) { + return in.readAllBytes(); + } + } catch (Exception ignored) {} + try { + Path file = Paths.get(resourcePath); + if (Files.exists(file)) { + return Files.readAllBytes(file); + } + } catch (Exception ignored) {} + return null; + } + + private static byte[] extractIcoPngFrame(byte[] ico) { + if (ico == null || ico.length < 6 || ico[0] != 0 || ico[1] != 0) { + return null; + } + int count = (ico[4] & 0xFF) | ((ico[5] & 0xFF) << 8); + int bestSize = -1; + int bestOffset = -1; + int bestLength = -1; + for (int i = 0; i < count; i++) { + int off = 6 + i * 16; + if (off + 16 > ico.length) break; + int w = ico[off] & 0xFF; + int h = ico[off + 1] & 0xFF; + int size = (w == 0 ? 256 : w) * (h == 0 ? 256 : h); + int len = (ico[off + 8] & 0xFF) | ((ico[off + 9] & 0xFF) << 8) + | ((ico[off + 10] & 0xFF) << 16) | ((ico[off + 11] & 0xFF) << 24); + int imgOff = (ico[off + 12] & 0xFF) | ((ico[off + 13] & 0xFF) << 8) + | ((ico[off + 14] & 0xFF) << 16) | ((ico[off + 15] & 0xFF) << 24); + if (imgOff < 0 || len <= 0 || imgOff + len > ico.length) continue; + boolean isPng = true; + for (int b = 0; b < PNG_SIGNATURE.length; b++) { + if (ico[imgOff + b] != PNG_SIGNATURE[b]) { isPng = false; break; } + } + if (isPng && size > bestSize) { + bestSize = size; + bestOffset = imgOff; + bestLength = len; + } + } + if (bestOffset < 0 || bestLength <= 0) return null; + byte[] png = new byte[bestLength]; + System.arraycopy(ico, bestOffset, png, 0, bestLength); + return png; + } + private static void extractAssets() { try { Path assetsDir = Paths.get("assets"); @@ -445,6 +516,11 @@ public class JFXLauncher extends Application { String url = "http://localhost:" + PORT + "/assets/ui/index.html"; engine.load(url); + javafx.scene.image.Image windowIcon = loadWindowIcon(); + if (windowIcon != null) { + stage.getIcons().add(windowIcon); + } + stage.setTitle(APP_TITLE); int winW = Config.getWindowWidth(); int winH = Config.getWindowHeight(); @@ -518,6 +594,7 @@ public class JFXLauncher extends Application { server.createContext("/api/exit-parent", this::handleExitParent); server.createContext("/api/system-info", this::handleSystemInfo); server.createContext("/api/open-log-file", this::handleOpenLogFile); + server.createContext("/api/clipboard", this::handleClipboard); server.createContext("/api/friends/list", this::handleFriendList); server.createContext("/api/friends/add", this::handleFriendAdd); server.createContext("/api/friends/remove", this::handleFriendRemove); @@ -561,21 +638,43 @@ public class JFXLauncher extends Application { private void handleOpenLogFile(HttpExchange exchange) { try { + Path target = null; if (gameLogFile != null && Files.exists(gameLogFile)) { + target = gameLogFile; + } else if (launcherLogFile != null && Files.exists(launcherLogFile)) { + target = launcherLogFile; + } + if (target != null) { if (Desktop.isDesktopSupported()) { - Desktop.getDesktop().open(gameLogFile.toFile()); - sendJson(exchange, Map.of("success", true)); + Desktop.getDesktop().open(target.toFile()); + sendJson(exchange, Map.of("success", true, "file", target.toString())); } else { sendJson(exchange, Map.of("success", false, "error", "Desktop not supported")); } } else { - sendJson(exchange, Map.of("success", false, "error", "No game log file found")); + sendJson(exchange, Map.of("success", false, "error", "No log file found")); } } catch (Exception e) { sendJson(exchange, Map.of("success", false, "error", e.getMessage())); } } + private void handleClipboard(HttpExchange exchange) { + try { + Map body = parseJson(exchange.getRequestBody()); + String text = body != null ? body.get("text") : null; + if (text == null) { + sendJson(exchange, Map.of("success", false, "error", "Empty clipboard text")); + return; + } + java.awt.Toolkit.getDefaultToolkit().getSystemClipboard().setContents( + new java.awt.datatransfer.StringSelection(text), null); + sendJson(exchange, Map.of("success", true)); + } catch (Exception e) { + sendJson(exchange, Map.of("success", false, "error", e.getMessage())); + } + } + private void handleOpenUrl(HttpExchange exchange) { try { Map body = parseJson(exchange.getRequestBody()); diff --git a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/utils/ZHttpClient.java b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/utils/ZHttpClient.java index 29ed214..027fb5e 100644 --- a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/utils/ZHttpClient.java +++ b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/utils/ZHttpClient.java @@ -717,6 +717,83 @@ public class ZHttpClient { } } + /** + * Pre-downloads every library referenced by a loader installer jar (Forge/NeoForge). + * The installer later runs as a separate JVM subprocess that downloads libraries + * directly from the loader's maven repository, bypassing this launcher's smart proxy. + * On blocked networks (e.g. maven.minecraftforge.net unreachable) that subprocess + * fails with "Connection reset". Because the installer reuses any library already + * present with a matching checksum ("File exists: Checksum validated"), pre-seeding + * those files through the proxy lets the install complete without direct downloads. + * + * @return number of new library files downloaded + */ + public static int prefetchInstallerLibraries(Path installerJar, Path minecraftDir) { + int prefetched = 0; + try (java.util.zip.ZipFile zip = new java.util.zip.ZipFile(installerJar.toFile())) { + for (String jsonName : List.of("version.json", "install_profile.json")) { + java.util.zip.ZipEntry entry = zip.getEntry(jsonName); + if (entry == null) continue; + String content = new String(zip.getInputStream(entry).readAllBytes(), StandardCharsets.UTF_8); + JSONObject root = new JSONObject(content); + JSONArray libs = root.optJSONArray("libraries"); + if (libs == null) continue; + for (int i = 0; i < libs.length(); i++) { + JSONObject lib = libs.optJSONObject(i); + if (lib == null) continue; + JSONObject downloads = lib.optJSONObject("downloads"); + if (downloads == null) continue; + JSONObject artifact = downloads.optJSONObject("artifact"); + if (artifact == null) continue; + String url = artifact.optString("url", null); + String path = artifact.optString("path", null); + String sha1 = artifact.optString("sha1", null); + if (url == null || path == null) continue; + + Path target = minecraftDir.resolve("libraries").resolve(path); + try { + if (Files.exists(target)) { + if (sha1 == null || sha1.isEmpty() || sha1Matches(target, sha1)) { + continue; + } + Files.delete(target); + } + Files.createDirectories(target.getParent()); + downloadFileWithSmartProxy(url, target); + if (sha1 != null && !sha1.isEmpty() && !sha1Matches(target, sha1)) { + Files.delete(target); + LauncherLogger.warn("[LIB] Checksum mismatch after prefetch: " + path); + continue; + } + prefetched++; + LauncherLogger.info("[LIB] Prefetched " + path); + } catch (Exception e) { + LauncherLogger.warn("[LIB] Prefetch failed for " + path + ": " + e.getMessage()); + } + } + } + } catch (Exception e) { + LauncherLogger.warn("[LIB] Installer prefetch scan failed: " + e.getMessage()); + } + return prefetched; + } + + private static boolean sha1Matches(Path file, String expectedSha1) throws Exception { + java.security.MessageDigest digest = java.security.MessageDigest.getInstance("SHA-1"); + try (java.io.InputStream in = Files.newInputStream(file)) { + byte[] buffer = new byte[8192]; + int read; + while ((read = in.read(buffer)) != -1) { + digest.update(buffer, 0, read); + } + } + StringBuilder sb = new StringBuilder(); + for (byte b : digest.digest()) { + sb.append(String.format("%02x", b)); + } + return sb.toString().equalsIgnoreCase(expectedSha1); + } + private static String resolveCoordinatesUrl(JSONObject lib) { String name = lib.getString("name"); String base = lib.getString("url"); diff --git a/launcher/launcher/src/resources/ui/icons/zernmc.ico b/launcher/launcher/src/resources/ui/icons/zernmc.ico new file mode 100644 index 0000000..6f60d98 Binary files /dev/null and b/launcher/launcher/src/resources/ui/icons/zernmc.ico differ diff --git a/launcher/launcher/src/resources/ui/icons/zernmc.png b/launcher/launcher/src/resources/ui/icons/zernmc.png new file mode 100644 index 0000000..45e75c2 Binary files /dev/null and b/launcher/launcher/src/resources/ui/icons/zernmc.png differ diff --git a/launcher/launcher/src/resources/ui/index.html b/launcher/launcher/src/resources/ui/index.html index f9ac99a..41afe00 100644 --- a/launcher/launcher/src/resources/ui/index.html +++ b/launcher/launcher/src/resources/ui/index.html @@ -151,7 +151,7 @@ -
+
-
+
@@ -550,7 +550,7 @@

Game Log

- +
diff --git a/launcher/launcher/src/resources/ui/launcher.js b/launcher/launcher/src/resources/ui/launcher.js index daa3075..3ba1fbe 100644 --- a/launcher/launcher/src/resources/ui/launcher.js +++ b/launcher/launcher/src/resources/ui/launcher.js @@ -58,6 +58,8 @@ const LOCALES = { 'logViewer.noLogs': 'No logs yet. Launch the game to see logs here.', 'logViewer.copy': 'Copy', 'logViewer.copied': 'Copied!', 'logViewer.openFile': 'Open File', 'logViewer.close': 'Close', + 'logViewer.opened': 'File opened', + 'logViewer.openFailed': 'Could not open the log file', 'logViewer.waiting': 'Waiting for game output...', 'install.title': 'Install Pack', 'install.tab.serverPack': 'Server Pack', 'install.tab.custom': 'Custom', @@ -267,6 +269,8 @@ const LOCALES = { 'logViewer.noLogs': 'Логов пока нет. Запустите игру, чтобы увидеть логи.', 'logViewer.copy': 'Копировать', 'logViewer.copied': 'Скопировано!', 'logViewer.openFile': 'Открыть файл', 'logViewer.close': 'Закрыть', + 'logViewer.opened': 'Файл открыт', + 'logViewer.openFailed': 'Не удалось открыть файл логов', 'logViewer.waiting': 'Ожидание вывода игры...', 'install.title': 'Установить сборку', 'install.tab.serverPack': 'Серверная сборка', 'install.tab.custom': 'Своя', @@ -475,7 +479,10 @@ class ZernMCLauncher { _localeData = LOCALES[lang] || LOCALES.en; applyLocale(); var sel = document.getElementById('locale-select'); - if (sel) sel.value = lang; + if (sel) { + sel.value = lang; + if (sel.dataset.enhanced) this.refreshCustomSelect(sel); + } } // ==================== BACKGROUND ==================== @@ -2477,6 +2484,7 @@ class ZernMCLauncher { openLogViewer() { var overlay = document.getElementById('log-viewer-overlay'); if (!overlay) return; + this._logRenderedLines = null; overlay.classList.remove('hidden'); this._pollLogViewer(); this._logPollInterval = setInterval(this._pollLogViewer.bind(this), 1500); @@ -2504,13 +2512,32 @@ class ZernMCLauncher { var content = document.getElementById('log-viewer-content'); if (!content) return; if (!raw || !raw.trim()) { + this._logRenderedLines = null; content.innerHTML = '
' + t('logViewer.noLogs') + '
'; return; } var lines = raw.split('\n').filter(function(l) { return l.trim(); }); - var html = lines.map(this._renderLogLine.bind(this)).join(''); - content.innerHTML = html; - content.scrollTop = content.scrollHeight; + var cleaned = lines.map(function(l) { return l.replace(/§[0-9a-fklmnor]/g, ''); }); + + var stick = content.scrollTop + content.clientHeight >= content.scrollHeight - 40; + + var prev = this._logRenderedLines; + var appendFrom = 0; + if (prev && prev.length > 0 && lines.length >= prev.length) { + var same = true; + for (var i = 0; i < prev.length; i++) { + if (cleaned[i] !== prev[i]) { same = false; break; } + } + if (same) appendFrom = prev.length; + } + + this._logRenderedLines = cleaned; + if (appendFrom > 0) { + content.insertAdjacentHTML('beforeend', lines.slice(appendFrom).map(this._renderLogLine.bind(this)).join('')); + } else { + content.innerHTML = lines.map(this._renderLogLine.bind(this)).join(''); + } + if (stick) content.scrollTop = content.scrollHeight; } _renderLogLine(line) { @@ -2524,10 +2551,18 @@ class ZernMCLauncher { return '
' + this.esc(text) + '
'; } - copyLogs() { + async copyLogs() { var content = document.getElementById('log-viewer-content'); if (!content) return; var text = content.textContent; + try { + var r = await this.req('/clipboard', { method: 'POST', body: JSON.stringify({ text: text }) }); + if (r && r.success) { + this.toast(t('logViewer.copied'), 'success'); + return; + } + } catch(e) {} + // Fallback for plain-browser use (outside the launcher) if (navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(text).then(function() { app.toast(t('logViewer.copied'), 'success'); @@ -2547,6 +2582,19 @@ class ZernMCLauncher { } } + async openLogFile() { + try { + var r = await this.req('/open-log-file', { method: 'POST' }); + if (r && r.success) { + this.toast(t('logViewer.opened'), 'success'); + } else { + this.toast((r && r.error) || t('logViewer.openFailed'), 'error'); + } + } catch(e) { + this.toast(t('logViewer.openFailed'), 'error'); + } + } + esc(s) { if (!s) return ''; const d = document.createElement('div'); diff --git a/launcher/launcher/src/resources/ui/style.css b/launcher/launcher/src/resources/ui/style.css index 97c6021..c749faa 100644 --- a/launcher/launcher/src/resources/ui/style.css +++ b/launcher/launcher/src/resources/ui/style.css @@ -29,6 +29,9 @@ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } +html, body, * { -webkit-user-select: none; user-select: none; } +input, textarea { -webkit-user-select: text; user-select: text; } + html { font-size: 14px; } body { @@ -226,15 +229,31 @@ body { } .section-title { font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 1px; color: var(--text-muted); transition: var(--transition); } .section-chevron { flex-shrink: 0; color: var(--text-muted); transition: transform .2s var(--ease, ease); } +.section-collapsible { display: flex; flex-direction: column; min-height: 0; } +.section-collapsible:not(.collapsed) { flex: 1 1 auto; } +.section-collapsible:not(.collapsed) .section-chevron { transform: rotate(0deg); } .section-collapsible.collapsed .section-chevron { transform: rotate(-90deg); } -.section-collapsible.collapsed .pack-list { display: none; } .section-collapsible.collapsed .section-header { margin-bottom: 0; } .section-collapsible.collapsed .section-title { color: var(--text-muted); } +.pack-list-collapse { + display: grid; + grid-template-rows: 0fr; + transition: grid-template-rows .3s var(--ease, ease); + min-height: 0; + overflow: hidden; +} +.section-collapsible:not(.collapsed) .pack-list-collapse { + grid-template-rows: 1fr; + flex: 1; +} +.pack-list-collapse > .pack-list { + overflow-y: auto; + min-height: 0; +} + .pack-list { display: flex; flex-direction: column; gap: 3px; - overflow-y: auto; max-height: calc((100vh - 460px) / 2); - min-height: 40px; } .pack-list:empty::after { content: 'No packs'; display: block; padding: 12px 8px; diff --git a/launcher/pom.xml b/launcher/pom.xml index cd39f99..a5401cc 100644 --- a/launcher/pom.xml +++ b/launcher/pom.xml @@ -20,7 +20,7 @@ 1.0.16 - 18 + 21 21 21 UTF-8