diff --git a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/Instance.java b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/Instance.java index 7c37175..da5eb2a 100644 --- a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/Instance.java +++ b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/Instance.java @@ -130,7 +130,9 @@ public class Instance { this.isServerPack = meta.isServerPack; this.serverVersion = meta.serverVersion; this.serverPackName = meta.serverPackName; - } catch (Exception ignored) {} + } catch (Exception e) { + System.err.println("[Instance] Failed to load metadata for " + name + ": " + e.getMessage()); + } } private void saveMetadata() { diff --git a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/ui/jfx/JFXLauncher.java b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/ui/jfx/JFXLauncher.java index 19b36c8..58ab3ad 100644 --- a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/ui/jfx/JFXLauncher.java +++ b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/ui/jfx/JFXLauncher.java @@ -44,6 +44,7 @@ import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicBoolean; import com.sun.net.httpserver.HttpServer; import com.sun.net.httpserver.HttpExchange; import com.sun.net.httpserver.Headers; @@ -51,7 +52,7 @@ import com.sun.net.httpserver.Headers; public class JFXLauncher extends Application { private static int PORT = 8080; private static final String APP_TITLE = "ZernMC Launcher"; - private static final String LAUNCHER_SERVER = System.getProperty("launcher.server", "http://87.120.187.36:1582"); + private static final String LAUNCHER_SERVER = System.getProperty("launcher.server", "https://api.zernmc.ru"); private final LauncherAPI api = new LauncherAPI(); private final Gson gson = new Gson(); private HttpServer server; @@ -64,7 +65,7 @@ public class JFXLauncher extends Application { private static volatile String installProgressLabel = ""; private static volatile int installProgressCurrent = 0; private static volatile int installProgressTotal = 100; - private static volatile boolean installInProgress = false; + private static final AtomicBoolean installInProgress = new AtomicBoolean(false); private static volatile String installStageName = ""; private static volatile int installStageIndex = 0; private static volatile int installStageCount = 1; @@ -114,11 +115,11 @@ public class JFXLauncher extends Application { } public static void setInstallInProgress(boolean inProgress) { - installInProgress = inProgress; + installInProgress.set(inProgress); } public static boolean isInstallInProgress() { - return installInProgress; + return installInProgress.get(); } public static void appendLauncherLog(String msg) { @@ -243,14 +244,9 @@ public class JFXLauncher extends Application { StringBuilder sb = new StringBuilder(); String line; while ((line = br.readLine()) != null) sb.append(line); - String response = sb.toString(); - int versionStart = response.indexOf("\"version\":\""); - if (versionStart >= 0) { - int afterVersion = versionStart + 11; - int versionEnd = response.indexOf("\"", afterVersion); - if (versionEnd > afterVersion) { - return response.substring(afterVersion, versionEnd); - } + org.json.JSONObject obj = new org.json.JSONObject(sb.toString()); + if (obj.has("version")) { + return obj.getString("version"); } } } @@ -669,13 +665,14 @@ public class JFXLauncher extends Application { } private void handleInstall(HttpExchange exchange) { - if (installInProgress) { + if (!installInProgress.compareAndSet(false, true)) { sendJson(exchange, Map.of("success", false, "error", "Installation already in progress")); return; } try { if (!api.isLoggedIn()) { + installInProgress.set(false); sendJson(exchange, Map.of("success", false, "error", "Not authenticated")); return; } @@ -690,6 +687,7 @@ public class JFXLauncher extends Application { var createResult = api.instances().createInstance(name); if (!createResult.isSuccess()) { + installInProgress.set(false); sendJson(exchange, Map.of("success", false, "error", createResult.getError())); return; } @@ -706,7 +704,6 @@ public class JFXLauncher extends Application { sendJson(exchange, Map.of("success", true, "message", "Installation started")); - setInstallInProgress(true); setInstallProgressWithStage("Preparing...", 0, 100, "Preparing", 0, 4); Thread installThread = new Thread(() -> { @@ -777,15 +774,15 @@ public class JFXLauncher extends Application { progress.put("label", installProgressLabel); progress.put("current", installProgressCurrent); progress.put("total", installProgressTotal); - progress.put("inProgress", installInProgress); + progress.put("inProgress", installInProgress.get()); progress.put("stageName", installStageName); progress.put("stageIndex", installStageIndex); progress.put("stageCount", Math.max(installStageCount, 1)); - if (installInProgress && installProgressTotal > 0) { + if (installInProgress.get() && installProgressTotal > 0) { progress.put("percent", (int) ((installProgressCurrent * 100.0) / installProgressTotal)); } else { - progress.put("percent", installInProgress ? 0 : 100); + progress.put("percent", installInProgress.get() ? 0 : 100); } sendJson(exchange, Map.of("success", true, "data", progress)); diff --git a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/utils/Config.java b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/utils/Config.java index 84102a0..33efce8 100644 --- a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/utils/Config.java +++ b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/utils/Config.java @@ -15,7 +15,7 @@ public class Config { private static final Properties props = new Properties(); private static volatile int maxMemory = 4096; - private static volatile String serverUrl = "http://87.120.187.36:1582"; + private static volatile String serverUrl = "https://api.zernmc.ru"; private static volatile String lastUsername = "Player"; private static volatile int windowWidth = 1280; private static volatile int windowHeight = 720; @@ -238,7 +238,14 @@ public class Config { public static long getSystemTotalRamMB() { long totalMb = getTotalSystemRamMB(); if (totalMb > 0) return totalMb; - return Runtime.getRuntime().maxMemory() / (1024 * 1024); + try { + java.lang.management.OperatingSystemMXBean osBean = java.lang.management.ManagementFactory.getOperatingSystemMXBean(); + if (osBean instanceof com.sun.management.OperatingSystemMXBean sunBean) { + return sunBean.getTotalMemorySize() / (1024 * 1024); + } + } catch (Exception ignored) {} + long jvmMax = Runtime.getRuntime().maxMemory() / (1024 * 1024); + return Math.max(jvmMax, 4096); } public static String getSystemJvmFlags() { diff --git a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/utils/ZHttpClient.java b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/utils/ZHttpClient.java index bc71777..bfa2b19 100644 --- a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/utils/ZHttpClient.java +++ b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/utils/ZHttpClient.java @@ -27,7 +27,7 @@ public class ZHttpClient { .version(HttpClient.Version.HTTP_1_1) .build(); - private static String BASE_URL = "http://87.120.187.36:1582"; + private static String BASE_URL = "https://api.zernmc.ru"; private static final AtomicBoolean useProxyMode = new AtomicBoolean(false); private static final AtomicBoolean proxyTested = new AtomicBoolean(false); @@ -41,7 +41,7 @@ public class ZHttpClient { } public enum ServiceType { - ZERN_SERVER("http://87.120.187.36:1582", true), + ZERN_SERVER("https://api.zernmc.ru", true), FABRIC_META("https://meta.fabricmc.net", false), FABRIC_MAVEN("https://maven.fabricmc.net", false), MOJANG_META("https://piston-meta.mojang.com", false), @@ -306,7 +306,7 @@ public class ZHttpClient { try { String encodedUrl = URLEncoder.encode(url, StandardCharsets.UTF_8); - String proxyUrl = BASE_URL + "/download?url=" + encodedUrl; + String proxyUrl = BASE_URL + "/proxy/download?url=" + encodedUrl; HttpRequest request = HttpRequest.newBuilder() .uri(URI.create(proxyUrl)) @@ -373,10 +373,6 @@ public class ZHttpClient { } public static String get(String endpoint) throws IOException, InterruptedException { - if (useProxyMode.get()) { - return proxyGet(endpoint); - } - try { HttpRequest.Builder requestBuilder = HttpRequest.newBuilder() .uri(URI.create(BASE_URL + endpoint)) @@ -402,33 +398,6 @@ public class ZHttpClient { } } - private static String proxyGet(String endpoint) throws IOException { - try { - HttpRequest.Builder requestBuilder = HttpRequest.newBuilder() - .uri(URI.create(BASE_URL + "/proxy" + endpoint)) - .timeout(Duration.ofSeconds(30)) - .header("User-Agent", "ZernMC-Launcher/1.0") - .GET(); - - String accessToken = AuthManager.getAccessToken(); - if (accessToken != null && !accessToken.equals("0")) { - requestBuilder.header("Authorization", "Bearer " + accessToken); - } - - HttpRequest request = requestBuilder.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("Proxy error: " + e.getMessage(), e); - } - } - public static List getFabricLoaderVersions() throws IOException, InterruptedException { String url = "https://meta.fabricmc.net/v2/versions/loader"; return parseFabricVersionsFromJson(getWithSmartProxy(url)); diff --git a/server/main.py b/server/main.py index 839288f..284309d 100644 --- a/server/main.py +++ b/server/main.py @@ -46,8 +46,8 @@ VERSIONS_DIR = BUILDS_DIR / "versions" # Mirror configuration LAUNCHER_MIRRORS = { - "main": "http://87.120.187.36:1582", - "mirror-1": "http://212.22.82.243:1582", + "main": "https://api.zernmc.ru", + "mirror-1": "https://api.zernmc.online", } # Server role: "main" or "mirror" @@ -57,12 +57,11 @@ SYNC_API_KEY = os.environ.get("SYNC_API_KEY", "changeme") # API key for mirror MASTER_KEY = os.environ.get("MASTER_KEY", "sashegdevsupeddevepta") # Master key for admin/mirror # IP Filtering Configuration -import os import middleware as mw # Only configure manually blocked IPs at import time # Public blocklists are loaded in lifespan (once, not per-worker) -MANUAL_BLOCKED_IPS = set(os.environ.get("BLOCKED_IPS", "").split(",")) - {""} +MANUAL_BLOCKED_IPS = set(os.environ.get("BLOCKED_IPS", "").split(",")) - {""} # noqa: F811 # Cache file for blocklist (load once) BLOCKLIST_CACHE_FILE = Path("data/blocklist_cache.txt") @@ -169,49 +168,17 @@ async def lifespan(app: FastAPI): logger.info("All packs ready. Server is running.") - # Mirror sync with main server + # Mirror sync with main server (full diff: hash check + delete stale) if SERVER_ROLE == "mirror" and MAIN_SERVER_URL: logger.info(f"Mirror mode: syncing from {MAIN_SERVER_URL}") try: async with httpx.AsyncClient() as client: - resp = await client.get(f"{MAIN_SERVER_URL}/launcher/version") - main_data = resp.json() - main_version = main_data.get("version") - logger.info(f"Main server version: {main_version}") - - # Get sync manifest with API key headers = {"X-Sync-Key": SYNC_API_KEY} - resp = await client.get(f"{MAIN_SERVER_URL}/launcher/sync/{main_version}", headers=headers) - if resp.status_code != 200: - logger.warning(f"Sync failed: {resp.status_code} - {resp.text}") - raise Exception(f"Sync auth failed: {resp.status_code}") - - sync_data = resp.json() - logger.info(f"Need to sync {len(sync_data.get('files', []))} files") - - # Download each file - for f in sync_data.get("files", []): - file_path = BUILDS_DIR / f["path"] - if not file_path.exists(): - logger.info(f"Syncing: {f['path']}") - file_path.parent.mkdir(parents=True, exist_ok=True) - - # Download file - file_url = f"{MAIN_SERVER_URL}/launcher/sync/{main_version}/file/{f['path']}" - resp = await client.get(file_url, headers=headers) - file_path.write_bytes(resp.content) - logger.debug(f"Downloaded: {f['path']}") - - # Delete removed files - for deleted_file in sync_data.get("delete", []): - del_path = BUILDS_DIR / deleted_file - if del_path.exists(): - del_path.unlink() - logger.info(f"Deleted: {deleted_file}") - - logger.info("Mirror sync complete") + result = await sync_mirror_full(client, headers, MAIN_SERVER_URL) + if result: + await sync_mirror_self_update(client, headers, MAIN_SERVER_URL) except Exception as e: - logger.warning(f"Mirror sync failed: {e}") + logger.warning(f"Initial mirror sync failed: {e}") # Scan launcher versions and generate meta logger.info("Scanning launcher versions...") @@ -237,49 +204,18 @@ async def lifespan(app: FastAPI): global proxy_client proxy_client = httpx.AsyncClient(timeout=60.0, follow_redirects=True) - # Start background sync task for mirrors + # Start background sync + self-update task for mirrors if SERVER_ROLE == "mirror" and MAIN_SERVER_URL: - import asyncio - async def periodic_sync(): - sync_interval = 7200 # 2 hours + sync_interval = 1800 # 30 minutes while True: await asyncio.sleep(sync_interval) try: logger.info("Periodic mirror sync started...") headers = {"X-Sync-Key": SYNC_API_KEY} - - resp = await proxy_client.get(f"{MAIN_SERVER_URL}/launcher/version") - main_data = resp.json() - main_version = main_data.get("version") - - resp = await proxy_client.get( - f"{MAIN_SERVER_URL}/launcher/sync/{main_version}", - headers=headers - ) - if resp.status_code != 200: - logger.warning(f"Periodic sync failed: {resp.status_code}") - continue - - sync_data = resp.json() - logger.info(f"Periodic sync: {len(sync_data.get('files', []))} files") - - for f in sync_data.get("files", []): - file_path = BUILDS_DIR / f["path"] - if not file_path.exists(): - file_path.parent.mkdir(parents=True, exist_ok=True) - file_url = f"{MAIN_SERVER_URL}/launcher/sync/{main_version}/file/{f['path']}" - resp = await proxy_client.get(file_url, headers=headers) - file_path.write_bytes(resp.content) - logger.debug(f"Synced: {f['path']}") - - for deleted_file in sync_data.get("delete", []): - del_path = BUILDS_DIR / deleted_file - if del_path.exists(): - del_path.unlink() - logger.info(f"Deleted: {deleted_file}") - - logger.info("Periodic mirror sync complete") + result = await sync_mirror_full(proxy_client, headers, MAIN_SERVER_URL) + if result: + await sync_mirror_self_update(proxy_client, headers, MAIN_SERVER_URL) except Exception as e: logger.warning(f"Periodic sync error: {e}") @@ -294,6 +230,236 @@ async def lifespan(app: FastAPI): logger.info("Server shutting down...") +# ====================== MIRROR SYNC HELPERS ====================== + + async def _calculate_file_hash_async(file_path: Path) -> Optional[str]: + """Calculate SHA256 hash of a file asynchronously""" + try: + loop = asyncio.get_running_loop() + def _hash(): + h = hashlib.sha256() + with open(file_path, "rb") as f: + while chunk := f.read(65536): + h.update(chunk) + return h.hexdigest() + return await loop.run_in_executor(None, _hash) + except Exception: + return None + + +async def sync_mirror_full( + client: httpx.AsyncClient, + headers: dict, + main_url: str, +) -> bool: + """Full mirror sync: compare by hash, download new/changed, delete stale. + Returns True if sync completed without critical errors.""" + try: + # Get latest version from main + resp = await client.get(f"{main_url}/launcher/version", timeout=15) + resp.raise_for_status() + main_data = resp.json() + main_version = main_data.get("version") + if not main_version: + logger.warning("sync_mirror_full: no version from main") + return False + logger.info(f"sync_mirror_full: main version = {main_version}") + + # Get sync manifest (full file list with hashes) + resp = await client.get( + f"{main_url}/launcher/sync/{main_version}", headers=headers, timeout=30 + ) + if resp.status_code != 200: + logger.warning(f"sync_mirror_full: manifest returned {resp.status_code}") + return False + + sync_data = resp.json() + server_files = sync_data.get("files", []) + logger.info(f"sync_mirror_full: {len(server_files)} files in manifest") + + # Build server file map + server_map = {} + for f in server_files: + server_map[f["path"]] = f + + # Scan local builds directory + local_files = {} + if BUILDS_DIR.exists(): + for fpath in BUILDS_DIR.rglob("*"): + if fpath.is_file() and fpath.name not in ("meta.json", "blocklist_cache.txt"): + rel = str(fpath.relative_to(BUILDS_DIR)) + local_files[rel] = "" + + # Download new/changed files + downloaded = 0 + for path, info in server_map.items(): + local_path = BUILDS_DIR / path + need_download = False + + if not local_path.exists(): + need_download = True + logger.info(f" New: {path}") + else: + local_hash = await _calculate_file_hash_async(local_path) + server_hash = info.get("hash", "").replace("sha256:", "") + if local_hash and local_hash != server_hash: + need_download = True + logger.info(f" Changed: {path}") + + if need_download: + local_path.parent.mkdir(parents=True, exist_ok=True) + file_url = f"{main_url}/launcher/sync/{main_version}/file/{path}" + try: + resp = await client.get(file_url, headers=headers, timeout=120) + resp.raise_for_status() + local_path.write_bytes(resp.content) + downloaded += 1 + logger.debug(f" Downloaded: {path}") + except Exception as e: + logger.warning(f" Failed to download {path}: {e}") + + # Delete files that exist locally but not on server + deleted = 0 + for local_path in list(local_files.keys()): + if local_path not in server_map: + del_path = BUILDS_DIR / local_path + try: + del_path.unlink() + deleted += 1 + logger.info(f" Deleted stale: {local_path}") + except Exception as e: + logger.warning(f" Failed to delete {local_path}: {e}") + + # Clean empty directories + for fpath in sorted(BUILDS_DIR.rglob("*"), key=lambda p: str(p), reverse=True): + if fpath.is_dir() and fpath != BUILDS_DIR: + try: + if not any(fpath.iterdir()): + fpath.rmdir() + logger.debug(f" Removed empty dir: {fpath.relative_to(BUILDS_DIR)}") + except Exception: + pass + + logger.info(f"sync_mirror_full: downloaded={downloaded}, deleted={deleted}") + return True + + except httpx.HTTPStatusError as e: + logger.warning(f"sync_mirror_full HTTP error: {e.response.status_code} - {e.response.text[:200]}") + return False + except Exception as e: + logger.warning(f"sync_mirror_full error: {e}") + return False + + +async def sync_mirror_self_update( + client: httpx.AsyncClient, + headers: dict, + main_url: str, +) -> bool: + """Check if the mirror's own launcher distribution needs updating. + Downloads new ZIP + extracts it, or replaces the bootstrap JAR.""" + try: + # Get current launcher version info + resp = await client.get(f"{main_url}/launcher/version", timeout=15) + resp.raise_for_status() + version_info = resp.json() + server_version = version_info.get("version", "") + if not server_version: + return False + + # Check current local version + local_version = "0.0.0" + build_version_file = BUILDS_DIR / "build.version" + if build_version_file.exists(): + local_version = build_version_file.read_text().strip() + + if local_version == server_version: + logger.debug("sync_mirror_self_update: already up to date") + return False + + logger.info(f"sync_mirror_self_update: local={local_version}, server={server_version}") + + # Parse versions + def parse_ver(v: str) -> tuple: + try: + return tuple(int(x) for x in v.split(".")) + except Exception: + return (0, 0, 0) + + if not (parse_ver(server_version) > parse_ver(local_version)): + logger.debug("sync_mirror_self_update: server version not newer") + return False + + # Get latest ZIP info from the version endpoint + zip_filename = None + zip_info = version_info.get("all_zips", []) + if zip_info: + zip_filename = zip_info[0].get("filename") + + if not zip_filename: + logger.warning("sync_mirror_self_update: no ZIP found in version info") + return False + + # Download the new launcher ZIP to a temp file + logger.info(f"sync_mirror_self_update: downloading {zip_filename}") + zip_url = f"{main_url}/launcher/download/zip/{zip_filename}" + zip_path = BUILDS_DIR / f".sync_{zip_filename}" + + resp = await client.get(zip_url, timeout=300) + resp.raise_for_status() + zip_path.write_bytes(resp.content) + logger.info(f"sync_mirror_self_update: downloaded {len(resp.content)} bytes") + + # Extract to a temp directory + import zipfile + import tempfile + import shutil + + with tempfile.TemporaryDirectory() as tmpdir: + extract_dir = Path(tmpdir) / "extracted" + extract_dir.mkdir(parents=True) + + with zipfile.ZipFile(zip_path) as zf: + zf.extractall(extract_dir) + + # Move extracted files to builds directory, replacing old ones + moved = 0 + for item in extract_dir.rglob("*"): + if item.is_file() and item.name not in ("meta.json", "build.version"): + rel = item.relative_to(extract_dir) + target = BUILDS_DIR / rel + target.parent.mkdir(parents=True, exist_ok=True) + try: + shutil.copy2(item, target) + moved += 1 + except Exception as e: + logger.warning(f" Failed to copy {rel}: {e}") + + logger.info(f"sync_mirror_self_update: moved {moved} files") + + # Clean up temp ZIP + zip_path.unlink(missing_ok=True) + + # Update version file + build_version_file.write_text(server_version) + logger.info(f"sync_mirror_self_update: updated to v{server_version}") + + # Regenerate meta for the new version + generate_launcher_builds_meta() + extract_new_format_versions() + + # Write a restart flag so the process manager can restart the server + restart_flag = BUILDS_DIR / ".restart-required" + restart_flag.write_text(server_version) + logger.warning(f"sync_mirror_self_update: restart flag written ({restart_flag})") + logger.warning("sync_mirror_self_update: server restart recommended to pick up new launcher files") + + return True + + except Exception as e: + logger.warning(f"sync_mirror_self_update error: {e}") + return False + # ====================== ШАБЛОН СТРАНИЦЫ АКТИВАЦИИ ====================== ACTIVATE_PASS_HTML = """ @@ -647,10 +813,12 @@ class CacheControlMiddleware: await self.app(scope, receive, send) return - async def send_wrapper(status, headers, *args, **kwargs): - cache_headers = [(b"cache-control", b"public, max-age=86400")] - headers = list(headers) + cache_headers - await send(status, headers, *args, **kwargs) + async def send_wrapper(message): + if message.get("type") == "http.response.start": + headers = message.get("headers", []) + cache_headers = [(b"cache-control", b"public, max-age=86400")] + message["headers"] = list(headers) + cache_headers + await send(message) await self.app(scope, receive, send_wrapper) @@ -840,7 +1008,7 @@ async def list_packs( with open(meta_path, 'r', encoding='utf-8') as f: meta = json.load(f) updated_at = meta.get("updated_at") - if updated_at and isinstance(updated_at, datetime): + if isinstance(updated_at, datetime): updated_at = updated_at.isoformat() desc_path = pack_dir / "description.txt" @@ -997,8 +1165,10 @@ async def get_pack_manifest(pack_name: str, request: Request, current_user: dict @app.get("/pack/{pack_name}/file/{file_path:path}") -async def get_pack_file(pack_name: str, file_path: str, request: Request): +async def get_pack_file(pack_name: str, file_path: str, request: Request, current_user: dict = Depends(get_current_user)): """Get a file from a pack""" + if not has_permission(current_user["role"], Permissions.DOWNLOAD_PACK): + raise HTTPException(403, "Requires active pass") full_path = PACKS_DIR / pack_name / file_path client_ip = request.client.host if request.client else None @@ -1044,8 +1214,6 @@ def get_current_launcher_version() -> str: def parse_version(version_str: str) -> dict: """Parse version string to determine if it's new or legacy format""" - import re - match = re.match(r'^(\d+)\.(\d+)\.(\d+)(.*)$', version_str) if not match: return {"major": 0, "minor": 0, "patch": 0, "suffix": version_str, "is_legacy": True} @@ -1073,7 +1241,6 @@ def is_new_format(filename: str) -> bool: def calculate_file_hash(file_path: Path) -> str: """Calculate SHA256 hash of a file""" - import hashlib hash_sha = hashlib.sha256() with open(file_path, 'rb') as f: while chunk := f.read(8192): @@ -1083,8 +1250,6 @@ def calculate_file_hash(file_path: Path) -> str: def generate_launcher_builds_meta(): """Generate meta.json in builds/ directory for incremental updates""" - import hashlib - version = get_current_launcher_version() if not version: return @@ -1167,7 +1332,6 @@ def scan_launcher_version(version: str) -> Optional[dict]: # Check cache first if meta_path.exists(): try: - import json with open(meta_path, 'r', encoding='utf-8') as f: return json.load(f) except Exception: @@ -1195,7 +1359,6 @@ def scan_launcher_version(version: str) -> Optional[dict]: # Save meta try: - import json with open(meta_path, 'w', encoding='utf-8') as f: json.dump(meta, f, indent=2) except Exception as e: @@ -1614,7 +1777,7 @@ async def get_launcher_file(version: str, file_path: str, request: Request): return await send_file_async(full_path, request, cache=True) -@app.get("/launcher/download/zip/{version}") +@app.get("/launcher/download/zip/version/{version}") async def download_launcher_zip_version(version: str, request: Request = None): """Download full ZIP for specific version (for new installs)""" zip_path = BUILDS_DIR / f"ZernMC-win-{version}.zip" @@ -2142,10 +2305,8 @@ if __name__ == "__main__": args = parse_args() if args.test: - import asyncio asyncio.run(run_test_mode()) elif args.sync: - import asyncio asyncio.run(run_sync_mode()) elif args.dev: run_development_mode(args.host, args.port, args.reload)