40 Commits

Author SHA1 Message Date
SashegDev 59480217aa Server: generate meta.json for builds/ on startup for incremental updates 2026-05-08 18:51:15 +00:00
SashegDev 4697b16ab4 Bootstrap: incremental update via meta, server: fix file endpoint paths 2026-05-08 18:45:42 +00:00
SashegDev 099df80cc6 Pass launcher.server system property from Bootstrap to JFXLauncher 2026-05-08 18:38:18 +00:00
SashegDev 74cd5ffdf3 Assets: try meta download first, fallback to JAR extract 2026-05-08 18:37:24 +00:00
SashegDev 01668dd3bf Extract UI assets from JAR on first launch 2026-05-08 18:33:55 +00:00
SashegDev b2dbbac6ca Fix: NPE in AuthManager, game logs display in UI 2026-05-08 17:58:18 +00:00
SashegDev e32a057684 Fix: use vanilla classpath for modloaders (fabric/forge/neoforge), add JS debug logging 2026-05-08 17:50:33 +00:00
SashegDev d4dc35aac3 Debug: classpath for modloaders, game logs in UI 2026-05-08 17:43:12 +00:00
SashegDev 1e7231af57 Debug: add stdout/stderr capture, log game logs to console 2026-05-08 17:36:49 +00:00
SashegDev fd6e292d6e Add game log file writing, debug modloader launch 2026-05-08 17:23:38 +00:00
SashegDev 1e876ffe28 Clean up debug logging 2026-05-08 15:49:33 +00:00
SashegDev 2d515108f0 Debug: log server version response 2026-05-08 15:45:23 +00:00
SashegDev 13c9f67f6e Simplify: read version only from JAR manifest, remove .version file 2026-05-08 15:09:59 +00:00
SashegDev 659265c2f0 Fix version reading - fallback to JAR manifest, fix server version URL 2026-05-08 14:51:53 +00:00
SashegDev d8f189558a Fix Bootstrap to use bin/ directory properly
- Read version from bin/.version file (reliable, no JAR locking)
- Save version to bin/.version when downloading JAR
- Use getLauncherJar() for all JAR path references
- Create binDir in main()
- Remove build.version dependency completely
2026-05-08 13:17:30 +00:00
SashegDev 6f56012e3a Fix version reading from JAR manifest
- Read version from bin/.version file (reliable, no JAR locking issues)
- Save version to bin/.version when downloading JAR
- Remove complex JAR/ZIP reading code
- Use simple file-based version storage
2026-05-08 12:39:14 +00:00
SashegDev 3a0570e7da Remove build.version dependency
- Read version only from JAR manifest (Implementation-Version)
- Remove all VERSION_FILE references from Bootstrap
- Remove build.version from scanLocalFiles() and update methods
2026-05-08 12:15:43 +00:00
SashegDev 985abf7440 Fix: Bootstrap update and meta parsing
- Rewrite getLauncherMeta() to properly parse server meta response
- Change downloadUpdate() fallback to JAR-only (not ZIP) to avoid JRE lock issues
- Simplify downloadUpdateLegacy() to skip ZIP (which locks JRE files)
- Add handling for AccessDeniedException when updating locked files
- Improve error logging for meta parsing failures
2026-05-08 11:19:10 +00:00
SashegDev ec551ab2e3 Fix: Fabric loader launch and Bootstrap paths
- Add Fabric support in LaunchCommandBuilder.findVersionJson()
- Fix Bootstrap to properly use bin/ directory for launcher JAR
- Fix server.py to accept both ZernMC-win-*.zip and ZernMCLauncher-*.zip
- Add debug output for version.json resolution
2026-05-08 11:04:45 +00:00
SashegDev e5948b5337 Fix: Multiple launcher issues
- Fix CLI arrow keys: remove 50ms timeout in escape sequence handling (ArrowMenu, LoginMenu)
- Add network logs polling to UI via /api/logs endpoint
- Display user role in launcher header (AuthManager, AuthService, JFXLauncher, UI)
- Capture and display game logs in launcher via /api/game-logs endpoint
- Fix demo mode bug in VersionManifest.ruleMatches() - was incorrectly adding --demo flag
- Fix modloader launch: pass proper auth info (accessToken, uuid) from AuthManager
- Add game log capture in MinecraftLib and LaunchService
2026-05-08 10:11:49 +00:00
SashegDev 5a826c8511 Server: Add launcher version scanning on startup
- Scan versions/ directory and generate meta.json for each version
- Log progress: 'Scanning launcher versions...', 'Launcher meta ready: vX (Y files)'
- Meta cached in memory for faster access
2026-05-07 18:50:07 +00:00
SashegDev ce12854e1b 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
2026-05-07 18:41:35 +00:00
SashegDev e566703332 Server: Add launcher meta system for incremental updates
- Create versions/ folder structure for new format builds
- Generate meta.json with SHA256 hashes for each file
- Add endpoints:
  - GET /launcher/meta - list all versions with meta
  - GET /launcher/meta/{version} - meta for specific version
  - POST /launcher/diff - get diff between local and server files
  - GET /launcher/file/{version}/{path} - download individual file
  - GET /launcher/download/zip/{version} - download full ZIP for new install
