From c03d7a788f2a6b49ff0db67364ea91caafa42700 Mon Sep 17 00:00:00 2001 From: Sashegdev Date: Mon, 6 Apr 2026 19:57:32 +0000 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=9E=D0=91=D0=90=D0=92=D0=9B=D0=95?= =?UTF-8?q?=D0=9D=D0=98=D0=95=20=D0=9F=D0=A0=D0=9E=D0=9A=D0=A1=D0=98=20?= =?UTF-8?q?=D0=A0=D0=95=D0=96=D0=98=D0=9C=D0=90=20=D0=99=D0=9E=D0=9E=D0=9E?= =?UTF-8?q?=D0=A3=201.0.5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- launcher/dependency-reduced-pom.xml | 2 +- launcher/pom.xml | 2 +- .../me/sashegdev/zernmc/launcher/Main.java | 3 + .../zernmc/launcher/minecraft/Instance.java | 2 +- .../minecraft/installer/FabricInstaller.java | 71 +-- .../launch/LaunchCommandBuilder.java | 69 ++- .../zernmc/launcher/utils/ZHttpClient.java | 559 +++++++++++++++--- server/main.py | 350 ++++++++++- 8 files changed, 884 insertions(+), 174 deletions(-) diff --git a/launcher/dependency-reduced-pom.xml b/launcher/dependency-reduced-pom.xml index 93c5bb6..81984aa 100644 --- a/launcher/dependency-reduced-pom.xml +++ b/launcher/dependency-reduced-pom.xml @@ -3,7 +3,7 @@ 4.0.0 me.sashegdev ZernMCLauncher - 1.0.4 + 1.0.5 diff --git a/launcher/pom.xml b/launcher/pom.xml index ac776cd..286d7a6 100644 --- a/launcher/pom.xml +++ b/launcher/pom.xml @@ -6,7 +6,7 @@ 4.0.0 me.sashegdev ZernMCLauncher - 1.0.4 + 1.0.5 jar diff --git a/launcher/src/main/java/me/sashegdev/zernmc/launcher/Main.java b/launcher/src/main/java/me/sashegdev/zernmc/launcher/Main.java index bb894f7..a5bae5c 100644 --- a/launcher/src/main/java/me/sashegdev/zernmc/launcher/Main.java +++ b/launcher/src/main/java/me/sashegdev/zernmc/launcher/Main.java @@ -24,6 +24,9 @@ public class Main { System.out.print("\033[H\033[2J"); System.out.println(ZAnsi.brightGreen("Добро пожаловать в ZernMC Launcher " + CURRENT_VERSION)); + //проверка всех сервисов при старте + ZHttpClient.checkAllServicesOnStartup(); + checkAndAutoUpdateLauncher(); try { diff --git a/launcher/src/main/java/me/sashegdev/zernmc/launcher/minecraft/Instance.java b/launcher/src/main/java/me/sashegdev/zernmc/launcher/minecraft/Instance.java index 9af51f5..86be644 100644 --- a/launcher/src/main/java/me/sashegdev/zernmc/launcher/minecraft/Instance.java +++ b/launcher/src/main/java/me/sashegdev/zernmc/launcher/minecraft/Instance.java @@ -51,7 +51,6 @@ public class Instance { saveMetadata(); } - /** Возвращает ТОТ САМЫЙ assetIndex, который сохранился при установке (например 30) */ public String getAssetIndex() { return assetIndex != null ? assetIndex : minecraftVersion; // fallback для старых сборок } @@ -67,6 +66,7 @@ public class Instance { public void setFabricVersionId(String fabricVersionId) { this.fabricVersionId = fabricVersionId; + saveMetadata(); } diff --git a/launcher/src/main/java/me/sashegdev/zernmc/launcher/minecraft/installer/FabricInstaller.java b/launcher/src/main/java/me/sashegdev/zernmc/launcher/minecraft/installer/FabricInstaller.java index 8f3abda..ebfb896 100644 --- a/launcher/src/main/java/me/sashegdev/zernmc/launcher/minecraft/installer/FabricInstaller.java +++ b/launcher/src/main/java/me/sashegdev/zernmc/launcher/minecraft/installer/FabricInstaller.java @@ -3,6 +3,7 @@ package me.sashegdev.zernmc.launcher.minecraft.installer; import me.sashegdev.zernmc.launcher.minecraft.Instance; import me.sashegdev.zernmc.launcher.utils.ProgressBar; import me.sashegdev.zernmc.launcher.utils.ZAnsi; +import me.sashegdev.zernmc.launcher.utils.ZHttpClient; import java.io.IOException; import java.net.URI; @@ -30,17 +31,14 @@ public class FabricInstaller { Path instancePath = instance.getPath(); cleanOldFabricLoaders(); - // Шаг 1: Устанавливаем vanilla и получаем правильный assetIndex VersionInstaller versionInstaller = new VersionInstaller(instancePath); - String assetIndex = versionInstaller.install(minecraftVersion); // Теперь возвращает "5" вместо "1.20.1" + String assetIndex = versionInstaller.install(minecraftVersion); System.out.println(ZAnsi.green("Asset index получен: " + assetIndex)); - // Сохраняем правильный assetIndex instance.setAssetIndex(assetIndex); instance.setMinecraftVersion(minecraftVersion); - // Шаг 2: Скачивание Fabric Installer String installerVersion = getLatestInstallerVersion(); String installerUrl = "https://maven.fabricmc.net/net/fabricmc/fabric-installer/" + installerVersion + "/fabric-installer-" + installerVersion + ".jar"; @@ -49,17 +47,14 @@ public class FabricInstaller { if (!Files.exists(installerJar)) { ProgressBar.show("Скачивание Fabric Installer", 0, 100, "%"); - downloadFile(installerUrl, installerJar); + downloadFileWithFallback(installerUrl, installerJar); ProgressBar.finish("Fabric Installer скачан"); - } else { - System.out.println(ZAnsi.green("Fabric Installer уже скачан, пропускаем...")); } System.out.println(ZAnsi.cyan("Запуск Fabric Installer...")); - - // Fabric создаёт версию: fabric-loader-{loaderVersion}-{minecraftVersion} + String fabricVersionId = "fabric-loader-" + loaderVersion + "-" + minecraftVersion; - + ProcessBuilder pb = new ProcessBuilder( "java", "-jar", installerJar.toAbsolutePath().toString(), "client", @@ -68,7 +63,7 @@ public class FabricInstaller { "-loader", loaderVersion, "-noprofile" ); - + pb.redirectOutput(ProcessBuilder.Redirect.INHERIT); pb.redirectError(ProcessBuilder.Redirect.INHERIT); @@ -80,39 +75,33 @@ public class FabricInstaller { return false; } - // Проверяем, создалась ли папка с Fabric версией Path fabricVersionDir = instancePath.resolve("versions").resolve(fabricVersionId); if (Files.exists(fabricVersionDir)) { System.out.println(ZAnsi.brightGreen("Fabric успешно установлен!")); - System.out.println(ZAnsi.white("Версия: ") + fabricVersionId); - System.out.println(ZAnsi.white("Asset index: ") + assetIndex); - // Сохраняем метаданные instance.setLoaderType("fabric"); instance.setLoaderVersion(loaderVersion); - instance.setFabricVersionId(fabricVersionId); // <-- ВАЖНО: сохраняем ID Fabric версии - - // Исправляем asset index в JSON файле Fabric версии + instance.setFabricVersionId(fabricVersionId); // ← СОХРАНЯЕМ + ensureAssetIndexInFabricVersion(fabricVersionDir, assetIndex); return true; } else { System.out.println(ZAnsi.brightRed("Fabric Installer отработал, но версия не найдена.")); - System.out.println(ZAnsi.yellow("Искали: " + fabricVersionDir)); - - // Отладка - Path versionsDir = instancePath.resolve("versions"); - if (Files.exists(versionsDir)) { - System.out.println(ZAnsi.cyan("Доступные версии:")); - try (var stream = Files.list(versionsDir)) { - stream.forEach(p -> System.out.println(" - " + p.getFileName())); - } - } return false; } } + private void downloadFileWithFallback(String url, Path target) throws Exception { + try { + ZHttpClient.downloadFile(url, target); + } catch (Exception e) { + System.out.println(ZAnsi.yellow("Не удалось скачать Fabric Installer: " + e.getMessage())); + throw e; + } + } + private void ensureAssetIndexInFabricVersion(Path fabricVersionDir, String assetIndex) throws IOException { Path versionJson = fabricVersionDir.resolve(fabricVersionDir.getFileName() + ".json"); @@ -165,25 +154,19 @@ public class FabricInstaller { } private String getLatestInstallerVersion() throws Exception { - String[] urls = { - "https://maven.fabricmc.net/net/fabricmc/fabric-installer/maven-metadata.xml", - "http://maven.fabricmc.net/net/fabricmc/fabric-installer/maven-metadata.xml" - }; - - for (String url : urls) { - try { - String xml = downloadString(url); - int start = xml.indexOf("") + 8; - int end = xml.indexOf("", start); - return xml.substring(start, end).trim(); - } catch (Exception e) { - System.out.println(ZAnsi.yellow("Не удалось получить версию из " + url + ": " + e.getMessage())); - } + try { + // Используем ZHttpClient с умным прокси + String xml = ZHttpClient.downloadString("https://maven.fabricmc.net/net/fabricmc/fabric-installer/maven-metadata.xml"); + int start = xml.indexOf("") + 8; + int end = xml.indexOf("", start); + return xml.substring(start, end).trim(); + } catch (Exception e) { + System.out.println(ZAnsi.yellow("Ошибка получения версии Fabric Installer: " + e.getMessage())); + throw new Exception("Не удалось получить версию Fabric Installer", e); } - - throw new Exception("Не удалось получить версию Fabric Installer"); } + // под рефактор оставить private String downloadString(String url) throws Exception { Exception lastException = null; diff --git a/launcher/src/main/java/me/sashegdev/zernmc/launcher/minecraft/launch/LaunchCommandBuilder.java b/launcher/src/main/java/me/sashegdev/zernmc/launcher/minecraft/launch/LaunchCommandBuilder.java index c7a5287..9d1f58a 100644 --- a/launcher/src/main/java/me/sashegdev/zernmc/launcher/minecraft/launch/LaunchCommandBuilder.java +++ b/launcher/src/main/java/me/sashegdev/zernmc/launcher/minecraft/launch/LaunchCommandBuilder.java @@ -39,27 +39,15 @@ public class LaunchCommandBuilder { String loaderType = instance.getLoaderType().toLowerCase(); if ("forge".equals(loaderType)) { - // Forge требует особого порядка аргументов command.addAll(getForgeJvmArguments()); - - // Forge специфичный classpath command.add("-cp"); command.add(buildForgeClasspath()); - - // Главный класс для Forge command.add("cpw.mods.modlauncher.Launcher"); - - // Аргументы Forge command.addAll(getForgeArguments(options)); } else { - // Стандартный classpath для vanilla/fabric command.add("-cp"); command.add(buildClasspath()); - - // Главный класс command.add(getMainClass()); - - // Стандартные аргументы Minecraft command.addAll(getMinecraftArguments(options)); } @@ -109,7 +97,6 @@ public class LaunchCommandBuilder { private List getForgeJvmArguments() { List jvmArgs = new ArrayList<>(); - // Критические аргументы для Forge jvmArgs.add("--add-modules=ALL-MODULE-PATH"); jvmArgs.add("--add-opens=java.base/java.util.jar=ALL-UNNAMED"); jvmArgs.add("--add-opens=java.base/java.lang.invoke=ALL-UNNAMED"); @@ -121,7 +108,6 @@ public class LaunchCommandBuilder { jvmArgs.add("--add-opens=java.base/sun.nio.ch=ALL-UNNAMED"); jvmArgs.add("--add-opens=jdk.unsupported/sun.misc=ALL-UNNAMED"); - // Forge специфичные свойства jvmArgs.add("-Dforge.logging.console.level=debug"); jvmArgs.add("-Dforge.logging.mojang.level=info"); jvmArgs.add("-DignoreList=bootstraplauncher,securejarhandler,asm-commons,asm-util,asm-analysis,asm-tree,asm,JarJarFileSystems,client-extra,fmlcore,javafmllanguage,lowcodelanguage,mclanguage,forge-"); @@ -133,9 +119,8 @@ public class LaunchCommandBuilder { private String buildClasspath() throws Exception { List paths = new ArrayList<>(); - String versionId = getVersionId(); // ← используем getVersionId() + String versionId = getVersionId(); - // Добавляем основной jar Path versionJar = instance.getPath() .resolve("versions") .resolve(versionId) @@ -144,7 +129,6 @@ public class LaunchCommandBuilder { if (Files.exists(versionJar)) { paths.add(versionJar.toAbsolutePath().toString()); } else { - // Fallback на vanilla версию String mcVersion = instance.getMinecraftVersion(); Path fallbackJar = instance.getPath() .resolve("versions") @@ -155,7 +139,6 @@ public class LaunchCommandBuilder { } } - // Все библиотеки Path librariesDir = instance.getPath().resolve("libraries"); if (Files.exists(librariesDir)) { try (var stream = Files.walk(librariesDir)) { @@ -169,16 +152,13 @@ public class LaunchCommandBuilder { return String.join(separator, paths); } - - // TODO: бля ктонить помогите с этим говном, я рот шатал, форджу не нравится то как я его запускаю private String buildForgeClasspath() throws Exception { List paths = new ArrayList<>(); - String versionId = getVersionId(); // ← используем getVersionId() + String versionId = getVersionId(); String mcVersion = instance.getMinecraftVersion(); String forgeVersion = instance.getLoaderVersion(); - // 1. Сначала добавляем все библиотеки из libraries Path librariesDir = instance.getPath().resolve("libraries"); if (Files.exists(librariesDir)) { try (var stream = Files.walk(librariesDir)) { @@ -188,7 +168,6 @@ public class LaunchCommandBuilder { } } - // 2. Добавляем jar версии (используя versionId) Path versionJar = instance.getPath() .resolve("versions") .resolve(versionId) @@ -196,7 +175,6 @@ public class LaunchCommandBuilder { if (Files.exists(versionJar)) { paths.add(0, versionJar.toAbsolutePath().toString()); } else { - // Fallback на vanilla jar Path vanillaJar = instance.getPath() .resolve("versions") .resolve(mcVersion) @@ -206,7 +184,6 @@ public class LaunchCommandBuilder { } } - // 3. Добавляем Forge universal jar Path forgeUniversal = instance.getPath() .resolve("libraries") .resolve("net") @@ -218,7 +195,6 @@ public class LaunchCommandBuilder { paths.add(forgeUniversal.toAbsolutePath().toString()); } - // 4. Добавляем Forge client jar Path forgeClient = instance.getPath() .resolve("libraries") .resolve("net") @@ -230,7 +206,6 @@ public class LaunchCommandBuilder { paths.add(forgeClient.toAbsolutePath().toString()); } - // 5. Добавляем fmlcore и другие Forge модули String[] forgeModules = {"fmlcore", "javafmllanguage", "lowcodelanguage", "mclanguage"}; for (String module : forgeModules) { Path modulePath = instance.getPath() @@ -263,6 +238,9 @@ public class LaunchCommandBuilder { } } + /** + * ИСПРАВЛЕНО: используем instance.getAssetIndex() вместо minecraftVersion + */ private List getMinecraftArguments(LaunchOptions options) { List args = new ArrayList<>(); @@ -275,8 +253,16 @@ public class LaunchCommandBuilder { args.add("--assetsDir"); args.add(instance.getPath().resolve("assets").toAbsolutePath().toString()); + // FIXED: Используем правильный assetIndex args.add("--assetIndex"); - args.add(instance.getAssetIndex()); + String assetIndex = instance.getAssetIndex(); + if (assetIndex == null || assetIndex.isEmpty()) { + assetIndex = instance.getMinecraftVersion(); + System.out.println(ZAnsi.yellow("Asset index не найден, использую версию: " + assetIndex)); + } else { + System.out.println(ZAnsi.green("Использую asset index: " + assetIndex)); + } + args.add(assetIndex); args.add("--username"); args.add(options.getUsername() != null ? options.getUsername() : "Player"); @@ -302,11 +288,12 @@ public class LaunchCommandBuilder { return args; } - //TODO: сделать это говно удобнее + /** + * ИСПРАВЛЕНО: для Forge тоже используем правильный assetIndex + */ private List getForgeArguments(LaunchOptions options) { List args = new ArrayList<>(); - // Forge требует специфические аргументы в правильном порядке args.add("--launchTarget"); args.add("forgeclient"); @@ -319,15 +306,19 @@ public class LaunchCommandBuilder { args.add("--fml.forgeGroup"); args.add("net.minecraftforge"); - // Добавляем стандартные аргументы Minecraft (Forge их тоже принимает) args.add("--gameDir"); args.add(instance.getPath().toAbsolutePath().toString()); args.add("--assetsDir"); args.add(instance.getPath().resolve("assets").toAbsolutePath().toString()); + // FIXED: Используем правильный assetIndex для Forge args.add("--assetIndex"); - args.add(instance.getAssetIndex()); + String assetIndex = instance.getAssetIndex(); + if (assetIndex == null || assetIndex.isEmpty()) { + assetIndex = instance.getMinecraftVersion(); + } + args.add(assetIndex); args.add("--username"); args.add(options.getUsername() != null ? options.getUsername() : "Player"); @@ -353,7 +344,9 @@ public class LaunchCommandBuilder { return args; } - //не трогать, оно работает + /** + * ИСПРАВЛЕНО: для Fabric используем сохраненный fabricVersionId + */ private String getVersionId() { String loaderType = instance.getLoaderType().toLowerCase(); String mcVersion = instance.getMinecraftVersion(); @@ -363,11 +356,15 @@ public class LaunchCommandBuilder { return mcVersion; } else if ("fabric".equals(loaderType)) { - // Fabric использует vanilla версию для jar файла - return mcVersion; + // Используем сохраненный fabricVersionId если есть + String fabricId = instance.getFabricVersionId(); + if (fabricId != null && !fabricId.isEmpty()) { + return fabricId; + } + // fallback + return "fabric-loader-" + loaderVer + "-" + mcVersion; } else if ("forge".equals(loaderType)) { - // Forge создаёт свою версию в папке versions return mcVersion + "-forge-" + loaderVer; } diff --git a/launcher/src/main/java/me/sashegdev/zernmc/launcher/utils/ZHttpClient.java b/launcher/src/main/java/me/sashegdev/zernmc/launcher/utils/ZHttpClient.java index cb46b57..a21e01d 100644 --- a/launcher/src/main/java/me/sashegdev/zernmc/launcher/utils/ZHttpClient.java +++ b/launcher/src/main/java/me/sashegdev/zernmc/launcher/utils/ZHttpClient.java @@ -1,125 +1,504 @@ package me.sashegdev.zernmc.launcher.utils; +import org.json.JSONArray; +import org.json.JSONObject; + import java.io.IOException; import java.net.URI; -//import java.net.http.HttpClient; +import java.net.URLEncoder; +import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; import java.time.Duration; import java.util.ArrayList; import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicBoolean; public class ZHttpClient { - private static final java.net.http.HttpClient client = java.net.http.HttpClient.newBuilder() - .connectTimeout(Duration.ofSeconds(10)) + private static final HttpClient client = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(15)) + .version(HttpClient.Version.HTTP_1_1) .build(); private static final String BASE_URL = "http://87.120.187.36:1582"; - - public static String get(String endpoint) throws IOException, InterruptedException { - HttpRequest request = HttpRequest.newBuilder() - .uri(URI.create(BASE_URL + endpoint)) - .timeout(Duration.ofSeconds(15)) - .GET() - .build(); - - HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); - - if (response.statusCode() != 200) { - throw new IOException("HTTP " + response.statusCode()); + + // Глобальный прокси режим (для обратной совместимости) + private static final AtomicBoolean useProxyMode = new AtomicBoolean(false); + private static final AtomicBoolean proxyTested = new AtomicBoolean(false); + + // Умное проксирование по сервисам + public enum ServiceType { + ZERN_SERVER(BASE_URL, true), // Всегда прямое подключение + FABRIC_META("https://meta.fabricmc.net", false), + FABRIC_MAVEN("https://maven.fabricmc.net", false), + MOJANG_META("https://piston-meta.mojang.com", false), + MOJANG_RESOURCES("https://resources.download.minecraft.net", false), + FORGE_MAVEN("https://maven.minecraftforge.net", false), + GOOGLE("https://google.com", false), + CLOUDFLARE("https://cloudflare.com", false); + + private final String baseUrl; + private final boolean alwaysDirect; + + ServiceType(String baseUrl, boolean alwaysDirect) { + this.baseUrl = baseUrl; + this.alwaysDirect = alwaysDirect; } - - return response.body(); - } - - public static String getBaseUrl() { - return BASE_URL; - } - - public static String getLauncherVersion() throws IOException, InterruptedException { - HttpRequest request = HttpRequest.newBuilder() - .uri(URI.create(BASE_URL + "/launcher/version")) - .timeout(Duration.ofSeconds(10)) - .GET() - .build(); - - HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); - - if (response.statusCode() != 200) { - throw new IOException("HTTP " + response.statusCode()); + + public String getBaseUrl() { + return baseUrl; } - - return response.body(); - } - public static String getLauncherVersionInfo() throws IOException, InterruptedException { - HttpRequest request = HttpRequest.newBuilder() - .uri(URI.create(BASE_URL + "/launcher/version")) - .timeout(Duration.ofSeconds(10)) - .GET() - .build(); - - HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); - - if (response.statusCode() != 200) { - throw new IOException("HTTP " + response.statusCode()); + + public boolean isAlwaysDirect() { + return alwaysDirect; } - - return response.body(); } + + // Статусы сервисов + private static final Map serviceProxyMode = new ConcurrentHashMap<>(); + private static final Map serviceFailCount = new ConcurrentHashMap<>(); + private static final Map serviceLastCheckTime = new ConcurrentHashMap<>(); + private static final Map serviceHealthy = new ConcurrentHashMap<>(); + + private static final int MAX_FAILS_BEFORE_PROXY = 2; + private static final long HEALTH_CHECK_INTERVAL_MS = 60000; // 1 минута + private static final long CHECK_TIMEOUT_MS = 5000; // 5 секунд на проверку + + // Статистика + private static int directSuccessCount = 0; + private static int proxySuccessCount = 0; + private static int directFailCount = 0; + + static { + for (ServiceType type : ServiceType.values()) { + serviceProxyMode.put(type, false); + serviceFailCount.put(type, 0); + serviceHealthy.put(type, false); + } + } + /** - * Получить список всех доступных версий Fabric Loader + * Проверить все сервисы при старте */ + public static void checkAllServicesOnStartup() { + if (proxyTested.get()) return; + + System.out.println(ZAnsi.cyan("Проверка доступности сервисов...")); + + List servicesToCheck = List.of( + ServiceType.ZERN_SERVER, + ServiceType.GOOGLE, + ServiceType.FABRIC_META, + ServiceType.FABRIC_MAVEN, + ServiceType.MOJANG_META, + ServiceType.FORGE_MAVEN + ); + + for (ServiceType service : servicesToCheck) { + boolean isHealthy = checkServiceHealth(service); + serviceHealthy.put(service, isHealthy); + + if (service.isAlwaysDirect()) { + if (!isHealthy) { + System.out.println(ZAnsi.red(" " + service.name() + " - НЕ ДОСТУПЕН (критично!)")); + } else { + System.out.println(ZAnsi.green(" " + service.name() + " - OK")); + } + } else { + if (isHealthy) { + System.out.println(ZAnsi.green(" " + service.name() + " - прямое подключение работает")); + } else { + System.out.println(ZAnsi.yellow(" " + service.name() + " - НЕ ДОСТУПЕН, будет использован прокси")); + serviceProxyMode.put(service, true); + serviceFailCount.put(service, MAX_FAILS_BEFORE_PROXY); + } + } + } + + // Проверяем, нужно ли включить глобальный прокси режим + boolean anyCriticalDown = !serviceHealthy.get(ServiceType.ZERN_SERVER); + if (anyCriticalDown) { + System.out.println(ZAnsi.brightRed("Критическая ошибка: Zern сервер недоступен!")); + } + + proxyTested.set(true); + + // Запускаем фоновую проверку + startHealthCheckThread(); + + printStats(); + } + + /** + * Проверить здоровье конкретного сервиса + */ + private static boolean checkServiceHealth(ServiceType service) { + if (service.isAlwaysDirect()) { + return checkDirectConnection(service.getBaseUrl()); + } + + return checkDirectConnection(service.getBaseUrl()); + } + + /** + * Проверить прямое подключение к URL + */ + private static boolean checkDirectConnection(String url) { + try { + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(url)) + .timeout(Duration.ofMillis(CHECK_TIMEOUT_MS)) + .HEAD() + .build(); + HttpResponse response = client.send(request, HttpResponse.BodyHandlers.discarding()); + return response.statusCode() < 500; + } catch (Exception e) { + return false; + } + } + + /** + * Запустить фоновый поток для периодической проверки + */ + private static void startHealthCheckThread() { + Thread healthThread = new Thread(() -> { + while (true) { + try { + Thread.sleep(HEALTH_CHECK_INTERVAL_MS); + performHealthCheck(); + } catch (InterruptedException e) { + break; + } + } + }); + healthThread.setDaemon(true); + healthThread.start(); + } + + /** + * Периодическая проверка здоровья сервисов + */ + private static void performHealthCheck() { + for (ServiceType service : ServiceType.values()) { + if (service.isAlwaysDirect()) continue; + + boolean isHealthy = checkServiceHealth(service); + serviceHealthy.put(service, isHealthy); + + if (isHealthy && serviceProxyMode.get(service)) { + // Сервис восстановился - пробуем переключить обратно + Long lastCheck = serviceLastCheckTime.get(service); + if (lastCheck == null || System.currentTimeMillis() - lastCheck > HEALTH_CHECK_INTERVAL_MS) { + serviceProxyMode.put(service, false); + serviceFailCount.put(service, 0); + System.out.println(ZAnsi.green("[NET] " + service.name() + " восстановлен, переключен на прямое подключение")); + } + } else if (!isHealthy && !serviceProxyMode.get(service)) { + int fails = serviceFailCount.getOrDefault(service, 0) + 1; + serviceFailCount.put(service, fails); + serviceLastCheckTime.put(service, System.currentTimeMillis()); + + if (fails >= MAX_FAILS_BEFORE_PROXY) { + serviceProxyMode.put(service, true); + System.out.println(ZAnsi.yellow("[NET] " + service.name() + " недоступен, включен прокси режим")); + } + } + } + } + + /** + * Определить тип сервиса по URL + */ + private static ServiceType detectService(String url) { + if (url.contains("meta.fabricmc.net")) return ServiceType.FABRIC_META; + if (url.contains("maven.fabricmc.net")) return ServiceType.FABRIC_MAVEN; + if (url.contains("piston-meta.mojang.com") || url.contains("launchermeta.mojang.com")) + return ServiceType.MOJANG_META; + if (url.contains("resources.download.minecraft.net")) return ServiceType.MOJANG_RESOURCES; + if (url.contains("maven.minecraftforge.net")) return ServiceType.FORGE_MAVEN; + if (url.contains("google.com")) return ServiceType.GOOGLE; + if (url.contains("cloudflare.com")) return ServiceType.CLOUDFLARE; + return null; + } + + /** + * Нужно ли использовать прокси для URL + */ + private static boolean shouldUseProxyForUrl(String url) { + if (useProxyMode.get()) return true; + + ServiceType service = detectService(url); + if (service == null) return false; + if (service.isAlwaysDirect()) return false; + + return serviceProxyMode.getOrDefault(service, false); + } + + /** + * Получить URL через прокси если нужно + */ + public static String getWithSmartProxy(String url) throws IOException, InterruptedException { + if (shouldUseProxyForUrl(url)) { + String encodedUrl = URLEncoder.encode(url, StandardCharsets.UTF_8); + return proxyGet("/download?url=" + encodedUrl); + } + + try { + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(url)) + .timeout(Duration.ofSeconds(30)) + .header("User-Agent", "ZernMC-Launcher/1.0") + .GET() + .build(); + + HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); + + if (response.statusCode() != 200) { + throw new IOException("HTTP " + response.statusCode()); + } + + directSuccessCount++; + return response.body(); + + } catch (Exception e) { + directFailCount++; + ServiceType service = detectService(url); + if (service != null && !service.isAlwaysDirect()) { + int fails = serviceFailCount.getOrDefault(service, 0) + 1; + serviceFailCount.put(service, fails); + if (fails >= MAX_FAILS_BEFORE_PROXY) { + serviceProxyMode.put(service, true); + } + } + throw e; + } + } + + /** + * Скачать файл с умным прокси + */ + public static void downloadFileWithSmartProxy(String url, Path target) throws Exception { + if (shouldUseProxyForUrl(url)) { + downloadViaProxy(url, target); + return; + } + + try { + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(url)) + .timeout(Duration.ofSeconds(30)) + .header("User-Agent", "ZernMC-Launcher/1.0") + .GET() + .build(); + + HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofFile(target)); + + if (response.statusCode() != 200) { + throw new IOException("HTTP " + response.statusCode()); + } + + directSuccessCount++; + + } catch (Exception e) { + directFailCount++; + ServiceType service = detectService(url); + if (service != null && !service.isAlwaysDirect()) { + int fails = serviceFailCount.getOrDefault(service, 0) + 1; + serviceFailCount.put(service, fails); + if (fails >= MAX_FAILS_BEFORE_PROXY) { + serviceProxyMode.put(service, true); + } + } + throw e; + } + } + + // ====================== ОСНОВНЫЕ МЕТОДЫ (СОХРАНЕНЫ) ====================== + + public static String get(String endpoint) throws IOException, InterruptedException { + checkAllServicesOnStartup(); + + if (useProxyMode.get()) { + return proxyGet(endpoint); + } + + try { + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(BASE_URL + endpoint)) + .timeout(Duration.ofSeconds(15)) + .header("User-Agent", "ZernMC-Launcher/1.0") + .GET() + .build(); + + HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); + + if (response.statusCode() != 200) { + throw new IOException("HTTP " + response.statusCode()); + } + return response.body(); + + } catch (Exception e) { + directFailCount++; + throw e; + } + } + + private static String proxyGet(String endpoint) throws IOException { + try { + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(BASE_URL + "/proxy" + endpoint)) + .timeout(Duration.ofSeconds(30)) + .header("User-Agent", "ZernMC-Launcher/1.0") + .GET() + .build(); + + HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); + + if (response.statusCode() != 200) { + throw new IOException("HTTP " + response.statusCode()); + } + + proxySuccessCount++; + return response.body(); + + } catch (Exception e) { + throw new IOException("Ошибка прокси запроса: " + e.getMessage(), e); + } + } + + private static void downloadViaProxy(String url, Path target) throws Exception { + String encodedUrl = URLEncoder.encode(url, StandardCharsets.UTF_8.toString()); + String proxyUrl = BASE_URL + "/proxy/download?url=" + encodedUrl; + + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(proxyUrl)) + .timeout(Duration.ofMinutes(5)) + .header("User-Agent", "ZernMC-Launcher/1.0") + .GET() + .build(); + + HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofFile(target)); + + if (response.statusCode() != 200) { + throw new IOException("HTTP " + response.statusCode()); + } + + proxySuccessCount++; + } + + // ====================== МЕТОДЫ ДЛЯ EXTERNAL РЕСУРСОВ ====================== + public static List getFabricLoaderVersions() throws IOException, InterruptedException { String url = "https://meta.fabricmc.net/v2/versions/loader"; - - HttpRequest request = HttpRequest.newBuilder() - .uri(URI.create(url)) - .GET() - .build(); - - HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); - - if (response.statusCode() != 200) { - throw new IOException("HTTP " + response.statusCode()); + String response = getWithSmartProxy(url); + return parseFabricVersionsFromJson(response); + } + + public static JSONObject getMojangVersionManifest() throws IOException, InterruptedException { + String url = "https://piston-meta.mojang.com/mc/game/version_manifest_v2.json"; + String response = getWithSmartProxy(url); + return new JSONObject(response); + } + + public static JSONObject getMojangVersionJson(String versionId) throws IOException, InterruptedException { + JSONObject manifest = getMojangVersionManifest(); + JSONArray versions = manifest.getJSONArray("versions"); + + String versionUrl = null; + for (int i = 0; i < versions.length(); i++) { + JSONObject v = versions.getJSONObject(i); + if (v.getString("id").equals(versionId)) { + versionUrl = v.getString("url"); + break; + } } - - // Парсим JSON массив - org.json.JSONArray array = new org.json.JSONArray(response.body()); + + if (versionUrl == null) { + throw new IOException("Version " + versionId + " not found"); + } + + String response = getWithSmartProxy(versionUrl); + return new JSONObject(response); + } + + public static String getForgeVersionsXml() throws IOException, InterruptedException { + String url = "https://maven.minecraftforge.net/net/minecraftforge/forge/maven-metadata.xml"; + return getWithSmartProxy(url); + } + + public static void downloadFile(String url, Path target) throws Exception { + downloadFileWithSmartProxy(url, target); + } + + public static void downloadAsset(String hash, Path target) throws Exception { + String url = "https://resources.download.minecraft.net/" + hash.substring(0, 2) + "/" + hash; + downloadFileWithSmartProxy(url, target); + } + + public static String downloadString(String url) throws IOException, InterruptedException { + return getWithSmartProxy(url); + } + + // ====================== ВСПОМОГАТЕЛЬНЫЕ МЕТОДЫ ====================== + + private static List parseFabricVersionsFromJson(String json) { + JSONArray array = new JSONArray(json); List versions = new ArrayList<>(); - for (int i = 0; i < array.length(); i++) { - org.json.JSONObject obj = array.getJSONObject(i); + JSONObject obj = array.getJSONObject(i); if (obj.has("version")) { versions.add(obj.getString("version")); } } - - versions.sort((a, b) -> { - // Правильная семантическая сортировка версий - String[] partsA = a.split("\\+")[0].split("\\."); - String[] partsB = b.split("\\+")[0].split("\\."); - for (int i = 0; i < Math.min(partsA.length, partsB.length); i++) { - int cmp = Integer.compare(Integer.parseInt(partsB[i]), Integer.parseInt(partsA[i])); - if (cmp != 0) return cmp; - } - return b.compareTo(a); // fallback - }); - return versions; } - - public static String downloadString(String url) throws IOException, InterruptedException { - HttpRequest request = HttpRequest.newBuilder() - .uri(URI.create(url)) - .GET() - .build(); - - HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); - - if (response.statusCode() != 200) { - throw new IOException("HTTP " + response.statusCode()); + + public static String getBaseUrl() { + return BASE_URL; + } + + public static String getLauncherVersionInfo() throws IOException, InterruptedException { + return get("/launcher/version"); + } + + public static void forceProxyMode() { + useProxyMode.set(true); + System.out.println(ZAnsi.yellow("Принудительно включен глобальный прокси режим")); + } + + public static void disableProxyMode() { + useProxyMode.set(false); + for (ServiceType type : ServiceType.values()) { + if (!type.isAlwaysDirect()) { + serviceProxyMode.put(type, false); + serviceFailCount.put(type, 0); + } + } + System.out.println(ZAnsi.green("Режим прокси выключен")); + } + + public static boolean isProxyMode() { + return useProxyMode.get(); + } + + public static void printStats() { + System.out.println(ZAnsi.cyan("\n=== Статистика сети ===")); + System.out.println(ZAnsi.white("Глобальный прокси режим: ") + (useProxyMode.get() ? "ВКЛЮЧЕН" : "ВЫКЛЮЧЕН")); + System.out.println(ZAnsi.white("Прямых успехов: ") + directSuccessCount); + System.out.println(ZAnsi.white("Прямых неудач: ") + directFailCount); + System.out.println(ZAnsi.white("Прокси успехов: ") + proxySuccessCount); + + System.out.println(ZAnsi.cyan("\nСтатус сервисов:")); + for (ServiceType type : ServiceType.values()) { + if (type.isAlwaysDirect()) continue; + String status = serviceProxyMode.get(type) ? ZAnsi.red("ПРОКСИ") : ZAnsi.green("ПРЯМО"); + String health = serviceHealthy.get(type) ? ZAnsi.green("✓") : ZAnsi.red("✗"); + System.out.println(ZAnsi.white(" " + type.name() + ": ") + status + " " + health); } - return response.body(); } } \ No newline at end of file diff --git a/server/main.py b/server/main.py index 5a6107a..9dc25c4 100644 --- a/server/main.py +++ b/server/main.py @@ -1,4 +1,4 @@ -from fastapi import FastAPI, HTTPException, Request +from fastapi import FastAPI, HTTPException, Request, Response from fastapi.responses import FileResponse, JSONResponse from contextlib import asynccontextmanager from pathlib import Path @@ -16,6 +16,10 @@ from middleware import LoggingMiddleware from cli import parse_args, run_test_mode, run_production_mode, run_development_mode from log_manager import init_logging +import httpx +import base64 +from fastapi.responses import StreamingResponse + logger = structlog.get_logger(__name__) # Cache for manifests - expires after 5 minutes @@ -478,6 +482,350 @@ async def get_launcher_full_info(): return info +# ====================== ПРОКСИ ЭНДПОИНТЫ ====================== +# Эти эндпоинты позволяют клиентам с сетевыми проблемами +# скачивать файлы через сервер Zern + +# Создаем HTTP клиент для прокси +proxy_client = httpx.AsyncClient(timeout=60.0, follow_redirects=True) + +# Кэш для часто запрашиваемых данных (5 минут) +from cachetools import TTLCache +proxy_cache = TTLCache(maxsize=50, ttl=300) + +# Список заблокированных/проблемных хостов (можно обновлять) +BLOCKED_HOSTS = [] + + +@app.get("/proxy/fabric/versions/loader") +async def proxy_fabric_versions(request: Request): + """Прокси для Fabric Meta API - список версий загрузчика""" + client_ip = request.client.host if request.client else "unknown" + logger.info(f"Proxy request: Fabric versions from {client_ip}") + + url = "https://meta.fabricmc.net/v2/versions/loader" + + # Проверяем кэш + if url in proxy_cache: + logger.debug(f"Proxy cache hit for {url}") + return JSONResponse(content=proxy_cache[url]) + + try: + response = await proxy_client.get(url) + response.raise_for_status() + data = response.json() + + # Кэшируем + proxy_cache[url] = data + + logger.info(f"Proxy success: Fabric versions ({len(data)} items)") + return JSONResponse(content=data) + + except httpx.HTTPError as e: + logger.error(f"Proxy error for Fabric versions: {e}") + raise HTTPException(502, f"Bad Gateway: {str(e)}") + except Exception as e: + logger.error(f"Proxy unexpected error: {e}") + raise HTTPException(500, f"Internal error: {str(e)}") + + +@app.get("/proxy/fabric/installer/latest") +async def proxy_fabric_installer_latest(request: Request): + """Получить последнюю версию Fabric Installer""" + client_ip = request.client.host if request.client else "unknown" + logger.info(f"Proxy request: Fabric installer latest from {client_ip}") + + url = "https://maven.fabricmc.net/net/fabricmc/fabric-installer/maven-metadata.xml" + + try: + response = await proxy_client.get(url) + response.raise_for_status() + xml = response.text + + # Парсим последнюю версию из XML + import re + match = re.search(r'([^<]+)', xml) + if match: + version = match.group(1) + logger.info(f"Proxy success: Latest Fabric installer version = {version}") + return JSONResponse(content={"version": version}) + else: + raise HTTPException(500, "Could not parse latest version") + + except httpx.HTTPError as e: + logger.error(f"Proxy error for Fabric installer latest: {e}") + raise HTTPException(502, f"Bad Gateway: {str(e)}") + + +@app.get("/proxy/fabric/installer/{version}") +async def proxy_fabric_installer_url(version: str, request: Request): + """Получить URL для скачивания Fabric Installer определенной версии""" + client_ip = request.client.host if request.client else "unknown" + logger.info(f"Proxy request: Fabric installer URL for v{version} from {client_ip}") + + url = f"https://maven.fabricmc.net/net/fabricmc/fabric-installer/{version}/fabric-installer-{version}.jar" + + return JSONResponse(content={"url": url, "version": version}) + + +@app.get("/proxy/fabric/maven/{path:path}") +async def proxy_fabric_maven(path: str, request: Request): + """Прокси для Fabric Maven файлов""" + client_ip = request.client.host if request.client else "unknown" + logger.info(f"Proxy request: Fabric Maven {path} from {client_ip}") + + full_url = f"https://maven.fabricmc.net/{path}" + + try: + response = await proxy_client.get(full_url) + response.raise_for_status() + + # Определяем content-type + content_type = "application/octet-stream" + if path.endswith(".jar"): + content_type = "application/java-archive" + elif path.endswith(".pom"): + content_type = "application/xml" + + return Response( + content=response.content, + media_type=content_type, + headers={"X-Proxied-By": "ZernMC"} + ) + + except httpx.HTTPError as e: + logger.error(f"Proxy error for Fabric Maven {path}: {e}") + raise HTTPException(502, f"Bad Gateway: {str(e)}") + + +@app.get("/proxy/mojang/version_manifest") +async def proxy_mojang_manifest(request: Request): + """Прокси для Mojang version manifest""" + client_ip = request.client.host if request.client else "unknown" + logger.info(f"Proxy request: Mojang manifest from {client_ip}") + + url = "https://piston-meta.mojang.com/mc/game/version_manifest_v2.json" + + if url in proxy_cache: + return JSONResponse(content=proxy_cache[url]) + + try: + response = await proxy_client.get(url) + response.raise_for_status() + data = response.json() + + proxy_cache[url] = data + logger.info("Proxy success: Mojang manifest") + return JSONResponse(content=data) + + except httpx.HTTPError as e: + logger.error(f"Proxy error for Mojang manifest: {e}") + raise HTTPException(502, f"Bad Gateway: {str(e)}") + + +@app.get("/proxy/mojang/version/{version_id}") +async def proxy_mojang_version(version_id: str, request: Request): + """Прокси для конкретной версии Mojang""" + client_ip = request.client.host if request.client else "unknown" + logger.info(f"Proxy request: Mojang version {version_id} from {client_ip}") + + # Сначала получаем манифест, чтобы найти URL версии + manifest_url = "https://piston-meta.mojang.com/mc/game/version_manifest_v2.json" + + cache_key = f"version_url_{version_id}" + version_url = proxy_cache.get(cache_key) + + if not version_url: + try: + response = await proxy_client.get(manifest_url) + response.raise_for_status() + manifest = response.json() + + for version in manifest.get("versions", []): + if version.get("id") == version_id: + version_url = version.get("url") + proxy_cache[cache_key] = version_url + break + + if not version_url: + raise HTTPException(404, f"Version {version_id} not found") + + except httpx.HTTPError as e: + logger.error(f"Proxy error getting version URL: {e}") + raise HTTPException(502, f"Bad Gateway: {str(e)}") + + try: + response = await proxy_client.get(version_url) + response.raise_for_status() + data = response.json() + + logger.info(f"Proxy success: Mojang version {version_id}") + return JSONResponse(content=data) + + except httpx.HTTPError as e: + logger.error(f"Proxy error for Mojang version {version_id}: {e}") + raise HTTPException(502, f"Bad Gateway: {str(e)}") + + +@app.get("/proxy/forge/versions") +async def proxy_forge_versions(request: Request): + """Прокси для списка версий Forge""" + client_ip = request.client.host if request.client else "unknown" + logger.info(f"Proxy request: Forge versions from {client_ip}") + + url = "https://maven.minecraftforge.net/net/minecraftforge/forge/maven-metadata.xml" + + try: + response = await proxy_client.get(url) + response.raise_for_status() + + # Возвращаем XML как есть + return Response( + content=response.content, + media_type="application/xml", + headers={"X-Proxied-By": "ZernMC"} + ) + + except httpx.HTTPError as e: + logger.error(f"Proxy error for Forge versions: {e}") + raise HTTPException(502, f"Bad Gateway: {str(e)}") + + +@app.get("/proxy/forge/maven/{path:path}") +async def proxy_forge_maven(path: str, request: Request): + """Прокси для Forge Maven файлов""" + client_ip = request.client.host if request.client else "unknown" + logger.info(f"Proxy request: Forge Maven {path} from {client_ip}") + + full_url = f"https://maven.minecraftforge.net/{path}" + + try: + response = await proxy_client.get(full_url) + response.raise_for_status() + + content_type = "application/octet-stream" + if path.endswith(".jar"): + content_type = "application/java-archive" + elif path.endswith(".pom"): + content_type = "application/xml" + + return Response( + content=response.content, + media_type=content_type, + headers={"X-Proxied-By": "ZernMC"} + ) + + except httpx.HTTPError as e: + logger.error(f"Proxy error for Forge Maven {path}: {e}") + raise HTTPException(502, f"Bad Gateway: {str(e)}") + + +@app.get("/proxy/download") +async def proxy_download(request: Request): + """Универсальный прокси для скачивания файлов""" + client_ip = request.client.host if request.client else "unknown" + url = request.query_params.get("url") + + if not url: + raise HTTPException(400, "Missing 'url' parameter") + + # Безопасность: проверяем URL + allowed_domains = [ + "maven.fabricmc.net", + "meta.fabricmc.net", + "piston-meta.mojang.com", + "launchermeta.mojang.com", + "resources.download.minecraft.net", + "maven.minecraftforge.net", + "files.minecraftforge.net" + ] + + # Проверяем, что URL ведет на разрешенный домен + from urllib.parse import urlparse + parsed = urlparse(url) + domain = parsed.netloc.lower() + + # Убираем порт если есть + domain = domain.split(':')[0] + + if domain not in allowed_domains and not any(domain.endswith(f".{d}") for d in allowed_domains): + logger.warning(f"Proxy blocked: {domain} not in allowed list (client: {client_ip}, url: {url[:100]})") + raise HTTPException(403, f"Domain {domain} not allowed") + + logger.info(f"Proxy download: {url[:100]}... from {client_ip}") + + try: + # Используем streaming response для больших файлов + response = await proxy_client.get(url) + response.raise_for_status() + + # Определяем content-type из ответа или по расширению + content_type = response.headers.get("content-type", "application/octet-stream") + + return Response( + content=response.content, + media_type=content_type, + headers={ + "X-Proxied-By": "ZernMC", + "X-Original-Url": url[:100] # только для отладки + } + ) + + except httpx.HTTPError as e: + logger.error(f"Proxy download error for {url[:100]}: {e}") + raise HTTPException(502, f"Bad Gateway: {str(e)}") + + +@app.get("/proxy/asset/{hash}") +async def proxy_asset(hash: str, request: Request): + """Прокси для Minecraft ассетов по хешу""" + client_ip = request.client.host if request.client else "unknown" + + if len(hash) < 2: + raise HTTPException(400, "Invalid hash") + + url = f"https://resources.download.minecraft.net/{hash[:2]}/{hash}" + logger.info(f"Proxy asset: {hash} from {client_ip}") + + try: + response = await proxy_client.get(url) + response.raise_for_status() + + return Response( + content=response.content, + media_type="application/octet-stream", + headers={"X-Proxied-By": "ZernMC"} + ) + + except httpx.HTTPError as e: + logger.error(f"Proxy asset error for {hash}: {e}") + raise HTTPException(502, f"Bad Gateway: {str(e)}") + + +@app.get("/proxy/status") +async def proxy_status(): + """Проверка статуса прокси сервера""" + return { + "status": "ok", + "cached_items": len(proxy_cache), + "allowed_domains": [ + "maven.fabricmc.net", + "meta.fabricmc.net", + "piston-meta.mojang.com", + "launchermeta.mojang.com", + "resources.download.minecraft.net", + "maven.minecraftforge.net" + ], + "note": "Use this proxy if you have network issues connecting to Fabric/Mojang/Forge" + } + + +# Cleanup on shutdown +@app.on_event("shutdown") +async def shutdown_proxy(): + await proxy_client.close() + + # ====================== ЗАПУСК ====================== if __name__ == "__main__":