diff --git a/launcher/bootstrap/src/main/java/me/sashegdev/zernmc/launcher/Bootstrap.java b/launcher/bootstrap/src/main/java/me/sashegdev/zernmc/launcher/Bootstrap.java index e73ac52..a432b71 100644 --- a/launcher/bootstrap/src/main/java/me/sashegdev/zernmc/launcher/Bootstrap.java +++ b/launcher/bootstrap/src/main/java/me/sashegdev/zernmc/launcher/Bootstrap.java @@ -13,11 +13,14 @@ import java.util.Arrays; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.UUID; import javax.swing.*; import javax.swing.plaf.basic.BasicProgressBarUI; import java.awt.*; import java.awt.event.MouseAdapter; import java.awt.event.MouseEvent; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; import com.google.gson.JsonElement; import com.google.gson.JsonObject; import com.google.gson.JsonParser; @@ -159,15 +162,38 @@ public class Bootstrap { ProcessBuilder pb = new ProcessBuilder(cmd); pb.directory(baseDir.toFile()); - pb.inheritIO(); + pb.redirectErrorStream(false); log("Starting process: " + String.join(" ", cmd)); Process p = pb.start(); + + // Read stderr in background thread for crash reporting + StringBuilder errorOutput = new StringBuilder(); + Thread errorReader = new Thread(() -> { + try (BufferedReader reader = new BufferedReader( + new InputStreamReader(p.getErrorStream(), StandardCharsets.UTF_8))) { + String line; + while ((line = reader.readLine()) != null) { + errorOutput.append(line).append("\n"); + log("[JFX] " + line); + } + } catch (Exception ignored) {} + }); + errorReader.setDaemon(true); + errorReader.start(); + int code = p.waitFor(); + errorReader.join(2000); log("JFX process exited with code: " + code); - System.exit(code); + + if (code != 0 && !GraphicsEnvironment.isHeadless()) { + String stderr = errorOutput.toString(); + SwingUtilities.invokeLater(() -> showCrashDialog(code, stderr)); + } else { + System.exit(code); + } } private static Path findJava(boolean preferConsole) { @@ -563,6 +589,203 @@ public class Bootstrap { } } + // ====================== CRASH HANDLER ====================== + + private static String readFileSafe(Path path, int maxLines) { + if (!Files.exists(path)) return "(file not found)"; + try { + List lines = Files.readAllLines(path, StandardCharsets.UTF_8); + if (lines.size() <= maxLines) { + return String.join("\n", lines); + } + List tail = lines.subList(lines.size() - maxLines, lines.size()); + return "... (" + (lines.size() - maxLines) + " lines omitted)\n" + String.join("\n", tail); + } catch (Exception e) { + return "(error reading: " + e.getMessage() + ")"; + } + } + + private static Map collectCrashInfo(int exitCode, String stderr) { + Map info = new HashMap<>(); + + // Version + info.put("launcher_version", readCurrentVersion()); + + // Exit + info.put("exit_code", exitCode); + info.put("stderr", stderr != null ? stderr : ""); + info.put("launcher_log", readFileSafe(logDir.resolve("launcher.log"), 200)); + + // System + Map system = new HashMap<>(); + system.put("os_name", System.getProperty("os.name", "unknown")); + system.put("os_version", System.getProperty("os.version", "unknown")); + system.put("os_arch", System.getProperty("os.arch", "unknown")); + system.put("java_version", System.getProperty("java.version", "unknown")); + system.put("java_vendor", System.getProperty("java.vendor", "unknown")); + system.put("available_processors", Runtime.getRuntime().availableProcessors()); + system.put("total_memory_mb", Runtime.getRuntime().maxMemory() / (1024 * 1024)); + info.put("system", system); + + // User (from auth.json, tokens excluded) + Map user = new HashMap<>(); + try { + Path authFile = Path.of(System.getProperty("user.home"), ".zernmc", "auth.json"); + if (Files.exists(authFile)) { + String authContent = Files.readString(authFile, StandardCharsets.UTF_8); + JsonObject authJson = JsonParser.parseString(authContent).getAsJsonObject(); + if (authJson.has("username")) user.put("username", authJson.get("username").getAsString()); + if (authJson.has("uuid")) user.put("uuid", authJson.get("uuid").getAsString()); + if (authJson.has("role")) user.put("role", authJson.get("role").getAsInt()); + } + } catch (Exception ignored) {} + info.put("user", user); + + return info; + } + + private static void showCrashDialog(int exitCode, String stderr) { + Map crashInfo = collectCrashInfo(exitCode, stderr); + Gson gson = new GsonBuilder().setPrettyPrinting().create(); + String fullReport = gson.toJson(crashInfo); + + JFrame frame = new JFrame("ZernMC Launcher - Crash Report"); + frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE); + frame.setSize(700, 520); + frame.setLocationRelativeTo(null); + frame.setResizable(true); + + Color bgColor = new Color(0x0c, 0x0c, 0x12); + Color surfaceColor = new Color(0x16, 0x16, 0x1f); + Color accentColor = new Color(0xe9, 0x45, 0x60); + Color textColor = new Color(0xee, 0xee, 0xf0); + Color mutedColor = new Color(0x88, 0x88, 0x9a); + + JPanel root = new JPanel(new BorderLayout()); + root.setBackground(bgColor); + root.setBorder(BorderFactory.createEmptyBorder(16, 20, 16, 20)); + + // Title + JLabel titleLabel = new JLabel("ZernMC Launcher Crashed (code: " + exitCode + ")"); + titleLabel.setFont(new Font("Segoe UI", Font.BOLD, 16)); + titleLabel.setForeground(accentColor); + titleLabel.setBorder(BorderFactory.createEmptyBorder(0, 0, 12, 0)); + root.add(titleLabel, BorderLayout.NORTH); + + // Log area + JTextArea logArea = new JTextArea(fullReport); + logArea.setEditable(false); + logArea.setFont(new Font("Consolas", Font.PLAIN, 12)); + logArea.setBackground(surfaceColor); + logArea.setForeground(textColor); + logArea.setCaretColor(textColor); + logArea.setBorder(BorderFactory.createEmptyBorder(8, 8, 8, 8)); + + JScrollPane scrollPane = new JScrollPane(logArea); + scrollPane.setBorder(BorderFactory.createLineBorder(new Color(0x2a, 0x2a, 0x3a))); + scrollPane.getViewport().setBackground(surfaceColor); + root.add(scrollPane, BorderLayout.CENTER); + + // Buttons + JPanel buttonPanel = new JPanel(new FlowLayout(FlowLayout.RIGHT, 8, 12)); + buttonPanel.setOpaque(false); + + JLabel statusLabel = new JLabel(" "); + statusLabel.setFont(new Font("Segoe UI", Font.PLAIN, 12)); + statusLabel.setForeground(mutedColor); + buttonPanel.add(statusLabel); + + JButton openLogsBtn = new JButton("Open Logs Folder"); + styleCrashButton(openLogsBtn, mutedColor, bgColor); + openLogsBtn.addActionListener(e -> { + try { + Desktop.getDesktop().open(logDir.toFile()); + } catch (Exception ignored) {} + }); + buttonPanel.add(openLogsBtn); + + JButton sendBtn = new JButton("Send to Server"); + styleCrashButton(sendBtn, new Color(0x45, 0xe9, 0x60), bgColor); + sendBtn.addActionListener(e -> { + sendBtn.setEnabled(false); + sendBtn.setText("Sending..."); + new Thread(() -> { + boolean ok = sendCrashReport(crashInfo); + SwingUtilities.invokeLater(() -> { + if (ok) { + statusLabel.setForeground(new Color(0x45, 0xe9, 0x60)); + statusLabel.setText("Crash report sent. Thank you!"); + sendBtn.setText("Sent"); + } else { + statusLabel.setForeground(accentColor); + statusLabel.setText("Failed to send. Check logs folder."); + sendBtn.setEnabled(true); + sendBtn.setText("Send to Server"); + } + }); + }).start(); + }); + buttonPanel.add(sendBtn); + + JButton closeBtn = new JButton("Close"); + styleCrashButton(closeBtn, new Color(0x45, 0x60, 0xe9), bgColor); + closeBtn.addActionListener(e -> { + frame.dispose(); + System.exit(exitCode); + }); + buttonPanel.add(closeBtn); + + root.add(buttonPanel, BorderLayout.SOUTH); + + frame.setContentPane(root); + frame.setVisible(true); + frame.toFront(); + } + + private static void styleCrashButton(JButton btn, Color fg, Color bg) { + btn.setFont(new Font("Segoe UI", Font.PLAIN, 13)); + btn.setForeground(fg); + btn.setBackground(bg); + btn.setBorderPainted(false); + btn.setFocusPainted(false); + btn.setContentAreaFilled(false); + btn.setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)); + btn.addMouseListener(new java.awt.event.MouseAdapter() { + public void mouseEntered(java.awt.event.MouseEvent e) { btn.setForeground(Color.WHITE); } + public void mouseExited(java.awt.event.MouseEvent e) { btn.setForeground(fg); } + }); + } + + private static boolean sendCrashReport(Map data) { + try { + Gson gson = new Gson(); + String jsonBody = gson.toJson(data); + byte[] bodyBytes = jsonBody.getBytes(StandardCharsets.UTF_8); + + URL url = new URL(BASE_URL + "/launcher/crash-report"); + HttpURLConnection conn = (HttpURLConnection) url.openConnection(); + conn.setRequestMethod("POST"); + conn.setRequestProperty("Content-Type", "application/json; charset=utf-8"); + conn.setRequestProperty("Accept", "application/json"); + conn.setRequestProperty("Content-Length", String.valueOf(bodyBytes.length)); + conn.setDoOutput(true); + conn.setConnectTimeout(10000); + conn.setReadTimeout(10000); + + try (java.io.OutputStream os = conn.getOutputStream()) { + os.write(bodyBytes); + os.flush(); + } + + int status = conn.getResponseCode(); + conn.disconnect(); + return status == 200; + } catch (Exception e) { + log("Crash report send failed: " + e.getMessage()); + return false; + } + } + // ====================== SWING UI ====================== private static class BootstrapUI { diff --git a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/Bootstrap.java b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/Bootstrap.java index bbb6220..60640b0 100644 --- a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/Bootstrap.java +++ b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/Bootstrap.java @@ -88,13 +88,14 @@ public class Bootstrap { try { String[] sa = server.split("\\."); String[] ca = current.split("\\."); - for (int i = 0; i < Math.min(sa.length, ca.length); i++) { - int sv = Integer.parseInt(sa[i]); - int cv = Integer.parseInt(ca[i]); + int max = Math.max(sa.length, ca.length); + for (int i = 0; i < max; i++) { + int sv = i < sa.length ? Integer.parseInt(sa[i]) : 0; + int cv = i < ca.length ? Integer.parseInt(ca[i]) : 0; if (sv > cv) return true; if (sv < cv) return false; } - return sa.length > ca.length; + return false; } catch (Exception ignored) {} return false; } diff --git a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/api/LauncherAPI.java b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/api/LauncherAPI.java index 89c2862..7ca82c9 100644 --- a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/api/LauncherAPI.java +++ b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/api/LauncherAPI.java @@ -144,18 +144,15 @@ public class LauncherAPI { private static int compareVersions(String a, String b) { String[] partsA = a.split("\\."); String[] partsB = b.split("\\."); - int len = Math.min(partsA.length, partsB.length); - for (int i = 0; i < len; i++) { + int max = Math.max(partsA.length, partsB.length); + for (int i = 0; i < max; i++) { try { - int numA = Integer.parseInt(partsA[i]); - int numB = Integer.parseInt(partsB[i]); + int numA = i < partsA.length ? Integer.parseInt(partsA[i]) : 0; + int numB = i < partsB.length ? Integer.parseInt(partsB[i]) : 0; if (numA != numB) return Integer.compare(numB, numA); - } catch (NumberFormatException e) { - int cmp = partsA[i].compareTo(partsB[i]); - if (cmp != 0) return cmp; - } + } catch (NumberFormatException ignored) {} } - return Integer.compare(partsB.length, partsA.length); + return 0; } private boolean isNeoForgeCompatible(String version, String mcVersion) { diff --git a/server/main.py b/server/main.py index e0d64bb..118bf1a 100644 --- a/server/main.py +++ b/server/main.py @@ -33,6 +33,7 @@ from playtime import router as playtime_router, init_playtime_db import asyncio import hashlib +import uuid import aiofiles import mimetypes @@ -67,6 +68,9 @@ MANUAL_BLOCKED_IPS = set(os.environ.get("BLOCKED_IPS", "").split(",")) - {""} # # Cache file for blocklist (load once) BLOCKLIST_CACHE_FILE = Path("data/blocklist_cache.txt") +# Crash reports directory +CRASH_REPORTS_DIR = Path("data/crash_reports") + @asynccontextmanager async def lifespan(app: FastAPI): @@ -143,6 +147,7 @@ async def lifespan(app: FastAPI): BUILDS_DIR.mkdir(exist_ok=True) PACKS_DIR.mkdir(exist_ok=True) DATA_DIR.mkdir(exist_ok=True) + CRASH_REPORTS_DIR.mkdir(exist_ok=True) init_db() init_friends_db() @@ -1894,6 +1899,33 @@ async def get_launcher_full_info(): return info +@app.post("/launcher/crash-report") +async def receive_crash_report(request: Request): + """Receive and store launcher crash reports""" + try: + body = await request.json() + except Exception: + raise HTTPException(status_code=400, detail="Invalid JSON") + + ip = request.client.host if request.client else "unknown" + body["source_ip"] = ip + body["received_at"] = datetime.utcnow().isoformat() + + report_id = datetime.utcnow().strftime("%Y%m%d_%H%M%S") + "_" + uuid.uuid4().hex[:8] + report_path = CRASH_REPORTS_DIR / f"{report_id}.json" + + try: + CRASH_REPORTS_DIR.mkdir(parents=True, exist_ok=True) + async with aiofiles.open(report_path, "w", encoding="utf-8") as f: + await f.write(json.dumps(body, indent=2, ensure_ascii=False)) + logger.info(f"Crash report saved: {report_path.name} from {ip}") + except Exception as e: + logger.error(f"Failed to save crash report: {e}") + raise HTTPException(status_code=500, detail="Failed to save report") + + return {"status": "ok", "id": report_id} + + # ====================== НОВОСТИ ====================== NEWS_DIR = Path(__file__).parent / "news" diff --git a/todo.txt b/todo.txt new file mode 100644 index 0000000..df27646 --- /dev/null +++ b/todo.txt @@ -0,0 +1,9 @@ +TODO / ideas backlog +==================== + +Java Agent + IPC (low priority, overkill now) +- Заменить прямой ProcessBuilder.start() на прокси-класс (-javaagent или -cp прокси) +- Прокси класс: подключается к localhost RPC, ждёт "launch", потом reflection вызывает реальный main +- Даёт: двустороннюю связь лаунчер-игра, контроль жизненного цикла, crash-репорты из процесса игры, + передачу настроек/токенов на лету, патчинг классов через Instrumentation API +- Для ZernMC сейчас оверхед — но запомнить на будущее