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 1f997e7..b4ff290 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 @@ -37,6 +37,7 @@ public class Bootstrap { private static Path javafxPath; private static boolean isCliMode; private static boolean isJfxMode; + private static boolean isJcefMode; private static BootstrapUI ui; private static Path getLauncherJar() { @@ -54,13 +55,29 @@ public class Bootstrap { log("=== ZernMC Launcher ==="); List argList = new ArrayList<>(Arrays.asList(args)); - isCliMode = argList.contains("--cli") || (System.console() != null && !argList.contains("--jfx")); - isJfxMode = !isCliMode; + isCliMode = argList.contains("--cli") || (System.console() != null && !argList.contains("--jfx") && !argList.contains("--jcef")); + // Auto-detect UI backend: prefer JCEF, fall back to JavaFX + boolean javafxAvailable = false; + if (Files.exists(javafxPath)) { + try (var list = Files.list(javafxPath)) { + javafxAvailable = list.findAny().isPresent(); + } catch (Exception ignored) {} + } + if (argList.contains("--jfx")) { + isJfxMode = true; + isJcefMode = false; + } else if (argList.contains("--jcef")) { + isJfxMode = false; + isJcefMode = true; + } else { + isJfxMode = javafxAvailable; + isJcefMode = !javafxAvailable; + } if (isCliMode && !argList.contains("--cli")) { argList.add("--cli"); } - log("Mode: " + (isCliMode ? "CLI" : "JFX")); + log("Mode: " + (isCliMode ? "CLI" : (isJcefMode ? "JCEF" : "JFX"))); if (!isCliMode && !GraphicsEnvironment.isHeadless()) { ui = new BootstrapUI(); @@ -146,16 +163,26 @@ public class Bootstrap { cmd.add("-Dsun.stderr.encoding=UTF-8"); cmd.add("-Dlauncher.server=" + BASE_URL); - if (Files.exists(javafxPath)) { + if (isJfxMode && Files.exists(javafxPath)) { cmd.add("--module-path"); cmd.add(javafxPath.toAbsolutePath().toString()); cmd.add("--add-modules"); cmd.add("javafx.controls,javafx.web"); } + if (isJcefMode) { + // JCEF requires --add-opens for macOS + cmd.add("--add-opens"); + cmd.add("java.desktop/sun.awt=ALL-UNNAMED"); + cmd.add("--add-opens"); + cmd.add("java.desktop/sun.lwawt=ALL-UNNAMED"); + cmd.add("--add-opens"); + cmd.add("java.desktop/sun.lwawt.macosx=ALL-UNNAMED"); + } + cmd.add("-jar"); cmd.add(getLauncherJar().toAbsolutePath().toString()); - cmd.add("--jfx"); + cmd.add(isJcefMode ? "--jcef" : "--jfx"); ProcessBuilder pb = new ProcessBuilder(cmd); pb.directory(baseDir.toFile()); diff --git a/launcher/launcher/pom.xml b/launcher/launcher/pom.xml index 92ec24c..cce22ee 100644 --- a/launcher/launcher/pom.xml +++ b/launcher/launcher/pom.xml @@ -57,36 +57,10 @@ commons-io - + - org.openjfx - javafx-controls - 21 - win - - - org.openjfx - javafx-web - 21 - win - - - org.openjfx - javafx-graphics - 21 - win - - - org.openjfx - javafx-base - 21 - win - - - org.openjfx - javafx-media - 21 - win + me.friwi + jcefmaven @@ -98,8 +72,77 @@ + + + + javafx + + + useJavaFX + true + + + + + org.openjfx + javafx-controls + 21 + win + + + org.openjfx + javafx-web + 21 + win + + + org.openjfx + javafx-graphics + 21 + win + + + org.openjfx + javafx-base + 21 + win + + + org.openjfx + javafx-media + 21 + win + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.11.0 + + + + + + + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.11.0 + + + **/ui/jfx/JFXLauncher.java + + + org.apache.maven.plugins maven-shade-plugin @@ -223,29 +266,29 @@ - + - + - + - + - + - + diff --git a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/Main.java b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/Main.java index 829723e..433a880 100644 --- a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/Main.java +++ b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/Main.java @@ -4,7 +4,7 @@ import me.sashegdev.zernmc.launcher.api.LauncherAPI; import me.sashegdev.zernmc.launcher.auth.AuthManager; import me.sashegdev.zernmc.launcher.menu.*; import me.sashegdev.zernmc.launcher.ui.ArrowMenu; -import me.sashegdev.zernmc.launcher.ui.jfx.JFXLauncher; +import me.sashegdev.zernmc.launcher.ui.jcef.JCEFLauncher; import me.sashegdev.zernmc.launcher.utils.*; import java.io.IOException; import java.util.List; @@ -35,8 +35,14 @@ public class Main { List argList = List.of(args); boolean jfxMode = argList.contains("--jfx"); + boolean jcefMode = argList.contains("--jcef"); boolean cliMode = argList.contains("--cli"); + if (jcefMode) { + launchJCEF(); + return; + } + if (jfxMode) { launchJFX(); return; @@ -48,14 +54,27 @@ public class Main { startCLI(); } + private static void launchJCEF() { + try { + JCEFLauncher.main(new String[]{}); + } catch (Exception e) { + System.err.println(ZAnsi.brightRed("Error starting JCEF: " + e.getMessage())); + e.printStackTrace(); + System.exit(1); + } + } + private static void launchJFX() { try { System.setProperty("javafx.runtime.version", "21"); - - JFXLauncher.main(new String[]{}); + Class jfxClass = Class.forName("me.sashegdev.zernmc.launcher.ui.jfx.JFXLauncher"); + java.lang.reflect.Method mainMethod = jfxClass.getMethod("main", String[].class); + mainMethod.invoke(null, new Object[]{new String[]{}}); } catch (Exception e) { System.err.println(ZAnsi.brightRed("Error starting JFX: " + e.getMessage())); - if (e.getMessage() != null && e.getMessage().contains("QuantumRenderer")) { + Throwable cause = e.getCause(); + String msg = cause != null ? cause.getMessage() : e.getMessage(); + if (msg != null && msg.contains("QuantumRenderer")) { System.err.println(ZAnsi.yellow("JavaFX is not available. Native libraries may be missing.")); System.err.println(ZAnsi.yellow("Try CLI mode: --cli")); } diff --git a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/api/launch/LaunchService.java b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/api/launch/LaunchService.java index 01969dc..32423a7 100644 --- a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/api/launch/LaunchService.java +++ b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/api/launch/LaunchService.java @@ -6,7 +6,7 @@ import me.sashegdev.zernmc.launcher.minecraft.Instance; import me.sashegdev.zernmc.launcher.minecraft.InstanceManager; import me.sashegdev.zernmc.launcher.minecraft.launch.LaunchCommandBuilder; import me.sashegdev.zernmc.launcher.minecraft.model.LaunchOptions; -import me.sashegdev.zernmc.launcher.ui.jfx.JFXLauncher; +import me.sashegdev.zernmc.launcher.utils.LogBridge; import me.sashegdev.zernmc.launcher.utils.Config; import me.sashegdev.zernmc.launcher.utils.LauncherLogger; @@ -98,14 +98,14 @@ public class LaunchService { String line; while ((line = reader.readLine()) != null) { String timestamped = "[" + java.time.LocalTime.now().format(java.time.format.DateTimeFormatter.ofPattern("HH:mm:ss")) + "] " + line; - JFXLauncher.appendGameLog(line); + LogBridge.appendGameLog(line); try { logFileOut.write((timestamped + "\n").getBytes(java.nio.charset.StandardCharsets.UTF_8)); logFileOut.flush(); } catch (Exception ignored) {} } } catch (Exception e) { - JFXLauncher.appendGameLog("[Error reading logs: " + e.getMessage() + "]"); + LogBridge.appendGameLog("[Error reading logs: " + e.getMessage() + "]"); } finally { try { logFileOut.close(); } catch (Exception ignored) {} } @@ -115,7 +115,7 @@ public class LaunchService { process.onExit().thenRun(() -> { runningProcesses.remove(pid); - JFXLauncher.appendGameLog("[Minecraft exited with code: " + process.exitValue() + "]"); + LogBridge.appendGameLog("[Minecraft exited with code: " + process.exitValue() + "]"); }); ProcessInfo info = new ProcessInfo(instanceName, pid, "RUNNING"); diff --git a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/MinecraftLib.java b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/MinecraftLib.java index 0219bf9..415f7fc 100644 --- a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/MinecraftLib.java +++ b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/MinecraftLib.java @@ -6,7 +6,7 @@ import me.sashegdev.zernmc.launcher.minecraft.installer.NeoForgeInstaller; import me.sashegdev.zernmc.launcher.minecraft.installer.VersionInstaller; import me.sashegdev.zernmc.launcher.minecraft.launch.LaunchCommandBuilder; import me.sashegdev.zernmc.launcher.minecraft.model.LaunchOptions; -import me.sashegdev.zernmc.launcher.ui.jfx.JFXLauncher; +import me.sashegdev.zernmc.launcher.utils.LogBridge; import me.sashegdev.zernmc.launcher.utils.ConsoleUtils; import me.sashegdev.zernmc.launcher.utils.LauncherLogger; import me.sashegdev.zernmc.launcher.utils.ZAnsi; @@ -130,10 +130,10 @@ public class MinecraftLib { try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) { String line; while ((line = reader.readLine()) != null) { - JFXLauncher.appendGameLog(line); + LogBridge.appendGameLog(line); } } catch (Exception e) { - JFXLauncher.appendGameLog("[Error reading output: " + e.getMessage() + "]"); + LogBridge.appendGameLog("[Error reading output: " + e.getMessage() + "]"); } }); outThread.setDaemon(true); @@ -144,10 +144,10 @@ public class MinecraftLib { try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getErrorStream()))) { String line; while ((line = reader.readLine()) != null) { - JFXLauncher.appendGameLog("[ERR] " + line); + LogBridge.appendGameLog("[ERR] " + line); } } catch (Exception e) { - JFXLauncher.appendGameLog("[Error reading stderr: " + e.getMessage() + "]"); + LogBridge.appendGameLog("[Error reading stderr: " + e.getMessage() + "]"); } }); errThread.setDaemon(true); diff --git a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/ui/jcef/JCEFLauncher.java b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/ui/jcef/JCEFLauncher.java new file mode 100644 index 0000000..f980736 --- /dev/null +++ b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/ui/jcef/JCEFLauncher.java @@ -0,0 +1,1689 @@ +package me.sashegdev.zernmc.launcher.ui.jcef; + +import com.google.gson.Gson; +import me.sashegdev.zernmc.launcher.api.LauncherAPI; +import me.sashegdev.zernmc.launcher.api.launch.LaunchService; +import me.sashegdev.zernmc.launcher.auth.AuthManager; +import me.sashegdev.zernmc.launcher.minecraft.Instance; +import me.sashegdev.zernmc.launcher.minecraft.InstanceManager; +import me.sashegdev.zernmc.launcher.minecraft.MinecraftLib; +import me.sashegdev.zernmc.launcher.minecraft.PackDownloader; +import me.sashegdev.zernmc.launcher.minecraft.ServerPack; +import me.sashegdev.zernmc.launcher.minecraft.launch.LaunchCommandBuilder; +import me.sashegdev.zernmc.launcher.utils.Config; +import me.sashegdev.zernmc.launcher.utils.LauncherLogger; +import me.sashegdev.zernmc.launcher.utils.Version; + +import me.friwi.jcefmaven.CefAppBuilder; +import me.friwi.jcefmaven.impl.progress.ConsoleProgressHandler; +import org.cef.CefApp; +import org.cef.CefClient; +import org.cef.CefSettings; +import org.cef.browser.CefBrowser; +import org.cef.browser.CefFrame; +import org.cef.handler.CefDisplayHandlerAdapter; +import org.cef.handler.CefLifeSpanHandlerAdapter; +import org.cef.handler.CefLoadHandlerAdapter; +import org.cef.network.CefRequest; + +import java.io.File; + +import java.awt.Desktop; +import java.awt.Component; +import java.awt.Dimension; +import java.awt.event.WindowAdapter; +import java.awt.event.WindowEvent; +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.io.InputStreamReader; +import java.net.HttpURLConnection; +import me.sashegdev.zernmc.launcher.utils.ZHttpClient; +import java.net.URL; +import java.net.URLDecoder; +import java.util.Base64; +import java.util.jar.JarEntry; +import java.util.jar.JarFile; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardOpenOption; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicBoolean; +import com.sun.net.httpserver.HttpServer; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.Headers; + +import javax.swing.JFrame; +import javax.swing.SwingUtilities; + +public class JCEFLauncher { + private static int PORT = 8080; + private static final String APP_TITLE = "ZernMC Launcher"; + private static final String LAUNCHER_SERVER = System.getProperty("launcher.server", "https://api.zernmc.ru"); + private final LauncherAPI api = new LauncherAPI(); + private final Gson gson = new Gson(); + private HttpServer server; + private static StringBuilder launcherLogBuffer = new StringBuilder(); + private static StringBuilder gameLogBuffer = new StringBuilder(); + private static Path gameLogFile; + private static Path launcherLogFile; + private JFrame mainFrame; + private CefApp cefApp; + private CefClient cefClient; + private CefBrowser cefBrowser; + + private static volatile String installProgressLabel = ""; + private static volatile int installProgressCurrent = 0; + private static volatile int installProgressTotal = 100; + private static final AtomicBoolean installInProgress = new AtomicBoolean(false); + private static volatile String installStageName = ""; + private static volatile int installStageIndex = 0; + private static volatile int installStageCount = 1; + + private static final java.util.concurrent.CopyOnWriteArrayList logConsumers = new java.util.concurrent.CopyOnWriteArrayList<>(); + private static final java.util.concurrent.CopyOnWriteArrayList gameLogConsumers = new java.util.concurrent.CopyOnWriteArrayList<>(); + + public interface LogConsumer { + void onLog(String line); + } + + public static void addLogConsumer(LogConsumer consumer) { + logConsumers.add(consumer); + } + + public static void removeLogConsumer(LogConsumer consumer) { + logConsumers.remove(consumer); + } + + public static void addGameLogConsumer(LogConsumer consumer) { + gameLogConsumers.add(consumer); + } + + public static void removeGameLogConsumer(LogConsumer consumer) { + gameLogConsumers.remove(consumer); + } + + public static void setInstallProgress(String label, int current, int total) { + installProgressLabel = label; + installProgressCurrent = current; + installProgressTotal = total; + } + + public static void setInstallStage(String stageName, int stageIndex, int stageCount) { + installStageName = stageName; + installStageIndex = stageIndex; + installStageCount = stageCount; + } + + public static void setInstallProgressWithStage(String label, int current, int total, String stageName, int stageIndex, int stageCount) { + installProgressLabel = label; + installProgressCurrent = current; + installProgressTotal = total; + installStageName = stageName; + installStageIndex = stageIndex; + installStageCount = stageCount; + } + + public static void setInstallInProgress(boolean inProgress) { + installInProgress.set(inProgress); + } + + public static boolean isInstallInProgress() { + return installInProgress.get(); + } + + public static void appendLauncherLog(String msg) { + synchronized (launcherLogBuffer) { + launcherLogBuffer.append(msg).append("\n"); + } + LauncherLogger.info("[EXT] " + msg); + for (LogConsumer consumer : logConsumers) { + try { consumer.onLog(msg); } catch (Exception ignored) {} + } + } + + public static void appendGameLog(String msg) { + synchronized (gameLogBuffer) { + gameLogBuffer.append(msg).append("\n"); + + if (gameLogFile != null) { + try { + String timestamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss")); + Files.writeString(gameLogFile, "[" + timestamp + "] " + msg + "\n", + StandardOpenOption.CREATE, StandardOpenOption.APPEND); + } catch (Exception ignored) {} + } + } + LauncherLogger.info("[GAME] " + msg); + for (LogConsumer consumer : gameLogConsumers) { + try { consumer.onLog(msg); } catch (Exception ignored) {} + } + } + + public static void initGameLog(Path instanceDir) { + synchronized (gameLogBuffer) { + gameLogBuffer.setLength(0); + } + try { + Path logsDir = instanceDir.resolve("logs"); + Files.createDirectories(logsDir); + gameLogFile = logsDir.resolve("game.log"); + Files.writeString(gameLogFile, "=== Game Log " + LocalDateTime.now() + " ===\n", + StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING); + } catch (Exception ignored) {} + } + + public static void clearGameLog() { + synchronized (gameLogBuffer) { + gameLogBuffer.setLength(0); + } + } + + public static String getGameLogs() { + synchronized (gameLogBuffer) { + return gameLogBuffer.toString(); + } + } + + public static String getLauncherLogs() { + synchronized (launcherLogBuffer) { + return launcherLogBuffer.toString(); + } + } + + public static void main(String[] args) { + Runtime.getRuntime().addShutdownHook(new Thread(() -> { + System.out.println("[JCEF] Shutdown hook triggered"); + LaunchService.killAllProcesses(); + })); + JCEFLauncher launcher = new JCEFLauncher(); + launcher.start(); + } + + private void start() { + try { + launcherLogFile = LauncherLogger.getLogFile(); + extractAssets(); + log("Starting JCEF UI..."); + log("Loading saved session..."); + boolean sessionLoaded = false; + try { + sessionLoaded = AuthManager.loadSavedSession(); + } catch (Exception e) { + log("Session load error: " + e.getMessage()); + } + if (sessionLoaded) { + log("Session loaded for: " + AuthManager.getUsername()); + log("Pass active: " + AuthManager.hasActivePass()); + log("Role: " + AuthManager.getRole() + " (" + AuthManager.getRoleName() + ")"); + } else { + log("No saved session found, will show login screen"); + } + log("Starting background network init..."); + new Thread(() -> { + try { + ZHttpClient.checkAllServicesOnStartup(); + log("Network init complete"); + } catch (Exception e) { + log("Network init warning: " + e.getMessage()); + } + }, "network-init").start(); + startServer(); + startUI(); + } catch (Exception e) { + log("Error: " + e.getMessage()); + e.printStackTrace(); + throw new RuntimeException(e); + } + } + + private void startUI() throws Exception { + // Initialize JCEF via jcefmaven (auto-downloads natives) + java.awt.Toolkit.getDefaultToolkit(); + + CefAppBuilder builder = new CefAppBuilder(); + builder.setInstallDir(new File("jcef-bundle")); + builder.setProgressHandler(new ConsoleProgressHandler()); + builder.getCefSettings().windowless_rendering_enabled = false; + cefApp = builder.build(); + cefClient = cefApp.createClient(); + + // Handle JS console via display handler + cefClient.addDisplayHandler(new CefDisplayHandlerAdapter() { + @Override + public boolean onConsoleMessage(CefBrowser browser, CefSettings.LogSeverity level, String message, String source, int line) { + String levelStr = level == CefSettings.LogSeverity.LOGSEVERITY_ERROR ? "[ERR]" : "[LOG]"; + System.out.println("[JS] " + levelStr + " " + message); + return true; + } + }); + + // Block new windows (popups) + cefClient.addLifeSpanHandler(new CefLifeSpanHandlerAdapter() { + @Override + public boolean onBeforePopup(CefBrowser browser, CefFrame frame, String targetUrl, String targetFrameName) { + log("[CEF] Blocked popup: " + targetUrl); + return true; // Block + } + }); + + String url = "http://localhost:" + PORT + "/assets/ui/index.html"; + cefBrowser = cefClient.createBrowser(url, false, false); + + // Inject JS bridges on page load + cefClient.addLoadHandler(new CefLoadHandlerAdapter() { + @Override + public void onLoadEnd(CefBrowser browser, CefFrame frame, int httpStatusCode) { + if (!frame.isMain()) return; + log("Page loaded"); + + // Override console.log/error, window.open, and external link handling + browser.executeJavaScript( + "if (!window._cefConsoleOverridden) {" + + " window._cefConsoleOverridden = true;" + + " window.open = function(url, name, features) {" + + " var req = new XMLHttpRequest();" + + " req.open('POST', '/api/open-url', false);" + + " req.send(JSON.stringify({url: url}));" + + " return window;" + + " };" + + " document.addEventListener('click', function(e) {" + + " var target = e.target;" + + " while (target && target.tagName !== 'A') target = target.parentNode;" + + " if (target && target.href && target.href !== '#' && !target.href.startsWith('http://localhost:" + PORT + "/')) {" + + " e.preventDefault();" + + " var req = new XMLHttpRequest();" + + " req.open('POST', '/api/open-url', false);" + + " req.send(JSON.stringify({url: target.href}));" + + " }" + + " }, true);" + + "}", + "about:blank", 0 + ); + } + }); + + Component browserComponent = cefBrowser.getUIComponent(); + + // Build Swing window + SwingUtilities.invokeAndWait(() -> { + mainFrame = new JFrame(APP_TITLE); + mainFrame.setDefaultCloseOperation(JFrame.DO_NOTHING_ON_CLOSE); + mainFrame.addWindowListener(new WindowAdapter() { + @Override + public void windowClosing(WindowEvent e) { + log("Closing..."); + shutdown(); + } + }); + + int winW = Config.getWindowWidth(); + int winH = Config.getWindowHeight(); + mainFrame.setSize(Math.max(winW, 800), Math.max(winH, 600)); + mainFrame.setMinimumSize(new Dimension(800, 600)); + + mainFrame.getContentPane().add(browserComponent); + mainFrame.setVisible(true); + }); + + log("Window displayed"); + } + + private void shutdown() { + LaunchService.killAllProcesses(); + if (server != null) server.stop(0); + if (cefBrowser != null) { + cefBrowser.close(true); + } + if (cefClient != null) { + cefClient.dispose(); + } + if (cefApp != null) { + cefApp.dispose(); + } + System.exit(0); + } + + // ====================== ASSETS EXTRACTION (identical to JFX) ====================== + + private static void extractAssets() { + try { + Path assetsDir = Paths.get("assets"); + String currentVersion = Version.getCurrentVersion(); + Path versionFile = assetsDir.resolve(".version"); + + if (Files.exists(assetsDir) && Files.exists(versionFile)) { + try { + String existingVersion = Files.readString(versionFile).trim(); + if (existingVersion.equals(currentVersion)) { + System.out.println("[JCEF] Assets up to date (v" + currentVersion + ")"); + return; + } + } catch (Exception ignored) {} + } + + if (Files.exists(assetsDir)) { + try { + Files.walk(assetsDir) + .sorted(java.util.Comparator.reverseOrder()) + .forEach(p -> { try { Files.deleteIfExists(p); } catch (Exception ignored) {} }); + } catch (Exception ignored) {} + } + + String serverVersion = getServerVersion(); + if (serverVersion != null && !serverVersion.isEmpty()) { + System.out.println("[JCEF] Loading assets via meta for version " + serverVersion); + if (downloadAssetsFromMeta(serverVersion)) { + System.out.println("[JCEF] Assets loaded via meta"); + Files.writeString(versionFile, currentVersion); + return; + } + System.out.println("[JCEF] Meta unavailable, using fallback"); + } + + System.out.println("[JCEF] Extracting assets from JAR..."); + Files.createDirectories(assetsDir); + Path jarPath = Paths.get(JCEFLauncher.class.getProtectionDomain().getCodeSource().getLocation().toURI()); + if (Files.exists(jarPath) && jarPath.toString().endsWith(".jar")) { + try (JarFile jar = new JarFile(jarPath.toFile())) { + var entries = jar.entries(); + while (entries.hasMoreElements()) { + JarEntry entry = entries.nextElement(); + if (entry.getName().startsWith("assets/")) { + Path outPath = assetsDir.resolve(entry.getName().substring(7)); + if (entry.isDirectory()) { + Files.createDirectories(outPath); + } else { + Files.createDirectories(outPath.getParent()); + try (InputStream is = jar.getInputStream(entry)) { + Files.copy(is, outPath, java.nio.file.StandardCopyOption.REPLACE_EXISTING); + } + } + } + } + } + System.out.println("[JCEF] Assets extracted from JAR"); + Files.writeString(versionFile, currentVersion); + } + } catch (Exception e) { + System.out.println("[JCEF] Error extracting assets: " + e.getMessage()); + } + } + + private static String getServerVersion() { + try { + URL url = new URL(LAUNCHER_SERVER + "/launcher/version"); + HttpURLConnection conn = (HttpURLConnection) url.openConnection(); + conn.setConnectTimeout(3000); + conn.setReadTimeout(3000); + if (conn.getResponseCode() == 200) { + try (BufferedReader br = new BufferedReader(new InputStreamReader(conn.getInputStream()))) { + StringBuilder sb = new StringBuilder(); + String line; + while ((line = br.readLine()) != null) sb.append(line); + org.json.JSONObject obj = new org.json.JSONObject(sb.toString()); + if (obj.has("version")) { + return obj.getString("version"); + } + } + } + } catch (Exception ignored) {} + return null; + } + + private static boolean downloadAssetsFromMeta(String version) { + try { + URL metaUrl = new URL(LAUNCHER_SERVER + "/launcher/meta/" + version); + HttpURLConnection conn = (HttpURLConnection) metaUrl.openConnection(); + conn.setConnectTimeout(5000); + conn.setReadTimeout(10000); + if (conn.getResponseCode() != 200) return false; + + try (BufferedReader br = new BufferedReader(new InputStreamReader(conn.getInputStream()))) { + StringBuilder sb = new StringBuilder(); + String line; + while ((line = br.readLine()) != null) sb.append(line); + org.json.JSONObject meta = new org.json.JSONObject(sb.toString()); + + Path assetsDir = Paths.get("assets"); + Files.createDirectories(assetsDir); + + for (Object fileObj : meta.getJSONArray("files")) { + org.json.JSONObject file = (org.json.JSONObject) fileObj; + String path = file.getString("path"); + if (path.startsWith("assets/")) { + String downloadUrl = LAUNCHER_SERVER + "/launcher/file/" + version + "/" + path; + Path outPath = assetsDir.resolve(path.substring(7)); + Files.createDirectories(outPath.getParent()); + + URL fileUrl = new URL(downloadUrl); + HttpURLConnection fileConn = (HttpURLConnection) fileUrl.openConnection(); + fileConn.setConnectTimeout(10000); + fileConn.setReadTimeout(30000); + if (fileConn.getResponseCode() == 200) { + try (InputStream is = fileConn.getInputStream()) { + Files.copy(is, outPath); + } + } + fileConn.disconnect(); + } + } + return true; + } + } catch (Exception e) { + System.out.println("[JCEF] Error loading via meta: " + e.getMessage()); + return false; + } + } + + // ====================== HTTP SERVER (identical to JFX) ====================== + + private void startServer() throws Exception { + int maxPortAttempts = 20; + for (int port = PORT; port < PORT + maxPortAttempts; port++) { + try { + server = HttpServer.create(new InetSocketAddress("127.0.0.1", port), 0); + PORT = port; + break; + } catch (java.net.BindException e) { + if (port == PORT + maxPortAttempts - 1) throw e; + } + } + + server.createContext("/api/login", this::handleLogin); + server.createContext("/api/auto-login", this::handleAutoLogin); + server.createContext("/api/account", this::handleAccount); + server.createContext("/api/token", this::handleToken); + server.createContext("/api/instances", this::handleInstances); + server.createContext("/api/launch", this::handleLaunch); + server.createContext("/api/install", this::handleInstall); + server.createContext("/api/install/progress", this::handleInstallProgress); + server.createContext("/api/logs", this::handleLogs); + server.createContext("/api/logs/stream", this::handleLogsStream); + server.createContext("/api/game-logs", this::handleGameLogs); + server.createContext("/api/game-logs/stream", this::handleGameLogsStream); + server.createContext("/api/mc-versions", this::handleMCVersions); + server.createContext("/api/loader-versions", this::handleLoaderVersions); + server.createContext("/api/packs", this::handlePacks); + server.createContext("/api/settings", this::handleSettings); + server.createContext("/api/activate-pass", this::handleActivatePass); + server.createContext("/api/register", this::handleRegister); + server.createContext("/api/pack-info", this::handlePackInfo); + server.createContext("/api/shutdown", this::handleShutdown); + server.createContext("/api/exit", this::handleExit); + server.createContext("/api/news", this::handleNews); + server.createContext("/api/open-url", this::handleOpenUrl); + server.createContext("/api/exit-parent", this::handleExitParent); + server.createContext("/api/system-info", this::handleSystemInfo); + server.createContext("/api/open-log-file", this::handleOpenLogFile); + server.createContext("/api/friends/list", this::handleFriendList); + server.createContext("/api/friends/add", this::handleFriendAdd); + server.createContext("/api/friends/remove", this::handleFriendRemove); + server.createContext("/api/friends/accept", this::handleFriendAccept); + server.createContext("/api/friends/requests", this::handleFriendRequests); + server.createContext("/api/friends/status", this::handleFriendStatus); + server.createContext("/api/check-updates", this::handleCheckUpdates); + server.createContext("/api/update", this::handleUpdate); + server.createContext("/api/playtime/sync", this::handlePlaytimeSync); + server.createContext("/api/playtime/stats", this::handlePlaytimeStats); + server.createContext("/api/whitelist/mods", this::handleWhitelistMods); + server.createContext("/api/whitelist/mods/install", this::handleWhitelistInstallMod); + server.createContext("/api/admin", this::handleAdmin); + server.createContext("/assets/", this::handleStatic); + + server.setExecutor(Executors.newCachedThreadPool()); + server.start(); + + log("HTTP server on port " + PORT); + } + + private void stopServer() { + if (server != null) server.stop(0); + } + + // ====================== HTTP HANDLERS (identical to JFX) ====================== + + private void handleSystemInfo(HttpExchange exchange) { + try { + Map info = new HashMap<>(); + info.put("cpuCores", Config.getSystemCpuCores()); + info.put("totalRamMB", Config.getSystemTotalRamMB()); + info.put("systemJvmFlags", Config.getSystemJvmFlags()); + sendJson(exchange, Map.of("success", true, "data", info)); + } catch (Exception e) { + sendJson(exchange, Map.of("success", false, "error", e.getMessage())); + } + } + + private void handleOpenLogFile(HttpExchange exchange) { + try { + if (gameLogFile != null && Files.exists(gameLogFile)) { + if (Desktop.isDesktopSupported()) { + Desktop.getDesktop().open(gameLogFile.toFile()); + sendJson(exchange, Map.of("success", true)); + } else { + sendJson(exchange, Map.of("success", false, "error", "Desktop not supported")); + } + } else { + sendJson(exchange, Map.of("success", false, "error", "No game log file found")); + } + } catch (Exception e) { + sendJson(exchange, Map.of("success", false, "error", e.getMessage())); + } + } + + private void handleOpenUrl(HttpExchange exchange) { + try { + Map body = parseJson(exchange.getRequestBody()); + String url = body.get("url"); + if (url != null && !url.isEmpty()) { + openInBrowser(url); + } + sendJson(exchange, Map.of("success", true)); + } catch (Exception e) { + sendJson(exchange, Map.of("success", false, "error", e.getMessage())); + } + } + + private void handleLogin(HttpExchange exchange) { + try { + log("handleLogin: " + exchange.getRequestMethod() + " from " + exchange.getRemoteAddress()); + if (!"POST".equals(exchange.getRequestMethod())) { + sendJson(exchange, Map.of("success", false, "error", "Method not supported")); + return; + } + + Map body = parseJson(exchange.getRequestBody()); + String username = body.get("username"); + String password = body.get("password"); + + var result = api.login(username, password); + if (result.isSuccess()) { + Map data = new HashMap<>(); + data.put("username", result.getData().getUsername()); + data.put("token", result.getData().getToken()); + data.put("passActive", AuthManager.hasActivePass()); + data.put("role", AuthManager.getRole()); + data.put("roleName", AuthManager.getRoleName()); + sendJson(exchange, Map.of("success", true, "data", data)); + log("Login success: " + username); + } else { + log("Login failed: " + result.getError()); + sendJson(exchange, Map.of("success", false, "error", result.getError())); + } + } catch (Exception e) { + log("Login error: " + e.getMessage()); + e.printStackTrace(); + sendJson(exchange, Map.of("success", false, "error", e.getMessage())); + } + } + + private void handleAccount(HttpExchange exchange) { + try { + log("handleAccount: isLoggedIn=" + api.isLoggedIn()); + if (!api.isLoggedIn()) { + log("handleAccount: not authenticated"); + sendJson(exchange, Map.of("success", false, "error", "Not authenticated")); + return; + } + Map data = new HashMap<>(); + data.put("username", api.getCurrentUsername()); + data.put("passActive", AuthManager.hasActivePass()); + data.put("role", AuthManager.getRole()); + data.put("roleName", AuthManager.getRoleName()); + sendJson(exchange, Map.of("success", true, "data", data)); + } catch (Exception e) { + sendJson(exchange, Map.of("success", false, "error", e.getMessage())); + } + } + + private void handleToken(HttpExchange exchange) { + try { + if (!api.isLoggedIn()) { + sendJson(exchange, Map.of("success", false, "error", "Not authenticated")); + return; + } + String token = AuthManager.getAccessToken(); + if (token == null || token.isEmpty() || "0".equals(token)) { + sendJson(exchange, Map.of("success", false, "error", "No token")); + return; + } + sendJson(exchange, Map.of("success", true, "token", token)); + } catch (Exception e) { + sendJson(exchange, Map.of("success", false, "error", e.getMessage())); + } + } + + private void handleAutoLogin(HttpExchange exchange) { + try { + boolean loggedIn = AuthManager.isLoggedIn(); + boolean authExists = AuthManager.authFileExists(); + System.out.println("[AUTH] handleAutoLogin: isLoggedIn=" + loggedIn + " authFile=" + authExists); + log("handleAutoLogin: isLoggedIn=" + loggedIn + " authFile=" + authExists); + if (AuthManager.tryAutoLogin()) { + Map data = new HashMap<>(); + data.put("username", AuthManager.getUsername()); + data.put("passActive", AuthManager.hasActivePass()); + data.put("role", AuthManager.getRole()); + data.put("roleName", AuthManager.getRoleName()); + 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()); + } else { + System.out.println("[AUTH] handleAutoLogin: no valid session (authFile=" + authExists + ")"); + log("handleAutoLogin: no valid session (authFile=" + authExists + ")"); + sendJson(exchange, Map.of("success", false, "autoLogin", false)); + } + } catch (Exception e) { + System.out.println("[AUTH] handleAutoLogin error: " + e.getMessage()); + log("handleAutoLogin error: " + e.getMessage()); + sendJson(exchange, Map.of("success", false, "error", e.getMessage())); + } + } + + private void handleInstances(HttpExchange exchange) { + try { + var result = api.getAllInstances(); + Map response = new HashMap<>(); + response.put("success", result.isSuccess()); + if (result.isSuccess()) { + response.put("data", result.getData()); + } else { + response.put("error", result.getError()); + } + sendJson(exchange, response); + } catch (Exception e) { + sendJson(exchange, Map.of("success", false, "error", e.getMessage())); + } + } + + private void handleLaunch(HttpExchange exchange) { + try { + if (!api.isLoggedIn()) { + sendJson(exchange, Map.of("success", false, "error", "Not authenticated")); + return; + } + + Map body = parseJson(exchange.getRequestBody()); + String name = body.get("name"); + + Instance instance = InstanceManager.getInstance(name); + if (instance == null) { + sendJson(exchange, Map.of("success", false, "error", "Pack not found")); + return; + } + + log("handleLaunch: " + name + " isServerPack=" + instance.isServerPack() + " hasPass=" + AuthManager.hasActivePass() + " isLoggedIn=" + api.isLoggedIn()); + + if (instance.isServerPack() && !AuthManager.hasActivePass()) { + log("handleLaunch: server pack requires active pass"); + sendJson(exchange, Map.of("success", false, "error", "Server pack requires an active pass")); + return; + } + + var result = api.launch(name); + if (result.isSuccess()) { + Map data = new HashMap<>(); + data.put("pid", result.getData().getPid()); + data.put("status", result.getData().getStatus()); + sendJson(exchange, Map.of("success", true, "data", data)); + log("Launched: " + name + " pid=" + result.getData().getPid()); + } else { + log("Launch failed: " + result.getError()); + sendJson(exchange, Map.of("success", false, "error", result.getError())); + } + } catch (Exception e) { + log("handleLaunch error: " + e.getMessage()); + sendJson(exchange, Map.of("success", false, "error", e.getMessage())); + } + } + + private void handleInstall(HttpExchange exchange) { + if (!installInProgress.compareAndSet(false, true)) { + sendJson(exchange, Map.of("success", false, "error", "Installation already in progress")); + return; + } + + try { + if (!api.isLoggedIn()) { + installInProgress.set(false); + sendJson(exchange, Map.of("success", false, "error", "Not authenticated")); + return; + } + + Map body = parseJson(exchange.getRequestBody()); + String name = body.get("name"); + String version = body.get("version"); + String loader = body.get("loader"); + String loaderVersion = body.get("loaderVersion"); + + log("Install: " + name + " " + version + " " + loader + (loaderVersion != null ? " " + loaderVersion : "")); + + var createResult = api.instances().createInstance(name); + if (!createResult.isSuccess()) { + installInProgress.set(false); + sendJson(exchange, Map.of("success", false, "error", createResult.getError())); + return; + } + + Instance instance = InstanceManager.getInstance(name); + if (instance != null) { + if (!"zernmc".equalsIgnoreCase(loader)) { + instance.setMinecraftVersion(version); + instance.setLoaderType(loader); + if (loaderVersion != null) { + instance.setLoaderVersion(loaderVersion); + } + } + + sendJson(exchange, Map.of("success", true, "message", "Installation started")); + + setInstallProgressWithStage("Preparing...", 0, 100, "Preparing", 0, 4); + + Thread installThread = new Thread(() -> { + try { + boolean success = false; + + if ("zernmc".equalsIgnoreCase(loader)) { + setInstallProgressWithStage("Fetching pack info...", 10, 100, "Fetching pack info", 0, 5); + PackDownloader downloader = new PackDownloader(instance); + downloader.setProgressCallback((label, pct, stageName, stageIdx, stageCount) -> { + setInstallProgressWithStage(label, pct, 100, stageName, stageIdx, stageCount); + }); + var packs = downloader.getAvailablePacks(); + ServerPack pack = null; + for (ServerPack p : packs) { + if (p.getName().equals(version)) { + pack = p; + break; + } + } + if (pack != null) { + try { + success = downloader.installOrUpdatePack(version, pack); + } catch (Exception e) { + log("Pack install error: " + e.getMessage()); + throw e; + } + if (success) { + setInstallProgressWithStage("Finalizing...", 95, 100, "Finalizing", 4, 5); + } else { + log("Install error: pack install returned false"); + } + } else { + log("Install error: pack not found on server: " + version + " (available: " + (packs != null ? packs.size() : 0) + ")"); + } + } else { + MinecraftLib lib = new MinecraftLib(instance); + setInstallProgressWithStage("Installing Minecraft...", 20, 100, "Installing Minecraft", 0, 3); + if ("vanilla".equalsIgnoreCase(loader)) { + success = lib.installMinecraft(version); + } else { + setInstallProgressWithStage("Installing loader...", 30, 100, "Installing loader", 1, 3); + success = lib.installPack(name, version, loader, loaderVersion != null ? loaderVersion : ""); + } + setInstallProgressWithStage("Finalizing...", 90, 100, "Finalizing", 2, 3); + } + + setInstallInProgress(false); + if (success) { + setInstallProgressWithStage("Installation complete!", 100, 100, "Done", 0, 1); + log("Installed: " + name); + } else { + setInstallProgressWithStage("Installation failed", 0, 100, "Failed", 0, 1); + log("Install error: " + name); + } + } catch (Exception e) { + log("Install error: " + e.getMessage()); + setInstallInProgress(false); + setInstallProgressWithStage("Error: " + e.getMessage(), 0, 100, "Error", 0, 1); + } + }); + installThread.setDaemon(true); + installThread.start(); + } else { + sendJson(exchange, Map.of("success", false, "error", "Instance not found")); + } + } catch (Exception e) { + log("Install error: " + e.getMessage()); + setInstallInProgress(false); + sendJson(exchange, Map.of("success", false, "error", e.getMessage())); + } + } + + private void handleInstallProgress(HttpExchange exchange) { + try { + Map progress = new java.util.HashMap<>(); + progress.put("label", installProgressLabel); + progress.put("current", installProgressCurrent); + progress.put("total", installProgressTotal); + progress.put("inProgress", installInProgress.get()); + progress.put("stageName", installStageName); + progress.put("stageIndex", installStageIndex); + progress.put("stageCount", Math.max(installStageCount, 1)); + + if (installInProgress.get() && installProgressTotal > 0) { + progress.put("percent", (int) ((installProgressCurrent * 100.0) / installProgressTotal)); + } else { + progress.put("percent", installInProgress.get() ? 0 : 100); + } + + sendJson(exchange, Map.of("success", true, "data", progress)); + } catch (Exception e) { + sendJson(exchange, Map.of("success", false, "error", e.getMessage())); + } + } + + private void handleLogs(HttpExchange exchange) { + sendJson(exchange, Map.of("success", true, "data", getLauncherLogs())); + } + + private void handleLogsStream(HttpExchange exchange) { + final LogConsumer[] consumerRef = new LogConsumer[1]; + try { + exchange.getResponseHeaders().set("Content-Type", "text/event-stream"); + exchange.getResponseHeaders().set("Cache-Control", "no-cache"); + exchange.getResponseHeaders().set("Connection", "keep-alive"); + exchange.sendResponseHeaders(200, 0); + + final OutputStream os = exchange.getResponseBody(); + + LogConsumer consumer = new LogConsumer() { + @Override + public synchronized void onLog(String line) { + try { + 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; + + addLogConsumer(consumer); + + Thread.currentThread().setName("sse-logs-stream"); + while (!Thread.currentThread().isInterrupted()) { + try { + os.write(": heartbeat\n\n".getBytes(StandardCharsets.UTF_8)); + os.flush(); + Thread.sleep(10000); + } catch (InterruptedException e) { break; } catch (Exception e) { break; } + } + } catch (Exception ignored) {} finally { + if (consumerRef[0] != null) removeLogConsumer(consumerRef[0]); + try { exchange.getResponseBody().close(); } catch (Exception ignored) {} + } + } + + private void handleGameLogs(HttpExchange exchange) { + sendJson(exchange, Map.of("success", true, "data", getGameLogs())); + } + + private void handleGameLogsStream(HttpExchange exchange) { + final LogConsumer[] consumerRef = new LogConsumer[1]; + try { + exchange.getResponseHeaders().set("Content-Type", "text/event-stream"); + exchange.getResponseHeaders().set("Cache-Control", "no-cache"); + exchange.getResponseHeaders().set("Connection", "keep-alive"); + exchange.sendResponseHeaders(200, 0); + + final OutputStream os = exchange.getResponseBody(); + + LogConsumer consumer = new LogConsumer() { + @Override + public synchronized void onLog(String line) { + try { + 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; + + addGameLogConsumer(consumer); + + Thread.currentThread().setName("sse-game-logs-stream"); + while (!Thread.currentThread().isInterrupted()) { + try { + os.write(": heartbeat\n\n".getBytes(StandardCharsets.UTF_8)); + os.flush(); + Thread.sleep(10000); + } catch (InterruptedException e) { break; } catch (Exception e) { break; } + } + } catch (Exception ignored) {} finally { + if (consumerRef[0] != null) removeGameLogConsumer(consumerRef[0]); + try { exchange.getResponseBody().close(); } catch (Exception ignored) {} + } + } + + private void handleMCVersions(HttpExchange exchange) { + try { + var versions = api.getMCVersions(); + if (versions.isSuccess()) { + sendJson(exchange, Map.of("success", true, "data", versions.getData())); + } else { + sendJson(exchange, Map.of("success", false, "error", versions.getError())); + } + } catch (Exception e) { + sendJson(exchange, Map.of("success", false, "error", e.getMessage())); + } + } + + private void handleLoaderVersions(HttpExchange exchange) { + try { + Map params = parseQuery(exchange.getRequestURI().getQuery()); + String mcVersion = params.get("mc"); + String loader = params.get("loader"); + + var versions = api.getLoaderVersions(mcVersion, loader); + if (versions.isSuccess()) { + sendJson(exchange, Map.of("success", true, "data", versions.getData())); + } else { + sendJson(exchange, Map.of("success", false, "error", versions.getError())); + } + } catch (Exception e) { + sendJson(exchange, Map.of("success", false, "error", e.getMessage())); + } + } + + private void handlePacks(HttpExchange exchange) { + try { + var result = api.getZernMCPacks(); + if (result.isSuccess()) { + sendJson(exchange, Map.of("success", true, "data", result.getData())); + } else { + sendJson(exchange, Map.of("success", false, "error", result.getError())); + } + } catch (Exception e) { + sendJson(exchange, Map.of("success", false, "error", e.getMessage())); + } + } + + private void handleNews(HttpExchange exchange) { + try { + var url = new java.net.URL(ZHttpClient.getBaseUrl() + "/news"); + var conn = (HttpURLConnection) url.openConnection(); + conn.setRequestMethod("GET"); + conn.setConnectTimeout(5000); + conn.setReadTimeout(5000); + int code = conn.getResponseCode(); + if (code == 200) { + byte[] body = conn.getInputStream().readAllBytes(); + var raw = new String(body, java.nio.charset.StandardCharsets.UTF_8); + sendJson(exchange, Map.of("success", true, "data", raw)); + } else { + sendJson(exchange, Map.of("success", false, "error", "Server returned " + code)); + } + } catch (Exception e) { + sendJson(exchange, Map.of("success", false, "error", e.getMessage())); + } + } + + private void handleActivatePass(HttpExchange exchange) { + try { + if (!api.isLoggedIn()) { + sendJson(exchange, Map.of("success", false, "error", "Not authenticated")); + return; + } + Map body = parseJson(exchange.getRequestBody()); + String code = body.get("pass_code"); + if (code == null || code.isEmpty()) { + sendJson(exchange, Map.of("success", false, "error", "Enter pass code")); + return; + } + var result = api.activatePass(code); + if (result.isSuccess()) { + sendJson(exchange, Map.of("success", true, "message", "Pass activated!")); + log("Pass activated"); + } else { + sendJson(exchange, Map.of("success", false, "error", result.getError())); + } + } catch (Exception e) { + sendJson(exchange, Map.of("success", false, "error", e.getMessage())); + } + } + + private void handleRegister(HttpExchange exchange) { + try { + Map body = parseJson(exchange.getRequestBody()); + String username = body.get("username"); + String password = body.get("password"); + if (username == null || password == null || username.isEmpty() || password.isEmpty()) { + sendJson(exchange, Map.of("success", false, "error", "Fill all fields")); + return; + } + var result = api.register(username, password); + if (result.isSuccess()) { + Map data = new HashMap<>(); + data.put("username", result.getData().getUsername()); + data.put("token", result.getData().getToken()); + data.put("passActive", AuthManager.hasActivePass()); + data.put("role", AuthManager.getRole()); + data.put("roleName", AuthManager.getRoleName()); + sendJson(exchange, Map.of("success", true, "data", data)); + log("Registered: " + username); + } else { + sendJson(exchange, Map.of("success", false, "error", result.getError())); + } + } catch (Exception e) { + sendJson(exchange, Map.of("success", false, "error", e.getMessage())); + } + } + + private void handlePackInfo(HttpExchange exchange) { + try { + Map params = parseQuery(exchange.getRequestURI().getQuery()); + String name = params.get("name"); + if (name == null || name.isEmpty()) { + sendJson(exchange, Map.of("success", false, "error", "Missing pack name")); + return; + } + Instance instance = InstanceManager.getInstance(name); + if (instance == null) { + sendJson(exchange, Map.of("success", false, "error", "Pack not found")); + return; + } + Path dir = instance.getPath(); + Map info = new HashMap<>(); + + int modsCount = 0; + Path modsDir = dir.resolve("mods"); + if (Files.exists(modsDir)) { + try (var files = Files.list(modsDir)) { + modsCount = (int) files.filter(p -> p.toString().endsWith(".jar")).count(); + } + } + info.put("modsCount", modsCount); + + List screenshots = new ArrayList<>(); + Path screenshotsDir = dir.resolve("screenshots"); + if (Files.exists(screenshotsDir)) { + try (var files = Files.list(screenshotsDir)) { + files.filter(p -> { + String n = p.getFileName().toString().toLowerCase(); + return n.endsWith(".png") || n.endsWith(".jpg"); + }).limit(20).forEach(p -> screenshots.add(p.getFileName().toString())); + } + } + info.put("screenshots", screenshots); + + List worlds = new ArrayList<>(); + Path savesDir = dir.resolve("saves"); + if (Files.exists(savesDir)) { + try (var dirs = Files.list(savesDir)) { + dirs.filter(Files::isDirectory).limit(20).forEach(p -> worlds.add(p.getFileName().toString())); + } + } + info.put("worlds", worlds); + + List recentLogs = new ArrayList<>(); + Path logsDir = dir.resolve("logs"); + if (Files.exists(logsDir)) { + try (var files = Files.list(logsDir)) { + files.filter(p -> p.toString().endsWith(".log") || p.toString().endsWith(".txt")) + .sorted((a, b) -> { + try { return Long.compare(Files.getLastModifiedTime(b).toMillis(), Files.getLastModifiedTime(a).toMillis()); } + catch (Exception e) { return 0; } + }) + .limit(5) + .forEach(p -> recentLogs.add(p.getFileName().toString())); + } + } + info.put("recentLogs", recentLogs); + + sendJson(exchange, Map.of("success", true, "data", info)); + } catch (Exception e) { + sendJson(exchange, Map.of("success", false, "error", e.getMessage())); + } + } + + private void handleSettings(HttpExchange exchange) { + try { + log("handleSettings: " + exchange.getRequestMethod()); + if ("POST".equals(exchange.getRequestMethod())) { + Map body = parseJson(exchange.getRequestBody()); + if (body.containsKey("maxMemory")) { + Config.setMaxMemory(Integer.parseInt(body.get("maxMemory"))); + } + if (body.containsKey("windowWidth")) { + Config.setWindowWidth(Integer.parseInt(body.get("windowWidth"))); + } + if (body.containsKey("windowHeight")) { + Config.setWindowHeight(Integer.parseInt(body.get("windowHeight"))); + } + if (body.containsKey("extraJvmArgs")) { + Config.setExtraJvmArgs(body.get("extraJvmArgs")); + } + if (body.containsKey("javaPath")) { + Config.setJavaPath(body.get("javaPath")); + } + if (body.containsKey("locale")) { + Config.setLocale(body.get("locale")); + } + if (body.containsKey("systemBasedJvm")) { + Config.setSystemBasedJvm(Boolean.parseBoolean(body.get("systemBasedJvm"))); + } + Map res = new HashMap<>(); + res.put("success", true); + res.put("maxMemory", Config.getMaxMemory()); + sendJson(exchange, res); + return; + } + Map data = new HashMap<>(); + data.put("maxMemory", Config.getMaxMemory()); + data.put("serverUrl", Config.getServerUrl()); + data.put("instancesDir", Config.getInstancesDir().toString()); + data.put("windowWidth", Config.getWindowWidth()); + data.put("windowHeight", Config.getWindowHeight()); + data.put("extraJvmArgs", Config.getExtraJvmArgs()); + data.put("javaPath", Config.getJavaPath()); + data.put("locale", Config.getLocale()); + data.put("systemBasedJvm", Config.isSystemBasedJvm()); + data.put("cpuCores", Config.getSystemCpuCores()); + data.put("totalRamMB", Config.getSystemTotalRamMB()); + sendJson(exchange, Map.of("success", true, "data", data)); + } catch (Exception e) { + sendJson(exchange, Map.of("success", false, "error", e.getMessage())); + } + } + + private Map parseQuery(String query) { + Map params = new HashMap<>(); + if (query != null && !query.isEmpty()) { + for (String pair : query.split("&")) { + String[] kv = pair.split("="); + if (kv.length == 2) { + params.put(kv[0], kv[1]); + } + } + } + return params; + } + + private void handleShutdown(HttpExchange exchange) { + log("Shutdown request received..."); + shutdown(); + } + + private void handleExit(HttpExchange exchange) { + log("Exiting..."); + shutdown(); + } + + private void handleExitParent(HttpExchange exchange) { + log("Terminating parent process..."); + LaunchService.killAllProcesses(); + if (server != null) server.stop(0); + if (cefBrowser != null) cefBrowser.close(true); + if (cefClient != null) cefClient.dispose(); + if (cefApp != null) cefApp.dispose(); + System.exit(240); + } + + private void handleStatic(HttpExchange exchange) { + try { + String path = exchange.getRequestURI().getPath(); + log("[UI] Request: " + path); + + String relativePath = path.startsWith("/") ? path.substring(1) : path; + Path file = Paths.get(relativePath).toAbsolutePath().normalize(); + Path assetsDir = Paths.get("assets").toAbsolutePath().normalize(); + + if (!file.startsWith(assetsDir)) { + log("[UI] Forbidden: " + file); + exchange.sendResponseHeaders(403, 0); + exchange.close(); + return; + } + + if (!Files.exists(file) || Files.isDirectory(file)) { + log("[UI] File not found: " + file); + exchange.sendResponseHeaders(404, 0); + exchange.close(); + return; + } + + byte[] content = Files.readAllBytes(file); + log("[UI] Loaded " + content.length + " bytes: " + path); + String ct = getContentType(path); + + exchange.getResponseHeaders().set("Content-Type", ct); + exchange.sendResponseHeaders(200, content.length); + exchange.getResponseBody().write(content); + exchange.close(); + } catch (Exception e) { + log("[UI] Error serving: " + e.getMessage()); + } + } + + private String getContentType(String path) { + if (path.endsWith(".html")) return "text/html; charset=utf-8"; + if (path.endsWith(".css")) return "text/css; charset=utf-8"; + if (path.endsWith(".js")) return "application/javascript; charset=utf-8"; + return "text/plain"; + } + + private Map parseJson(InputStream body) { + try { + byte[] rawBytes = body.readAllBytes(); + log("parseJson: " + rawBytes.length + " bytes"); + String raw = new String(rawBytes, StandardCharsets.UTF_8); + Map rawMap = gson.fromJson(raw, Map.class); + Map result = new HashMap<>(); + if (rawMap != null) { + for (Map.Entry entry : rawMap.entrySet()) { + String key = entry.getKey() != null ? entry.getKey().toString() : null; + String value = null; + Object v = entry.getValue(); + if (v instanceof Number) { + Number n = (Number) v; + if (n.doubleValue() == n.longValue()) { + value = String.valueOf(n.longValue()); + } else { + value = String.valueOf(n.doubleValue()); + } + } else { + value = v != null ? v.toString() : null; + } + if (key != null) result.put(key, value); + } + } + return result; + } catch (Exception e) { + log("parseJson error: " + e.getMessage()); + return new HashMap<>(); + } + } + + private void openInBrowser(String url) { + if (url == null || url.isEmpty()) return; + try { + java.awt.Desktop.getDesktop().browse(new java.net.URI(url)); + } catch (Exception e) { + try { + new ProcessBuilder("xdg-open", url).start(); + } catch (Exception e2) { + log("Failed to open browser: " + e2.getMessage()); + } + } + } + + private void sendJson(HttpExchange exchange, Map response) { + try { + String json = gson.toJson(response); + byte[] bytes = json.getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().set("Content-Type", "application/json; charset=utf-8"); + exchange.sendResponseHeaders(200, bytes.length); + exchange.getResponseBody().write(bytes); + exchange.close(); + } catch (Exception e) { + log("sendJson error: " + e.getMessage()); + } + } + + private void sendRawJson(HttpExchange exchange, String json) { + try { + byte[] bytes = json.getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().set("Content-Type", "application/json; charset=utf-8"); + exchange.sendResponseHeaders(200, bytes.length); + exchange.getResponseBody().write(bytes); + exchange.close(); + } catch (Exception e) { + log("sendRawJson error: " + e.getMessage()); + } + } + + private String readBody(HttpExchange exchange) throws IOException { + return new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8); + } + + private String remoteApiCall(String method, String path, String body) throws Exception { + URL url = new URL(LAUNCHER_SERVER + path); + HttpURLConnection conn = (HttpURLConnection) url.openConnection(); + conn.setRequestMethod(method); + conn.setRequestProperty("Content-Type", "application/json"); + conn.setRequestProperty("User-Agent", "ZernMC-Launcher/1.0"); + String token = AuthManager.getAccessToken(); + if (token != null && !token.isEmpty() && !token.equals("0")) { + conn.setRequestProperty("Authorization", "Bearer " + token); + } + conn.setConnectTimeout(10000); + conn.setReadTimeout(15000); + if (body != null && !body.isEmpty()) { + conn.setDoOutput(true); + try (OutputStream os = conn.getOutputStream()) { + os.write(body.getBytes(StandardCharsets.UTF_8)); + } + } + int code = conn.getResponseCode(); + try (InputStream is = code >= 400 ? conn.getErrorStream() : conn.getInputStream()) { + return new String(is.readAllBytes(), StandardCharsets.UTF_8); + } + } + + private void handleFriendList(HttpExchange exchange) { + try { + String response = remoteApiCall("GET", "/api/friends/list", null); + sendRawJson(exchange, response); + } catch (Exception e) { + sendJson(exchange, Map.of("success", false, "error", e.getMessage())); + } + } + + private void handleFriendAdd(HttpExchange exchange) { + try { + String body = readBody(exchange); + String response = remoteApiCall("POST", "/api/friends/add", body); + sendRawJson(exchange, response); + } catch (Exception e) { + sendJson(exchange, Map.of("success", false, "error", e.getMessage())); + } + } + + private void handleFriendRemove(HttpExchange exchange) { + try { + String body = readBody(exchange); + String response = remoteApiCall("POST", "/api/friends/remove", body); + sendRawJson(exchange, response); + } catch (Exception e) { + sendJson(exchange, Map.of("success", false, "error", e.getMessage())); + } + } + + private void handleFriendAccept(HttpExchange exchange) { + try { + String body = readBody(exchange); + String response = remoteApiCall("POST", "/api/friends/accept", body); + sendRawJson(exchange, response); + } catch (Exception e) { + sendJson(exchange, Map.of("success", false, "error", e.getMessage())); + } + } + + private void handleFriendRequests(HttpExchange exchange) { + try { + String response = remoteApiCall("GET", "/api/friends/requests", null); + sendRawJson(exchange, response); + } catch (Exception e) { + sendJson(exchange, Map.of("success", false, "error", e.getMessage())); + } + } + + private void handleFriendStatus(HttpExchange exchange) { + try { + String body = readBody(exchange); + String response = remoteApiCall("POST", "/api/friends/status", body); + sendRawJson(exchange, response); + } catch (Exception e) { + sendJson(exchange, Map.of("success", false, "error", e.getMessage())); + } + } + + private void handlePlaytimeSync(HttpExchange exchange) { + try { + String body = readBody(exchange); + String response = remoteApiCall("POST", "/api/playtime/sync", body); + sendRawJson(exchange, response); + } catch (Exception e) { + sendJson(exchange, Map.of("success", false, "error", e.getMessage())); + } + } + + private void handlePlaytimeStats(HttpExchange exchange) { + try { + String response = remoteApiCall("GET", "/api/playtime/stats", null); + sendRawJson(exchange, response); + } catch (Exception e) { + sendJson(exchange, Map.of("success", false, "error", e.getMessage())); + } + } + + private void handleCheckUpdates(HttpExchange exchange) { + try { + Map params = parseQuery(exchange.getRequestURI().getQuery()); + String rawName = params.get("name"); + if (rawName == null || rawName.isBlank()) { + sendJson(exchange, Map.of("success", false, "error", "Missing 'name' parameter")); + return; + } + String name = URLDecoder.decode(rawName, StandardCharsets.UTF_8); + Instance instance = InstanceManager.getInstance(name); + if (instance == null) { + sendJson(exchange, Map.of("success", false, "error", "Instance not found")); + return; + } + if (!instance.isServerPack()) { + sendJson(exchange, Map.of("success", false, "error", "Not a server pack")); + return; + } + PackDownloader downloader = new PackDownloader(instance); + String packName = instance.getServerPackName(); + int serverVersion = downloader.getPackManifest(packName).getVersion(); + int localVersion = instance.getServerVersion(); + sendJson(exchange, Map.of( + "success", true, + "data", Map.of( + "hasUpdate", serverVersion > localVersion, + "serverVersion", serverVersion, + "localVersion", localVersion + ) + )); + } catch (Exception e) { + sendJson(exchange, Map.of("success", false, "error", e.getMessage())); + } + } + + private void handleUpdate(HttpExchange exchange) { + try { + Map body = parseJson(exchange.getRequestBody()); + String name = body.get("name"); + if (name == null || name.isBlank()) { + sendJson(exchange, Map.of("success", false, "error", "Missing 'name'")); + return; + } + Instance instance = InstanceManager.getInstance(name); + if (instance == null) { + sendJson(exchange, Map.of("success", false, "error", "Instance not found")); + return; + } + if (!instance.isServerPack()) { + sendJson(exchange, Map.of("success", false, "error", "Not a server pack")); + return; + } + PackDownloader downloader = new PackDownloader(instance); + boolean success = downloader.updatePack(instance.getServerPackName()); + if (success) { + sendJson(exchange, Map.of("success", true, "data", Map.of("version", instance.getServerVersion()))); + } else { + sendJson(exchange, Map.of("success", false, "error", "Update failed")); + } + } catch (Exception e) { + sendJson(exchange, Map.of("success", false, "error", e.getMessage())); + } + } + + private void handleWhitelistMods(HttpExchange exchange) { + try { + if (!api.isLoggedIn()) { + sendJson(exchange, Map.of("success", false, "error", "Not authenticated")); + return; + } + String response = ZHttpClient.get("/whitelist/mods"); + List> mods = new ArrayList<>(); + for (String line : response.split("\n")) { + line = line.trim(); + if (line.isEmpty()) continue; + String[] parts = line.split("::"); + if (parts.length >= 3) { + Map mod = new HashMap<>(); + mod.put("name", parts[0].trim()); + mod.put("size", Long.parseLong(parts[1].trim())); + mod.put("hash", parts[2].trim()); + mods.add(mod); + } + } + sendJson(exchange, Map.of("success", true, "data", mods)); + } catch (Exception e) { + sendJson(exchange, Map.of("success", false, "error", e.getMessage())); + } + } + + private void handleWhitelistInstallMod(HttpExchange exchange) { + try { + if (!api.isLoggedIn()) { + sendJson(exchange, Map.of("success", false, "error", "Not authenticated")); + return; + } + Map body = parseJson(exchange.getRequestBody()); + String instanceName = body.get("instance"); + String modName = body.get("modName"); + String modHash = body.get("modHash"); + + if (instanceName == null || modName == null || modHash == null) { + sendJson(exchange, Map.of("success", false, "error", "Missing parameters")); + return; + } + + Instance instance = InstanceManager.getInstance(instanceName); + if (instance == null) { + sendJson(exchange, Map.of("success", false, "error", "Instance not found")); + return; + } + + Path modsDir = instance.getPath().resolve("mods"); + Files.createDirectories(modsDir); + Path targetFile = modsDir.resolve(modName); + + String url = ZHttpClient.getBaseUrl() + "/whitelist/mods/" + modHash + "/" + modName; + java.net.http.HttpRequest request = java.net.http.HttpRequest.newBuilder() + .uri(java.net.URI.create(url)) + .timeout(java.time.Duration.ofMinutes(5)) + .header("Authorization", "Bearer " + AuthManager.getAccessToken()) + .GET() + .build(); + java.net.http.HttpResponse 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")); + } catch (Exception e) { + sendJson(exchange, Map.of("success", false, "error", e.getMessage())); + } + } + + // ====================== ADMIN ====================== + + private void handleAdmin(HttpExchange exchange) { + try { + String path = exchange.getRequestURI().getPath(); + String method = exchange.getRequestMethod(); + byte[] reqBodyBytes = exchange.getRequestBody().readAllBytes(); + String body = reqBodyBytes.length > 0 ? new String(reqBodyBytes, StandardCharsets.UTF_8) : null; + + if (!api.isLoggedIn()) { + sendJson(exchange, Map.of("success", false, "error", "Not authenticated")); + return; + } + + String remotePath = path.replace("/api/admin", "/admin"); + + if ("POST".equals(method) && remotePath.equals("/admin/whitelist/mods") && body != null) { + handleAdminModUpload(exchange, body); + return; + } + + String apiMethod = method; + if ("DELETE".equals(method)) { + apiMethod = "POST"; + remotePath = remotePath + "/delete"; + } + + String response = remoteApiCall(apiMethod, remotePath, body); + sendRawJson(exchange, response); + } catch (Exception e) { + sendJson(exchange, Map.of("success", false, "error", e.getMessage())); + } + } + + private void handleAdminModUpload(HttpExchange exchange, String jsonBody) throws Exception { + @SuppressWarnings("unchecked") + Map data = gson.fromJson(jsonBody, Map.class); + String filename = data.get("filename"); + String base64Data = data.get("data"); + if (filename == null || base64Data == null) { + sendJson(exchange, Map.of("success", false, "error", "Missing filename or data")); + return; + } + + byte[] fileBytes = Base64.getDecoder().decode(base64Data); + + String boundary = "Boundary-" + System.currentTimeMillis(); + URL url = new URL(LAUNCHER_SERVER + "/admin/whitelist/mods"); + HttpURLConnection conn = (HttpURLConnection) url.openConnection(); + conn.setRequestMethod("POST"); + conn.setDoOutput(true); + conn.setRequestProperty("Content-Type", "multipart/form-data; boundary=" + boundary); + String token = AuthManager.getAccessToken(); + if (token != null && !token.isEmpty() && !token.equals("0")) { + conn.setRequestProperty("Authorization", "Bearer " + token); + } + conn.setConnectTimeout(30000); + conn.setReadTimeout(60000); + + String header = "--" + boundary + "\r\n" + + "Content-Disposition: form-data; name=\"file\"; filename=\"" + filename + "\"\r\n" + + "Content-Type: application/java-archive\r\n\r\n"; + String footer = "\r\n--" + boundary + "--\r\n"; + + try (OutputStream os = conn.getOutputStream()) { + os.write(header.getBytes(StandardCharsets.UTF_8)); + os.write(fileBytes); + os.write(footer.getBytes(StandardCharsets.UTF_8)); + } + + int code = conn.getResponseCode(); + String respBody; + try (InputStream is = code >= 400 ? conn.getErrorStream() : conn.getInputStream()) { + respBody = new String(is.readAllBytes(), StandardCharsets.UTF_8); + } + + if (code >= 400) { + sendJson(exchange, Map.of("success", false, "error", "Upload failed: HTTP " + code + " - " + respBody)); + } else { + sendRawJson(exchange, respBody); + } + } + + private void log(String msg) { + String timestamp = java.time.LocalTime.now().format(java.time.format.DateTimeFormatter.ofPattern("HH:mm:ss")); + String entry = "[" + timestamp + "] " + msg; + synchronized (launcherLogBuffer) { + launcherLogBuffer.append(entry).append("\n"); + } + LauncherLogger.info("[JCEF] " + msg); + for (LogConsumer consumer : logConsumers) { + try { consumer.onLog(entry); } catch (Exception ignored) {} + } + } +} diff --git a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/ui/jfx/JFXLauncher.java b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/ui/jfx/JFXLauncher.java index f71a9d8..0c40c67 100644 --- a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/ui/jfx/JFXLauncher.java +++ b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/ui/jfx/JFXLauncher.java @@ -504,6 +504,7 @@ public class JFXLauncher extends Application { server.createContext("/api/playtime/stats", this::handlePlaytimeStats); server.createContext("/api/whitelist/mods", this::handleWhitelistMods); server.createContext("/api/whitelist/mods/install", this::handleWhitelistInstallMod); + server.createContext("/api/token", this::handleToken); server.createContext("/api/admin", this::handleAdmin); server.createContext("/assets/", this::handleStatic); @@ -611,6 +612,23 @@ public class JFXLauncher extends Application { } } + private void handleToken(HttpExchange exchange) { + try { + if (!api.isLoggedIn()) { + sendJson(exchange, Map.of("success", false, "error", "Not authenticated")); + return; + } + String token = AuthManager.getAccessToken(); + if (token == null || token.isEmpty() || "0".equals(token)) { + sendJson(exchange, Map.of("success", false, "error", "No token")); + return; + } + sendJson(exchange, Map.of("success", true, "token", token)); + } catch (Exception e) { + sendJson(exchange, Map.of("success", false, "error", e.getMessage())); + } + } + private void handleAutoLogin(HttpExchange exchange) { try { boolean loggedIn = AuthManager.isLoggedIn(); diff --git a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/utils/LogBridge.java b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/utils/LogBridge.java new file mode 100644 index 0000000..a87eb4a --- /dev/null +++ b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/utils/LogBridge.java @@ -0,0 +1,96 @@ +package me.sashegdev.zernmc.launcher.utils; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.concurrent.CopyOnWriteArrayList; + +public class LogBridge { + + private static final StringBuilder launcherLogBuffer = new StringBuilder(); + private static final StringBuilder gameLogBuffer = new StringBuilder(); + private static Path gameLogFile; + + private static final CopyOnWriteArrayList logConsumers = new CopyOnWriteArrayList<>(); + private static final CopyOnWriteArrayList gameLogConsumers = new CopyOnWriteArrayList<>(); + + public interface LogConsumer { + void onLog(String line); + } + + public static void addLogConsumer(LogConsumer consumer) { + logConsumers.add(consumer); + } + + public static void removeLogConsumer(LogConsumer consumer) { + logConsumers.remove(consumer); + } + + public static void addGameLogConsumer(LogConsumer consumer) { + gameLogConsumers.add(consumer); + } + + public static void removeGameLogConsumer(LogConsumer consumer) { + gameLogConsumers.remove(consumer); + } + + public static void appendLauncherLog(String msg) { + synchronized (launcherLogBuffer) { + launcherLogBuffer.append(msg).append("\n"); + } + LauncherLogger.info("[EXT] " + msg); + for (LogConsumer consumer : logConsumers) { + try { consumer.onLog(msg); } catch (Exception ignored) {} + } + } + + public static void appendGameLog(String msg) { + synchronized (gameLogBuffer) { + gameLogBuffer.append(msg).append("\n"); + if (gameLogFile != null) { + try { + String timestamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss")); + Files.writeString(gameLogFile, "[" + timestamp + "] " + msg + "\n", + StandardOpenOption.CREATE, StandardOpenOption.APPEND); + } catch (Exception ignored) {} + } + } + LauncherLogger.info("[GAME] " + msg); + for (LogConsumer consumer : gameLogConsumers) { + try { consumer.onLog(msg); } catch (Exception ignored) {} + } + } + + public static void initGameLog(Path instanceDir) { + synchronized (gameLogBuffer) { + gameLogBuffer.setLength(0); + } + try { + Path logsDir = instanceDir.resolve("logs"); + Files.createDirectories(logsDir); + gameLogFile = logsDir.resolve("game.log"); + Files.writeString(gameLogFile, "=== Game Log " + LocalDateTime.now() + " ===\n", + StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING); + } catch (Exception ignored) {} + } + + public static void clearGameLog() { + synchronized (gameLogBuffer) { + gameLogBuffer.setLength(0); + } + } + + public static String getGameLogs() { + synchronized (gameLogBuffer) { + return gameLogBuffer.toString(); + } + } + + public static String getLauncherLogs() { + synchronized (launcherLogBuffer) { + return launcherLogBuffer.toString(); + } + } +} diff --git a/launcher/launcher/src/resources/ui/index.html b/launcher/launcher/src/resources/ui/index.html index ad2d98b..d6565da 100644 --- a/launcher/launcher/src/resources/ui/index.html +++ b/launcher/launcher/src/resources/ui/index.html @@ -32,11 +32,15 @@ @@ -59,7 +65,7 @@