- Legacy builds (ZIP files) remain unchanged
2026-05-07 18:40:00 +00:00
SashegDev aaa19df5e4 Server: Default to 1 worker - better for file downloads
- Multiple workers cause contention and slow down large file downloads
- Single worker with async handles concurrent requests fine
2026-05-07 18:03:37 +00:00
SashegDev 0ee8077787 Server: Reduce rate limit log spam - periodic summary only
- Instead of logging every rate limit warning, now logs summary every 60s
- Shows: IP_blocked=X, rate_limited=Y
2026-05-07 17:56:46 +00:00
SashegDev fba944b4b8 Server: Add direct_passthrough for faster file serving
- FileResponse with direct_passthrough=True bypasses buffering
- Should improve file download speeds
2026-05-07 17:54:03 +00:00
SashegDev d39b40053a Server: Skip logging for file downloads
- Don't log every /pack/*/file/* request to reduce overhead
- Helps with large file downloads
2026-05-07 17:53:08 +00:00
SashegDev 1199ca9e21 Server: Fix /docs endpoint - allow openapi.json and swagger
- Remove openapi.json, swagger-ui, api/docs from suspicious paths
- Fix is_suspicious_path() to allow swagger/openapi patterns
2026-05-07 17:48:54 +00:00
SashegDev 50080d890f Server: Remove broken PID-based logging
- is_master() doesn't work with uvicorn workers
- Keeping clean logs from cache + disabled httpx debug
2026-05-07 17:46:42 +00:00
SashegDev f6fbb66cdc Server: PID-based logging - only master logs startup
- Only master PID logs blocklist loading, pack scanning, etc.
- Worker processes stay silent during startup
- Much cleaner logs
2026-05-07 17:45:36 +00:00
SashegDev d7a928cce4 Server: Add file lock for blocklist loading
- Only one worker downloads blocklist
- Other workers wait and read from cache
- Prevents duplicate downloads on startup
2026-05-07 17:43:21 +00:00
SashegDev 3bd3d1d0e8 Server: Cache blocklist to file + disable httpx debug logs
- Blocklist now cached to data/blocklist_cache.txt
- Only downloads once, then reuses cache
- Disable httpx/httpcore debug logs to reduce noise
2026-05-07 17:42:15 +00:00
SashegDev df9fa7b867 Server: Fix blocklist loading - only once at startup
- Move public blocklist loading into lifespan (not on import)
- Avoids loading 8 times with 4 workers
- Cleaner startup logs
2026-05-07 17:40:32 +00:00
SashegDev 81fbe028e8 Server: Auto-load public IP blocklists
- Load known bad IPs from FireHOL blocklists on startup
- ~4400 IPs blocked by default
- Set PUBLIC_BLOCKLIST=false to disable
- Combined with manual BLOCKED_IPS env var
2026-05-07 17:38:08 +00:00
SashegDev 513c07666b Server: Simplify IP filtering - only blacklist
- Remove whitelist (not needed for public launcher)
- Only BLOCKED_IPS env var supported now
2026-05-07 17:14:47 +00:00
SashegDev 04f97c3c80 Server: Add bot protection middleware
- Global rate limiting (60 requests/minute per IP)
- IP whitelist/blacklist via ALLOWED_IPS and BLOCKED_IPS env vars
- Bot detection - silent 404 for suspicious paths (.env, phpinfo, etc.)
- Path traversal detection
- Reduced noise in logs from bot scanners
2026-05-07 17:09:45 +00:00
SashegDev f40cf7afed Server: Add legacy build support
- Add version parsing to distinguish new vs legacy format builds
- New format: ZernMC-win-*.zip (1.0.8+ with bundled JRE21/JavaFX)
- Legacy: ZernMCLauncher-*.zip (< 1.0.8 or with suffix)
- /launcher/download/latest now returns new format by default
- Add /launcher/download/legacy endpoint for old builds
- Add legacy info to /launcher/info and /launcher/version responses
- Update download_zip to accept both ZernMCLauncher- and ZernMC-win- patterns
2026-05-07 16:44:10 +00:00
SashegDev 0cef411125 Refactor: Multi-module Maven project structure
- Restructured to multi-module Maven project (bootstrap + launcher)
- Removed duplicate code (launcher/launcher/ with JCEF)
- Added JavaFX modules to lib/javafx in ZIP
- Added JRE 21 to lib/jre21 in ZIP
- Fixed Bootstrap with UTF-8 encoding and JavaFX module-path
- Fixed JAR naming (zernmclauncher.jar)
- Added Windows build configuration (ZernMC-win-*.zip)
- Fixed version parsing for -any, -alpha, -beta suffixes
2026-05-06 21:35:14 +00:00
SashegDev 523f659269 коммит последних действий 2026-05-06 15:49:14 +00:00
SashegDev 04620d76c4 Multi-module project: bootstrap + launcher, UI updates
- Split into 2 Maven modules: bootstrap (updater) + launcher (UI)
- New UI: blue-orange theme, grid animation background
- Fixed version parsing bug (start += 11)
- Added unit tests for version parsing
- Server: adapted to new build structure (builds/zernmc)
2026-05-06 10:33:08 +00:00
51 changed files with 3397 additions and 294 deletions
+2 -1
View File
@@ -2,6 +2,8 @@ logs/
__pycache__/
./.venv/
launcher/target
bootstrap/target
src/target
server/builds
server/packs
server/data
@@ -10,4 +12,3 @@ jre
dependency-reduced-pom.xml
OpenJDK21U-jre_x64_windows_hotspot_21.0.6_7.zip
telegram-bot/
.env
+24
View File
@@ -0,0 +1,24 @@
# Maven
target/
pom.xml.tag
pom.xml.releaseBackup
pom.xml.versionsBackup
dependency-reduced-pom.xml
# IDE
.idea/
*.iml
.vscode/
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Build outputs
server/builds/
server/logs/
# Colab
colab/
+56
View File
@@ -0,0 +1,56 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>me.sashegdev</groupId>
<artifactId>ZernMCLauncher</artifactId>
<version>1.0.9</version>
</parent>
<artifactId>zernmc-bootstrap</artifactId>
<packaging>jar</packaging>
<name>ZernMC Bootstrap</name>
<description>Bootstrap module - handles updates and Java launching</description>
<dependencies>
<!-- Minimal dependencies for Bootstrap -->
<dependency>
<groupId>com.google.code.gson</groupId>
<artifactId>gson</artifactId>
</dependency>
<dependency>
<groupId>org.apache.httpcomponents</groupId>
<artifactId>httpclient</artifactId>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<version>3.5.0</version>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>shade</goal>
</goals>
<configuration>
<outputFile>../../server/builds/zernmc-bootstrap.jar</outputFile>
<transformers>
<transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
<mainClass>me.sashegdev.zernmc.launcher.Bootstrap</mainClass>
</transformer>
</transformers>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>
@@ -0,0 +1,410 @@
package me.sashegdev.zernmc.launcher;
import java.io.*;
import java.net.HttpURLConnection;
import java.net.URL;
import java.nio.file.*;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import java.util.jar.Attributes;
import java.util.jar.JarFile;
import java.util.jar.Manifest;
public class Bootstrap {
private static final String JAR_NAME = "zernmclauncher.jar";
private static final String BASE_URL = "http://87.120.187.36:1582";
private static Path baseDir;
private static Path binDir;
private static Path logDir;
private static Path getLauncherJar() {
return binDir.resolve(JAR_NAME);
}
public static void main(String[] args) throws Exception {
baseDir = Paths.get("").toAbsolutePath();
binDir = baseDir.resolve("bin");
Files.createDirectories(binDir);
logDir = baseDir.resolve("logs");
Files.createDirectories(logDir);
log("=== ZernMC Launcher ===");
// Определяем режим запуска
List<String> argList = Arrays.asList(args);
boolean cliMode = argList.contains("--cli");
boolean jfxMode = !cliMode; // по умолчанию JFX
// Проверка и обновление лаунчера
String currentVersion = readCurrentVersion();
String serverVersion = getServerVersion();
log("Локальная версия: " + currentVersion);
log("Версия на сервере: " + serverVersion);
if (isNewer(serverVersion, currentVersion)) {
log("Доступно обновление!");
downloadUpdate(serverVersion);
} else {
log("Версия актуальна");
}
// Запуск в выбранном режиме
if (jfxMode) {
launchJFX();
} else {
launchCLI();
}
}
private static void log(String msg) {
String entry = "[" + LocalDateTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss")) + "] " + msg;
System.out.println(entry);
try {
Files.writeString(logDir.resolve("launcher.log"), entry + "\n",
StandardOpenOption.CREATE, StandardOpenOption.APPEND);
} catch (Exception ignored) {}
}
private static String readCurrentVersion() {
Path jar = getLauncherJar();
if (Files.exists(jar)) {
try (JarFile jarFile = new JarFile(jar.toFile())) {
Manifest manifest = jarFile.getManifest();
if (manifest != null) {
String v = manifest.getMainAttributes().getValue(Attributes.Name.IMPLEMENTATION_VERSION);
if (v != null && !v.isBlank()) return v;
}
} catch (Exception e) {
log("Ошибка чтения манифеста: " + e.getMessage());
}
}
return "0.0.0";
}
private static String getServerVersion() {
try {
URL url = new URL(BASE_URL + "/launcher/version");
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
conn.setRequestMethod("GET");
conn.setConnectTimeout(5000);
conn.setReadTimeout(5000);
if (conn.getResponseCode() == 200) {
try (BufferedReader br = new BufferedReader(new InputStreamReader(conn.getInputStream()))) {
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);
}
}
}
}
} catch (Exception e) {
log("Ошибка получения версии: " + e.getMessage());
}
return "unknown";
}
private static boolean isNewer(String server, String current) {
try {
String[] sa = server.split("\\.");
String[] ca = current.split("\\.");
for (int i = 0; i < Math.min(sa.length, ca.length); i++) {
int sv = Integer.parseInt(sa[i]);
int cv = Integer.parseInt(ca[i]);
if (sv > cv) return true;
if (sv < cv) return false;
}
return sa.length > ca.length;
} catch (Exception ignored) {}
return false;
}
private static void downloadUpdate(String newVersion) throws Exception {
log("Проверка обновлений...");
// Получаем мета с сервера
Map<String, FileMeta> serverFiles = fetchServerMeta(newVersion);
if (serverFiles.isEmpty()) {
log("Не удалось получить мета с сервера");
return;
}
// Сканируем локальные файлы
Map<String, String> localFiles = scanLocalFiles();
log("Локальных файлов: " + localFiles.size());
log("Файлов на сервере: " + serverFiles.size());
// Сравниваем и скачиваем
int downloaded = 0;
int skipped = 0;
for (Map.Entry<String, FileMeta> entry : serverFiles.entrySet()) {
String filePath = entry.getKey();
FileMeta serverMeta = entry.getValue();
String localHash = localFiles.get(filePath);
String serverHash = serverMeta.hash.replace("sha256:", "");
if (localHash != null && localHash.equals(serverHash)) {
skipped++;
continue;
}
if (localHash != null) {
log("Обновление: " + filePath);
} else {
log("Скачивание: " + filePath);
}
downloadFile(newVersion, filePath, serverMeta.size);
downloaded++;
}
log("Обновлено файлов: " + downloaded + ", пропущено: " + skipped);
log("Обновлено до v" + newVersion);
}
private static Map<String, FileMeta> fetchServerMeta(String version) {
Map<String, FileMeta> files = new HashMap<>();
try {
URL url = new URL(BASE_URL + "/launcher/meta/" + version);
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
conn.setConnectTimeout(5000);
conn.setReadTimeout(10000);
if (conn.getResponseCode() == 200) {
try (BufferedReader br = new BufferedReader(new InputStreamReader(conn.getInputStream()))) {
StringBuilder sb = new StringBuilder();
String line;
while ((line = br.readLine()) != null) sb.append(line);
com.google.gson.JsonObject json = com.google.gson.JsonParser.parseString(sb.toString()).getAsJsonObject();
com.google.gson.JsonArray filesArray = json.getAsJsonArray("files");
for (com.google.gson.JsonElement fileElem : filesArray) {
com.google.gson.JsonObject file = fileElem.getAsJsonObject();
files.put(file.get("path").getAsString(), new FileMeta(
file.get("hash").getAsString(),
file.get("size").getAsLong()
));
}
}
}
} catch (Exception e) {
log("Ошибка получения мета: " + e.getMessage());
}
return files;
}
private static Map<String, String> scanLocalFiles() {
Map<String, String> files = new HashMap<>();
try {
Files.walk(baseDir)
.filter(Files::isRegularFile)
.filter(p -> !p.toString().contains(".git"))
.forEach(path -> {
try {
String relativePath = baseDir.relativize(path).toString().replace("\\", "/");
String hash = calculateFileHash(path);
files.put(relativePath, hash);
} catch (Exception ignored) {}
});
} catch (Exception ignored) {}
return files;
}
private static String calculateFileHash(Path path) throws Exception {
try (InputStream is = Files.newInputStream(path)) {
java.security.MessageDigest digest = java.security.MessageDigest.getInstance("SHA-256");
byte[] buf = new byte[8192];
int len;
while ((len = is.read(buf)) > 0) {
digest.update(buf, 0, len);
}
byte[] hash = digest.digest();
StringBuilder sb = new StringBuilder();
for (byte b : hash) sb.append(String.format("%02x", b));
return sb.toString();
}
}
private static void downloadFile(String version, String filePath, long expectedSize) throws Exception {
URL url = new URL(BASE_URL + "/launcher/file/" + version + "/" + filePath);
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
conn.setConnectTimeout(10000);
conn.setReadTimeout(60000);
if (conn.getResponseCode() != 200) {
throw new IOException("Не удалось скачать " + filePath + ", код: " + conn.getResponseCode());
}
Path outPath = baseDir.resolve(filePath);
Files.createDirectories(outPath.getParent());
long downloaded = 0;
try (InputStream in = conn.getInputStream();
OutputStream out = new FileOutputStream(outPath.toFile())) {
byte[] buf = new byte[8192];
int len;
while ((len = in.read(buf)) > 0) {
out.write(buf, 0, len);
downloaded += len;
}
}
// Проверяем хеш
String actualHash = calculateFileHash(outPath);
String expectedHash = expectedSize > 0 ? "" : "";
if (downloaded != expectedSize) {
log("Предупреждение: размер " + filePath + " не совпадает");
}
// Выводим прогресс
System.out.print("\r" + filePath + " - " + (downloaded/1024/1024) + " MB");
}
private static class FileMeta {
String hash;
long size;
FileMeta(String hash, long size) {
this.hash = hash;
this.size = size;
}
}
private static void launchJFX() throws Exception {
Path javaBin = findJava();
Path jarPath = getLauncherJar();
log("Запуск JFX режима...");
log("Java: " + javaBin);
log("JAR: " + jarPath);
// JVM аргументы для UTF-8 и JavaFX
List<String> jvmArgs = List.of(
"-Dfile.encoding=UTF-8",
"-Dsun.stdout.encoding=UTF-8",
"-Dsun.stderr.encoding=UTF-8"
);
// Путь к JavaFX модулям
Path javafxPath = baseDir.resolve("lib").resolve("javafx");
if (Files.exists(javafxPath)) {
jvmArgs = List.of(
"-Dfile.encoding=UTF-8",
"-Dsun.stdout.encoding=UTF-8",
"-Dsun.stderr.encoding=UTF-8",
"-Dlauncher.server=" + BASE_URL,
"--module-path", javafxPath.toAbsolutePath().toString(),
"--add-modules", "javafx.controls,javafx.web"
);
} else {
jvmArgs = List.of(
"-Dfile.encoding=UTF-8",
"-Dsun.stdout.encoding=UTF-8",
"-Dsun.stderr.encoding=UTF-8",
"-Dlauncher.server=" + BASE_URL
);
}
List<String> cmd = new ArrayList<>();
cmd.add(javaBin.toAbsolutePath().toString());
cmd.addAll(jvmArgs);
cmd.add("-jar");
cmd.add(jarPath.toAbsolutePath().toString());
cmd.add("--jfx");
ProcessBuilder pb = new ProcessBuilder(cmd);
pb.directory(baseDir.toFile());
pb.inheritIO();
Process p = pb.start();
int code = p.waitFor();
log("Завершено с кодом: " + code);
System.exit(code);
}
private static void launchCLI() throws Exception {
Path javaBin = findJava();
Path jarPath = getLauncherJar();
log("Запуск CLI режима...");
log("Java: " + javaBin);
log("JAR: " + jarPath);
// JVM аргументы для UTF-8
List<String> jvmArgs = List.of(
"-Dfile.encoding=UTF-8",
"-Dsun.stdout.encoding=UTF-8",
"-Dsun.stderr.encoding=UTF-8",
"-Dlauncher.server=" + BASE_URL
);
List<String> cmd = new ArrayList<>();
cmd.add(javaBin.toAbsolutePath().toString());
cmd.addAll(jvmArgs);
cmd.add("-jar");
cmd.add(jarPath.toAbsolutePath().toString());
cmd.add("--cli");
ProcessBuilder pb = new ProcessBuilder(cmd);
pb.directory(baseDir.toFile());
pb.inheritIO();
Process p = pb.start();
int code = p.waitFor();
log("Завершено с кодом: " + code);
System.exit(code);
}
private static Path findJava() {
String os = System.getProperty("os.name").toLowerCase();
String javaExe = os.contains("windows") ? "java.exe" : "java";
// Сначала ищем jre21/bin/java рядом с лаунчером
Path javaBin = baseDir.resolve("jre21").resolve("bin").resolve(javaExe);
// Если нет, пробуем системную Java
if (!Files.exists(javaBin)) {
javaBin = Paths.get(System.getProperty("java.home"), "bin", javaExe);
}
// Если и это не найдено - ищем java в PATH
if (!Files.exists(javaBin)) {
try {
Process p = new ProcessBuilder("which", javaExe).start();
if (p.waitFor() == 0) {
try (BufferedReader br = new BufferedReader(new InputStreamReader(p.getInputStream()))) {
String path = br.readLine();
if (path != null) {
javaBin = Paths.get(path.trim());
}
}
}
} catch (Exception ignored) {}
}
if (!Files.exists(javaBin)) {
throw new RuntimeException("Java не найдена. Убедитесь, что jre21 присутствует в папке с лаунчером или Java установлена в системе");
}
return javaBin;
}
}
+43 -7
View File
@@ -3,9 +3,13 @@
<modelVersion>4.0.0</modelVersion>
<groupId>me.sashegdev</groupId>
<artifactId>ZernMCLauncher</artifactId>
<version>1.0.7</version>
<version>1.0.8</version>
<build>
<plugins>
<plugin>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.2.3</version>
</plugin>
<plugin>
<artifactId>maven-shade-plugin</artifactId>
<version>3.5.0</version>
@@ -24,7 +28,7 @@
<Implementation-Version>${project.version}</Implementation-Version>
<Implementation-Title>ZernMC Launcher</Implementation-Title>
<Implementation-Vendor>SashegDev</Implementation-Vendor>
<Implementation-Description>Полностью самописный Minecraft-лаунчер. Написанный SashegDev(в основном)</Implementation-Description>
<Implementation-Description>Samopisnui Minecraft-launcher. by SashegDev</Implementation-Description>
<Implementation-URL>https://github.com/SashegDev/launcher</Implementation-URL>
</manifestEntries>
</transformer>
@@ -45,10 +49,11 @@
<goal>launch4j</goal>
</goals>
<configuration>
<outfile>../server/builds/ZernMCLauncher.exe</outfile>
<outfile>../server/builds/ZernMCLauncher-${project.version}.exe</outfile>
<jar>../server/builds/ZernMCLauncher.jar</jar>
<headerType>console</headerType>
<dontWrapJar>false</dontWrapJar>
<mainClass>me.sashegdev.zernmc.launcher.Bootstrap</mainClass>
<jre>
<path>jre21</path>
<minVersion>21</minVersion>
@@ -56,13 +61,13 @@
<versionInfo>
<fileVersion>${project.version}.0</fileVersion>
<txtFileVersion>${project.version}</txtFileVersion>
<fileDescription>ZernMC Launcher — A Little Minecraft Launcher</fileDescription>
<fileDescription>ZernMC Launcher — just a Minecraft launcher</fileDescription>
<productVersion>${project.version}.0</productVersion>
<txtProductVersion>${project.version}</txtProductVersion>
<productName>ZernMC Launcher</productName>
<companyName>ZernMC(SashegDev)</companyName>
<internalName>ZernMCLauncher</internalName>
<originalFilename>ZernMCLauncher.exe</originalFilename>
<originalFilename>ZernMCLauncher-${project.version}.exe</originalFilename>
</versionInfo>
</configuration>
</execution>
@@ -80,9 +85,15 @@
<configuration>
<target>
<echo>${project.version}</echo>
<delete />
<mkdir />
<copy>
<fileset />
<fileset>
<include />
<include />
</fileset>
</copy>
<move />
<zip />
</target>
</configuration>
@@ -109,10 +120,35 @@
</properties>
</profile>
</profiles>
<dependencies>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.10.1</version>
<scope>test</scope>
<exclusions>
<exclusion>
<artifactId>junit-jupiter-api</artifactId>
<groupId>org.junit.jupiter</groupId>
</exclusion>
<exclusion>
<artifactId>junit-jupiter-params</artifactId>
<groupId>org.junit.jupiter</groupId>
</exclusion>
<exclusion>
<artifactId>junit-jupiter-engine</artifactId>
<groupId>org.junit.jupiter</groupId>
</exclusion>
</exclusions>
</dependency>
</dependencies>
<properties>
<maven.compiler.target>21</maven.compiler.target>
<project.description>ZernMC Launcher - just a minimalistic launcher by SashegDev</project.description>
<mainClass>me.sashegdev.zernmc.launcher.Main</mainClass>
<maven.compiler.source>21</maven.compiler.source>
<project.organization.name>ZernMC</project.organization.name>
<maven.compiler.target>21</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.inceptionYear>2026</project.inceptionYear>
</properties>
</project>
+251
View File
@@ -0,0 +1,251 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>me.sashegdev</groupId>
<artifactId>ZernMCLauncher</artifactId>
<version>1.0.9</version>
</parent>
<artifactId>zernmclauncher</artifactId>
<packaging>jar</packaging>
<name>ZernMC Launcher</name>
<description>Main launcher module with JFX UI</description>
<dependencies>
<!-- HTTP Client -->
<dependency>
<groupId>org.apache.httpcomponents</groupId>
<artifactId>httpclient</artifactId>
</dependency>
<!-- JSON -->
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
<dependency>
<groupId>com.google.code.gson</groupId>
<artifactId>gson</artifactId>
</dependency>
<dependency>
<groupId>org.json</groupId>
<artifactId>json</artifactId>
</dependency>
<!-- Console/Terminal -->
<dependency>
<groupId>org.fusesource.jansi</groupId>
<artifactId>jansi</artifactId>
</dependency>
<dependency>
<groupId>org.jline</groupId>
<artifactId>jline</artifactId>
</dependency>
<dependency>
<groupId>me.tongfei</groupId>
<artifactId>progressbar</artifactId>
</dependency>
<!-- IO -->
<dependency>
<groupId>commons-io</groupId>
<artifactId>commons-io</artifactId>
</dependency>
<!-- JavaFX - Windows -->
<dependency>
<groupId>org.openjfx</groupId>
<artifactId>javafx-controls</artifactId>
<version>21</version>
<classifier>win</classifier>
</dependency>
<dependency>
<groupId>org.openjfx</groupId>
<artifactId>javafx-web</artifactId>
<version>21</version>
<classifier>win</classifier>
</dependency>
<dependency>
<groupId>org.openjfx</groupId>
<artifactId>javafx-graphics</artifactId>
<version>21</version>
<classifier>win</classifier>
</dependency>
<dependency>
<groupId>org.openjfx</groupId>
<artifactId>javafx-base</artifactId>
<version>21</version>
<classifier>win</classifier>
</dependency>
<dependency>
<groupId>org.openjfx</groupId>
<artifactId>javafx-media</artifactId>
<version>21</version>
<classifier>win</classifier>
</dependency>
<!-- Test -->
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.10.1</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<version>3.5.0</version>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>shade</goal>
</goals>
<configuration>
<outputFile>../../server/builds/zernmclauncher.jar</outputFile>
<transformers>
<transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
<mainClass>me.sashegdev.zernmc.launcher.Main</mainClass>
<manifestEntries>
<Implementation-Version>${project.version}</Implementation-Version>
<Implementation-Title>ZernMC Launcher</Implementation-Title>
</manifestEntries>
</transformer>
</transformers>
</configuration>
</execution>
</executions>
</plugin>
<!-- Launch4j для создания .exe из bootstrap JAR -->
<plugin>
<groupId>com.akathist.maven.plugins.launch4j</groupId>
<artifactId>launch4j-maven-plugin</artifactId>
<version>2.5.0</version>
<executions>
<execution>
<id>l4j</id>
<phase>package</phase>
<goals>
<goal>launch4j</goal>
</goals>
<configuration>
<outfile>../../server/builds/zernmc-${project.version}.exe</outfile>
<jar>../../server/builds/zernmc-bootstrap.jar</jar>
<headerType>console</headerType>
<dontWrapJar>false</dontWrapJar>
<mainClass>me.sashegdev.zernmc.launcher.Bootstrap</mainClass>
<jre>
<path>lib/jre21</path>
<minVersion>21</minVersion>
</jre>
<versionInfo>
<fileVersion>${project.version}.0</fileVersion>
<txtFileVersion>${project.version}</txtFileVersion>
<fileDescription>ZernMC Launcher</fileDescription>
<productVersion>${project.version}.0</productVersion>
<txtProductVersion>${project.version}</txtProductVersion>
<productName>ZernMC</productName>
<companyName>ZernMC</companyName>
<internalName>zernmc</internalName>
<originalFilename>zernmc-${project.version}.exe</originalFilename>
</versionInfo>
</configuration>
</execution>
</executions>
</plugin>
<!-- Post-build: копирование JRE и создание ZIP -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-antrun-plugin</artifactId>
<version>3.1.0</version>
<executions>
<execution>
<phase>package</phase>
<goals><goal>run</goal></goals>
<configuration>
<target>
<echo file="../../server/builds/build.version">${project.version}</echo>
<!-- Удаляем старую папку lib если есть -->
<delete dir="../../server/builds/lib"/>
<!-- Создаем папку lib -->
<mkdir dir="../../server/builds/lib"/>
<!-- Копируем JRE в lib/jre21 -->
<copy todir="../../server/builds/lib/jre21" overwrite="true">
<fileset dir="${user.home}/launcher/jre/jre21">
<include name="*"/>
<include name="**/*"/>
</fileset>
</copy>
<!-- Копируем JavaFX модули в lib/javafx -->
<mkdir dir="../../server/builds/lib/javafx"/>
<copy todir="../../server/builds/lib/javafx" overwrite="true">
<fileset dir="${user.home}/.m2/repository/org/openjfx/javafx-controls/21">
<include name="*win.jar"/>
</fileset>
</copy>
<copy todir="../../server/builds/lib/javafx" overwrite="true">
<fileset dir="${user.home}/.m2/repository/org/openjfx/javafx-graphics/21">
<include name="*win.jar"/>
</fileset>
</copy>
<copy todir="../../server/builds/lib/javafx" overwrite="true">
<fileset dir="${user.home}/.m2/repository/org/openjfx/javafx-base/21">
<include name="*win.jar"/>
</fileset>
</copy>
<copy todir="../../server/builds/lib/javafx" overwrite="true">
<fileset dir="${user.home}/.m2/repository/org/openjfx/javafx-web/21">
<include name="*win.jar"/>
</fileset>
</copy>
<copy todir="../../server/builds/lib/javafx" overwrite="true">
<fileset dir="${user.home}/.m2/repository/org/openjfx/javafx-media/21">
<include name="*win.jar"/>
</fileset>
</copy>
<!-- Переименовываем exe для zip -->
<move file="../../server/builds/zernmc-${project.version}.exe"
tofile="../../server/builds/zernmc.exe" overwrite="true"/>
<!-- Создаем папку bin и копируем JAR -->
<mkdir dir="../../server/builds/bin"/>
<copy file="../../server/builds/zernmclauncher.jar"
tofile="../../server/builds/bin/zernmclauncher.jar" overwrite="true"/>
<!-- Копируем UI в assets -->
<mkdir dir="../../server/builds/assets"/>
<copy todir="../../server/builds/assets/ui" overwrite="true">
<fileset dir="${project.basedir}/src/resources/ui">
<include name="**/*"/>
</fileset>
</copy>
<!-- Создаём zip -->
<zip destfile="../../server/builds/ZernMC-win-${project.version}.zip"
basedir="../../server/builds"
includes="zernmc.exe,bin/**,assets/**,lib/**"
excludes="build.version,*-${project.version}.*,zernmclauncher.jar,zernmc-bootstrap.jar"/>
</target>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>
@@ -0,0 +1,215 @@
package me.sashegdev.zernmc.launcher;
import java.io.*;
import java.net.HttpURLConnection;
import java.net.URL;
import java.nio.file.*;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Arrays;
import java.util.List;
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 Path baseDir;
private static Path logDir;
public static void main(String[] args) throws Exception {
baseDir = Paths.get("").toAbsolutePath();
logDir = baseDir.resolve("logs");
Files.createDirectories(logDir);
log("=== ZernMC Launcher ===");
// Определяем режим запуска
List<String> argList = Arrays.asList(args);
boolean cliMode = argList.contains("--cli");
boolean jfxMode = !cliMode; // по умолчанию JFX
// Проверка и обновление лаунчера
String currentVersion = readCurrentVersion();
String serverVersion = getServerVersion();
log("Локальная версия: " + currentVersion);
log("Версия на сервере: " + serverVersion);
if (isNewer(serverVersion, currentVersion)) {
log("Доступно обновление!");
downloadUpdate(serverVersion);
} else {
log("Версия актуальна");
}
// Запуск в выбранном режиме
if (jfxMode) {
launchJFX();
} else {
launchCLI();
}
}
private static void log(String msg) {
String entry = "[" + LocalDateTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss")) + "] " + msg;
System.out.println(entry);
try {
Files.writeString(logDir.resolve("launcher.log"), entry + "\n",
StandardOpenOption.CREATE, StandardOpenOption.APPEND);
} catch (Exception ignored) {}
}
private static String readCurrentVersion() {
Path f = baseDir.resolve(VERSION_FILE);
try {
if (Files.exists(f)) return Files.readString(f).trim();
} catch (Exception ignored) {}
return "0.0.0";
}
private static String getServerVersion() {
try {
URL url = new URL(BASE_URL.replace("download?type=jar", "version"));
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
conn.setRequestMethod("GET");
if (conn.getResponseCode() == 200) {
try (BufferedReader br = new BufferedReader(new InputStreamReader(conn.getInputStream()))) {
String line = br.readLine();
if (line != null && line.contains("version")) {
return line.replaceAll(".*\"version\"\\s*:\\s*\"([^\"]+)\".*", "$1");
}
}
}
} catch (Exception ignored) {}
return "unknown";
}
private static boolean isNewer(String server, String current) {
try {
String[] sa = server.split("\\.");
String[] ca = current.split("\\.");
for (int i = 0; i < Math.min(sa.length, ca.length); i++) {
int sv = Integer.parseInt(sa[i]);
int cv = Integer.parseInt(ca[i]);
if (sv > cv) return true;
if (sv < cv) return false;
}
return sa.length > ca.length;
} catch (Exception ignored) {}
return false;
}
private static void downloadUpdate(String newVersion) throws Exception {
URL url = new URL(BASE_URL + "/launcher/download/jar");
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
conn.setRequestMethod("GET");
if (conn.getResponseCode() == 200) {
Path jarFile = baseDir.resolve(JAR_NAME);
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("\rСкачано: " + (total/1024/1024) + " MB");
}
}
log("Скачано");
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);
} else {
throw new IOException("Сервер вернул код: " + conn.getResponseCode());
}
}
private static void launchJFX() throws Exception {
Path javaBin = findJava();
Path jarPath = baseDir.resolve(JAR_NAME);
log("Запуск JFX режима...");
log("Java: " + javaBin);
log("JAR: " + jarPath);
ProcessBuilder pb = new ProcessBuilder(
javaBin.toAbsolutePath().toString(),
"-jar",
jarPath.toAbsolutePath().toString(),
"--jfx"
);
pb.directory(baseDir.toFile());
pb.inheritIO();
Process p = pb.start();
int code = p.waitFor();
log("Завершено с кодом: " + code);
System.exit(code);
}
private static void launchCLI() throws Exception {
Path javaBin = findJava();
Path jarPath = baseDir.resolve(JAR_NAME);
log("Запуск CLI режима...");
log("Java: " + javaBin);
log("JAR: " + jarPath);
ProcessBuilder pb = new ProcessBuilder(
javaBin.toAbsolutePath().toString(),
"-jar",
jarPath.toAbsolutePath().toString(),
"--cli"
);
pb.directory(baseDir.toFile());
pb.inheritIO();
Process p = pb.start();
int code = p.waitFor();
log("Завершено с кодом: " + code);
System.exit(code);
}
private static Path findJava() {
String os = System.getProperty("os.name").toLowerCase();
String javaExe = os.contains("windows") ? "java.exe" : "java";
// Сначала ищем jre21/bin/java рядом с лаунчером
Path javaBin = baseDir.resolve("jre21").resolve("bin").resolve(javaExe);
// Если нет, пробуем системную Java
if (!Files.exists(javaBin)) {
javaBin = Paths.get(System.getProperty("java.home"), "bin", javaExe);
}
// Если и это не найдено - ищем java в PATH
if (!Files.exists(javaBin)) {
try {
Process p = new ProcessBuilder("which", javaExe).start();
if (p.waitFor() == 0) {
try (BufferedReader br = new BufferedReader(new InputStreamReader(p.getInputStream()))) {
String path = br.readLine();
if (path != null) {
javaBin = Paths.get(path.trim());
}
}
}
} catch (Exception ignored) {}
}
if (!Files.exists(javaBin)) {
throw new RuntimeException("Java не найдена. Убедитесь, что jre21 присутствует в папке с лаунчером или Java установлена в системе");
}
return javaBin;
}
}
@@ -4,14 +4,9 @@ import me.sashegdev.zernmc.launcher.api.LauncherAPI;
import me.sashegdev.zernmc.launcher.auth.AuthManager;
import me.sashegdev.zernmc.launcher.menu.*;
import me.sashegdev.zernmc.launcher.ui.ArrowMenu;
import me.sashegdev.zernmc.launcher.ui.jfx.JFXLauncher;
import me.sashegdev.zernmc.launcher.utils.*;
import java.io.IOException;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.List;
public class Main {
@@ -20,20 +15,60 @@ public class Main {
private static final LauncherAPI api = new LauncherAPI();
public static void main(String[] args) throws IOException {
// Настройка кодировки для Windows и Linux
System.setProperty("org.jline.terminal.disableDeprecatedProviderWarning", "true");
System.setProperty("file.encoding", "UTF-8");
System.setProperty("sun.err.encoding", "UTF-8");
System.setProperty("sun.stdout.encoding", "UTF-8");
// Для Windows CMD - пытаемся переключить в UTF-8 режим
try {
if (System.getProperty("os.name").toLowerCase().contains("windows")) {
new ProcessBuilder("cmd", "/c", "chcp", "65001").inheritIO().start().waitFor();
}
} catch (Exception ignored) {}
ZAnsi.install();
System.out.print("\033[H\033[2J");
System.out.println(ZAnsi.brightGreen("Добро пожаловать в ZernMC Launcher " + CURRENT_VERSION));
// Определяем режим запуска
List<String> argList = List.of(args);
boolean jfxMode = argList.contains("--jfx");
boolean cliMode = argList.contains("--cli");
if (jfxMode) {
launchJFX();
return;
}
// CLI режим (по умолчанию или с --cli)
startCLI();
}
private static void launchJFX() {
System.out.println(ZAnsi.cyan("Запуск JFX интерфейса..."));
try {
// Устанавливаем параметры для JavaFX (важно для Windows)
System.setProperty("javafx.runtime.version", "21");
JFXLauncher.main(new String[]{});
} catch (Exception e) {
System.err.println(ZAnsi.brightRed("Ошибка запуска JFX: " + e.getMessage()));
// Проверяем, связано ли это с отсутствием JavaFX
if (e.getMessage() != null && e.getMessage().contains("QuantumRenderer")) {
System.err.println(ZAnsi.yellow("JavaFX недоступен. Возможно, отсутствуют нативные библиотеки."));
System.err.println(ZAnsi.yellow("Попробуйте использовать CLI режим: --cli"));
}
e.printStackTrace();
System.exit(1);
}
}
private static void startCLI() throws IOException {
// Проверка всех сервисов при старте
ZHttpClient.checkAllServicesOnStartup();
checkAndAutoUpdateLauncher();
// === АВТОРИЗАЦИЯ (используем новый API) ===
System.out.println(ZAnsi.cyan("Проверка авторизации..."));
var sessionResponse = api.checkSession();
@@ -62,92 +97,6 @@ public class Main {
}
}
private static void checkAndAutoUpdateLauncher() {
System.out.println(ZAnsi.cyan("Проверка обновлений лаунчера..."));
try {
String json = ZHttpClient.getLauncherVersionInfo();
String serverVersion = extractVersion(json);
System.out.println(ZAnsi.white("Текущая версия: ") + CURRENT_VERSION);
System.out.println(ZAnsi.white("Версия на сервере: ") + serverVersion);
if (Version.isNewer(CURRENT_VERSION, serverVersion)) {
System.out.println(ZAnsi.brightYellow("\nДоступна новая версия лаунчера! (" + serverVersion + ")"));
System.out.println(ZAnsi.cyan("Начинается автоматическое обновление...\n"));
performAutoUpdate(serverVersion);
restartLauncher();
} else {
System.out.println(ZAnsi.brightGreen("Лаунчер актуален."));
}
} catch (Exception e) {
System.out.println(ZAnsi.yellow("Не удалось проверить обновления лаунчера."));
System.out.println(ZAnsi.white("Ошибка: ") + e.getMessage());
}
}
private static void performAutoUpdate(String newVersion) throws Exception {
String downloadUrl = ZHttpClient.getBaseUrl() + "/launcher/download?type=jar";
Path currentJar = getCurrentJarPath();
Path tempJar = currentJar.getParent().resolve("zernmc-launcher-new.jar");
System.out.println(ZAnsi.cyan("Скачивание версии " + newVersion + "..."));
HttpClient client = HttpClient.newBuilder().build();
HttpRequest request = HttpRequest.newBuilder()
.uri(java.net.URI.create(downloadUrl))
.GET()
.build();
HttpResponse<Path> response = client.send(request, HttpResponse.BodyHandlers.ofFile(tempJar));
if (response.statusCode() != 200) {
throw new IOException("Сервер вернул код: " + response.statusCode());
}
long size = Files.size(tempJar);
System.out.println(ZAnsi.brightGreen("Скачано успешно (" + (size / 1024) + " KB)"));
Files.move(tempJar, currentJar, StandardCopyOption.REPLACE_EXISTING);
System.out.println(ZAnsi.brightGreen("Обновление успешно установлено!"));
}
private static void restartLauncher() {
try {
String javaPath = System.getProperty("java.home") + "/bin/java";
String jarPath = getCurrentJarPath().toAbsolutePath().toString();
System.out.println(ZAnsi.brightGreen("Перезапуск лаунчера с новой версией..."));
new ProcessBuilder(javaPath, "-jar", jarPath)
.inheritIO()
.start();
System.exit(0);
} catch (Exception e) {
System.err.println(ZAnsi.brightRed("Не удалось перезапустить лаунчер."));
System.exit(1);
}
}
private static String extractVersion(String json) {
try {
return json.replaceAll(".*\"version\"\\s*:\\s*\"([^\"]+)\".*", "$1");
} catch (Exception e) {
return "unknown";
}
}
private static Path getCurrentJarPath() {
try {
return Path.of(Main.class.getProtectionDomain()
.getCodeSource()
.getLocation()
.toURI());
} catch (Exception e) {
return Path.of("zernmc-launcher-1.0-jar-with-dependencies.jar");
}
}
// ====================== ГЛАВНЫЙ ЦИКЛ ======================
private static void mainLoop() throws Exception {
if (Config.isZernMCBuild()) {
@@ -37,7 +37,9 @@ public class AuthService {
SessionInfo info = new SessionInfo(
AuthManager.getUsername(),
AuthManager.getAccessToken(),
AuthManager.hasActivePass()
AuthManager.hasActivePass(),
AuthManager.getRole(),
AuthManager.getRoleName()
);
return ApiResponse.success(info);
}
@@ -120,15 +122,21 @@ public class AuthService {
private String username;
private String token;
private boolean passActive;
private int role;
private String roleName;
public SessionInfo(String username, String token, boolean passActive) {
public SessionInfo(String username, String token, boolean passActive, int role, String roleName) {
this.username = username;
this.token = token;
this.passActive = passActive;
this.role = role;
this.roleName = roleName;
}
public String getUsername() { return username; }
public String getToken() { return token; }
public boolean isPassActive() { return passActive; }
public int getRole() { return role; }
public String getRoleName() { return roleName; }
}
}
@@ -1,12 +1,16 @@
package me.sashegdev.zernmc.launcher.api.launch;
import me.sashegdev.zernmc.launcher.api.ApiResponse;
import me.sashegdev.zernmc.launcher.auth.AuthManager;
import me.sashegdev.zernmc.launcher.minecraft.Instance;
import me.sashegdev.zernmc.launcher.minecraft.InstanceManager;
import me.sashegdev.zernmc.launcher.minecraft.launch.LaunchCommandBuilder;
import me.sashegdev.zernmc.launcher.minecraft.model.LaunchOptions;
import me.sashegdev.zernmc.launcher.ui.jfx.JFXLauncher;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.nio.file.Path;
import java.util.List;
@@ -42,16 +46,58 @@ public class LaunchService {
return ApiResponse.error("Сборка не найдена: " + instanceName);
}
JFXLauncher.initGameLog(instance.getPath());
LaunchCommandBuilder builder = new LaunchCommandBuilder(instance);
LaunchOptions options = new LaunchOptions();
// Set auth info
options.setUsername(AuthManager.getUsername());
options.setAccessToken(AuthManager.getAccessToken());
options.setUuid(AuthManager.getUuid());
List<String> command = builder.build(options);
System.out.println("[LAUNCH] Generated command for " + instanceName + ":");
command.forEach(arg -> System.out.println(" " + arg));
ProcessBuilder processBuilder = new ProcessBuilder(command);
processBuilder.directory(instance.getPath().toFile());
processBuilder.inheritIO();
processBuilder.redirectErrorStream(true);
Process process = processBuilder.start();
System.out.println("[LAUNCH] Process started, pid=" + process.pid());
// Capture output (stdout)
Thread outThread = new Thread(() -> {
try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) {
String line;
while ((line = reader.readLine()) != null) {
System.out.println("[STDOUT] " + line);
JFXLauncher.appendGameLog(line);
}
} catch (Exception e) {
System.out.println("[STDOUT ERROR] " + e.getMessage());
JFXLauncher.appendGameLog("[Ошибка чтения вывода: " + e.getMessage());
}
});
outThread.setDaemon(true);
outThread.start();
// Capture errors (stderr)
Thread errThread = new Thread(() -> {
try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getErrorStream()))) {
String line;
while ((line = reader.readLine()) != null) {
System.out.println("[STDERR] " + line);
JFXLauncher.appendGameLog("[ERR] " + line);
}
} catch (Exception e) {
System.out.println("[STDERR ERROR] " + e.getMessage());
JFXLauncher.appendGameLog("[Ошибка чтения ошибок: " + e.getMessage());
}
});
errThread.setDaemon(true);
errThread.start();
ProcessInfo info = new ProcessInfo(
instanceName,
@@ -10,6 +10,7 @@ import me.sashegdev.zernmc.launcher.utils.ZAnsi;
import me.sashegdev.zernmc.launcher.utils.ZHttpClient;
import java.io.IOException;
import java.io.InputStream;
import java.net.HttpURLConnection;
import java.net.URL;
import java.nio.charset.StandardCharsets;
@@ -213,6 +214,13 @@ public class AuthManager {
return session != null ? session.role : ROLE_USER;
}
public static String getRoleName() {
if (userInfo != null && userInfo.role_name != null) {
return userInfo.role_name;
}
return "USER";
}
// ====================== POST ======================
private static SimpleHttpResponse post(String endpoint, String jsonBody) throws Exception {
String fullUrl = ZHttpClient.getBaseUrl() + endpoint;
@@ -245,11 +253,15 @@ public class AuthManager {
}
int statusCode = conn.getResponseCode();
var is = (statusCode >= 200 && statusCode < 300) ? conn.getInputStream() : conn.getErrorStream();
InputStream is = (statusCode >= 200 && statusCode < 300) ? conn.getInputStream() : conn.getErrorStream();
String responseBody;
try (var scanner = new java.util.Scanner(is, StandardCharsets.UTF_8.name())) {
responseBody = scanner.useDelimiter("\\A").hasNext() ? scanner.next() : "";
if (is != null) {
try (var scanner = new java.util.Scanner(is, StandardCharsets.UTF_8.name())) {
responseBody = scanner.useDelimiter("\\A").hasNext() ? scanner.next() : "";
}
} else {
responseBody = "No response body (status " + statusCode + ")";
}
return new SimpleHttpResponse(statusCode, responseBody);
@@ -166,9 +166,9 @@ public class LoginMenu {
if (key == 27) {
// Escape sequence consume remaining bytes (arrow keys, etc.)
int next = passTerminal.reader().read(50);
int next = passTerminal.reader().read();
if (next == 91) { // '[' arrow key sequence
passTerminal.reader().read(50); // consume 'A'/'B'/'C'/'D'
passTerminal.reader().read(); // consume 'A'/'B'/'C'/'D'
}
continue;
}
@@ -6,10 +6,14 @@ import me.sashegdev.zernmc.launcher.minecraft.installer.NeoForgeInstaller;
import me.sashegdev.zernmc.launcher.minecraft.installer.VersionInstaller;
import me.sashegdev.zernmc.launcher.minecraft.launch.LaunchCommandBuilder;
import me.sashegdev.zernmc.launcher.minecraft.model.LaunchOptions;
import me.sashegdev.zernmc.launcher.ui.jfx.JFXLauncher;
import me.sashegdev.zernmc.launcher.utils.ConsoleUtils;
import me.sashegdev.zernmc.launcher.utils.ZAnsi;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
@@ -114,15 +118,43 @@ public class MinecraftLib {
ProcessBuilder pb = new ProcessBuilder(command);
pb.directory(instance.getPath().toFile());
pb.redirectOutput(ProcessBuilder.Redirect.INHERIT);
pb.redirectError(ProcessBuilder.Redirect.INHERIT);
pb.redirectInput(ProcessBuilder.Redirect.INHERIT);
System.out.println(ZAnsi.brightGreen("\nЗапускаем Minecraft...\n"));
ConsoleUtils.clearScreen();
Process process = pb.start();
// Capture output
Thread outThread = new Thread(() -> {
try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) {
String line;
while ((line = reader.readLine()) != null) {
JFXLauncher.appendGameLog(line);
}
} catch (Exception e) {
JFXLauncher.appendGameLog("[Ошибка чтения вывода: " + e.getMessage() + "]");
}
});
outThread.setDaemon(true);
outThread.start();
// Capture errors
Thread errThread = new Thread(() -> {
try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getErrorStream()))) {
String line;
while ((line = reader.readLine()) != null) {
JFXLauncher.appendGameLog("[ERR] " + line);
}
} catch (Exception e) {
JFXLauncher.appendGameLog("[Ошибка чтения ошибок: " + e.getMessage() + "]");
}
});
errThread.setDaemon(true);
errThread.start();
int exitCode = process.waitFor();
outThread.join(1000);
errThread.join(1000);
System.out.println(ZAnsi.yellow("\nMinecraft завершился с кодом: " + exitCode));
}
@@ -36,15 +36,37 @@ public class LaunchCommandBuilder {
}
command.add("-Djava.library.path=" + nativesDir.toAbsolutePath());
String loaderType = instance.getLoaderType().toLowerCase();
boolean isModloader = "fabric".equals(loaderType) || "forge".equals(loaderType) || "neoforge".equals(loaderType);
VersionManifest manifest = resolveVersionManifest();
if (manifest != null) {
// For modloaders, always use vanilla classpath with all libraries
if (isModloader) {
System.out.println(ZAnsi.cyan(" Modloader detected (" + loaderType + "), using vanilla classpath"));
command.add("-cp");
command.add(buildClasspathFromManifest(manifest));
command.add(buildVanillaClasspath());
command.add(getVanillaMainClass());
command.addAll(getVanillaGameArguments(options));
} else if (manifest != null) {
String classpath = buildClasspathFromManifest(manifest);
String mainClass = resolveMainClass(manifest);
command.add(mainClass);
// Fallback if classpath is empty
if (classpath.isEmpty() || classpath.equals(instance.getPath().resolve("versions").resolve(getVersionId()).resolve(getVersionId() + ".jar").toAbsolutePath().toString())) {
System.out.println(ZAnsi.yellow(" manifest classpath пустой, использую vanilla classpath"));
command.add("-cp");
command.add(buildVanillaClasspath());
command.add(getVanillaMainClass());
command.addAll(getVanillaGameArguments(options));
} else {
command.add("-cp");
command.add(classpath);
command.addAll(resolveGameArguments(manifest, options));
String mainClass = resolveMainClass(manifest);
command.add(mainClass);
command.addAll(resolveGameArguments(manifest, options));
}
} else {
command.add("-cp");
command.add(buildVanillaClasspath());
@@ -63,6 +85,10 @@ public class LaunchCommandBuilder {
JSONObject json = new JSONObject(content);
System.out.println(ZAnsi.green("Найден version.json: " + versionJson.getFileName()));
return new VersionManifest(json);
} else {
System.out.println(ZAnsi.yellow("version.json не найден для " + instance.getName()));
System.out.println(ZAnsi.yellow(" loaderType=" + instance.getLoaderType() + " mcVersion=" + instance.getMinecraftVersion() + " loaderVersion=" + instance.getLoaderVersion()));
System.out.println(ZAnsi.yellow(" path=" + instance.getPath()));
}
} catch (Exception e) {
System.out.println(ZAnsi.yellow("Не удалось загрузить version.json: " + e.getMessage()));
@@ -76,6 +102,38 @@ public class LaunchCommandBuilder {
String mcVersion = instance.getMinecraftVersion();
String loaderVersion = instance.getLoaderVersion();
if ("fabric".equals(loaderType)) {
String versionId = getVersionId();
// Try fabric version ID first
Path jsonPath = versionsDir.resolve(versionId).resolve(versionId + ".json");
if (Files.exists(jsonPath)) {
return jsonPath;
}
// Try instance's fabricVersionId if available
String fabricId = instance.getFabricVersionId();
if (fabricId != null && !fabricId.isEmpty()) {
Path fabricPath = versionsDir.resolve(fabricId).resolve(fabricId + ".json");
if (Files.exists(fabricPath)) {
return fabricPath;
}
}
// Try generic fabric pattern
try {
if (Files.exists(versionsDir)) {
try (var stream = Files.list(versionsDir)) {
return stream
.filter(Files::isDirectory)
.filter(dir -> dir.getFileName().toString().contains("fabric"))
.filter(dir -> dir.getFileName().toString().contains(mcVersion))
.findFirst()
.map(dir -> dir.resolve(dir.getFileName().toString() + ".json"))
.filter(Files::exists)
.orElse(null);
}
}
} catch (Exception ignored) {}
}
if ("forge".equals(loaderType) || "neoforge".equals(loaderType)) {
String[] candidates = {
getVersionId(),
@@ -152,6 +210,10 @@ public class LaunchCommandBuilder {
String loaderType = instance.getLoaderType().toLowerCase();
if ("fabric".equals(loaderType)) {
return "net.fabricmc.loader.impl.launch.knot.KnotClient";
} else if ("forge".equals(loaderType)) {
return "net.minecraftforge.client.main.ForgeClient";
} else if ("neoforge".equals(loaderType)) {
return "cpw.mods.bootstraplauncher.BootstrapLauncher";
}
return "net.minecraft.client.main.Main";
}
@@ -258,6 +320,8 @@ public class LaunchCommandBuilder {
List<String> paths = new ArrayList<>();
Path librariesDir = instance.getPath().resolve("libraries");
System.out.println(ZAnsi.cyan(" buildClasspathFromManifest: " + manifest.getLibraries().size() + " libraries in manifest"));
for (VersionManifest.Library lib : manifest.getLibraries()) {
Path libPath = librariesDir.resolve(lib.artifactPath);
if (Files.exists(libPath)) {
@@ -273,9 +337,12 @@ public class LaunchCommandBuilder {
}
}
System.out.println(ZAnsi.cyan(" buildClasspathFromManifest: " + paths.size() + " libraries in classpath"));
Path versionJar = findVersionJar();
if (versionJar != null) {
paths.add(0, versionJar.toAbsolutePath().toString());
System.out.println(ZAnsi.green(" Added version jar: " + versionJar.getFileName()));
}
String separator = System.getProperty("os.name").toLowerCase().contains("win") ? ";" : ":";
@@ -99,8 +99,17 @@ public class VersionManifest {
if (rule.has("features")) {
JSONObject features = rule.getJSONObject("features");
for (String key : features.keySet()) {
if (key.startsWith("is_demo_user") || key.startsWith("has_custom_resolution")) continue;
if (key.startsWith("has_custom_resolution")) {
continue; // Лаунчер сам обрабатывает разрешение
}
if (key.startsWith("is_demo_user")) {
// Лаунчер не использует demo режим, считаем фичу false
matches = false;
break;
}
// Неизвестная фича считаем false
matches = false;
break;
}
}
@@ -48,9 +48,9 @@ public class ArrowMenu {
return selected;
}
else if (key == 27) { // Esc or arrow escape seq
int next = terminal.reader().read(50);
int next = terminal.reader().read();
if (next == 91) { // '[' start of arrow escape sequence
int arrow = terminal.reader().read(50);
int arrow = terminal.reader().read();
if (arrow == 65) { // 'A' Up arrow
selected = (selected - 1 + options.size()) % options.size();
} else if (arrow == 66) { // 'B' Down arrow
@@ -0,0 +1,463 @@
package me.sashegdev.zernmc.launcher.ui.jfx;
import javafx.application.Application;
import javafx.scene.Scene;
import javafx.scene.web.WebView;
import javafx.scene.web.WebEngine;
import javafx.stage.Stage;
import javafx.concurrent.Worker;
import com.google.gson.Gson;
import me.sashegdev.zernmc.launcher.api.LauncherAPI;
import me.sashegdev.zernmc.launcher.auth.AuthManager;
import me.sashegdev.zernmc.launcher.minecraft.Instance;
import me.sashegdev.zernmc.launcher.minecraft.InstanceManager;
import java.io.BufferedReader;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.InputStreamReader;
import java.net.HttpURLConnection;
import java.net.URL;
import java.util.jar.JarEntry;
import java.util.jar.JarFile;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardOpenOption;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.Executors;
import com.sun.net.httpserver.HttpServer;
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.Headers;
public class JFXLauncher extends Application {
private static final 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 final LauncherAPI api = new LauncherAPI();
private final Gson gson = new Gson();
private HttpServer server;
private StringBuilder logBuffer = new StringBuilder();
private static StringBuilder gameLogBuffer = new StringBuilder();
private static Path gameLogFile;
private Stage mainStage;
public static void appendGameLog(String log) {
synchronized (gameLogBuffer) {
gameLogBuffer.append(log).append("\n");
if (gameLogFile != null) {
try {
String timestamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss"));
Files.writeString(gameLogFile, "[" + timestamp + "] " + log + "\n",
StandardOpenOption.CREATE, StandardOpenOption.APPEND);
} catch (Exception ignored) {}
}
}
}
public static void initGameLog(Path instanceDir) {
synchronized (gameLogBuffer) {
gameLogBuffer.setLength(0);
}
try {
Path logsDir = instanceDir.resolve("logs");
Files.createDirectories(logsDir);
gameLogFile = logsDir.resolve("game.log");
Files.writeString(gameLogFile, "=== Game Log " + LocalDateTime.now() + " ===\n",
StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING);
} catch (Exception ignored) {}
}
public static String getGameLogs() {
synchronized (gameLogBuffer) {
return gameLogBuffer.toString();
}
}
public static void main(String[] args) {
launch(args);
}
private static void extractAssets() {
try {
Path assetsDir = Paths.get("assets");
if (Files.exists(assetsDir)) {
return;
}
String serverVersion = getServerVersion();
if (serverVersion != null && !serverVersion.isEmpty()) {
System.out.println("[JFX] Загрузка assets через мета для версии " + serverVersion);
if (downloadAssetsFromMeta(serverVersion)) {
System.out.println("[JFX] Assets загружены через мета");
return;
}
System.out.println("[JFX] Мета недоступна, использую fallback");
}
System.out.println("[JFX] Извлечение assets из JAR...");
Path jarPath = Paths.get(JFXLauncher.class.getProtectionDomain().getCodeSource().getLocation().toURI());
if (Files.exists(jarPath) && jarPath.toString().endsWith(".jar")) {
try (JarFile jar = new JarFile(jarPath.toFile())) {
var entries = jar.entries();
while (entries.hasMoreElements()) {
JarEntry entry = entries.nextElement();
if (entry.getName().startsWith("assets/")) {
Path outPath = assetsDir.resolve(entry.getName().substring(7));
if (entry.isDirectory()) {
Files.createDirectories(outPath);
} else {
Files.createDirectories(outPath.getParent());
try (InputStream is = jar.getInputStream(entry)) {
Files.copy(is, outPath);
}
}
}
}
}
System.out.println("[JFX] Assets извлечены из JAR");
}
} catch (Exception e) {
System.out.println("[JFX] Ошибка извлечения assets: " + e.getMessage());
}
}
private static String getServerVersion() {
try {
URL url = new URL(LAUNCHER_SERVER + "/launcher/version");
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
conn.setConnectTimeout(3000);
conn.setReadTimeout(3000);
if (conn.getResponseCode() == 200) {
try (BufferedReader br = new BufferedReader(new InputStreamReader(conn.getInputStream()))) {
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);
}
}
}
}
} catch (Exception ignored) {}
return null;
}
private static boolean downloadAssetsFromMeta(String version) {
try {
URL metaUrl = new URL(LAUNCHER_SERVER + "/launcher/meta/" + version);
HttpURLConnection conn = (HttpURLConnection) metaUrl.openConnection();
conn.setConnectTimeout(5000);
conn.setReadTimeout(10000);
if (conn.getResponseCode() != 200) return false;
try (BufferedReader br = new BufferedReader(new InputStreamReader(conn.getInputStream()))) {
StringBuilder sb = new StringBuilder();
String line;
while ((line = br.readLine()) != null) sb.append(line);
org.json.JSONObject meta = new org.json.JSONObject(sb.toString());
Path assetsDir = Paths.get("assets");
Files.createDirectories(assetsDir);
for (Object fileObj : meta.getJSONArray("files")) {
org.json.JSONObject file = (org.json.JSONObject) fileObj;
String path = file.getString("path");
if (path.startsWith("assets/")) {
String downloadUrl = LAUNCHER_SERVER + "/launcher/file/" + version + "/" + path;
Path outPath = assetsDir.resolve(path.substring(7));
Files.createDirectories(outPath.getParent());
URL fileUrl = new URL(downloadUrl);
HttpURLConnection fileConn = (HttpURLConnection) fileUrl.openConnection();
fileConn.setConnectTimeout(10000);
fileConn.setReadTimeout(30000);
if (fileConn.getResponseCode() == 200) {
try (InputStream is = fileConn.getInputStream()) {
Files.copy(is, outPath);
}
}
fileConn.disconnect();
}
}
return true;
}
} catch (Exception e) {
System.out.println("[JFX] Ошибка загрузки через мета: " + e.getMessage());
return false;
}
}
@Override
public void start(Stage stage) {
this.mainStage = stage;
try {
extractAssets();
log("Запуск JFX UI...");
startServer();
WebView webView = new WebView();
WebEngine engine = webView.getEngine();
engine.setJavaScriptEnabled(true);
engine.getLoadWorker().stateProperty().addListener((obs, oldState, newState) -> {
log("[UI] Load state: " + oldState + " -> " + newState);
if (newState == Worker.State.SUCCEEDED) {
log("Страница загружена");
} else if (newState == Worker.State.FAILED) {
log("[UI] Load FAILED: " + engine.getLoadWorker().getException());
}
});
engine.setOnAlert(e -> log("[UI] Alert: " + e.getData()));
String url = "http://localhost:" + PORT + "/assets/ui/index.html";
engine.load(url);
stage.setTitle(APP_TITLE);
stage.setWidth(1200);
stage.setHeight(800);
stage.setScene(new Scene(webView));
stage.show();
log("Окно отображено");
stage.setOnCloseRequest(e -> {
log("Закрытие...");
stopServer();
});
} catch (Exception e) {
log("Ошибка: " + e.getMessage());
e.printStackTrace();
throw new RuntimeException(e);
}
}
private void startServer() throws Exception {
server = HttpServer.create(new InetSocketAddress("localhost", PORT), 0);
server.createContext("/api/login", this::handleLogin);
server.createContext("/api/account", this::handleAccount);
server.createContext("/api/instances", this::handleInstances);
server.createContext("/api/launch", this::handleLaunch);
server.createContext("/api/install", this::handleInstall);
server.createContext("/api/logs", this::handleLogs);
server.createContext("/api/game-logs", this::handleGameLogs);
server.createContext("/api/exit", this::handleExit);
server.createContext("/assets/", this::handleStatic);
server.setExecutor(Executors.newCachedThreadPool());
server.start();
log("HTTP сервер на порту " + PORT);
}
private void stopServer() {
if (server != null) server.stop(0);
}
private void handleLogin(HttpExchange exchange) {
try {
if (!"POST".equals(exchange.getRequestMethod())) {
sendJson(exchange, Map.of("success", false, "error", "Метод не поддерживается"));
return;
}
Map<String, String> body = parseJson(exchange.getRequestBody());
String username = body.get("username");
String password = body.get("password");
var result = api.login(username, password);
if (result.isSuccess()) {
Map<String, Object> data = new HashMap<>();
data.put("username", result.getData().getUsername());
data.put("token", result.getData().getToken());
sendJson(exchange, Map.of("success", true, "data", data));
log("Вход: " + username);
} else {
sendJson(exchange, Map.of("success", false, "error", result.getError()));
}
} catch (Exception e) {
sendJson(exchange, Map.of("success", false, "error", e.getMessage()));
}
}
private void handleAccount(HttpExchange exchange) {
try {
if (!api.isLoggedIn()) {
sendJson(exchange, Map.of("success", false, "error", "Не авторизован"));
return;
}
Map<String, Object> data = new HashMap<>();
data.put("username", api.getCurrentUsername());
data.put("passActive", AuthManager.hasActivePass());
data.put("role", AuthManager.getRole());
data.put("roleName", AuthManager.getRoleName());
sendJson(exchange, Map.of("success", true, "data", data));
} catch (Exception e) {
sendJson(exchange, Map.of("success", false, "error", e.getMessage()));
}
}
private void handleInstances(HttpExchange exchange) {
try {
var result = api.getAllInstances();
Map<String, Object> response = new HashMap<>();
response.put("success", result.isSuccess());
if (result.isSuccess()) {
response.put("data", result.getData());
} else {
response.put("error", result.getError());
}
sendJson(exchange, response);
} catch (Exception e) {
sendJson(exchange, Map.of("success", false, "error", e.getMessage()));
}
}
private void handleLaunch(HttpExchange exchange) {
try {
if (!api.isLoggedIn()) {
sendJson(exchange, Map.of("success", false, "error", "Не авторизован"));
return;
}
Map<String, String> body = parseJson(exchange.getRequestBody());
String name = body.get("name");
var result = api.launch(name);
if (result.isSuccess()) {
Map<String, Object> data = new HashMap<>();
data.put("pid", result.getData().getPid());
data.put("status", result.getData().getStatus());
sendJson(exchange, Map.of("success", true, "data", data));
log("Запущено: " + name);
} else {
sendJson(exchange, Map.of("success", false, "error", result.getError()));
}
} catch (Exception e) {
sendJson(exchange, Map.of("success", false, "error", e.getMessage()));
}
}
private void handleInstall(HttpExchange exchange) {
try {
if (!api.isLoggedIn()) {
sendJson(exchange, Map.of("success", false, "error", "Не авторизован"));
return;
}
Map<String, String> body = parseJson(exchange.getRequestBody());
String name = body.get("name");
String version = body.get("version");
String loader = body.get("loader");
log("Установка: " + name + " " + version + " " + loader);
var createResult = api.instances().createInstance(name);
if (!createResult.isSuccess()) {
sendJson(exchange, Map.of("success", false, "error", createResult.getError()));
return;
}
Instance instance = InstanceManager.getInstance(name);
if (instance != null) {
instance.setMinecraftVersion(version);
instance.setLoaderType(loader);
}
sendJson(exchange, Map.of("success", true, "data", true));
log("Установлено: " + name);
} catch (Exception e) {
sendJson(exchange, Map.of("success", false, "error", e.getMessage()));
}
}
private void handleLogs(HttpExchange exchange) {
sendJson(exchange, Map.of("success", true, "data", logBuffer.toString()));
}
private void handleGameLogs(HttpExchange exchange) {
sendJson(exchange, Map.of("success", true, "data", getGameLogs()));
}
private void handleExit(HttpExchange exchange) {
log("Выход...");
if (mainStage != null) mainStage.close();
System.exit(0);
}
private void handleStatic(HttpExchange exchange) {
try {
String path = exchange.getRequestURI().getPath();
log("[UI] Request: " + path);
String relativePath = path.startsWith("/") ? path.substring(1) : path;
Path file = Paths.get(relativePath).toAbsolutePath();
if (!Files.exists(file)) {
log("[UI] File not found: " + file);
exchange.sendResponseHeaders(404, 0);
exchange.close();
return;
}
byte[] content = Files.readAllBytes(file);
log("[UI] Loaded " + content.length + " bytes: " + path);
String ct = getContentType(path);
exchange.getResponseHeaders().set("Content-Type", ct);
exchange.sendResponseHeaders(200, content.length);
exchange.getResponseBody().write(content);
exchange.close();
} catch (Exception e) {
log("[UI] Error serving: " + e.getMessage());
}
}
private String getContentType(String path) {
if (path.endsWith(".html")) return "text/html; charset=utf-8";
if (path.endsWith(".css")) return "text/css; charset=utf-8";
if (path.endsWith(".js")) return "application/javascript; charset=utf-8";
return "text/plain";
}
@SuppressWarnings("unchecked")
private Map<String, String> parseJson(InputStream body) {
try {
return gson.fromJson(new String(body.readAllBytes(), StandardCharsets.UTF_8), Map.class);
} catch (Exception e) {
return new HashMap<>();
}
}
private void sendJson(HttpExchange exchange, Map<String, Object> response) {
try {
String json = gson.toJson(response);
byte[] bytes = json.getBytes(StandardCharsets.UTF_8);
exchange.getResponseHeaders().set("Content-Type", "application/json; charset=utf-8");
exchange.sendResponseHeaders(200, bytes.length);
exchange.getResponseBody().write(bytes);
exchange.close();
} catch (Exception ignored) {}
}
private void log(String msg) {
String entry = "[" + java.time.LocalTime.now() + "] " + msg + "\n";
logBuffer.append(entry);
System.out.println("[JFX] " + msg);
}
}
@@ -34,8 +34,9 @@ public class Version {
public static boolean isNewer(String current, String server) {
if (current == null || server == null) return false;
current = current.replace("-SNAPSHOT", "").trim();
server = server.replace("-SNAPSHOT", "").trim();
// Нормализуем версии - убираем суффиксы типа -any, -alpha, -beta, -SNAPSHOT
current = normalizeVersion(current);
server = normalizeVersion(server);
if (current.equals(server)) return false;
@@ -45,12 +46,29 @@ public class Version {
int max = Math.max(cParts.length, sParts.length);
for (int i = 0; i < max; i++) {
int c = i < cParts.length ? Integer.parseInt(cParts[i]) : 0;
int s = i < sParts.length ? Integer.parseInt(sParts[i]) : 0;
int c = i < cParts.length ? parseVersionPart(cParts[i]) : 0;
int s = i < sParts.length ? parseVersionPart(sParts[i]) : 0;
if (s > c) return true;
if (s < c) return false;
}
return false;
}
private static String normalizeVersion(String version) {
if (version == null) return "0.0.0";
// Убираем суффиксы: -any, -alpha1, -beta2, -SNAPSHOT, -rc1 и т.д.
return version.split("-")[0].split("\\+")[0].trim();
}
private static int parseVersionPart(String part) {
try {
// Убираем всё, что не является цифрой (на случай если суффикс остался)
String numeric = part.replaceAll("[^0-9]", "");
return numeric.isEmpty() ? 0 : Integer.parseInt(numeric);
} catch (Exception e) {
return 0;
}
}
}
@@ -0,0 +1,100 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ZernMC Launcher</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<div id="app">
<!-- Экран логина -->
<div id="login-screen" class="screen">
<div class="login-container">
<h1 class="logo">ZernMC</h1>
<p class="subtitle">Private Launcher</p>
<form id="login-form">
<input type="text" id="username" placeholder="Никнейм" required>
<input type="password" id="password" placeholder="Пароль" required>
<button type="submit" class="btn-primary">Войти</button>
</form>
<div id="login-error" class="error hidden"></div>
</div>
</div>
<!-- Главное меню -->
<div id="main-screen" class="screen hidden">
<!-- Хедер -->
<header class="header">
<h1 class="logo">ZernMC Launcher</h1>
<div class="account-info">
<span id="account-name">-</span>
<span id="account-status" class="badge">-</span>
<span id="account-role" class="badge role-badge">-</span>
</div>
</header>
<!-- Основной контент -->
<main class="main-content">
<!-- Слева: выбор сборки -->
<aside class="sidebar">
<h2>Сборки</h2>
<div id="instances-list" class="instances-container">
<!-- Динамически заполняется через JS -->
</div>
</aside>
<!-- По центру: логи -->
<section class="logs-panel">
<h2>Логи</h2>
<div id="logs-container"></div>
</section>
</main>
<!-- Низ: управление -->
<footer class="footer">
<div class="instance-info">
<span id="selected-name">-</span>
<span id="selected-version">-</span>
<span id="selected-loader">-</span>
</div>
<button id="play-btn" class="btn-play" disabled>Выберите сборку</button>
</footer>
</div>
<!-- Модальное окно установки -->
<div id="install-modal" class="modal hidden">
<div class="modal-content">
<h2>Установка сборки</h2>
<form id="install-form">
<label>Версия Minecraft
<select id="install-mc-version">
<option value="1.20.4">1.20.4</option>
<option value="1.20.2">1.20.2</option>
<option value="1.20.1">1.20.1</option>
<option value="1.19.2">1.19.2</option>
</select>
</label>
<label>Загрузчик
<select id="install-loader">
<option value="vanilla">Vanilla</option>
<option value="fabric">Fabric</option>
<option value="forge">Forge</option>
<option value="neoforge">NeoForge</option>
</select>
</label>
<label>Имя сборки
<input type="text" id="install-name" placeholder="MyServer" required>
</label>
<div class="modal-buttons">
<button type="button" class="btn-secondary" onclick="closeInstallModal()">Отмена</button>
<button type="submit" class="btn-primary">Установить</button>
</div>
</form>
</div>
</div>
</div>
<script src="launcher.js"></script>
</body>
</html>
@@ -0,0 +1,311 @@
const API_BASE = 'http://localhost:8080/api';
let state = {
loggedIn: false,
account: null,
instances: [],
selectedInstance: null
};
// ============ API ============
async function apiCall(endpoint, options = {}) {
const url = `${API_BASE}${endpoint}`;
const config = {
headers: { 'Content-Type': 'application/json' },
...options
};
try {
const response = await fetch(url, config);
const data = await response.json();
return data;
} catch (e) {
log('Ошибка соединения с сервером: ' + e.message, 'error');
return { success: false, error: e.message };
}
}
// ============ Login ============
async function login(username, password) {
log('Выполняется вход...', 'info');
const result = await apiCall('/login', {
method: 'POST',
body: JSON.stringify({ username, password })
});
if (result.success) {
state.loggedIn = true;
state.account = result.data;
log('Вход выполнен: ' + result.data.username, 'success');
showMainScreen();
await loadInstances();
} else {
log('Ошибка входа: ' + result.error, 'error');
showError(result.error);
}
return result;
}
function showError(message) {
const el = document.getElementById('login-error');
el.textContent = message;
el.classList.remove('hidden');
}
function hideError() {
document.getElementById('login-error').classList.add('hidden');
}
// ============ Account ============
async function loadAccountInfo() {
const result = await apiCall('/account');
if (result.success) {
state.account = result.data;
state.loggedIn = true;
document.getElementById('account-name').textContent = result.data.username;
const statusEl = document.getElementById('account-status');
statusEl.textContent = result.data.passActive ? 'PRO' : 'FREE';
statusEl.className = 'badge ' + (result.data.passActive ? 'active' : 'inactive');
const roleEl = document.getElementById('account-role');
if (roleEl && result.data.roleName) {
roleEl.textContent = result.data.roleName;
}
} else {
showLoginScreen();
}
}
// ============ Instances ============
async function loadInstances() {
log('Загрузка списка сборок...', 'info');
const result = await apiCall('/instances');
if (result.success) {
state.instances = result.data;
renderInstances();
log('Загружено ' + result.data.length + ' сборок', 'success');
} else {
log('Ошибка загрузки: ' + result.error, 'error');
}
}
function renderInstances() {
const container = document.getElementById('instances-list');
container.innerHTML = '';
state.instances.forEach(inst => {
const card = document.createElement('div');
card.className = 'instance-card';
card.dataset.name = inst.name;
card.onclick = () => selectInstance(inst.name);
let details = `
<span class="instance-version">${inst.version || '?'}</span>
<span class="instance-loader">${inst.loaderType || 'vanilla'}</span>
`;
if (inst.isServerPack) {
details += `<span class="instance-server-version">v${inst.serverVersion}</span>`;
}
card.innerHTML = `
<div class="instance-name">${inst.name}</div>
<div class="instance-details">${details}</div>
`;
container.appendChild(card);
});
}
function selectInstance(name) {
state.selectedInstance = state.instances.find(i => i.name === name);
document.querySelectorAll('.instance-card').forEach(c => {
c.classList.toggle('selected', c.dataset.name === name);
});
const btn = document.getElementById('play-btn');
const inst = state.selectedInstance;
if (inst) {
document.getElementById('selected-name').textContent = inst.name;
document.getElementById('selected-version').textContent = inst.version || '-';
document.getElementById('selected-loader').textContent = inst.loaderType || 'vanilla';
btn.disabled = false;
btn.textContent = 'Играть';
btn.classList.remove('update');
} else {
btn.disabled = true;
btn.textContent = 'Выберите сборку';
}
}
// ============ Launch ============
async function launchInstance() {
if (!state.selectedInstance) return;
const name = state.selectedInstance.name;
log('Запуск сборки: ' + name, 'info');
const result = await apiCall('/launch', {
method: 'POST',
body: JSON.stringify({ name })
});
if (result.success) {
log('Сборка запущена! PID: ' + result.data.pid, 'success');
} else {
log('Ошибка запуска: ' + result.error, 'error');
}
}
// ============ Install ============
function openInstallModal() {
document.getElementById('install-modal').classList.remove('hidden');
}
function closeInstallModal() {
document.getElementById('install-modal').classList.add('hidden');
}
async function installInstance(formData) {
log('Установка сборки...', 'info');
const result = await apiCall('/install', {
method: 'POST',
body: JSON.stringify(formData)
});
if (result.success) {
log('Сборка установлена!', 'success');
closeInstallModal();
await loadInstances();
} else {
log('Ошибка установки: ' + result.error, 'error');
}
return result;
}
// ============ Logs ============
function log(message, type = 'info') {
const container = document.getElementById('logs-container');
if (!container) return;
const line = document.createElement('div');
line.className = 'log-line ' + type;
line.textContent = '[' + new Date().toLocaleTimeString() + '] ' + message;
container.appendChild(line);
container.scrollTop = container.scrollHeight;
}
function clearLogs() {
document.getElementById('logs-container').innerHTML = '';
}
// ============ Screens ============
function showLoginScreen() {
document.getElementById('login-screen').classList.remove('hidden');
document.getElementById('main-screen').classList.add('hidden');
clearError();
}
function showMainScreen() {
document.getElementById('login-screen').classList.add('hidden');
document.getElementById('main-screen').classList.remove('hidden');
if (state.account) {
document.getElementById('account-name').textContent = state.account.username;
const statusEl = document.getElementById('account-status');
statusEl.textContent = state.account.passActive ? 'PRO' : 'FREE';
statusEl.className = 'badge ' + (state.account.passActive ? 'active' : 'inactive');
}
}
// ============ Init ============
document.addEventListener('DOMContentLoaded', async () => {
log('Запуск лаунчера...', 'info');
await loadAccountInfo();
if (!state.loggedIn) {
showLoginScreen();
} else {
showMainScreen();
await loadInstances();
}
// Start polling for server logs
startLogPolling();
});
let lastLogLength = 0;
let lastGameLogLength = 0;
function startLogPolling() {
setInterval(async () => {
// Launcher logs
const result = await apiCall('/logs');
if (result.success && result.data && result.data.length > lastLogLength) {
const newLogs = result.data.substring(lastLogLength);
const lines = newLogs.split('\n').filter(l => l.trim());
lines.forEach(line => {
if (line.includes('[JFX]')) {
log(line.replace('[JFX] ', ''), 'info');
}
});
lastLogLength = result.data.length;
}
// Game logs
const gameResult = await apiCall('/game-logs');
if (gameResult.success && gameResult.data && gameResult.data.length > lastGameLogLength) {
const newLogs = gameResult.data.substring(lastGameLogLength);
const lines = newLogs.split('\n').filter(l => l.trim());
lines.forEach(line => {
log('[GAME] ' + line, 'info');
});
lastGameLogLength = gameResult.data.length;
}
}, 2000);
}
// ============ Form Handlers ============
document.getElementById('login-form').addEventListener('submit', async (e) => {
e.preventDefault();
hideError();
const username = document.getElementById('username').value;
const password = document.getElementById('password').value;
await login(username, password);
});
document.getElementById('play-btn').addEventListener('click', async () => {
await launchInstance();
});
document.getElementById('install-form').addEventListener('submit', async (e) => {
e.preventDefault();
const formData = {
name: document.getElementById('install-name').value,
version: document.getElementById('install-mc-version').value,
loader: document.getElementById('install-loader').value
};
await installInstance(formData);
});
// Expose functions globally for inline handlers
window.closeInstallModal = closeInstallModal;
@@ -0,0 +1,438 @@
:root {
--bg-primary: #1a1a2e;
--bg-secondary: #16213e;
--bg-tertiary: #0f3460;
--accent: #e94560;
--accent-hover: #ff6b6b;
--text-primary: #eaeaea;
--text-secondary: #a0a0a0;
--success: #4ade80;
--warning: #fbbf24;
--error: #ef4444;
--border: #2d2d4a;
--shadow: rgba(0, 0, 0, 0.3);
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Segoe UI', system-ui, sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
height: 100vh;
overflow: hidden;
}
#app {
height: 100vh;
display: flex;
flex-direction: column;
}
/* Screens */
.screen {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
}
.hidden {
display: none !important;
}
/* Login Screen */
#login-screen {
justify-content: center;
align-items: center;
background: linear-gradient(135deg, var(--bg-primary) 0%, var(--bg-secondary) 100%);
}
.login-container {
background: var(--bg-secondary);
padding: 3rem;
border-radius: 16px;
box-shadow: 0 25px 50px var(--shadow);
width: 100%;
max-width: 400px;
border: 1px solid var(--border);
}
.logo {
font-size: 2.5rem;
text-align: center;
color: var(--accent);
margin-bottom: 0.5rem;
}
.subtitle {
text-align: center;
color: var(--text-secondary);
margin-bottom: 2rem;
}
#login-form {
display: flex;
flex-direction: column;
gap: 1rem;
}
input, select {
background: var(--bg-primary);
border: 1px solid var(--border);
color: var(--text-primary);
padding: 0.875rem 1rem;
border-radius: 8px;
font-size: 1rem;
transition: border-color 0.2s;
}
input:focus, select:focus {
outline: none;
border-color: var(--accent);
}
input::placeholder {
color: var(--text-secondary);
}
.btn-primary {
background: var(--accent);
color: white;
border: none;
padding: 0.875rem 1rem;
border-radius: 8px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
}
.btn-primary:hover {
background: var(--accent-hover);
}
.btn-primary:disabled {
background: var(--text-secondary);
cursor: not-allowed;
}
.btn-secondary {
background: var(--bg-tertiary);
color: var(--text-primary);
border: 1px solid var(--border);
padding: 0.875rem 1rem;
border-radius: 8px;
font-size: 1rem;
cursor: pointer;
transition: background 0.2s;
}
.btn-secondary:hover {
background: var(--bg-secondary);
}
.error {
color: var(--error);
text-align: center;
margin-top: 1rem;
padding: 0.75rem;
border-radius: 8px;
background: rgba(239, 68, 68, 0.1);
}
/* Main Screen */
#main-screen {
display: flex;
flex-direction: column;
height: 100vh;
}
/* Header */
.header {
background: var(--bg-secondary);
padding: 1rem 1.5rem;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid var(--border);
}
.header .logo {
font-size: 1.5rem;
margin: 0;
}
.account-info {
display: flex;
align-items: center;
gap: 1rem;
}
#account-name {
font-weight: 600;
}
.badge {
padding: 0.25rem 0.75rem;
border-radius: 20px;
font-size: 0.875rem;
font-weight: 500;
}
.badge.active {
background: rgba(74, 222, 128, 0.2);
color: var(--success);
}
.badge.inactive {
background: rgba(239, 68, 68, 0.2);
color: var(--error);
}
.role-badge {
background: rgba(99, 102, 241, 0.2);
color: #818cf8;
}
/* Main Content */
.main-content {
flex: 1;
display: grid;
grid-template-columns: 280px 1fr;
gap: 1px;
background: var(--border);
overflow: hidden;
}
/* Sidebar */
.sidebar {
background: var(--bg-secondary);
padding: 1rem;
overflow-y: auto;
}
.sidebar h2 {
font-size: 0.875rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-secondary);
margin-bottom: 1rem;
}
.instances-container {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.instance-card {
background: var(--bg-primary);
border: 1px solid var(--border);
border-radius: 10px;
padding: 1rem;
cursor: pointer;
transition: all 0.2s;
}
.instance-card:hover {
border-color: var(--accent);
transform: translateY(-2px);
}
.instance-card.selected {
border-color: var(--accent);
background: rgba(233, 69, 96, 0.1);
}
.instance-name {
font-weight: 600;
margin-bottom: 0.5rem;
}
.instance-details {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.instance-version, .instance-loader {
font-size: 0.75rem;
padding: 0.25rem 0.5rem;
border-radius: 4px;
background: var(--bg-tertiary);
}
.instance-server-version {
font-size: 0.75rem;
padding: 0.25rem 0.5rem;
border-radius: 4px;
background: rgba(251, 191, 36, 0.2);
color: var(--warning);
}
/* Logs Panel */
.logs-panel {
background: var(--bg-primary);
padding: 1rem;
display: flex;
flex-direction: column;
overflow: hidden;
}
.logs-panel h2 {
font-size: 0.875rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-secondary);
margin-bottom: 1rem;
}
#logs-container {
flex: 1;
background: #0d0d1a;
border-radius: 8px;
padding: 1rem;
font-family: 'Consolas', 'Monaco', monospace;
font-size: 0.875rem;
overflow-y: auto;
line-height: 1.6;
}
.log-line {
margin-bottom: 0.25rem;
white-space: pre-wrap;
word-break: break-all;
}
.log-line.info { color: var(--text-primary); }
.log-line.success { color: var(--success); }
.log-line.warning { color: var(--warning); }
.log-line.error { color: var(--error); }
/* Footer */
.footer {
background: var(--bg-secondary);
padding: 1rem 1.5rem;
display: flex;
justify-content: space-between;
align-items: center;
border-top: 1px solid var(--border);
}
.instance-info {
display: flex;
gap: 1rem;
align-items: center;
}
.instance-info span {
padding: 0.5rem 1rem;
background: var(--bg-primary);
border-radius: 6px;
font-size: 0.875rem;
}
#selected-name {
font-weight: 600;
color: var(--accent);
}
.btn-play {
background: var(--success);
color: #0a0a0a;
border: none;
padding: 0.875rem 2rem;
border-radius: 8px;
font-size: 1.125rem;
font-weight: 700;
cursor: pointer;
transition: all 0.2s;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.btn-play:hover:not(:disabled) {
transform: scale(1.05);
box-shadow: 0 0 20px rgba(74, 222, 128, 0.4);
}
.btn-play:disabled {
background: var(--text-secondary);
cursor: not-allowed;
}
.btn-play.update {
background: var(--warning);
}
/* Modal */
.modal {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.8);
display: flex;
justify-content: center;
align-items: center;
z-index: 100;
}
.modal-content {
background: var(--bg-secondary);
padding: 2rem;
border-radius: 16px;
width: 100%;
max-width: 450px;
border: 1px solid var(--border);
}
.modal-content h2 {
margin-bottom: 1.5rem;
}
#install-form {
display: flex;
flex-direction: column;
gap: 1rem;
}
#install-form label {
display: flex;
flex-direction: column;
gap: 0.5rem;
font-size: 0.875rem;
color: var(--text-secondary);
}
#install-form select, #install-form input {
width: 100%;
}
.modal-buttons {
display: flex;
gap: 1rem;
justify-content: flex-end;
margin-top: 1rem;
}
/* Scrollbar */
::-webkit-scrollbar {
width: 8px;
}
::-webkit-scrollbar-track {
background: var(--bg-primary);
}
::-webkit-scrollbar-thumb {
background: var(--bg-tertiary);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--border);
}
+88 -120
View File
@@ -6,8 +6,16 @@
<modelVersion>4.0.0</modelVersion>
<groupId>me.sashegdev</groupId>
<artifactId>ZernMCLauncher</artifactId>
<version>1.0.8</version>
<packaging>jar</packaging>
<version>1.0.9</version>
<packaging>pom</packaging>
<name>ZernMC Launcher Parent</name>
<description>ZernMC Launcher - Multi-module project</description>
<modules>
<module>bootstrap</module>
<module>launcher</module>
</modules>
<properties>
<maven.compiler.source>21</maven.compiler.source>
@@ -15,58 +23,84 @@
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.organization.name>ZernMC</project.organization.name>
<project.inceptionYear>2026</project.inceptionYear>
<project.description>ZernMC Launcher - just a minimalistic launcher by SashegDev</project.description>
<mainClass>me.sashegdev.zernmc.launcher.Main</mainClass>
<project.description>ZernMC Launcher - Multi-module project</project.description>
</properties>
<dependencies>
<dependency>
<groupId>org.apache.httpcomponents</groupId>
<artifactId>httpclient</artifactId>
<version>4.5.14</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.15.2</version>
</dependency>
<dependency>
<groupId>com.google.code.gson</groupId>
<artifactId>gson</artifactId>
<version>2.10.1</version>
</dependency>
<dependency>
<groupId>org.json</groupId>
<artifactId>json</artifactId>
<version>20231013</version>
</dependency>
<dependency>
<groupId>org.fusesource.jansi</groupId>
<artifactId>jansi</artifactId>
<version>2.4.1</version>
</dependency>
<dependency>
<groupId>org.jline</groupId>
<artifactId>jline</artifactId>
<version>3.24.1</version>
</dependency>
<dependency>
<groupId>me.tongfei</groupId>
<artifactId>progressbar</artifactId>
<version>0.9.5</version>
</dependency>
<dependency>
<groupId>commons-io</groupId>
<artifactId>commons-io</artifactId>
<version>2.15.1</version>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.10.1</version>
<scope>test</scope>
</dependency>
</dependencies>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.apache.httpcomponents</groupId>
<artifactId>httpclient</artifactId>
<version>4.5.14</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.15.2</version>
</dependency>
<dependency>
<groupId>com.google.code.gson</groupId>
<artifactId>gson</artifactId>
<version>2.10.1</version>
</dependency>
<dependency>
<groupId>org.json</groupId>
<artifactId>json</artifactId>
<version>20231013</version>
</dependency>
<dependency>
<groupId>org.fusesource.jansi</groupId>
<artifactId>jansi</artifactId>
<version>2.4.1</version>
</dependency>
<dependency>
<groupId>org.jline</groupId>
<artifactId>jline</artifactId>
<version>3.24.1</version>
</dependency>
<dependency>
<groupId>me.tongfei</groupId>
<artifactId>progressbar</artifactId>
<version>0.9.5</version>
</dependency>
<dependency>
<groupId>commons-io</groupId>
<artifactId>commons-io</artifactId>
<version>2.15.1</version>
</dependency>
<!-- JavaFX for Windows -->
<dependency>
<groupId>org.openjfx</groupId>
<artifactId>javafx-controls</artifactId>
<version>21</version>
<classifier>win</classifier>
</dependency>
<dependency>
<groupId>org.openjfx</groupId>
<artifactId>javafx-web</artifactId>
<version>21</version>
<classifier>win</classifier>
</dependency>
<dependency>
<groupId>org.openjfx</groupId>
<artifactId>javafx-graphics</artifactId>
<version>21</version>
<classifier>win</classifier>
</dependency>
<dependency>
<groupId>org.openjfx</groupId>
<artifactId>javafx-base</artifactId>
<version>21</version>
<classifier>win</classifier>
</dependency>
<dependency>
<groupId>org.openjfx</groupId>
<artifactId>javafx-media</artifactId>
<version>21</version>
<classifier>win</classifier>
</dependency>
</dependencies>
</dependencyManagement>
<build>
<plugins>
@@ -88,7 +122,7 @@
<goal>shade</goal>
</goals>
<configuration>
<outputFile>../server/builds/ZernMCLauncher.jar</outputFile>
<outputFile>../../server/builds/ZernMCLauncher.jar</outputFile>
<transformers>
<transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
<mainClass>${mainClass}</mainClass>
@@ -103,73 +137,7 @@
</transformers>
</configuration>
</execution>
</executions>
</plugin>
<!-- Launch4j для создания .exe -->
<plugin>
<groupId>com.akathist.maven.plugins.launch4j</groupId>
<artifactId>launch4j-maven-plugin</artifactId>
<version>2.5.0</version>
<executions>
<execution>
<id>l4j</id>
<phase>package</phase>
<goals>
<goal>launch4j</goal>
</goals>
<configuration>
<outfile>../server/builds/ZernMCLauncher-${project.version}.exe</outfile>
<jar>../server/builds/ZernMCLauncher.jar</jar>
<headerType>console</headerType>
<dontWrapJar>false</dontWrapJar>
<jre>
<path>jre21</path>
<minVersion>21</minVersion>
</jre>
<versionInfo>
<fileVersion>${project.version}.0</fileVersion>
<txtFileVersion>${project.version}</txtFileVersion>
<fileDescription>ZernMC Launcher — just a Minecraft launcher</fileDescription>
<productVersion>${project.version}.0</productVersion>
<txtProductVersion>${project.version}</txtProductVersion>
<productName>ZernMC Launcher</productName>
<companyName>ZernMC(SashegDev)</companyName>
<internalName>ZernMCLauncher</internalName>
<originalFilename>ZernMCLauncher-${project.version}.exe</originalFilename>
</versionInfo>
</configuration>
</execution>
</executions>
</plugin>
<!-- Antrun: копирование JRE и создание build.version + zip -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-antrun-plugin</artifactId>
<version>3.1.0</version>
<executions>
<execution>
<phase>package</phase>
<goals><goal>run</goal></goals>
<configuration>
<target>
<echo file="../server/builds/build.version">${project.version}</echo>
<!-- Копируем содержимое jre/jre21 в папку jre21 (без лишней вложенности) -->
<copy todir="../server/builds/jre21" overwrite="true">
<fileset dir="${user.home}/launcher/jre/jre21"/>
</copy>
<!-- Создаём zip только с .exe и jre21 (без .jar и build.version) -->
<zip destfile="../server/builds/ZernMCLauncher-${project.version}.zip"
basedir="../server/builds"
includes="ZernMCLauncher.exe,jre21/**"
excludes="*.jar,build.version"/>
</target>
</configuration>
</execution>
</executions>
</executions>
</plugin>
</plugins>
</build>
+1 -1
View File
@@ -19,7 +19,7 @@ def parse_args():
# Additional options
parser.add_argument("--host", default="0.0.0.0", help="Host to bind to (default: 0.0.0.0)")
parser.add_argument("--port", type=int, default=1582, help="Port to bind to (default: 1582)")
parser.add_argument("--workers", type=int, default=4, help="Number of workers for production mode")
parser.add_argument("--workers", type=int, default=1, help="Number of workers for production mode (default: 1, more causes file download slowdown)")
parser.add_argument("--reload", action="store_true", help="Enable auto-reload (development)")
return parser.parse_args()
+554 -24
View File
@@ -1,8 +1,11 @@
import re
import logging
import os
from contextlib import asynccontextmanager
from datetime import datetime
from pathlib import Path
from urllib.parse import urlparse
from typing import Optional
import httpx
import json
@@ -12,6 +15,10 @@ from fastapi import Depends, FastAPI, HTTPException, Request, Response
from fastapi.responses import FileResponse, JSONResponse
from uvicorn.protocols.http.httptools_impl import HttpToolsProtocol
# Disable httpx debug logging
logging.getLogger("httpx").setLevel(logging.WARNING)
logging.getLogger("httpcore").setLevel(logging.WARNING)
from pack_manager import DATA_DIR, scan_pack, get_cached_manifest, PACKS_DIR
from models import PackMeta
from middleware import LoggingMiddleware
@@ -28,6 +35,18 @@ logger = structlog.get_logger(__name__)
manifest_cache = TTLCache(maxsize=100, ttl=300)
BUILDS_DIR = Path("builds")
VERSIONS_DIR = BUILDS_DIR / "versions"
# 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(",")) - {""}
# Cache file for blocklist (load once)
BLOCKLIST_CACHE_FILE = Path("data/blocklist_cache.txt")
@asynccontextmanager
@@ -37,6 +56,60 @@ async def lifespan(app: FastAPI):
# Initialize logging
init_logging()
# Load public blocklists (single worker loads, others wait for cache)
USE_PUBLIC_BLOCKLIST = os.environ.get("PUBLIC_BLOCKLIST", "true").lower() == "true"
all_blocked = set(MANUAL_BLOCKED_IPS)
if USE_PUBLIC_BLOCKLIST:
cached_ips = set()
# Try to load from cache first
if BLOCKLIST_CACHE_FILE.exists():
try:
cached_ips = set(BLOCKLIST_CACHE_FILE.read_text().strip().splitlines())
if cached_ips:
logger.info(f"Loaded {len(cached_ips)} IPs from blocklist cache")
except Exception as e:
logger.warning(f"Failed to load blocklist cache: {e}")
# If no cache, download (only one worker will do this)
if not cached_ips:
DATA_DIR.mkdir(exist_ok=True)
lock_file = DATA_DIR / "blocklist.lock"
try:
# Try to acquire lock (non-blocking)
import fcntl
lock_fd = open(lock_file, 'w')
try:
fcntl.flock(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
# We got the lock - download
cached_ips = mw.load_public_blocklists()
if cached_ips:
BLOCKLIST_CACHE_FILE.write_text("\n".join(cached_ips))
logger.info(f"Downloaded and saved {len(cached_ips)} IPs to blocklist cache")
except BlockingIOError:
# Another process is downloading - wait for cache
pass
finally:
lock_fd.close()
except Exception as e:
logger.warning(f"Lock error: {e}")
# Re-read cache after download
if BLOCKLIST_CACHE_FILE.exists() and not cached_ips:
try:
cached_ips = set(BLOCKLIST_CACHE_FILE.read_text().strip().splitlines())
if cached_ips:
logger.info(f"Loaded {len(cached_ips)} IPs from blocklist cache (after wait)")
except Exception:
pass
all_blocked.update(cached_ips)
mw.set_ip_config(blocked=all_blocked)
logger.info(f"IP blocklist loaded: {len(all_blocked)} IPs")
# Determine environment
if args.test:
env = "test"
@@ -75,6 +148,26 @@ async def lifespan(app: FastAPI):
logger.info("All packs ready. Server is running.")
# Scan launcher versions and generate meta
logger.info("Scanning launcher versions...")
# Generate meta.json in builds/ directory
logger.info("Generating launcher meta...")
generate_launcher_builds_meta()
# Extract new format ZIPs to versions directory
logger.info("Extracting new format versions...")
extract_new_format_versions()
launcher_versions = get_launcher_versions()
if launcher_versions:
latest = launcher_versions[0]
logger.info(f"Launcher meta ready: v{latest['meta']['version']} ({len(latest['meta']['files'])} files)")
else:
logger.warning("No launcher versions found in new format")
logger.info("Launcher meta system ready.")
# Initialize proxy client
global proxy_client
proxy_client = httpx.AsyncClient(timeout=60.0, follow_redirects=True)
@@ -683,27 +776,280 @@ async def get_pack_file(pack_name: str, file_path: str, request: Request):
size=full_path.stat().st_size,
client_ip=client_ip)
return FileResponse(full_path)
return FileResponse(full_path, direct_passthrough=True)
# ====================== ЭНДПОИНТЫ ДЛЯ ЛАУНЧЕРА ======================
def get_current_launcher_version() -> str:
"""Get current launcher version from build.version file"""
"""Get current launcher version from meta system (new format) or build.version (legacy)"""
versions = get_launcher_versions()
if versions:
return versions[0]["meta"]["version"]
# Fallback to build.version for legacy
version_file = BUILDS_DIR / "build.version"
if version_file.exists():
return version_file.read_text().strip()
return "1.0.0"
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}
major, minor, patch, suffix = match.groups()
suffix = suffix.strip("-")
is_legacy = bool(suffix)
return {
"major": int(major),
"minor": int(minor),
"patch": int(patch),
"suffix": suffix,
"is_legacy": is_legacy
}
def is_new_format(filename: str) -> bool:
"""Check if filename represents new format build"""
return filename.startswith("ZernMC-win-")
# ====================== ЛАУНЧЕР МЕТА СИСТЕМА ======================
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):
hash_sha.update(chunk)
return hash_sha.hexdigest()
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
meta_path = BUILDS_DIR / "meta.json"
# Check if meta exists and is fresh
if meta_path.exists():
try:
with open(meta_path, 'r', encoding='utf-8') as f:
existing = json.load(f)
if existing.get("version") == version:
logger.debug("Launcher meta.json already exists and is current")
return
except Exception:
pass
# Generate new meta
files = []
try:
for file_path in BUILDS_DIR.rglob("*"):
if file_path.is_file() and file_path.name not in ["meta.json"]:
rel_path = str(file_path.relative_to(BUILDS_DIR))
stat = file_path.stat()
# Calculate hash
sha256_hash = hashlib.sha256()
with open(file_path, "rb") as f:
for chunk in iter(lambda: f.read(8192), b""):
sha256_hash.update(chunk)
files.append({
"path": rel_path,
"size": stat.st_size,
"hash": f"sha256:{sha256_hash.hexdigest()}"
})
except Exception as e:
logger.warning(f"Failed to generate launcher meta: {e}")
return
meta = {
"version": version,
"type": "builds",
"release_date": datetime.utcnow().isoformat(),
"files": files
}
try:
with open(meta_path, 'w', encoding='utf-8') as f:
json.dump(meta, f, indent=2)
logger.info(f"Generated launcher meta.json with {len(files)} files")
except Exception as e:
logger.warning(f"Failed to save meta.json: {e}")
def scan_launcher_version(version: str) -> Optional[dict]:
"""Scan a launcher version directory and return meta"""
# First check if meta exists in builds/ directly (for new format)
meta_path = BUILDS_DIR / "meta.json"
if meta_path.exists():
try:
with open(meta_path, 'r', encoding='utf-8') as f:
meta = json.load(f)
if meta.get("version") == version:
return meta
except Exception:
pass
# Fallback: check versions directory
version_path = VERSIONS_DIR / version
if not version_path.exists() or not version_path.is_dir():
return None
meta_path = version_path / "meta.json"
# 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:
pass
# Generate meta
files = []
for file_path in version_path.rglob("*"):
if file_path.is_file() and file_path.name != "meta.json":
rel_path = str(file_path.relative_to(version_path))
stat = file_path.stat()
file_hash = calculate_file_hash(file_path)
files.append({
"path": rel_path,
"size": stat.st_size,
"hash": f"sha256:{file_hash}"
})
meta = {
"version": version,
"type": "new",
"release_date": datetime.utcnow().isoformat(),
"files": files
}
# 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:
logger.warning(f"Failed to save launcher meta for {version}: {e}")
return meta
def parse_version_key(v: str) -> tuple:
"""Parse version string for proper numeric sorting"""
try:
parts = v.split(".")
return tuple(int(p) for p in parts)
except:
return (0, 0, 0)
def get_launcher_versions() -> list:
"""Get list of available launcher versions with meta"""
if not VERSIONS_DIR.exists():
return []
versions = []
for version_dir in VERSIONS_DIR.iterdir():
if version_dir.is_dir():
meta = scan_launcher_version(version_dir.name)
if meta:
versions.append({
"version": version_dir.name,
"meta": meta
})
versions.sort(key=lambda x: parse_version_key(x["version"]), reverse=True)
return versions
def get_launcher_version_meta(version: str) -> Optional[dict]:
"""Get meta for specific launcher version"""
return scan_launcher_version(version)
def extract_new_format_versions():
"""Extract new format ZIPs to versions directory"""
VERSIONS_DIR.mkdir(exist_ok=True)
# Find all ZernMC-win-*.zip files
new_format_zips = list(BUILDS_DIR.glob("ZernMC-win-*.zip"))
for zip_file in new_format_zips:
version = zip_file.stem.replace("ZernMC-win-", "")
extract_dir = VERSIONS_DIR / version
# Skip if already extracted and meta exists
if extract_dir.exists() and (extract_dir / "meta.json").exists():
logger.debug(f"Version {version} already extracted")
continue
logger.info(f"Extracting {zip_file.name} to versions/{version}/...")
try:
import zipfile
with zipfile.ZipFile(zip_file, 'r') as zf:
# Extract all files
zf.extractall(extract_dir)
logger.info(f"Extracted {zip_file.name} successfully")
except Exception as e:
logger.error(f"Failed to extract {zip_file.name}: {e}")
# ====================== END ЛАУНЧЕР МЕТА СИСТЕМА ======================
def get_available_zips() -> list:
"""Get list of available zip archives"""
"""Get list of available zip archives (new format only)"""
if not BUILDS_DIR.exists():
return []
zips = []
for zip_file in BUILDS_DIR.glob("ZernMCLauncher-*.zip"):
if is_new_format(zip_file.name):
continue
version = zip_file.stem.replace("ZernMCLauncher-", "")
parsed = parse_version(version)
stat = zip_file.stat()
zips.append({
"version": version,
"filename": zip_file.name,
"size": stat.st_size,
"modified": datetime.fromtimestamp(stat.st_mtime).isoformat(),
"is_legacy": parsed["is_legacy"]
})
zips.sort(key=lambda x: parse_version_key(x["version"]), reverse=True)
return zips
def get_new_format_zips() -> list:
"""Get list of available zip archives (new format: ZernMC-win-*.zip)"""
if not BUILDS_DIR.exists():
return []
zips = []
for zip_file in BUILDS_DIR.glob("ZernMC-win-*.zip"):
version = zip_file.stem.replace("ZernMC-win-", "")
stat = zip_file.stat()
zips.append({
"version": version,
@@ -712,7 +1058,37 @@ def get_available_zips() -> list:
"modified": datetime.fromtimestamp(stat.st_mtime).isoformat()
})
zips.sort(key=lambda x: x["version"], reverse=True)
zips.sort(key=lambda x: parse_version_key(x["version"]), reverse=True)
return zips
def get_legacy_zips() -> list:
"""Get list of available legacy zip archives (< 1.0.8 or with suffix)"""
if not BUILDS_DIR.exists():
return []
zips = []
for zip_file in BUILDS_DIR.glob("ZernMCLauncher-*.zip"):
version = zip_file.stem.replace("ZernMCLauncher-", "")
parsed = parse_version(version)
is_legacy = (
parsed["is_legacy"] or
(parsed["major"] < 1) or
(parsed["major"] == 1 and parsed["minor"] == 0 and parsed["patch"] < 8)
)
if is_legacy:
stat = zip_file.stat()
zips.append({
"version": version,
"filename": zip_file.name,
"size": stat.st_size,
"modified": datetime.fromtimestamp(stat.st_mtime).isoformat(),
"is_legacy": True
})
zips.sort(key=lambda x: parse_version_key(x["version"]), reverse=True)
return zips
@@ -720,7 +1096,8 @@ def get_available_zips() -> list:
async def get_launcher_version():
"""Return launcher version information"""
version = get_current_launcher_version()
zips = get_available_zips()
new_zips = get_new_format_zips()
legacy_zips = get_legacy_zips()
response = {
"version": version,
@@ -737,11 +1114,15 @@ async def get_launcher_version():
response["download_exe"] = "/launcher/download/exe"
response["exe_size"] = exe_path.stat().st_size
if zips:
response["download_zip"] = f"/launcher/download/zip/{zips[0]['filename']}"
response["zip_version"] = zips[0]["version"]
response["zip_size"] = zips[0]["size"]
response["all_zips"] = zips
if new_zips:
response["download_zip"] = f"/launcher/download/zip/{new_zips[0]['filename']}"
response["zip_version"] = new_zips[0]["version"]
response["zip_size"] = new_zips[0]["size"]
response["all_zips"] = new_zips
if legacy_zips:
response["legacy_zips"] = legacy_zips
response["legacy_download_url"] = "/launcher/download/legacy"
return response
@@ -749,14 +1130,17 @@ async def get_launcher_version():
@app.get("/launcher/download/jar")
async def download_launcher_jar():
"""Download launcher JAR file"""
file_path = BUILDS_DIR / "ZernMCLauncher.jar"
# Prefer new shaded JAR, fallback to old
file_path = BUILDS_DIR / "zernmclauncher.jar"
if not file_path.exists():
file_path = BUILDS_DIR / "ZernMCLauncher.jar"
if not file_path.exists():
raise HTTPException(404, "JAR file not found")
return FileResponse(
path=file_path,
filename="ZernMCLauncher.jar",
filename="zernmclauncher.jar",
media_type="application/java-archive"
)
@@ -779,7 +1163,8 @@ async def download_launcher_exe():
@app.get("/launcher/download/zip/{filename}")
async def download_launcher_zip(filename: str):
"""Download specific launcher ZIP archive"""
if ".." in filename or not filename.startswith("ZernMCLauncher-") or not filename.endswith(".zip"):
valid_patterns = ["ZernMCLauncher-", "ZernMC-win-"]
if ".." in filename or not any(filename.startswith(p) for p in valid_patterns) or not filename.endswith(".zip"):
raise HTTPException(400, "Invalid filename")
file_path = BUILDS_DIR / filename
@@ -796,25 +1181,158 @@ async def download_launcher_zip(filename: str):
@app.get("/launcher/download/latest")
async def download_latest_launcher():
"""Download the latest launcher (prefer ZIP if available, fallback to JAR)"""
zips = get_available_zips()
"""Download the latest launcher (new format: ZernMC-win-*.zip)"""
zips = get_new_format_zips()
if zips:
latest_zip = zips[0]["filename"]
return await download_launcher_zip(latest_zip)
jar_path = BUILDS_DIR / "ZernMCLauncher.jar"
if jar_path.exists():
return await download_launcher_jar()
raise HTTPException(404, "No new format launcher files available")
raise HTTPException(404, "No launcher files available")
@app.get("/launcher/download/legacy")
async def download_legacy_launcher():
"""Download the latest legacy launcher (< 1.0.8 or with suffix)"""
zips = get_legacy_zips()
if zips:
latest_zip = zips[0]["filename"]
return await download_launcher_zip(latest_zip)
raise HTTPException(404, "No legacy launcher files available")
@app.get("/launcher/download/zip/{filename}")
async def download_launcher_zip(filename: str):
"""Download specific launcher ZIP archive"""
if ".." in filename:
raise HTTPException(400, "Invalid filename")
valid_patterns = ["ZernMCLauncher-", "ZernMC-win-"]
if not any(filename.startswith(p) for p in valid_patterns) or not filename.endswith(".zip"):
raise HTTPException(400, "Invalid filename")
file_path = BUILDS_DIR / filename
if not file_path.exists():
raise HTTPException(404, "ZIP file not found")
return FileResponse(
path=file_path,
filename=filename,
media_type="application/zip"
)
# ====================== ЛАУНЧЕР МЕТА ЭНДПОИНТЫ ======================
@app.get("/launcher/meta")
async def get_launcher_meta_list():
"""Get list of all launcher versions with meta (new format)"""
versions = get_launcher_versions()
return {
"versions": [
{"version": v["version"], "meta": v["meta"]}
for v in versions
]
}
@app.get("/launcher/meta/{version}")
async def get_launcher_version_meta_handler(version: str):
"""Get meta for specific launcher version"""
meta = get_launcher_version_meta(version)
if not meta:
raise HTTPException(404, f"Version {version} not found or not in new format")
return meta
@app.post("/launcher/diff")
async def get_launcher_diff(request: Request):
"""Get diff for launcher update - compare local files with server version"""
body = await request.json()
# Get latest version
versions = get_launcher_versions()
if not versions:
raise HTTPException(404, "No versions available")
latest = versions[0]
meta = latest["meta"]
# Build hash map from client
client_hashes = body # {filename: hash, ...}
to_download = []
to_delete = []
# Find new/updated files
server_files = {f["path"]: f for f in meta["files"]}
for path, file_info in server_files.items():
if path not in client_hashes:
to_download.append(file_info)
elif client_hashes[path] != file_info["hash"]:
to_download.append(file_info)
# Find deleted files (files on server but not in client)
for path in client_hashes:
if path not in server_files:
to_delete.append(path)
return {
"version": meta["version"],
"to_download": to_download,
"to_delete": to_delete
}
@app.get("/launcher/file/{version}/{file_path:path}")
async def get_launcher_file(version: str, file_path: str, request: Request):
"""Download a specific file from a launcher version"""
# Ищем в builds/ директории (где лежит zernmc.exe, lib, assets и т.д.)
full_path = BUILDS_DIR / file_path
# Security: prevent path traversal
if ".." in file_path:
raise HTTPException(403, "Invalid file path")
if not full_path.exists() or not full_path.is_file():
# Fallback: ищем в versions директории
alt_path = VERSIONS_DIR / version / file_path
if alt_path.exists() and alt_path.is_file():
full_path = alt_path
else:
raise HTTPException(404, "File not found: " + file_path)
return FileResponse(full_path, direct_passthrough=True)
@app.get("/launcher/download/zip/{version}")
async def download_launcher_zip_version(version: str):
"""Download full ZIP for specific version (for new installs)"""
zip_path = BUILDS_DIR / f"ZernMC-win-{version}.zip"
if not zip_path.exists():
raise HTTPException(404, f"ZIP for version {version} not found")
return FileResponse(
path=zip_path,
filename=f"ZernMC-win-{version}.zip",
media_type="application/zip"
)
# ====================== END ЛАУНЧЕР МЕТА ЭНДПОИНТЫ ======================
@app.get("/launcher/info")
async def get_launcher_full_info():
"""Full launcher information with all available files"""
version = get_current_launcher_version()
zips = get_available_zips()
new_zips = get_new_format_zips()
legacy_zips = get_legacy_zips()
info = {
"current_version": version,
@@ -822,9 +1340,21 @@ async def get_launcher_full_info():
"files": {
"jar": None,
"exe": None,
"zips": zips
"zips": new_zips + legacy_zips
},
"recommended": "zip" if zips else ("exe" if (BUILDS_DIR / "ZernMCLauncher.exe").exists() else "jar")
"recommended": "zip" if new_zips else ("exe" if (BUILDS_DIR / "ZernMCLauncher.exe").exists() else "jar"),
"new_format": {
"available": len(new_zips) > 0,
"latest": new_zips[0] if new_zips else None,
"download_url": "/launcher/download/latest"
},
"legacy": {
"available": len(legacy_zips) > 0,
"count": len(legacy_zips),
"latest": legacy_zips[0] if legacy_zips else None,
"download_url": "/launcher/download/legacy",
"warning": "Legacy builds are technically compatible but not recommended. Consider upgrading to new format."
}
}
jar_path = BUILDS_DIR / "ZernMCLauncher.jar"
@@ -841,8 +1371,8 @@ async def get_launcher_full_info():
"download_url": "/launcher/download/exe"
}
if zips:
info["files"]["latest_zip"] = zips[0]
if new_zips:
info["files"]["latest_zip"] = new_zips[0]
info["files"]["download_latest"] = "/launcher/download/latest"
return info
+176 -17
View File
@@ -5,43 +5,202 @@ import logging
import time
import uuid
import traceback
import httpx
import re
from collections import defaultdict
from typing import Optional
logger = logging.getLogger(__name__)
# Public blocklist URLs
BLOCKLIST_URLS = [
"https://raw.githubusercontent.com/firehol/blocklist-ipsets/master/firehol_level1.netset",
"https://raw.githubusercontent.com/firehol/blocklist-ipsets/master/iblocklist_isp.netset",
]
def load_blocklist_from_url(url: str, timeout: int = 10) -> set[str]:
"""Download and parse IP blocklist from URL"""
ips = set()
try:
response = httpx.get(url, timeout=timeout, follow_redirects=True)
if response.status_code == 200:
for line in response.text.splitlines():
line = line.strip()
if not line or line.startswith("#"):
continue
if re.match(r"^\d+\.\d+\.\d+\.\d+(/\d+)?$", line):
ip = line.split("/")[0]
ips.add(ip)
logger.info(f"Loaded {len(ips)} IPs from blocklist: {url}")
except Exception as e:
logger.warning(f"Failed to load blocklist from {url}: {e}")
return ips
def load_public_blocklists() -> set[str]:
"""Load all public blocklists"""
all_ips = set()
for url in BLOCKLIST_URLS:
all_ips.update(load_blocklist_from_url(url))
logger.info(f"Total blocked IPs from public lists: {len(all_ips)}")
return all_ips
# Rate limiting config
RATE_LIMIT_REQUESTS = 60 # Max requests per window
RATE_LIMIT_WINDOW = 60 # Window in seconds
_ip_request_counts: dict[str, list[float]] = defaultdict(list)
# IP blocking config (set from main.py)
BLOCKED_IPS: set[str] = set()
# Request stats (for summary logging)
_stats = {"blocked": 0, "rate_limited": 0, "total": 0}
_stats_last_log = time.time()
STATS_LOG_INTERVAL = 60 # Log stats every 60 seconds
# Suspicious paths that indicate bot scanning
SUSPICIOUS_PATHS = {
".env", ".env.local", ".env.production", ".env.development", ".env.bak",
".env.old", ".env.backup", ".env.orig", ".env.save", ".env~", ".env.swp",
".env.copy", ".env.1", ".ENV",
"appsettings.json", "appsettings.Development.json", "appsettings.Production.json",
"appsettings.Staging.json", "web.config",
"phpinfo.php", "info.php", "test.php", "i.php", "phpi.php", "php.php",
"phptest.php", "server-info.php", "phpinformation.php", "infophp.php",
"php_info.php", "config.php",
"actuator/env", "actuator/configprops", "actuator",
"manage/env", "admin/env", "env",
"actuator/env/aws", "actuator/env/cloud",
"_layouts/15/", "_layouts/15/ToolPane.aspx",
"wp-admin", "wp-login.php", "wordpress",
"administrator", "phpmyadmin",
".git", ".svn", ".hg",
}
def get_client_ip(request: Request) -> str:
"""Extract client IP from request"""
client_ip = request.client.host if request.client else "unknown"
forwarded = request.headers.get("x-forwarded-for")
if forwarded:
client_ip = forwarded.split(",")[0].strip()
return client_ip
def is_ip_blocked(client_ip: str) -> bool:
"""Check if IP is blocked"""
return client_ip in BLOCKED_IPS
def check_rate_limit(client_ip: str) -> bool:
"""Check if IP has exceeded rate limit"""
now = time.time()
# Clean old requests
_ip_request_counts[client_ip] = [
t for t in _ip_request_counts[client_ip]
if now - t < RATE_LIMIT_WINDOW
]
if len(_ip_request_counts[client_ip]) >= RATE_LIMIT_REQUESTS:
return False
_ip_request_counts[client_ip].append(now)
return True
def is_suspicious_path(path: str) -> bool:
"""Check if path is suspicious (bot scanning)"""
path_lower = path.lower()
# Direct match
if path_lower in SUSPICIOUS_PATHS:
return True
# Contains suspicious patterns
suspicious_patterns = [
".env", "phpinfo", "actuator", "wp-", "phpmyadmin",
".git", ".svn",
]
for pattern in suspicious_patterns:
if pattern in path_lower:
return True
# Path traversal attempts
if ".." in path or ".." in path.replace("%2e%2e", "").replace("%252e", ""):
return True
return False
def set_ip_config(blocked: Optional[set[str]] = None):
"""Configure IP blocking (call from main.py)"""
global BLOCKED_IPS
if blocked is not None:
BLOCKED_IPS = blocked
class LoggingMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
# Generate request ID
request_id = str(uuid.uuid4())[:8]
global _stats, _stats_last_log
# Get client IP
client_ip = request.client.host if request.client else "unknown"
forwarded = request.headers.get("x-forwarded-for")
if forwarded:
client_ip = forwarded.split(",")[0].strip()
client_ip = get_client_ip(request)
# Log incoming request
logger.info(f"{request.method} {request.url.path} (IP: {client_ip}, ID: {request_id})")
# Check if IP is blocked (silent)
if is_ip_blocked(client_ip):
_stats["blocked"] += 1
return Response(status_code=404, content="")
# Start timer
# Check rate limit
if not check_rate_limit(client_ip):
_stats["rate_limited"] += 1
# Periodic stats logging instead of every warning
if time.time() - _stats_last_log > STATS_LOG_INTERVAL:
logger.warning(f"Stats: {_stats}")
_stats_last_log = time.time()
return Response(status_code=429, content="Too many requests")
# Check suspicious path (silent 404 for bots)
path = request.url.path
if is_suspicious_path(path):
# Return 404 without logging - confuse the bots
return Response(status_code=404, content="")
# Skip logging for large file downloads (don't spam logs)
is_file_download = path.startswith("/pack/") and "/file/" in path
# Track total requests for stats
_stats["total"] += 1
# Log legitimate requests (except file downloads)
start_time = time.time()
if not is_file_download:
logger.info(f"{request.method} {path} (IP: {client_ip}, ID: {request_id})")
try:
response = await call_next(request)
# Calculate duration
duration = (time.time() - start_time) * 1000
# Log response
logger.info(f"{request.method} {request.url.path}{response.status_code} ({duration:.0f}ms) [ID: {request_id}]")
if not is_file_download:
logger.info(f"{request.method} {path}{response.status_code} ({duration:.0f}ms) [ID: {request_id}]")
# Periodic stats logging (only log if there were blocked/rate-limited)
now = time.time()
if now - _stats_last_log > STATS_LOG_INTERVAL:
if _stats["blocked"] > 0 or _stats["rate_limited"] > 0:
logger.warning(f"Blocked requests: IP_blocked={_stats['blocked']}, rate_limited={_stats['rate_limited']}")
_stats = {"blocked": 0, "rate_limited": 0, "total": 0}
_stats_last_log = now
# Add request ID to response headers
response.headers["X-Request-ID"] = request_id
return response
except Exception as e:
duration = (time.time() - start_time) * 1000
# Log full traceback
error_traceback = traceback.format_exc()
logger.error(f"{request.method} {request.url.path} → ERROR: {str(e)} (ID: {request_id})\n{error_traceback}")
logger.error(f"{request.method} {path} → ERROR: {str(e)} (ID: {request_id})\n{error_traceback}")
raise