v1.0.16.21 — prefetch Forge/NeoForge libraries through smart proxy; UI polish (no text selection, log viewer copy/open, locale in settings, window icon, adaptive pack list)
This commit is contained in:
+19
-14
@@ -48,7 +48,16 @@ public class ForgeInstaller {
|
|||||||
LauncherLogger.info("Downloading Forge Installer...");
|
LauncherLogger.info("Downloading Forge Installer...");
|
||||||
downloadFileWithProgress(installerUrl, installerJar);
|
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("Running Forge Installer...");
|
||||||
LauncherLogger.info("This may take a few minutes. Please wait...");
|
LauncherLogger.info("This may take a few minutes. Please wait...");
|
||||||
|
|
||||||
@@ -105,6 +114,13 @@ public class ForgeInstaller {
|
|||||||
while (attempt <= maxRetries) {
|
while (attempt <= maxRetries) {
|
||||||
LauncherLogger.info("Attempt " + attempt + " of " + 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(
|
ProcessBuilder pb = new ProcessBuilder(
|
||||||
JavaResolver.getJavaExecutable(),
|
JavaResolver.getJavaExecutable(),
|
||||||
"-jar",
|
"-jar",
|
||||||
@@ -175,19 +191,8 @@ public class ForgeInstaller {
|
|||||||
if (attempt < maxRetries) {
|
if (attempt < maxRetries) {
|
||||||
LauncherLogger.warn("Install error. Retrying in 5 seconds...");
|
LauncherLogger.warn("Install error. Retrying in 5 seconds...");
|
||||||
Thread.sleep(5000);
|
Thread.sleep(5000);
|
||||||
|
// Prefetch at the top of the next attempt already re-seeds any library that
|
||||||
// Clean temp files before retry
|
// was left partial or corrupted (SHA-1 mismatch -> delete + re-download via proxy).
|
||||||
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 */ }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
LauncherLogger.error("Forge Installer exited with error code: " + exitCode);
|
LauncherLogger.error("Forge Installer exited with error code: " + exitCode);
|
||||||
|
|
||||||
|
|||||||
+4
@@ -49,6 +49,10 @@ public class NeoForgeInstaller {
|
|||||||
LauncherLogger.info("Downloading NeoForge Installer...");
|
LauncherLogger.info("Downloading NeoForge Installer...");
|
||||||
downloadFileWithProgress(installerUrl, installerJar);
|
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("Running NeoForge Installer...");
|
||||||
LauncherLogger.info("This may take a few minutes. Please wait...");
|
LauncherLogger.info("This may take a few minutes. Please wait...");
|
||||||
|
|
||||||
|
|||||||
@@ -206,6 +206,77 @@ public class JFXLauncher extends Application {
|
|||||||
launch(args);
|
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() {
|
private static void extractAssets() {
|
||||||
try {
|
try {
|
||||||
Path assetsDir = Paths.get("assets");
|
Path assetsDir = Paths.get("assets");
|
||||||
@@ -445,6 +516,11 @@ public class JFXLauncher extends Application {
|
|||||||
String url = "http://localhost:" + PORT + "/assets/ui/index.html";
|
String url = "http://localhost:" + PORT + "/assets/ui/index.html";
|
||||||
engine.load(url);
|
engine.load(url);
|
||||||
|
|
||||||
|
javafx.scene.image.Image windowIcon = loadWindowIcon();
|
||||||
|
if (windowIcon != null) {
|
||||||
|
stage.getIcons().add(windowIcon);
|
||||||
|
}
|
||||||
|
|
||||||
stage.setTitle(APP_TITLE);
|
stage.setTitle(APP_TITLE);
|
||||||
int winW = Config.getWindowWidth();
|
int winW = Config.getWindowWidth();
|
||||||
int winH = Config.getWindowHeight();
|
int winH = Config.getWindowHeight();
|
||||||
@@ -518,6 +594,7 @@ public class JFXLauncher extends Application {
|
|||||||
server.createContext("/api/exit-parent", this::handleExitParent);
|
server.createContext("/api/exit-parent", this::handleExitParent);
|
||||||
server.createContext("/api/system-info", this::handleSystemInfo);
|
server.createContext("/api/system-info", this::handleSystemInfo);
|
||||||
server.createContext("/api/open-log-file", this::handleOpenLogFile);
|
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/list", this::handleFriendList);
|
||||||
server.createContext("/api/friends/add", this::handleFriendAdd);
|
server.createContext("/api/friends/add", this::handleFriendAdd);
|
||||||
server.createContext("/api/friends/remove", this::handleFriendRemove);
|
server.createContext("/api/friends/remove", this::handleFriendRemove);
|
||||||
@@ -561,21 +638,43 @@ public class JFXLauncher extends Application {
|
|||||||
|
|
||||||
private void handleOpenLogFile(HttpExchange exchange) {
|
private void handleOpenLogFile(HttpExchange exchange) {
|
||||||
try {
|
try {
|
||||||
|
Path target = null;
|
||||||
if (gameLogFile != null && Files.exists(gameLogFile)) {
|
if (gameLogFile != null && Files.exists(gameLogFile)) {
|
||||||
|
target = gameLogFile;
|
||||||
|
} else if (launcherLogFile != null && Files.exists(launcherLogFile)) {
|
||||||
|
target = launcherLogFile;
|
||||||
|
}
|
||||||
|
if (target != null) {
|
||||||
if (Desktop.isDesktopSupported()) {
|
if (Desktop.isDesktopSupported()) {
|
||||||
Desktop.getDesktop().open(gameLogFile.toFile());
|
Desktop.getDesktop().open(target.toFile());
|
||||||
sendJson(exchange, Map.of("success", true));
|
sendJson(exchange, Map.of("success", true, "file", target.toString()));
|
||||||
} else {
|
} else {
|
||||||
sendJson(exchange, Map.of("success", false, "error", "Desktop not supported"));
|
sendJson(exchange, Map.of("success", false, "error", "Desktop not supported"));
|
||||||
}
|
}
|
||||||
} else {
|
} 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) {
|
} catch (Exception e) {
|
||||||
sendJson(exchange, Map.of("success", false, "error", e.getMessage()));
|
sendJson(exchange, Map.of("success", false, "error", e.getMessage()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void handleClipboard(HttpExchange exchange) {
|
||||||
|
try {
|
||||||
|
Map<String, String> 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) {
|
private void handleOpenUrl(HttpExchange exchange) {
|
||||||
try {
|
try {
|
||||||
Map<String, String> body = parseJson(exchange.getRequestBody());
|
Map<String, String> body = parseJson(exchange.getRequestBody());
|
||||||
|
|||||||
@@ -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) {
|
private static String resolveCoordinatesUrl(JSONObject lib) {
|
||||||
String name = lib.getString("name");
|
String name = lib.getString("name");
|
||||||
String base = lib.getString("url");
|
String base = lib.getString("url");
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 103 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.9 KiB |
@@ -151,7 +151,7 @@
|
|||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="server-packs-list" class="pack-list"></div>
|
<div class="pack-list-collapse"><div id="server-packs-list" class="pack-list"></div></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sidebar-section section-collapsible" id="local-packs-section">
|
<div class="sidebar-section section-collapsible" id="local-packs-section">
|
||||||
@@ -164,7 +164,7 @@
|
|||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="local-packs-list" class="pack-list"></div>
|
<div class="pack-list-collapse"><div id="local-packs-list" class="pack-list"></div></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -550,7 +550,7 @@
|
|||||||
<h3 data-i18n="logViewer.title">Game Log</h3>
|
<h3 data-i18n="logViewer.title">Game Log</h3>
|
||||||
<div class="log-viewer-actions">
|
<div class="log-viewer-actions">
|
||||||
<button class="btn-secondary btn-sm" id="copy-log-btn" onclick="app.copyLogs()"><span data-i18n="logViewer.copy">Copy</span></button>
|
<button class="btn-secondary btn-sm" id="copy-log-btn" onclick="app.copyLogs()"><span data-i18n="logViewer.copy">Copy</span></button>
|
||||||
<button class="btn-secondary btn-sm" onclick="app.req('/open-log-file', {method:'POST'})"><span data-i18n="logViewer.openFile">Open File</span></button>
|
<button class="btn-secondary btn-sm" onclick="app.openLogFile()"><span data-i18n="logViewer.openFile">Open File</span></button>
|
||||||
<button class="modal-close" id="close-log-viewer-btn" onclick="app.closeLogViewer()">×</button>
|
<button class="modal-close" id="close-log-viewer-btn" onclick="app.closeLogViewer()">×</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -58,6 +58,8 @@ const LOCALES = {
|
|||||||
'logViewer.noLogs': 'No logs yet. Launch the game to see logs here.',
|
'logViewer.noLogs': 'No logs yet. Launch the game to see logs here.',
|
||||||
'logViewer.copy': 'Copy', 'logViewer.copied': 'Copied!',
|
'logViewer.copy': 'Copy', 'logViewer.copied': 'Copied!',
|
||||||
'logViewer.openFile': 'Open File', 'logViewer.close': 'Close',
|
'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...',
|
'logViewer.waiting': 'Waiting for game output...',
|
||||||
'install.title': 'Install Pack',
|
'install.title': 'Install Pack',
|
||||||
'install.tab.serverPack': 'Server Pack', 'install.tab.custom': 'Custom',
|
'install.tab.serverPack': 'Server Pack', 'install.tab.custom': 'Custom',
|
||||||
@@ -267,6 +269,8 @@ const LOCALES = {
|
|||||||
'logViewer.noLogs': 'Логов пока нет. Запустите игру, чтобы увидеть логи.',
|
'logViewer.noLogs': 'Логов пока нет. Запустите игру, чтобы увидеть логи.',
|
||||||
'logViewer.copy': 'Копировать', 'logViewer.copied': 'Скопировано!',
|
'logViewer.copy': 'Копировать', 'logViewer.copied': 'Скопировано!',
|
||||||
'logViewer.openFile': 'Открыть файл', 'logViewer.close': 'Закрыть',
|
'logViewer.openFile': 'Открыть файл', 'logViewer.close': 'Закрыть',
|
||||||
|
'logViewer.opened': 'Файл открыт',
|
||||||
|
'logViewer.openFailed': 'Не удалось открыть файл логов',
|
||||||
'logViewer.waiting': 'Ожидание вывода игры...',
|
'logViewer.waiting': 'Ожидание вывода игры...',
|
||||||
'install.title': 'Установить сборку',
|
'install.title': 'Установить сборку',
|
||||||
'install.tab.serverPack': 'Серверная сборка', 'install.tab.custom': 'Своя',
|
'install.tab.serverPack': 'Серверная сборка', 'install.tab.custom': 'Своя',
|
||||||
@@ -475,7 +479,10 @@ class ZernMCLauncher {
|
|||||||
_localeData = LOCALES[lang] || LOCALES.en;
|
_localeData = LOCALES[lang] || LOCALES.en;
|
||||||
applyLocale();
|
applyLocale();
|
||||||
var sel = document.getElementById('locale-select');
|
var sel = document.getElementById('locale-select');
|
||||||
if (sel) sel.value = lang;
|
if (sel) {
|
||||||
|
sel.value = lang;
|
||||||
|
if (sel.dataset.enhanced) this.refreshCustomSelect(sel);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== BACKGROUND ====================
|
// ==================== BACKGROUND ====================
|
||||||
@@ -2477,6 +2484,7 @@ class ZernMCLauncher {
|
|||||||
openLogViewer() {
|
openLogViewer() {
|
||||||
var overlay = document.getElementById('log-viewer-overlay');
|
var overlay = document.getElementById('log-viewer-overlay');
|
||||||
if (!overlay) return;
|
if (!overlay) return;
|
||||||
|
this._logRenderedLines = null;
|
||||||
overlay.classList.remove('hidden');
|
overlay.classList.remove('hidden');
|
||||||
this._pollLogViewer();
|
this._pollLogViewer();
|
||||||
this._logPollInterval = setInterval(this._pollLogViewer.bind(this), 1500);
|
this._logPollInterval = setInterval(this._pollLogViewer.bind(this), 1500);
|
||||||
@@ -2504,13 +2512,32 @@ class ZernMCLauncher {
|
|||||||
var content = document.getElementById('log-viewer-content');
|
var content = document.getElementById('log-viewer-content');
|
||||||
if (!content) return;
|
if (!content) return;
|
||||||
if (!raw || !raw.trim()) {
|
if (!raw || !raw.trim()) {
|
||||||
|
this._logRenderedLines = null;
|
||||||
content.innerHTML = '<div class="log-line log-empty">' + t('logViewer.noLogs') + '</div>';
|
content.innerHTML = '<div class="log-line log-empty">' + t('logViewer.noLogs') + '</div>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
var lines = raw.split('\n').filter(function(l) { return l.trim(); });
|
var lines = raw.split('\n').filter(function(l) { return l.trim(); });
|
||||||
var html = lines.map(this._renderLogLine.bind(this)).join('');
|
var cleaned = lines.map(function(l) { return l.replace(/§[0-9a-fklmnor]/g, ''); });
|
||||||
content.innerHTML = html;
|
|
||||||
content.scrollTop = content.scrollHeight;
|
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) {
|
_renderLogLine(line) {
|
||||||
@@ -2524,10 +2551,18 @@ class ZernMCLauncher {
|
|||||||
return '<div class="' + cls + '">' + this.esc(text) + '</div>';
|
return '<div class="' + cls + '">' + this.esc(text) + '</div>';
|
||||||
}
|
}
|
||||||
|
|
||||||
copyLogs() {
|
async copyLogs() {
|
||||||
var content = document.getElementById('log-viewer-content');
|
var content = document.getElementById('log-viewer-content');
|
||||||
if (!content) return;
|
if (!content) return;
|
||||||
var text = content.textContent;
|
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) {
|
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||||
navigator.clipboard.writeText(text).then(function() {
|
navigator.clipboard.writeText(text).then(function() {
|
||||||
app.toast(t('logViewer.copied'), 'success');
|
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) {
|
esc(s) {
|
||||||
if (!s) return '';
|
if (!s) return '';
|
||||||
const d = document.createElement('div');
|
const d = document.createElement('div');
|
||||||
|
|||||||
@@ -29,6 +29,9 @@
|
|||||||
|
|
||||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
*, *::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; }
|
html { font-size: 14px; }
|
||||||
|
|
||||||
body {
|
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-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-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 .section-chevron { transform: rotate(-90deg); }
|
||||||
.section-collapsible.collapsed .pack-list { display: none; }
|
|
||||||
.section-collapsible.collapsed .section-header { margin-bottom: 0; }
|
.section-collapsible.collapsed .section-header { margin-bottom: 0; }
|
||||||
.section-collapsible.collapsed .section-title { color: var(--text-muted); }
|
.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 {
|
.pack-list {
|
||||||
display: flex; flex-direction: column; gap: 3px;
|
display: flex; flex-direction: column; gap: 3px;
|
||||||
overflow-y: auto; max-height: calc((100vh - 460px) / 2);
|
|
||||||
min-height: 40px;
|
|
||||||
}
|
}
|
||||||
.pack-list:empty::after {
|
.pack-list:empty::after {
|
||||||
content: 'No packs'; display: block; padding: 12px 8px;
|
content: 'No packs'; display: block; padding: 12px 8px;
|
||||||
|
|||||||
+1
-1
@@ -20,7 +20,7 @@
|
|||||||
|
|
||||||
<properties>
|
<properties>
|
||||||
<revision>1.0.16</revision>
|
<revision>1.0.16</revision>
|
||||||
<hotfix>18</hotfix>
|
<hotfix>21</hotfix>
|
||||||
<maven.compiler.source>21</maven.compiler.source>
|
<maven.compiler.source>21</maven.compiler.source>
|
||||||
<maven.compiler.target>21</maven.compiler.target>
|
<maven.compiler.target>21</maven.compiler.target>
|
||||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||||
|
|||||||
Reference in New Issue
Block a user