2 Commits

Author SHA1 Message Date
SashegDev cdc81e4e8d v1.0.16.13 — TSPU/SNI-obkhod: domain selection at login + dynamic mirrors
- add DomainSelector: probe every API candidate at launcher startup, pick
  fastest reachable (primary api.zern.cc, legacy .ru/.online, geo pl/swe);
  integrate into CLI start and JFX network init
- Config.setServerUrl() + persist chosen domain in launcher.properties
- ZHttpClient: default BASE_URL and ZERN_SERVER health-check follow the
  selected domain (runtime failover instead of hardcoded api.zernmc.ru)
- server: LAUNCHER_MIRRORS main=api.zern.cc (primary), legacy .ru/.online,
  geo-pl api.pl.zern.cc, geo-swe api.swe.zern.cc; /launcher/mirrors exposes them
- diag: check all TLD+geo API hosts (api.{pl,ru,swe}.zern.cc, .ru, .online)
  for DNS/TCP/HTTP, known server IPs for all 4 geo nodes
- reverse-proxy geo boxes (pl, swe) -> main:1582 so clients bypass TSPU via
  an unblocked SNI; ru left as-is (VLESS VPN box, not proxying API)
2026-08-18 22:19:55 +00:00
SashegDev 4f5bd5387a v1.0.16.3 — fix install hang at 0% (JDK request timeout not enforced on stalled body, cancel via sendAsync; proxy fallback on timeout), verify client.jar integrity, mod install via smart proxy 2026-08-15 09:31:23 +00:00
40 changed files with 2302 additions and 275 deletions
+1
View File
@@ -20,3 +20,4 @@ packs/
.__pycache__ .__pycache__
.pytest_cache .pytest_cache
.venvtodo.txt .venvtodo.txt
*.bak
@@ -78,6 +78,19 @@ public class Bootstrap {
log("Server version: " + serverVersion); log("Server version: " + serverVersion);
setVersionInfo(currentVersion, serverVersion); setVersionInfo(currentVersion, serverVersion);
if ("unknown".equals(serverVersion)) {
boolean ranDiagnostics = runDiagnosticsIfPossible();
if (ranDiagnostics) {
serverVersion = getServerVersion();
log("Server version after diagnostics: " + serverVersion);
setVersionInfo(currentVersion, serverVersion);
}
if ("unknown".equals(serverVersion) && !argList.contains("--offline")) {
log("Server still unreachable. Launcher will start in offline mode.");
argList.add("--offline");
}
}
loadMirrors(); loadMirrors();
log("Primary server: " + BASE_URL); log("Primary server: " + BASE_URL);
log("Mirrors available: " + (MIRRORS.size() + 1)); log("Mirrors available: " + (MIRRORS.size() + 1));
@@ -125,6 +138,119 @@ public class Bootstrap {
} }
} }
private static boolean runDiagnosticsIfPossible() {
Path diagJar = binDir.resolve("zernmcdiag.jar");
if (!Files.exists(diagJar)) {
log("Diagnostics tool not found: bin/zernmcdiag.jar is missing.");
log("Please download the latest launcher manually from the ZernMC website.");
showMessageUi("ZernMC — нет связи с сервером",
"Не удалось связаться с серверами Zern.\n\n"
+ "Файл диагностики (bin/zernmcdiag.jar) отсутствует.\n"
+ "Скачайте свежий лаунчер вручную с официального сайта ZernMC.");
return false;
}
boolean confirmed;
if (isCliMode) {
confirmed = askCliConfirm();
} else {
confirmed = confirmUi("ZernMC — нет связи с сервером",
"Не удалось связаться с серверами Zern.\nПровести диагностику сети?");
}
if (!confirmed) {
log("Diagnostics declined by user.");
return false;
}
try {
launchDiag(diagJar);
return true;
} catch (Exception e) {
log("Failed to launch diagnostics: " + e.getMessage());
return false;
}
}
private static void launchDiag(Path diagJar) throws Exception {
String os = System.getProperty("os.name").toLowerCase();
Path javaBin = findJava(false);
if (os.contains("windows")) {
Path javawPath = javaBin.resolveSibling("javaw.exe");
if (Files.exists(javawPath)) {
javaBin = javawPath;
}
}
List<String> cmd = new ArrayList<>();
cmd.add(javaBin.toAbsolutePath().toString());
cmd.add("-Dfile.encoding=UTF-8");
cmd.add("-jar");
cmd.add(diagJar.toAbsolutePath().toString());
if (isCliMode) {
cmd.add("--cli");
}
log("Starting diagnostics: " + String.join(" ", cmd));
ProcessBuilder pb = new ProcessBuilder(cmd);
pb.directory(baseDir.toFile());
pb.redirectErrorStream(false);
Process p = pb.start();
drainOutput(p.getInputStream(), "diag");
drainOutput(p.getErrorStream(), "diag-err");
int exit = p.waitFor();
log("Diagnostics finished, exit code: " + exit);
}
private static void drainOutput(InputStream stream, String tag) {
Thread t = new Thread(() -> {
try (BufferedReader reader = new BufferedReader(new InputStreamReader(stream, StandardCharsets.UTF_8))) {
String line;
while ((line = reader.readLine()) != null) {
log("[" + tag + "] " + line);
}
} catch (IOException ignored) {
}
}, "diag-drain-" + tag);
t.setDaemon(true);
t.start();
}
private static boolean askCliConfirm() {
System.out.println("Server unreachable. Run network diagnostics? [y/N]");
try {
BufferedReader reader = new BufferedReader(new InputStreamReader(System.in, StandardCharsets.UTF_8));
String line = reader.readLine();
return line != null && (line.equalsIgnoreCase("y") || line.equalsIgnoreCase("yes"));
} catch (IOException e) {
return false;
}
}
private static boolean confirmUi(String title, String message) {
final boolean[] result = new boolean[1];
try {
SwingUtilities.invokeAndWait(() -> {
int choice = JOptionPane.showConfirmDialog(null, message, title,
JOptionPane.YES_NO_OPTION, JOptionPane.QUESTION_MESSAGE);
result[0] = choice == JOptionPane.YES_OPTION;
});
} catch (Exception e) {
log("UI confirm error: " + e.getMessage());
}
return result[0];
}
private static void showMessageUi(String title, String message) {
try {
SwingUtilities.invokeAndWait(() ->
JOptionPane.showMessageDialog(null, message, title, JOptionPane.INFORMATION_MESSAGE));
} catch (Exception e) {
log("UI message error: " + e.getMessage());
}
}
private static void launchInProcess(String[] args) throws Exception { private static void launchInProcess(String[] args) throws Exception {
ClassLoader parent = Bootstrap.class.getClassLoader(); ClassLoader parent = Bootstrap.class.getClassLoader();
URL[] urls = { getLauncherJar().toUri().toURL() }; URL[] urls = { getLauncherJar().toUri().toURL() };
@@ -172,6 +298,12 @@ public class Bootstrap {
cmd.add(getLauncherJar().toAbsolutePath().toString()); cmd.add(getLauncherJar().toAbsolutePath().toString());
cmd.add("--jfx"); cmd.add("--jfx");
for (String arg : args) {
if (!"--jfx".equals(arg) && !"--cli".equals(arg) && !cmd.contains(arg)) {
cmd.add(arg);
}
}
ProcessBuilder pb = new ProcessBuilder(cmd); ProcessBuilder pb = new ProcessBuilder(cmd);
pb.directory(baseDir.toFile()); pb.directory(baseDir.toFile());
pb.redirectErrorStream(false); pb.redirectErrorStream(false);
+75
View File
@@ -0,0 +1,75 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>me.sashegdev</groupId>
<artifactId>ZernMCLauncher</artifactId>
<version>${revision}</version>
</parent>
<artifactId>zernmc-diag</artifactId>
<packaging>jar</packaging>
<name>ZernMC Network Diagnostics</name>
<description>Standalone network diagnostics utility (DNS, connectivity, report upload)</description>
<dependencies>
<dependency>
<groupId>dnsjava</groupId>
<artifactId>dnsjava</artifactId>
</dependency>
<dependency>
<groupId>org.json</groupId>
<artifactId>json</artifactId>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.10.1</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<version>3.5.0</version>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>shade</goal>
</goals>
<configuration>
<outputFile>../../server/builds/bin/zernmcdiag.jar</outputFile>
<filters>
<filter>
<artifact>dnsjava:dnsjava</artifact>
<excludes>
<exclude>META-INF/services/java.net.spi.InetAddressResolverProvider</exclude>
<exclude>META-INF/services/sun.net.spi.nameservice.NameServiceDescriptor</exclude>
</excludes>
</filter>
</filters>
<transformers>
<transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
<mainClass>me.sashegdev.zernmc.launcher.diag.DiagMain</mainClass>
<manifestEntries>
<Implementation-Version>${project.version}.${hotfix}</Implementation-Version>
<Implementation-Title>ZernMC Network Diagnostics</Implementation-Title>
<Implementation-Vendor>SashegDev</Implementation-Vendor>
</manifestEntries>
</transformer>
</transformers>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>
@@ -0,0 +1,29 @@
package me.sashegdev.zernmc.launcher.diag;
import java.nio.file.Path;
public record DiagCheck(String category, String name, String status, String detail, long latencyMs) {
public boolean ok() {
return "OK".equals(status);
}
public String renderLine() {
StringBuilder sb = new StringBuilder();
if ("OK".equals(status)) {
sb.append(" [OK] ");
} else if ("FAIL".equals(status)) {
sb.append(" [FAIL]");
} else {
sb.append(" [SKIP]");
}
sb.append(' ').append(category).append(" / ").append(name);
if (latencyMs >= 0) {
sb.append(" (").append(latencyMs).append(" ms)");
}
if (detail != null && !detail.isEmpty()) {
sb.append(" -> ").append(detail);
}
return sb.toString();
}
}
@@ -0,0 +1,55 @@
package me.sashegdev.zernmc.launcher.diag;
import java.util.List;
import java.util.Map;
public final class DiagConstants {
public static final String HOST_API = "api.zern.cc";
public static final List<String> HOSTS = List.of(
HOST_API,
"api.zernmc.ru",
"api.zernmc.online",
"api.pl.zern.cc",
"api.swe.zern.cc",
"api.ru.zern.cc"
);
public static final List<String> KNOWN_SERVER_IPS = List.of(
"87.120.187.36", // main (Frankfurt)
"212.22.82.243", // ru geo (Moscow)
"2.26.5.202", // pl geo (Warsaw)
"2.26.55.218" // swe geo (Stockholm)
);
public static final String PRIMARY_API = "https://" + HOST_API;
public static final String DIRECT_API = "http://87.120.187.36:1582";
public static final List<Integer> TLS_PORTS = List.of(443, 80);
public static final int DIRECT_PORT = 1582;
public static final String VERSION_PATH = "/launcher/version";
public static final String CLIENT_IP_PATH = "/launcher/ip";
public static final String DIAG_UPLOAD_PATH = "/diag/upload";
public static final String DOH_GOOGLE_URL = "https://dns.google/resolve?name=%s&type=A";
public static final String DOH_CLOUDFLARE_URL = "https://cloudflare-dns.com/dns-query?name=%s&type=A";
public static final Map<String, String> DNS_PROVIDERS = Map.ofEntries(
Map.entry("Google 8.8.8.8", "8.8.8.8"),
Map.entry("Google 8.8.4.4", "8.8.4.4"),
Map.entry("Cloudflare 1.1.1.1", "1.1.1.1"),
Map.entry("Cloudflare 1.0.0.1", "1.0.0.1"),
Map.entry("Yandex 77.88.8.8", "77.88.8.8"),
Map.entry("Yandex 77.88.8.1", "77.88.8.1"),
Map.entry("OpenDNS 208.67.222.222", "208.67.222.222"),
Map.entry("Quad9 9.9.9.9", "9.9.9.9")
);
public static final int CONNECT_TIMEOUT_MS = 8000;
public static final int READ_TIMEOUT_MS = 8000;
private DiagConstants() {
}
}
@@ -0,0 +1,79 @@
package me.sashegdev.zernmc.launcher.diag;
import java.awt.event.WindowAdapter;
import java.awt.event.WindowEvent;
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.util.Arrays;
import java.util.List;
import javax.swing.SwingUtilities;
import javax.swing.UIManager;
public final class DiagMain {
public static void main(String[] args) {
List<String> argList = Arrays.asList(args);
boolean cliMode = argList.contains("--cli");
Path baseDir = baseDirFromArgs(argList);
if (cliMode) {
runCli(baseDir);
return;
}
SwingUtilities.invokeLater(() -> {
try {
UIManager.setLookAndFeel(UIManager.getSystemLookAndFeelClassName());
} catch (Exception ignored) {
}
DiagnosticsUI ui = new DiagnosticsUI(baseDir);
ui.addWindowListener(new WindowAdapter() {
@Override
public void windowClosed(WindowEvent e) {
System.exit(0);
}
});
ui.setVisible(true);
ui.start();
});
}
private static Path baseDirFromArgs(List<String> args) {
int idx = args.indexOf("--dir");
if (idx >= 0 && idx + 1 < args.size()) {
return Path.of(args.get(idx + 1)).toAbsolutePath();
}
return Path.of("").toAbsolutePath();
}
private static void runCli(Path baseDir) {
NetworkDiagnostics diag = new NetworkDiagnostics(baseDir, System.out::println, null);
Path log = diag.run();
System.out.println("Diagnostics log: " + (log != null ? log.toAbsolutePath() : "n/a"));
System.out.println("Client IP: " + diag.getClientIp());
if (log != null) {
System.out.print("Send report to the Zern server? [y/N]: ");
System.out.flush();
try {
BufferedReader reader = new BufferedReader(new InputStreamReader(System.in, StandardCharsets.UTF_8));
String line = reader.readLine();
if (line != null && (line.equalsIgnoreCase("y") || line.equalsIgnoreCase("yes"))) {
ReportSender.SendResult r = ReportSender.send(log, diag.getClientIp());
System.out.println("Send result: " + (r.ok() ? "OK via " + r.endpoint() : "FAILED (" + r.detail() + ")"));
}
} catch (Exception e) {
System.out.println("Send skipped: " + e.getMessage());
}
}
int exitCode = 0;
for (DiagCheck c : diag.getChecks()) {
if ("FAIL".equals(c.status()) && c.category().equals("HTTP")) {
exitCode = 1;
}
}
System.exit(exitCode);
}
}
@@ -0,0 +1,194 @@
package me.sashegdev.zernmc.launcher.diag;
import java.awt.BorderLayout;
import java.awt.Color;
import java.awt.Desktop;
import java.awt.Dimension;
import java.awt.FlowLayout;
import java.awt.Font;
import java.awt.GridLayout;
import java.nio.file.Files;
import java.nio.file.Path;
import javax.swing.BorderFactory;
import javax.swing.JButton;
import javax.swing.JFrame;
import javax.swing.JLabel;
import javax.swing.JOptionPane;
import javax.swing.JPanel;
import javax.swing.JScrollPane;
import javax.swing.JTextArea;
import javax.swing.SwingWorker;
import javax.swing.border.EmptyBorder;
public final class DiagnosticsUI extends JFrame {
private static final Color BG = new Color(0x15151a);
private static final Color PANEL = new Color(0x1e1e24);
private static final Color TEXT = new Color(0xe8e8ea);
private static final Color ACCENT = new Color(0x5dade2);
private final Path baseDir;
private final JTextArea output = new JTextArea();
private final JButton sendButton = new JButton("Отправить на сервер");
private final JLabel statusLabel = new JLabel("Запуск диагностики...");
private volatile Path diagLog;
private volatile String clientIp = "unknown";
public DiagnosticsUI(Path baseDir) {
this.baseDir = baseDir;
setTitle("ZernMC — Диагностика сети");
setDefaultCloseOperation(DISPOSE_ON_CLOSE);
setSize(760, 560);
setMinimumSize(new Dimension(600, 420));
setLocationRelativeTo(null);
getContentPane().setBackground(BG);
((JPanel) getContentPane()).setBorder(new EmptyBorder(16, 16, 16, 16));
JLabel header = new JLabel("Проверка связи с серверами Zern");
header.setFont(new Font("Segoe UI", Font.BOLD, 17));
header.setForeground(ACCENT);
header.setBorder(new EmptyBorder(0, 0, 10, 0));
output.setEditable(false);
output.setFont(new Font("Consolas", Font.PLAIN, 12));
output.setBackground(PANEL);
output.setForeground(TEXT);
output.setBorder(BorderFactory.createEmptyBorder(8, 8, 8, 8));
JScrollPane scroll = new JScrollPane(output);
scroll.setBorder(BorderFactory.createLineBorder(PANEL));
statusLabel.setForeground(TEXT);
statusLabel.setBorder(new EmptyBorder(8, 0, 0, 0));
JButton openButton = new JButton("Открыть папку с логом");
JButton closeButton = new JButton("Закрыть");
sendButton.setEnabled(false);
JPanel buttons = new JPanel(new FlowLayout(FlowLayout.RIGHT, 8, 0));
buttons.setOpaque(false);
buttons.add(openButton);
buttons.add(sendButton);
buttons.add(closeButton);
JPanel south = new JPanel(new BorderLayout());
south.setOpaque(false);
south.add(statusLabel, BorderLayout.NORTH);
south.add(buttons, BorderLayout.SOUTH);
setLayout(new BorderLayout());
add(header, BorderLayout.NORTH);
add(scroll, BorderLayout.CENTER);
add(south, BorderLayout.SOUTH);
openButton.addActionListener(e -> openFolder());
closeButton.addActionListener(e -> dispose());
sendButton.addActionListener(e -> sendReport());
}
public void start() {
SwingWorker<Void, String> worker = new SwingWorker<>() {
@Override
protected Void doInBackground() {
publish("\u2014 Сервер Zern недоступен, выполняем диагностику \u2014");
NetworkDiagnostics diag = new NetworkDiagnostics(baseDir,
this::publish,
c -> publish(renderCheck(c)));
diagLog = diag.run();
clientIp = diag.getClientIp();
publish("Диагностика завершена. Лог: " + (diagLog != null ? diagLog.toAbsolutePath() : "n/a"));
return null;
}
@Override
protected void process(java.util.List<String> chunks) {
for (String line : chunks) {
appendLine(line);
}
}
@Override
protected void done() {
sendButton.setEnabled(diagLog != null);
statusLabel.setText("Готово. Можете отправить лог на сервер или сохранить его.");
}
};
worker.execute();
}
private static String renderCheck(DiagCheck c) {
String prefix = switch (c.status()) {
case "OK" -> "[OK] ";
case "FAIL" -> "[FAIL]";
default -> "[SKIP]";
};
StringBuilder sb = new StringBuilder(prefix).append(' ').append(c.category()).append(" / ").append(c.name());
if (c.latencyMs() >= 0) {
sb.append(" (").append(c.latencyMs()).append(" ms)");
}
if (c.detail() != null && !c.detail().isEmpty()) {
sb.append(" -> ").append(c.detail());
}
return sb.toString();
}
private void appendLine(String line) {
output.append(line);
if (!line.endsWith("\n")) {
output.append("\n");
}
output.setCaretPosition(output.getDocument().getLength());
}
private void openFolder() {
try {
Path dir = diagLog != null ? diagLog.getParent() : baseDir;
if (dir != null && Files.exists(dir)) {
Desktop.getDesktop().open(dir.toFile());
} else {
JOptionPane.showMessageDialog(this, "Папка ещё не создана.");
}
} catch (Exception e) {
JOptionPane.showMessageDialog(this, "Не удалось открыть папку: " + e.getMessage());
}
}
private void sendReport() {
if (diagLog == null) {
return;
}
sendButton.setEnabled(false);
statusLabel.setText("Отправка отчёта на сервер...");
SwingWorker<ReportSender.SendResult, Void> worker = new SwingWorker<>() {
@Override
protected ReportSender.SendResult doInBackground() throws Exception {
return ReportSender.send(diagLog, clientIp);
}
@Override
protected void done() {
sendButton.setEnabled(true);
try {
ReportSender.SendResult r = get();
if (r.ok()) {
statusLabel.setText("Отчёт отправлен: " + r.endpoint());
JOptionPane.showMessageDialog(DiagnosticsUI.this,
"Отчёт успешно отправлен на сервер Zern.",
"Отправлено", JOptionPane.INFORMATION_MESSAGE);
} else {
statusLabel.setText("Не удалось отправить отчёт.");
JOptionPane.showMessageDialog(DiagnosticsUI.this,
"Не удалось отправить отчёт на сервер.\n"
+ "Сохраните лог и отправьте его вручную:\n" + diagLog.toAbsolutePath()
+ "\n\nОшибка: " + r.detail(),
"Ошибка отправки", JOptionPane.WARNING_MESSAGE);
}
} catch (Exception e) {
statusLabel.setText("Ошибка отправки: " + e.getMessage());
}
}
};
worker.execute();
}
}
@@ -0,0 +1,128 @@
package me.sashegdev.zernmc.launcher.diag;
import java.net.InetAddress;
import java.net.URL;
import java.net.URLConnection;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import org.json.JSONArray;
import org.json.JSONObject;
import org.xbill.DNS.ARecord;
import org.xbill.DNS.Lookup;
import org.xbill.DNS.Record;
import org.xbill.DNS.SimpleResolver;
import org.xbill.DNS.Type;
public final class DnsResolver {
private DnsResolver() {
}
public static List<String> resolveSystem(String host) {
try {
InetAddress[] addrs = InetAddress.getAllByName(host);
List<String> ips = new ArrayList<>();
for (InetAddress a : addrs) {
String ip = a.getHostAddress();
if (ip != null && !ips.contains(ip)) {
ips.add(ip);
}
}
return ips;
} catch (Exception e) {
return List.of();
}
}
public static List<String> resolveDoHGoogle(String host) {
try {
String url = String.format(DiagConstants.DOH_GOOGLE_URL, host);
String body = doGet(url, "application/json");
JSONObject json = new JSONObject(body);
JSONArray answers = json.optJSONArray("Answer");
if (answers == null) {
return List.of();
}
List<String> ips = new ArrayList<>();
for (int i = 0; i < answers.length(); i++) {
JSONObject a = answers.optJSONObject(i);
if (a != null && a.optInt("type", 0) == 1) {
String data = a.optString("data", null);
if (data != null && !ips.contains(data)) {
ips.add(data);
}
}
}
return ips;
} catch (Exception e) {
return List.of();
}
}
public static List<String> resolveDoHCloudflare(String host) {
try {
String url = String.format(DiagConstants.DOH_CLOUDFLARE_URL, host);
String body = doGet(url, "application/dns-json");
JSONObject json = new JSONObject(body);
JSONArray answers = json.optJSONArray("Answer");
if (answers == null) {
return List.of();
}
List<String> ips = new ArrayList<>();
for (int i = 0; i < answers.length(); i++) {
JSONObject a = answers.optJSONObject(i);
if (a != null && a.optInt("type", 0) == 1) {
String data = a.optString("data", null);
if (data != null && !ips.contains(data)) {
ips.add(data);
}
}
}
return ips;
} catch (Exception e) {
return List.of();
}
}
public static List<String> resolveUdp(String providerIp, String host) {
try {
SimpleResolver resolver = new SimpleResolver(providerIp);
resolver.setTimeout(Duration.ofSeconds(5));
Lookup lookup = new Lookup(org.xbill.DNS.Name.fromString(host + "."), Type.A);
lookup.setResolver(resolver);
lookup.setCache(null);
Record[] records = lookup.run();
if (records == null) {
return List.of();
}
List<String> ips = new ArrayList<>();
for (Record r : records) {
if (r instanceof ARecord a) {
String ip = a.getAddress().getHostAddress();
if (ip != null && !ips.contains(ip)) {
ips.add(ip);
}
}
}
return ips;
} catch (Exception e) {
return List.of();
}
}
private static String doGet(String url, String accept) throws Exception {
URLConnection conn = new URL(url).openConnection();
conn.setConnectTimeout(DiagConstants.CONNECT_TIMEOUT_MS);
conn.setReadTimeout(DiagConstants.READ_TIMEOUT_MS);
conn.setRequestProperty("Accept", accept);
conn.setRequestProperty("User-Agent", "ZernMC-Diag");
conn.connect();
byte[] buf;
try (var in = conn.getInputStream()) {
buf = in.readAllBytes();
}
return new String(buf, java.nio.charset.StandardCharsets.UTF_8);
}
}
@@ -0,0 +1,58 @@
package me.sashegdev.zernmc.launcher.diag;
import java.io.InputStream;
import java.net.HttpURLConnection;
import java.net.InetSocketAddress;
import java.net.Socket;
import java.net.URI;
import java.nio.charset.StandardCharsets;
public final class NetProbe {
public record TcpResult(boolean ok, long latencyMs, String detail) {
}
public record HttpResult(boolean ok, int status, long latencyMs, String bodySnippet, String detail) {
}
private NetProbe() {
}
public static TcpResult tcpConnect(String host, int port, int timeoutMs) {
long t0 = System.currentTimeMillis();
try (Socket socket = new Socket()) {
socket.connect(new InetSocketAddress(host, port), timeoutMs);
return new TcpResult(true, System.currentTimeMillis() - t0, host + ":" + port);
} catch (Exception e) {
return new TcpResult(false, System.currentTimeMillis() - t0, host + ":" + port + " -> " + e.getMessage());
}
}
public static HttpResult httpGet(String url, int timeoutMs) {
long t0 = System.currentTimeMillis();
HttpURLConnection conn = null;
try {
conn = (HttpURLConnection) URI.create(url).toURL().openConnection();
conn.setConnectTimeout(timeoutMs);
conn.setReadTimeout(timeoutMs);
conn.setRequestProperty("User-Agent", "ZernMC-Diag/1.0");
conn.setInstanceFollowRedirects(false);
int status = conn.getResponseCode();
InputStream in = status >= 400 ? conn.getErrorStream() : conn.getInputStream();
String snippet = "";
if (in != null) {
byte[] bytes = in.readAllBytes();
String body = new String(bytes, StandardCharsets.UTF_8).trim();
snippet = body.length() > 200 ? body.substring(0, 200) + "..." : body;
}
boolean ok = status >= 200 && status < 400;
return new HttpResult(ok, status, System.currentTimeMillis() - t0, snippet, "HTTP " + status);
} catch (Exception e) {
return new HttpResult(false, 0, System.currentTimeMillis() - t0, "", e.getMessage());
} finally {
if (conn != null) {
conn.disconnect();
}
}
}
}
@@ -0,0 +1,263 @@
package me.sashegdev.zernmc.launcher.diag;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.Date;
import java.util.List;
import java.util.concurrent.Callable;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.function.Consumer;
import org.json.JSONObject;
public final class NetworkDiagnostics {
private final Path baseDir;
private final Consumer<String> onLine;
private final Consumer<DiagCheck> onCheck;
private final List<DiagCheck> checks = new CopyOnWriteArrayList<>();
private final StringBuilder report = new StringBuilder();
private String clientIp = "unknown";
public NetworkDiagnostics(Path baseDir, Consumer<String> onLine, Consumer<DiagCheck> onCheck) {
this.baseDir = baseDir;
this.onLine = onLine;
this.onCheck = onCheck;
}
public List<DiagCheck> getChecks() {
return checks;
}
public String getClientIp() {
return clientIp;
}
public Path run() {
checks.clear();
report.setLength(0);
report.append("=== ZernMC Network Diagnostics Report ===\n");
report.append("Timestamp: ").append(timestamp()).append('\n');
report.append("Base dir: ").append(baseDir.toAbsolutePath()).append('\n');
report.append("Zern server known IP(s): ").append(String.join(", ", DiagConstants.KNOWN_SERVER_IPS)).append('\n');
report.append("Primary API: ").append(DiagConstants.PRIMARY_API).append('\n');
report.append("Direct API: ").append(DiagConstants.DIRECT_API).append('\n');
runDnsChecks();
runConnectivityChecks();
runHttpChecks();
clientIp = determineClientIp();
int ok = 0, fail = 0, skip = 0;
for (DiagCheck c : checks) {
switch (c.status()) {
case "OK" -> ok++;
case "FAIL" -> fail++;
default -> skip++;
}
}
report.append("=== Summary ===\n");
report.append("OK: ").append(ok).append(", FAIL: ").append(fail).append(", SKIP: ").append(skip).append('\n');
report.append("--- JSON ---\n");
JSONObject json = new JSONObject();
json.put("client_ip", clientIp);
json.put("timestamp", timestamp());
JSONObject jsonChecks = new JSONObject();
for (DiagCheck c : checks) {
JSONObject j = new JSONObject();
j.put("status", c.status());
j.put("detail", c.detail() == null ? "" : c.detail());
j.put("latency_ms", c.latencyMs());
jsonChecks.put(c.category() + "/" + c.name(), j);
}
json.put("checks", jsonChecks);
report.append(json.toString(2)).append('\n');
return writeLog();
}
private Path writeLog() {
try {
Files.createDirectories(baseDir);
Path log = baseDir.resolve("diag.log");
Files.writeString(log, report.toString(), StandardCharsets.UTF_8);
if (onLine != null) {
onLine.accept("\nDiagnostic log written to: " + log.toAbsolutePath());
}
return log;
} catch (IOException e) {
if (onLine != null) {
onLine.accept("Failed to write diag.log: " + e.getMessage());
}
return null;
}
}
private void runDnsChecks() {
report.append("\n=== DNS resolution ===\n");
for (String host : DiagConstants.HOSTS) {
report.append("Host: ").append(host).append('\n');
submit(resolveToCheck(host, "system", DnsResolver.resolveSystem(host)));
}
List<Callable<DiagCheck>> tasks = new ArrayList<>();
List<String> taskKeys = new ArrayList<>();
for (String host : DiagConstants.HOSTS) {
tasks.add(() -> resolveToCheck(host, "DoH Google", DnsResolver.resolveDoHGoogle(host)));
taskKeys.add("DoH Google/" + host);
tasks.add(() -> resolveToCheck(host, "DoH Cloudflare", DnsResolver.resolveDoHCloudflare(host)));
taskKeys.add("DoH Cloudflare/" + host);
for (String provider : DiagConstants.DNS_PROVIDERS.keySet()) {
String providerIp = DiagConstants.DNS_PROVIDERS.get(provider);
tasks.add(() -> resolveToCheck(host, "UDP " + provider, DnsResolver.resolveUdp(providerIp, host)));
taskKeys.add("UDP " + provider + "/" + host);
}
}
ExecutorService pool = Executors.newFixedThreadPool(Math.min(8, tasks.size()), r -> {
Thread t = new Thread(r, "diag-dns");
t.setDaemon(true);
return t;
});
try {
List<Future<DiagCheck>> futures = new ArrayList<>();
for (Callable<DiagCheck> task : tasks) {
futures.add(pool.submit(task));
}
int idx = 0;
for (Future<DiagCheck> f : futures) {
String key = taskKeys.get(idx);
try {
DiagCheck c = f.get(20, TimeUnit.SECONDS);
submit(c);
} catch (Exception e) {
submit(new DiagCheck("DNS", key, "FAIL", "query error: " + e.getMessage(), -1));
}
idx++;
}
} finally {
pool.shutdownNow();
}
}
static DiagCheck resolveToCheck(String host, String source, List<String> ips) {
if (ips.isEmpty()) {
return new DiagCheck("DNS", host + " / " + source, "FAIL", "no A records", -1);
}
boolean matchesKnown = false;
for (String ip : ips) {
if (DiagConstants.KNOWN_SERVER_IPS.contains(ip)) {
matchesKnown = true;
break;
}
}
if (matchesKnown) {
return new DiagCheck("DNS", host + " / " + source, "OK", String.join(", ", ips), -1);
}
String detail = "resolved to " + String.join(", ", ips) + " (DIFFERENT from known " + String.join(", ", DiagConstants.KNOWN_SERVER_IPS) + ")";
return new DiagCheck("DNS", host + " / " + source, "FAIL", detail, -1);
}
private void runConnectivityChecks() {
report.append("\n=== Connectivity ===\n");
for (String ip : DiagConstants.KNOWN_SERVER_IPS) {
for (int port : DiagConstants.TLS_PORTS) {
submit(tcpToCheck(ip, port));
}
}
submit(tcpToCheck("87.120.187.36", DiagConstants.DIRECT_PORT));
List<String> systemIps = DnsResolver.resolveSystem(DiagConstants.HOST_API);
for (String ip : systemIps) {
if (!DiagConstants.KNOWN_SERVER_IPS.contains(ip)) {
submit(tcpToCheck(ip, 443));
}
}
}
private DiagCheck tcpToCheck(String host, int port) {
NetProbe.TcpResult r = NetProbe.tcpConnect(host, port, DiagConstants.CONNECT_TIMEOUT_MS);
return new DiagCheck("TCP", host + ":" + port, r.ok() ? "OK" : "FAIL", r.detail(), r.latencyMs());
}
private void runHttpChecks() {
report.append("\n=== HTTP ===\n");
for (String host : DiagConstants.HOSTS) {
submit(httpToCheck("https://" + host + DiagConstants.VERSION_PATH));
}
submit(httpToCheck(DiagConstants.DIRECT_API + DiagConstants.VERSION_PATH));
}
private DiagCheck httpToCheck(String url) {
NetProbe.HttpResult r = NetProbe.httpGet(url, DiagConstants.READ_TIMEOUT_MS);
String detail = r.ok() ? r.detail() + (r.bodySnippet().isEmpty() ? "" : " body=" + r.bodySnippet()) : r.detail();
return new DiagCheck("HTTP", url, r.ok() ? "OK" : "FAIL", detail, r.latencyMs());
}
private String determineClientIp() {
String ip = null;
NetProbe.HttpResult direct = NetProbe.httpGet(DiagConstants.DIRECT_API + DiagConstants.CLIENT_IP_PATH, DiagConstants.READ_TIMEOUT_MS);
if (direct.ok()) {
ip = parseIp(direct.bodySnippet());
}
if (ip == null) {
NetProbe.HttpResult primary = NetProbe.httpGet(DiagConstants.PRIMARY_API + DiagConstants.CLIENT_IP_PATH, DiagConstants.READ_TIMEOUT_MS);
if (primary.ok()) {
ip = parseIp(primary.bodySnippet());
}
}
String result = ip != null ? ip : "unknown";
submit(new DiagCheck("INFO", "client public IP", ip != null ? "OK" : "FAIL", ip != null ? ip : "could not determine", -1));
report.append("=== Client IP ===\nClient public IP: ").append(result).append('\n');
return result;
}
static String parseIp(String snippet) {
if (snippet == null) {
return null;
}
try {
int start = snippet.indexOf("\"ip\"");
if (start < 0) {
return null;
}
int colon = snippet.indexOf(':', start);
int q1 = snippet.indexOf('"', colon);
int q2 = snippet.indexOf('"', q1 + 1);
if (q1 < 0 || q2 < 0) {
return null;
}
String ip = snippet.substring(q1 + 1, q2);
return ip.matches("[\\d.]+") ? ip : null;
} catch (Exception e) {
return null;
}
}
private void submit(DiagCheck c) {
checks.add(c);
report.append(c.renderLine()).append('\n');
if (onCheck != null) {
onCheck.accept(c);
}
if (onLine != null) {
onLine.accept(c.renderLine());
}
}
private static String timestamp() {
return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(new Date());
}
}
@@ -0,0 +1,69 @@
package me.sashegdev.zernmc.launcher.diag;
import java.io.IOException;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.URI;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.List;
public final class ReportSender {
public record SendResult(boolean ok, String endpoint, String detail) {
}
private ReportSender() {
}
public static String buildFilename(String clientIp) {
String ts = new SimpleDateFormat("yyyyMMdd_HHmmss").format(new Date());
String ip = (clientIp == null || clientIp.isBlank()) ? "unknown" : clientIp;
return "diag_" + ts + "_" + ip + ".log";
}
public static SendResult send(Path logFile, String clientIp) throws IOException {
String content = Files.readString(logFile, StandardCharsets.UTF_8);
String name = buildFilename(clientIp);
String encoded = URLEncoder.encode(name, StandardCharsets.UTF_8);
List<String> endpoints = List.of(
DiagConstants.PRIMARY_API + DiagConstants.DIAG_UPLOAD_PATH + "?name=" + encoded,
DiagConstants.DIRECT_API + DiagConstants.DIAG_UPLOAD_PATH + "?name=" + encoded
);
String lastDetail = null;
for (String endpoint : endpoints) {
try {
HttpURLConnection conn = (HttpURLConnection) URI.create(endpoint).toURL().openConnection();
conn.setRequestMethod("POST");
conn.setConnectTimeout(DiagConstants.CONNECT_TIMEOUT_MS);
conn.setReadTimeout(DiagConstants.READ_TIMEOUT_MS);
conn.setDoOutput(true);
conn.setRequestProperty("Content-Type", "text/plain; charset=utf-8");
conn.setRequestProperty("User-Agent", "ZernMC-Diag/1.0");
byte[] body = content.getBytes(StandardCharsets.UTF_8);
conn.setFixedLengthStreamingMode(body.length);
try (OutputStream out = conn.getOutputStream()) {
out.write(body);
}
int status = conn.getResponseCode();
boolean ok = status >= 200 && status < 300;
String detail = "HTTP " + status;
conn.disconnect();
return new SendResult(ok, endpoint, detail);
} catch (Exception e) {
lastDetail = e.getMessage();
}
}
return new SendResult(false, lastEndpointUsed(endpoints), lastDetail != null ? lastDetail : "all endpoints failed");
}
private static String lastEndpointUsed(List<String> endpoints) {
return endpoints.get(endpoints.size() - 1);
}
}
@@ -0,0 +1,35 @@
package me.sashegdev.zernmc.launcher.diag;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
class DiagCheckTest {
@Test
void renderLine_ok() {
DiagCheck c = new DiagCheck("DNS", "api.zernmc.ru / system", "OK", "87.120.187.36", 12);
String line = c.renderLine();
assertTrue(line.contains("[OK]"));
assertTrue(line.contains("api.zernmc.ru / system"));
assertTrue(line.contains("12 ms"));
assertTrue(line.contains("87.120.187.36"));
}
@Test
void renderLine_fail() {
DiagCheck c = new DiagCheck("TCP", "1.2.3.4:443", "FAIL", "connection refused", -1);
String line = c.renderLine();
assertTrue(line.contains("[FAIL]"));
assertFalse(line.contains("ms)"));
assertTrue(line.contains("connection refused"));
}
@Test
void ok_returnsStatus() {
assertTrue(new DiagCheck("a", "b", "OK", null, -1).ok());
assertFalse(new DiagCheck("a", "b", "FAIL", null, -1).ok());
}
}
@@ -0,0 +1,52 @@
package me.sashegdev.zernmc.launcher.diag;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
class DnsResolverTest {
@Test
void resolveToCheck_emptyIsFail() {
DiagCheck c = NetworkDiagnostics.resolveToCheck("api.zernmc.ru", "Google 8.8.8.8", List.of());
assertEquals("FAIL", c.status());
assertEquals("no A records", c.detail());
}
@Test
void resolveToCheck_knownIpIsOk() {
DiagCheck c = NetworkDiagnostics.resolveToCheck("api.zernmc.ru", "system", List.of("87.120.187.36"));
assertEquals("OK", c.status());
assertTrue(c.detail().contains("87.120.187.36"));
}
@Test
void resolveToCheck_unknownIpIsFail() {
DiagCheck c = NetworkDiagnostics.resolveToCheck("api.zernmc.ru", "system", List.of("1.2.3.4"));
assertEquals("FAIL", c.status());
assertTrue(c.detail().contains("DIFFERENT from known"));
assertTrue(c.detail().contains("1.2.3.4"));
}
@Test
void resolveToCheck_multipleIpsOneKnown() {
DiagCheck c = NetworkDiagnostics.resolveToCheck("zern.cc", "system", List.of("1.2.3.4", "87.120.187.36"));
assertEquals("OK", c.status());
}
@Test
void parseIp_valid() {
assertEquals("1.2.3.4", NetworkDiagnostics.parseIp("{\"ip\":\"1.2.3.4\"}"));
}
@Test
void parseIp_invalidReturnsNull() {
assertEquals(null, NetworkDiagnostics.parseIp("not json"));
assertEquals(null, NetworkDiagnostics.parseIp("{\"other\":\"x\"}"));
assertEquals(null, NetworkDiagnostics.parseIp(null));
assertEquals(null, NetworkDiagnostics.parseIp("{\"ip\":\"not-an-ip\"}"));
}
}
@@ -0,0 +1,24 @@
package me.sashegdev.zernmc.launcher.diag;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertTrue;
class ReportSenderTest {
@Test
void buildFilename_containsTimestampAndIp() {
String name = ReportSender.buildFilename("1.2.3.4");
assertTrue(name.matches("diag_\\d{8}_\\d{6}_[\\d.]+[.]log"), name);
assertTrue(name.contains("1.2.3.4"));
}
@Test
void buildFilename_unknownIp() {
String name = ReportSender.buildFilename(null);
assertTrue(name.contains("unknown"), name);
assertTrue(name.matches("diag_\\d{8}_\\d{6}_unknown[.]log"), name);
String blank = ReportSender.buildFilename(" ");
assertTrue(blank.contains("unknown"), blank);
}
}
@@ -37,6 +37,11 @@ public class Main {
boolean jfxMode = argList.contains("--jfx"); boolean jfxMode = argList.contains("--jfx");
boolean cliMode = argList.contains("--cli"); boolean cliMode = argList.contains("--cli");
if (argList.contains("--offline")) {
System.setProperty("zernmc.offline", "true");
LauncherLogger.info("Offline mode requested (--offline)");
}
if (jfxMode) { if (jfxMode) {
launchJFX(); launchJFX();
return; return;
@@ -65,6 +70,7 @@ public class Main {
} }
private static void startCLI() throws IOException { private static void startCLI() throws IOException {
DomainSelector.selectBest();
ZHttpClient.checkAllServicesOnStartup(true); ZHttpClient.checkAllServicesOnStartup(true);
System.out.println(ZAnsi.cyan("Checking authorization...")); System.out.println(ZAnsi.cyan("Checking authorization..."));
@@ -88,7 +88,7 @@ public class LauncherAPI {
} }
return ApiResponse.success(mcVersions); return ApiResponse.success(mcVersions);
} catch (Exception e) { } catch (Exception e) {
System.out.println("[API] MC versions fetch failed: " + e.getMessage()); LauncherLogger.warn("[API] MC versions fetch failed: " + e.getMessage());
} }
return ApiResponse.error("Failed to load Minecraft versions"); return ApiResponse.error("Failed to load Minecraft versions");
} }
@@ -136,7 +136,7 @@ public class LauncherAPI {
} }
return ApiResponse.success(versions); return ApiResponse.success(versions);
} catch (Exception e) { } catch (Exception e) {
System.out.println("[API] Loader versions fetch failed: " + e.getMessage()); LauncherLogger.warn("[API] Loader versions fetch failed: " + e.getMessage());
return ApiResponse.error("Failed to load loader versions"); return ApiResponse.error("Failed to load loader versions");
} }
} }
@@ -26,10 +26,15 @@ public class AuthService {
return ApiResponse.error(result.error != null ? result.error : "Registration failed"); return ApiResponse.error(result.error != null ? result.error : "Registration failed");
} catch (Exception e) { } catch (Exception e) {
String msg = e.getMessage(); String msg = e.getMessage();
if (msg != null && msg.contains("HTTP 409")) { if (msg != null) {
return ApiResponse.error("Username already taken"); java.util.regex.Matcher m = java.util.regex.Pattern.compile("HTTP (\\d{3})").matcher(msg);
if (m.find()) {
int code = Integer.parseInt(m.group(1));
String body = msg.substring(m.end()).trim();
return ApiResponse.error(AuthManager.friendlyHttpError(code, body));
}
} }
return ApiResponse.error("Registration error: " + msg); return ApiResponse.error(AuthManager.friendlyConnectionError(e));
} }
} }
@@ -40,9 +45,9 @@ public class AuthService {
LoginResult loginResult = new LoginResult(AuthManager.getUsername(), AuthManager.getAccessToken()); LoginResult loginResult = new LoginResult(AuthManager.getUsername(), AuthManager.getAccessToken());
return ApiResponse.success(loginResult); return ApiResponse.success(loginResult);
} }
return ApiResponse.error(result.error != null ? result.error : "Invalid login or password"); return ApiResponse.error(result.error != null ? result.error : "Неверный логин или пароль");
} catch (Exception e) { } catch (Exception e) {
return ApiResponse.error("Auth error: " + e.getMessage()); return ApiResponse.error(AuthManager.friendlyConnectionError(e));
} }
} }
@@ -24,7 +24,7 @@ public class LaunchService {
static { static {
Runtime.getRuntime().addShutdownHook(new Thread(() -> { Runtime.getRuntime().addShutdownHook(new Thread(() -> {
System.out.println("[LAUNCH] Shutting down all running processes..."); LauncherLogger.info("[LAUNCH] Shutting down all running processes...");
runningProcesses.values().forEach(p -> { runningProcesses.values().forEach(p -> {
try { try {
p.destroy(); p.destroy();
@@ -67,13 +67,44 @@ public class LaunchService {
LauncherLogger.info("Launching: " + instanceName + " (serverPack=" + instance.isServerPack() + ")"); LauncherLogger.info("Launching: " + instanceName + " (serverPack=" + instance.isServerPack() + ")");
long t0 = System.currentTimeMillis();
LaunchCommandBuilder builder = new LaunchCommandBuilder(instance);
LaunchOptions options = createOptions(); LaunchOptions options = createOptions();
options.setUsername(AuthManager.getUsername()); options.setUsername(AuthManager.getUsername());
options.setAccessToken(AuthManager.getAccessToken()); options.setAccessToken(AuthManager.getAccessToken());
options.setUuid(AuthManager.getUuid()); options.setUuid(AuthManager.getUuid());
return launchProcess(instance, instanceName, options);
} catch (Exception e) {
LauncherLogger.error("Launch error for " + instanceName, e);
return ApiResponse.error("Launch error: " + e.getMessage());
}
}
public ApiResponse<ProcessInfo> launchOffline(String instanceName, String nickname) {
try {
Instance instance = InstanceManager.getInstance(instanceName);
if (instance == null) {
return ApiResponse.error("Pack not found: " + instanceName);
}
LauncherLogger.info("Offline launch: " + instanceName);
String safeName = (nickname == null || nickname.isBlank()) ? "Player" : nickname.trim();
LaunchOptions options = createOptions();
options.setUsername(safeName);
options.setAccessToken("0");
options.setUuid(java.util.UUID.nameUUIDFromBytes(
("OfflinePlayer:" + safeName).getBytes(java.nio.charset.StandardCharsets.UTF_8)).toString());
return launchProcess(instance, instanceName, options);
} catch (Exception e) {
LauncherLogger.error("Offline launch error for " + instanceName, e);
return ApiResponse.error("Offline launch error: " + e.getMessage());
}
}
private ApiResponse<ProcessInfo> launchProcess(Instance instance, String instanceName, LaunchOptions options) {
try {
long t0 = System.currentTimeMillis();
LaunchCommandBuilder builder = new LaunchCommandBuilder(instance);
List<String> command = builder.build(options); List<String> command = builder.build(options);
LauncherLogger.info("Launch: command built in " + (System.currentTimeMillis() - t0) + " ms"); LauncherLogger.info("Launch: command built in " + (System.currentTimeMillis() - t0) + " ms");
LauncherLogger.info("Generated command for " + instanceName + ":"); LauncherLogger.info("Generated command for " + instanceName + ":");
@@ -113,17 +113,62 @@ public class AuthManager {
saveSession(); saveSession();
userInfo = fetchUserInfo(); userInfo = fetchUserInfo();
return AuthResult.ok(); return AuthResult.ok();
} else if (resp.statusCode() == 422) {
return AuthResult.fail("Validation error: " + extractError(resp.body()));
} else { } else {
return AuthResult.fail(extractError(resp.body())); return AuthResult.fail(friendlyHttpError(resp.statusCode(), resp.body()));
} }
} catch (Exception e) { } catch (Exception e) {
e.printStackTrace(); LauncherLogger.warn("authRequest failed (" + endpoint + "): " + e.getMessage());
return AuthResult.fail("Connection error: " + e.getMessage()); return AuthResult.fail(friendlyConnectionError(e));
} }
} }
/**
* Maps an HTTP status code + raw body to a short, human-readable error message
* (in Russian) suitable for showing directly to the user.
*/
public static String friendlyHttpError(int statusCode, String body) {
switch (statusCode) {
case 401:
case 403:
return "Неверный логин или пароль";
case 404:
return "Сервис временно недоступен, попробуйте позже";
case 409:
return "Логин уже занят";
case 422:
return "Некорректные данные: " + extractError(body);
case 429:
return "Слишком много попыток. Подождите немного.";
default:
if (statusCode >= 500) {
return "Сервер временно недоступен, попробуйте позже";
}
}
String detail = extractError(body);
if (detail.isEmpty() || detail.startsWith("<")) {
return "Ошибка сервера (код " + statusCode + ")";
}
if (detail.length() > 120) {
detail = detail.substring(0, 117) + "...";
}
return detail;
}
/**
* Maps a network/IO exception to a short, human-readable message.
*/
public static String friendlyConnectionError(Exception e) {
String m = e == null ? "" : String.valueOf(e.getMessage()).toLowerCase();
if (e instanceof java.net.SocketTimeoutException
|| e instanceof java.net.ConnectException
|| e instanceof java.net.UnknownHostException
|| m.contains("timeout") || m.contains("connect") || m.contains("unknownhost")
|| m.contains("refused")) {
return "Нет соединения с сервером. Проверьте интернет.";
}
return "Ошибка соединения с сервером. Попробуйте позже.";
}
public static void logout() { public static void logout() {
if (session != null && session.refreshToken != null) { if (session != null && session.refreshToken != null) {
try { try {
@@ -1,5 +1,7 @@
package me.sashegdev.zernmc.launcher.minecraft; package me.sashegdev.zernmc.launcher.minecraft;
import me.sashegdev.zernmc.launcher.utils.LauncherLogger;
import com.google.gson.Gson; import com.google.gson.Gson;
import com.google.gson.GsonBuilder; import com.google.gson.GsonBuilder;
import java.io.IOException; import java.io.IOException;
@@ -142,7 +144,7 @@ public class Instance {
this.serverPackName = meta.serverPackName; this.serverPackName = meta.serverPackName;
this.presetId = meta.presetId; this.presetId = meta.presetId;
} catch (Exception e) { } catch (Exception e) {
System.err.println("[Instance] Failed to load metadata for " + name + ": " + e.getMessage()); LauncherLogger.warn("[Instance] Failed to load metadata for " + name + ": " + e.getMessage());
} }
} }
@@ -1,5 +1,7 @@
package me.sashegdev.zernmc.launcher.minecraft; package me.sashegdev.zernmc.launcher.minecraft;
import me.sashegdev.zernmc.launcher.utils.LauncherLogger;
import me.sashegdev.zernmc.launcher.utils.Config; import me.sashegdev.zernmc.launcher.utils.Config;
import java.io.IOException; import java.io.IOException;
@@ -51,7 +53,7 @@ public class InstanceManager {
try { try {
Files.deleteIfExists(path); Files.deleteIfExists(path);
} catch (IOException e) { } catch (IOException e) {
System.err.println("Не удалось удалить: " + path); LauncherLogger.warn("Не удалось удалить: " + path);
} }
}); });
return true; return true;
@@ -71,12 +71,12 @@ public class MinecraftLib {
* Stub - will be expanded * Stub - will be expanded
*/ */
public boolean installPack(String packName, String minecraftVersion, String loaderType, String loaderVersion) throws Exception { public boolean installPack(String packName, String minecraftVersion, String loaderType, String loaderVersion) throws Exception {
System.out.println(ZAnsi.cyan("Starting full pack install: " + packName)); LauncherLogger.info("Starting full pack install: " + packName);
// 1. Install Minecraft // 1. Install Minecraft
boolean mcInstalled = installMinecraft(minecraftVersion); boolean mcInstalled = installMinecraft(minecraftVersion);
if (!mcInstalled) { if (!mcInstalled) {
System.out.println(ZAnsi.brightRed("Failed to install Minecraft " + minecraftVersion)); LauncherLogger.error("Failed to install Minecraft " + minecraftVersion);
return false; return false;
} }
@@ -84,40 +84,39 @@ public class MinecraftLib {
if ("fabric".equalsIgnoreCase(loaderType)) { if ("fabric".equalsIgnoreCase(loaderType)) {
boolean fabricInstalled = installFabric(minecraftVersion, loaderVersion); boolean fabricInstalled = installFabric(minecraftVersion, loaderVersion);
if (!fabricInstalled) { if (!fabricInstalled) {
System.out.println(ZAnsi.brightRed("Failed to install Fabric")); LauncherLogger.error("Failed to install Fabric");
return false; return false;
} }
} else if ("forge".equalsIgnoreCase(loaderType)) { } else if ("forge".equalsIgnoreCase(loaderType)) {
boolean forgeInstalled = installForge(minecraftVersion, loaderVersion); boolean forgeInstalled = installForge(minecraftVersion, loaderVersion);
if (!forgeInstalled) { if (!forgeInstalled) {
System.out.println(ZAnsi.brightRed("Failed to install Forge")); LauncherLogger.error("Failed to install Forge");
return false; return false;
} }
} else if ("neoforge".equalsIgnoreCase(loaderType)) { } else if ("neoforge".equalsIgnoreCase(loaderType)) {
boolean neoforgeInstalled = installNeoForge(minecraftVersion, loaderVersion); boolean neoforgeInstalled = installNeoForge(minecraftVersion, loaderVersion);
if (!neoforgeInstalled) { if (!neoforgeInstalled) {
System.out.println(ZAnsi.brightRed("Failed to install NeoForge")); LauncherLogger.error("Failed to install NeoForge");
return false; return false;
} }
} }
// 3. In the future: diff and mod download // 3. In the future: diff and mod download
System.out.println(ZAnsi.brightGreen("Basic pack install complete!")); LauncherLogger.info("Basic pack install complete!");
return true; return true;
} }
//Launch //Launch
public void launch(LaunchOptions options) throws Exception { public void launch(LaunchOptions options) throws Exception {
System.out.println(ZAnsi.brightGreen("Launching pack: " + instance.getName())); LauncherLogger.info("Launching pack: " + instance.getName());
cleanupOldLoaders(); cleanupOldLoaders();
validateJarFiles(); validateJarFiles();
LaunchCommandBuilder builder = new LaunchCommandBuilder(instance); LaunchCommandBuilder builder = new LaunchCommandBuilder(instance);
List<String> command = builder.build(options); List<String> command = builder.build(options);
System.out.println(ZAnsi.cyan("Launch command (" + command.size() + " args):")); LauncherLogger.info("Launch command (" + command.size() + " args) written below");
command.forEach(arg -> System.out.println(" " + arg));
ProcessBuilder pb = new ProcessBuilder(command); ProcessBuilder pb = new ProcessBuilder(command);
pb.directory(instance.getPath().toFile()); pb.directory(instance.getPath().toFile());
@@ -126,12 +125,12 @@ public class MinecraftLib {
Path cmdLogFile = instance.getPath().resolve("launch-command.log"); Path cmdLogFile = instance.getPath().resolve("launch-command.log");
try { try {
Files.writeString(cmdLogFile, String.join(" \\\n ", command)); Files.writeString(cmdLogFile, String.join(" \\\n ", command));
System.out.println(ZAnsi.green(" Launch command written to " + cmdLogFile.toAbsolutePath())); LauncherLogger.info("Launch command written to " + cmdLogFile.toAbsolutePath());
} catch (Exception e) { } catch (Exception e) {
System.out.println(ZAnsi.yellow(" Failed to write launch command log: " + e.getMessage())); LauncherLogger.warn("Failed to write launch command log: " + e.getMessage());
} }
System.out.println(ZAnsi.brightGreen("\nStarting Minecraft...\n")); LauncherLogger.info("Starting Minecraft...");
ConsoleUtils.clearScreen(); ConsoleUtils.clearScreen();
Process process = pb.start(); Process process = pb.start();
@@ -168,7 +167,7 @@ public class MinecraftLib {
outThread.join(1000); outThread.join(1000);
errThread.join(1000); errThread.join(1000);
System.out.println(ZAnsi.yellow("\nMinecraft exited with code: " + exitCode)); LauncherLogger.info("Minecraft exited with code: " + exitCode);
} }
private void safeDeleteDirectory(Path dir) { private void safeDeleteDirectory(Path dir) {
@@ -215,7 +214,7 @@ public class MinecraftLib {
if (currentLoaderVer == null) return; if (currentLoaderVer == null) return;
System.out.println(ZAnsi.yellow("Cleaning old loader versions...")); LauncherLogger.info("Cleaning old loader versions...");
// Delete all old fabric-loader / forge // Delete all old fabric-loader / forge
Path libraries = instance.getPath().resolve("libraries"); Path libraries = instance.getPath().resolve("libraries");
@@ -247,14 +246,14 @@ public class MinecraftLib {
.filter(p -> !isValidJar(p)) .filter(p -> !isValidJar(p))
.forEach(p -> { .forEach(p -> {
try { try {
System.out.println(ZAnsi.yellow(" Removing corrupt JAR: " + instance.getPath().relativize(p))); LauncherLogger.warn("Removing corrupt JAR: " + instance.getPath().relativize(p));
Files.delete(p); Files.delete(p);
} catch (IOException e) { } catch (IOException e) {
System.out.println(ZAnsi.red(" Failed to delete: " + p.getFileName())); LauncherLogger.error("Failed to delete: " + p.getFileName());
} }
}); });
} catch (Exception e) { } catch (Exception e) {
System.out.println(ZAnsi.yellow(" Error scanning for corrupt JARs: " + e.getMessage())); LauncherLogger.warn("Error scanning for corrupt JARs: " + e.getMessage());
} }
} }
} }
@@ -184,8 +184,7 @@ public class PackDownloader {
boolean success = lib.installFabric(manifest.getMinecraftVersion(), manifest.getLoaderVersion()); boolean success = lib.installFabric(manifest.getMinecraftVersion(), manifest.getLoaderVersion());
LauncherLogger.info("installOrUpdatePack: Fabric install result=" + success); LauncherLogger.info("installOrUpdatePack: Fabric install result=" + success);
if (!success) { if (!success) {
System.err.println(ZAnsi.brightRed("Failed to install Fabric")); LauncherLogger.error("[INSTALL] Failed to install Fabric");
LauncherLogger.error("installOrUpdatePack: Fabric install failed");
reportProgress("Failed to install Fabric", 0, "error", 0, 1); reportProgress("Failed to install Fabric", 0, "error", 0, 1);
return false; return false;
} }
@@ -195,8 +194,7 @@ public class PackDownloader {
boolean success = lib.installNeoForge(manifest.getMinecraftVersion(), manifest.getLoaderVersion()); boolean success = lib.installNeoForge(manifest.getMinecraftVersion(), manifest.getLoaderVersion());
LauncherLogger.info("installOrUpdatePack: NeoForge install result=" + success); LauncherLogger.info("installOrUpdatePack: NeoForge install result=" + success);
if (!success) { if (!success) {
System.err.println(ZAnsi.brightRed("Failed to install NeoForge")); LauncherLogger.error("[INSTALL] Failed to install NeoForge");
LauncherLogger.error("installOrUpdatePack: NeoForge install failed");
reportProgress("Failed to install NeoForge", 0, "error", 0, 1); reportProgress("Failed to install NeoForge", 0, "error", 0, 1);
return false; return false;
} }
@@ -206,8 +204,7 @@ public class PackDownloader {
boolean success = lib.installForge(manifest.getMinecraftVersion(), manifest.getLoaderVersion()); boolean success = lib.installForge(manifest.getMinecraftVersion(), manifest.getLoaderVersion());
LauncherLogger.info("installOrUpdatePack: Forge install result=" + success); LauncherLogger.info("installOrUpdatePack: Forge install result=" + success);
if (!success) { if (!success) {
System.err.println(ZAnsi.brightRed("Failed to install Forge")); LauncherLogger.error("[INSTALL] Failed to install Forge");
LauncherLogger.error("installOrUpdatePack: Forge install failed");
reportProgress("Failed to install Forge", 0, "error", 0, 1); reportProgress("Failed to install Forge", 0, "error", 0, 1);
return false; return false;
} }
@@ -217,16 +214,14 @@ public class PackDownloader {
boolean success = lib.installMinecraft(manifest.getMinecraftVersion()); boolean success = lib.installMinecraft(manifest.getMinecraftVersion());
LauncherLogger.info("installOrUpdatePack: Vanilla install result=" + success); LauncherLogger.info("installOrUpdatePack: Vanilla install result=" + success);
if (!success) { if (!success) {
System.err.println(ZAnsi.brightRed("Failed to install Vanilla Minecraft")); LauncherLogger.error("[INSTALL] Failed to install Vanilla Minecraft");
LauncherLogger.error("installOrUpdatePack: Vanilla install failed");
reportProgress("Failed to install Vanilla Minecraft", 0, "error", 0, 1); reportProgress("Failed to install Vanilla Minecraft", 0, "error", 0, 1);
return false; return false;
} }
} }
LauncherLogger.info("installOrUpdatePack: Minecraft+loader install completed successfully"); LauncherLogger.info("installOrUpdatePack: Minecraft+loader install completed successfully");
} else { } else {
LauncherLogger.info("installOrUpdatePack: Minecraft already installed, skipping"); LauncherLogger.info("Minecraft already installed, skipping...");
System.out.println(ZAnsi.green("Minecraft already installed, skipping..."));
} }
reportProgress("Scanning local files...", 30, "Installing pack files", 4, 5); reportProgress("Scanning local files...", 30, "Installing pack files", 4, 5);
@@ -239,7 +234,7 @@ public class PackDownloader {
// If pack has no files (vanilla/loader only), skip diff // If pack has no files (vanilla/loader only), skip diff
if (manifest.files == null || manifest.files.isEmpty()) { if (manifest.files == null || manifest.files.isEmpty()) {
LauncherLogger.info("installOrUpdatePack: no files in manifest, skipping diff"); LauncherLogger.info("installOrUpdatePack: no files in manifest, skipping diff");
System.out.println(ZAnsi.green("Pack contains no additional files")); LauncherLogger.info("Pack contains no additional files");
reportProgress("Installing...", 50, "Installing pack files", 4, 5); reportProgress("Installing...", 50, "Installing pack files", 4, 5);
saveUserFilesSnapshot(localFiles); saveUserFilesSnapshot(localFiles);
@@ -253,7 +248,7 @@ public class PackDownloader {
instance.setAssetIndex(resolveAssetIndex(manifest)); instance.setAssetIndex(resolveAssetIndex(manifest));
reportProgress("Pack installed successfully!", 100, "Installing pack files", 4, 5); reportProgress("Pack installed successfully!", 100, "Installing pack files", 4, 5);
System.out.println(ZAnsi.brightGreen("Pack installed successfully!")); LauncherLogger.info("Pack installed successfully!");
return true; return true;
} }
LauncherLogger.info("installOrUpdatePack: manifest has " + manifest.files.size() + " files, proceeding to diff"); LauncherLogger.info("installOrUpdatePack: manifest has " + manifest.files.size() + " files, proceeding to diff");
@@ -302,7 +297,7 @@ public class PackDownloader {
instance.setLoaderVersion(manifest.getLoaderVersion()); instance.setLoaderVersion(manifest.getLoaderVersion());
instance.setAssetIndex(resolveAssetIndex(manifest)); instance.setAssetIndex(resolveAssetIndex(manifest));
System.out.println(ZAnsi.brightGreen("Pack installed successfully!")); LauncherLogger.info("Pack installed successfully!");
LauncherLogger.info("installOrUpdatePack: SUCCESS"); LauncherLogger.info("installOrUpdatePack: SUCCESS");
} else { } else {
LauncherLogger.error("installOrUpdatePack: FAILED - applyDiff returned false without exception"); LauncherLogger.error("installOrUpdatePack: FAILED - applyDiff returned false without exception");
@@ -332,17 +327,17 @@ public class PackDownloader {
} }
public boolean updatePack(String packName, String preset) throws Exception { public boolean updatePack(String packName, String preset) throws Exception {
System.out.println(ZAnsi.cyan("Checking updates for " + instance.getName() + "...")); LauncherLogger.info("Checking updates for " + instance.getName() + "...");
PackManifest manifest = getPackManifest(packName); PackManifest manifest = getPackManifest(packName);
int serverVersion = manifest.getVersion(); int serverVersion = manifest.getVersion();
if (serverVersion <= instance.getServerVersion()) { if (serverVersion <= instance.getServerVersion()) {
System.out.println(ZAnsi.green("Pack is already up to date (v" + instance.getServerVersion() + ")")); LauncherLogger.info("Pack is already up to date (v" + instance.getServerVersion() + ")");
return true; return true;
} }
System.out.println(ZAnsi.yellow("Update available: v" + instance.getServerVersion() + " v" + serverVersion)); LauncherLogger.info("Update available: v" + instance.getServerVersion() + " -> v" + serverVersion);
// Scan local files // Scan local files
Map<String, String> localFiles = scanLocalFiles(); Map<String, String> localFiles = scanLocalFiles();
@@ -351,7 +346,7 @@ public class PackDownloader {
Set<String> protectedFiles = getUserModifiedFiles(localFiles); Set<String> protectedFiles = getUserModifiedFiles(localFiles);
if (!protectedFiles.isEmpty()) { if (!protectedFiles.isEmpty()) {
LauncherLogger.info("[DEBUG] Found " + protectedFiles.size() + " user-modified files (will not overwrite)"); LauncherLogger.info("[DEBUG] Found " + protectedFiles.size() + " user-modified files (will not overwrite)");
System.out.println(ZAnsi.yellow("Found " + protectedFiles.size() + " user-modified files, they will not be overwritten")); LauncherLogger.info("Found " + protectedFiles.size() + " user-modified files, they will not be overwritten");
} }
// Get diff // Get diff
@@ -366,7 +361,7 @@ public class PackDownloader {
saveUserFilesSnapshot(updatedHashes); saveUserFilesSnapshot(updatedHashes);
instance.setServerVersion(serverVersion); instance.setServerVersion(serverVersion);
System.out.println(ZAnsi.brightGreen("Pack updated to v" + serverVersion)); LauncherLogger.info("Pack updated to v" + serverVersion);
} }
return success; return success;
@@ -519,34 +514,30 @@ public class PackDownloader {
private boolean applyDiff(DiffResponse diff, String packName, Set<String> protectedFiles, String preset) { private boolean applyDiff(DiffResponse diff, String packName, Set<String> protectedFiles, String preset) {
LauncherLogger.info("applyDiff: start. toDownload=" + diff.getToDownload().size() + " toDelete=" + diff.getToDelete().size() + " protected=" + protectedFiles.size()); LauncherLogger.info("applyDiff: start. toDownload=" + diff.getToDownload().size() + " toDelete=" + diff.getToDelete().size() + " protected=" + protectedFiles.size());
System.out.println(ZAnsi.cyan("\nApplying changes:")); LauncherLogger.info("Apply changes: Download " + diff.getToDownload().size() + " files, Delete " + diff.getToDelete().size() + " files"
System.out.println(" Download: " + diff.getToDownload().size() + " files"); + (!protectedFiles.isEmpty() ? ", Protected " + protectedFiles.size() + " user-modified files (skipped)" : ""));
System.out.println(" Delete: " + diff.getToDelete().size() + " files");
if (!protectedFiles.isEmpty()) {
System.out.println(ZAnsi.yellow(" Protected: " + protectedFiles.size() + " user-modified files (skipped)"));
}
// Create directories if needed // Create directories if needed
try { try {
Files.createDirectories(instance.getPath()); Files.createDirectories(instance.getPath());
} catch (IOException e) { } catch (IOException e) {
System.err.println(ZAnsi.red("Error creating directories: " + e.getMessage())); LauncherLogger.error("Error creating directories: " + e.getMessage());
return false; return false;
} }
// Delete files (skip protected) // Delete files (skip protected)
for (String filePath : diff.getToDelete()) { for (String filePath : diff.getToDelete()) {
if (protectedFiles.contains(filePath)) { if (protectedFiles.contains(filePath)) {
System.out.println(ZAnsi.yellow(" Skipped delete (user-modified): " + filePath)); LauncherLogger.info("Skipped delete (user-modified): " + filePath);
continue; continue;
} }
Path fullPath = instance.getPath().resolve(filePath); Path fullPath = instance.getPath().resolve(filePath);
try { try {
if (Files.deleteIfExists(fullPath)) { if (Files.deleteIfExists(fullPath)) {
System.out.println(ZAnsi.yellow(" Deleted: " + filePath)); LauncherLogger.info("Deleted: " + filePath);
} }
} catch (IOException e) { } catch (IOException e) {
System.err.println(ZAnsi.red(" Error deleting " + filePath + ": " + e.getMessage())); LauncherLogger.error("Error deleting " + filePath + ": " + e.getMessage());
} }
} }
@@ -565,7 +556,7 @@ public class PackDownloader {
if (protectedFiles.contains(path)) { if (protectedFiles.contains(path)) {
skipped++; skipped++;
System.out.println(ZAnsi.yellow(" Skipped (user-modified): " + path)); LauncherLogger.info("Skipped (user-modified): " + path);
continue; continue;
} }
@@ -597,7 +588,6 @@ public class PackDownloader {
} }
if (!hashMatch) { if (!hashMatch) {
LauncherLogger.warn("applyDiff: skipping file " + path + " due to hash mismatch (expected=" + file.getHash() + " got=" + actualHash + ")"); LauncherLogger.warn("applyDiff: skipping file " + path + " due to hash mismatch (expected=" + file.getHash() + " got=" + actualHash + ")");
System.err.println(ZAnsi.yellow(" Skipped (hash mismatch): " + path));
skipped++; skipped++;
continue; continue;
} }
@@ -642,8 +632,7 @@ public class PackDownloader {
} catch (Exception e) { } catch (Exception e) {
LauncherLogger.error("applyDiff: download FAILED for path=" + path + " error=" + e.getClass().getName() + ": " + e.getMessage()); LauncherLogger.error("applyDiff: download FAILED for path=" + path + " error=" + e.getClass().getName() + ": " + e.getMessage());
System.err.println("\n" + ZAnsi.red(" Download error " + path + ": " + e.getMessage())); LauncherLogger.error("Download error " + path + ": " + e.getMessage() + " - skipping file");
System.err.println(ZAnsi.yellow(" Skipping file " + path + " due to download error"));
skipped++; skipped++;
continue; continue;
} }
@@ -653,7 +642,7 @@ public class PackDownloader {
ProgressBar.finish("Download"); ProgressBar.finish("Download");
} }
if (skipped > 0) { if (skipped > 0) {
System.out.println(ZAnsi.cyan(" Skipped " + skipped + " user-modified files")); LauncherLogger.info("Skipped " + skipped + " user-modified files");
} }
return true; return true;
@@ -6,7 +6,9 @@ import me.sashegdev.zernmc.launcher.utils.LauncherLogger;
import me.sashegdev.zernmc.launcher.utils.ProgressBar; import me.sashegdev.zernmc.launcher.utils.ProgressBar;
import me.sashegdev.zernmc.launcher.utils.ZHttpClient; import me.sashegdev.zernmc.launcher.utils.ZHttpClient;
import java.io.BufferedReader;
import java.io.IOException; import java.io.IOException;
import java.io.InputStreamReader;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
@@ -33,6 +35,8 @@ public class FabricInstaller {
instance.setAssetIndex(assetIndex); instance.setAssetIndex(assetIndex);
instance.setMinecraftVersion(minecraftVersion); instance.setMinecraftVersion(minecraftVersion);
ProgressBar.showIndeterminate("Preparing Fabric installer");
String installerVersion = getLatestInstallerVersion(); String installerVersion = getLatestInstallerVersion();
String installerUrl = "https://maven.fabricmc.net/net/fabricmc/fabric-installer/" String installerUrl = "https://maven.fabricmc.net/net/fabricmc/fabric-installer/"
+ installerVersion + "/fabric-installer-" + installerVersion + ".jar"; + installerVersion + "/fabric-installer-" + installerVersion + ".jar";
@@ -40,7 +44,7 @@ public class FabricInstaller {
Path installerJar = instancePath.resolve("fabric-installer.jar"); Path installerJar = instancePath.resolve("fabric-installer.jar");
if (!Files.exists(installerJar)) { if (!Files.exists(installerJar)) {
ProgressBar.show("Downloading Fabric Installer", 0, 100, "%"); ProgressBar.showIndeterminate("Downloading Fabric Installer");
downloadFileWithFallback(installerUrl, installerJar); downloadFileWithFallback(installerUrl, installerJar);
ProgressBar.finish("Fabric Installer downloaded"); ProgressBar.finish("Fabric Installer downloaded");
} }
@@ -48,6 +52,7 @@ public class FabricInstaller {
preDownloadFabricLibraries(minecraftVersion, loaderVersion); preDownloadFabricLibraries(minecraftVersion, loaderVersion);
LauncherLogger.info("Running Fabric Installer..."); LauncherLogger.info("Running Fabric Installer...");
ProgressBar.showIndeterminate("Running Fabric Installer (may take a few minutes)");
String fabricVersionId = "fabric-loader-" + loaderVersion + "-" + minecraftVersion; String fabricVersionId = "fabric-loader-" + loaderVersion + "-" + minecraftVersion;
@@ -60,17 +65,50 @@ public class FabricInstaller {
"-noprofile" "-noprofile"
); );
pb.redirectOutput(ProcessBuilder.Redirect.INHERIT); pb.redirectErrorStream(true);
pb.redirectError(ProcessBuilder.Redirect.INHERIT);
Process process = pb.start(); Process process = pb.start();
boolean finished = process.waitFor(10, TimeUnit.MINUTES);
if (!finished) { boolean hasErrors = false;
process.destroyForcibly();
LauncherLogger.error("Fabric Installer timed out after 10 minutes"); try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) {
return false; String line;
while ((line = reader.readLine()) != null) {
if (line.contains("Downloading") || line.contains("Extracting")
|| line.contains("Installing") || line.contains("Creating")
|| line.contains("SUCCESS") || line.contains("successfully")) {
ProgressBar.showIndeterminate("Fabric Installer: " + ProgressBar.cleanLine(line));
}
if (line.contains("Downloading") || line.contains("Extracting")) {
LauncherLogger.info(" -> " + line);
} else if (line.contains("SUCCESS") || line.contains("successfully")) {
LauncherLogger.info(" + " + line);
} else if (line.contains("WARNING") || line.contains("warning")) {
LauncherLogger.warn(" ! " + line);
} else if (line.contains("ERROR") || line.contains("error") || line.contains("failed") || line.contains("timed out")) {
LauncherLogger.error(" X " + line);
if (line.contains("timed out") || line.contains("failed to download")) {
hasErrors = true;
}
} else if (!line.isBlank()) {
LauncherLogger.info(" " + line);
}
}
}
int exitCode;
if (hasErrors) {
process.destroyForcibly();
exitCode = 1;
} else {
if (!process.waitFor(10, TimeUnit.MINUTES)) {
process.destroyForcibly();
LauncherLogger.error("Fabric Installer timed out after 10 minutes");
return false;
}
exitCode = process.exitValue();
} }
int exitCode = process.exitValue();
if (exitCode != 0) { if (exitCode != 0) {
LauncherLogger.error("Fabric Installer failed (code " + exitCode + ")"); LauncherLogger.error("Fabric Installer failed (code " + exitCode + ")");
@@ -3,6 +3,7 @@ package me.sashegdev.zernmc.launcher.minecraft.installer;
import me.sashegdev.zernmc.launcher.minecraft.Instance; import me.sashegdev.zernmc.launcher.minecraft.Instance;
import me.sashegdev.zernmc.launcher.utils.JavaResolver; import me.sashegdev.zernmc.launcher.utils.JavaResolver;
import me.sashegdev.zernmc.launcher.utils.LauncherLogger; import me.sashegdev.zernmc.launcher.utils.LauncherLogger;
import me.sashegdev.zernmc.launcher.utils.ProgressBar;
import me.sashegdev.zernmc.launcher.utils.ZHttpClient; import me.sashegdev.zernmc.launcher.utils.ZHttpClient;
import java.io.*; import java.io.*;
@@ -57,15 +58,19 @@ public class ModLoaderInstaller {
instance.setAssetIndex(assetIndex); instance.setAssetIndex(assetIndex);
createLauncherProfile(); createLauncherProfile();
ProgressBar.showIndeterminate("Preparing " + type.loaderName + " installer");
String installerUrl = buildInstallerUrl(mcVersion, loaderVersion, type); String installerUrl = buildInstallerUrl(mcVersion, loaderVersion, type);
String jarName = type.loaderName + "-installer.jar"; String jarName = type.loaderName + "-installer.jar";
Path installerJar = instance.getPath().resolve(jarName); Path installerJar = instance.getPath().resolve(jarName);
LauncherLogger.info("Downloading " + type.loaderName + " Installer..."); LauncherLogger.info("Downloading " + type.loaderName + " Installer...");
ProgressBar.showIndeterminate("Downloading " + type.loaderName + " Installer");
ZHttpClient.downloadFileWithSmartProxy(installerUrl, installerJar); ZHttpClient.downloadFileWithSmartProxy(installerUrl, installerJar);
LauncherLogger.info("Running " + type.loaderName + " Installer..."); LauncherLogger.info("Running " + type.loaderName + " Installer...");
LauncherLogger.info("This may take a few minutes. Please wait..."); LauncherLogger.info("This may take a few minutes. Please wait...");
ProgressBar.showIndeterminate("Running " + type.loaderName + " Installer (may take a few minutes)");
boolean success = runInstaller(installerJar, type); boolean success = runInstaller(installerJar, type);
@@ -145,6 +150,11 @@ public class ModLoaderInstaller {
while ((line = reader.readLine()) != null) { while ((line = reader.readLine()) != null) {
output.append(line).append("\n"); output.append(line).append("\n");
if (line.contains("Downloading") || line.contains("Extracting")
|| line.contains("Installing") || line.contains("Creating")
|| line.contains("SUCCESS") || line.contains("successfully")) {
ProgressBar.showIndeterminate(type.loaderName + " Installer: " + ProgressBar.cleanLine(line));
}
if (line.contains("Downloading") || line.contains("Extracting")) { if (line.contains("Downloading") || line.contains("Extracting")) {
LauncherLogger.info(" -> " + line); LauncherLogger.info(" -> " + line);
} else if (line.contains("SUCCESS") || line.contains("successfully")) { } else if (line.contains("SUCCESS") || line.contains("successfully")) {
@@ -218,6 +228,7 @@ public class ModLoaderInstaller {
private void downloadMissingLibraries(LoaderType type) throws Exception { private void downloadMissingLibraries(LoaderType type) throws Exception {
LauncherLogger.info("Checking and downloading missing libraries..."); LauncherLogger.info("Checking and downloading missing libraries...");
ProgressBar.showIndeterminate("Downloading missing libraries");
ZHttpClient.repairMissingLibraries(instance.getPath()); ZHttpClient.repairMissingLibraries(instance.getPath());
} }
} }
@@ -64,7 +64,8 @@ public class VersionInstaller {
Files.createDirectories(versionDir); Files.createDirectories(versionDir);
boolean alreadyInstalled = Files.exists(versionDir.resolve(versionId + ".json")) boolean alreadyInstalled = Files.exists(versionDir.resolve(versionId + ".json"))
&& Files.exists(versionDir.resolve(versionId + ".jar")); && Files.exists(versionDir.resolve(versionId + ".jar"))
&& isValidJar(versionDir.resolve(versionId + ".jar"));
if (alreadyInstalled) { if (alreadyInstalled) {
LauncherLogger.info("Minecraft " + versionId + " already installed, skipping"); LauncherLogger.info("Minecraft " + versionId + " already installed, skipping");
String existing = Files.readString(versionDir.resolve(versionId + ".json")); String existing = Files.readString(versionDir.resolve(versionId + ".json"));
@@ -73,20 +74,29 @@ public class VersionInstaller {
? existingData.getJSONObject("assetIndex").getString("id") ? existingData.getJSONObject("assetIndex").getString("id")
: existingData.getString("assets"); : existingData.getString("assets");
} }
// A stale/partial client.jar (e.g. aborted by the download timeout) must be
// removed, otherwise it would be skipped as "already installed" and stay corrupt.
try {
Files.deleteIfExists(versionDir.resolve(versionId + ".jar"));
} catch (IOException e) {
LauncherLogger.warn("Could not remove stale client.jar: " + e.getMessage());
}
ProgressBar.show("Fetching version info", 0, 1, "files"); ProgressBar.show("Fetching version info", 0, 1, "files");
String versionJson; String versionJson;
try { try {
// Prefers Zern server Mojang proxy, falls back to direct piston-meta // Prefers Zern server Mojang proxy, falls back to direct piston-meta
LauncherLogger.info("[VERSION] Fetching version json for " + versionId + " (server proxy, then direct piston-meta)");
versionJson = ZHttpClient.getMojangVersionJson(versionId).toString(); versionJson = ZHttpClient.getMojangVersionJson(versionId).toString();
LauncherLogger.info("[VERSION] Version json fetched for " + versionId);
} catch (Exception e) { } catch (Exception e) {
System.err.println(ZAnsi.red("[VERSION] Failed to fetch version info: " + e.getMessage())); LauncherLogger.error("[VERSION] Failed to fetch version info: " + e.getMessage());
throw e; throw e;
} }
try { try {
Files.writeString(versionDir.resolve(versionId + ".json"), versionJson); Files.writeString(versionDir.resolve(versionId + ".json"), versionJson);
} catch (Exception e) { } catch (Exception e) {
System.err.println(ZAnsi.red("[VERSION] Failed to write version info: " + e.getMessage())); LauncherLogger.error("[VERSION] Failed to write version info: " + e.getMessage());
throw e; throw e;
} }
ProgressBar.show("Version info", 1, 1, "files"); ProgressBar.show("Version info", 1, 1, "files");
@@ -95,16 +105,20 @@ public class VersionInstaller {
// client.jar // client.jar
ProgressBar.show("Downloading client.jar", 0, 1, "files"); ProgressBar.show("Downloading client.jar", 0, 1, "files");
LauncherLogger.info("[VERSION] Downloading client.jar for " + versionId + "...");
Path clientJar = versionDir.resolve(versionId + ".jar"); Path clientJar = versionDir.resolve(versionId + ".jar");
if (!Files.exists(clientJar)) { if (!Files.exists(clientJar)) {
try { try {
ZHttpClient.downloadFileWithSmartProxy( ZHttpClient.downloadFileWithSmartProxy(
versionData.getJSONObject("downloads").getJSONObject("client").getString("url"), versionData.getJSONObject("downloads").getJSONObject("client").getString("url"),
clientJar); clientJar);
LauncherLogger.info("[VERSION] client.jar downloaded for " + versionId);
} catch (Exception e) { } catch (Exception e) {
System.err.println(ZAnsi.red("[VERSION] Failed to download client.jar: " + e.getMessage())); LauncherLogger.error("[VERSION] Failed to download client.jar: " + e.getMessage());
throw e; throw e;
} }
} else {
LauncherLogger.info("[VERSION] client.jar already present for " + versionId);
} }
ProgressBar.show("Client.jar", 1, 1, "files"); ProgressBar.show("Client.jar", 1, 1, "files");
@@ -233,7 +247,7 @@ public class VersionInstaller {
try { try {
ZHttpClient.downloadFileWithSmartProxy(url, target); ZHttpClient.downloadFileWithSmartProxy(url, target);
} catch (Exception e) { } catch (Exception e) {
System.err.println(ZAnsi.yellow("[LIB] Failed to download " + path + ": " + e.getMessage())); LauncherLogger.warn("[LIB] Failed to download " + path + ": " + e.getMessage());
} }
} }
count++; count++;
@@ -306,7 +320,7 @@ public class VersionInstaller {
synchronized (this) { synchronized (this) {
failed[0]++; failed[0]++;
} }
System.err.println("Failed to download " + hash); LauncherLogger.error("[VERSION] Failed to download asset " + hash);
} else { } else {
try { Thread.sleep(500 * attempt); } catch (InterruptedException ignored) {} try { Thread.sleep(500 * attempt); } catch (InterruptedException ignored) {}
} }
@@ -322,7 +336,7 @@ public class VersionInstaller {
.get(10, TimeUnit.MINUTES); .get(10, TimeUnit.MINUTES);
} catch (TimeoutException e) { } catch (TimeoutException e) {
futures.forEach(f -> f.cancel(true)); futures.forEach(f -> f.cancel(true));
System.err.println("Asset download timed out after 10 minutes"); LauncherLogger.error("[VERSION] Asset download timed out after 10 minutes");
} catch (CancellationException e) { } catch (CancellationException e) {
// one of the futures was cancelled // one of the futures was cancelled
} catch (CompletionException e) { } catch (CompletionException e) {
@@ -337,6 +351,14 @@ public class VersionInstaller {
} }
} }
private boolean isValidJar(Path path) {
try (java.util.zip.ZipFile zf = new java.util.zip.ZipFile(path.toFile())) {
return true;
} catch (Exception e) {
return false;
}
}
public String getAssetIndexId(String versionId) throws Exception { public String getAssetIndexId(String versionId) throws Exception {
JSONObject versionData = ZHttpClient.getMojangVersionJson(versionId); JSONObject versionData = ZHttpClient.getMojangVersionJson(versionId);
@@ -2,6 +2,7 @@ package me.sashegdev.zernmc.launcher.minecraft.launch;
import me.sashegdev.zernmc.launcher.minecraft.Instance; import me.sashegdev.zernmc.launcher.minecraft.Instance;
import me.sashegdev.zernmc.launcher.minecraft.model.LaunchOptions; import me.sashegdev.zernmc.launcher.minecraft.model.LaunchOptions;
import me.sashegdev.zernmc.launcher.utils.LauncherLogger;
import me.sashegdev.zernmc.launcher.utils.ZAnsi; import me.sashegdev.zernmc.launcher.utils.ZAnsi;
import org.json.JSONObject; import org.json.JSONObject;
@@ -36,7 +37,7 @@ public class LaunchCommandBuilder {
} }
public List<String> build(LaunchOptions options) throws Exception { public List<String> build(LaunchOptions options) throws Exception {
System.out.println(ZAnsi.cyan("Generating launch command for " + instance.getName() + "...")); LauncherLogger.info(ZAnsi.cyan("Generating launch command for " + instance.getName() + "..."));
List<String> command = new ArrayList<>(); List<String> command = new ArrayList<>();
@@ -57,7 +58,7 @@ public class LaunchCommandBuilder {
// with placeholder substitution. Do NOT manually add -p, -cp, // with placeholder substitution. Do NOT manually add -p, -cp,
// --add-modules, --add-opens, or -DignoreList. The version.json // --add-modules, --add-opens, or -DignoreList. The version.json
// defines all of these. This matches AstralRinth's approach. // defines all of these. This matches AstralRinth's approach.
System.out.println(ZAnsi.cyan(" Forge/NeoForge: using version.json args with placeholder substitution")); LauncherLogger.info(ZAnsi.cyan(" Forge/NeoForge: using version.json args with placeholder substitution"));
// Ensure version jar exists at versions/<versionId>/<versionId>.jar // Ensure version jar exists at versions/<versionId>/<versionId>.jar
// so securejarhandler creates module "minecraft" (from Automatic-Module-Name) // so securejarhandler creates module "minecraft" (from Automatic-Module-Name)
@@ -92,9 +93,9 @@ public class LaunchCommandBuilder {
boolean referencesClasspath = referencesClasspath(allJvmArgs) || referencesClasspath(allGameArgs); boolean referencesClasspath = referencesClasspath(allJvmArgs) || referencesClasspath(allGameArgs);
if (referencesClasspath) { if (referencesClasspath) {
vars.put("classpath", writeClasspathFile(fullClasspath)); vars.put("classpath", writeClasspathFile(fullClasspath));
System.out.println(ZAnsi.green(" ${classpath} referenced, substituted full classpath")); LauncherLogger.info(ZAnsi.green(" ${classpath} referenced, substituted full classpath"));
} else { } else {
System.out.println(ZAnsi.cyan(" ${classpath} not referenced by version.json, not substituting")); LauncherLogger.info(ZAnsi.cyan(" ${classpath} not referenced by version.json, not substituting"));
} }
boolean hasCpInJson = containsCpArg(allJvmArgs); boolean hasCpInJson = containsCpArg(allJvmArgs);
@@ -107,9 +108,9 @@ public class LaunchCommandBuilder {
command.add(resolved); command.add(resolved);
} }
} }
System.out.println(ZAnsi.green(" Using " + allJvmArgs.size() + " JVM args from version.json")); LauncherLogger.info(ZAnsi.green(" Using " + allJvmArgs.size() + " JVM args from version.json"));
} else { } else {
System.out.println(ZAnsi.yellow(" WARNING: No JVM args in version.json, using manual fallback")); LauncherLogger.info(ZAnsi.yellow(" WARNING: No JVM args in version.json, using manual fallback"));
command.addAll(getJvmArguments(options)); command.addAll(getJvmArguments(options));
} }
@@ -141,7 +142,7 @@ public class LaunchCommandBuilder {
if (!hasCpInJson) { if (!hasCpInJson) {
command.add("-cp"); command.add("-cp");
command.add(writeClasspathFile(fullClasspath)); command.add(writeClasspathFile(fullClasspath));
System.out.println(ZAnsi.green(" Added -cp classpath for main class lookup")); LauncherLogger.info(ZAnsi.green(" Added -cp classpath for main class lookup"));
} }
// Main class from version.json // Main class from version.json
@@ -151,9 +152,9 @@ public class LaunchCommandBuilder {
} }
if (mainClass == null || mainClass.isEmpty()) { if (mainClass == null || mainClass.isEmpty()) {
mainClass = "cpw.mods.bootstraplauncher.BootstrapLauncher"; mainClass = "cpw.mods.bootstraplauncher.BootstrapLauncher";
System.out.println(ZAnsi.yellow(" Using fallback main class: " + mainClass)); LauncherLogger.info(ZAnsi.yellow(" Using fallback main class: " + mainClass));
} else { } else {
System.out.println(ZAnsi.green(" Main class from manifest: " + mainClass)); LauncherLogger.info(ZAnsi.green(" Main class from manifest: " + mainClass));
} }
command.add(mainClass); command.add(mainClass);
@@ -167,9 +168,9 @@ public class LaunchCommandBuilder {
command.add(resolved); command.add(resolved);
} }
} }
System.out.println(ZAnsi.green(" Using " + allGameArgs.size() + " game args from version.json")); LauncherLogger.info(ZAnsi.green(" Using " + allGameArgs.size() + " game args from version.json"));
} else { } else {
System.out.println(ZAnsi.yellow(" WARNING: No game args in version.json, using manual fallback")); LauncherLogger.info(ZAnsi.yellow(" WARNING: No game args in version.json, using manual fallback"));
command.addAll(getVanillaGameArguments(options)); command.addAll(getVanillaGameArguments(options));
command.addAll(getModloaderLaunchArgs()); command.addAll(getModloaderLaunchArgs());
} }
@@ -183,24 +184,24 @@ public class LaunchCommandBuilder {
command.add("-Djava.library.path=" + nativesDir.toAbsolutePath()); command.add("-Djava.library.path=" + nativesDir.toAbsolutePath());
if ("fabric".equals(loaderType)) { if ("fabric".equals(loaderType)) {
System.out.println(ZAnsi.cyan(" Fabric: using vanilla classpath")); LauncherLogger.info(ZAnsi.cyan(" Fabric: using vanilla classpath"));
command.add("-cp"); command.add("-cp");
command.add(writeClasspathFile(buildVanillaClasspath())); command.add(writeClasspathFile(buildVanillaClasspath()));
String mainClass = null; String mainClass = null;
if (manifest != null) { if (manifest != null) {
mainClass = manifest.getMainClass(); mainClass = manifest.getMainClass();
System.out.println(ZAnsi.cyan(" Main class from manifest: " + mainClass)); LauncherLogger.info(ZAnsi.cyan(" Main class from manifest: " + mainClass));
} }
if (mainClass == null || mainClass.isEmpty()) { if (mainClass == null || mainClass.isEmpty()) {
mainClass = getVanillaMainClass(); mainClass = getVanillaMainClass();
System.out.println(ZAnsi.yellow(" Using fallback main class: " + mainClass)); LauncherLogger.info(ZAnsi.yellow(" Using fallback main class: " + mainClass));
} }
command.add(mainClass); command.add(mainClass);
command.addAll(getVanillaGameArguments(options)); command.addAll(getVanillaGameArguments(options));
} else if (manifest != null) { } else if (manifest != null) {
String classpath = buildClasspathFromManifest(manifest, true); String classpath = buildClasspathFromManifest(manifest, true);
if (classpath.isEmpty()) { if (classpath.isEmpty()) {
System.out.println(ZAnsi.yellow(" manifest classpath empty, using vanilla classpath")); LauncherLogger.info(ZAnsi.yellow(" manifest classpath empty, using vanilla classpath"));
command.add("-cp"); command.add("-cp");
command.add(writeClasspathFile(buildVanillaClasspath())); command.add(writeClasspathFile(buildVanillaClasspath()));
command.add(getVanillaMainClass()); command.add(getVanillaMainClass());
@@ -229,20 +230,20 @@ public class LaunchCommandBuilder {
if (versionJson != null && Files.exists(versionJson)) { if (versionJson != null && Files.exists(versionJson)) {
String content = Files.readString(versionJson); String content = Files.readString(versionJson);
JSONObject json = new JSONObject(content); JSONObject json = new JSONObject(content);
System.out.println(ZAnsi.green("Found version.json: " + versionJson.getFileName())); LauncherLogger.info(ZAnsi.green("Found version.json: " + versionJson.getFileName()));
VersionManifest manifest = new VersionManifest(json); VersionManifest manifest = new VersionManifest(json);
manifest.resolveParent(instance.getPath().resolve("versions")); manifest.resolveParent(instance.getPath().resolve("versions"));
if (manifest.getInheritsFrom() != null) { if (manifest.getInheritsFrom() != null) {
System.out.println(ZAnsi.cyan(" inheritsFrom: " + manifest.getInheritsFrom())); LauncherLogger.info(ZAnsi.cyan(" inheritsFrom: " + manifest.getInheritsFrom()));
} }
return manifest; return manifest;
} else { } else {
System.out.println(ZAnsi.yellow("version.json not found for " + instance.getName())); LauncherLogger.info(ZAnsi.yellow("version.json not found for " + instance.getName()));
System.out.println(ZAnsi.yellow(" loaderType=" + instance.getLoaderType() + " mcVersion=" + instance.getMinecraftVersion() + " loaderVersion=" + instance.getLoaderVersion())); LauncherLogger.info(ZAnsi.yellow(" loaderType=" + instance.getLoaderType() + " mcVersion=" + instance.getMinecraftVersion() + " loaderVersion=" + instance.getLoaderVersion()));
System.out.println(ZAnsi.yellow(" path=" + instance.getPath())); LauncherLogger.info(ZAnsi.yellow(" path=" + instance.getPath()));
} }
} catch (Exception e) { } catch (Exception e) {
System.out.println(ZAnsi.yellow("Failed to load version.json: " + e.getMessage())); LauncherLogger.info(ZAnsi.yellow("Failed to load version.json: " + e.getMessage()));
} }
return null; return null;
} }
@@ -496,14 +497,14 @@ public class LaunchCommandBuilder {
String assetIndex = instance.getAssetIndex(); String assetIndex = instance.getAssetIndex();
if (assetIndex == null || assetIndex.isEmpty()) { if (assetIndex == null || assetIndex.isEmpty()) {
assetIndex = instance.getMinecraftVersion(); assetIndex = instance.getMinecraftVersion();
System.out.println(ZAnsi.yellow("Asset index not found, using version: " + assetIndex)); LauncherLogger.info(ZAnsi.yellow("Asset index not found, using version: " + assetIndex));
} else { } else {
// Validate that the index file exists. A wrong/stale asset index // Validate that the index file exists. A wrong/stale asset index
// (e.g. the Zern-OBT "1.20.1" bug, real index is "5") makes the game // (e.g. the Zern-OBT "1.20.1" bug, real index is "5") makes the game
// fail to load the base resource pack (grey panorama, no sounds). // fail to load the base resource pack (grey panorama, no sounds).
Path indexPath = instance.getPath().resolve("assets").resolve("indexes").resolve(assetIndex + ".json"); Path indexPath = instance.getPath().resolve("assets").resolve("indexes").resolve(assetIndex + ".json");
if (!Files.exists(indexPath)) { if (!Files.exists(indexPath)) {
System.out.println(ZAnsi.yellow("Asset index file missing: " + assetIndex + ".json, searching for existing index...")); LauncherLogger.info(ZAnsi.yellow("Asset index file missing: " + assetIndex + ".json, searching for existing index..."));
try (java.util.stream.Stream<Path> stream = Files.list(instance.getPath().resolve("assets").resolve("indexes"))) { try (java.util.stream.Stream<Path> stream = Files.list(instance.getPath().resolve("assets").resolve("indexes"))) {
java.util.List<Path> existing = stream java.util.List<Path> existing = stream
.filter(p -> p.getFileName().toString().endsWith(".json")) .filter(p -> p.getFileName().toString().endsWith(".json"))
@@ -512,16 +513,16 @@ public class LaunchCommandBuilder {
if (!existing.isEmpty()) { if (!existing.isEmpty()) {
String found = existing.get(existing.size() - 1).getFileName().toString() String found = existing.get(existing.size() - 1).getFileName().toString()
.replace(".json", ""); .replace(".json", "");
System.out.println(ZAnsi.green("Using existing asset index: " + found + " (was " + assetIndex + ")")); LauncherLogger.info(ZAnsi.green("Using existing asset index: " + found + " (was " + assetIndex + ")"));
assetIndex = found; assetIndex = found;
} else { } else {
System.out.println(ZAnsi.yellow("No asset index files found in assets/indexes/")); LauncherLogger.info(ZAnsi.yellow("No asset index files found in assets/indexes/"));
} }
} catch (Exception e) { } catch (Exception e) {
System.out.println(ZAnsi.yellow("Failed to search asset indexes: " + e.getMessage())); LauncherLogger.info(ZAnsi.yellow("Failed to search asset indexes: " + e.getMessage()));
} }
} }
System.out.println(ZAnsi.green("Using asset index: " + assetIndex)); LauncherLogger.info(ZAnsi.green("Using asset index: " + assetIndex));
} }
args.add(assetIndex); args.add(assetIndex);
args.add("--username"); args.add("--username");
@@ -612,7 +613,7 @@ public class LaunchCommandBuilder {
List<String> paths = new ArrayList<>(); List<String> paths = new ArrayList<>();
Path librariesDir = instance.getPath().resolve("libraries"); Path librariesDir = instance.getPath().resolve("libraries");
System.out.println(ZAnsi.cyan(" buildClasspathFromManifest: " + manifest.getAllLibraries().size() + " libraries in manifest (including inherited)")); LauncherLogger.info(ZAnsi.cyan(" buildClasspathFromManifest: " + manifest.getAllLibraries().size() + " libraries in manifest (including inherited)"));
String loaderType = instance.getLoaderType().toLowerCase(); String loaderType = instance.getLoaderType().toLowerCase();
boolean isForgeLike = "forge".equals(loaderType) || "neoforge".equals(loaderType); boolean isForgeLike = "forge".equals(loaderType) || "neoforge".equals(loaderType);
@@ -628,7 +629,7 @@ public class LaunchCommandBuilder {
if (isValidJar(libPath)) { if (isValidJar(libPath)) {
paths.add(libPath.toAbsolutePath().toString()); paths.add(libPath.toAbsolutePath().toString());
} else { } else {
System.out.println(ZAnsi.yellow(" Corrupt library, deleting: " + lib.name)); LauncherLogger.info(ZAnsi.yellow(" Corrupt library, deleting: " + lib.name));
Files.delete(libPath); Files.delete(libPath);
} }
} else { } else {
@@ -638,7 +639,7 @@ public class LaunchCommandBuilder {
if (isValidJar(fallbackPath)) { if (isValidJar(fallbackPath)) {
paths.add(fallbackPath.toAbsolutePath().toString()); paths.add(fallbackPath.toAbsolutePath().toString());
} else { } else {
System.out.println(ZAnsi.yellow(" Corrupt library, deleting: " + lib.name)); LauncherLogger.info(ZAnsi.yellow(" Corrupt library, deleting: " + lib.name));
Files.delete(fallbackPath); Files.delete(fallbackPath);
} }
} else { } else {
@@ -647,23 +648,23 @@ public class LaunchCommandBuilder {
Path found = scanForJar(librariesDir, artifactName); Path found = scanForJar(librariesDir, artifactName);
if (found != null) { if (found != null) {
if (isValidJar(found)) { if (isValidJar(found)) {
System.out.println(ZAnsi.green(" Found by scan: " + lib.name + "" + librariesDir.relativize(found))); LauncherLogger.info(ZAnsi.green(" Found by scan: " + lib.name + "" + librariesDir.relativize(found)));
paths.add(found.toAbsolutePath().toString()); paths.add(found.toAbsolutePath().toString());
} else { } else {
System.out.println(ZAnsi.yellow(" Corrupt library (scan), deleting: " + found.getFileName())); LauncherLogger.info(ZAnsi.yellow(" Corrupt library (scan), deleting: " + found.getFileName()));
Files.delete(found); Files.delete(found);
} }
} else { } else {
System.out.println(ZAnsi.yellow(" Library not found (even after scan): " + lib.name)); LauncherLogger.info(ZAnsi.yellow(" Library not found (even after scan): " + lib.name));
} }
} }
} }
} }
System.out.println(ZAnsi.cyan(" buildClasspathFromManifest: " + paths.size() + " libraries in classpath")); LauncherLogger.info(ZAnsi.cyan(" buildClasspathFromManifest: " + paths.size() + " libraries in classpath"));
if (isForgeLike) { if (isForgeLike) {
System.out.println(ZAnsi.cyan(" Version ID: " + getVersionId() + " (MC: " + instance.getMinecraftVersion() + ")")); LauncherLogger.info(ZAnsi.cyan(" Version ID: " + getVersionId() + " (MC: " + instance.getMinecraftVersion() + ")"));
System.out.println(ZAnsi.cyan(" Paths dir: " + instance.getPath().resolve("versions"))); LauncherLogger.info(ZAnsi.cyan(" Paths dir: " + instance.getPath().resolve("versions")));
} }
if (includeVersionJar) { if (includeVersionJar) {
@@ -671,9 +672,9 @@ public class LaunchCommandBuilder {
if (versionJar != null) { if (versionJar != null) {
if (isValidJar(versionJar)) { if (isValidJar(versionJar)) {
paths.add(0, versionJar.toAbsolutePath().toString()); paths.add(0, versionJar.toAbsolutePath().toString());
System.out.println(ZAnsi.green(" Added version jar: " + versionJar.getFileName())); LauncherLogger.info(ZAnsi.green(" Added version jar: " + versionJar.getFileName()));
} else { } else {
System.out.println(ZAnsi.yellow(" Corrupt version jar, deleting: " + versionJar.getFileName())); LauncherLogger.info(ZAnsi.yellow(" Corrupt version jar, deleting: " + versionJar.getFileName()));
Files.delete(versionJar); Files.delete(versionJar);
} }
} }
@@ -785,7 +786,7 @@ public class LaunchCommandBuilder {
Path targetJar = targetDir.resolve(versionId + ".jar"); Path targetJar = targetDir.resolve(versionId + ".jar");
if (Files.exists(targetJar) && isValidJar(targetJar)) { if (Files.exists(targetJar) && isValidJar(targetJar)) {
System.out.println(ZAnsi.green(" Version jar already exists: " + targetJar)); LauncherLogger.info(ZAnsi.green(" Version jar already exists: " + targetJar));
return; return;
} }
@@ -795,11 +796,11 @@ public class LaunchCommandBuilder {
Path vanillaJar = versionsDir.resolve(mcVersion).resolve(mcVersion + ".jar"); Path vanillaJar = versionsDir.resolve(mcVersion).resolve(mcVersion + ".jar");
if (Files.exists(vanillaJar) && isValidJar(vanillaJar)) { if (Files.exists(vanillaJar) && isValidJar(vanillaJar)) {
Files.copy(vanillaJar, targetJar); Files.copy(vanillaJar, targetJar);
System.out.println(ZAnsi.green(" Copied vanilla jar for securejarhandler: " + vanillaJar.getFileName() + "" + targetJar)); LauncherLogger.info(ZAnsi.green(" Copied vanilla jar for securejarhandler: " + vanillaJar.getFileName() + "" + targetJar));
return; return;
} }
System.out.println(ZAnsi.yellow(" No vanilla jar found at " + vanillaJar)); LauncherLogger.info(ZAnsi.yellow(" No vanilla jar found at " + vanillaJar));
} }
/** /**
@@ -833,7 +834,7 @@ public class LaunchCommandBuilder {
.findFirst() .findFirst()
.orElse(null); .orElse(null);
if (found != null) { if (found != null) {
System.out.println(ZAnsi.green(" Found SRG client jar: " + found + " (module name: " + getModuleName(found) + ")")); LauncherLogger.info(ZAnsi.green(" Found SRG client jar: " + found + " (module name: " + getModuleName(found) + ")"));
return found; return found;
} }
} catch (Exception e) { /* continue */ } } catch (Exception e) { /* continue */ }
@@ -848,7 +849,7 @@ public class LaunchCommandBuilder {
.findFirst() .findFirst()
.orElse(null); .orElse(null);
if (found != null) { if (found != null) {
System.out.println(ZAnsi.green(" Found SRG client jar (fallback scan): " + found + " (module name: " + getModuleName(found) + ")")); LauncherLogger.info(ZAnsi.green(" Found SRG client jar (fallback scan): " + found + " (module name: " + getModuleName(found) + ")"));
return found; return found;
} }
} catch (Exception e) { /* continue */ } } catch (Exception e) { /* continue */ }
@@ -903,12 +904,12 @@ public class LaunchCommandBuilder {
for (String candidate : candidates) { for (String candidate : candidates) {
Path jarPath = librariesDir.resolve(candidate); Path jarPath = librariesDir.resolve(candidate);
if (Files.exists(jarPath) && isValidJar(jarPath)) { if (Files.exists(jarPath) && isValidJar(jarPath)) {
System.out.println(ZAnsi.green(" Found Forge jar: " + jarPath.getFileName())); LauncherLogger.info(ZAnsi.green(" Found Forge jar: " + jarPath.getFileName()));
return jarPath; return jarPath;
} }
} }
System.out.println(ZAnsi.yellow(" No Forge client/universal jar found!")); LauncherLogger.info(ZAnsi.yellow(" No Forge client/universal jar found!"));
return null; return null;
} }
@@ -1047,7 +1048,7 @@ public class LaunchCommandBuilder {
Path tempFile = Files.createTempFile("zernmc-cp-", ".txt"); Path tempFile = Files.createTempFile("zernmc-cp-", ".txt");
Files.writeString(tempFile, classpath); Files.writeString(tempFile, classpath);
tempFiles.add(tempFile); tempFiles.add(tempFile);
System.out.println(ZAnsi.cyan(" Classpath too long (" + classpath.length() + " chars), using argfile: " + tempFile)); LauncherLogger.info(ZAnsi.cyan(" Classpath too long (" + classpath.length() + " chars), using argfile: " + tempFile));
return "@" + tempFile.toAbsolutePath(); return "@" + tempFile.toAbsolutePath();
} }
} }
@@ -1,5 +1,7 @@
package me.sashegdev.zernmc.launcher.minecraft.launch; package me.sashegdev.zernmc.launcher.minecraft.launch;
import me.sashegdev.zernmc.launcher.utils.LauncherLogger;
import org.json.JSONArray; import org.json.JSONArray;
import org.json.JSONObject; import org.json.JSONObject;
@@ -75,7 +77,7 @@ public class VersionManifest {
this.parent = new VersionManifest(json); this.parent = new VersionManifest(json);
this.parent.resolveParent(versionsDir); this.parent.resolveParent(versionsDir);
} catch (Exception e) { } catch (Exception e) {
System.out.println("Failed to load parent version " + inheritsFrom + ": " + e.getMessage()); LauncherLogger.warn("Failed to load parent version " + inheritsFrom + ": " + e.getMessage());
} }
} }
} }
@@ -18,6 +18,7 @@ import me.sashegdev.zernmc.launcher.minecraft.PackDownloader;
import me.sashegdev.zernmc.launcher.minecraft.ServerPack; import me.sashegdev.zernmc.launcher.minecraft.ServerPack;
import me.sashegdev.zernmc.launcher.minecraft.launch.LaunchCommandBuilder; import me.sashegdev.zernmc.launcher.minecraft.launch.LaunchCommandBuilder;
import me.sashegdev.zernmc.launcher.utils.Config; import me.sashegdev.zernmc.launcher.utils.Config;
import me.sashegdev.zernmc.launcher.utils.DomainSelector;
import me.sashegdev.zernmc.launcher.utils.LauncherLogger; import me.sashegdev.zernmc.launcher.utils.LauncherLogger;
import me.sashegdev.zernmc.launcher.utils.Version; import me.sashegdev.zernmc.launcher.utils.Version;
@@ -216,7 +217,7 @@ public class JFXLauncher extends Application {
try { try {
String existingVersion = Files.readString(versionFile).trim(); String existingVersion = Files.readString(versionFile).trim();
if (existingVersion.equals(currentVersion)) { if (existingVersion.equals(currentVersion)) {
System.out.println("[JFX] Assets up to date (v" + currentVersion + ")"); log("Assets up to date (v" + currentVersion + ")");
return; return;
} }
} catch (Exception ignored) {} } catch (Exception ignored) {}
@@ -233,16 +234,16 @@ public class JFXLauncher extends Application {
String serverVersion = getServerVersion(); String serverVersion = getServerVersion();
if (serverVersion != null && !serverVersion.isEmpty()) { if (serverVersion != null && !serverVersion.isEmpty()) {
System.out.println("[JFX] Loading assets via meta for version " + serverVersion); log("Loading assets via meta for version " + serverVersion);
if (downloadAssetsFromMeta(serverVersion)) { if (downloadAssetsFromMeta(serverVersion)) {
System.out.println("[JFX] Assets loaded via meta"); log("Assets loaded via meta");
Files.writeString(versionFile, currentVersion); Files.writeString(versionFile, currentVersion);
return; return;
} }
System.out.println("[JFX] Meta unavailable, using fallback"); log("Meta unavailable, using fallback");
} }
System.out.println("[JFX] Extracting assets from JAR..."); log("Extracting assets from JAR...");
Files.createDirectories(assetsDir); Files.createDirectories(assetsDir);
Path jarPath = Paths.get(JFXLauncher.class.getProtectionDomain().getCodeSource().getLocation().toURI()); Path jarPath = Paths.get(JFXLauncher.class.getProtectionDomain().getCodeSource().getLocation().toURI());
if (Files.exists(jarPath) && jarPath.toString().endsWith(".jar")) { if (Files.exists(jarPath) && jarPath.toString().endsWith(".jar")) {
@@ -263,11 +264,11 @@ public class JFXLauncher extends Application {
} }
} }
} }
System.out.println("[JFX] Assets extracted from JAR"); log("Assets extracted from JAR");
Files.writeString(versionFile, currentVersion); Files.writeString(versionFile, currentVersion);
} }
} catch (Exception e) { } catch (Exception e) {
System.out.println("[JFX] Error extracting assets: " + e.getMessage()); log("Error extracting assets: " + e.getMessage());
} }
} }
@@ -332,7 +333,7 @@ public class JFXLauncher extends Application {
return true; return true;
} }
} catch (Exception e) { } catch (Exception e) {
System.out.println("[JFX] Error loading via meta: " + e.getMessage()); log("Error loading assets via meta: " + e.getMessage());
return false; return false;
} }
} }
@@ -363,6 +364,7 @@ public class JFXLauncher extends Application {
log("Starting background network init..."); log("Starting background network init...");
Thread netInitThread = new Thread(() -> { Thread netInitThread = new Thread(() -> {
try { try {
DomainSelector.selectBest();
ZHttpClient.checkAllServicesOnStartup(); ZHttpClient.checkAllServicesOnStartup();
log("Network init complete"); log("Network init complete");
} catch (Exception e) { } catch (Exception e) {
@@ -391,9 +393,9 @@ public class JFXLauncher extends Application {
engine.setOnAlert(e -> { engine.setOnAlert(e -> {
String msg = e.getData(); String msg = e.getData();
if (msg.startsWith("[LOG] ")) { if (msg.startsWith("[LOG] ")) {
System.out.println("[JS] " + msg.substring(6)); log("[JS] " + msg.substring(6));
} else if (msg.startsWith("[ERR] ")) { } else if (msg.startsWith("[ERR] ")) {
System.err.println("[JS] " + msg.substring(6)); log("[JS ERROR] " + msg.substring(6));
} else { } else {
log("[UI] Alert: " + msg); log("[UI] Alert: " + msg);
} }
@@ -493,6 +495,8 @@ public class JFXLauncher extends Application {
server.createContext("/api/account", this::handleAccount); server.createContext("/api/account", this::handleAccount);
server.createContext("/api/instances", this::handleInstances); server.createContext("/api/instances", this::handleInstances);
server.createContext("/api/launch", this::handleLaunch); server.createContext("/api/launch", this::handleLaunch);
server.createContext("/api/offline/status", this::handleOfflineStatus);
server.createContext("/api/offline-launch", this::handleOfflineLaunch);
server.createContext("/api/install", this::handleInstall); server.createContext("/api/install", this::handleInstall);
server.createContext("/api/install/progress", this::handleInstallProgress); server.createContext("/api/install/progress", this::handleInstallProgress);
server.createContext("/api/network/status", this::handleNetworkStatus); server.createContext("/api/network/status", this::handleNetworkStatus);
@@ -612,9 +616,8 @@ public class JFXLauncher extends Application {
sendJson(exchange, Map.of("success", false, "error", result.getError())); sendJson(exchange, Map.of("success", false, "error", result.getError()));
} }
} catch (Exception e) { } catch (Exception e) {
log("Login error: " + e.getMessage()); log("Login error: " + AuthManager.friendlyConnectionError(e));
e.printStackTrace(); sendJson(exchange, Map.of("success", false, "error", AuthManager.friendlyConnectionError(e)));
sendJson(exchange, Map.of("success", false, "error", e.getMessage()));
} }
} }
@@ -641,7 +644,6 @@ public class JFXLauncher extends Application {
try { try {
boolean loggedIn = AuthManager.isLoggedIn(); boolean loggedIn = AuthManager.isLoggedIn();
boolean authExists = AuthManager.authFileExists(); boolean authExists = AuthManager.authFileExists();
System.out.println("[AUTH] handleAutoLogin: isLoggedIn=" + loggedIn + " authFile=" + authExists);
log("handleAutoLogin: isLoggedIn=" + loggedIn + " authFile=" + authExists); log("handleAutoLogin: isLoggedIn=" + loggedIn + " authFile=" + authExists);
if (AuthManager.tryAutoLogin()) { if (AuthManager.tryAutoLogin()) {
Map<String, Object> data = new HashMap<>(); Map<String, Object> data = new HashMap<>();
@@ -650,15 +652,12 @@ public class JFXLauncher extends Application {
data.put("role", AuthManager.getRole()); data.put("role", AuthManager.getRole());
data.put("roleName", AuthManager.getRoleName()); data.put("roleName", AuthManager.getRoleName());
sendJson(exchange, Map.of("success", true, "data", data, "autoLogin", true)); sendJson(exchange, Map.of("success", true, "data", data, "autoLogin", true));
System.out.println("[AUTH] Auto-login performed: " + AuthManager.getUsername());
log("Auto-login performed: " + AuthManager.getUsername()); log("Auto-login performed: " + AuthManager.getUsername());
} else { } else {
System.out.println("[AUTH] handleAutoLogin: no valid session (authFile=" + authExists + ")");
log("handleAutoLogin: no valid session (authFile=" + authExists + ")"); log("handleAutoLogin: no valid session (authFile=" + authExists + ")");
sendJson(exchange, Map.of("success", false, "autoLogin", false)); sendJson(exchange, Map.of("success", false, "autoLogin", false));
} }
} catch (Exception e) { } catch (Exception e) {
System.out.println("[AUTH] handleAutoLogin error: " + e.getMessage());
log("handleAutoLogin error: " + e.getMessage()); log("handleAutoLogin error: " + e.getMessage());
sendJson(exchange, Map.of("success", false, "error", e.getMessage())); sendJson(exchange, Map.of("success", false, "error", e.getMessage()));
} }
@@ -721,6 +720,54 @@ public class JFXLauncher extends Application {
} }
} }
private void handleOfflineStatus(HttpExchange exchange) {
try {
boolean offline = isOfflineMode();
sendJson(exchange, Map.of("success", true, "offline", offline));
} catch (Exception e) {
sendJson(exchange, Map.of("success", false, "error", e.getMessage()));
}
}
private void handleOfflineLaunch(HttpExchange exchange) {
try {
Map<String, String> body = parseJson(exchange.getRequestBody());
String name = body.get("name");
String nickname = body.get("nickname");
Instance instance = InstanceManager.getInstance(name);
if (instance == null) {
sendJson(exchange, Map.of("success", false, "error", "Pack not found"));
return;
}
var result = api.launch().launchOffline(name, nickname);
if (result.isSuccess()) {
Map<String, Object> data = new HashMap<>();
data.put("pid", result.getData().getPid());
data.put("status", result.getData().getStatus());
sendJson(exchange, Map.of("success", true, "data", data));
log("Offline launched: " + name + " pid=" + result.getData().getPid());
} else {
log("Offline launch failed: " + result.getError());
sendJson(exchange, Map.of("success", false, "error", result.getError()));
}
} catch (Exception e) {
log("handleOfflineLaunch error: " + e.getMessage());
sendJson(exchange, Map.of("success", false, "error", e.getMessage()));
}
}
private boolean isOfflineMode() {
if ("true".equalsIgnoreCase(System.getProperty("zernmc.offline", "false"))) {
return true;
}
if (ZHttpClient.isNetworkInitialized() && !ZHttpClient.isZernServerHealthy()) {
return true;
}
return false;
}
private void handleInstall(HttpExchange exchange) { private void handleInstall(HttpExchange exchange) {
if (!installInProgress.compareAndSet(false, true)) { if (!installInProgress.compareAndSet(false, true)) {
sendJson(exchange, Map.of("success", false, "error", "Installation already in progress")); sendJson(exchange, Map.of("success", false, "error", "Installation already in progress"));
@@ -769,8 +816,8 @@ public class JFXLauncher extends Application {
boolean success = false; boolean success = false;
if (!ZHttpClient.isNetworkInitialized()) { if (!ZHttpClient.isNetworkInitialized()) {
log("Network init not finished, checking Mojang services synchronously..."); log("Network init not finished, running full service check (CLI-style)...");
ZHttpClient.forceCheckMojangServices(); ZHttpClient.checkAllServicesOnStartup();
} }
if ("zernmc".equalsIgnoreCase(loader)) { if ("zernmc".equalsIgnoreCase(loader)) {
@@ -895,17 +942,16 @@ public class JFXLauncher extends Application {
exchange.sendResponseHeaders(200, 0); exchange.sendResponseHeaders(200, 0);
final OutputStream os = exchange.getResponseBody(); final OutputStream os = exchange.getResponseBody();
// Bounded queue: producers (LauncherLogger on the install thread) must
// never block on a slow/stalled SSE client. If the UI can't keep up,
// lines are dropped here - they still reach the log file and buffer.
final java.util.concurrent.BlockingQueue<String> queue =
new java.util.concurrent.LinkedBlockingQueue<>(5000);
LogConsumer consumer = new LogConsumer() { LogConsumer consumer = new LogConsumer() {
@Override @Override
public synchronized void onLog(String line) { public void onLog(String line) {
try { queue.offer(line);
String data = "data: " + line.replace("\n", "").replace("\r", "") + "\n\n";
os.write(data.getBytes(StandardCharsets.UTF_8));
os.flush();
} catch (Exception e) {
removeLogConsumer(this);
}
} }
}; };
consumerRef[0] = consumer; consumerRef[0] = consumer;
@@ -915,9 +961,13 @@ public class JFXLauncher extends Application {
Thread.currentThread().setName("sse-logs-stream"); Thread.currentThread().setName("sse-logs-stream");
while (!Thread.currentThread().isInterrupted()) { while (!Thread.currentThread().isInterrupted()) {
try { try {
os.write(": heartbeat\n\n".getBytes(StandardCharsets.UTF_8)); String line = queue.poll(10, java.util.concurrent.TimeUnit.SECONDS);
if (line != null) {
os.write(("data: " + line.replace("\n", "").replace("\r", "") + "\n\n").getBytes(StandardCharsets.UTF_8));
} else {
os.write(": heartbeat\n\n".getBytes(StandardCharsets.UTF_8));
}
os.flush(); os.flush();
Thread.sleep(10000);
} catch (InterruptedException e) { break; } catch (Exception e) { break; } } catch (InterruptedException e) { break; } catch (Exception e) { break; }
} }
} catch (Exception ignored) {} finally { } catch (Exception ignored) {} finally {
@@ -939,17 +989,14 @@ public class JFXLauncher extends Application {
exchange.sendResponseHeaders(200, 0); exchange.sendResponseHeaders(200, 0);
final OutputStream os = exchange.getResponseBody(); final OutputStream os = exchange.getResponseBody();
// Bounded queue: producers must never block on a slow/stalled SSE client.
final java.util.concurrent.BlockingQueue<String> queue =
new java.util.concurrent.LinkedBlockingQueue<>(5000);
LogConsumer consumer = new LogConsumer() { LogConsumer consumer = new LogConsumer() {
@Override @Override
public synchronized void onLog(String line) { public void onLog(String line) {
try { queue.offer(line);
String data = "data: " + line.replace("\n", "").replace("\r", "") + "\n\n";
os.write(data.getBytes(StandardCharsets.UTF_8));
os.flush();
} catch (Exception e) {
removeGameLogConsumer(this);
}
} }
}; };
consumerRef[0] = consumer; consumerRef[0] = consumer;
@@ -959,9 +1006,13 @@ public class JFXLauncher extends Application {
Thread.currentThread().setName("sse-game-logs-stream"); Thread.currentThread().setName("sse-game-logs-stream");
while (!Thread.currentThread().isInterrupted()) { while (!Thread.currentThread().isInterrupted()) {
try { try {
os.write(": heartbeat\n\n".getBytes(StandardCharsets.UTF_8)); String line = queue.poll(10, java.util.concurrent.TimeUnit.SECONDS);
if (line != null) {
os.write(("data: " + line.replace("\n", "").replace("\r", "") + "\n\n").getBytes(StandardCharsets.UTF_8));
} else {
os.write(": heartbeat\n\n".getBytes(StandardCharsets.UTF_8));
}
os.flush(); os.flush();
Thread.sleep(10000);
} catch (InterruptedException e) { break; } catch (Exception e) { break; } } catch (InterruptedException e) { break; } catch (Exception e) { break; }
} }
} catch (Exception ignored) {} finally { } catch (Exception ignored) {} finally {
@@ -1080,7 +1131,7 @@ public class JFXLauncher extends Application {
sendJson(exchange, Map.of("success", false, "error", result.getError())); sendJson(exchange, Map.of("success", false, "error", result.getError()));
} }
} catch (Exception e) { } catch (Exception e) {
sendJson(exchange, Map.of("success", false, "error", e.getMessage())); sendJson(exchange, Map.of("success", false, "error", AuthManager.friendlyConnectionError(e)));
} }
} }
@@ -1624,22 +1675,10 @@ public class JFXLauncher extends Application {
Files.createDirectories(modsDir); Files.createDirectories(modsDir);
Path targetFile = modsDir.resolve(modName); Path targetFile = modsDir.resolve(modName);
// Download mod from server // Download mod from server (through ZHttpClient so the read timeout is
// actually enforced raw client.send() hangs forever on a stalled body)
String url = ZHttpClient.getBaseUrl() + "/whitelist/mods/" + modHash + "/" + modName; String url = ZHttpClient.getBaseUrl() + "/whitelist/mods/" + modHash + "/" + modName;
java.net.http.HttpRequest request = java.net.http.HttpRequest.newBuilder() ZHttpClient.downloadFileWithSmartProxy(url, targetFile);
.uri(java.net.URI.create(url))
.timeout(java.time.Duration.ofMinutes(5))
.header("Authorization", "Bearer " + AuthManager.getAccessToken())
.GET()
.build();
java.net.http.HttpResponse<Path> resp = java.net.http.HttpClient.newHttpClient()
.send(request, java.net.http.HttpResponse.BodyHandlers.ofFile(targetFile));
if (resp.statusCode() != 200) {
Files.deleteIfExists(targetFile);
sendJson(exchange, Map.of("success", false, "error", "HTTP " + resp.statusCode()));
return;
}
sendJson(exchange, Map.of("success", true, "message", modName + " installed")); sendJson(exchange, Map.of("success", true, "message", modName + " installed"));
} catch (Exception e) { } catch (Exception e) {
@@ -1735,7 +1774,7 @@ public class JFXLauncher extends Application {
} }
} }
private void log(String msg) { private static void log(String msg) {
String timestamp = java.time.LocalTime.now().format(java.time.format.DateTimeFormatter.ofPattern("HH:mm:ss")); String timestamp = java.time.LocalTime.now().format(java.time.format.DateTimeFormatter.ofPattern("HH:mm:ss"));
String entry = "[" + timestamp + "] " + msg; String entry = "[" + timestamp + "] " + msg;
synchronized (launcherLogBuffer) { synchronized (launcherLogBuffer) {
@@ -13,7 +13,7 @@ public class Config {
private static final Properties props = new Properties(); private static final Properties props = new Properties();
private static volatile int maxMemory = 4096; private static volatile int maxMemory = 4096;
private static volatile String serverUrl = "https://api.zernmc.ru"; private static volatile String serverUrl = "https://api.zern.cc";
private static volatile String lastUsername = "Player"; private static volatile String lastUsername = "Player";
private static volatile int windowWidth = 1280; private static volatile int windowWidth = 1280;
private static volatile int windowHeight = 720; private static volatile int windowHeight = 720;
@@ -144,6 +144,11 @@ public class Config {
return serverUrl; return serverUrl;
} }
public static void setServerUrl(String url) {
serverUrl = url != null && !url.isBlank() ? url : serverUrl;
save();
}
public static String getLastUsername() { public static String getLastUsername() {
return lastUsername; return lastUsername;
} }
@@ -0,0 +1,126 @@
package me.sashegdev.zernmc.launcher.utils;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
/**
* Picks the fastest reachable ZernMC API domain at launcher startup.
* TSPU/SNI-обход: если основной api.zern.cc недоступен (заблокирован ТСПУ),
* пробиваем legacy-домены и гео-зеркала и переключаемся на первый живой/быстрый.
*/
public final class DomainSelector {
private static final HttpClient client = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(8))
.version(HttpClient.Version.HTTP_1_1)
.build();
private static final List<String> FALLBACK_CANDIDATES = List.of(
"https://api.zern.cc",
"https://api.zernmc.ru",
"https://api.zernmc.online",
"https://api.pl.zern.cc",
"https://api.swe.zern.cc"
);
private static final String MIRRORS_PATH = "/launcher/mirrors";
private static final String VERSION_PATH = "/launcher/version";
private DomainSelector() {
}
/**
* @return выбранный base URL или null, если ни один домен не ответил.
*/
public static String selectBest() {
List<String> candidates = new ArrayList<>();
String configured = Config.getServerUrl();
if (configured != null && !configured.isBlank() && !candidates.contains(configured)) {
candidates.add(configured);
}
for (String c : FALLBACK_CANDIDATES) {
if (!candidates.contains(c)) {
candidates.add(c);
}
}
candidates.addAll(fetchMirrors());
String best = null;
long bestMs = Long.MAX_VALUE;
for (String base : candidates) {
Probe r = probe(base);
if (r.ok && r.latencyMs >= 0 && r.latencyMs < bestMs) {
bestMs = r.latencyMs;
best = base;
}
}
if (best == null) {
// nothing reachable: keep whatever is configured
System.err.println(ZAnsi.brightRed("DomainSelector: no reachable API domain"));
return null;
}
if (!best.equals(configured)) {
ZHttpClient.setBaseUrl(best);
Config.setServerUrl(best);
System.out.println(ZAnsi.cyan("Selected API domain: " + best)
+ " (" + bestMs + " ms)");
}
return best;
}
private static List<String> fetchMirrors() {
List<String> mirrors = new ArrayList<>();
for (String candidate : FALLBACK_CANDIDATES) {
Probe p = probe(candidate);
if (!p.ok || p.body == null) {
continue;
}
try {
org.json.JSONObject json = new org.json.JSONObject(p.body);
var arr = json.optJSONArray("mirrors");
if (arr == null) {
continue;
}
for (int i = 0; i < arr.length(); i++) {
String url = arr.optJSONObject(i) != null
? arr.optJSONObject(i).optString("url", null) : null;
if (url != null && !url.isBlank() && !mirrors.contains(url)) {
mirrors.add(url);
}
}
break; // первый живой источник списка достаточен
} catch (Exception ignored) {
// continue to next candidate
}
}
return mirrors;
}
private static Probe probe(String baseUrl) {
long t0 = System.currentTimeMillis();
try {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(baseUrl + MIRRORS_PATH))
.timeout(Duration.ofSeconds(8))
.GET()
.header("User-Agent", "ZernMC-Launcher/DomainSelector")
.build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
long ms = System.currentTimeMillis() - t0;
boolean ok = response.statusCode() == 200;
return new Probe(ok, ms, ok ? response.body() : null);
} catch (Exception e) {
return new Probe(false, System.currentTimeMillis() - t0, null);
}
}
private record Probe(boolean ok, long latencyMs, String body) {
}
}
@@ -15,6 +15,7 @@ import java.util.concurrent.BlockingQueue;
import java.util.concurrent.LinkedBlockingQueue; import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.ReentrantLock; import java.util.concurrent.locks.ReentrantLock;
import java.util.regex.Pattern;
public class LauncherLogger { public class LauncherLogger {
@@ -25,6 +26,7 @@ public class LauncherLogger {
private static final int STDOUT_QUEUE_CAPACITY = 2048; private static final int STDOUT_QUEUE_CAPACITY = 2048;
private static final BlockingQueue<String> stdoutQueue = new LinkedBlockingQueue<>(STDOUT_QUEUE_CAPACITY); private static final BlockingQueue<String> stdoutQueue = new LinkedBlockingQueue<>(STDOUT_QUEUE_CAPACITY);
private static final Thread stdoutWriterThread; private static final Thread stdoutWriterThread;
private static final Pattern ANSI_PATTERN = Pattern.compile("\u001B\\[[0-9;]*[A-Za-z]");
static { static {
stdoutWriterThread = new Thread(() -> { stdoutWriterThread = new Thread(() -> {
@@ -97,9 +99,12 @@ public class LauncherLogger {
// block application threads. // block application threads.
offerStdout(line); offerStdout(line);
// File and UI log never get ANSI escape codes; the console keeps them.
String plainLine = ANSI_PATTERN.matcher(line).replaceAll("");
// Forward to the JFX UI log so installer/progress output is visible // Forward to the JFX UI log so installer/progress output is visible
// in the GUI. The bridge is a safe no-op when JavaFX is unavailable. // in the GUI. The bridge is a safe no-op when JavaFX is unavailable.
JFXBridge.appendLauncherLogFromLogger(line); JFXBridge.appendLauncherLogFromLogger(plainLine);
if (t != null) { if (t != null) {
StringWriter sw = new StringWriter(); StringWriter sw = new StringWriter();
@@ -120,7 +125,7 @@ public class LauncherLogger {
} }
if (acquired) { if (acquired) {
try { try {
Files.writeString(logFile, line + "\n", StandardOpenOption.APPEND); Files.writeString(logFile, plainLine + "\n", StandardOpenOption.APPEND);
if (t != null) { if (t != null) {
StringWriter sw = new StringWriter(); StringWriter sw = new StringWriter();
PrintWriter pw = new PrintWriter(sw); PrintWriter pw = new PrintWriter(sw);
@@ -4,14 +4,27 @@ import me.sashegdev.zernmc.launcher.ui.jfx.JFXBridge;
import java.text.DecimalFormat; import java.text.DecimalFormat;
/**
* Progress reporting for installs/downloads.
*
* IMPORTANT: never writes directly to System.out/System.err. In JFX mode the
* launcher runs under javaw.exe whose stdout pipe is drained by the parent exe;
* once that pipe fills (or the exe stops reading), a synchronous
* System.out.write/flush blocks the calling (install/UI) thread forever.
* All console/progress output must go through LauncherLogger, which is
* non-blocking by design (bounded stdout queue + tryLock file write + SSE queue).
*/
public class ProgressBar { public class ProgressBar {
private static final int BAR_LENGTH = 40;
private static final DecimalFormat DF = new DecimalFormat("#.##"); private static final DecimalFormat DF = new DecimalFormat("#.##");
private static String currentLabel = ""; private static String currentLabel = "";
private static long currentTotal = 0; private static long currentTotal = 0;
private static final long DOWNLOAD_LOG_INTERVAL_MS = 500;
private static volatile long lastDownloadLogMs = 0;
private static volatile long lastIndeterminateLogMs = 0;
public static void show(String label, long current, long total, String unit) { public static void show(String label, long current, long total, String unit) {
currentLabel = label; currentLabel = label;
currentTotal = total; currentTotal = total;
@@ -19,19 +32,11 @@ public class ProgressBar {
JFXBridge.setInstallProgress(label, (int) current, (int) total); JFXBridge.setInstallProgress(label, (int) current, (int) total);
if (total <= 0) { if (total <= 0) {
System.out.print("\r" + ZAnsi.cyan(label) + " ..."); LauncherLogger.info(label + " ...");
return; return;
} }
double progress = (double) current / total; int percent = (int) ((double) current / total * 100);
int filled = (int) (progress * BAR_LENGTH); LauncherLogger.info(label + " " + percent + "% (" + current + "/" + total + " " + unit + ")");
String bar = "".repeat(filled) + "".repeat(BAR_LENGTH - filled);
int percent = (int) (progress * 100);
String text = String.format("%s [%s] %3d%% (%d/%d %s)",
ZAnsi.cyan(label), bar, percent, current, total, unit);
System.out.print("\r" + text);
System.out.flush();
} }
public static void showDownload(String label, long downloaded, long totalBytes) { public static void showDownload(String label, long downloaded, long totalBytes) {
@@ -40,25 +45,13 @@ public class ProgressBar {
JFXBridge.setInstallProgress(label + " " + formatBytes(downloaded) + "/" + formatBytes(totalBytes), (int) downloaded, (int) totalBytes); JFXBridge.setInstallProgress(label + " " + formatBytes(downloaded) + "/" + formatBytes(totalBytes), (int) downloaded, (int) totalBytes);
if (totalBytes <= 0) { // Throttle console/file logging: called once per chunk during downloads.
System.out.print("\r" + ZAnsi.cyan(label) + " ..."); long now = System.currentTimeMillis();
return; if (now - lastDownloadLogMs >= DOWNLOAD_LOG_INTERVAL_MS) {
lastDownloadLogMs = now;
LauncherLogger.info(label + " " + formatBytes(downloaded)
+ (totalBytes > 0 ? "/" + formatBytes(totalBytes) : ""));
} }
double progress = (double) downloaded / totalBytes;
int filled = (int) (progress * BAR_LENGTH);
String bar = "".repeat(filled) + "".repeat(BAR_LENGTH - filled);
String percent = DF.format(progress * 100);
String text = String.format("%s [%s] %6s%% %s / %s",
ZAnsi.cyan(label),
bar,
percent,
formatBytes(downloaded),
formatBytes(totalBytes));
System.out.print("\r" + text);
System.out.flush();
} }
public static void showAnimated(String label, long current, long total, String unit) { public static void showAnimated(String label, long current, long total, String unit) {
@@ -67,18 +60,51 @@ public class ProgressBar {
JFXBridge.setInstallProgress(label, (int) current, (int) (total > 0 ? total : 100)); JFXBridge.setInstallProgress(label, (int) current, (int) (total > 0 ? total : 100));
if (total <= 0) { long now = System.currentTimeMillis();
char[] spinner = {'|', '/', '-', '\\'}; if (now - lastDownloadLogMs >= DOWNLOAD_LOG_INTERVAL_MS) {
int idx = (int) (current / 1024) % 4; lastDownloadLogMs = now;
System.out.print("\r" + label + " [" + spinner[idx] + "] " + formatBytes(current)); LauncherLogger.info(label + ": " + formatBytes(current)
} else { + (total > 0 ? "/" + formatBytes(total) + " " + unit : ""));
show(label, (int) ((current * 100) / total), 100, unit);
} }
} }
public static void finish(String message) { public static void finish(String message) {
System.out.println("\r" + ZAnsi.brightGreen(message + " done")); LauncherLogger.info(message + " done");
System.out.flush(); }
/**
* Shows a stage without a known total (e.g. running the Forge/NeoForge/Fabric
* installer subprocess). Always updates the UI label so the user sees what the
* launcher is doing right now; console/file logging is throttled to avoid spam.
*/
public static void showIndeterminate(String label) {
currentLabel = label;
currentTotal = 0;
JFXBridge.setInstallProgress(label, 0, 0);
long now = System.currentTimeMillis();
if (now - lastIndeterminateLogMs >= DOWNLOAD_LOG_INTERVAL_MS) {
lastIndeterminateLogMs = now;
LauncherLogger.info(label + " ...");
}
}
/**
* Strips ANSI codes, indentation and [category] prefixes from a child-process
* output line so it can be used as a short, human-readable progress label.
*/
public static String cleanLine(String line) {
if (line == null) return "";
String s = line.replaceAll("\\u001B\\[[;\\d]*m", "").trim();
int bracket = s.indexOf("] ");
if (s.startsWith("[") && bracket > 0) {
s = s.substring(bracket + 2).trim();
}
if (s.length() > 60) {
s = s.substring(0, 57) + "...";
}
return s;
} }
public static void setStage(String stageName, int stageIndex, int stageCount) { public static void setStage(String stageName, int stageIndex, int stageCount) {
@@ -86,8 +112,7 @@ public class ProgressBar {
} }
public static void clearLine() { public static void clearLine() {
System.out.print("\r" + " ".repeat(110) + "\r"); // No-op: console progress now goes through LauncherLogger.
System.out.flush();
} }
public static String formatBytes(long bytes) { public static String formatBytes(long bytes) {
@@ -11,6 +11,7 @@ import java.net.URLEncoder;
import java.net.http.HttpClient; import java.net.http.HttpClient;
import java.net.http.HttpRequest; import java.net.http.HttpRequest;
import java.net.http.HttpResponse; import java.net.http.HttpResponse;
import java.net.http.HttpTimeoutException;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
@@ -19,18 +20,36 @@ import java.util.ArrayList;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicBoolean;
import java.util.stream.Stream; import java.util.stream.Stream;
public class ZHttpClient { public class ZHttpClient {
/**
* Dedicated daemon executor for the shared HttpClient. sendAsync() work runs
* here (never the common ForkJoinPool, so a stalled download cannot starve
* JFX's pools), and daemon threads never keep the launcher JVM alive.
*/
private static final ExecutorService CLIENT_EXECUTOR = Executors.newCachedThreadPool(r -> {
Thread t = new Thread(r, "zern-http-client");
t.setDaemon(true);
return t;
});
private static final HttpClient client = HttpClient.newBuilder() private static final HttpClient client = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(15)) .connectTimeout(Duration.ofSeconds(15))
.version(HttpClient.Version.HTTP_1_1) .version(HttpClient.Version.HTTP_1_1)
.executor(CLIENT_EXECUTOR)
.build(); .build();
private static String BASE_URL = "https://api.zernmc.ru"; private static String BASE_URL = "https://api.zern.cc";
private static final AtomicBoolean useProxyMode = new AtomicBoolean(false); private static final AtomicBoolean useProxyMode = new AtomicBoolean(false);
private static final AtomicBoolean proxyTested = new AtomicBoolean(false); private static final AtomicBoolean proxyTested = new AtomicBoolean(false);
@@ -45,7 +64,7 @@ public class ZHttpClient {
} }
public enum ServiceType { public enum ServiceType {
ZERN_SERVER("https://api.zernmc.ru", true), ZERN_SERVER("https://api.zern.cc", true),
FABRIC_META("https://meta.fabricmc.net", false), FABRIC_META("https://meta.fabricmc.net", false),
FABRIC_MAVEN("https://maven.fabricmc.net", false), FABRIC_MAVEN("https://maven.fabricmc.net", false),
MOJANG_META("https://piston-meta.mojang.com", false), MOJANG_META("https://piston-meta.mojang.com", false),
@@ -129,12 +148,19 @@ public class ZHttpClient {
serviceFailCount.put(service, MAX_FAILS_BEFORE_PROXY); serviceFailCount.put(service, MAX_FAILS_BEFORE_PROXY);
} }
} }
LauncherLogger.info("[NET] " + service.name() + ": "
+ (isHealthy ? "OK" : "unavailable") +
(serviceProxyMode.get(service) ? " (proxy mode)" : " (direct)"));
} }
if (!serviceHealthy.get(ServiceType.ZERN_SERVER)) { if (!serviceHealthy.get(ServiceType.ZERN_SERVER)) {
if (verbose) { if (verbose) {
System.out.println(ZAnsi.brightRed("Critical error: Zern server is unreachable!")); System.out.println(ZAnsi.brightRed("Critical error: Zern server is unreachable!"));
} }
LauncherLogger.warn("[NET] Critical: Zern server unreachable");
} else {
String ip = getClientPublicIp();
LauncherLogger.info("[NET] Client public IP: " + (ip != null ? ip : "unknown"));
} }
proxyTested.set(true); proxyTested.set(true);
@@ -144,6 +170,29 @@ public class ZHttpClient {
} }
} }
/**
* Asks the Zern server what public IP it sees for this client (used to
* attribute user logs when login/registration fails). Returns null on failure.
*/
public static String getClientPublicIp() {
try {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(BASE_URL + "/launcher/ip"))
.timeout(Duration.ofMillis(15000))
.GET()
.header("User-Agent", "ZernMC-Launcher/IPCheck")
.build();
HttpResponse<String> response = sendBounded(request, HttpResponse.BodyHandlers.ofString(), 15);
if (response.statusCode() == 200) {
JSONObject json = new JSONObject(response.body());
return json.optString("ip", null);
}
} catch (Exception e) {
return null;
}
return null;
}
public static void forceCheckMojangServices() { public static void forceCheckMojangServices() {
System.out.println(ZAnsi.cyan("Forcing Mojang services check...")); System.out.println(ZAnsi.cyan("Forcing Mojang services check..."));
@@ -164,6 +213,9 @@ public class ZHttpClient {
} }
private static boolean checkServiceHealth(ServiceType service) { private static boolean checkServiceHealth(ServiceType service) {
if (service == ServiceType.ZERN_SERVER) {
return checkDirectConnection(BASE_URL);
}
return checkDirectConnection(service.getBaseUrl()); return checkDirectConnection(service.getBaseUrl());
} }
@@ -219,7 +271,7 @@ public class ZHttpClient {
if (isHealthy && serviceProxyMode.get(service)) { if (isHealthy && serviceProxyMode.get(service)) {
serviceProxyMode.put(service, false); serviceProxyMode.put(service, false);
serviceFailCount.put(service, 0); serviceFailCount.put(service, 0);
System.out.println(ZAnsi.green("[NET] " + service.name() + " restored, switched to direct connection")); LauncherLogger.info("[NET] " + service.name() + " restored, switched to direct connection");
} else if (!isHealthy && !serviceProxyMode.get(service)) { } else if (!isHealthy && !serviceProxyMode.get(service)) {
int fails = serviceFailCount.getOrDefault(service, 0) + 1; int fails = serviceFailCount.getOrDefault(service, 0) + 1;
serviceFailCount.put(service, fails); serviceFailCount.put(service, fails);
@@ -227,7 +279,7 @@ public class ZHttpClient {
if (fails >= MAX_FAILS_BEFORE_PROXY) { if (fails >= MAX_FAILS_BEFORE_PROXY) {
serviceProxyMode.put(service, true); serviceProxyMode.put(service, true);
System.out.println(ZAnsi.yellow("[NET] " + service.name() + " unavailable, proxy mode enabled")); LauncherLogger.warn("[NET] " + service.name() + " unavailable, proxy mode enabled");
} }
} }
} }
@@ -271,6 +323,7 @@ public class ZHttpClient {
msg.contains("connection") || msg.contains("connection") ||
msg.contains("timeout") || msg.contains("timeout") ||
msg.contains("refused") || msg.contains("refused") ||
msg.contains("received within") ||
msg.contains("closed") || msg.contains("closed") ||
msg.contains("reset") || msg.contains("reset") ||
msg.contains("abort"); msg.contains("abort");
@@ -296,23 +349,46 @@ public class ZHttpClient {
if (fails >= MAX_FAILS_BEFORE_PROXY && !serviceProxyMode.get(service)) { if (fails >= MAX_FAILS_BEFORE_PROXY && !serviceProxyMode.get(service)) {
serviceProxyMode.put(service, true); serviceProxyMode.put(service, true);
System.out.println(ZAnsi.yellow("[NET] " + service.name() + " blocked, switching to proxy")); LauncherLogger.warn("[NET] " + service.name() + " blocked, switching to proxy");
} }
} }
/** /**
* Synchronous HTTP send with a hard timeout. Uses client.send() (blocking on the * HTTP send with a hard, actually-enforced timeout.
* calling thread) rather than sendAsync(...).get(...) the latter submits work *
* to the HttpClient's executor and, when that was a non-daemon CachedThreadPool, * NB: HttpRequest.timeout() alone is NOT reliable for streaming body handlers
* kept the JVM alive / could starve the common ForkJoinPool (the v1.0.14.2 * (BodyHandlers.ofFile/ofString): the JDK cancels the request timer as soon as
* "JFX hang (ForkJoinPool)" regression, re-introduced in v1.0.14.3). * response headers arrive, so a download that stalls mid-body blocks
* The request-level .timeout(Duration) bounds the actual network I/O. * client.send() forever (no exception, no retry, no proxy fallback the
* exact "install hangs at 0%" bug). Therefore we drive the exchange through
* sendAsync() + future.get(timeout) and abort it with cancel() on timeout.
*
* The client runs on a dedicated daemon executor (see CLIENT_EXECUTOR), so
* this does not reintroduce the v1.0.14.2 JFX hang (ForkJoinPool starvation /
* non-daemon executor keeping the JVM alive) that forced the old sync-send
* implementation.
*/ */
private static <T> HttpResponse<T> sendBounded(HttpRequest request, HttpResponse.BodyHandler<T> handler, long timeoutSeconds) throws IOException, InterruptedException { private static <T> HttpResponse<T> sendBounded(HttpRequest request, HttpResponse.BodyHandler<T> handler, long timeoutSeconds) throws IOException, InterruptedException {
HttpRequest timed = HttpRequest.newBuilder(request, (name, value) -> true) HttpRequest timed = HttpRequest.newBuilder(request, (name, value) -> true)
.timeout(Duration.ofSeconds(timeoutSeconds)) .timeout(Duration.ofSeconds(timeoutSeconds))
.build(); .build();
return client.send(timed, handler); CompletableFuture<HttpResponse<T>> future = client.sendAsync(timed, handler);
try {
return future.get(timeoutSeconds, TimeUnit.SECONDS);
} catch (TimeoutException e) {
future.cancel(true);
throw new HttpTimeoutException("Response not received within " + timeoutSeconds + "s: " + timed.uri());
} catch (ExecutionException e) {
Throwable cause = e.getCause();
if (cause instanceof IOException) throw (IOException) cause;
if (cause instanceof InterruptedException) throw (InterruptedException) cause;
if (cause instanceof RuntimeException) throw (RuntimeException) cause;
throw new IOException("HTTP request failed: " + timed.uri(), cause);
} catch (InterruptedException e) {
future.cancel(true);
Thread.currentThread().interrupt();
throw e;
}
} }
public static String getWithSmartProxy(String url) throws IOException, InterruptedException { public static String getWithSmartProxy(String url) throws IOException, InterruptedException {
@@ -455,7 +531,7 @@ public class ZHttpClient {
last = e; last = e;
if (!isRetryableError(e)) throw e; if (!isRetryableError(e)) throw e;
if (attempt < maxAttempts) { if (attempt < maxAttempts) {
System.out.println(ZAnsi.yellow("[NET] Server request retry " + attempt + "/" + maxAttempts + " for " + endpoint)); LauncherLogger.warn("[NET] Server request retry " + attempt + "/" + maxAttempts + " for " + endpoint + " (" + e.getMessage() + ")");
try { Thread.sleep(500L * attempt); } catch (InterruptedException ie) { try { Thread.sleep(500L * attempt); } catch (InterruptedException ie) {
Thread.currentThread().interrupt(); Thread.currentThread().interrupt();
throw ie; throw ie;
@@ -501,7 +577,7 @@ public class ZHttpClient {
try { try {
return getMojangVersionManifestViaServer(); return getMojangVersionManifestViaServer();
} catch (Exception e) { } catch (Exception e) {
System.out.println(ZAnsi.yellow("[NET] Server manifest proxy failed (" + e.getMessage() + "), trying direct piston-meta...")); LauncherLogger.warn("[NET] Server manifest proxy failed (" + e.getMessage() + "), trying direct piston-meta...");
return new JSONObject(getWithSmartProxy("https://piston-meta.mojang.com/mc/game/version_manifest_v2.json")); return new JSONObject(getWithSmartProxy("https://piston-meta.mojang.com/mc/game/version_manifest_v2.json"));
} }
} }
@@ -523,17 +599,30 @@ public class ZHttpClient {
} }
public static JSONObject getMojangVersionJson(String versionId) throws IOException, InterruptedException { public static JSONObject getMojangVersionJson(String versionId) throws IOException, InterruptedException {
long deadline = System.currentTimeMillis() + 90_000;
LauncherLogger.info("[NET] Fetching version info for " + versionId + " via server proxy...");
try { try {
return getMojangVersionViaServer(versionId); JSONObject result = getMojangVersionViaServer(versionId);
LauncherLogger.info("[NET] Version info for " + versionId + " fetched via proxy (" + (System.currentTimeMillis() - (deadline - 90_000)) + "ms)");
return result;
} catch (Exception e) { } catch (Exception e) {
System.out.println(ZAnsi.yellow("[NET] Server version proxy failed (" + e.getMessage() + "), trying direct piston-meta...")); if (System.currentTimeMillis() >= deadline) {
throw new IOException("Version " + versionId + " fetch timed out after 90s: " + e.getMessage(), e);
}
LauncherLogger.warn("[NET] Server version proxy failed (" + e.getMessage() + "), trying direct piston-meta...");
JSONObject manifest = getMojangVersionManifest(); JSONObject manifest = getMojangVersionManifest();
JSONArray versions = manifest.getJSONArray("versions"); JSONArray versions = manifest.getJSONArray("versions");
for (int i = 0; i < versions.length(); i++) { for (int i = 0; i < versions.length(); i++) {
JSONObject v = versions.getJSONObject(i); JSONObject v = versions.getJSONObject(i);
if (v.getString("id").equals(versionId)) { if (v.getString("id").equals(versionId)) {
return new JSONObject(getWithSmartProxy(v.getString("url"))); if (System.currentTimeMillis() >= deadline) {
throw new IOException("Version " + versionId + " fetch timed out after 90s");
}
LauncherLogger.info("[NET] Fetching version info for " + versionId + " directly...");
JSONObject result = new JSONObject(getWithSmartProxy(v.getString("url")));
LauncherLogger.info("[NET] Version info for " + versionId + " fetched directly");
return result;
} }
} }
throw new IOException("Version " + versionId + " not found"); throw new IOException("Version " + versionId + " not found");
@@ -574,12 +663,12 @@ public class ZHttpClient {
try { try {
repairLibrariesFromJson(versionJson, minecraftDir); repairLibrariesFromJson(versionJson, minecraftDir);
} catch (Exception e) { } catch (Exception e) {
System.out.println(ZAnsi.yellow("[LIB] Repair skipped for " + versionDir.getFileName() + ": " + e.getMessage())); LauncherLogger.warn("[LIB] Repair skipped for " + versionDir.getFileName() + ": " + e.getMessage());
} }
} }
}); });
} catch (IOException e) { } catch (IOException e) {
System.out.println(ZAnsi.yellow("[LIB] Repair scan failed: " + e.getMessage())); LauncherLogger.warn("[LIB] Repair scan failed: " + e.getMessage());
} }
} }
@@ -619,12 +708,12 @@ public class ZHttpClient {
} }
} catch (Exception e) { } catch (Exception e) {
String libName = lib.optString("name", lib.optString("path", "?")); String libName = lib.optString("name", lib.optString("path", "?"));
System.out.println(ZAnsi.yellow("[LIB] Repair failed for " + libName + ": " + e.getMessage())); LauncherLogger.warn("[LIB] Repair failed for " + libName + ": " + e.getMessage());
} }
} }
if (repaired > 0) { if (repaired > 0) {
System.out.println(ZAnsi.green("[LIB] Repaired " + repaired + " missing libraries")); LauncherLogger.info("[LIB] Repaired " + repaired + " missing libraries");
} }
} }
@@ -724,6 +813,10 @@ public class ZHttpClient {
return proxyTested.get(); return proxyTested.get();
} }
public static boolean isZernServerHealthy() {
return serviceHealthy.getOrDefault(ServiceType.ZERN_SERVER, false);
}
public static Map<String, Object> getNetworkStatus() { public static Map<String, Object> getNetworkStatus() {
Map<String, Object> status = new HashMap<>(); Map<String, Object> status = new HashMap<>();
status.put("initialized", proxyTested.get()); status.put("initialized", proxyTested.get());
@@ -39,6 +39,7 @@
<div class="field"> <div class="field">
<label for="password" data-i18n="login.password">Password</label> <label for="password" data-i18n="login.password">Password</label>
<input type="password" id="password" autocomplete="current-password" required> <input type="password" id="password" autocomplete="current-password" required>
<span class="field-hint hidden" id="password-hint" data-i18n="login.passTooShort"></span>
</div> </div>
<div class="field hidden" id="confirm-field"> <div class="field hidden" id="confirm-field">
<label for="confirm-password" data-i18n="login.confirm">Confirm Password</label> <label for="confirm-password" data-i18n="login.confirm">Confirm Password</label>
@@ -57,6 +58,38 @@
</div> </div>
</div> </div>
<!-- Offline Screen -->
<div id="offline-screen" class="screen hidden">
<div class="offline-container">
<div class="offline-badge">
<svg width="44" height="44" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 1l22 22"/><path d="M16.72 11.06A10.94 10.94 0 0 1 19 12.55"/><path d="M5 12.55a10.94 10.94 0 0 1 5.17-2.39"/><path d="M10.71 5.05A16 16 0 0 1 22.58 9"/><path d="M1.42 9a15.91 15.91 0 0 1 4.7-2.88"/><path d="M8.53 16.11a6 6 0 0 1 6.95 0"/><line x1="12" y1="20" x2="12.01" y2="20"/></svg>
</div>
<h2 class="offline-title" data-i18n="offline.title">Offline Mode</h2>
<p class="offline-subtitle" data-i18n="offline.subtitle">The Zern server is unreachable. You can still launch locally installed packs without an account.</p>
<form id="offline-form" class="login-form">
<div class="field">
<label for="offline-nickname" data-i18n="offline.nickname">Nickname</label>
<input type="text" id="offline-nickname" maxlength="16" autocomplete="off">
</div>
<div class="field">
<label for="offline-instance" data-i18n="offline.instance">Pack</label>
<select id="offline-instance"></select>
</div>
<p id="offline-error" class="error-msg hidden"></p>
<button type="submit" class="btn-primary" id="offline-launch-btn">
<span class="btn-label" data-i18n="offline.launch">Launch Offline</span>
<div class="spinner hidden"></div>
</button>
</form>
<div class="offline-footer">
<button type="button" class="btn-ghost" id="offline-retry-btn" data-i18n="offline.retry">Try again online</button>
<button type="button" class="btn-ghost" id="offline-exit-btn" data-i18n="offline.exit">Exit</button>
</div>
</div>
</div>
<!-- Loading Overlay --> <!-- Loading Overlay -->
<div id="loading-overlay" class="overlay hidden"> <div id="loading-overlay" class="overlay hidden">
<div class="loader-ring"></div> <div class="loader-ring"></div>
+212 -6
View File
@@ -12,7 +12,7 @@ const LOCALES = {
'login.hasAccount': 'Already have an account?', 'login.hasAccount': 'Already have an account?',
'login.confirm': 'Confirm Password', 'login.confirm': 'Confirm Password',
'login.passMismatch': 'Passwords do not match', 'login.passMismatch': 'Passwords do not match',
'login.passTooShort': 'Password must be at least 3 characters', 'login.passTooShort': 'Password must be at least 6 characters',
'login.signingIn': 'Signing in...', 'login.signingIn': 'Signing in...',
'loading.text': 'Loading...', 'loading.text': 'Loading...',
'sidebar.serverPacks': 'Server Packs', 'sidebar.localPacks': 'Local Packs', 'sidebar.serverPacks': 'Server Packs', 'sidebar.localPacks': 'Local Packs',
@@ -88,6 +88,24 @@ const LOCALES = {
'toast.loggedOut': 'Logged out', 'toast.loggedOut': 'Logged out',
'toast.enterCredentials': 'Enter username and password', 'toast.enterCredentials': 'Enter username and password',
'toast.loginFailed': 'Login failed', 'toast.loginFailed': 'Login failed',
'login.errNetwork': 'Could not reach the server. Check your connection.',
'login.errInvalidInput': 'Invalid data. The password must be at least 6 characters.',
'login.errTaken': 'This username is already taken.',
'login.errBadCredentials': 'Wrong username or password.',
'login.errServer': 'The server returned an error. Try again later.',
'offline.title': 'Offline Mode',
'offline.subtitle': 'The Zern server is unreachable. You can still launch locally installed packs without an account.',
'offline.nickname': 'Nickname',
'offline.instance': 'Pack',
'offline.noInstances': 'No locally installed packs found',
'offline.launch': 'Launch Offline',
'offline.launching': 'Launching...',
'offline.needNickname': 'Enter a nickname',
'offline.nickTooLong': 'Nickname must be at most 16 characters',
'offline.needInstance': 'Select a pack',
'offline.retry': 'Try again online',
'offline.exit': 'Exit',
'offline.stillOffline': 'Server is still unreachable',
'toast.launching': 'Launching {name}...', 'toast.launching': 'Launching {name}...',
'toast.launchFailed': 'Launch failed', 'toast.launchFailed': 'Launch failed',
'toast.updated': '{name} updated to v{version}!', 'toast.updated': '{name} updated to v{version}!',
@@ -203,7 +221,7 @@ const LOCALES = {
'login.hasAccount': 'Уже есть аккаунт?', 'login.hasAccount': 'Уже есть аккаунт?',
'login.confirm': 'Подтвердите пароль', 'login.confirm': 'Подтвердите пароль',
'login.passMismatch': 'Пароли не совпадают', 'login.passMismatch': 'Пароли не совпадают',
'login.passTooShort': 'Пароль должен быть минимум 3 символа', 'login.passTooShort': 'Пароль должен быть минимум 6 символов',
'login.signingIn': 'Вход...', 'login.signingIn': 'Вход...',
'loading.text': 'Загрузка...', 'loading.text': 'Загрузка...',
'sidebar.serverPacks': 'Серверные сборки', 'sidebar.localPacks': 'Локальные сборки', 'sidebar.serverPacks': 'Серверные сборки', 'sidebar.localPacks': 'Локальные сборки',
@@ -279,6 +297,24 @@ const LOCALES = {
'toast.loggedOut': 'Вы вышли', 'toast.loggedOut': 'Вы вышли',
'toast.enterCredentials': 'Введите логин и пароль', 'toast.enterCredentials': 'Введите логин и пароль',
'toast.loginFailed': 'Ошибка входа', 'toast.loginFailed': 'Ошибка входа',
'login.errNetwork': 'Не удалось связаться с сервером. Проверьте подключение.',
'login.errInvalidInput': 'Некорректные данные. Пароль должен быть минимум 6 символов.',
'login.errTaken': 'Это имя пользователя уже занято.',
'login.errBadCredentials': 'Неверный логин или пароль.',
'login.errServer': 'Сервер вернул ошибку. Попробуйте позже.',
'offline.title': 'Оффлайн-режим',
'offline.subtitle': 'Сервер Zern недоступен. Вы можете запустить локально установленные сборки без аккаунта.',
'offline.nickname': 'Никнейм',
'offline.instance': 'Сборка',
'offline.noInstances': 'Локально установленные сборки не найдены',
'offline.launch': 'Запустить оффлайн',
'offline.launching': 'Запуск...',
'offline.needNickname': 'Введите никнейм',
'offline.nickTooLong': 'Никнейм должен быть не длиннее 16 символов',
'offline.needInstance': 'Выберите сборку',
'offline.retry': 'Попробовать снова онлайн',
'offline.exit': 'Выйти',
'offline.stillOffline': 'Сервер всё ещё недоступен',
'toast.launching': 'Запуск {name}...', 'toast.launching': 'Запуск {name}...',
'toast.launchFailed': 'Ошибка запуска', 'toast.launchFailed': 'Ошибка запуска',
'toast.updated': '{name} обновлён до v{version}!', 'toast.updated': '{name} обновлён до v{version}!',
@@ -534,6 +570,12 @@ class ZernMCLauncher {
// ==================== AUTH ==================== // ==================== AUTH ====================
async checkAuth() { async checkAuth() {
this.showLoading(true); this.showLoading(true);
const status = await this.req('/offline/status');
if (status.success && status.offline) {
this.showOffline();
this.showLoading(false);
return;
}
const auto = await this.req('/auto-login'); const auto = await this.req('/auto-login');
if (auto.success && auto.autoLogin) { if (auto.success && auto.autoLogin) {
this.state.account = auto.data; this.state.account = auto.data;
@@ -565,7 +607,7 @@ class ZernMCLauncher {
if (this._registerMode) { if (this._registerMode) {
const confirm = document.getElementById('confirm-password').value; const confirm = document.getElementById('confirm-password').value;
if (password !== confirm) { this.showLoginError(t('login.passMismatch')); return; } if (password !== confirm) { this.showLoginError(t('login.passMismatch')); return; }
if (password.length < 3) { this.showLoginError(t('login.passTooShort')); return; } if (password.length < 6) { this.showLoginError(t('login.passTooShort')); return; }
} }
btn.disabled = true; btn.disabled = true;
@@ -594,7 +636,48 @@ class ZernMCLauncher {
return; return;
} }
} }
this.showLoginError(r.error || t('toast.loginFailed')); this.showLoginError(this.friendlyError(r.error) || r.error || t('toast.loginFailed'));
}
}
friendlyError(err) {
if (!err) return null;
const s = String(err);
if (/failed to fetch|fetch failed|networkerror|ENOTFOUND|ECONNREFUSED|ECONNRESET|timeout|aborted/i.test(s)) {
return t('login.errNetwork');
}
if (s.includes('HTTP 422') || /at least 6|string should have/i.test(s)) {
return t('login.errInvalidInput');
}
if (s.includes('HTTP 409') || /already taken/i.test(s)) {
return t('login.errTaken');
}
if (s.includes('HTTP 401') || /invalid.*(login|password)|wrong.*(login|password)/i.test(s)) {
return t('login.errBadCredentials');
}
if (/HTTP \d{3}/.test(s)) {
return t('login.errServer');
}
return null;
}
validatePassword() {
if (!this._registerMode) return;
const password = document.getElementById('password');
const hint = document.getElementById('password-hint');
const btn = document.getElementById('login-btn');
const tooShort = password.value.length < 6;
if (tooShort) {
password.classList.add('invalid');
hint.classList.remove('hidden');
} else {
password.classList.remove('invalid');
hint.classList.add('hidden');
}
if (btn) btn.disabled = tooShort;
const errEl = document.getElementById('login-error');
if (errEl && !errEl.classList.contains('hidden')) {
errEl.classList.add('hidden');
} }
} }
@@ -622,10 +705,14 @@ class ZernMCLauncher {
if (isReg) { if (isReg) {
confirmField.classList.remove('hidden'); confirmField.classList.remove('hidden');
document.getElementById('confirm-password').required = true; document.getElementById('confirm-password').required = true;
this.validatePassword();
} else { } else {
confirmField.classList.add('hidden'); confirmField.classList.add('hidden');
document.getElementById('confirm-password').required = false; document.getElementById('confirm-password').required = false;
document.getElementById('confirm-password').value = ''; document.getElementById('confirm-password').value = '';
document.getElementById('login-btn').disabled = false;
document.getElementById('password').classList.remove('invalid');
document.getElementById('password-hint').classList.add('hidden');
} }
document.getElementById('login-error').classList.add('hidden'); document.getElementById('login-error').classList.add('hidden');
@@ -689,9 +776,113 @@ class ZernMCLauncher {
} }
} }
// ==================== OFFLINE MODE ====================
showOffline() {
document.getElementById('login-screen').classList.add('hidden');
document.getElementById('main-screen').classList.add('hidden');
document.getElementById('offline-screen').classList.remove('hidden');
this.loadOfflineInstances();
}
async loadOfflineInstances() {
const select = document.getElementById('offline-instance');
if (!select) return;
select.innerHTML = '';
const r = await this.req('/instances');
let instances = (r.success && r.data) ? r.data : [];
instances = instances.filter(function(inst) { return inst.name; });
if (instances.length === 0) {
const opt = document.createElement('option');
opt.textContent = t('offline.noInstances');
opt.value = '';
select.appendChild(opt);
return;
}
instances.forEach(function(inst) {
const opt = document.createElement('option');
opt.value = inst.name;
let label = inst.name;
if (inst.minecraftVersion) label += ' (' + inst.minecraftVersion + ')';
opt.textContent = label;
select.appendChild(opt);
});
this.state.offlineInstances = instances;
}
async handleOfflineLaunch(e) {
e.preventDefault();
const nickname = document.getElementById('offline-nickname').value.trim();
const name = document.getElementById('offline-instance').value;
const errEl = document.getElementById('offline-error');
const btn = document.getElementById('offline-launch-btn');
const label = btn.querySelector('.btn-label');
const spinner = btn.querySelector('.spinner');
errEl.classList.add('hidden');
if (!nickname) {
errEl.textContent = t('offline.needNickname');
errEl.classList.remove('hidden');
return;
}
if (nickname.length > 16) {
errEl.textContent = t('offline.nickTooLong');
errEl.classList.remove('hidden');
return;
}
if (!name) {
errEl.textContent = t('offline.needInstance');
errEl.classList.remove('hidden');
return;
}
btn.disabled = true;
label.textContent = t('offline.launching');
spinner.classList.remove('hidden');
const r = await this.req('/offline-launch', {
method: 'POST',
body: JSON.stringify({ name: name, nickname: nickname }),
timeout: 120000
});
btn.disabled = false;
label.textContent = t('offline.launch');
spinner.classList.add('hidden');
if (r.success) {
this.toast(tr('toast.launched', null, {pid: String(r.data?.pid || '')}), 'success');
} else {
errEl.textContent = r.error || t('toast.launchFailed');
errEl.classList.remove('hidden');
}
}
async retryOnline() {
this.showLoading(true);
document.getElementById('offline-screen').classList.add('hidden');
const status = await this.req('/offline/status');
if (status.success && status.offline) {
this.showOffline();
this.showLoading(false);
this.toast(t('offline.stillOffline'), 'warning');
return;
}
await this.checkAuth();
}
// ==================== NAV ==================== // ==================== NAV ====================
bindEvents() { bindEvents() {
document.getElementById('login-form').addEventListener('submit', e => this.handleLogin(e)); document.getElementById('login-form').addEventListener('submit', e => this.handleLogin(e));
const offlineForm = document.getElementById('offline-form');
if (offlineForm) offlineForm.addEventListener('submit', e => this.handleOfflineLaunch(e));
const retryBtn = document.getElementById('offline-retry-btn');
if (retryBtn) retryBtn.addEventListener('click', () => this.retryOnline());
const exitBtn = document.getElementById('offline-exit-btn');
if (exitBtn) exitBtn.addEventListener('click', () => this.req('/shutdown', { method: 'POST' }));
const pwInput = document.getElementById('password');
if (pwInput) pwInput.addEventListener('input', () => this.validatePassword());
const confirmInput = document.getElementById('confirm-password');
if (confirmInput) confirmInput.addEventListener('input', () => this.validatePassword());
document.getElementById('login-footer').addEventListener('click', () => this.toggleMode()); document.getElementById('login-footer').addEventListener('click', () => this.toggleMode());
document.getElementById('logout-btn').addEventListener('click', () => this.logout()); document.getElementById('logout-btn').addEventListener('click', () => this.logout());
document.getElementById('settings-btn').addEventListener('click', () => this.switchView('settings')); document.getElementById('settings-btn').addEventListener('click', () => this.switchView('settings'));
@@ -1868,13 +2059,28 @@ class ZernMCLauncher {
modalP.classList.remove('hidden'); modalP.classList.remove('hidden');
const pct = (r.data.percent || 0) + '%'; const pct = (r.data.percent || 0) + '%';
const label = r.data.label || 'Installing...'; const label = r.data.label || 'Installing...';
const indeterminate = r.data.inProgress && (!r.data.total || r.data.total <= 0);
document.getElementById('progress-fill').style.width = pct; const fill = document.getElementById('progress-fill');
if (indeterminate) {
fill.classList.add('indeterminate');
fill.style.width = '';
} else {
fill.classList.remove('indeterminate');
fill.style.width = pct;
}
document.getElementById('progress-label').textContent = label; document.getElementById('progress-label').textContent = label;
const inlineProgress = document.getElementById('inline-install-progress'); const inlineProgress = document.getElementById('inline-install-progress');
if (!inlineProgress.classList.contains('hidden')) { if (!inlineProgress.classList.contains('hidden')) {
document.getElementById('inline-progress-fill').style.width = pct; const inlineFill = document.getElementById('inline-progress-fill');
if (indeterminate) {
inlineFill.classList.add('indeterminate');
inlineFill.style.width = '';
} else {
inlineFill.classList.remove('indeterminate');
inlineFill.style.width = pct;
}
document.getElementById('inline-progress-label').textContent = label; document.getElementById('inline-progress-label').textContent = label;
const inlineStage = document.getElementById('inline-progress-stage'); const inlineStage = document.getElementById('inline-progress-stage');
if (r.data.stageName && r.data.stageCount > 1) { if (r.data.stageName && r.data.stageCount > 1) {
@@ -99,6 +99,14 @@ body {
border-color: var(--accent); border-color: var(--accent);
box-shadow: 0 0 0 2px var(--accent-glow); box-shadow: 0 0 0 2px var(--accent-glow);
} }
.field input.invalid {
border-color: var(--error);
box-shadow: 0 0 0 2px rgba(248,113,113,0.15);
}
.field .field-hint {
font-size: 12px; color: var(--error); line-height: 1.4;
}
.field .field-hint.hidden { display: none; }
.field select { .field select {
width: 100%; padding: 10px 12px; font-size: 15px; width: 100%; padding: 10px 12px; font-size: 15px;
background: var(--bg-surface); border: 1px solid var(--border); background: var(--bg-surface); border: 1px solid var(--border);
@@ -642,6 +650,15 @@ body {
background: linear-gradient(90deg, var(--accent), #ff6b6b); background: linear-gradient(90deg, var(--accent), #ff6b6b);
border-radius: 3px; transition: width 0.3s ease; border-radius: 3px; transition: width 0.3s ease;
} }
.progress-fill.indeterminate {
width: 30%;
transition: none;
animation: indeterminate 1.3s ease-in-out infinite;
}
@keyframes indeterminate {
0% { margin-left: -30%; }
100% { margin-left: 100%; }
}
.progress-label { font-size: 13px; color: var(--text-secondary); margin-top: 8px; text-align: center; } .progress-label { font-size: 13px; color: var(--text-secondary); margin-top: 8px; text-align: center; }
.progress-stage { font-size: 11px; color: var(--text-muted); margin-top: 4px; text-align: center; } .progress-stage { font-size: 11px; color: var(--text-muted); margin-top: 4px; text-align: center; }
@@ -900,3 +917,40 @@ body {
.pack-entry.disabled .pack-entry-name::after { content: ' (' attr(data-disabled-text) ')'; color: var(--error); font-size: 11px; } .pack-entry.disabled .pack-entry-name::after { content: ' (' attr(data-disabled-text) ')'; color: var(--error); font-size: 11px; }
.admin-pack-disabled { color: var(--error) !important; font-weight: 600; } .admin-pack-disabled { color: var(--error) !important; font-weight: 600; }
.admin-pack-enabled { color: var(--success) !important; font-weight: 600; } .admin-pack-enabled { color: var(--success) !important; font-weight: 600; }
/* ========== OFFLINE MODE ========== */
.offline-container {
position: relative; z-index: 1;
background: var(--bg-elevated);
border-radius: var(--radius-md);
padding: 32px 32px 24px;
width: 100%;
max-width: 380px;
box-shadow: 0 8px 40px rgba(0,0,0,0.5);
text-align: center;
}
.offline-badge {
width: 64px; height: 64px;
margin: 0 auto 14px;
display: flex; align-items: center; justify-content: center;
background: rgba(233,69,96,0.12);
border: 1px solid rgba(233,69,96,0.3);
border-radius: 14px;
color: var(--accent);
}
.offline-title { font-size: 22px; font-weight: 700; color: var(--text); }
.offline-subtitle {
color: var(--text-muted); font-size: 13px; margin: 8px 0 20px; line-height: 1.5;
}
.offline-container .login-form { text-align: left; }
.offline-footer {
display: flex; gap: 8px; justify-content: center; margin-top: 18px;
}
.btn-ghost {
padding: 9px 14px; font-size: 13px;
background: transparent; color: var(--text-secondary);
border: 1px solid var(--border); border-radius: 4px;
cursor: pointer; font-family: var(--font);
transition: border-color 150ms ease, color 150ms ease;
}
.btn-ghost:hover { border-color: var(--accent); color: var(--accent); }
+7 -1
View File
@@ -14,12 +14,13 @@
<modules> <modules>
<module>bootstrap</module> <module>bootstrap</module>
<module>diag</module>
<module>launcher</module> <module>launcher</module>
</modules> </modules>
<properties> <properties>
<revision>1.0.16</revision> <revision>1.0.16</revision>
<hotfix>2</hotfix> <hotfix>13</hotfix>
<maven.compiler.source>21</maven.compiler.source> <maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target> <maven.compiler.target>21</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
@@ -95,6 +96,11 @@
<version>23.0.1</version> <version>23.0.1</version>
<classifier>win</classifier> <classifier>win</classifier>
</dependency> </dependency>
<dependency>
<groupId>dnsjava</groupId>
<artifactId>dnsjava</artifactId>
<version>3.6.5</version>
</dependency>
<dependency> <dependency>
<groupId>org.openjfx</groupId> <groupId>org.openjfx</groupId>
<artifactId>javafx-media</artifactId> <artifactId>javafx-media</artifactId>
+9
View File
@@ -383,6 +383,7 @@ async def register(body: RegisterRequest, request: Request):
allowed, wait = check_rate_limit(ip) allowed, wait = check_rate_limit(ip)
if not allowed: if not allowed:
logger.warning("register rate limited", username=body.username, client_ip=ip)
raise HTTPException(429, f"Слишком много попыток. Подождите {wait} секунд") raise HTTPException(429, f"Слишком много попыток. Подождите {wait} секунд")
with get_db() as conn: with get_db() as conn:
@@ -392,6 +393,7 @@ async def register(body: RegisterRequest, request: Request):
).fetchone() ).fetchone()
if existing: if existing:
logger.warning("register failed: username taken", username=body.username, client_ip=ip)
raise HTTPException(409, "Пользователь с таким именем уже существует") raise HTTPException(409, "Пользователь с таким именем уже существует")
uuid = generate_uuid() uuid = generate_uuid()
@@ -405,6 +407,7 @@ async def register(body: RegisterRequest, request: Request):
) )
user_id = cursor.lastrowid user_id = cursor.lastrowid
logger.info("register ok", username=body.username, user_id=user_id, client_ip=ip)
# Создаем сессию # Создаем сессию
session_token = secrets.token_urlsafe(32) session_token = secrets.token_urlsafe(32)
@@ -456,6 +459,7 @@ async def login(body: LoginRequest, request: Request):
allowed, wait = check_rate_limit(ip) allowed, wait = check_rate_limit(ip)
if not allowed: if not allowed:
logger.warning("login rate limited", username=body.username, client_ip=ip)
raise HTTPException(429, f"Слишком много попыток. Подождите {wait} секунд") raise HTTPException(429, f"Слишком много попыток. Подождите {wait} секунд")
with get_db() as conn: with get_db() as conn:
@@ -465,15 +469,20 @@ async def login(body: LoginRequest, request: Request):
).fetchone() ).fetchone()
if not user or not verify_password(body.password, user["password_hash"]): if not user or not verify_password(body.password, user["password_hash"]):
logger.warning("login failed: bad credentials", username=body.username, client_ip=ip)
record_login_attempt(ip, False) record_login_attempt(ip, False)
raise HTTPException(401, "Неверное имя пользователя или пароль") raise HTTPException(401, "Неверное имя пользователя или пароль")
if not user["is_active"]: if not user["is_active"]:
logger.warning("login failed: account deactivated", username=body.username, client_ip=ip)
raise HTTPException(403, "Аккаунт деактивирован") raise HTTPException(403, "Аккаунт деактивирован")
if user["banned_until"] and user["banned_until"] > time.time(): if user["banned_until"] and user["banned_until"] > time.time():
logger.warning("login failed: account banned", username=body.username, client_ip=ip)
raise HTTPException(403, "Аккаунт забанен") raise HTTPException(403, "Аккаунт забанен")
logger.info("login ok", username=user["username"], user_id=user["id"], client_ip=ip)
record_login_attempt(ip, True) record_login_attempt(ip, True)
now = time.time() now = time.time()
+88 -9
View File
@@ -48,8 +48,11 @@ WHITELIST_DIR = Path(__file__).parent / "whitelist"
# Mirror configuration # Mirror configuration
LAUNCHER_MIRRORS = { LAUNCHER_MIRRORS = {
"main": "https://api.zernmc.ru", "main": "https://api.zern.cc", # primary
"mirror-1": "https://api.zernmc.online", "mirror-1": "https://api.zernmc.ru", # legacy
"mirror-2": "https://api.zernmc.online", # legacy
"geo-pl": "https://api.pl.zern.cc",
"geo-swe": "https://api.swe.zern.cc",
} }
# Server role: "main" or "mirror" # Server role: "main" or "mirror"
@@ -71,6 +74,9 @@ BLOCKLIST_CACHE_FILE = Path("data/blocklist_cache.txt")
# Crash reports directory # Crash reports directory
CRASH_REPORTS_DIR = Path("data/crash_reports") CRASH_REPORTS_DIR = Path("data/crash_reports")
# Network diagnostics reports directory
DIAG_REPORTS_DIR = Path("data/diag_reports")
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
@@ -1350,6 +1356,14 @@ def generate_launcher_builds_meta():
# CLI exe is not shipped to users # CLI exe is not shipped to users
if rel_path == "zernmc-cli.exe": if rel_path == "zernmc-cli.exe":
continue continue
# zernmc.exe embeds the launcher version, so its hash changes on
# every build even when the bootstrap code is unchanged. Exclude it
# from the incremental update: a new exe is only delivered via a
# full ZIP reinstall. Without this the bootstrap would re-download
# and re-stage its own exe on every launcher version bump, and the
# detached .cmd helper fails with "file not found".
if rel_path == "zernmc.exe":
continue
stat = file_path.stat() stat = file_path.stat()
# Calculate hash # Calculate hash
@@ -1391,6 +1405,8 @@ def generate_version_meta(version_path: Path, version: str) -> dict:
for file_path in version_path.rglob("*"): for file_path in version_path.rglob("*"):
if file_path.is_file() and file_path.name != "meta.json": if file_path.is_file() and file_path.name != "meta.json":
rel_path = str(file_path.relative_to(version_path)) rel_path = str(file_path.relative_to(version_path))
if rel_path in ("zernmc.exe", "zernmc-cli.exe"):
continue
stat = file_path.stat() stat = file_path.stat()
file_hash = calculate_file_hash(file_path) file_hash = calculate_file_hash(file_path)
files.append({ files.append({
@@ -1488,13 +1504,18 @@ def extract_new_format_versions():
# Find all ZernMC-win-*.zip files # Find all ZernMC-win-*.zip files
new_format_zips = list(BUILDS_DIR.glob("ZernMC-win-*.zip")) new_format_zips = list(BUILDS_DIR.glob("ZernMC-win-*.zip"))
total = len(new_format_zips)
extracted = 0
skipped = 0
failed = 0
for zip_file in new_format_zips: for zip_file in new_format_zips:
version = zip_file.stem.replace("ZernMC-win-", "") version = zip_file.stem.replace("ZernMC-win-", "")
extract_dir = VERSIONS_DIR / version extract_dir = VERSIONS_DIR / version
# Skip if already extracted and meta exists # Skip if already extracted and meta exists
if extract_dir.exists() and (extract_dir / "meta.json").exists(): if extract_dir.exists() and (extract_dir / "meta.json").exists():
logger.debug(f"Version {version} already extracted") skipped += 1
continue continue
logger.info(f"Extracting {zip_file.name} to versions/{version}/...") logger.info(f"Extracting {zip_file.name} to versions/{version}/...")
@@ -1511,10 +1532,14 @@ def extract_new_format_versions():
# writes versions/<v>/meta.json, so every scan would re-extract the zip. # writes versions/<v>/meta.json, so every scan would re-extract the zip.
generate_version_meta(extract_dir, version) generate_version_meta(extract_dir, version)
extracted += 1
logger.info(f"Extracted {zip_file.name} successfully") logger.info(f"Extracted {zip_file.name} successfully")
except Exception as e: except Exception as e:
failed += 1
logger.error(f"Failed to extract {zip_file.name}: {e}") logger.error(f"Failed to extract {zip_file.name}: {e}")
logger.info(f"Version scan complete: {total} total, {extracted} extracted, {skipped} skipped (already present), {failed} failed")
# ====================== END ЛАУНЧЕР МЕТА СИСТЕМА ====================== # ====================== END ЛАУНЧЕР МЕТА СИСТЕМА ======================
@@ -1594,6 +1619,14 @@ def get_legacy_zips() -> list:
return zips return zips
@app.get("/launcher/ip")
async def get_launcher_client_ip(request: Request):
"""Return the public IP that the server sees for this client."""
from middleware import get_client_ip
client_ip = get_client_ip(request)
return {"ip": client_ip}
@app.get("/launcher/version") @app.get("/launcher/version")
async def get_launcher_version(): async def get_launcher_version():
"""Return launcher version information""" """Return launcher version information"""
@@ -1965,6 +1998,36 @@ async def receive_crash_report(request: Request):
return {"status": "ok", "id": report_id} return {"status": "ok", "id": report_id}
@app.post("/diag/upload")
async def receive_diag_report(request: Request):
"""Receive and store network diagnostics reports from the diag utility"""
try:
body = await request.body()
except Exception:
raise HTTPException(status_code=400, detail="Failed to read body")
if not body:
raise HTTPException(status_code=400, detail="Empty body")
ip = request.client.host if request.client else "unknown"
name = request.query_params.get("name", "").strip()
if not name or "/" in name or "\\" in name or ".." in name:
name = f"diag_{datetime.utcnow().strftime('%Y%m%d_%H%M%S')}_{ip}.log"
report_path = DIAG_REPORTS_DIR / name
try:
DIAG_REPORTS_DIR.mkdir(parents=True, exist_ok=True)
async with aiofiles.open(report_path, "w", encoding="utf-8") as f:
await f.write(body.decode("utf-8", errors="replace"))
logger.info(f"Diag report saved: {report_path.name} from {ip}")
except Exception as e:
logger.error(f"Failed to save diag report: {e}")
raise HTTPException(status_code=500, detail="Failed to save report")
return {"status": "ok", "name": report_path.name}
# ====================== НОВОСТИ ====================== # ====================== НОВОСТИ ======================
NEWS_DIR = Path(__file__).parent / "news" NEWS_DIR = Path(__file__).parent / "news"
@@ -2195,19 +2258,34 @@ async def proxy_mojang_version(version_id: str, request: Request):
# Сначала получаем манифест, чтобы найти URL версии # Сначала получаем манифест, чтобы найти URL версии
manifest_url = "https://piston-meta.mojang.com/mc/game/version_manifest_v2.json" manifest_url = "https://piston-meta.mojang.com/mc/game/version_manifest_v2.json"
cache_key = f"version_url_{version_id}" # Cache the final version JSON, not just the version URL. piston-meta is
version_url = proxy_cache.get(cache_key) # slow/unreliable from some regions, so full serialization of the version
# JSON (plus a warm-up) keeps installs from stalling on install time.
cache_key = f"version_json_{version_id}"
cached = proxy_cache.get(cache_key)
if cached is not None:
logger.info(f"Proxy served from cache: Mojang version {version_id}")
return JSONResponse(content=cached)
version_url = proxy_cache.get(f"version_url_{version_id}")
if not version_url: if not version_url:
try: try:
response = await proxy_client.get(manifest_url) # Reuse the manifest already cached by /proxy/mojang/version_manifest
response.raise_for_status() manifest = proxy_cache.get(manifest_url)
manifest = response.json() if manifest is None:
response = await proxy_client.get(manifest_url)
response.raise_for_status()
manifest = response.json()
proxy_cache[manifest_url] = manifest
logger.info("Proxy success: Mojang manifest")
else:
logger.info("Proxy served manifest from cache")
for version in manifest.get("versions", []): for version in manifest.get("versions", []):
if version.get("id") == version_id: if version.get("id") == version_id:
version_url = version.get("url") version_url = version.get("url")
proxy_cache[cache_key] = version_url proxy_cache[f"version_url_{version_id}"] = version_url
break break
if not version_url: if not version_url:
@@ -2222,6 +2300,7 @@ async def proxy_mojang_version(version_id: str, request: Request):
response.raise_for_status() response.raise_for_status()
data = response.json() data = response.json()
proxy_cache[cache_key] = data
logger.info(f"Proxy success: Mojang version {version_id}") logger.info(f"Proxy success: Mojang version {version_id}")
return JSONResponse(content=data) return JSONResponse(content=data)