feat: robust update with .update suffix + bootstrap self-update
This commit is contained in:
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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 сейчас оверхед — но запомнить на будущее
|
||||||
Reference in New Issue
Block a user