Compare commits
22 Commits
929d5a4ad6
...
4acbdedf70
| Author | SHA1 | Date | |
|---|---|---|---|
| 4acbdedf70 | |||
| e962adbb58 | |||
| 22b056614b | |||
| 6024df5093 | |||
| 1307d10e81 | |||
| cffa6519ca | |||
| ce8cf32ddd | |||
| 72c58aced3 | |||
| ffc491c333 | |||
| 2846e3edd1 | |||
| 7ae81b3c91 | |||
| 375b98586d | |||
| 348969e79c | |||
| 15532bf341 | |||
| 424cf9bc25 | |||
| ef7d5edef3 | |||
| d9c527b8db | |||
| f32ac1ef98 | |||
| 6763d0144a | |||
| 37ec2bc342 | |||
| 6a0c59f032 | |||
| 599e9d5e67 |
+2
-2
@@ -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
|
||||
|
||||
@@ -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,16 +162,39 @@ 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);
|
||||
|
||||
if (code != 0 && !GraphicsEnvironment.isHeadless()) {
|
||||
String stderr = errorOutput.toString();
|
||||
SwingUtilities.invokeLater(() -> showCrashDialog(code, stderr));
|
||||
} else {
|
||||
System.exit(code);
|
||||
}
|
||||
}
|
||||
|
||||
private static Path findJava(boolean preferConsole) {
|
||||
String os = System.getProperty("os.name").toLowerCase();
|
||||
@@ -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
@@ -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,6 +10,7 @@ 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";
|
||||
@@ -30,6 +31,8 @@ public class Bootstrap {
|
||||
boolean cliMode = argList.contains("--cli");
|
||||
boolean jfxMode = !cliMode;
|
||||
|
||||
cleanupStaleUpdates();
|
||||
|
||||
String currentVersion = readCurrentVersion();
|
||||
String serverVersion = getServerVersion();
|
||||
|
||||
@@ -38,7 +41,17 @@ public class Bootstrap {
|
||||
|
||||
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");
|
||||
}
|
||||
@@ -50,13 +63,107 @@ public class Bootstrap {
|
||||
}
|
||||
}
|
||||
|
||||
private static void log(String msg) {
|
||||
String entry = "[" + LocalDateTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss")) + "] " + msg;
|
||||
System.out.println(entry);
|
||||
private static void cleanupStaleUpdates() {
|
||||
try (Stream<Path> files = Files.list(baseDir)) {
|
||||
files.filter(p -> p.toString().endsWith(".update"))
|
||||
.forEach(p -> {
|
||||
try {
|
||||
Files.writeString(logDir.resolve("launcher.log"), entry + "\n",
|
||||
StandardOpenOption.CREATE, StandardOpenOption.APPEND);
|
||||
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() {
|
||||
@@ -88,50 +195,25 @@ public class Bootstrap {
|
||||
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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
+14
-6
@@ -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();
|
||||
|
||||
|
||||
+5
@@ -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) {
|
||||
|
||||
+10
-48
@@ -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);
|
||||
}
|
||||
}
|
||||
+262
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+40
-55
@@ -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);
|
||||
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,7 +256,7 @@ 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");
|
||||
@@ -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) + ")");
|
||||
}
|
||||
}
|
||||
}
|
||||
+10
-19
@@ -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,6 +286,8 @@ 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))
|
||||
@@ -299,11 +309,22 @@ public class ZHttpClient {
|
||||
} catch (Exception e) {
|
||||
if (isConnectionError(e)) {
|
||||
directFailCount++;
|
||||
markServiceAsBlocked(url);
|
||||
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;
|
||||
@@ -325,12 +346,20 @@ public class ZHttpClient {
|
||||
return response.body();
|
||||
|
||||
} catch (Exception e) {
|
||||
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; }
|
||||
}
|
||||
}
|
||||
|
||||
throw new IOException("Failed to fetch data: " + url);
|
||||
}
|
||||
|
||||
public static void downloadFileWithSmartProxy(String url, Path target) throws Exception {
|
||||
if (!shouldUseProxyForUrl(url)) {
|
||||
int directRetries = 3;
|
||||
for (int directAttempt = 1; directAttempt <= directRetries; directAttempt++) {
|
||||
try {
|
||||
HttpRequest.Builder requestBuilder = HttpRequest.newBuilder()
|
||||
.uri(URI.create(url))
|
||||
@@ -356,11 +385,23 @@ public class ZHttpClient {
|
||||
} catch (Exception e) {
|
||||
if (isConnectionError(e)) {
|
||||
directFailCount++;
|
||||
markServiceAsBlocked(url);
|
||||
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;
|
||||
}
|
||||
}
|
||||
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;
|
||||
|
||||
@@ -378,6 +419,15 @@ public class ZHttpClient {
|
||||
}
|
||||
|
||||
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; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static String get(String endpoint) throws IOException, InterruptedException {
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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'));
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
+246
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+414
@@ -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);
|
||||
}
|
||||
}
|
||||
+25
-2
@@ -6,7 +6,7 @@
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<groupId>me.sashegdev</groupId>
|
||||
<artifactId>ZernMCLauncher</artifactId>
|
||||
<version>1.0.10</version>
|
||||
<version>${revision}</version>
|
||||
<packaging>pom</packaging>
|
||||
|
||||
<name>ZernMC Launcher Parent</name>
|
||||
@@ -18,6 +18,8 @@
|
||||
</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>
|
||||
@@ -104,6 +106,27 @@
|
||||
|
||||
<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>
|
||||
@@ -127,7 +150,7 @@
|
||||
<transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
|
||||
<mainClass>${mainClass}</mainClass>
|
||||
<manifestEntries>
|
||||
<Implementation-Version>${project.version}</Implementation-Version>
|
||||
<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>
|
||||
|
||||
+2
-2
@@ -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
@@ -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 из ответа или по расширению
|
||||
|
||||
@@ -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"}]
|
||||
@@ -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 сейчас оверхед — но запомнить на будущее
|
||||
Reference in New Issue
Block a user