22 Commits

Author SHA1 Message Date
SashegDev 4acbdedf70 v1.0.14.2 — fix JFX hang (ForkJoinPool), retry logic for connection reset, library logging 2026-07-30 16:09:44 +00:00
SashegDev e962adbb58 v1.0.14.1 — bump hotfix, proxy allows all Mojang CDN domains 2026-07-30 15:37:43 +00:00
SashegDev 22b056614b fix: add missing Mojang CDN domains (piston-data, libraries.minecraft.net, maven repos) to proxy allow list, increase proxy timeout to 300s 2026-07-30 15:36:06 +00:00
SashegDev 6024df5093 fix: inject real version into UI, add timeout to FabricInstaller process 2026-07-30 15:30:32 +00:00
SashegDev 1307d10e81 v1.0.14 — bridge release for older launchers 2026-07-30 15:17:38 +00:00
SashegDev cffa6519ca v1.0.13.1 — pack download stability, proxy for MC/Forge downloads, build.version priority 2026-07-30 14:31:13 +00:00
SashegDev ce8cf32ddd fix: preserve username case in register/login validators 2026-07-30 13:03:48 +00:00
SashegDev 72c58aced3 feat: include hotfix in manifest and build.version (1.0.13.0) 2026-07-30 11:52:40 +00:00
SashegDev ffc491c333 chore: ignore todo.txt 2026-07-30 11:29:48 +00:00
SashegDev 2846e3edd1 feat: auto-detect new ZIP archives in builds/ + fix exe download path 2026-07-30 11:29:45 +00:00
SashegDev 7ae81b3c91 chore: remove accidental todo.txt 2026-07-30 11:28:33 +00:00
SashegDev 375b98586d feat: robust update with .update suffix + bootstrap self-update 2026-07-30 11:28:30 +00:00
SashegDev 348969e79c chore: remove accidental todo.txt 2026-07-30 11:12:11 +00:00
SashegDev 15532bf341 fix: support 4-part Windows version in version comparison 2026-07-30 11:12:08 +00:00
SashegDev 424cf9bc25 chore: bump hotfix to 1 2026-07-30 11:06:49 +00:00
SashegDev ef7d5edef3 feat: add hotfix property for Windows 4-part version 2026-07-30 11:05:10 +00:00
SashegDev d9c527b8db feat: login/register mode toggle with wave background animation 2026-07-30 10:25:26 +00:00
SashegDev f32ac1ef98 bump: version 1.0.11 → 1.0.12 2026-07-30 10:07:35 +00:00
SashegDev 6763d0144a feat: Discord-style login page redesign 2026-07-30 10:05:53 +00:00
SashegDev 37ec2bc342 chore: add .flattened-pom.xml to .gitignore 2026-07-29 06:16:46 +00:00
SashegDev 6a0c59f032 refactor: use ${revision} for CI-friendly version management 2026-07-29 06:16:32 +00:00
SashegDev 599e9d5e67 bump: version 1.0.10 → 1.0.11, fix Forge/NeoForge launch 2026-07-29 06:15:26 +00:00
24 changed files with 1984 additions and 568 deletions
+2 -2
View File
@@ -10,6 +10,7 @@ server/data
jre
.vscode
dependency-reduced-pom.xml
.flattened-pom.xml
OpenJDK21U-jre_x64_windows_hotspot_21.0.6_7.zip
telegram-bot/
builds/
@@ -18,5 +19,4 @@ data/
packs/
.__pycache__
.pytest_cache
.venv
resources
.venvtodo.txt
+1 -1
View File
@@ -7,7 +7,7 @@
<parent>
<groupId>me.sashegdev</groupId>
<artifactId>ZernMCLauncher</artifactId>
<version>1.0.10</version>
<version>${revision}</version>
</parent>
<artifactId>zernmc-bootstrap</artifactId>
@@ -13,11 +13,14 @@ import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import javax.swing.*;
import javax.swing.plaf.basic.BasicProgressBarUI;
import java.awt.*;
import java.awt.event.MouseAdapter;
import java.awt.event.MouseEvent;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
@@ -159,15 +162,38 @@ public class Bootstrap {
ProcessBuilder pb = new ProcessBuilder(cmd);
pb.directory(baseDir.toFile());
pb.inheritIO();
pb.redirectErrorStream(false);
log("Starting process: " + String.join(" ", cmd));
Process p = pb.start();
// Read stderr in background thread for crash reporting
StringBuilder errorOutput = new StringBuilder();
Thread errorReader = new Thread(() -> {
try (BufferedReader reader = new BufferedReader(
new InputStreamReader(p.getErrorStream(), StandardCharsets.UTF_8))) {
String line;
while ((line = reader.readLine()) != null) {
errorOutput.append(line).append("\n");
log("[JFX] " + line);
}
} catch (Exception ignored) {}
});
errorReader.setDaemon(true);
errorReader.start();
int code = p.waitFor();
errorReader.join(2000);
log("JFX process exited with code: " + code);
System.exit(code);
if (code != 0 && !GraphicsEnvironment.isHeadless()) {
String stderr = errorOutput.toString();
SwingUtilities.invokeLater(() -> showCrashDialog(code, stderr));
} else {
System.exit(code);
}
}
private static Path findJava(boolean preferConsole) {
@@ -563,6 +589,203 @@ public class Bootstrap {
}
}
// ====================== CRASH HANDLER ======================
private static String readFileSafe(Path path, int maxLines) {
if (!Files.exists(path)) return "(file not found)";
try {
List<String> lines = Files.readAllLines(path, StandardCharsets.UTF_8);
if (lines.size() <= maxLines) {
return String.join("\n", lines);
}
List<String> tail = lines.subList(lines.size() - maxLines, lines.size());
return "... (" + (lines.size() - maxLines) + " lines omitted)\n" + String.join("\n", tail);
} catch (Exception e) {
return "(error reading: " + e.getMessage() + ")";
}
}
private static Map<String, Object> collectCrashInfo(int exitCode, String stderr) {
Map<String, Object> info = new HashMap<>();
// Version
info.put("launcher_version", readCurrentVersion());
// Exit
info.put("exit_code", exitCode);
info.put("stderr", stderr != null ? stderr : "");
info.put("launcher_log", readFileSafe(logDir.resolve("launcher.log"), 200));
// System
Map<String, Object> system = new HashMap<>();
system.put("os_name", System.getProperty("os.name", "unknown"));
system.put("os_version", System.getProperty("os.version", "unknown"));
system.put("os_arch", System.getProperty("os.arch", "unknown"));
system.put("java_version", System.getProperty("java.version", "unknown"));
system.put("java_vendor", System.getProperty("java.vendor", "unknown"));
system.put("available_processors", Runtime.getRuntime().availableProcessors());
system.put("total_memory_mb", Runtime.getRuntime().maxMemory() / (1024 * 1024));
info.put("system", system);
// User (from auth.json, tokens excluded)
Map<String, Object> user = new HashMap<>();
try {
Path authFile = Path.of(System.getProperty("user.home"), ".zernmc", "auth.json");
if (Files.exists(authFile)) {
String authContent = Files.readString(authFile, StandardCharsets.UTF_8);
JsonObject authJson = JsonParser.parseString(authContent).getAsJsonObject();
if (authJson.has("username")) user.put("username", authJson.get("username").getAsString());
if (authJson.has("uuid")) user.put("uuid", authJson.get("uuid").getAsString());
if (authJson.has("role")) user.put("role", authJson.get("role").getAsInt());
}
} catch (Exception ignored) {}
info.put("user", user);
return info;
}
private static void showCrashDialog(int exitCode, String stderr) {
Map<String, Object> crashInfo = collectCrashInfo(exitCode, stderr);
Gson gson = new GsonBuilder().setPrettyPrinting().create();
String fullReport = gson.toJson(crashInfo);
JFrame frame = new JFrame("ZernMC Launcher - Crash Report");
frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
frame.setSize(700, 520);
frame.setLocationRelativeTo(null);
frame.setResizable(true);
Color bgColor = new Color(0x0c, 0x0c, 0x12);
Color surfaceColor = new Color(0x16, 0x16, 0x1f);
Color accentColor = new Color(0xe9, 0x45, 0x60);
Color textColor = new Color(0xee, 0xee, 0xf0);
Color mutedColor = new Color(0x88, 0x88, 0x9a);
JPanel root = new JPanel(new BorderLayout());
root.setBackground(bgColor);
root.setBorder(BorderFactory.createEmptyBorder(16, 20, 16, 20));
// Title
JLabel titleLabel = new JLabel("ZernMC Launcher Crashed (code: " + exitCode + ")");
titleLabel.setFont(new Font("Segoe UI", Font.BOLD, 16));
titleLabel.setForeground(accentColor);
titleLabel.setBorder(BorderFactory.createEmptyBorder(0, 0, 12, 0));
root.add(titleLabel, BorderLayout.NORTH);
// Log area
JTextArea logArea = new JTextArea(fullReport);
logArea.setEditable(false);
logArea.setFont(new Font("Consolas", Font.PLAIN, 12));
logArea.setBackground(surfaceColor);
logArea.setForeground(textColor);
logArea.setCaretColor(textColor);
logArea.setBorder(BorderFactory.createEmptyBorder(8, 8, 8, 8));
JScrollPane scrollPane = new JScrollPane(logArea);
scrollPane.setBorder(BorderFactory.createLineBorder(new Color(0x2a, 0x2a, 0x3a)));
scrollPane.getViewport().setBackground(surfaceColor);
root.add(scrollPane, BorderLayout.CENTER);
// Buttons
JPanel buttonPanel = new JPanel(new FlowLayout(FlowLayout.RIGHT, 8, 12));
buttonPanel.setOpaque(false);
JLabel statusLabel = new JLabel(" ");
statusLabel.setFont(new Font("Segoe UI", Font.PLAIN, 12));
statusLabel.setForeground(mutedColor);
buttonPanel.add(statusLabel);
JButton openLogsBtn = new JButton("Open Logs Folder");
styleCrashButton(openLogsBtn, mutedColor, bgColor);
openLogsBtn.addActionListener(e -> {
try {
Desktop.getDesktop().open(logDir.toFile());
} catch (Exception ignored) {}
});
buttonPanel.add(openLogsBtn);
JButton sendBtn = new JButton("Send to Server");
styleCrashButton(sendBtn, new Color(0x45, 0xe9, 0x60), bgColor);
sendBtn.addActionListener(e -> {
sendBtn.setEnabled(false);
sendBtn.setText("Sending...");
new Thread(() -> {
boolean ok = sendCrashReport(crashInfo);
SwingUtilities.invokeLater(() -> {
if (ok) {
statusLabel.setForeground(new Color(0x45, 0xe9, 0x60));
statusLabel.setText("Crash report sent. Thank you!");
sendBtn.setText("Sent");
} else {
statusLabel.setForeground(accentColor);
statusLabel.setText("Failed to send. Check logs folder.");
sendBtn.setEnabled(true);
sendBtn.setText("Send to Server");
}
});
}).start();
});
buttonPanel.add(sendBtn);
JButton closeBtn = new JButton("Close");
styleCrashButton(closeBtn, new Color(0x45, 0x60, 0xe9), bgColor);
closeBtn.addActionListener(e -> {
frame.dispose();
System.exit(exitCode);
});
buttonPanel.add(closeBtn);
root.add(buttonPanel, BorderLayout.SOUTH);
frame.setContentPane(root);
frame.setVisible(true);
frame.toFront();
}
private static void styleCrashButton(JButton btn, Color fg, Color bg) {
btn.setFont(new Font("Segoe UI", Font.PLAIN, 13));
btn.setForeground(fg);
btn.setBackground(bg);
btn.setBorderPainted(false);
btn.setFocusPainted(false);
btn.setContentAreaFilled(false);
btn.setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR));
btn.addMouseListener(new java.awt.event.MouseAdapter() {
public void mouseEntered(java.awt.event.MouseEvent e) { btn.setForeground(Color.WHITE); }
public void mouseExited(java.awt.event.MouseEvent e) { btn.setForeground(fg); }
});
}
private static boolean sendCrashReport(Map<String, Object> data) {
try {
Gson gson = new Gson();
String jsonBody = gson.toJson(data);
byte[] bodyBytes = jsonBody.getBytes(StandardCharsets.UTF_8);
URL url = new URL(BASE_URL + "/launcher/crash-report");
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
conn.setRequestMethod("POST");
conn.setRequestProperty("Content-Type", "application/json; charset=utf-8");
conn.setRequestProperty("Accept", "application/json");
conn.setRequestProperty("Content-Length", String.valueOf(bodyBytes.length));
conn.setDoOutput(true);
conn.setConnectTimeout(10000);
conn.setReadTimeout(10000);
try (java.io.OutputStream os = conn.getOutputStream()) {
os.write(bodyBytes);
os.flush();
}
int status = conn.getResponseCode();
conn.disconnect();
return status == 200;
} catch (Exception e) {
log("Crash report send failed: " + e.getMessage());
return false;
}
}
// ====================== SWING UI ======================
private static class BootstrapUI {
+12 -12
View File
@@ -7,7 +7,7 @@
<parent>
<groupId>me.sashegdev</groupId>
<artifactId>ZernMCLauncher</artifactId>
<version>1.0.10</version>
<version>${revision}</version>
</parent>
<artifactId>zernmclauncher</artifactId>
@@ -116,7 +116,7 @@
<transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
<mainClass>me.sashegdev.zernmc.launcher.Main</mainClass>
<manifestEntries>
<Implementation-Version>${project.version}</Implementation-Version>
<Implementation-Version>${project.version}.${hotfix}</Implementation-Version>
<Implementation-Title>ZernMC Launcher</Implementation-Title>
</manifestEntries>
</transformer>
@@ -150,11 +150,11 @@
<minVersion>21</minVersion>
</jre>
<versionInfo>
<fileVersion>${project.version}.0</fileVersion>
<txtFileVersion>${project.version}</txtFileVersion>
<fileVersion>${project.version}.${hotfix}</fileVersion>
<txtFileVersion>${project.version}.${hotfix}</txtFileVersion>
<fileDescription>ZernMC Launcher</fileDescription>
<productVersion>${project.version}.0</productVersion>
<txtProductVersion>${project.version}</txtProductVersion>
<productVersion>${project.version}.${hotfix}</productVersion>
<txtProductVersion>${project.version}.${hotfix}</txtProductVersion>
<productName>ZernMC</productName>
<companyName>ZernMC</companyName>
<internalName>zernmc</internalName>
@@ -181,11 +181,11 @@
<minVersion>21</minVersion>
</jre>
<versionInfo>
<fileVersion>${project.version}.0</fileVersion>
<txtFileVersion>${project.version}</txtFileVersion>
<fileVersion>${project.version}.${hotfix}</fileVersion>
<txtFileVersion>${project.version}.${hotfix}</txtFileVersion>
<fileDescription>ZernMC Launcher CLI</fileDescription>
<productVersion>${project.version}.0</productVersion>
<txtProductVersion>${project.version}</txtProductVersion>
<productVersion>${project.version}.${hotfix}</productVersion>
<txtProductVersion>${project.version}.${hotfix}</txtProductVersion>
<productName>ZernMC CLI</productName>
<companyName>ZernMC</companyName>
<internalName>zernmc-cli</internalName>
@@ -207,7 +207,7 @@
<goals><goal>run</goal></goals>
<configuration>
<target>
<echo file="../../server/builds/build.version">${project.version}</echo>
<echo file="../../server/builds/build.version">${project.version}.${hotfix}</echo>
<!-- Удаляем старую папку lib если есть -->
<delete dir="../../server/builds/lib"/>
@@ -280,7 +280,7 @@ How to use CLI:
</echo>
<!-- Создаём один архив со всем -->
<zip destfile="../../server/builds/ZernMC-win-${project.version}.zip"
<zip destfile="../../server/builds/ZernMC-win-${revision}.${hotfix}.zip"
basedir="../../server/builds"
includes="zernmc.exe,zernmc-cli.exe,bin/**,assets/**,lib/**,README.txt"
excludes="build.version,*.jar"/>
@@ -10,55 +10,162 @@ import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.stream.Stream;
public class Bootstrap {
private static final String VERSION_FILE = "build.version";
private static final String JAR_NAME = "ZernMCLauncher.jar";
private static final String BASE_URL = "https://api.zernmc.ru";
private static Path baseDir;
private static Path logDir;
public static void main(String[] args) throws Exception {
baseDir = Paths.get("").toAbsolutePath();
logDir = baseDir.resolve("logs");
Files.createDirectories(logDir);
log("=== ZernMC Launcher ===");
List<String> argList = Arrays.asList(args);
boolean cliMode = argList.contains("--cli");
boolean jfxMode = !cliMode;
cleanupStaleUpdates();
String currentVersion = readCurrentVersion();
String serverVersion = getServerVersion();
log("Local version: " + currentVersion);
log("Server version: " + serverVersion);
if (isNewer(serverVersion, currentVersion)) {
log("Update available!");
downloadUpdate(serverVersion);
updateJar(serverVersion);
Path ownExe = getOwnExe();
if (ownExe != null) {
log("Self-updating bootstrap...");
selfUpdate(ownExe);
return;
}
Files.writeString(baseDir.resolve(VERSION_FILE), serverVersion);
log("Updated to v" + serverVersion);
} else {
log("Version is up to date");
}
if (jfxMode) {
launchJFX();
} else {
launchCLI();
}
}
private static void log(String msg) {
String entry = "[" + LocalDateTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss")) + "] " + msg;
System.out.println(entry);
try {
Files.writeString(logDir.resolve("launcher.log"), entry + "\n",
StandardOpenOption.CREATE, StandardOpenOption.APPEND);
private static void cleanupStaleUpdates() {
try (Stream<Path> files = Files.list(baseDir)) {
files.filter(p -> p.toString().endsWith(".update"))
.forEach(p -> {
try {
Files.deleteIfExists(p);
log("Cleaned stale: " + p.getFileName());
} catch (Exception ignored) {}
});
} catch (Exception ignored) {}
}
private static void updateJar(String newVersion) throws Exception {
Path jarFile = baseDir.resolve(JAR_NAME);
Path jarUpdate = baseDir.resolve(JAR_NAME + ".update");
if (Files.exists(jarFile)) {
try {
Files.move(jarFile, jarUpdate, StandardCopyOption.REPLACE_EXISTING);
log("Renamed " + JAR_NAME + "" + JAR_NAME + ".update");
} catch (Exception e) {
log("Could not rename " + JAR_NAME + ": " + e.getMessage());
}
}
downloadFile(BASE_URL + "/launcher/download/jar", jarFile);
log("Downloaded new " + JAR_NAME);
try {
Files.deleteIfExists(jarUpdate);
log("Removed " + JAR_NAME + ".update");
} catch (Exception e) {
log("Could not remove " + JAR_NAME + ".update: " + e.getMessage());
}
Files.writeString(baseDir.resolve(VERSION_FILE), newVersion);
}
private static void selfUpdate(Path exePath) throws Exception {
String exeName = exePath.getFileName().toString();
Path exeUpdate = baseDir.resolve(exeName + ".update");
try {
Files.move(exePath, exeUpdate, StandardCopyOption.REPLACE_EXISTING);
log("Renamed " + exeName + "" + exeName + ".update");
} catch (Exception e) {
log("Could not rename " + exeName + ": " + e.getMessage());
return;
}
downloadFile(BASE_URL + "/launcher/download/exe", exePath);
log("Launching new " + exeName + "...");
new ProcessBuilder(exePath.toAbsolutePath().toString())
.directory(baseDir.toFile())
.inheritIO()
.start();
Thread.sleep(500);
log("Exiting old process");
System.exit(0);
}
private static Path getOwnExe() {
try {
String cmd = ProcessHandle.current().info().command().orElse(null);
if (cmd != null) {
Path p = Paths.get(cmd);
String name = p.getFileName().toString().toLowerCase();
if (name.contains("zernmc")) return p;
}
} catch (Exception ignored) {}
for (String name : Arrays.asList("zernmc.exe", "zernmc-cli.exe")) {
Path p = baseDir.resolve(name);
if (Files.exists(p)) return p;
}
return null;
}
private static void downloadFile(String urlStr, Path target) throws Exception {
URL url = new URL(urlStr);
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
conn.setRequestMethod("GET");
if (conn.getResponseCode() == 200) {
try (InputStream in = conn.getInputStream();
OutputStream out = new FileOutputStream(target.toFile())) {
byte[] buf = new byte[8192];
int len;
long total = 0;
while ((len = in.read(buf)) > 0) {
out.write(buf, 0, len);
total += len;
System.out.print("\rDownloaded: " + (total / 1024 / 1024) + " MB");
}
}
System.out.println();
} else {
throw new IOException("Server returned code: " + conn.getResponseCode());
}
}
private static String readCurrentVersion() {
Path f = baseDir.resolve(VERSION_FILE);
try {
@@ -66,7 +173,7 @@ public class Bootstrap {
} catch (Exception ignored) {}
return "0.0.0";
}
private static String getServerVersion() {
try {
URL url = new URL(BASE_URL + "/launcher/version");
@@ -83,65 +190,40 @@ public class Bootstrap {
} catch (Exception ignored) {}
return "unknown";
}
private static boolean isNewer(String server, String current) {
try {
String[] sa = server.split("\\.");
String[] ca = current.split("\\.");
for (int i = 0; i < Math.min(sa.length, ca.length); i++) {
int sv = Integer.parseInt(sa[i]);
int cv = Integer.parseInt(ca[i]);
int max = Math.max(sa.length, ca.length);
for (int i = 0; i < max; i++) {
int sv = i < sa.length ? Integer.parseInt(sa[i]) : 0;
int cv = i < ca.length ? Integer.parseInt(ca[i]) : 0;
if (sv > cv) return true;
if (sv < cv) return false;
}
return sa.length > ca.length;
return false;
} catch (Exception ignored) {}
return false;
}
private static void downloadUpdate(String newVersion) throws Exception {
URL url = new URL(BASE_URL + "/launcher/download/jar");
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
conn.setRequestMethod("GET");
if (conn.getResponseCode() == 200) {
Path jarFile = baseDir.resolve(JAR_NAME);
Path tmp = jarFile.resolveSibling("zernmc-launcher-new.jar");
try (InputStream in = conn.getInputStream();
OutputStream out = new FileOutputStream(tmp.toFile())) {
byte[] buf = new byte[8192];
int len;
long total = 0;
while ((len = in.read(buf)) > 0) {
out.write(buf, 0, len);
total += len;
System.out.print("\rDownloaded: " + (total/1024/1024) + " MB");
}
}
log("Downloaded");
Path backup = jarFile.resolveSibling(JAR_NAME + ".old");
if (Files.exists(jarFile)) Files.move(jarFile, backup, StandardCopyOption.REPLACE_EXISTING);
Files.move(tmp, jarFile, StandardCopyOption.REPLACE_EXISTING);
if (Files.exists(backup)) Files.delete(backup);
Files.writeString(baseDir.resolve(VERSION_FILE), newVersion);
log("Updated to v" + newVersion);
} else {
throw new IOException("Server returned code: " + conn.getResponseCode());
}
private static void log(String msg) {
String entry = "[" + LocalDateTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss")) + "] " + msg;
System.out.println(entry);
try {
Files.writeString(logDir.resolve("launcher.log"), entry + "\n",
StandardOpenOption.CREATE, StandardOpenOption.APPEND);
} catch (Exception ignored) {}
}
private static void launchJFX() throws Exception {
Path javaBin = findJava();
Path jarPath = baseDir.resolve(JAR_NAME);
log("Starting JFX mode...");
log("Java: " + javaBin);
log("JAR: " + jarPath);
List<String> cmd = new ArrayList<>();
cmd.add(javaBin.toAbsolutePath().toString());
cmd.add("-Dfile.encoding=UTF-8");
@@ -150,17 +232,17 @@ public class Bootstrap {
cmd.add("-jar");
cmd.add(jarPath.toAbsolutePath().toString());
cmd.add("--jfx");
ProcessBuilder pb = new ProcessBuilder(cmd);
pb.directory(baseDir.toFile());
if (System.getProperty("os.name").toLowerCase().contains("windows")) {
pb.environment().put("JAVA_TOOL_OPTIONS", "-Dfile.encoding=UTF-8");
}
pb.redirectErrorStream(true);
Process p = pb.start();
Thread outputThread = new Thread(() -> {
try (BufferedReader reader = new BufferedReader(
new InputStreamReader(p.getInputStream(), StandardCharsets.UTF_8))) {
@@ -171,21 +253,21 @@ public class Bootstrap {
} catch (Exception ignored) {}
});
outputThread.start();
int code = p.waitFor();
try { outputThread.interrupt(); } catch (Exception ignored) {}
log("Exited with code: " + code);
System.exit(code);
}
private static void launchCLI() throws Exception {
Path javaBin = findJava();
Path jarPath = baseDir.resolve(JAR_NAME);
log("Starting CLI mode...");
log("Java: " + javaBin);
log("JAR: " + jarPath);
List<String> cmd = new ArrayList<>();
cmd.add(javaBin.toAbsolutePath().toString());
cmd.add("-Dfile.encoding=UTF-8");
@@ -194,17 +276,17 @@ public class Bootstrap {
cmd.add("-jar");
cmd.add(jarPath.toAbsolutePath().toString());
cmd.add("--cli");
ProcessBuilder pb = new ProcessBuilder(cmd);
pb.directory(baseDir.toFile());
if (System.getProperty("os.name").toLowerCase().contains("windows")) {
pb.environment().put("JAVA_TOOL_OPTIONS", "-Dfile.encoding=UTF-8");
}
pb.redirectErrorStream(true);
Process p = pb.start();
Thread outputThread = new Thread(() -> {
try (BufferedReader reader = new BufferedReader(
new InputStreamReader(p.getInputStream(), StandardCharsets.UTF_8))) {
@@ -215,23 +297,23 @@ public class Bootstrap {
} catch (Exception ignored) {}
});
outputThread.start();
int code = p.waitFor();
try { outputThread.interrupt(); } catch (Exception ignored) {}
log("Exited with code: " + code);
System.exit(code);
}
private static Path findJava() {
String os = System.getProperty("os.name").toLowerCase();
String javaExe = os.contains("windows") ? "java.exe" : "java";
Path javaBin = baseDir.resolve("jre21").resolve("bin").resolve(javaExe);
if (!Files.exists(javaBin)) {
javaBin = Paths.get(System.getProperty("java.home"), "bin", javaExe);
}
if (!Files.exists(javaBin)) {
try {
Process p = new ProcessBuilder("which", javaExe).start();
@@ -245,11 +327,11 @@ public class Bootstrap {
}
} catch (Exception ignored) {}
}
if (!Files.exists(javaBin)) {
throw new RuntimeException("Java not found. Make sure jre21 is present in the launcher folder or Java is installed on the system");
}
return javaBin;
}
}
@@ -144,18 +144,15 @@ public class LauncherAPI {
private static int compareVersions(String a, String b) {
String[] partsA = a.split("\\.");
String[] partsB = b.split("\\.");
int len = Math.min(partsA.length, partsB.length);
for (int i = 0; i < len; i++) {
int max = Math.max(partsA.length, partsB.length);
for (int i = 0; i < max; i++) {
try {
int numA = Integer.parseInt(partsA[i]);
int numB = Integer.parseInt(partsB[i]);
int numA = i < partsA.length ? Integer.parseInt(partsA[i]) : 0;
int numB = i < partsB.length ? Integer.parseInt(partsB[i]) : 0;
if (numA != numB) return Integer.compare(numB, numA);
} catch (NumberFormatException e) {
int cmp = partsA[i].compareTo(partsB[i]);
if (cmp != 0) return cmp;
}
} catch (NumberFormatException ignored) {}
}
return Integer.compare(partsB.length, partsA.length);
return 0;
}
private boolean isNeoForgeCompatible(String version, String mcVersion) {
@@ -1,8 +1,7 @@
package me.sashegdev.zernmc.launcher.minecraft;
import me.sashegdev.zernmc.launcher.minecraft.installer.FabricInstaller;
import me.sashegdev.zernmc.launcher.minecraft.installer.ForgeInstaller;
import me.sashegdev.zernmc.launcher.minecraft.installer.NeoForgeInstaller;
import me.sashegdev.zernmc.launcher.minecraft.installer.ModLoaderInstaller;
import me.sashegdev.zernmc.launcher.minecraft.installer.VersionInstaller;
import me.sashegdev.zernmc.launcher.minecraft.launch.LaunchCommandBuilder;
import me.sashegdev.zernmc.launcher.minecraft.model.LaunchOptions;
@@ -45,13 +44,13 @@ public class MinecraftLib {
}
public boolean installForge(String minecraftVersion, String forgeVersion) throws Exception {
ForgeInstaller installer = new ForgeInstaller(instance);
return installer.install(minecraftVersion, forgeVersion);
return new ModLoaderInstaller(instance)
.install(minecraftVersion, forgeVersion, ModLoaderInstaller.LoaderType.FORGE);
}
public boolean installNeoForge(String minecraftVersion, String neoforgeVersion) throws Exception {
NeoForgeInstaller installer = new NeoForgeInstaller(instance);
return installer.install(minecraftVersion, neoforgeVersion);
return new ModLoaderInstaller(instance)
.install(minecraftVersion, neoforgeVersion, ModLoaderInstaller.LoaderType.NEOFORGE);
}
public boolean installFabric(String minecraftVersion, String loaderVersion) throws Exception {
@@ -123,6 +122,15 @@ public class MinecraftLib {
ProcessBuilder pb = new ProcessBuilder(command);
pb.directory(instance.getPath().toFile());
// Write launch command to file for debugging
Path cmdLogFile = instance.getPath().resolve("launch-command.log");
try {
Files.writeString(cmdLogFile, String.join(" \\\n ", command));
System.out.println(ZAnsi.green(" Launch command written to " + cmdLogFile.toAbsolutePath()));
} catch (Exception e) {
System.out.println(ZAnsi.yellow(" Failed to write launch command log: " + e.getMessage()));
}
System.out.println(ZAnsi.brightGreen("\nStarting Minecraft...\n"));
ConsoleUtils.clearScreen();
@@ -177,8 +177,10 @@ public class PackDownloader {
if (needsMinecraftInstall) {
LauncherLogger.info("installOrUpdatePack: needs Minecraft install. loader=" + manifest.getLoaderType() + " loaderVer=" + manifest.getLoaderVersion());
reportProgress("Installing " + manifest.getMinecraftVersion() + " with " + manifest.getLoaderType() + "...", 10, "Installing pack files", 4, 5);
if ("fabric".equalsIgnoreCase(manifest.getLoaderType())) {
LauncherLogger.info("installOrUpdatePack: installing Fabric mc=" + manifest.getMinecraftVersion() + " loader=" + manifest.getLoaderVersion());
reportProgress("Installing Fabric " + manifest.getLoaderVersion() + "...", 15, "Installing pack files", 4, 5);
boolean success = lib.installFabric(manifest.getMinecraftVersion(), manifest.getLoaderVersion());
LauncherLogger.info("installOrUpdatePack: Fabric install result=" + success);
if (!success) {
@@ -189,6 +191,7 @@ public class PackDownloader {
}
} else if ("neoforge".equalsIgnoreCase(manifest.getLoaderType())) {
LauncherLogger.info("installOrUpdatePack: installing NeoForge mc=" + manifest.getMinecraftVersion() + " loader=" + manifest.getLoaderVersion());
reportProgress("Installing NeoForge " + manifest.getLoaderVersion() + "...", 15, "Installing pack files", 4, 5);
boolean success = lib.installNeoForge(manifest.getMinecraftVersion(), manifest.getLoaderVersion());
LauncherLogger.info("installOrUpdatePack: NeoForge install result=" + success);
if (!success) {
@@ -199,6 +202,7 @@ public class PackDownloader {
}
} else if ("forge".equalsIgnoreCase(manifest.getLoaderType())) {
LauncherLogger.info("installOrUpdatePack: installing Forge mc=" + manifest.getMinecraftVersion() + " loader=" + manifest.getLoaderVersion());
reportProgress("Installing Forge " + manifest.getLoaderVersion() + "...", 15, "Installing pack files", 4, 5);
boolean success = lib.installForge(manifest.getMinecraftVersion(), manifest.getLoaderVersion());
LauncherLogger.info("installOrUpdatePack: Forge install result=" + success);
if (!success) {
@@ -209,6 +213,7 @@ public class PackDownloader {
}
} else {
LauncherLogger.info("installOrUpdatePack: installing Vanilla Minecraft " + manifest.getMinecraftVersion());
reportProgress("Installing Vanilla Minecraft...", 15, "Installing pack files", 4, 5);
boolean success = lib.installMinecraft(manifest.getMinecraftVersion());
LauncherLogger.info("installOrUpdatePack: Vanilla install result=" + success);
if (!success) {
@@ -6,20 +6,13 @@ import me.sashegdev.zernmc.launcher.utils.ZAnsi;
import me.sashegdev.zernmc.launcher.utils.ZHttpClient;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.util.concurrent.TimeUnit;
public class FabricInstaller {
private final Instance instance;
private final HttpClient httpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(15))
.build();
public FabricInstaller(Instance instance) {
this.instance = instance;
@@ -68,7 +61,13 @@ public class FabricInstaller {
pb.redirectError(ProcessBuilder.Redirect.INHERIT);
Process process = pb.start();
int exitCode = process.waitFor();
boolean finished = process.waitFor(10, TimeUnit.MINUTES);
if (!finished) {
process.destroyForcibly();
System.out.println(ZAnsi.brightRed("Fabric Installer timed out after 10 minutes"));
return false;
}
int exitCode = process.exitValue();
if (exitCode != 0) {
System.out.println(ZAnsi.brightRed("Fabric Installer failed (code " + exitCode + ")"));
@@ -166,48 +165,11 @@ public class FabricInstaller {
}
}
// under refactor - keep
private String downloadString(String url) throws Exception {
Exception lastException = null;
for (int attempt = 1; attempt <= 3; attempt++) {
try {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(url))
.timeout(Duration.ofSeconds(30 * attempt))
.header("User-Agent", "ZernMC-Launcher/1.0")
.GET()
.build();
HttpResponse<String> resp = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
if (resp.statusCode() == 200) {
return resp.body();
}
throw new IOException("HTTP " + resp.statusCode());
} catch (Exception e) {
lastException = e;
System.out.println(ZAnsi.yellow("Attempt " + attempt + " failed: " + e.getMessage()));
if (attempt < 3) {
Thread.sleep(1000 * attempt);
}
}
}
throw lastException;
return ZHttpClient.getWithSmartProxy(url);
}
private void downloadFile(String url, Path target) throws Exception {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(url))
.timeout(Duration.ofSeconds(60))
.GET()
.build();
HttpResponse<Path> response = httpClient.send(request,
HttpResponse.BodyHandlers.ofFile(target));
if (response.statusCode() != 200) {
throw new IOException("HTTP " + response.statusCode() + " when downloading " + url);
}
ZHttpClient.downloadFileWithSmartProxy(url, target);
}
}
@@ -0,0 +1,262 @@
package me.sashegdev.zernmc.launcher.minecraft.installer;
import me.sashegdev.zernmc.launcher.minecraft.Instance;
import me.sashegdev.zernmc.launcher.utils.ZAnsi;
import me.sashegdev.zernmc.launcher.utils.ZHttpClient;
import java.io.*;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.util.*;
import java.util.concurrent.TimeUnit;
public class ModLoaderInstaller {
public enum LoaderType {
FORGE("forge", "net.minecraftforge", "forge", "https://maven.minecraftforge.net"),
NEOFORGE("neoforge", "net.neoforged", null, "https://maven.neoforged.net/releases");
final String loaderName;
final String mavenGroup;
final String fixedArtifact;
final String mavenBase;
LoaderType(String loaderName, String mavenGroup, String fixedArtifact, String mavenBase) {
this.loaderName = loaderName;
this.mavenGroup = mavenGroup;
this.fixedArtifact = fixedArtifact;
this.mavenBase = mavenBase;
}
public String artifact(String mcVersion) {
if (this == NEOFORGE && !"1.20.1".equals(mcVersion)) return "neoforge";
return "forge";
}
}
private final Instance instance;
public ModLoaderInstaller(Instance instance) {
this.instance = instance;
}
public boolean install(String mcVersion, String loaderVersion, LoaderType type) throws Exception {
System.out.println(ZAnsi.cyan("Installing " + type.loaderName + " " + loaderVersion + " for Minecraft " + mcVersion));
System.out.println(ZAnsi.cyan("Installing base Minecraft version " + mcVersion + "..."));
VersionInstaller vanillaInstaller = new VersionInstaller(instance.getPath());
String assetIndex = vanillaInstaller.install(mcVersion);
if (assetIndex == null || assetIndex.isEmpty()) {
System.out.println(ZAnsi.brightRed("Failed to install base Minecraft version"));
return false;
}
instance.setAssetIndex(assetIndex);
createLauncherProfile();
String installerUrl = buildInstallerUrl(mcVersion, loaderVersion, type);
String jarName = type.loaderName + "-installer.jar";
Path installerJar = instance.getPath().resolve(jarName);
System.out.println(ZAnsi.cyan("Downloading " + type.loaderName + " Installer..."));
ZHttpClient.downloadFileWithSmartProxy(installerUrl, installerJar);
System.out.println(ZAnsi.cyan("Running " + type.loaderName + " Installer..."));
System.out.println(ZAnsi.yellow("This may take a few minutes. Please wait...\n"));
boolean success = runInstaller(installerJar, type);
if (success) {
try {
downloadMissingLibraries(type);
} catch (Exception e) {
System.out.println(ZAnsi.yellow("Warning: could not download some libraries: " + e.getMessage()));
}
System.out.println(ZAnsi.brightGreen("\n" + type.loaderName + " " + loaderVersion + " installed successfully!"));
instance.setMinecraftVersion(mcVersion);
instance.setLoaderType(type.loaderName);
instance.setLoaderVersion(loaderVersion);
Files.deleteIfExists(installerJar);
return true;
} else {
System.out.println(ZAnsi.brightRed("\nError installing " + type.loaderName + "!"));
return false;
}
}
private String buildInstallerUrl(String mcVersion, String loaderVersion, LoaderType type) {
if (type == LoaderType.FORGE) {
return type.mavenBase + "/" + type.mavenGroup.replace('.', '/')
+ "/" + type.fixedArtifact + "/" + mcVersion + "-" + loaderVersion
+ "/" + type.fixedArtifact + "-" + mcVersion + "-" + loaderVersion + "-installer.jar";
}
String artifact = type.artifact(mcVersion);
return type.mavenBase + "/" + type.mavenGroup.replace('.', '/')
+ "/" + artifact + "/" + loaderVersion
+ "/" + artifact + "-" + loaderVersion + "-installer.jar";
}
private void createLauncherProfile() throws IOException {
Path profilePath = instance.getPath().resolve("launcher_profiles.json");
if (Files.exists(profilePath)) return;
String minimalProfile = """
{
"profiles": {},
"selectedProfile": "Default"
}
""";
Files.writeString(profilePath, minimalProfile, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING);
System.out.println(ZAnsi.yellow("Created launcher_profiles.json"));
}
private boolean runInstaller(Path installerJar, LoaderType type) throws IOException, InterruptedException {
int maxRetries = 3;
int attempt = 1;
while (attempt <= maxRetries) {
System.out.println(ZAnsi.cyan("Attempt " + attempt + " of " + maxRetries));
ProcessBuilder pb = new ProcessBuilder(
"java",
"-jar",
installerJar.toAbsolutePath().toString(),
"--installClient",
instance.getPath().toAbsolutePath().toString()
);
pb.environment().put("JAVA_OPTS", "-Dhttp.connectionTimeout=60000 -Dhttp.socketTimeout=60000");
pb.directory(instance.getPath().toFile());
pb.redirectErrorStream(true);
Process process = pb.start();
StringBuilder output = new StringBuilder();
boolean hasErrors = false;
try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) {
String line;
while ((line = reader.readLine()) != null) {
output.append(line).append("\n");
if (line.contains("Downloading") || line.contains("Extracting")) {
System.out.println(ZAnsi.blue(" -> " + line));
} else if (line.contains("SUCCESS") || line.contains("successfully")) {
System.out.println(ZAnsi.brightGreen(" + " + line));
} else if (line.contains("WARNING") || line.contains("warning")) {
System.out.println(ZAnsi.yellow(" ! " + line));
} else if (line.contains("ERROR") || line.contains("error") || line.contains("failed") || line.contains("timed out")) {
System.out.println(ZAnsi.brightRed(" X " + line));
if (line.contains("timed out") || line.contains("failed to download")) {
hasErrors = true;
}
} else if (!line.isBlank()) {
System.out.println(" " + line);
}
}
}
int exitCode;
if (hasErrors) {
process.destroyForcibly();
exitCode = 1;
} else {
if (!process.waitFor(10, TimeUnit.MINUTES)) {
process.destroyForcibly();
System.out.println(ZAnsi.brightRed(type.loaderName + " Installer timed out after 10 minutes"));
if (attempt < maxRetries) {
attempt++;
Thread.sleep(5000);
continue;
}
return false;
}
exitCode = process.exitValue();
}
if (exitCode == 0) {
return true;
}
if (attempt < maxRetries) {
System.out.println(ZAnsi.yellow("Install error. Retrying in 5 seconds..."));
Thread.sleep(5000);
Path librariesDir = instance.getPath().resolve("libraries");
if (Files.exists(librariesDir)) {
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 {
System.out.println(ZAnsi.brightRed(type.loaderName + " Installer exited with error code: " + exitCode));
if (output.toString().contains("timed out")) {
System.out.println(ZAnsi.yellow("\nPossible solutions:"));
System.out.println(ZAnsi.yellow("1. Check your internet connection"));
System.out.println(ZAnsi.yellow("2. Run the launcher as administrator"));
System.out.println(ZAnsi.yellow("3. Temporarily disable antivirus/firewall"));
System.out.println(ZAnsi.yellow("4. Try installing a different version"));
}
}
attempt++;
}
return false;
}
private void downloadMissingLibraries(LoaderType type) throws Exception {
System.out.println(ZAnsi.cyan("Checking and downloading missing libraries..."));
Map<String, List<String>> alternativeUrls = new LinkedHashMap<>();
alternativeUrls.put("org/ow2/asm/asm/9.6/asm-9.6.jar", List.of(
"https://repo1.maven.org/maven2/org/ow2/asm/asm/9.6/asm-9.6.jar",
"https://mirrors.huaweicloud.com/repository/maven/org/ow2/asm/asm/9.6/asm-9.6.jar"
));
if (type == LoaderType.NEOFORGE) {
alternativeUrls.put("org/ow2/asm/asm-commons/9.6/asm-commons-9.6.jar", List.of(
"https://repo1.maven.org/maven2/org/ow2/asm/asm-commons/9.6/asm-commons-9.6.jar"
));
alternativeUrls.put("org/ow2/asm/asm-tree/9.6/asm-tree-9.6.jar", List.of(
"https://repo1.maven.org/maven2/org/ow2/asm/asm-tree/9.6/asm-tree-9.6.jar"
));
}
Path librariesDir = instance.getPath().resolve("libraries");
for (Map.Entry<String, List<String>> entry : alternativeUrls.entrySet()) {
Path target = librariesDir.resolve(entry.getKey());
if (!Files.exists(target)) {
Files.createDirectories(target.getParent());
System.out.println(ZAnsi.yellow("Downloading: " + target.getFileName()));
boolean downloaded = false;
for (String mirrorUrl : entry.getValue()) {
for (int attempt = 1; attempt <= 3; attempt++) {
try {
ZHttpClient.downloadFileWithSmartProxy(mirrorUrl, target);
downloaded = true;
break;
} catch (Exception e) {
if (attempt == 3 && mirrorUrl.equals(entry.getValue().get(entry.getValue().size() - 1))) throw e;
System.out.println(ZAnsi.yellow("Retry " + attempt + "/3..."));
Thread.sleep(2000);
}
}
if (downloaded) break;
}
}
}
}
}
@@ -3,43 +3,38 @@ package me.sashegdev.zernmc.launcher.minecraft.installer;
import me.sashegdev.zernmc.launcher.minecraft.model.MinecraftVersion;
import me.sashegdev.zernmc.launcher.utils.ProgressBar;
import me.sashegdev.zernmc.launcher.utils.ZAnsi;
import me.sashegdev.zernmc.launcher.utils.ZHttpClient;
import org.json.JSONArray;
import org.json.JSONObject;
import java.io.IOException;
import java.io.InputStream;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CancellationException;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.jar.JarEntry;
import java.util.jar.JarFile;
public class VersionInstaller {
private final Path minecraftDir;
private final HttpClient httpClient;
private final ExecutorService executor = Executors.newFixedThreadPool(32);
public VersionInstaller(Path minecraftDir) {
this.minecraftDir = minecraftDir;
this.httpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(15))
.build();
}
public List<MinecraftVersion> getAvailableVersions() throws Exception {
String jsonString = downloadString("https://piston-meta.mojang.com/mc/game/version_manifest_v2.json");
String jsonString = ZHttpClient.getWithSmartProxy("https://piston-meta.mojang.com/mc/game/version_manifest_v2.json");
JSONObject root = new JSONObject(jsonString);
JSONArray versionsArray = root.getJSONArray("versions");
@@ -70,16 +65,28 @@ public class VersionInstaller {
if (versionUrl == null) throw new Exception("Version " + versionId + " not found");
ProgressBar.show("Fetching version info", 0, 1, "files");
String versionJson = downloadString(versionUrl);
Files.writeString(versionDir.resolve(versionId + ".json"), versionJson);
String versionJson;
try {
versionJson = ZHttpClient.getWithSmartProxy(versionUrl);
Files.writeString(versionDir.resolve(versionId + ".json"), versionJson);
} catch (Exception e) {
System.err.println(ZAnsi.red("[VERSION] Failed to fetch version info: " + e.getMessage()));
throw e;
}
ProgressBar.show("Version info", 1, 1, "files");
JSONObject versionData = new JSONObject(versionJson);
// client.jar
ProgressBar.show("Downloading client.jar", 0, 1, "files");
downloadFile(versionData.getJSONObject("downloads").getJSONObject("client").getString("url"),
versionDir.resolve(versionId + ".jar"), "client.jar");
try {
ZHttpClient.downloadFileWithSmartProxy(
versionData.getJSONObject("downloads").getJSONObject("client").getString("url"),
versionDir.resolve(versionId + ".jar"));
} catch (Exception e) {
System.err.println(ZAnsi.red("[VERSION] Failed to download client.jar: " + e.getMessage()));
throw e;
}
ProgressBar.show("Client.jar", 1, 1, "files");
// Libraries
@@ -147,7 +154,7 @@ public class VersionInstaller {
if (!Files.exists(libJar)) {
try {
Files.createDirectories(libJar.getParent());
downloadFile(url, libJar, "");
ZHttpClient.downloadFileWithSmartProxy(url, libJar);
} catch (Exception e) {
failed++;
continue;
@@ -199,9 +206,9 @@ public class VersionInstaller {
Files.createDirectories(target.getParent());
try {
downloadFile(url, target, "library");
ZHttpClient.downloadFileWithSmartProxy(url, target);
} catch (Exception e) {
// Skip problematic libraries
System.err.println(ZAnsi.yellow("[LIB] Failed to download " + path + ": " + e.getMessage()));
}
}
count++;
@@ -220,7 +227,7 @@ public class VersionInstaller {
Path indexPath = indexesDir.resolve(assetIndex + ".json");
System.out.println(ZAnsi.cyan("Downloading asset index (" + assetIndex + ")..."));
downloadFile(indexUrl, indexPath, "asset index");
ZHttpClient.downloadFileWithSmartProxy(indexUrl, indexPath);
String jsonContent = Files.readString(indexPath);
JSONObject root = new JSONObject(jsonContent);
@@ -249,20 +256,20 @@ public class VersionInstaller {
boolean downloaded = false;
for (int attempt = 1; attempt <= 3; attempt++) {
try {
downloadFile(url, target, "");
ZHttpClient.downloadFileWithSmartProxy(url, target);
synchronized (this) {
success[0]++;
ProgressBar.show("Assets", success[0], total, "files");
}
downloaded = true;
break;
} catch (Exception e) {
if (attempt == 3) {
synchronized (this) {
success[0]++;
ProgressBar.show("Assets", success[0], total, "files");
failed[0]++;
}
downloaded = true;
break;
} catch (Exception e) {
if (attempt == 3) {
synchronized (this) {
failed[0]++;
}
System.err.println("Failed to download " + hash);
} else {
System.err.println("Failed to download " + hash);
} else {
try { Thread.sleep(500 * attempt); } catch (InterruptedException ignored) {}
}
}
@@ -272,8 +279,17 @@ public class VersionInstaller {
futures.add(future);
}
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
executor.shutdown();
try {
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0]))
.get(10, TimeUnit.MINUTES);
} catch (TimeoutException e) {
futures.forEach(f -> f.cancel(true));
System.err.println("Asset download timed out after 10 minutes");
} catch (CancellationException e) {
// one of the futures was cancelled
} catch (CompletionException e) {
// one of the futures failed exceptionally
}
ProgressBar.finish("Assets downloaded (" + success[0] + " ok, " + failed[0] + " skipped)");
@@ -287,13 +303,13 @@ public class VersionInstaller {
String versionUrl = getVersionUrl(versionId);
if (versionUrl == null) throw new Exception("Version not found");
String versionJson = downloadString(versionUrl);
String versionJson = ZHttpClient.getWithSmartProxy(versionUrl);
JSONObject versionData = new JSONObject(versionJson);
if (versionData.has("assetIndex") && versionData.getJSONObject("assetIndex").has("id")) {
return versionData.getJSONObject("assetIndex").getString("id"); // "5" для 1.20.1
return versionData.getJSONObject("assetIndex").getString("id");
}
return versionData.getString("assets"); // fallback (very old versions)
return versionData.getString("assets");
}
private String getVersionUrl(String versionId) throws Exception {
@@ -302,35 +318,4 @@ public class VersionInstaller {
}
return null;
}
private String downloadString(String url) throws Exception {
HttpRequest req = HttpRequest.newBuilder().uri(URI.create(url)).GET().build();
HttpResponse<String> resp = httpClient.send(req, HttpResponse.BodyHandlers.ofString());
if (resp.statusCode() != 200) throw new IOException("HTTP " + resp.statusCode());
return resp.body();
}
private void downloadFile(String url, Path target, String label) throws Exception {
if (!label.isEmpty()) {
ProgressBar.clearLine();
System.out.println(ZAnsi.cyan("Downloading " + label + "..."));
}
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(url))
.GET()
.build();
HttpResponse<Path> response = httpClient.send(request, HttpResponse.BodyHandlers.ofFile(target));
if (response.statusCode() != 200) {
if (label.isEmpty()) return; // for assets silently
throw new IOException("HTTP " + response.statusCode() + " while downloading " + label);
}
if (!label.isEmpty()) {
long size = Files.size(target);
ProgressBar.finish(label + " (" + ProgressBar.formatBytes(size) + ")");
}
}
}
}
@@ -85,23 +85,14 @@ public class LaunchCommandBuilder {
String cpFile = writeClasspathFile(classpath);
// DEBUG: print classpath entries to identify split-package sources
String sep = System.getProperty("os.name").toLowerCase().contains("win") ? ";" : ":";
System.out.println(ZAnsi.cyan(" === CLASSPATH ENTRIES (" + classpath.split(sep.replace("\\","\\\\")).length + " jars) ==="));
for (String entry : classpath.split(sep.replace("\\","\\\\"))) {
String fileName = entry.contains("/") ? entry.substring(entry.lastIndexOf('/') + 1) : entry.substring(entry.lastIndexOf('\\') + 1);
System.out.println(ZAnsi.cyan(" CP: " + fileName));
}
System.out.println(ZAnsi.cyan(" === END CLASSPATH ==="));
// Build variable map for placeholder substitution
Map<String, String> vars = buildVariableMap(options);
vars.put("classpath", cpFile);
// Parse ALL version.json JVM args (merged with parent) with placeholder
// substitution. This includes -Djava.library.path, -cp, --add-modules,
// --add-opens, etc. — everything the launcher needs.
List<String> allJvmArgs = manifest != null ? manifest.getAllJvmArguments() : new ArrayList<>();
// Parse version.json JVM args (child only). Forge/NeoForge defines its own
// -p, --add-modules, --add-opens. -Djava.library.path is NOT in child args,
// so it's added manually below.
List<String> allJvmArgs = manifest != null ? manifest.getJvmArguments() : new ArrayList<>();
if (!allJvmArgs.isEmpty()) {
for (String arg : allJvmArgs) {
String resolved = resolveVariable(arg, vars);
@@ -113,11 +104,11 @@ public class LaunchCommandBuilder {
} else {
System.out.println(ZAnsi.yellow(" WARNING: No JVM args in version.json, using manual fallback"));
command.addAll(getJvmArguments(options));
command.add("-Djava.library.path=" + nativesDir.toAbsolutePath());
command.add("-cp");
command.add(cpFile);
}
// Forge/NeoForge child version.json doesn't include -Djava.library.path
command.add("-Djava.library.path=" + nativesDir.toAbsolutePath());
// Append memory/GC args (always after version.json args, like AstralRinth)
int ramMB = options.getMaxMemory() > 0 ? options.getMaxMemory() : 4096;
command.add("-Xmx" + ramMB + "M");
@@ -147,9 +138,9 @@ public class LaunchCommandBuilder {
}
command.add(mainClass);
// Parse ALL version.json game args (merged with parent) with placeholder
// substitution. This includes --launchTarget, --fml.forgeVersion,
// --fml.mcVersion, --version, --gameDir, --assetsDir, etc.
// Parse version.json game args (merged with parent) with placeholder
// substitution. Parent provides --username, --version, etc.;
// child provides --launchTarget, --fml.*.
List<String> allGameArgs = manifest != null ? manifest.getAllGameArguments() : new ArrayList<>();
if (!allGameArgs.isEmpty()) {
for (String arg : allGameArgs) {
@@ -802,7 +802,7 @@ public class JFXLauncher extends Application {
setInstallProgressWithStage("Installation failed", 0, 100, "Failed", 0, 1);
log("Install error: " + name);
}
} catch (Exception e) {
} catch (Throwable e) {
log("Install error: " + e.getMessage());
setInstallInProgress(false);
setInstallProgressWithStage("Error: " + e.getMessage(), 0, 100, "Error", 0, 1);
@@ -1229,6 +1229,12 @@ public class JFXLauncher extends Application {
log("[UI] Loaded " + content.length + " bytes: " + path);
String ct = getContentType(path);
if (ct.startsWith("text/")) {
String text = new String(content, java.nio.charset.StandardCharsets.UTF_8);
text = text.replace("__LAUNCHER_VERSION__", me.sashegdev.zernmc.launcher.utils.Version.getCurrentVersion());
content = text.getBytes(java.nio.charset.StandardCharsets.UTF_8);
}
exchange.getResponseHeaders().set("Content-Type", ct);
exchange.sendResponseHeaders(200, content.length);
exchange.getResponseBody().write(content);
@@ -18,6 +18,7 @@ import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicBoolean;
public class ZHttpClient {
@@ -25,6 +26,7 @@ public class ZHttpClient {
private static final HttpClient client = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(15))
.version(HttpClient.Version.HTTP_1_1)
.executor(Executors.newCachedThreadPool())
.build();
private static String BASE_URL = "https://api.zernmc.ru";
@@ -237,6 +239,9 @@ public class ZHttpClient {
if (url.contains("maven.neoforged.net")) return ServiceType.NEOFORGE_MAVEN;
if (url.contains("google.com")) return ServiceType.GOOGLE;
if (url.contains("cloudflare.com")) return ServiceType.CLOUDFLARE;
if (url.contains("libraries.minecraft.net")) return ServiceType.MOJANG_RESOURCES;
if (url.contains("piston-data.mojang.com")) return ServiceType.MOJANG_META;
if (url.contains("repo1.maven.org") || url.contains("repo.maven.apache.org")) return ServiceType.MOJANG_RESOURCES;
return null;
}
@@ -256,10 +261,13 @@ public class ZHttpClient {
return cause instanceof java.net.ConnectException ||
cause instanceof java.net.UnknownHostException ||
cause instanceof java.nio.channels.ClosedChannelException ||
cause instanceof java.net.SocketException ||
msg.contains("connection") ||
msg.contains("timeout") ||
msg.contains("refused") ||
msg.contains("closed");
msg.contains("closed") ||
msg.contains("reset") ||
msg.contains("abort");
}
private static void markServiceAsBlocked(String url) {
@@ -278,106 +286,148 @@ public class ZHttpClient {
public static String getWithSmartProxy(String url) throws IOException, InterruptedException {
if (!shouldUseProxyForUrl(url)) {
int directRetries = 3;
for (int directAttempt = 1; directAttempt <= directRetries; directAttempt++) {
try {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(url))
.timeout(Duration.ofSeconds(25))
.header("User-Agent", "ZernMC-Launcher/1.0")
.GET()
.build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() == 200) {
directSuccessCount++;
return response.body();
}
if (response.statusCode() >= 400) {
throw new IOException("HTTP " + response.statusCode());
}
} catch (Exception e) {
if (isConnectionError(e)) {
directFailCount++;
if (directAttempt < directRetries) {
System.out.println(ZAnsi.yellow("[NET] Direct retry " + directAttempt + "/" + directRetries + " for " + url.substring(0, Math.min(60, url.length())) + "..."));
try { Thread.sleep(1000 * directAttempt); } catch (InterruptedException ie) { break; }
continue;
}
markServiceAsBlocked(url);
} else {
throw e;
}
}
break;
}
}
int maxRetries = 3;
for (int attempt = 1; attempt <= maxRetries; attempt++) {
try {
String encodedUrl = URLEncoder.encode(url, StandardCharsets.UTF_8);
String proxyUrl = BASE_URL + "/proxy/download?url=" + encodedUrl;
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(url))
.timeout(Duration.ofSeconds(25))
.uri(URI.create(proxyUrl))
.timeout(Duration.ofSeconds(40))
.header("User-Agent", "ZernMC-Launcher/1.0")
.GET()
.build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() == 200) {
directSuccessCount++;
return response.body();
if (response.statusCode() != 200) {
throw new IOException("Proxy HTTP " + response.statusCode());
}
if (response.statusCode() >= 400) {
throw new IOException("HTTP " + response.statusCode());
}
proxySuccessCount++;
return response.body();
} catch (Exception e) {
if (isConnectionError(e)) {
directFailCount++;
markServiceAsBlocked(url);
if (attempt == maxRetries || !isConnectionError(e)) {
throw new IOException("Failed to fetch data directly or via proxy: " + e.getMessage(), e);
}
try { Thread.sleep(1000 * attempt); } catch (InterruptedException ie) { break; }
}
}
try {
String encodedUrl = URLEncoder.encode(url, StandardCharsets.UTF_8);
String proxyUrl = BASE_URL + "/proxy/download?url=" + encodedUrl;
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(proxyUrl))
.timeout(Duration.ofSeconds(40))
.header("User-Agent", "ZernMC-Launcher/1.0")
.GET()
.build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() != 200) {
throw new IOException("Proxy HTTP " + response.statusCode());
}
proxySuccessCount++;
return response.body();
} catch (Exception e) {
throw new IOException("Failed to fetch data directly or via proxy: " + e.getMessage(), e);
}
throw new IOException("Failed to fetch data: " + url);
}
public static void downloadFileWithSmartProxy(String url, Path target) throws Exception {
if (!shouldUseProxyForUrl(url)) {
try {
HttpRequest.Builder requestBuilder = HttpRequest.newBuilder()
.uri(URI.create(url))
.timeout(Duration.ofSeconds(40))
.header("User-Agent", "ZernMC-Launcher/1.0")
.GET();
int directRetries = 3;
for (int directAttempt = 1; directAttempt <= directRetries; directAttempt++) {
try {
HttpRequest.Builder requestBuilder = HttpRequest.newBuilder()
.uri(URI.create(url))
.timeout(Duration.ofSeconds(40))
.header("User-Agent", "ZernMC-Launcher/1.0")
.GET();
if (url.startsWith(BASE_URL)) {
String accessToken = AuthManager.getAccessToken();
if (accessToken != null && !accessToken.equals("0")) {
requestBuilder.header("Authorization", "Bearer " + accessToken);
if (url.startsWith(BASE_URL)) {
String accessToken = AuthManager.getAccessToken();
if (accessToken != null && !accessToken.equals("0")) {
requestBuilder.header("Authorization", "Bearer " + accessToken);
}
}
HttpRequest request = requestBuilder.build();
HttpResponse<Path> response = client.send(request, HttpResponse.BodyHandlers.ofFile(target));
if (response.statusCode() == 200) {
directSuccessCount++;
return;
}
} catch (Exception e) {
if (isConnectionError(e)) {
directFailCount++;
if (directAttempt < directRetries) {
System.out.println(ZAnsi.yellow("[NET] Direct download retry " + directAttempt + "/" + directRetries + " for " + url.substring(0, Math.min(60, url.length())) + "..."));
try { Thread.sleep(1000 * directAttempt); } catch (InterruptedException ie) { break; }
continue;
}
markServiceAsBlocked(url);
} else {
throw e;
}
}
HttpRequest request = requestBuilder.build();
HttpResponse<Path> response = client.send(request, HttpResponse.BodyHandlers.ofFile(target));
if (response.statusCode() == 200) {
directSuccessCount++;
return;
}
} catch (Exception e) {
if (isConnectionError(e)) {
directFailCount++;
markServiceAsBlocked(url);
}
break;
}
}
String encodedUrl = URLEncoder.encode(url, StandardCharsets.UTF_8);
String proxyUrl = BASE_URL + "/proxy/download?url=" + encodedUrl;
int maxRetries = 3;
for (int attempt = 1; attempt <= maxRetries; attempt++) {
try {
String encodedUrl = URLEncoder.encode(url, StandardCharsets.UTF_8);
String proxyUrl = BASE_URL + "/proxy/download?url=" + encodedUrl;
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(proxyUrl))
.timeout(Duration.ofMinutes(5))
.header("User-Agent", "ZernMC-Launcher/1.0")
.GET()
.build();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(proxyUrl))
.timeout(Duration.ofMinutes(5))
.header("User-Agent", "ZernMC-Launcher/1.0")
.GET()
.build();
HttpResponse<Path> response = client.send(request, HttpResponse.BodyHandlers.ofFile(target));
HttpResponse<Path> response = client.send(request, HttpResponse.BodyHandlers.ofFile(target));
if (response.statusCode() != 200) {
throw new IOException("Proxy download failed: HTTP " + response.statusCode());
if (response.statusCode() != 200) {
throw new IOException("Proxy download failed: HTTP " + response.statusCode());
}
proxySuccessCount++;
return;
} catch (Exception e) {
if (attempt == maxRetries || !isConnectionError(e)) {
throw new IOException("Proxy download failed: " + e.getMessage(), e);
}
try { Thread.sleep(1000 * attempt); } catch (InterruptedException ie) { break; }
}
}
proxySuccessCount++;
}
public static String get(String endpoint) throws IOException, InterruptedException {
+18 -10
View File
@@ -12,10 +12,10 @@
<div id="app">
<!-- Login Screen -->
<div id="login-screen" class="screen">
<div class="login-container">
<div class="login-brand">
<div class="login-container" id="login-container">
<div class="login-header">
<div class="brand-icon">
<svg width="56" height="56" viewBox="0 0 56 56" fill="none">
<svg width="40" height="40" viewBox="0 0 56 56" fill="none">
<rect width="56" height="56" rx="14" fill="url(#brandGrad)"/>
<path d="M18 28 L28 18 L38 28 L28 38 Z" fill="white" opacity="0.9"/>
<defs>
@@ -26,25 +26,33 @@
</defs>
</svg>
</div>
<h1 class="brand-title">ZernMC</h1>
<p class="brand-sub">Launcher <span id="version" data-i18n="version">1.0.9</span></p>
<h1 class="login-title" id="login-title" data-i18n="login.title">Sign In</h1>
<p class="login-subtitle" id="login-subtitle" data-i18n="login.subtitle">Welcome back to ZernMC</p>
<span id="version" class="hidden">__LAUNCHER_VERSION__</span>
</div>
<form id="login-form" class="login-form">
<div class="field">
<input type="text" id="username" placeholder="Username" data-i18n-placeholder="login.username" autocomplete="username" required>
<label for="username" data-i18n="login.username">Username</label>
<input type="text" id="username" autocomplete="username" required>
</div>
<div class="field">
<input type="password" id="password" placeholder="Password" data-i18n-placeholder="login.password" autocomplete="current-password" required>
<label for="password" data-i18n="login.password">Password</label>
<input type="password" id="password" autocomplete="current-password" required>
</div>
<div class="field hidden" id="confirm-field">
<label for="confirm-password" data-i18n="login.confirm">Confirm Password</label>
<input type="password" id="confirm-password" autocomplete="new-password">
</div>
<p id="login-error" class="error-msg hidden"></p>
<button type="submit" class="btn-primary" id="login-btn">
<span class="btn-label" data-i18n="login.title">Sign In</span>
<span class="btn-label" id="login-btn-label" data-i18n="login.title">Sign In</span>
<div class="spinner hidden"></div>
</button>
<p class="login-hint" data-i18n="login.hint">New account will be created automatically on first login</p>
<div class="login-footer" id="login-footer">
<span id="footer-hint" data-i18n="login.hint">Need an account?</span>
<span class="login-footer-action" id="footer-action" data-i18n="login.register">Register</span>
</div>
</form>
</div>
</div>
@@ -73,7 +81,7 @@
</svg>
<div class="sidebar-brand-text">
<span class="sidebar-brand-name">ZernMC</span>
<span class="sidebar-brand-ver">v<span id="header-version">1.0.9</span></span>
<span class="sidebar-brand-ver">v<span id="header-version">__LAUNCHER_VERSION__</span></span>
</div>
</div>
+104 -26
View File
@@ -4,7 +4,15 @@ const LOCALES = {
en: {
'nav.packs': 'Packs', 'nav.news': 'News', 'nav.settings': 'Settings',
'login.title': 'Sign In', 'login.username': 'Username', 'login.password': 'Password',
'login.hint': 'New account will be created automatically on first login',
'login.subtitle': 'Welcome back to ZernMC',
'login.hint': 'Need an account?',
'login.register': 'Register',
'login.registerTitle': 'Create Account',
'login.registerSubtitle': 'Join ZernMC today',
'login.hasAccount': 'Already have an account?',
'login.confirm': 'Confirm Password',
'login.passMismatch': 'Passwords do not match',
'login.passTooShort': 'Password must be at least 3 characters',
'login.signingIn': 'Signing in...',
'loading.text': 'Loading...',
'sidebar.serverPacks': 'Server Packs', 'sidebar.localPacks': 'Local Packs',
@@ -180,7 +188,15 @@ const LOCALES = {
ru: {
'nav.packs': 'Сборки', 'nav.news': 'Новости', 'nav.settings': 'Настройки',
'login.title': 'Вход', 'login.username': 'Логин', 'login.password': 'Пароль',
'login.hint': 'Новый аккаунт создаётся автоматически при первом входе',
'login.subtitle': 'С возвращением в ZernMC',
'login.hint': 'Нет аккаунта?',
'login.register': 'Регистрация',
'login.registerTitle': 'Создать аккаунт',
'login.registerSubtitle': 'Присоединяйтесь к ZernMC',
'login.hasAccount': 'Уже есть аккаунт?',
'login.confirm': 'Подтвердите пароль',
'login.passMismatch': 'Пароли не совпадают',
'login.passTooShort': 'Пароль должен быть минимум 3 символа',
'login.signingIn': 'Вход...',
'loading.text': 'Загрузка...',
'sidebar.serverPacks': 'Серверные сборки', 'sidebar.localPacks': 'Локальные сборки',
@@ -415,28 +431,43 @@ class ZernMCLauncher {
initBg() {
const c = document.getElementById('bg-canvas');
const ctx = c.getContext('2d');
let mx = 0, my = 0, ox = 0, oy = 0;
let t = 0, mode = 'login', modePulse = 0;
this.setWaveMode = m => { mode = m; modePulse = 1; };
const resize = () => { c.width = window.innerWidth; c.height = window.innerHeight; };
window.addEventListener('resize', resize);
const resize = () => { c.width = window.innerWidth; c.height = window.innerHeight; draw(); };
const draw = () => {
ctx.clearRect(0, 0, c.width, c.height);
const gs = 48, r = 1.2;
ctx.fillStyle = '#e94560';
for (let x = 0; x <= c.width; x += gs)
for (let y = 0; y <= c.height; y += gs)
ctx.beginPath(), ctx.arc(x + ox * 8, y + oy * 8, r, 0, Math.PI * 2), ctx.fill();
};
window.addEventListener('resize', resize);
window.addEventListener('mousemove', e => {
mx = (e.clientX / innerWidth - 0.5) * 2;
my = (e.clientY / innerHeight - 0.5) * 2;
});
const anim = () => {
ox += (mx * 0.3 - ox) * 0.04;
oy += (my * 0.3 - oy) * 0.04;
draw();
requestAnimationFrame(anim);
const w = c.width, h = c.height;
modePulse += (0 - modePulse) * 0.03;
const baseAlpha = 0.04 + modePulse * 0.03;
const amp = 20 + modePulse * 10;
const freq = 0.008 + modePulse * 0.003;
const speed = 0.008;
const lines = 5;
for (let i = 0; i < lines; i++) {
const yOff = (h / (lines + 1)) * (i + 1);
const alpha = baseAlpha * (1 - i * 0.12);
ctx.beginPath();
for (let x = 0; x <= w; x += 2) {
const wave = Math.sin(x * freq + t * speed + i * 1.8) * amp;
const wave2 = Math.sin(x * freq * 0.5 + t * speed * 0.7 + i * 2.5) * amp * 0.5;
const y = yOff + wave + wave2;
x === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
}
ctx.strokeStyle = `rgba(233, 69, 96, ${alpha})`;
ctx.lineWidth = 1.2;
ctx.stroke();
}
t++;
};
const anim = () => { draw(); requestAnimationFrame(anim); };
resize();
anim();
}
@@ -509,30 +540,37 @@ class ZernMCLauncher {
e.preventDefault();
const username = document.getElementById('username').value.trim();
const password = document.getElementById('password').value;
console.log('handleLogin: attempting login for', username);
const errEl = document.getElementById('login-error');
const btn = document.getElementById('login-btn');
const label = btn.querySelector('.btn-label');
const label = document.getElementById('login-btn-label');
const spinner = btn.querySelector('.spinner');
if (!username || !password) { this.showLoginError(t('toast.enterCredentials')); return; }
if (this._registerMode) {
const confirm = document.getElementById('confirm-password').value;
if (password !== confirm) { this.showLoginError(t('login.passMismatch')); return; }
if (password.length < 3) { this.showLoginError(t('login.passTooShort')); return; }
}
btn.disabled = true;
label.textContent = t('login.signingIn');
spinner.classList.remove('hidden');
const r = await this.req('/login', { method: 'POST', body: JSON.stringify({ username, password }) });
const endpoint = this._registerMode ? '/register' : '/login';
const r = await this.req(endpoint, { method: 'POST', body: JSON.stringify({ username, password }) });
btn.disabled = false;
label.textContent = t('login.title');
label.textContent = this._registerMode ? t('login.register') : t('login.title');
spinner.classList.add('hidden');
if (r.success) {
this.state.account = r.data;
this.enterMain();
this.toast(tr('toast.welcome', null, {username: r.data.username}), 'success');
const key = this._registerMode ? 'toast.accountCreated' : 'toast.welcome';
this.toast(tr(key, null, {username: r.data.username}), 'success');
} else {
if (r.error && (r.error.includes('not found') || r.error.includes('Invalid'))) {
if (!this._registerMode && r.error && (r.error.includes('not found') || r.error.includes('Invalid'))) {
var reg = await this.req('/register', { method: 'POST', body: JSON.stringify({ username, password }) });
if (reg.success) {
this.state.account = reg.data;
@@ -545,6 +583,45 @@ class ZernMCLauncher {
}
}
toggleMode() {
this._registerMode = !this._registerMode;
const isReg = this._registerMode;
const container = document.getElementById('login-container');
const title = document.getElementById('login-title');
const subtitle = document.getElementById('login-subtitle');
const btnLabel = document.getElementById('login-btn-label');
const footerHint = document.getElementById('footer-hint');
const footerAction = document.getElementById('footer-action');
const confirmField = document.getElementById('confirm-field');
container.style.transform = 'translateY(-4px)';
container.style.opacity = '0.6';
setTimeout(() => {
title.textContent = isReg ? t('login.registerTitle') : t('login.title');
subtitle.textContent = isReg ? t('login.registerSubtitle') : t('login.subtitle');
btnLabel.textContent = isReg ? t('login.register') : t('login.title');
footerHint.textContent = isReg ? t('login.hasAccount') : t('login.hint');
footerAction.textContent = isReg ? t('login.title') : t('login.register');
if (isReg) {
confirmField.classList.remove('hidden');
document.getElementById('confirm-password').required = true;
} else {
confirmField.classList.add('hidden');
document.getElementById('confirm-password').required = false;
document.getElementById('confirm-password').value = '';
}
document.getElementById('login-error').classList.add('hidden');
container.style.transform = '';
container.style.opacity = '';
if (this.setWaveMode) this.setWaveMode(isReg ? 'register' : 'login');
}, 150);
}
showLoginError(msg) {
const el = document.getElementById('login-error');
el.textContent = msg;
@@ -600,6 +677,7 @@ class ZernMCLauncher {
// ==================== NAV ====================
bindEvents() {
document.getElementById('login-form').addEventListener('submit', e => this.handleLogin(e));
document.getElementById('login-footer').addEventListener('click', () => this.toggleMode());
document.getElementById('logout-btn').addEventListener('click', () => this.logout());
document.getElementById('settings-btn').addEventListener('click', () => this.switchView('settings'));
+48 -47
View File
@@ -59,85 +59,86 @@ body {
/* ========== LOGIN ========== */
.login-container {
position: relative; z-index: 1;
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 48px 40px 40px;
border-radius: var(--radius-md);
padding: 32px 32px 24px;
width: 100%;
max-width: 380px;
box-shadow: var(--shadow);
animation: floatIn 0.5s ease forwards;
max-width: 360px;
box-shadow: 0 8px 40px rgba(0,0,0,0.5);
transition: transform 0.35s ease, opacity 0.35s ease;
}
@keyframes floatIn {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
.login-header { text-align: center; margin-bottom: 28px; overflow: hidden; }
.brand-icon { margin-bottom: 12px; }
.login-title {
font-size: 22px; font-weight: 700;
color: var(--text); transition: transform 0.3s ease, opacity 0.3s ease;
}
.login-subtitle {
color: var(--text-muted); font-size: 13px; margin-top: 4px;
transition: transform 0.3s ease, opacity 0.3s ease;
}
.login-brand { text-align: center; margin-bottom: 36px; }
.brand-icon { margin-bottom: 16px; }
.brand-title {
font-size: 28px; font-weight: 800;
color: var(--text);
}
.brand-sub { color: var(--text-muted); font-size: 13px; margin-top: 4px; }
.login-form { display: flex; flex-direction: column; gap: 16px; }
.login-form { display: flex; flex-direction: column; gap: 20px; }
.field { position: relative; }
.field { display: flex; flex-direction: column; gap: 6px; }
.field label {
position: absolute; top: 50%; left: 14px; transform: translateY(-50%);
font-size: 13px; color: var(--text-muted);
transition: var(--transition); pointer-events: none;
background: var(--bg-elevated); padding: 0 4px;
}
.field input:focus + label,
.field input:not(:placeholder-shown) + label,
.field textarea:focus + label,
.field textarea:not(:placeholder-shown) + label {
top: 0; font-size: 11px; color: var(--accent);
font-size: 11px; font-weight: 700;
text-transform: uppercase; letter-spacing: 0.5px;
color: var(--text-secondary);
}
.field input {
width: 100%; padding: 14px 14px; font-size: 14px;
background: var(--bg-surface); border: 1px solid var(--border-light);
border-radius: var(--radius-sm); color: var(--text);
font-family: var(--font); transition: var(--transition);
width: 100%; padding: 10px 12px; font-size: 15px;
background: var(--bg-surface); border: 1px solid var(--border);
border-radius: 4px; color: var(--text);
font-family: var(--font); transition: border-color 150ms ease, box-shadow 150ms ease;
outline: none;
}
.field input:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-glow);
box-shadow: 0 0 0 2px var(--accent-glow);
}
.field select {
width: 100%; padding: 12px 14px; font-size: 14px;
background: var(--bg-surface); border: 1px solid var(--border-light);
border-radius: var(--radius-sm); color: var(--text);
width: 100%; padding: 10px 12px; font-size: 15px;
background: var(--bg-surface); border: 1px solid var(--border);
border-radius: 4px; color: var(--text);
font-family: var(--font); cursor: pointer; outline: none;
}
.field select:focus { border-color: var(--accent); }
#confirm-field {
transition: opacity 0.3s ease, max-height 0.3s ease, margin 0.3s ease;
overflow: hidden; max-height: 60px; opacity: 1; margin: 0;
}
#confirm-field.hidden { max-height: 0; opacity: 0; margin: 0; display: block !important; padding: 0; overflow: hidden; pointer-events: none; }
.btn-primary {
width: 100%; padding: 14px; border: none; border-radius: var(--radius-sm);
background: linear-gradient(135deg, var(--accent), #ff6b6b);
color: #fff; font-size: 15px; font-weight: 600; cursor: pointer;
font-family: var(--font); transition: var(--transition);
width: 100%; padding: 14px; border: none; border-radius: 4px;
background: var(--accent); color: #fff;
font-size: 14px; font-weight: 600; cursor: pointer;
font-family: var(--font); transition: background 150ms ease, box-shadow 150ms ease;
display: flex; align-items: center; justify-content: center; gap: 8px;
min-height: 48px; position: relative;
min-height: 44px; position: relative;
}
.btn-primary:hover { transform: translateY(-1px); box-shadow: var(--shadow-glow); }
.btn-primary:active { transform: translateY(0); }
.btn-primary:disabled { opacity: 0.6; cursor: not-allowed; transform: none; box-shadow: none; }
.btn-primary:hover { background: #d63850; }
.btn-primary:active { background: #c22f46; transform: scale(0.98); }
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; transform: none; box-shadow: none; }
.error-msg {
color: var(--error); font-size: 13px; text-align: center;
padding: 10px; background: rgba(248,113,113,0.1);
border-radius: var(--radius-sm); animation: shake 0.4s ease;
border-radius: 4px; animation: shake 0.4s ease;
}
@keyframes shake {
0%,100%{transform:translateX(0)}20%{transform:translateX(-4px)}40%{transform:translateX(4px)}60%{transform:translateX(-3px)}80%{transform:translateX(3px)}
}
.login-hint { text-align: center; font-size: 12px; color: var(--text-muted); margin-top: 4px; }
.login-footer {
display: flex; justify-content: center; gap: 4px;
font-size: 12px; color: var(--text-muted); margin-top: 4px;
}
.login-footer-action { color: var(--accent); cursor: pointer; }
.login-footer-action:hover { text-decoration: underline; }
.spinner {
position: absolute; width: 20px; height: 20px;
@@ -0,0 +1,246 @@
package me.sashegdev.zernmc.launcher.integration;
import me.sashegdev.zernmc.launcher.minecraft.Instance;
import me.sashegdev.zernmc.launcher.minecraft.installer.ModLoaderInstaller;
import me.sashegdev.zernmc.launcher.minecraft.installer.VersionInstaller;
import me.sashegdev.zernmc.launcher.minecraft.launch.LaunchCommandBuilder;
import me.sashegdev.zernmc.launcher.minecraft.model.LaunchOptions;
import org.junit.jupiter.api.*;
import java.io.ByteArrayOutputStream;
import java.io.PrintStream;
import java.net.URL;
import java.net.HttpURLConnection;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
import static org.junit.jupiter.api.Assumptions.assumeTrue;
@Tag("integration")
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
class ForgeInstallIntegrationTest {
private static final String MC_VERSION = "1.20.1";
private static final String FORGE_VERSION = "47.4.22";
private static Path instanceDir;
private static Instance instance;
private static boolean installSuccess = false;
@BeforeAll
static void setup() throws Exception {
// Check network
assumeTrue(networkReachable("https://maven.minecraftforge.net"),
"Forge maven not reachable, skipping");
assumeTrue(networkReachable("https://piston-meta.mojang.com"),
"Mojang meta not reachable, skipping");
instanceDir = Files.createTempDirectory("zernmc-forge-test-");
instance = new Instance("forge-test", instanceDir);
instance.setMinecraftVersion(MC_VERSION);
instance.setLoaderType("forge");
instance.setLoaderVersion(FORGE_VERSION);
instance.setAssetIndex(MC_VERSION);
}
@AfterAll
static void cleanup() throws Exception {
if (instanceDir != null) {
try (var stream = Files.walk(instanceDir)) {
stream.sorted((a, b) -> b.compareTo(a))
.forEach(p -> {
try { Files.deleteIfExists(p); } catch (Exception ignored) {}
});
}
}
}
@Test
@Order(1)
@DisplayName("Install Minecraft vanilla 1.20.1")
void installMinecraft() throws Exception {
System.out.println("\n=== Installing Minecraft " + MC_VERSION + " ===");
VersionInstaller versionInstaller = new VersionInstaller(instanceDir);
String assetIndex = versionInstaller.install(MC_VERSION);
assertNotNull(assetIndex, "assetIndex must not be null after install");
instance.setAssetIndex(assetIndex);
// Verify version.json exists
Path versionJson = instanceDir.resolve("versions/" + MC_VERSION + "/" + MC_VERSION + ".json");
assertTrue(Files.exists(versionJson), "version.json must exist after vanilla install");
// Verify client jar exists
Path clientJar = instanceDir.resolve("versions/" + MC_VERSION + "/" + MC_VERSION + ".jar");
assertTrue(Files.exists(clientJar), "client jar must exist after vanilla install");
// Verify libraries exist
Path libsDir = instanceDir.resolve("libraries");
assertTrue(Files.exists(libsDir) && Files.list(libsDir).findAny().isPresent(),
"At least one library must exist after vanilla install");
System.out.println("=== Minecraft install OK ===\n");
}
@Test
@Order(2)
@DisplayName("Install Forge 1.20.1-47.4.22")
void installForge() throws Exception {
System.out.println("\n=== Installing Forge " + MC_VERSION + "-" + FORGE_VERSION + " ===");
ModLoaderInstaller installer = new ModLoaderInstaller(instance);
boolean success = installer.install(MC_VERSION, FORGE_VERSION, ModLoaderInstaller.LoaderType.FORGE);
assertTrue(success, "Forge installation must succeed");
installSuccess = true;
// Verify Forge version.json exists
String forgeVersionId = MC_VERSION + "-forge-" + FORGE_VERSION;
Path forgeJson = instanceDir.resolve("versions/" + forgeVersionId + "/" + forgeVersionId + ".json");
assertTrue(Files.exists(forgeJson), "Forge version.json must exist at " + forgeJson);
System.out.println(" Forge version.json size: " + Files.size(forgeJson) + " bytes\n");
}
@Test
@Order(3)
@DisplayName("Build launch command and verify structure")
void buildAndVerifyCommand() throws Exception {
assumeTrue(installSuccess, "Skipping: Forge install did not succeed");
System.out.println("\n=== Building launch command ===");
LaunchOptions options = new LaunchOptions();
options.setUsername("TestPlayer");
options.setUuid("00000000-0000-0000-0000-000000000000");
options.setAccessToken("test-token");
options.setMaxMemory(4096);
options.setWidth(854);
options.setHeight(480);
LaunchCommandBuilder builder = new LaunchCommandBuilder(instance);
List<String> command = builder.build(options);
// === JVM args checks ===
assertFalse(command.contains("-cp"),
"Forge must NOT have -cp (causes split-package)");
// Find -Djava.library.path (added manually, not from forge child)
String libPath = null;
for (String arg : command) {
if (arg != null && arg.startsWith("-Djava.library.path=")) {
libPath = arg;
break;
}
}
assertNotNull(libPath, "Must have -Djava.library.path");
assertTrue(libPath.contains("natives"), "-Djava.library.path must point to natives dir");
// Must have -p (module path) from forge child
int pIdx = command.indexOf("-p");
assertTrue(pIdx >= 0, "Must have -p");
String modulePath = command.get(pIdx + 1);
assertNotNull(modulePath, "-p must have a value");
assertTrue(modulePath.contains("bootstraplauncher"), "-p must include bootstraplauncher");
// Must have --add-modules + ALL-MODULE-PATH as two args (forge format)
int amIdx = command.indexOf("--add-modules");
assertTrue(amIdx >= 0, "Must have --add-modules");
assertEquals("ALL-MODULE-PATH", command.get(amIdx + 1),
"--add-modules must be followed by ALL-MODULE-PATH");
// Must have --add-opens (at least one) from forge child
boolean hasAddOpens = command.stream().anyMatch(a -> a != null && a.startsWith("--add-opens"));
assertTrue(hasAddOpens, "Must have at least one --add-opens");
// Main class
assertTrue(command.contains("cpw.mods.bootstraplauncher.BootstrapLauncher"),
"Main class must be BootstrapLauncher");
// Game args: parent (--username) + child (--launchTarget)
assertTrue(command.contains("--username"), "Game args must include --username from parent");
assertTrue(command.contains("--launchTarget"), "Game args must include --launchTarget from child");
// Memory/GC args
assertTrue(command.contains("-Xmx4096M"), "Must have -Xmx");
assertTrue(command.contains("-XX:+UseG1GC"), "Must have GC flags");
System.out.println("=== Command structure OK (" + command.size() + " args) ===\n");
}
@Test
@Order(4)
@DisplayName("Module path is valid (headless smoke test)")
void modulePathSmokeTest() throws Exception {
assumeTrue(installSuccess, "Skipping: Forge install did not succeed");
System.out.println("\n=== Module path smoke test ===");
LaunchOptions options = new LaunchOptions();
options.setUsername("TestPlayer");
LaunchCommandBuilder builder = new LaunchCommandBuilder(instance);
List<String> command = builder.build(options);
// Extract key args
String libPath = null;
String modulePath = null;
for (int i = 0; i < command.size(); i++) {
String arg = command.get(i);
if (arg != null) {
if (arg.startsWith("-Djava.library.path=")) libPath = arg;
}
}
int pIdx = command.indexOf("-p");
if (pIdx >= 0) modulePath = command.get(pIdx + 1);
// Find --add-modules + ALL-MODULE-PATH as two args
int amIdx = command.indexOf("--add-modules");
String addModules = null;
if (amIdx >= 0 && amIdx + 1 < command.size()) {
addModules = command.get(amIdx + 1);
}
assertNotNull(libPath, "-Djava.library.path must exist");
assertNotNull(modulePath, "-p must exist");
assertNotNull(addModules, "--add-modules must exist");
assertEquals("ALL-MODULE-PATH", addModules, "--add-modules must be ALL-MODULE-PATH");
// Run: java -Djava.library.path=<path> -p <mp> --add-modules ALL-MODULE-PATH -version
ProcessBuilder pb = new ProcessBuilder(
"java",
libPath,
"-p", modulePath,
"--add-modules", addModules,
"-version"
);
pb.redirectErrorStream(true);
long start = System.currentTimeMillis();
Process proc = pb.start();
String output = new String(proc.getInputStream().readAllBytes());
int exit = proc.waitFor();
long elapsed = System.currentTimeMillis() - start;
System.out.println(" JVM accepted module path in " + elapsed + "ms");
System.out.println(" Output: " + output.trim().replace('\n', ' '));
assertEquals(0, exit,
"JVM must accept module path without errors.\nExit: " + exit + "\nOutput: " + output);
System.out.println("=== Module path smoke test OK ===\n");
}
// ================================================================
// Helper
// ================================================================
private static boolean networkReachable(String urlStr) {
try {
URL url = new URL(urlStr);
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
conn.setConnectTimeout(5000);
conn.setRequestMethod("GET");
int code = conn.getResponseCode();
conn.disconnect();
return true;
} catch (Exception e) {
System.out.println(" Network check failed for " + urlStr + ": " + e.getMessage());
return false;
}
}
}
@@ -0,0 +1,414 @@
package me.sashegdev.zernmc.launcher.minecraft.launch;
import me.sashegdev.zernmc.launcher.minecraft.Instance;
import me.sashegdev.zernmc.launcher.minecraft.model.LaunchOptions;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.io.ByteArrayOutputStream;
import java.io.PrintStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
import static org.junit.jupiter.api.Assertions.*;
class LaunchCommandBuilderTest {
@TempDir
Path tempDir;
// ================================================================
// Helpers
// ================================================================
private Path createJar(Path path) throws Exception {
Files.createDirectories(path.getParent());
try (ZipOutputStream zos = new ZipOutputStream(Files.newOutputStream(path))) {
zos.putNextEntry(new ZipEntry("META-INF/MANIFEST.MF"));
zos.write("Manifest-Version: 1.0\n".getBytes());
zos.closeEntry();
}
return path;
}
private Path createJarWithModule(Path path, String moduleName) throws Exception {
Files.createDirectories(path.getParent());
try (ZipOutputStream zos = new ZipOutputStream(Files.newOutputStream(path))) {
zos.putNextEntry(new ZipEntry("META-INF/MANIFEST.MF"));
zos.write(("Manifest-Version: 1.0\nAutomatic-Module-Name: " + moduleName + "\n").getBytes());
zos.closeEntry();
}
return path;
}
private void writeVersionJson(Path path, String content) throws Exception {
Files.createDirectories(path.getParent());
Files.writeString(path, content);
}
// ================================================================
// Fixture setup
// ================================================================
private Instance createForgeFixture(Path dir) throws Exception {
String mcVer = "1.20.1";
String forgeVer = "47.3.0";
String versionId = mcVer + "-forge-" + forgeVer;
// Vanilla parent
String vanillaJson = """
{
"id": "1.20.1",
"mainClass": "net.minecraft.client.main.Main",
"arguments": {
"jvm": ["-Djava.library.path=${natives_directory}", "-cp", "${classpath}"],
"game": ["--username", "${auth_player_name}", "--version", "${version_name}", "--gameDir", "${game_directory}", "--assetsDir", "${assets_root}", "--assetIndex", "${assets_index_name}", "--uuid", "${auth_uuid}", "--accessToken", "${auth_access_token}", "--userType", "${user_type}", "--versionType", "${version_type}"]
},
"assetIndex": {"id": "1.20.1"},
"libraries": [{"name": "net.minecraft:client:1.20.1", "downloads": {"artifact": {"path": "net/minecraft/client/1.20.1/client-1.20.1.jar"}}}]
}
""";
writeVersionJson(dir.resolve("versions/1.20.1/1.20.1.json"), vanillaJson);
createJarWithModule(dir.resolve("versions/1.20.1/1.20.1.jar"), "minecraft");
createJar(dir.resolve("libraries/net/minecraft/client/1.20.1/client-1.20.1.jar"));
// Forge child
String forgeJson = """
{
"id": "1.20.1-forge-47.3.0",
"inheritsFrom": "1.20.1",
"mainClass": "cpw.mods.bootstraplauncher.BootstrapLauncher",
"arguments": {
"jvm": ["-p", "${classpath}", "--add-modules=ALL-MODULE-PATH", "--add-opens=java.base/java.util.jar=ALL-UNNAMED", "--add-opens=java.base/java.lang.invoke=ALL-UNNAMED"],
"game": ["--launchTarget", "forgeclient", "--fml.forgeVersion", "47.3.0", "--fml.mcVersion", "1.20.1", "--fml.forgeGroup", "net.minecraftforge"]
},
"libraries": [{"name": "net.minecraftforge:forge:1.20.1-47.3.0", "downloads": {"artifact": {"path": "net/minecraftforge/forge/1.20.1-47.3.0/forge-1.20.1-47.3.0.jar"}}}]
}
""";
writeVersionJson(dir.resolve("versions/" + versionId + "/" + versionId + ".json"), forgeJson);
createJar(dir.resolve("libraries/net/minecraftforge/forge/1.20.1-47.3.0/forge-1.20.1-47.3.0.jar"));
Files.createDirectories(dir.resolve("natives"));
Instance instance = new Instance("test-forge", dir);
instance.setMinecraftVersion(mcVer);
instance.setLoaderType("forge");
instance.setLoaderVersion(forgeVer);
instance.setAssetIndex(mcVer);
return instance;
}
private Instance createNeoForgeFixture(Path dir) throws Exception {
String mcVer = "1.20.1";
String neoVer = "47.1.106";
String versionId = mcVer + "-neoforge-" + neoVer;
String vanillaJson = """
{
"id": "1.20.1",
"mainClass": "net.minecraft.client.main.Main",
"arguments": {
"jvm": ["-Djava.library.path=${natives_directory}", "-cp", "${classpath}"],
"game": ["--username", "${auth_player_name}", "--version", "${version_name}"]
},
"assetIndex": {"id": "1.20.1"},
"libraries": [{"name": "net.minecraft:client:1.20.1", "downloads": {"artifact": {"path": "net/minecraft/client/1.20.1/client-1.20.1.jar"}}}]
}
""";
writeVersionJson(dir.resolve("versions/1.20.1/1.20.1.json"), vanillaJson);
createJarWithModule(dir.resolve("versions/1.20.1/1.20.1.jar"), "minecraft");
createJar(dir.resolve("libraries/net/minecraft/client/1.20.1/client-1.20.1.jar"));
String neoJson = """
{
"id": "1.20.1-neoforge-47.1.106",
"inheritsFrom": "1.20.1",
"mainClass": "cpw.mods.bootstraplauncher.BootstrapLauncher",
"arguments": {
"jvm": ["-p", "${classpath}", "--add-modules=ALL-MODULE-PATH", "--add-opens=java.base/java.util.jar=ALL-UNNAMED"],
"game": ["--launchTarget", "neoforgeclient", "--fml.neoForgeVersion", "47.1.106", "--fml.mcVersion", "1.20.1"]
},
"libraries": [{"name": "net.neoforged:neoforge:1.20.1-47.1.106", "downloads": {"artifact": {"path": "net/neoforged/neoforge/1.20.1-47.1.106/neoforge-1.20.1-47.1.106.jar"}}}]
}
""";
writeVersionJson(dir.resolve("versions/" + versionId + "/" + versionId + ".json"), neoJson);
createJar(dir.resolve("libraries/net/neoforged/neoforge/1.20.1-47.1.106/neoforge-1.20.1-47.1.106.jar"));
Files.createDirectories(dir.resolve("natives"));
Instance instance = new Instance("test-neoforge", dir);
instance.setMinecraftVersion(mcVer);
instance.setLoaderType("neoforge");
instance.setLoaderVersion(neoVer);
instance.setAssetIndex(mcVer);
instance.setFabricVersionId(versionId);
return instance;
}
private Instance createFabricFixture(Path dir) throws Exception {
String mcVer = "1.20.1";
String loaderVer = "0.16.10";
String versionId = "fabric-loader-" + loaderVer + "-" + mcVer;
String vanillaJson = """
{
"id": "1.20.1",
"mainClass": "net.minecraft.client.main.Main",
"arguments": {
"game": ["--username", "${auth_player_name}", "--version", "${version_name}"]
},
"assetIndex": {"id": "1.20.1"},
"libraries": [{"name": "net.minecraft:client:1.20.1", "downloads": {"artifact": {"path": "net/minecraft/client/1.20.1/client-1.20.1.jar"}}}]
}
""";
writeVersionJson(dir.resolve("versions/1.20.1/1.20.1.json"), vanillaJson);
createJar(dir.resolve("versions/1.20.1/1.20.1.jar"));
String fabricJson = """
{
"id": "fabric-loader-0.16.10-1.20.1",
"inheritsFrom": "1.20.1",
"mainClass": "net.fabricmc.loader.impl.launch.knot.KnotClient",
"arguments": {
"game": ["--username", "${auth_player_name}"]
},
"libraries": [{"name": "net.fabricmc:fabric-loader:0.16.10", "downloads": {"artifact": {"path": "net/fabricmc/fabric-loader/0.16.10/fabric-loader-0.16.10.jar"}}}]
}
""";
writeVersionJson(dir.resolve("versions/" + versionId + "/" + versionId + ".json"), fabricJson);
createJar(dir.resolve("libraries/net/minecraft/client/1.20.1/client-1.20.1.jar"));
createJar(dir.resolve("libraries/net/fabricmc/fabric-loader/0.16.10/fabric-loader-0.16.10.jar"));
Files.createDirectories(dir.resolve("natives"));
Instance instance = new Instance("test-fabric", dir);
instance.setMinecraftVersion(mcVer);
instance.setLoaderType("fabric");
instance.setLoaderVersion(loaderVer);
instance.setAssetIndex(mcVer);
instance.setFabricVersionId(versionId);
return instance;
}
private Instance createVanillaFixture(Path dir) throws Exception {
Files.createDirectories(dir.resolve("natives"));
// No version.json forces vanilla fallback
Instance instance = new Instance("test-vanilla", dir);
instance.setMinecraftVersion("1.21");
instance.setLoaderType("vanilla");
instance.setAssetIndex("1.21");
return instance;
}
// ================================================================
// Forge tests
// ================================================================
@Test
void forge_noParentJvmArgs() throws Exception {
Instance instance = createForgeFixture(tempDir);
LaunchOptions options = new LaunchOptions();
LaunchCommandBuilder builder = new LaunchCommandBuilder(instance);
List<String> command = builder.build(options);
// Parent's -cp must NOT appear (causes split-package with child's -p)
assertFalse(command.contains("-cp"), "Forge must not have -cp from parent");
// -Djava.library.path must be added manually (not in child args)
String expectedLibPath = "-Djava.library.path=" + tempDir.resolve("natives").toAbsolutePath();
assertTrue(command.contains(expectedLibPath), "Forge must have -Djava.library.path");
// Child's -p (module path) must be present
int pIndex = command.indexOf("-p");
assertTrue(pIndex >= 0, "Forge must have -p");
assertTrue(pIndex + 1 < command.size(), "-p must have a value");
assertNotNull(command.get(pIndex + 1), "-p value must not be null");
// Child's --add-modules must be present
assertTrue(command.contains("--add-modules=ALL-MODULE-PATH"), "Forge must have --add-modules=ALL-MODULE-PATH");
// Memory/GC args
assertTrue(command.contains("-Xmx4096M"), "Forge must have memory args");
assertTrue(command.contains("-XX:+UseG1GC"), "Forge must have GC args");
// Main class
assertTrue(command.contains("cpw.mods.bootstraplauncher.BootstrapLauncher"), "Forge main class must be BootstrapLauncher");
// Game args: parent + child
int launchTargetIdx = command.indexOf("--launchTarget");
assertTrue(launchTargetIdx >= 0, "Game args must include child's --launchTarget");
assertEquals("forgeclient", command.get(launchTargetIdx + 1), "Launch target must be forgeclient");
int usernameIdx = command.indexOf("--username");
assertTrue(usernameIdx >= 0, "Game args must include parent's --username");
}
@Test
void forge_classpathContainsBothVanillaAndForgeLibraries() throws Exception {
Instance instance = createForgeFixture(tempDir);
LaunchOptions options = new LaunchOptions();
LaunchCommandBuilder builder = new LaunchCommandBuilder(instance);
List<String> command = builder.build(options);
// Find -p's classpath value
int pIdx = command.indexOf("-p");
String cpValue = command.get(pIdx + 1);
// Version jar from ensureVersionJarForForge
assertTrue(cpValue.contains("1.20.1-forge-47.3.0.jar") || cpValue.contains("1.20.1.jar"),
"Classpath must include version jar");
// Forge library
assertTrue(cpValue.contains("forge-1.20.1-47.3.0.jar"),
"Classpath must include Forge library");
// Vanilla library
assertTrue(cpValue.contains("client-1.20.1.jar"),
"Classpath must include vanilla client library");
}
// ================================================================
// NeoForge tests
// ================================================================
@Test
void neoforge_noParentJvmArgs() throws Exception {
Instance instance = createNeoForgeFixture(tempDir);
LaunchOptions options = new LaunchOptions();
LaunchCommandBuilder builder = new LaunchCommandBuilder(instance);
List<String> command = builder.build(options);
// Same checks as Forge
assertFalse(command.contains("-cp"), "NeoForge must not have -cp from parent");
String expectedLibPath = "-Djava.library.path=" + tempDir.resolve("natives").toAbsolutePath();
assertTrue(command.contains(expectedLibPath), "NeoForge must have -Djava.library.path");
assertTrue(command.contains("-p"), "NeoForge must have -p");
assertTrue(command.contains("--add-modules=ALL-MODULE-PATH"), "NeoForge must have --add-modules=ALL-MODULE-PATH");
assertTrue(command.contains("cpw.mods.bootstraplauncher.BootstrapLauncher"), "NeoForge main class must be BootstrapLauncher");
// Game args: parent + child
int launchTargetIdx = command.indexOf("--launchTarget");
assertTrue(launchTargetIdx >= 0, "Game args must include child's --launchTarget");
assertEquals("neoforgeclient", command.get(launchTargetIdx + 1), "Launch target must be neoforgeclient");
assertTrue(command.contains("--username"), "Game args must include parent's --username");
}
// ================================================================
// Fabric tests
// ================================================================
@Test
void fabric_followsVanillaArgPattern() throws Exception {
Instance instance = createFabricFixture(tempDir);
LaunchOptions options = new LaunchOptions();
LaunchCommandBuilder builder = new LaunchCommandBuilder(instance);
List<String> command = builder.build(options);
// Fabric uses -cp (vanilla pattern), not -p
assertTrue(command.contains("-cp"), "Fabric must have -cp");
assertFalse(command.contains("-p"), "Fabric must not have -p");
// -Djava.library.path present
String expectedLibPath = "-Djava.library.path=" + tempDir.resolve("natives").toAbsolutePath();
assertTrue(command.contains(expectedLibPath), "Fabric must have -Djava.library.path");
// Main class is KnotClient
assertTrue(command.contains("net.fabricmc.loader.impl.launch.knot.KnotClient"),
"Fabric main class must be KnotClient");
// Game args from version.json (merged with parent)
assertTrue(command.contains("--username"), "Fabric must have game args");
}
// ================================================================
// Vanilla tests
// ================================================================
@Test
void vanilla_usesVanillaArgs() throws Exception {
Instance instance = createVanillaFixture(tempDir);
LaunchOptions options = new LaunchOptions();
LaunchCommandBuilder builder = new LaunchCommandBuilder(instance);
List<String> command = builder.build(options);
// Vanilla uses -cp
assertTrue(command.contains("-cp"), "Vanilla must have -cp");
assertFalse(command.contains("-p"), "Vanilla must not have -p");
// -Djava.library.path present
String expectedLibPath = "-Djava.library.path=" + tempDir.resolve("natives").toAbsolutePath();
assertTrue(command.contains(expectedLibPath), "Vanilla must have -Djava.library.path");
// Memory/GC args
assertTrue(command.contains("-Xmx4096M"), "Vanilla must have memory args");
assertTrue(command.contains("-XX:+UseG1GC"), "Vanilla must have GC args");
// Main class
assertTrue(command.contains("net.minecraft.client.main.Main"),
"Vanilla main class must be net.minecraft.client.main.Main");
// Game args
assertTrue(command.contains("--username"), "Vanilla must have --username");
}
// ================================================================
// Headless smoke tests run Java with constructed args to verify
// module path validity. No display needed, uses -version.
// ================================================================
@Test
void forge_modulePathIsValid_smoke() throws Exception {
Instance instance = createForgeFixture(tempDir);
LaunchOptions options = new LaunchOptions();
LaunchCommandBuilder builder = new LaunchCommandBuilder(instance);
List<String> command = builder.build(options);
// Extract -p (module path) and its value
int pIdx = command.indexOf("-p");
assertTrue(pIdx >= 0, "Forge must have -p");
String modulePath = command.get(pIdx + 1);
// Extract --add-modules, -Djava.library.path
String addModules = null;
String libPath = null;
for (String arg : command) {
if (arg.startsWith("-Djava.library.path=")) {
libPath = arg;
}
}
for (int i = 0; i < command.size(); i++) {
if ("--add-modules=ALL-MODULE-PATH".equals(command.get(i))) {
addModules = command.get(i);
}
}
assertNotNull(addModules, "Must have --add-modules=ALL-MODULE-PATH");
// Build minimal Java command that validates the module path
// java -Djava.library.path=<path> -p <classpath> --add-modules=ALL-MODULE-PATH -version
ProcessBuilder pb = new ProcessBuilder(
"java",
libPath,
"-p", modulePath,
"--add-modules=ALL-MODULE-PATH",
"-version"
);
pb.redirectErrorStream(true);
Process process = pb.start();
String output = new String(process.getInputStream().readAllBytes());
int exitCode = process.waitFor();
assertTrue(exitCode == 0,
"Java should accept module path without errors. Exit code: " + exitCode
+ "\nOutput: " + output);
}
}
+178 -155
View File
@@ -1,156 +1,179 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>me.sashegdev</groupId>
<artifactId>ZernMCLauncher</artifactId>
<version>1.0.10</version>
<packaging>pom</packaging>
<name>ZernMC Launcher Parent</name>
<description>ZernMC Launcher - Multi-module project</description>
<modules>
<module>bootstrap</module>
<module>launcher</module>
</modules>
<properties>
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.organization.name>ZernMC</project.organization.name>
<project.inceptionYear>2026</project.inceptionYear>
<project.description>ZernMC Launcher - Multi-module project</project.description>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.apache.httpcomponents</groupId>
<artifactId>httpclient</artifactId>
<version>4.5.14</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.15.2</version>
</dependency>
<dependency>
<groupId>com.google.code.gson</groupId>
<artifactId>gson</artifactId>
<version>2.10.1</version>
</dependency>
<dependency>
<groupId>org.json</groupId>
<artifactId>json</artifactId>
<version>20231013</version>
</dependency>
<dependency>
<groupId>org.fusesource.jansi</groupId>
<artifactId>jansi</artifactId>
<version>2.4.1</version>
</dependency>
<dependency>
<groupId>org.jline</groupId>
<artifactId>jline</artifactId>
<version>3.24.1</version>
</dependency>
<dependency>
<groupId>me.tongfei</groupId>
<artifactId>progressbar</artifactId>
<version>0.9.5</version>
</dependency>
<dependency>
<groupId>commons-io</groupId>
<artifactId>commons-io</artifactId>
<version>2.15.1</version>
</dependency>
<!-- JavaFX for Windows -->
<dependency>
<groupId>org.openjfx</groupId>
<artifactId>javafx-controls</artifactId>
<version>23</version>
<classifier>win</classifier>
</dependency>
<dependency>
<groupId>org.openjfx</groupId>
<artifactId>javafx-web</artifactId>
<version>23</version>
<classifier>win</classifier>
</dependency>
<dependency>
<groupId>org.openjfx</groupId>
<artifactId>javafx-graphics</artifactId>
<version>23</version>
<classifier>win</classifier>
</dependency>
<dependency>
<groupId>org.openjfx</groupId>
<artifactId>javafx-base</artifactId>
<version>23</version>
<classifier>win</classifier>
</dependency>
<dependency>
<groupId>org.openjfx</groupId>
<artifactId>javafx-media</artifactId>
<version>23</version>
<classifier>win</classifier>
</dependency>
</dependencies>
</dependencyManagement>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.2.3</version>
</plugin>
<!-- Shade Plugin -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<version>3.5.0</version>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>shade</goal>
</goals>
<configuration>
<outputFile>../../server/builds/ZernMCLauncher.jar</outputFile>
<transformers>
<transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
<mainClass>${mainClass}</mainClass>
<manifestEntries>
<Implementation-Version>${project.version}</Implementation-Version>
<Implementation-Title>ZernMC Launcher</Implementation-Title>
<Implementation-Vendor>SashegDev</Implementation-Vendor>
<Implementation-Description>Samopisnui Minecraft-launcher. by SashegDev</Implementation-Description>
<Implementation-URL>https://github.com/SashegDev/launcher</Implementation-URL>
</manifestEntries>
</transformer>
</transformers>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
<profiles>
<profile>
<id>global</id>
<activation>
<activeByDefault>true</activeByDefault>
</activation>
<properties>
<launcher.title>ZernMC Launcher</launcher.title>
<server.url>http://87.120.187.36:1582</server.url>
</properties>
</profile>
</profiles>
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>me.sashegdev</groupId>
<artifactId>ZernMCLauncher</artifactId>
<version>${revision}</version>
<packaging>pom</packaging>
<name>ZernMC Launcher Parent</name>
<description>ZernMC Launcher - Multi-module project</description>
<modules>
<module>bootstrap</module>
<module>launcher</module>
</modules>
<properties>
<revision>1.0.14</revision>
<hotfix>2</hotfix>
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.organization.name>ZernMC</project.organization.name>
<project.inceptionYear>2026</project.inceptionYear>
<project.description>ZernMC Launcher - Multi-module project</project.description>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.apache.httpcomponents</groupId>
<artifactId>httpclient</artifactId>
<version>4.5.14</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.15.2</version>
</dependency>
<dependency>
<groupId>com.google.code.gson</groupId>
<artifactId>gson</artifactId>
<version>2.10.1</version>
</dependency>
<dependency>
<groupId>org.json</groupId>
<artifactId>json</artifactId>
<version>20231013</version>
</dependency>
<dependency>
<groupId>org.fusesource.jansi</groupId>
<artifactId>jansi</artifactId>
<version>2.4.1</version>
</dependency>
<dependency>
<groupId>org.jline</groupId>
<artifactId>jline</artifactId>
<version>3.24.1</version>
</dependency>
<dependency>
<groupId>me.tongfei</groupId>
<artifactId>progressbar</artifactId>
<version>0.9.5</version>
</dependency>
<dependency>
<groupId>commons-io</groupId>
<artifactId>commons-io</artifactId>
<version>2.15.1</version>
</dependency>
<!-- JavaFX for Windows -->
<dependency>
<groupId>org.openjfx</groupId>
<artifactId>javafx-controls</artifactId>
<version>23</version>
<classifier>win</classifier>
</dependency>
<dependency>
<groupId>org.openjfx</groupId>
<artifactId>javafx-web</artifactId>
<version>23</version>
<classifier>win</classifier>
</dependency>
<dependency>
<groupId>org.openjfx</groupId>
<artifactId>javafx-graphics</artifactId>
<version>23</version>
<classifier>win</classifier>
</dependency>
<dependency>
<groupId>org.openjfx</groupId>
<artifactId>javafx-base</artifactId>
<version>23</version>
<classifier>win</classifier>
</dependency>
<dependency>
<groupId>org.openjfx</groupId>
<artifactId>javafx-media</artifactId>
<version>23</version>
<classifier>win</classifier>
</dependency>
</dependencies>
</dependencyManagement>
<build>
<plugins>
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>flatten-maven-plugin</artifactId>
<version>1.6.0</version>
<configuration>
<updatePomFile>true</updatePomFile>
<flattenMode>resolveCiFriendliesOnly</flattenMode>
</configuration>
<executions>
<execution>
<id>flatten</id>
<phase>process-resources</phase>
<goals><goal>flatten</goal></goals>
</execution>
<execution>
<id>flatten-clean</id>
<phase>clean</phase>
<goals><goal>clean</goal></goals>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.2.3</version>
</plugin>
<!-- Shade Plugin -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<version>3.5.0</version>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>shade</goal>
</goals>
<configuration>
<outputFile>../../server/builds/ZernMCLauncher.jar</outputFile>
<transformers>
<transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
<mainClass>${mainClass}</mainClass>
<manifestEntries>
<Implementation-Version>${project.version}.${hotfix}</Implementation-Version>
<Implementation-Title>ZernMC Launcher</Implementation-Title>
<Implementation-Vendor>SashegDev</Implementation-Vendor>
<Implementation-Description>Samopisnui Minecraft-launcher. by SashegDev</Implementation-Description>
<Implementation-URL>https://github.com/SashegDev/launcher</Implementation-URL>
</manifestEntries>
</transformer>
</transformers>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
<profiles>
<profile>
<id>global</id>
<activation>
<activeByDefault>true</activeByDefault>
</activation>
<properties>
<launcher.title>ZernMC Launcher</launcher.title>
<server.url>http://87.120.187.36:1582</server.url>
</properties>
</profile>
</profiles>
</project>
+2 -2
View File
@@ -295,7 +295,7 @@ class LoginRequest(BaseModel):
def validate_username(cls, v):
if not re.match(r'^[a-zA-Z0-9_]+$', v):
raise ValueError('Имя пользователя может содержать только буквы, цифры и подчеркивания')
return v.lower()
return v
class RegisterRequest(BaseModel):
username: str = Field(..., min_length=3, max_length=32)
@@ -305,7 +305,7 @@ class RegisterRequest(BaseModel):
def validate_username(cls, v):
if not re.match(r'^[a-zA-Z0-9_]+$', v):
raise ValueError('Имя пользователя может содержать только буквы, цифры и подчеркивания')
return v.lower()
return v
class TokenResponse(BaseModel):
access_token: str
+65 -9
View File
@@ -33,6 +33,7 @@ from playtime import router as playtime_router, init_playtime_db
import asyncio
import hashlib
import uuid
import aiofiles
import mimetypes
@@ -67,6 +68,9 @@ MANUAL_BLOCKED_IPS = set(os.environ.get("BLOCKED_IPS", "").split(",")) - {""} #
# Cache file for blocklist (load once)
BLOCKLIST_CACHE_FILE = Path("data/blocklist_cache.txt")
# Crash reports directory
CRASH_REPORTS_DIR = Path("data/crash_reports")
@asynccontextmanager
async def lifespan(app: FastAPI):
@@ -143,6 +147,7 @@ async def lifespan(app: FastAPI):
BUILDS_DIR.mkdir(exist_ok=True)
PACKS_DIR.mkdir(exist_ok=True)
DATA_DIR.mkdir(exist_ok=True)
CRASH_REPORTS_DIR.mkdir(exist_ok=True)
init_db()
init_friends_db()
@@ -222,6 +227,24 @@ async def lifespan(app: FastAPI):
asyncio.create_task(periodic_sync())
# Background task: auto-detect new ZIP archives in builds/ and register them
async def watch_new_zips():
watch_interval = int(os.environ.get("ZIP_WATCH_INTERVAL", "15"))
while True:
await asyncio.sleep(watch_interval)
try:
before = set(f.name for f in VERSIONS_DIR.iterdir()) if VERSIONS_DIR.exists() else set()
extract_new_format_versions()
after = set(f.name for f in VERSIONS_DIR.iterdir()) if VERSIONS_DIR.exists() else set()
new_versions = after - before
if new_versions:
logger.info(f"New launcher versions detected: {', '.join(sorted(new_versions))}")
generate_launcher_builds_meta()
except Exception as e:
logger.warning(f"ZIP watch error: {e}")
asyncio.create_task(watch_new_zips())
yield
# Cleanup proxy client
@@ -1238,15 +1261,17 @@ async def get_pack_file(pack_name: str, file_path: str, request: Request, curren
# ====================== ЭНДПОИНТЫ ДЛЯ ЛАУНЧЕРА ======================
def get_current_launcher_version() -> str:
"""Get current launcher version from meta system (new format) or build.version (legacy)"""
"""Get current launcher version — prefers build.version (includes hotfix), falls back to extracted versions"""
version_file = BUILDS_DIR / "build.version"
if version_file.exists():
v = version_file.read_text().strip()
if v:
return v
versions = get_launcher_versions()
if versions:
return versions[0]["meta"]["version"]
# Fallback to build.version for legacy
version_file = BUILDS_DIR / "build.version"
if version_file.exists():
return version_file.read_text().strip()
return "1.0.0"
@@ -1562,12 +1587,12 @@ async def get_launcher_version():
"updated_at": datetime.utcnow().isoformat()
}
jar_path = BUILDS_DIR / "ZernMCLauncher.jar"
jar_path = BUILDS_DIR / "zernmclauncher.jar"
if jar_path.exists():
response["download_jar"] = "/launcher/download/jar"
response["jar_size"] = jar_path.stat().st_size
exe_path = BUILDS_DIR / "ZernMCLauncher.exe"
exe_path = BUILDS_DIR / "zernmc.exe"
if exe_path.exists():
response["download_exe"] = "/launcher/download/exe"
response["exe_size"] = exe_path.stat().st_size
@@ -1606,7 +1631,7 @@ async def download_launcher_jar(request: Request = None):
@app.get("/launcher/download/exe")
async def download_launcher_exe(request: Request = None):
"""Download launcher EXE file (Windows)"""
file_path = BUILDS_DIR / "ZernMCLauncher.exe"
file_path = BUILDS_DIR / "zernmc.exe"
if not file_path.exists():
raise HTTPException(404, "EXE file not found")
@@ -1894,6 +1919,33 @@ async def get_launcher_full_info():
return info
@app.post("/launcher/crash-report")
async def receive_crash_report(request: Request):
"""Receive and store launcher crash reports"""
try:
body = await request.json()
except Exception:
raise HTTPException(status_code=400, detail="Invalid JSON")
ip = request.client.host if request.client else "unknown"
body["source_ip"] = ip
body["received_at"] = datetime.utcnow().isoformat()
report_id = datetime.utcnow().strftime("%Y%m%d_%H%M%S") + "_" + uuid.uuid4().hex[:8]
report_path = CRASH_REPORTS_DIR / f"{report_id}.json"
try:
CRASH_REPORTS_DIR.mkdir(parents=True, exist_ok=True)
async with aiofiles.open(report_path, "w", encoding="utf-8") as f:
await f.write(json.dumps(body, indent=2, ensure_ascii=False))
logger.info(f"Crash report saved: {report_path.name} from {ip}")
except Exception as e:
logger.error(f"Failed to save crash report: {e}")
raise HTTPException(status_code=500, detail="Failed to save report")
return {"status": "ok", "id": report_id}
# ====================== НОВОСТИ ======================
NEWS_DIR = Path(__file__).parent / "news"
@@ -2278,11 +2330,15 @@ async def proxy_download(request: Request):
"maven.fabricmc.net",
"meta.fabricmc.net",
"piston-meta.mojang.com",
"piston-data.mojang.com",
"launchermeta.mojang.com",
"resources.download.minecraft.net",
"libraries.minecraft.net",
"maven.minecraftforge.net",
"files.minecraftforge.net",
"maven.neoforged.net",
"repo1.maven.org",
"repo.maven.apache.org",
"api.zernmc.ru",
"api.zernmc.online"
]
@@ -2302,7 +2358,7 @@ async def proxy_download(request: Request):
try:
# Используем streaming response для больших файлов
response = await proxy_client.get(url)
response = await proxy_client.get(url, timeout=300.0)
response.raise_for_status()
# Определяем content-type из ответа или по расширению
+10
View File
@@ -0,0 +1,10 @@
Pack Download Stability Fix
Update
v1.0.13.1
[{"bold":1,"text":"Fixed pack downloads getting stuck on Minecraft/Forge installation","click_action":"none"}]
- All Minecraft downloads now use smart proxy system (like the rest of the launcher)
- Added read timeouts to prevent hanging on slow/unstable connections
- Asset download no longer blocks forever if Mojang CDN stalls
- Forge installer process now has a 10-minute timeout
- Progress bar now updates during Minecraft installation phase
[{"clickable":1,"text":"Download v1.0.13.1","click_action":"open_url","url":"https://zernmc.ru/download"}]
+9
View File
@@ -0,0 +1,9 @@
TODO / ideas backlog
====================
Java Agent + IPC (low priority, overkill now)
- Заменить прямой ProcessBuilder.start() на прокси-класс (-javaagent или -cp прокси)
- Прокси класс: подключается к localhost RPC, ждёт "launch", потом reflection вызывает реальный main
- Даёт: двустороннюю связь лаунчер-игра, контроль жизненного цикла, crash-репорты из процесса игры,
передачу настроек/токенов на лету, патчинг классов через Instrumentation API
- Для ZernMC сейчас оверхед — но запомнить на будущее