feat: robust update with .update suffix + bootstrap self-update

This commit is contained in:
SashegDev
2026-07-30 11:28:30 +00:00
parent 348969e79c
commit 375b98586d
3 changed files with 167 additions and 77 deletions
@@ -10,55 +10,162 @@ import java.time.format.DateTimeFormatter;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Arrays; import java.util.Arrays;
import java.util.List; import java.util.List;
import java.util.stream.Stream;
public class Bootstrap { public class Bootstrap {
private static final String VERSION_FILE = "build.version"; private static final String VERSION_FILE = "build.version";
private static final String JAR_NAME = "ZernMCLauncher.jar"; private static final String JAR_NAME = "ZernMCLauncher.jar";
private static final String BASE_URL = "https://api.zernmc.ru"; private static final String BASE_URL = "https://api.zernmc.ru";
private static Path baseDir; private static Path baseDir;
private static Path logDir; private static Path logDir;
public static void main(String[] args) throws Exception { public static void main(String[] args) throws Exception {
baseDir = Paths.get("").toAbsolutePath(); baseDir = Paths.get("").toAbsolutePath();
logDir = baseDir.resolve("logs"); logDir = baseDir.resolve("logs");
Files.createDirectories(logDir); Files.createDirectories(logDir);
log("=== ZernMC Launcher ==="); log("=== ZernMC Launcher ===");
List<String> argList = Arrays.asList(args); List<String> argList = Arrays.asList(args);
boolean cliMode = argList.contains("--cli"); boolean cliMode = argList.contains("--cli");
boolean jfxMode = !cliMode; boolean jfxMode = !cliMode;
cleanupStaleUpdates();
String currentVersion = readCurrentVersion(); String currentVersion = readCurrentVersion();
String serverVersion = getServerVersion(); String serverVersion = getServerVersion();
log("Local version: " + currentVersion); log("Local version: " + currentVersion);
log("Server version: " + serverVersion); log("Server version: " + serverVersion);
if (isNewer(serverVersion, currentVersion)) { if (isNewer(serverVersion, currentVersion)) {
log("Update available!"); 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 { } else {
log("Version is up to date"); log("Version is up to date");
} }
if (jfxMode) { if (jfxMode) {
launchJFX(); launchJFX();
} else { } else {
launchCLI(); launchCLI();
} }
} }
private static void log(String msg) { private static void cleanupStaleUpdates() {
String entry = "[" + LocalDateTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss")) + "] " + msg; try (Stream<Path> files = Files.list(baseDir)) {
System.out.println(entry); files.filter(p -> p.toString().endsWith(".update"))
try { .forEach(p -> {
Files.writeString(logDir.resolve("launcher.log"), entry + "\n", try {
StandardOpenOption.CREATE, StandardOpenOption.APPEND); Files.deleteIfExists(p);
log("Cleaned stale: " + p.getFileName());
} catch (Exception ignored) {}
});
} 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() { private static String readCurrentVersion() {
Path f = baseDir.resolve(VERSION_FILE); Path f = baseDir.resolve(VERSION_FILE);
try { try {
@@ -66,7 +173,7 @@ public class Bootstrap {
} catch (Exception ignored) {} } catch (Exception ignored) {}
return "0.0.0"; return "0.0.0";
} }
private static String getServerVersion() { private static String getServerVersion() {
try { try {
URL url = new URL(BASE_URL + "/launcher/version"); URL url = new URL(BASE_URL + "/launcher/version");
@@ -83,7 +190,7 @@ public class Bootstrap {
} catch (Exception ignored) {} } catch (Exception ignored) {}
return "unknown"; return "unknown";
} }
private static boolean isNewer(String server, String current) { private static boolean isNewer(String server, String current) {
try { try {
String[] sa = server.split("\\."); String[] sa = server.split("\\.");
@@ -99,50 +206,24 @@ public class Bootstrap {
} catch (Exception ignored) {} } catch (Exception ignored) {}
return false; return false;
} }
private static void downloadUpdate(String newVersion) throws Exception { private static void log(String msg) {
URL url = new URL(BASE_URL + "/launcher/download/jar"); String entry = "[" + LocalDateTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss")) + "] " + msg;
HttpURLConnection conn = (HttpURLConnection) url.openConnection(); System.out.println(entry);
conn.setRequestMethod("GET"); try {
Files.writeString(logDir.resolve("launcher.log"), entry + "\n",
if (conn.getResponseCode() == 200) { StandardOpenOption.CREATE, StandardOpenOption.APPEND);
Path jarFile = baseDir.resolve(JAR_NAME); } catch (Exception ignored) {}
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 launchJFX() throws Exception { private static void launchJFX() throws Exception {
Path javaBin = findJava(); Path javaBin = findJava();
Path jarPath = baseDir.resolve(JAR_NAME); Path jarPath = baseDir.resolve(JAR_NAME);
log("Starting JFX mode..."); log("Starting JFX mode...");
log("Java: " + javaBin); log("Java: " + javaBin);
log("JAR: " + jarPath); log("JAR: " + jarPath);
List<String> cmd = new ArrayList<>(); List<String> cmd = new ArrayList<>();
cmd.add(javaBin.toAbsolutePath().toString()); cmd.add(javaBin.toAbsolutePath().toString());
cmd.add("-Dfile.encoding=UTF-8"); cmd.add("-Dfile.encoding=UTF-8");
@@ -151,17 +232,17 @@ public class Bootstrap {
cmd.add("-jar"); cmd.add("-jar");
cmd.add(jarPath.toAbsolutePath().toString()); cmd.add(jarPath.toAbsolutePath().toString());
cmd.add("--jfx"); cmd.add("--jfx");
ProcessBuilder pb = new ProcessBuilder(cmd); ProcessBuilder pb = new ProcessBuilder(cmd);
pb.directory(baseDir.toFile()); pb.directory(baseDir.toFile());
if (System.getProperty("os.name").toLowerCase().contains("windows")) { if (System.getProperty("os.name").toLowerCase().contains("windows")) {
pb.environment().put("JAVA_TOOL_OPTIONS", "-Dfile.encoding=UTF-8"); pb.environment().put("JAVA_TOOL_OPTIONS", "-Dfile.encoding=UTF-8");
} }
pb.redirectErrorStream(true); pb.redirectErrorStream(true);
Process p = pb.start(); Process p = pb.start();
Thread outputThread = new Thread(() -> { Thread outputThread = new Thread(() -> {
try (BufferedReader reader = new BufferedReader( try (BufferedReader reader = new BufferedReader(
new InputStreamReader(p.getInputStream(), StandardCharsets.UTF_8))) { new InputStreamReader(p.getInputStream(), StandardCharsets.UTF_8))) {
@@ -172,21 +253,21 @@ public class Bootstrap {
} catch (Exception ignored) {} } catch (Exception ignored) {}
}); });
outputThread.start(); outputThread.start();
int code = p.waitFor(); int code = p.waitFor();
try { outputThread.interrupt(); } catch (Exception ignored) {} try { outputThread.interrupt(); } catch (Exception ignored) {}
log("Exited with code: " + code); log("Exited with code: " + code);
System.exit(code); System.exit(code);
} }
private static void launchCLI() throws Exception { private static void launchCLI() throws Exception {
Path javaBin = findJava(); Path javaBin = findJava();
Path jarPath = baseDir.resolve(JAR_NAME); Path jarPath = baseDir.resolve(JAR_NAME);
log("Starting CLI mode..."); log("Starting CLI mode...");
log("Java: " + javaBin); log("Java: " + javaBin);
log("JAR: " + jarPath); log("JAR: " + jarPath);
List<String> cmd = new ArrayList<>(); List<String> cmd = new ArrayList<>();
cmd.add(javaBin.toAbsolutePath().toString()); cmd.add(javaBin.toAbsolutePath().toString());
cmd.add("-Dfile.encoding=UTF-8"); cmd.add("-Dfile.encoding=UTF-8");
@@ -195,17 +276,17 @@ public class Bootstrap {
cmd.add("-jar"); cmd.add("-jar");
cmd.add(jarPath.toAbsolutePath().toString()); cmd.add(jarPath.toAbsolutePath().toString());
cmd.add("--cli"); cmd.add("--cli");
ProcessBuilder pb = new ProcessBuilder(cmd); ProcessBuilder pb = new ProcessBuilder(cmd);
pb.directory(baseDir.toFile()); pb.directory(baseDir.toFile());
if (System.getProperty("os.name").toLowerCase().contains("windows")) { if (System.getProperty("os.name").toLowerCase().contains("windows")) {
pb.environment().put("JAVA_TOOL_OPTIONS", "-Dfile.encoding=UTF-8"); pb.environment().put("JAVA_TOOL_OPTIONS", "-Dfile.encoding=UTF-8");
} }
pb.redirectErrorStream(true); pb.redirectErrorStream(true);
Process p = pb.start(); Process p = pb.start();
Thread outputThread = new Thread(() -> { Thread outputThread = new Thread(() -> {
try (BufferedReader reader = new BufferedReader( try (BufferedReader reader = new BufferedReader(
new InputStreamReader(p.getInputStream(), StandardCharsets.UTF_8))) { new InputStreamReader(p.getInputStream(), StandardCharsets.UTF_8))) {
@@ -216,23 +297,23 @@ public class Bootstrap {
} catch (Exception ignored) {} } catch (Exception ignored) {}
}); });
outputThread.start(); outputThread.start();
int code = p.waitFor(); int code = p.waitFor();
try { outputThread.interrupt(); } catch (Exception ignored) {} try { outputThread.interrupt(); } catch (Exception ignored) {}
log("Exited with code: " + code); log("Exited with code: " + code);
System.exit(code); System.exit(code);
} }
private static Path findJava() { private static Path findJava() {
String os = System.getProperty("os.name").toLowerCase(); String os = System.getProperty("os.name").toLowerCase();
String javaExe = os.contains("windows") ? "java.exe" : "java"; String javaExe = os.contains("windows") ? "java.exe" : "java";
Path javaBin = baseDir.resolve("jre21").resolve("bin").resolve(javaExe); Path javaBin = baseDir.resolve("jre21").resolve("bin").resolve(javaExe);
if (!Files.exists(javaBin)) { if (!Files.exists(javaBin)) {
javaBin = Paths.get(System.getProperty("java.home"), "bin", javaExe); javaBin = Paths.get(System.getProperty("java.home"), "bin", javaExe);
} }
if (!Files.exists(javaBin)) { if (!Files.exists(javaBin)) {
try { try {
Process p = new ProcessBuilder("which", javaExe).start(); Process p = new ProcessBuilder("which", javaExe).start();
@@ -246,11 +327,11 @@ public class Bootstrap {
} }
} catch (Exception ignored) {} } catch (Exception ignored) {}
} }
if (!Files.exists(javaBin)) { 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"); throw new RuntimeException("Java not found. Make sure jre21 is present in the launcher folder or Java is installed on the system");
} }
return javaBin; return javaBin;
} }
} }
+2 -2
View File
@@ -18,8 +18,8 @@
</modules> </modules>
<properties> <properties>
<revision>1.0.12</revision> <revision>1.0.13</revision>
<hotfix>1</hotfix> <hotfix>0</hotfix>
<maven.compiler.source>21</maven.compiler.source> <maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target> <maven.compiler.target>21</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
+9
View File
@@ -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 сейчас оверхед — но запомнить на будущее