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 60640b0..2ca0f04 100644 --- a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/Bootstrap.java +++ b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/Bootstrap.java @@ -10,55 +10,162 @@ import java.time.format.DateTimeFormatter; import java.util.ArrayList; import java.util.Arrays; import java.util.List; +import java.util.stream.Stream; public class Bootstrap { private static final String VERSION_FILE = "build.version"; private static final String JAR_NAME = "ZernMCLauncher.jar"; private static final String BASE_URL = "https://api.zernmc.ru"; - + private static Path baseDir; private static Path logDir; - + public static void main(String[] args) throws Exception { baseDir = Paths.get("").toAbsolutePath(); logDir = baseDir.resolve("logs"); Files.createDirectories(logDir); - + log("=== ZernMC Launcher ==="); - + List argList = Arrays.asList(args); boolean cliMode = argList.contains("--cli"); boolean jfxMode = !cliMode; - + + cleanupStaleUpdates(); + String currentVersion = readCurrentVersion(); String serverVersion = getServerVersion(); - + log("Local version: " + currentVersion); log("Server version: " + serverVersion); - + if (isNewer(serverVersion, currentVersion)) { log("Update available!"); - downloadUpdate(serverVersion); + updateJar(serverVersion); + + Path ownExe = getOwnExe(); + if (ownExe != null) { + log("Self-updating bootstrap..."); + selfUpdate(ownExe); + return; + } + + Files.writeString(baseDir.resolve(VERSION_FILE), serverVersion); + log("Updated to v" + serverVersion); } else { log("Version is up to date"); } - + if (jfxMode) { launchJFX(); } else { launchCLI(); } } - - private static void log(String msg) { - String entry = "[" + LocalDateTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss")) + "] " + msg; - System.out.println(entry); - try { - Files.writeString(logDir.resolve("launcher.log"), entry + "\n", - StandardOpenOption.CREATE, StandardOpenOption.APPEND); + + private static void cleanupStaleUpdates() { + try (Stream files = Files.list(baseDir)) { + files.filter(p -> p.toString().endsWith(".update")) + .forEach(p -> { + try { + Files.deleteIfExists(p); + log("Cleaned stale: " + p.getFileName()); + } catch (Exception ignored) {} + }); } catch (Exception ignored) {} } - + + private static void updateJar(String newVersion) throws Exception { + Path jarFile = baseDir.resolve(JAR_NAME); + Path jarUpdate = baseDir.resolve(JAR_NAME + ".update"); + + if (Files.exists(jarFile)) { + try { + Files.move(jarFile, jarUpdate, StandardCopyOption.REPLACE_EXISTING); + log("Renamed " + JAR_NAME + " → " + JAR_NAME + ".update"); + } catch (Exception e) { + log("Could not rename " + JAR_NAME + ": " + e.getMessage()); + } + } + + downloadFile(BASE_URL + "/launcher/download/jar", jarFile); + log("Downloaded new " + JAR_NAME); + + try { + Files.deleteIfExists(jarUpdate); + log("Removed " + JAR_NAME + ".update"); + } catch (Exception e) { + log("Could not remove " + JAR_NAME + ".update: " + e.getMessage()); + } + + Files.writeString(baseDir.resolve(VERSION_FILE), newVersion); + } + + private static void selfUpdate(Path exePath) throws Exception { + String exeName = exePath.getFileName().toString(); + Path exeUpdate = baseDir.resolve(exeName + ".update"); + + try { + Files.move(exePath, exeUpdate, StandardCopyOption.REPLACE_EXISTING); + log("Renamed " + exeName + " → " + exeName + ".update"); + } catch (Exception e) { + log("Could not rename " + exeName + ": " + e.getMessage()); + return; + } + + downloadFile(BASE_URL + "/launcher/download/exe", exePath); + + log("Launching new " + exeName + "..."); + new ProcessBuilder(exePath.toAbsolutePath().toString()) + .directory(baseDir.toFile()) + .inheritIO() + .start(); + + Thread.sleep(500); + log("Exiting old process"); + System.exit(0); + } + + private static Path getOwnExe() { + try { + String cmd = ProcessHandle.current().info().command().orElse(null); + if (cmd != null) { + Path p = Paths.get(cmd); + String name = p.getFileName().toString().toLowerCase(); + if (name.contains("zernmc")) return p; + } + } catch (Exception ignored) {} + + for (String name : Arrays.asList("zernmc.exe", "zernmc-cli.exe")) { + Path p = baseDir.resolve(name); + if (Files.exists(p)) return p; + } + return null; + } + + private static void downloadFile(String urlStr, Path target) throws Exception { + URL url = new URL(urlStr); + HttpURLConnection conn = (HttpURLConnection) url.openConnection(); + conn.setRequestMethod("GET"); + + if (conn.getResponseCode() == 200) { + try (InputStream in = conn.getInputStream(); + OutputStream out = new FileOutputStream(target.toFile())) { + byte[] buf = new byte[8192]; + int len; + long total = 0; + while ((len = in.read(buf)) > 0) { + out.write(buf, 0, len); + total += len; + System.out.print("\rDownloaded: " + (total / 1024 / 1024) + " MB"); + } + } + System.out.println(); + } else { + throw new IOException("Server returned code: " + conn.getResponseCode()); + } + } + private static String readCurrentVersion() { Path f = baseDir.resolve(VERSION_FILE); try { @@ -66,7 +173,7 @@ public class Bootstrap { } catch (Exception ignored) {} return "0.0.0"; } - + private static String getServerVersion() { try { URL url = new URL(BASE_URL + "/launcher/version"); @@ -83,7 +190,7 @@ public class Bootstrap { } catch (Exception ignored) {} return "unknown"; } - + private static boolean isNewer(String server, String current) { try { String[] sa = server.split("\\."); @@ -99,50 +206,24 @@ public class Bootstrap { } catch (Exception ignored) {} return false; } - - private static void downloadUpdate(String newVersion) throws Exception { - URL url = new URL(BASE_URL + "/launcher/download/jar"); - HttpURLConnection conn = (HttpURLConnection) url.openConnection(); - conn.setRequestMethod("GET"); - - if (conn.getResponseCode() == 200) { - Path jarFile = baseDir.resolve(JAR_NAME); - Path tmp = jarFile.resolveSibling("zernmc-launcher-new.jar"); - - try (InputStream in = conn.getInputStream(); - OutputStream out = new FileOutputStream(tmp.toFile())) { - byte[] buf = new byte[8192]; - int len; - long total = 0; - while ((len = in.read(buf)) > 0) { - out.write(buf, 0, len); - total += len; - System.out.print("\rDownloaded: " + (total/1024/1024) + " MB"); - } - } - log("Downloaded"); - - Path backup = jarFile.resolveSibling(JAR_NAME + ".old"); - - if (Files.exists(jarFile)) Files.move(jarFile, backup, StandardCopyOption.REPLACE_EXISTING); - Files.move(tmp, jarFile, StandardCopyOption.REPLACE_EXISTING); - if (Files.exists(backup)) Files.delete(backup); - - Files.writeString(baseDir.resolve(VERSION_FILE), newVersion); - log("Updated to v" + newVersion); - } else { - throw new IOException("Server returned code: " + conn.getResponseCode()); - } + + private static void log(String msg) { + String entry = "[" + LocalDateTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss")) + "] " + msg; + System.out.println(entry); + try { + Files.writeString(logDir.resolve("launcher.log"), entry + "\n", + StandardOpenOption.CREATE, StandardOpenOption.APPEND); + } catch (Exception ignored) {} } - + private static void launchJFX() throws Exception { Path javaBin = findJava(); Path jarPath = baseDir.resolve(JAR_NAME); - + log("Starting JFX mode..."); log("Java: " + javaBin); log("JAR: " + jarPath); - + List cmd = new ArrayList<>(); cmd.add(javaBin.toAbsolutePath().toString()); cmd.add("-Dfile.encoding=UTF-8"); @@ -151,17 +232,17 @@ public class Bootstrap { cmd.add("-jar"); cmd.add(jarPath.toAbsolutePath().toString()); cmd.add("--jfx"); - + ProcessBuilder pb = new ProcessBuilder(cmd); pb.directory(baseDir.toFile()); - + if (System.getProperty("os.name").toLowerCase().contains("windows")) { pb.environment().put("JAVA_TOOL_OPTIONS", "-Dfile.encoding=UTF-8"); } - + pb.redirectErrorStream(true); Process p = pb.start(); - + Thread outputThread = new Thread(() -> { try (BufferedReader reader = new BufferedReader( new InputStreamReader(p.getInputStream(), StandardCharsets.UTF_8))) { @@ -172,21 +253,21 @@ public class Bootstrap { } catch (Exception ignored) {} }); outputThread.start(); - + int code = p.waitFor(); try { outputThread.interrupt(); } catch (Exception ignored) {} log("Exited with code: " + code); System.exit(code); } - + private static void launchCLI() throws Exception { Path javaBin = findJava(); Path jarPath = baseDir.resolve(JAR_NAME); - + log("Starting CLI mode..."); log("Java: " + javaBin); log("JAR: " + jarPath); - + List cmd = new ArrayList<>(); cmd.add(javaBin.toAbsolutePath().toString()); cmd.add("-Dfile.encoding=UTF-8"); @@ -195,17 +276,17 @@ public class Bootstrap { cmd.add("-jar"); cmd.add(jarPath.toAbsolutePath().toString()); cmd.add("--cli"); - + ProcessBuilder pb = new ProcessBuilder(cmd); pb.directory(baseDir.toFile()); - + if (System.getProperty("os.name").toLowerCase().contains("windows")) { pb.environment().put("JAVA_TOOL_OPTIONS", "-Dfile.encoding=UTF-8"); } - + pb.redirectErrorStream(true); Process p = pb.start(); - + Thread outputThread = new Thread(() -> { try (BufferedReader reader = new BufferedReader( new InputStreamReader(p.getInputStream(), StandardCharsets.UTF_8))) { @@ -216,23 +297,23 @@ public class Bootstrap { } catch (Exception ignored) {} }); outputThread.start(); - + int code = p.waitFor(); try { outputThread.interrupt(); } catch (Exception ignored) {} log("Exited with code: " + code); System.exit(code); } - + private static Path findJava() { String os = System.getProperty("os.name").toLowerCase(); String javaExe = os.contains("windows") ? "java.exe" : "java"; - + Path javaBin = baseDir.resolve("jre21").resolve("bin").resolve(javaExe); - + if (!Files.exists(javaBin)) { javaBin = Paths.get(System.getProperty("java.home"), "bin", javaExe); } - + if (!Files.exists(javaBin)) { try { Process p = new ProcessBuilder("which", javaExe).start(); @@ -246,11 +327,11 @@ public class Bootstrap { } } catch (Exception ignored) {} } - + if (!Files.exists(javaBin)) { throw new RuntimeException("Java not found. Make sure jre21 is present in the launcher folder or Java is installed on the system"); } - + return javaBin; } } diff --git a/launcher/pom.xml b/launcher/pom.xml index 77f4fc7..eb16a28 100644 --- a/launcher/pom.xml +++ b/launcher/pom.xml @@ -18,8 +18,8 @@ - 1.0.12 - 1 + 1.0.13 + 0 21 21 UTF-8 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 сейчас оверхед — но запомнить на будущее