From ce12854e1b9a4f206274ae1eaf9b5a67cd44bb35 Mon Sep 17 00:00:00 2001 From: SashegDev Date: Thu, 7 May 2026 18:41:35 +0000 Subject: [PATCH] Bootstrap: Add incremental update support via meta system - Get server version from /launcher/meta (new method) - Scan local files and calculate SHA256 hashes - POST to /launcher/diff to get what files need update - Download only changed files via /launcher/file/{version}/{path} - Delete obsolete files - Fallback to ZIP/JAR if meta system fails - Works with legacy method as backup --- .../sashegdev/zernmc/launcher/Bootstrap.java | 361 +++++++++++++++++- 1 file changed, 353 insertions(+), 8 deletions(-) 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 123aabe..404ab90 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 @@ -4,16 +4,16 @@ import java.io.*; import java.net.HttpURLConnection; import java.net.URL; import java.nio.file.*; +import java.security.MessageDigest; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.List; +import java.util.*; 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 = "http://87.120.187.36:1582"; + private static final int BUFFER_SIZE = 8192; private static Path baseDir; private static Path logDir; @@ -71,7 +71,33 @@ public class Bootstrap { private static String getServerVersion() { try { - URL url = new URL(BASE_URL.replace("download?type=jar", "version")); + // Пробуем получить версию из мета-системы + URL url = new URL(BASE_URL + "/launcher/meta"); + HttpURLConnection conn = (HttpURLConnection) url.openConnection(); + conn.setRequestMethod("GET"); + if (conn.getResponseCode() == 200) { + StringBuilder sb = new StringBuilder(); + try (BufferedReader br = new BufferedReader(new InputStreamReader(conn.getInputStream()))) { + String line; + while ((line = br.readLine()) != null) sb.append(line); + } + + String json = sb.toString(); + // Ищем первую версию в мета + int vIndex = json.indexOf("\"version\""); + if (vIndex != -1) { + int start = json.indexOf("\"", vIndex + 9) + 1; + int end = json.indexOf("\"", start); + if (start > 0 && end > start) { + return json.substring(start, end); + } + } + } + } catch (Exception ignored) {} + + // Fallback на старый метод + try { + URL url = new URL(BASE_URL + "/launcher/version"); HttpURLConnection conn = (HttpURLConnection) url.openConnection(); conn.setRequestMethod("GET"); if (conn.getResponseCode() == 200) { @@ -102,6 +128,326 @@ public class Bootstrap { } private static void downloadUpdate(String newVersion) throws Exception { + // Пытаемся использовать инкрементное обновление через мета-систему + try { + // 1. Получаем мета с сервера + Map meta = getLauncherMeta(); + @SuppressWarnings("unchecked") + List> files = (List>) meta.get("files"); + + if (files == null || files.isEmpty()) { + // Мета пустая - fallback на старый метод + log("Мета недоступна, используем старый метод"); + downloadUpdateLegacy(newVersion); + return; + } + + log("Мета получена: " + files.size() + " файлов"); + + // 2. Сканируем локальные файлы + Map localFiles = scanLocalFiles(); + log("Локально: " + localFiles.size() + " файлов"); + + // 3. Получаем diff + Map diff = getLauncherDiff(localFiles); + @SuppressWarnings("unchecked") + List> toDownload = (List>) diff.get("to_download"); + @SuppressWarnings("unchecked") + List toDelete = (List) diff.get("to_delete"); + + log("К скачиванию: " + toDownload.size() + ", к удалению: " + toDelete.size()); + + // 4. Удаляем лишние файлы + for (String filePath : toDelete) { + Path f = baseDir.resolve(filePath); + if (Files.exists(f)) { + Files.delete(f); + log("Удален: " + filePath); + } + } + + // 5. Скачиваем новые/измененные файлы + String serverVersion = (String) diff.get("version"); + for (Map file : toDownload) { + String path = (String) file.get("path"); + downloadLauncherFile(serverVersion, path); + log("Скачан: " + path); + } + + // 6. Записываем новую версию + Files.writeString(baseDir.resolve(VERSION_FILE), serverVersion); + log("Обновлено до v" + serverVersion); + + } catch (Exception e) { + log("Ошибка инкрементного обновления: " + e.getMessage()); + log("Fallback на старый метод..."); + // Fallback на старый метод + downloadUpdateLegacy(newVersion); + } + } + + private static Map getLauncherMeta() throws Exception { + URL url = new URL(BASE_URL + "/launcher/meta"); + HttpURLConnection conn = (HttpURLConnection) url.openConnection(); + conn.setRequestMethod("GET"); + + if (conn.getResponseCode() != 200) { + throw new IOException("Не удалось получить мета"); + } + + StringBuilder sb = new StringBuilder(); + try (BufferedReader br = new BufferedReader(new InputStreamReader(conn.getInputStream()))) { + String line; + while ((line = br.readLine()) != null) sb.append(line); + } + + // Парсим JSON вручную + String json = sb.toString(); + int versionsStart = json.indexOf("\"versions\""); + if (versionsStart == -1) throw new IOException("Нет versions в ответе"); + + int arrayStart = json.indexOf("[", versionsStart); + int arrayEnd = json.indexOf("]", arrayStart); + String versionsArray = json.substring(arrayStart + 1, arrayEnd); + + // Ищем первую версию + int metaStart = versionsArray.indexOf("\"meta\""); + if (metaStart == -1) throw new IOException("Нет meta в версии"); + + int objStart = versionsArray.indexOf("{", metaStart); + int objEnd = versionsArray.lastIndexOf("}"); + + String metaJson = versionsArray.substring(objStart, objEnd + 1); + return parseMetaJson(metaJson); + } + + @SuppressWarnings("unchecked") + private static Map parseMetaJson(String json) { + Map result = new HashMap<>(); + List> files = new ArrayList<>(); + + // Простой парсинг + String[] lines = json.split(","); + for (String line : lines) { + line = line.trim(); + if (line.contains("\"version\"")) { + String v = line.split(":")[1].replaceAll("[\"\\s]", ""); + result.put("version", v); + } else if (line.contains("\"files\"")) { + // Файлы парсим отдельно + } else if (line.contains("\"path\"") && line.contains("\"size\"") && line.contains("\"hash\"")) { + Map file = new HashMap<>(); + String[] parts = line.split(":"); + for (String part : parts) { + if (part.contains("path")) file.put("path", extractJsonValue(part)); + else if (part.contains("size")) file.put("size", Long.parseLong(extractJsonValue(part))); + else if (part.contains("hash")) file.put("hash", extractJsonValue(part)); + } + if (!file.isEmpty()) files.add(file); + } + } + result.put("files", files); + return result; + } + + private static String extractJsonValue(String part) { + int idx = part.indexOf(":"); + if (idx == -1) return ""; + String val = part.substring(idx + 1).trim(); + if (val.startsWith("\"")) val = val.substring(1); + if (val.endsWith("\"")) val = val.substring(0, val.length() - 1); + return val; + } + + private static Map scanLocalFiles() throws Exception { + Map hashes = new HashMap<>(); + + // Сканируем основную директорию + try (DirectoryStream stream = Files.newDirectoryStream(baseDir)) { + for (Path p : stream) { + if (p.getFileName().toString().equals("build.version")) continue; + if (p.getFileName().toString().equals("logs")) continue; + if (p.getFileName().toString().equals("data")) continue; + + if (Files.isRegularFile(p)) { + hashes.put(p.getFileName().toString(), calculateSHA256(p)); + } else if (Files.isDirectory(p)) { + scanDirectory(p, p.getFileName().toString(), hashes); + } + } + } + return hashes; + } + + private static void scanDirectory(Path dir, String basePath, Map hashes) throws Exception { + try (DirectoryStream stream = Files.newDirectoryStream(dir)) { + for (Path p : stream) { + String relPath = basePath + "/" + p.getFileName().toString(); + if (Files.isRegularFile(p)) { + hashes.put(relPath, calculateSHA256(p)); + } else if (Files.isDirectory(p)) { + scanDirectory(p, relPath, hashes); + } + } + } + } + + private static String calculateSHA256(Path file) throws Exception { + MessageDigest md = MessageDigest.getInstance("SHA-256"); + try (InputStream is = Files.newInputStream(file)) { + byte[] buf = new byte[BUFFER_SIZE]; + int len; + while ((len = is.read(buf)) != -1) { + md.update(buf, 0, len); + } + } + StringBuilder sb = new StringBuilder(); + for (byte b : md.digest()) sb.append(String.format("%02x", b)); + return "sha256:" + sb.toString(); + } + + private static Map getLauncherDiff(Map localFiles) throws Exception { + // Создаем JSON {filename: hash, ...} + StringBuilder json = new StringBuilder("{"); + int i = 0; + for (Map.Entry e : localFiles.entrySet()) { + if (i > 0) json.append(","); + json.append("\"").append(e.getKey()).append("\":\"").append(e.getValue()).append("\""); + i++; + } + json.append("}"); + + URL url = new URL(BASE_URL + "/launcher/diff"); + HttpURLConnection conn = (HttpURLConnection) url.openConnection(); + conn.setRequestMethod("POST"); + conn.setDoOutput(true); + conn.setRequestProperty("Content-Type", "application/json"); + + try (OutputStream os = conn.getOutputStream()) { + os.write(json.toString().getBytes("UTF-8")); + } + + if (conn.getResponseCode() != 200) { + throw new IOException("Diff запрос вернул: " + conn.getResponseCode()); + } + + StringBuilder sb = new StringBuilder(); + try (BufferedReader br = new BufferedReader(new InputStreamReader(conn.getInputStream()))) { + String line; + while ((line = br.readLine()) != null) sb.append(line); + } + + return parseDiffJson(sb.toString()); + } + + @SuppressWarnings("unchecked") + private static Map parseDiffJson(String json) { + Map result = new HashMap<>(); + List> toDownload = new ArrayList<>(); + List toDelete = new ArrayList<>(); + + String[] parts = json.split(","); + for (String part : parts) { + part = part.trim(); + if (part.contains("\"version\"")) { + result.put("version", extractJsonValue(part)); + } else if (part.contains("\"to_download\"")) { + // Парсим файлы + } else if (part.contains("\"to_delete\"")) { + // Парсим удаляемые + } else if (part.contains("\"path\"") && part.contains("\"size\"")) { + Map file = new HashMap<>(); + if (part.contains("\"hash\"")) file.put("hash", "need"); + file.put("path", extractJsonValue(part)); + String sizePart = part.split("size")[1].split(",")[0]; + file.put("size", Long.parseLong(extractJsonValue(sizePart.split(":")[1]))); + toDownload.add(file); + } else if (part.startsWith("\"")) { + toDelete.add(extractJsonValue(part)); + } + } + + result.put("to_download", toDownload); + result.put("to_delete", toDelete); + return result; + } + + private static void downloadLauncherFile(String version, String filePath) throws Exception { + String encodedPath = filePath.replace(" ", "%20"); + URL url = new URL(BASE_URL + "/launcher/file/" + version + "/" + encodedPath); + HttpURLConnection conn = (HttpURLConnection) url.openConnection(); + conn.setRequestMethod("GET"); + + if (conn.getResponseCode() != 200) { + throw new IOException("Не удалось скачать файл: " + filePath); + } + + Path targetPath = baseDir.resolve(filePath); + Files.createDirectories(targetPath.getParent()); + + try (InputStream in = conn.getInputStream(); + OutputStream out = new FileOutputStream(targetPath.toFile())) { + byte[] buf = new byte[BUFFER_SIZE]; + int len; + long total = 0; + while ((len = in.read(buf)) > 0) { + out.write(buf, 0, len); + total += len; + } + } + } + + // Fallback на старый метод (скачивание ZIP) + private static void downloadUpdateLegacy(String newVersion) throws Exception { + // Пробуем скачать ZIP + String zipUrl = BASE_URL + "/launcher/download/zip/ZernMC-win-" + newVersion + ".zip"; + URL url = new URL(zipUrl); + HttpURLConnection conn = (HttpURLConnection) url.openConnection(); + conn.setRequestMethod("GET"); + + if (conn.getResponseCode() == 200) { + Path tempZip = baseDir.resolve("update.zip"); + + try (InputStream in = conn.getInputStream(); + OutputStream out = new FileOutputStream(tempZip.toFile())) { + byte[] buf = new byte[BUFFER_SIZE]; + int len; + long total = 0; + while ((len = in.read(buf)) > 0) { + out.write(buf, 0, len); + total += len; + System.out.print("\rСкачано: " + (total/1024/1024) + " MB"); + } + } + log("ZIP скачан, распаковка..."); + + // Распаковываем ZIP + java.util.zip.ZipFile zip = new java.util.zip.ZipFile(tempZip.toFile()); + java.util.Enumeration entries = zip.entries(); + while (entries.hasMoreElements()) { + java.util.zip.ZipEntry entry = entries.nextElement(); + Path outPath = baseDir.resolve(entry.getName()); + if (entry.isDirectory()) { + Files.createDirectories(outPath); + } else { + Files.createDirectories(outPath.getParent()); + try (InputStream is = zip.getInputStream(entry)) { + Files.copy(is, outPath, StandardCopyOption.REPLACE_EXISTING); + } + } + } + zip.close(); + Files.delete(tempZip); + + Files.writeString(baseDir.resolve(VERSION_FILE), newVersion); + log("Обновлено до v" + newVersion + " (ZIP метод)"); + } else { + // Последний fallback - JAR + downloadUpdateJar(newVersion); + } + } + + private static void downloadUpdateJar(String newVersion) throws Exception { URL url = new URL(BASE_URL + "/launcher/download/jar"); HttpURLConnection conn = (HttpURLConnection) url.openConnection(); conn.setRequestMethod("GET"); @@ -112,7 +458,7 @@ public class Bootstrap { try (InputStream in = conn.getInputStream(); OutputStream out = new FileOutputStream(tmp.toFile())) { - byte[] buf = new byte[8192]; + byte[] buf = new byte[BUFFER_SIZE]; int len; long total = 0; while ((len = in.read(buf)) > 0) { @@ -121,16 +467,15 @@ public class Bootstrap { System.out.print("\rСкачано: " + (total/1024/1024) + " MB"); } } - log("Скачано"); + log("JAR скачан"); 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("Обновлено до v" + newVersion); + log("Обновлено до v" + newVersion + " (JAR метод)"); } else { throw new IOException("Сервер вернул код: " + conn.getResponseCode()); }