Слияние ui -> main #1

Merged
sasheg merged 145 commits from ui into main 2026-08-20 13:54:02 +00:00
10 changed files with 281 additions and 29 deletions
Showing only changes of commit 566559d639 - Show all commits
@@ -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);
@@ -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...");
@@ -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<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) {
try {
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) {
String name = lib.getString("name");
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>
</button>
</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 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>
</button>
</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>
@@ -550,7 +550,7 @@
<h3 data-i18n="logViewer.title">Game Log</h3>
<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" 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()">&times;</button>
</div>
</div>
+53 -5
View File
@@ -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 = '<div class="log-line log-empty">' + t('logViewer.noLogs') + '</div>';
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 '<div class="' + cls + '">' + this.esc(text) + '</div>';
}
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');
+22 -3
View File
@@ -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;
+1 -1
View File
@@ -20,7 +20,7 @@
<properties>
<revision>1.0.16</revision>
<hotfix>18</hotfix>
<hotfix>21</hotfix>
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>