1 Commits

Author SHA1 Message Date
SashegDev c9ed825686 feat(ui): add Web UI with JavaFX, install service, and new tests
- Add JavaFX WebView for native window UI (fallback to TUI on headless)
- Create WebServer with Javalin HTTP server
- Add webapp with dark theme and grid animation
- Create InstallService for ZernMC pack installation
- Integrate CLI installation logic via PackDownloader
- Add verifyHashes() using /pack/{name}/diff endpoint
- Add API endpoints: /instances/zernmc/install, /instances/{name}/updates, /instances/{name}/verify, /instances/{name}/playtime
- Add 14 new tests (WebServerTest, HeadlessDetectionTest, InstanceServiceTest)
- Total 44 tests now passing
2026-05-05 06:48:27 +00:00
61 changed files with 2692 additions and 3530 deletions
+1 -2
View File
@@ -2,8 +2,6 @@ logs/
__pycache__/ __pycache__/
./.venv/ ./.venv/
launcher/target launcher/target
bootstrap/target
src/target
server/builds server/builds
server/packs server/packs
server/data server/data
@@ -12,3 +10,4 @@ jre
dependency-reduced-pom.xml dependency-reduced-pom.xml
OpenJDK21U-jre_x64_windows_hotspot_21.0.6_7.zip OpenJDK21U-jre_x64_windows_hotspot_21.0.6_7.zip
telegram-bot/ telegram-bot/
.env
-24
View File
@@ -1,24 +0,0 @@
# 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
@@ -1,56 +0,0 @@
<?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>
@@ -1,410 +0,0 @@
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;
}
}
+7 -43
View File
@@ -3,13 +3,9 @@
<modelVersion>4.0.0</modelVersion> <modelVersion>4.0.0</modelVersion>
<groupId>me.sashegdev</groupId> <groupId>me.sashegdev</groupId>
<artifactId>ZernMCLauncher</artifactId> <artifactId>ZernMCLauncher</artifactId>
<version>1.0.8</version> <version>1.0.7</version>
<build> <build>
<plugins> <plugins>
<plugin>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.2.3</version>
</plugin>
<plugin> <plugin>
<artifactId>maven-shade-plugin</artifactId> <artifactId>maven-shade-plugin</artifactId>
<version>3.5.0</version> <version>3.5.0</version>
@@ -28,7 +24,7 @@
<Implementation-Version>${project.version}</Implementation-Version> <Implementation-Version>${project.version}</Implementation-Version>
<Implementation-Title>ZernMC Launcher</Implementation-Title> <Implementation-Title>ZernMC Launcher</Implementation-Title>
<Implementation-Vendor>SashegDev</Implementation-Vendor> <Implementation-Vendor>SashegDev</Implementation-Vendor>
<Implementation-Description>Samopisnui Minecraft-launcher. by SashegDev</Implementation-Description> <Implementation-Description>Полностью самописный Minecraft-лаунчер. Написанный SashegDev(в основном)</Implementation-Description>
<Implementation-URL>https://github.com/SashegDev/launcher</Implementation-URL> <Implementation-URL>https://github.com/SashegDev/launcher</Implementation-URL>
</manifestEntries> </manifestEntries>
</transformer> </transformer>
@@ -49,11 +45,10 @@
<goal>launch4j</goal> <goal>launch4j</goal>
</goals> </goals>
<configuration> <configuration>
<outfile>../server/builds/ZernMCLauncher-${project.version}.exe</outfile> <outfile>../server/builds/ZernMCLauncher.exe</outfile>
<jar>../server/builds/ZernMCLauncher.jar</jar> <jar>../server/builds/ZernMCLauncher.jar</jar>
<headerType>console</headerType> <headerType>console</headerType>
<dontWrapJar>false</dontWrapJar> <dontWrapJar>false</dontWrapJar>
<mainClass>me.sashegdev.zernmc.launcher.Bootstrap</mainClass>
<jre> <jre>
<path>jre21</path> <path>jre21</path>
<minVersion>21</minVersion> <minVersion>21</minVersion>
@@ -61,13 +56,13 @@
<versionInfo> <versionInfo>
<fileVersion>${project.version}.0</fileVersion> <fileVersion>${project.version}.0</fileVersion>
<txtFileVersion>${project.version}</txtFileVersion> <txtFileVersion>${project.version}</txtFileVersion>
<fileDescription>ZernMC Launcher — just a Minecraft launcher</fileDescription> <fileDescription>ZernMC Launcher — A Little Minecraft Launcher</fileDescription>
<productVersion>${project.version}.0</productVersion> <productVersion>${project.version}.0</productVersion>
<txtProductVersion>${project.version}</txtProductVersion> <txtProductVersion>${project.version}</txtProductVersion>
<productName>ZernMC Launcher</productName> <productName>ZernMC Launcher</productName>
<companyName>ZernMC(SashegDev)</companyName> <companyName>ZernMC(SashegDev)</companyName>
<internalName>ZernMCLauncher</internalName> <internalName>ZernMCLauncher</internalName>
<originalFilename>ZernMCLauncher-${project.version}.exe</originalFilename> <originalFilename>ZernMCLauncher.exe</originalFilename>
</versionInfo> </versionInfo>
</configuration> </configuration>
</execution> </execution>
@@ -85,15 +80,9 @@
<configuration> <configuration>
<target> <target>
<echo>${project.version}</echo> <echo>${project.version}</echo>
<delete />
<mkdir />
<copy> <copy>
<fileset> <fileset />
<include />
<include />
</fileset>
</copy> </copy>
<move />
<zip /> <zip />
</target> </target>
</configuration> </configuration>
@@ -120,35 +109,10 @@
</properties> </properties>
</profile> </profile>
</profiles> </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> <properties>
<project.description>ZernMC Launcher - just a minimalistic launcher by SashegDev</project.description> <maven.compiler.target>21</maven.compiler.target>
<mainClass>me.sashegdev.zernmc.launcher.Main</mainClass> <mainClass>me.sashegdev.zernmc.launcher.Main</mainClass>
<maven.compiler.source>21</maven.compiler.source> <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.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.inceptionYear>2026</project.inceptionYear>
</properties> </properties>
</project> </project>
-251
View File
@@ -1,251 +0,0 @@
<?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>
@@ -1,215 +0,0 @@
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;
}
}
@@ -1,173 +0,0 @@
package me.sashegdev.zernmc.launcher;
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.util.List;
public class Main {
private static final String CURRENT_VERSION = Version.getCurrentVersion();
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();
// === АВТОРИЗАЦИЯ (используем новый API) ===
System.out.println(ZAnsi.cyan("Проверка авторизации..."));
var sessionResponse = api.checkSession();
if (!sessionResponse.isSuccess()) {
LoginMenu loginMenu = new LoginMenu();
boolean loggedIn = loginMenu.show();
if (!loggedIn) {
System.out.println(ZAnsi.yellow("До свидания!"));
ZAnsi.uninstall();
System.exit(0);
}
} else {
var sessionInfo = sessionResponse.getData();
System.out.println(ZAnsi.brightGreen("Добро пожаловать обратно, " + sessionInfo.getUsername() + "!"));
}
// === ГЛАВНЫЙ ЦИКЛ ===
try {
mainLoop();
} catch (Exception e) {
System.err.println(ZAnsi.brightRed("Критическая ошибка: " + e.getMessage()));
e.printStackTrace();
} finally {
ZAnsi.uninstall();
}
}
// ====================== ГЛАВНЫЙ ЦИКЛ ======================
private static void mainLoop() throws Exception {
if (Config.isZernMCBuild()) {
zernMCFlow();
} else {
globalFlow();
}
}
// ====================== ZERNMC FLOW ======================
private static void zernMCFlow() throws Exception {
ConsoleUtils.clearScreen();
System.out.println(ZAnsi.header("=== ZernMC Private Launcher ==="));
// 1. Проверка подключения к серверу
System.out.println(ZAnsi.cyan("Проверка подключения к ZernMC серверу..."));
try {
String response = ZHttpClient.get("/health");
System.out.println(ZAnsi.brightGreen("✓ Сервер доступен"));
} catch (Exception e) {
System.out.println(ZAnsi.brightRed("✗ Не удалось подключиться к ZernMC серверу"));
System.out.println(ZAnsi.white("Ошибка: " + e.getMessage()));
ConsoleUtils.pause();
System.exit(1);
}
// 2. Авторизация
boolean sessionRestored = AuthManager.loadSavedSession();
if (!sessionRestored) {
LoginMenu loginMenu = new LoginMenu();
boolean loggedIn = loginMenu.show();
if (!loggedIn) {
System.exit(0);
}
} else {
System.out.println(ZAnsi.brightGreen("Добро пожаловать обратно, " + AuthManager.getUsername() + "!"));
}
// 3. Запуск меню (LaunchMenu сам определит режим и вызовет нужный flow)
LaunchMenu launchMenu = new LaunchMenu();
launchMenu.show(); // ← Здесь будет вызван showZernMCOnly() внутри
}
// ====================== GLOBAL FLOW ======================
private static void globalFlow() throws Exception {
while (true) {
ConsoleUtils.clearScreen();
System.out.println(ZAnsi.header("=== ZernMC Launcher ==="));
List<String> options = List.of(
"Запустить игру",
"Проверка обновлений",
"Настройки",
"Проверка подключения к серверам",
"Выход"
);
ArrowMenu menu = new ArrowMenu("Главное меню", options);
int choice = menu.show();
if (choice == -1 || choice == 4) {
System.out.println(ZAnsi.yellow("До свидания!"));
break;
}
switch (choice) {
case 0 -> new LaunchMenu().show(); // обычный LaunchMenu
case 1 -> new UpdateMenu().show();
case 2 -> new SettingsMenu().show();
case 3 -> new ServerCheckMenu().show();
}
}
}
}
@@ -1,463 +0,0 @@
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);
}
}
@@ -1,100 +0,0 @@
<!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>
@@ -1,311 +0,0 @@
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;
@@ -1,438 +0,0 @@
: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);
}
+75 -43
View File
@@ -6,16 +6,8 @@
<modelVersion>4.0.0</modelVersion> <modelVersion>4.0.0</modelVersion>
<groupId>me.sashegdev</groupId> <groupId>me.sashegdev</groupId>
<artifactId>ZernMCLauncher</artifactId> <artifactId>ZernMCLauncher</artifactId>
<version>1.0.9</version> <version>1.0.8</version>
<packaging>pom</packaging> <packaging>jar</packaging>
<name>ZernMC Launcher Parent</name>
<description>ZernMC Launcher - Multi-module project</description>
<modules>
<module>bootstrap</module>
<module>launcher</module>
</modules>
<properties> <properties>
<maven.compiler.source>21</maven.compiler.source> <maven.compiler.source>21</maven.compiler.source>
@@ -23,10 +15,10 @@
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.organization.name>ZernMC</project.organization.name> <project.organization.name>ZernMC</project.organization.name>
<project.inceptionYear>2026</project.inceptionYear> <project.inceptionYear>2026</project.inceptionYear>
<project.description>ZernMC Launcher - Multi-module project</project.description> <project.description>ZernMC Launcher - just a minimalistic launcher by SashegDev</project.description>
<mainClass>me.sashegdev.zernmc.launcher.Main</mainClass>
</properties> </properties>
<dependencyManagement>
<dependencies> <dependencies>
<dependency> <dependency>
<groupId>org.apache.httpcomponents</groupId> <groupId>org.apache.httpcomponents</groupId>
@@ -68,39 +60,13 @@
<artifactId>commons-io</artifactId> <artifactId>commons-io</artifactId>
<version>2.15.1</version> <version>2.15.1</version>
</dependency> </dependency>
<!-- JavaFX for Windows -->
<dependency> <dependency>
<groupId>org.openjfx</groupId> <groupId>org.junit.jupiter</groupId>
<artifactId>javafx-controls</artifactId> <artifactId>junit-jupiter</artifactId>
<version>21</version> <version>5.10.1</version>
<classifier>win</classifier> <scope>test</scope>
</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> </dependency>
</dependencies> </dependencies>
</dependencyManagement>
<build> <build>
<plugins> <plugins>
@@ -122,7 +88,7 @@
<goal>shade</goal> <goal>shade</goal>
</goals> </goals>
<configuration> <configuration>
<outputFile>../../server/builds/ZernMCLauncher.jar</outputFile> <outputFile>../server/builds/ZernMCLauncher.jar</outputFile>
<transformers> <transformers>
<transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer"> <transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
<mainClass>${mainClass}</mainClass> <mainClass>${mainClass}</mainClass>
@@ -139,6 +105,72 @@
</execution> </execution>
</executions> </executions>
</plugin> </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>
</plugin>
</plugins> </plugins>
</build> </build>
<profiles> <profiles>
@@ -0,0 +1,287 @@
package me.sashegdev.zernmc.launcher;
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.utils.*;
import me.sashegdev.zernmc.launcher.web.UIWindow;
import me.sashegdev.zernmc.launcher.web.WebServer;
import java.awt.GraphicsEnvironment;
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.Arrays;
import java.util.List;
public class Main {
private static final String CURRENT_VERSION = Version.getCurrentVersion();
private static final LauncherAPI api = new LauncherAPI();
public static void main(String[] args) throws Exception {
boolean cliMode = Arrays.asList(args).contains("--cli") || Arrays.asList(args).contains("-c");
if (cliMode) {
runTUI(args);
} else {
try {
startWebUI(args);
} catch (Exception e) {
System.err.println(ZAnsi.red("UI не запустился: " + e.getMessage()));
System.out.println(ZAnsi.yellow("Переключаюсь на режим TUI..."));
runTUI(args);
}
}
}
private static void startWebUI(String[] args) throws Exception {
System.setProperty("org.jline.terminal.disableDeprecatedProviderWarning", "true");
System.setProperty("file.encoding", "UTF-8");
int startPort = 8080;
for (int i = 0; i < args.length - 1; i++) {
if (args[i].equals("--port") || args[i].equals("-p")) {
startPort = Integer.parseInt(args[i + 1]);
}
}
System.out.println(ZAnsi.brightGreen("Запуск Web UI..."));
System.out.println(ZAnsi.cyan("Поиск свободного порта..."));
int port = WebServer.findFreePort(startPort);
// Запускаем WebServer в отдельном потоке
Thread serverThread = new Thread(() -> {
try {
WebServer.start(port);
} catch (Exception e) {
System.err.println("WebServer error: " + e.getMessage());
}
});
serverThread.setDaemon(true);
serverThread.start();
// Даем серверу время запуститься
Thread.sleep(1000);
// Проверяем headless перед запуском JavaFX
if (java.awt.GraphicsEnvironment.isHeadless()) {
System.out.println(ZAnsi.yellow("Дисплей недоступен, переключаюсь на TUI..."));
WebServer.stop();
runTUI(args);
return;
}
// Запускаем JavaFX окно
UIWindow.start(port);
}
private static void runTUI(String[] args) throws IOException {
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");
ZAnsi.install();
System.out.print("\033[H\033[2J");
System.out.println(ZAnsi.brightGreen("Добро пожаловать в ZernMC Launcher " + CURRENT_VERSION) + ZAnsi.cyan(" [CLI mode]"));
// Проверка всех сервисов при старте
ZHttpClient.checkAllServicesOnStartup();
checkAndAutoUpdateLauncher();
// === АВТОРИЗАЦИЯ (используем новый API) ===
System.out.println(ZAnsi.cyan("Проверка авторизации..."));
var sessionResponse = api.checkSession();
if (!sessionResponse.isSuccess()) {
LoginMenu loginMenu = new LoginMenu();
boolean loggedIn = loginMenu.show();
if (!loggedIn) {
System.out.println(ZAnsi.yellow("До свидания!"));
ZAnsi.uninstall();
System.exit(0);
}
} else {
var sessionInfo = sessionResponse.getData();
System.out.println(ZAnsi.brightGreen("Добро пожаловать обратно, " + sessionInfo.getUsername() + "!"));
}
// === ГЛАВНЫЙ ЦИКЛ ===
try {
mainLoop();
} catch (Exception e) {
System.err.println(ZAnsi.brightRed("Критическая ошибка: " + e.getMessage()));
e.printStackTrace();
} finally {
ZAnsi.uninstall();
}
}
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()) {
zernMCFlow();
} else {
globalFlow();
}
}
// ====================== ZERNMC FLOW ======================
private static void zernMCFlow() throws Exception {
ConsoleUtils.clearScreen();
System.out.println(ZAnsi.header("=== ZernMC Private Launcher ==="));
// 1. Проверка подключения к серверу
System.out.println(ZAnsi.cyan("Проверка подключения к ZernMC серверу..."));
try {
String response = ZHttpClient.get("/health");
System.out.println(ZAnsi.brightGreen("✓ Сервер доступен"));
} catch (Exception e) {
System.out.println(ZAnsi.brightRed("✗ Не удалось подключиться к ZernMC серверу"));
System.out.println(ZAnsi.white("Ошибка: " + e.getMessage()));
ConsoleUtils.pause();
System.exit(1);
}
// 2. Авторизация
boolean sessionRestored = AuthManager.loadSavedSession();
if (!sessionRestored) {
LoginMenu loginMenu = new LoginMenu();
boolean loggedIn = loginMenu.show();
if (!loggedIn) {
System.exit(0);
}
} else {
System.out.println(ZAnsi.brightGreen("Добро пожаловать обратно, " + AuthManager.getUsername() + "!"));
}
// 3. Запуск меню (LaunchMenu сам определит режим и вызовет нужный flow)
LaunchMenu launchMenu = new LaunchMenu();
launchMenu.show(); // ← Здесь будет вызван showZernMCOnly() внутри
}
// ====================== GLOBAL FLOW ======================
private static void globalFlow() throws Exception {
while (true) {
ConsoleUtils.clearScreen();
System.out.println(ZAnsi.header("=== ZernMC Launcher ==="));
List<String> options = List.of(
"Запустить игру",
"Проверка обновлений",
"Настройки",
"Проверка подключения к серверам",
"Выход"
);
ArrowMenu menu = new ArrowMenu("Главное меню", options);
int choice = menu.show();
if (choice == -1 || choice == 4) {
System.out.println(ZAnsi.yellow("До свидания!"));
break;
}
switch (choice) {
case 0 -> new LaunchMenu().show(); // обычный LaunchMenu
case 1 -> new UpdateMenu().show();
case 2 -> new SettingsMenu().show();
case 3 -> new ServerCheckMenu().show();
}
}
}
}
@@ -2,6 +2,7 @@ package me.sashegdev.zernmc.launcher.api;
import me.sashegdev.zernmc.launcher.api.auth.AuthService; import me.sashegdev.zernmc.launcher.api.auth.AuthService;
import me.sashegdev.zernmc.launcher.api.instance.InstanceService; import me.sashegdev.zernmc.launcher.api.instance.InstanceService;
import me.sashegdev.zernmc.launcher.api.install.InstallService;
import me.sashegdev.zernmc.launcher.api.launch.LaunchService; import me.sashegdev.zernmc.launcher.api.launch.LaunchService;
import java.util.List; import java.util.List;
@@ -15,11 +16,13 @@ public class LauncherAPI {
private final AuthService authService; private final AuthService authService;
private final InstanceService instanceService; private final InstanceService instanceService;
private final LaunchService launchService; private final LaunchService launchService;
private final InstallService installService;
public LauncherAPI() { public LauncherAPI() {
this.authService = new AuthService(); this.authService = new AuthService();
this.instanceService = new InstanceService(); this.instanceService = new InstanceService();
this.launchService = new LaunchService(); this.launchService = new LaunchService();
this.installService = new InstallService();
} }
public AuthService auth() { public AuthService auth() {
@@ -34,6 +37,10 @@ public class LauncherAPI {
return launchService; return launchService;
} }
public InstallService install() {
return installService;
}
// ====================== Удобные методы ====================== // ====================== Удобные методы ======================
public boolean isLoggedIn() { public boolean isLoggedIn() {
@@ -37,9 +37,7 @@ public class AuthService {
SessionInfo info = new SessionInfo( SessionInfo info = new SessionInfo(
AuthManager.getUsername(), AuthManager.getUsername(),
AuthManager.getAccessToken(), AuthManager.getAccessToken(),
AuthManager.hasActivePass(), AuthManager.hasActivePass()
AuthManager.getRole(),
AuthManager.getRoleName()
); );
return ApiResponse.success(info); return ApiResponse.success(info);
} }
@@ -122,21 +120,15 @@ public class AuthService {
private String username; private String username;
private String token; private String token;
private boolean passActive; private boolean passActive;
private int role;
private String roleName;
public SessionInfo(String username, String token, boolean passActive, int role, String roleName) { public SessionInfo(String username, String token, boolean passActive) {
this.username = username; this.username = username;
this.token = token; this.token = token;
this.passActive = passActive; this.passActive = passActive;
this.role = role;
this.roleName = roleName;
} }
public String getUsername() { return username; } public String getUsername() { return username; }
public String getToken() { return token; } public String getToken() { return token; }
public boolean isPassActive() { return passActive; } public boolean isPassActive() { return passActive; }
public int getRole() { return role; }
public String getRoleName() { return roleName; }
} }
} }
@@ -0,0 +1,216 @@
package me.sashegdev.zernmc.launcher.api.install;
import me.sashegdev.zernmc.launcher.api.ApiResponse;
import me.sashegdev.zernmc.launcher.minecraft.Instance;
import me.sashegdev.zernmc.launcher.minecraft.InstanceManager;
import me.sashegdev.zernmc.launcher.minecraft.PackDownloader;
import me.sashegdev.zernmc.launcher.minecraft.ServerPack;
import me.sashegdev.zernmc.launcher.utils.ZHttpClient;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
public class InstallService {
public ApiResponse<InstallResult> installZernMCPack(String packName, String instanceName) {
try {
boolean created = InstanceManager.createInstanceFolder(instanceName);
if (!created) {
return ApiResponse.error("Сборка с таким именем уже существует: " + instanceName);
}
Instance instance = InstanceManager.getInstance(instanceName);
if (instance == null) {
return ApiResponse.error("Не удалось создать директорию сборки");
}
PackDownloader downloader = new PackDownloader(instance);
// Получаем список доступных сборок
List<ServerPack> availablePacks = downloader.getAvailablePacks();
// Находим нужную сборку
ServerPack selectedPack = availablePacks.stream()
.filter(p -> p.getName().equals(packName))
.findFirst()
.orElse(null);
if (selectedPack == null) {
return ApiResponse.error("Сборка не найдена: " + packName);
}
boolean success = downloader.installOrUpdatePack(packName, selectedPack);
if (success) {
return ApiResponse.success(new InstallResult(
instanceName,
selectedPack.getMinecraftVersion(),
selectedPack.getLoaderType(),
selectedPack.getVersion()
));
} else {
return ApiResponse.error("Не удалось установить сборку");
}
} catch (Exception e) {
return ApiResponse.error("Ошибка установки: " + e.getMessage());
}
}
public ApiResponse<UpdateCheckResult> checkForUpdates(String instanceName) {
try {
Instance instance = InstanceManager.getInstance(instanceName);
if (instance == null || !instance.isServerPack()) {
return ApiResponse.success(new UpdateCheckResult(false, false, 0, 0));
}
PackDownloader downloader = new PackDownloader(instance);
boolean hasUpdate = downloader.checkForUpdates(instance.getServerPackName());
return ApiResponse.success(new UpdateCheckResult(
hasUpdate,
true,
instance.getServerVersion(),
hasUpdate ? instance.getServerVersion() + 1 : instance.getServerVersion()
));
} catch (Exception e) {
return ApiResponse.error("Ошибка проверки обновлений: " + e.getMessage());
}
}
public ApiResponse<HashCheckResult> verifyHashes(String instanceName) {
try {
Instance instance = InstanceManager.getInstance(instanceName);
if (instance == null) {
return ApiResponse.error("Сборка не найдена: " + instanceName);
}
if (!instance.isServerPack() || instance.getServerPackName() == null) {
return ApiResponse.success(new HashCheckResult(false, List.of()));
}
PackDownloader downloader = new PackDownloader(instance);
Map<String, String> localFiles = downloader.scanLocalFiles();
// Отправляем хеши на сервер через diff
var diff = downloader.getDiff(instance.getServerPackName(), localFiles);
List<String> mismatched = new ArrayList<>();
for (var f : diff.getToDownload()) {
mismatched.add(f.getPath());
}
mismatched.addAll(diff.getToUpdate());
mismatched.addAll(diff.getToDelete());
boolean hasMismatches = !mismatched.isEmpty();
return ApiResponse.success(new HashCheckResult(hasMismatches, mismatched));
} catch (Exception e) {
return ApiResponse.error("Ошибка проверки хешей: " + e.getMessage());
}
}
public ApiResponse<PlayTimeInfo> getPlayTime(String instanceName) {
try {
Instance instance = InstanceManager.getInstance(instanceName);
if (instance == null) {
return ApiResponse.error("Сборка не найдена: " + instanceName);
}
if (instance.isServerPack()) {
// TODO: Для ZernMC получаем время с сервера
// String response = ZHttpClient.get("/users/me/playtime?pack=" + instance.getServerPackName());
// Пока возвращаем 0 - в будущем интегрировать с сервером
return ApiResponse.success(new PlayTimeInfo(0, true));
}
// Для локальных сборок возвращаем 0
return ApiResponse.success(new PlayTimeInfo(0, false));
} catch (Exception e) {
return ApiResponse.error("Ошибка получения времени: " + e.getMessage());
}
}
private int extractPlayTime(String json) {
try {
// Простой парсинг JSON
String minutes = json.replaceAll(".*\"minutes\"\\s*:\\s*(\\d+).*", "$1");
return Integer.parseInt(minutes);
} catch (Exception e) {
return 0;
}
}
public static class InstallResult {
private String name;
private String mcVersion;
private String loaderType;
private int serverVersion;
public InstallResult(String name, String mcVersion, String loaderType, int serverVersion) {
this.name = name;
this.mcVersion = mcVersion;
this.loaderType = loaderType;
this.serverVersion = serverVersion;
}
public String getName() { return name; }
public String getMcVersion() { return mcVersion; }
public String getLoaderType() { return loaderType; }
public int getServerVersion() { return serverVersion; }
}
public static class UpdateCheckResult {
private boolean hasUpdate;
private boolean isServerPack;
private int currentVersion;
private int latestVersion;
public UpdateCheckResult(boolean hasUpdate, boolean isServerPack, int currentVersion, int latestVersion) {
this.hasUpdate = hasUpdate;
this.isServerPack = isServerPack;
this.currentVersion = currentVersion;
this.latestVersion = latestVersion;
}
public boolean isHasUpdate() { return hasUpdate; }
public boolean isServerPack() { return isServerPack; }
public int getCurrentVersion() { return currentVersion; }
public int getLatestVersion() { return latestVersion; }
}
public static class HashCheckResult {
private boolean hasMismatches;
private List<String> mismatchedFiles;
public HashCheckResult(boolean hasMismatches, List<String> mismatchedFiles) {
this.hasMismatches = hasMismatches;
this.mismatchedFiles = mismatchedFiles;
}
public boolean hasMismatches() { return hasMismatches; }
public List<String> getMismatchedFiles() { return mismatchedFiles; }
}
public static class PlayTimeInfo {
private int totalMinutes;
private boolean fromServer;
public PlayTimeInfo(int totalMinutes, boolean fromServer) {
this.totalMinutes = totalMinutes;
this.fromServer = fromServer;
}
public int getTotalMinutes() { return totalMinutes; }
public boolean isFromServer() { return fromServer; }
public String getFormattedTime() {
int hours = totalMinutes / 60;
int minutes = totalMinutes % 60;
if (hours > 0) {
return hours + "ч " + minutes + "м";
}
return minutes + "м";
}
}
}
@@ -1,16 +1,12 @@
package me.sashegdev.zernmc.launcher.api.launch; package me.sashegdev.zernmc.launcher.api.launch;
import me.sashegdev.zernmc.launcher.api.ApiResponse; 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.Instance;
import me.sashegdev.zernmc.launcher.minecraft.InstanceManager; import me.sashegdev.zernmc.launcher.minecraft.InstanceManager;
import me.sashegdev.zernmc.launcher.minecraft.launch.LaunchCommandBuilder; import me.sashegdev.zernmc.launcher.minecraft.launch.LaunchCommandBuilder;
import me.sashegdev.zernmc.launcher.minecraft.model.LaunchOptions; 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.IOException;
import java.io.InputStreamReader;
import java.nio.file.Path; import java.nio.file.Path;
import java.util.List; import java.util.List;
@@ -46,58 +42,16 @@ public class LaunchService {
return ApiResponse.error("Сборка не найдена: " + instanceName); return ApiResponse.error("Сборка не найдена: " + instanceName);
} }
JFXLauncher.initGameLog(instance.getPath());
LaunchCommandBuilder builder = new LaunchCommandBuilder(instance); LaunchCommandBuilder builder = new LaunchCommandBuilder(instance);
LaunchOptions options = new LaunchOptions(); 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); 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 processBuilder = new ProcessBuilder(command);
processBuilder.directory(instance.getPath().toFile()); processBuilder.directory(instance.getPath().toFile());
processBuilder.redirectErrorStream(true); processBuilder.inheritIO();
Process process = processBuilder.start(); 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( ProcessInfo info = new ProcessInfo(
instanceName, instanceName,
@@ -10,7 +10,6 @@ import me.sashegdev.zernmc.launcher.utils.ZAnsi;
import me.sashegdev.zernmc.launcher.utils.ZHttpClient; import me.sashegdev.zernmc.launcher.utils.ZHttpClient;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream;
import java.net.HttpURLConnection; import java.net.HttpURLConnection;
import java.net.URL; import java.net.URL;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
@@ -214,13 +213,6 @@ public class AuthManager {
return session != null ? session.role : ROLE_USER; 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 ====================== // ====================== POST ======================
private static SimpleHttpResponse post(String endpoint, String jsonBody) throws Exception { private static SimpleHttpResponse post(String endpoint, String jsonBody) throws Exception {
String fullUrl = ZHttpClient.getBaseUrl() + endpoint; String fullUrl = ZHttpClient.getBaseUrl() + endpoint;
@@ -253,16 +245,12 @@ public class AuthManager {
} }
int statusCode = conn.getResponseCode(); int statusCode = conn.getResponseCode();
InputStream is = (statusCode >= 200 && statusCode < 300) ? conn.getInputStream() : conn.getErrorStream(); var is = (statusCode >= 200 && statusCode < 300) ? conn.getInputStream() : conn.getErrorStream();
String responseBody; String responseBody;
if (is != null) {
try (var scanner = new java.util.Scanner(is, StandardCharsets.UTF_8.name())) { try (var scanner = new java.util.Scanner(is, StandardCharsets.UTF_8.name())) {
responseBody = scanner.useDelimiter("\\A").hasNext() ? scanner.next() : ""; responseBody = scanner.useDelimiter("\\A").hasNext() ? scanner.next() : "";
} }
} else {
responseBody = "No response body (status " + statusCode + ")";
}
return new SimpleHttpResponse(statusCode, responseBody); return new SimpleHttpResponse(statusCode, responseBody);
@@ -166,9 +166,9 @@ public class LoginMenu {
if (key == 27) { if (key == 27) {
// Escape sequence consume remaining bytes (arrow keys, etc.) // Escape sequence consume remaining bytes (arrow keys, etc.)
int next = passTerminal.reader().read(); int next = passTerminal.reader().read(50);
if (next == 91) { // '[' arrow key sequence if (next == 91) { // '[' arrow key sequence
passTerminal.reader().read(); // consume 'A'/'B'/'C'/'D' passTerminal.reader().read(50); // consume 'A'/'B'/'C'/'D'
} }
continue; continue;
} }
@@ -6,14 +6,10 @@ import me.sashegdev.zernmc.launcher.minecraft.installer.NeoForgeInstaller;
import me.sashegdev.zernmc.launcher.minecraft.installer.VersionInstaller; import me.sashegdev.zernmc.launcher.minecraft.installer.VersionInstaller;
import me.sashegdev.zernmc.launcher.minecraft.launch.LaunchCommandBuilder; import me.sashegdev.zernmc.launcher.minecraft.launch.LaunchCommandBuilder;
import me.sashegdev.zernmc.launcher.minecraft.model.LaunchOptions; 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.ConsoleUtils;
import me.sashegdev.zernmc.launcher.utils.ZAnsi; import me.sashegdev.zernmc.launcher.utils.ZAnsi;
import java.io.BufferedReader;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.util.List; import java.util.List;
@@ -118,43 +114,15 @@ public class MinecraftLib {
ProcessBuilder pb = new ProcessBuilder(command); ProcessBuilder pb = new ProcessBuilder(command);
pb.directory(instance.getPath().toFile()); 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")); System.out.println(ZAnsi.brightGreen("\nЗапускаем Minecraft...\n"));
ConsoleUtils.clearScreen(); ConsoleUtils.clearScreen();
Process process = pb.start(); 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(); int exitCode = process.waitFor();
outThread.join(1000);
errThread.join(1000);
System.out.println(ZAnsi.yellow("\nMinecraft завершился с кодом: " + exitCode)); System.out.println(ZAnsi.yellow("\nMinecraft завершился с кодом: " + exitCode));
} }
@@ -272,7 +272,7 @@ public class PackDownloader {
/** /**
* Сканирование локальных файлов и вычисление хешей * Сканирование локальных файлов и вычисление хешей
*/ */
private Map<String, String> scanLocalFiles() throws IOException { public Map<String, String> scanLocalFiles() throws IOException {
Map<String, String> files = new HashMap<>(); Map<String, String> files = new HashMap<>();
Path instancePath = instance.getPath(); Path instancePath = instance.getPath();
@@ -312,9 +312,9 @@ public class PackDownloader {
} }
/** /**
* Отправить diff запрос на сервер * Отправить diff запрос на сервер (получить список файлов для обновления)
*/ */
private DiffResponse getDiff(String packName, Map<String, String> localFiles) throws Exception { public DiffResponse getDiff(String packName, Map<String, String> localFiles) throws Exception {
String json = gson.toJson(localFiles); String json = gson.toJson(localFiles);
// Получаем токен авторизации // Получаем токен авторизации
@@ -36,37 +36,15 @@ public class LaunchCommandBuilder {
} }
command.add("-Djava.library.path=" + nativesDir.toAbsolutePath()); 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(); 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("-cp");
command.add(buildVanillaClasspath()); command.add(buildClasspathFromManifest(manifest));
command.add(getVanillaMainClass());
command.addAll(getVanillaGameArguments(options));
} else if (manifest != null) {
String classpath = buildClasspathFromManifest(manifest);
// 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);
String mainClass = resolveMainClass(manifest); String mainClass = resolveMainClass(manifest);
command.add(mainClass); command.add(mainClass);
command.addAll(resolveGameArguments(manifest, options)); command.addAll(resolveGameArguments(manifest, options));
}
} else { } else {
command.add("-cp"); command.add("-cp");
command.add(buildVanillaClasspath()); command.add(buildVanillaClasspath());
@@ -85,10 +63,6 @@ public class LaunchCommandBuilder {
JSONObject json = new JSONObject(content); JSONObject json = new JSONObject(content);
System.out.println(ZAnsi.green("Найден version.json: " + versionJson.getFileName())); System.out.println(ZAnsi.green("Найден version.json: " + versionJson.getFileName()));
return new VersionManifest(json); 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) { } catch (Exception e) {
System.out.println(ZAnsi.yellow("Не удалось загрузить version.json: " + e.getMessage())); System.out.println(ZAnsi.yellow("Не удалось загрузить version.json: " + e.getMessage()));
@@ -102,38 +76,6 @@ public class LaunchCommandBuilder {
String mcVersion = instance.getMinecraftVersion(); String mcVersion = instance.getMinecraftVersion();
String loaderVersion = instance.getLoaderVersion(); 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)) { if ("forge".equals(loaderType) || "neoforge".equals(loaderType)) {
String[] candidates = { String[] candidates = {
getVersionId(), getVersionId(),
@@ -210,10 +152,6 @@ public class LaunchCommandBuilder {
String loaderType = instance.getLoaderType().toLowerCase(); String loaderType = instance.getLoaderType().toLowerCase();
if ("fabric".equals(loaderType)) { if ("fabric".equals(loaderType)) {
return "net.fabricmc.loader.impl.launch.knot.KnotClient"; 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"; return "net.minecraft.client.main.Main";
} }
@@ -320,8 +258,6 @@ public class LaunchCommandBuilder {
List<String> paths = new ArrayList<>(); List<String> paths = new ArrayList<>();
Path librariesDir = instance.getPath().resolve("libraries"); Path librariesDir = instance.getPath().resolve("libraries");
System.out.println(ZAnsi.cyan(" buildClasspathFromManifest: " + manifest.getLibraries().size() + " libraries in manifest"));
for (VersionManifest.Library lib : manifest.getLibraries()) { for (VersionManifest.Library lib : manifest.getLibraries()) {
Path libPath = librariesDir.resolve(lib.artifactPath); Path libPath = librariesDir.resolve(lib.artifactPath);
if (Files.exists(libPath)) { if (Files.exists(libPath)) {
@@ -337,12 +273,9 @@ public class LaunchCommandBuilder {
} }
} }
System.out.println(ZAnsi.cyan(" buildClasspathFromManifest: " + paths.size() + " libraries in classpath"));
Path versionJar = findVersionJar(); Path versionJar = findVersionJar();
if (versionJar != null) { if (versionJar != null) {
paths.add(0, versionJar.toAbsolutePath().toString()); 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") ? ";" : ":"; String separator = System.getProperty("os.name").toLowerCase().contains("win") ? ";" : ":";
@@ -99,17 +99,8 @@ public class VersionManifest {
if (rule.has("features")) { if (rule.has("features")) {
JSONObject features = rule.getJSONObject("features"); JSONObject features = rule.getJSONObject("features");
for (String key : features.keySet()) { for (String key : features.keySet()) {
if (key.startsWith("has_custom_resolution")) { if (key.startsWith("is_demo_user") || key.startsWith("has_custom_resolution")) continue;
continue; // Лаунчер сам обрабатывает разрешение
}
if (key.startsWith("is_demo_user")) {
// Лаунчер не использует demo режим, считаем фичу false
matches = false; matches = false;
break;
}
// Неизвестная фича считаем false
matches = false;
break;
} }
} }
@@ -48,9 +48,9 @@ public class ArrowMenu {
return selected; return selected;
} }
else if (key == 27) { // Esc or arrow escape seq else if (key == 27) { // Esc or arrow escape seq
int next = terminal.reader().read(); int next = terminal.reader().read(50);
if (next == 91) { // '[' start of arrow escape sequence if (next == 91) { // '[' start of arrow escape sequence
int arrow = terminal.reader().read(); int arrow = terminal.reader().read(50);
if (arrow == 65) { // 'A' Up arrow if (arrow == 65) { // 'A' Up arrow
selected = (selected - 1 + options.size()) % options.size(); selected = (selected - 1 + options.size()) % options.size();
} else if (arrow == 66) { // 'B' Down arrow } else if (arrow == 66) { // 'B' Down arrow
@@ -34,9 +34,8 @@ public class Version {
public static boolean isNewer(String current, String server) { public static boolean isNewer(String current, String server) {
if (current == null || server == null) return false; if (current == null || server == null) return false;
// Нормализуем версии - убираем суффиксы типа -any, -alpha, -beta, -SNAPSHOT current = current.replace("-SNAPSHOT", "").trim();
current = normalizeVersion(current); server = server.replace("-SNAPSHOT", "").trim();
server = normalizeVersion(server);
if (current.equals(server)) return false; if (current.equals(server)) return false;
@@ -46,29 +45,12 @@ public class Version {
int max = Math.max(cParts.length, sParts.length); int max = Math.max(cParts.length, sParts.length);
for (int i = 0; i < max; i++) { for (int i = 0; i < max; i++) {
int c = i < cParts.length ? parseVersionPart(cParts[i]) : 0; int c = i < cParts.length ? Integer.parseInt(cParts[i]) : 0;
int s = i < sParts.length ? parseVersionPart(sParts[i]) : 0; int s = i < sParts.length ? Integer.parseInt(sParts[i]) : 0;
if (s > c) return true; if (s > c) return true;
if (s < c) return false; if (s < c) return false;
} }
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,68 @@
package me.sashegdev.zernmc.launcher.web;
import java.awt.GraphicsEnvironment;
import javafx.application.Application;
import javafx.concurrent.Worker;
import javafx.geometry.Rectangle2D;
import javafx.scene.Scene;
import javafx.scene.web.WebEngine;
import javafx.scene.web.WebView;
import javafx.stage.Screen;
import javafx.stage.Stage;
import javafx.stage.StageStyle;
public class UIWindow extends Application {
private static String url;
private static int port;
public static void start(int port) {
// Backup проверка headless
if (java.awt.GraphicsEnvironment.isHeadless()) {
throw new RuntimeException("Headless environment - no display available");
}
UIWindow.port = port;
UIWindow.url = "http://localhost:" + port;
Application.launch(UIWindow.class);
}
@Override
public void start(Stage stage) {
stage.setTitle("ZernMC Launcher");
stage.initStyle(StageStyle.UNDECORATED);
WebView webView = new WebView();
WebEngine webEngine = webView.getEngine();
webEngine.load(url);
webEngine.getLoadWorker().stateProperty().addListener((obs, oldState, newState) -> {
if (newState == Worker.State.FAILED) {
System.err.println("Failed to load: " + url);
}
});
Scene scene = new Scene(webView);
stage.setScene(scene);
Rectangle2D screenBounds = Screen.getPrimary().getVisualBounds();
double screenWidth = screenBounds.getWidth();
double screenHeight = screenBounds.getHeight();
double windowWidth = Math.min(1200, screenWidth * 0.8);
double windowHeight = Math.min(800, screenHeight * 0.85);
stage.setWidth(windowWidth);
stage.setHeight(windowHeight);
stage.setX((screenWidth - windowWidth) / 2);
stage.setY((screenHeight - windowHeight) / 2);
stage.show();
stage.setOnCloseRequest(event -> {
WebServer.stop();
System.exit(0);
});
}
}
@@ -0,0 +1,329 @@
package me.sashegdev.zernmc.launcher.web;
import io.javalin.Javalin;
import io.javalin.http.staticfiles.Location;
import me.sashegdev.zernmc.launcher.api.ApiResponse;
import me.sashegdev.zernmc.launcher.api.LauncherAPI;
import me.sashegdev.zernmc.launcher.api.instance.InstanceService;
import me.sashegdev.zernmc.launcher.auth.AuthManager;
import me.sashegdev.zernmc.launcher.utils.ZAnsi;
import java.awt.Desktop;
import java.io.IOException;
import java.net.ServerSocket;
import java.net.URI;
import java.util.List;
import java.util.Map;
public class WebServer {
private static final LauncherAPI api = new LauncherAPI();
private static Javalin app;
private static int currentPort;
private static volatile boolean running = false;
public static int findFreePort(int startPort) throws IOException {
for (int port = startPort; port < startPort + 100; port++) {
if (isPortAvailable(port)) {
return port;
}
}
throw new IOException("Не удалось найти свободный порт в диапазоне " + startPort + "-" + (startPort + 99));
}
private static boolean isPortAvailable(int port) {
try (ServerSocket socket = new ServerSocket(port)) {
return true;
} catch (IOException e) {
return false;
}
}
public static void start(int port) throws Exception {
currentPort = port;
running = true;
// Отключаем логирование Javalin в консоль
System.setProperty("org.slf4j.simpleLogger.defaultLogLevel", "error");
app = Javalin.create(config -> {
config.staticFiles.add("/webapp", Location.CLASSPATH);
config.staticFiles.add("/assets", Location.CLASSPATH);
}).start(port);
// API эндпоинты
setupApiRoutes();
System.out.println(ZAnsi.brightGreen("✓ Web UI готов на http://localhost:" + port));
// Блокируем главный поток (сервер работает)
while (running) {
Thread.sleep(1000);
}
}
private static void setupApiRoutes() {
// Auth
app.get("/api/auth/status", ctx -> {
if (AuthManager.loadSavedSession()) {
ctx.json(Map.of(
"success", true,
"loggedIn", true,
"username", AuthManager.getUsername()
));
} else {
ctx.json(Map.of(
"success", true,
"loggedIn", false
));
}
});
app.post("/api/auth/login", ctx -> {
Map<String, String> body = ctx.bodyAsClass(Map.class);
String username = body.get("username");
String password = body.get("password");
if (username == null || password == null) {
ctx.status(400).json(Map.of("success", false, "error", "Missing username or password"));
return;
}
var result = api.login(username, password);
if (result.isSuccess()) {
ctx.json(Map.of("success", true, "username", username));
} else {
ctx.status(401).json(Map.of("success", false, "error", result.getError()));
}
});
app.post("/api/auth/logout", ctx -> {
AuthManager.logout();
ctx.json(Map.of("success", true));
});
// Instances - локальные
app.get("/api/instances", ctx -> {
var result = api.getAllInstances();
if (result.isSuccess()) {
ctx.json(Map.of("success", true, "data", result.getData()));
} else {
ctx.status(500).json(Map.of("success", false, "error", result.getError()));
}
});
// Instance детали
app.get("/api/instances/{name}", ctx -> {
String name = ctx.pathParam("name");
var result = api.instances().getInstance(name);
if (result.isSuccess()) {
ctx.json(Map.of("success", true, "data", result.getData()));
} else {
ctx.status(404).json(Map.of("success", false, "error", result.getError()));
}
});
// Launch
app.post("/api/instances/{name}/launch", ctx -> {
String name = ctx.pathParam("name");
var result = api.launch(name);
if (result.isSuccess()) {
ctx.json(Map.of("success", true, "message", "Launch started"));
} else {
ctx.status(500).json(Map.of("success", false, "error", result.getError()));
}
});
// Delete
app.post("/api/instances/{name}/delete", ctx -> {
String name = ctx.pathParam("name");
var result = api.instances().deleteInstance(name);
if (result.isSuccess()) {
ctx.json(Map.of("success", true, "message", "Instance deleted"));
} else {
ctx.status(500).json(Map.of("success", false, "error", result.getError()));
}
});
// ZernMC серверные сборки
app.get("/api/instances/zernmc", ctx -> {
// TODO: получить реальные сборки с сервера
List<Map<String, Object>> packs = List.of(
Map.of("name", "ZernMC SkyBlock", "version", 1, "loader", "Fabric", "loaderVersion", "0.15.9", "filesCount", 150),
Map.of("name", "ZernMC RPG", "version", 3, "loader", "Fabric", "loaderVersion", "0.15.9", "filesCount", 200)
);
ctx.json(Map.of("success", true, "data", packs));
});
// Установка ZernMC сборки
app.post("/api/instances/zernmc/install", ctx -> {
Map<String, String> body = ctx.bodyAsClass(Map.class);
String packName = body.get("packName");
String instanceName = body.get("instanceName");
if (packName == null || instanceName == null) {
ctx.status(400).json(Map.of("success", false, "error", "Missing packName or instanceName"));
return;
}
var result = api.install().installZernMCPack(packName, instanceName);
if (result.isSuccess()) {
ctx.json(Map.of(
"success", true,
"data", Map.of(
"name", result.getData().getName(),
"mcVersion", result.getData().getMcVersion(),
"loaderType", result.getData().getLoaderType(),
"serverVersion", result.getData().getServerVersion()
)
));
} else {
ctx.status(500).json(Map.of("success", false, "error", result.getError()));
}
});
// Проверка обновлений
app.get("/api/instances/{name}/updates", ctx -> {
String name = ctx.pathParam("name");
var result = api.install().checkForUpdates(name);
if (result.isSuccess()) {
ctx.json(Map.of(
"success", true,
"data", Map.of(
"hasUpdate", result.getData().isHasUpdate(),
"isServerPack", result.getData().isServerPack(),
"currentVersion", result.getData().getCurrentVersion(),
"latestVersion", result.getData().getLatestVersion()
)
));
} else {
ctx.status(500).json(Map.of("success", false, "error", result.getError()));
}
});
// Проверка хешей
app.get("/api/instances/{name}/verify", ctx -> {
String name = ctx.pathParam("name");
var result = api.install().verifyHashes(name);
if (result.isSuccess()) {
ctx.json(Map.of(
"success", true,
"data", Map.of(
"hasMismatches", result.getData().hasMismatches(),
"mismatchedFiles", result.getData().getMismatchedFiles()
)
));
} else {
ctx.status(500).json(Map.of("success", false, "error", result.getError()));
}
});
// Получение времени игры
app.get("/api/instances/{name}/playtime", ctx -> {
String name = ctx.pathParam("name");
var result = api.install().getPlayTime(name);
if (result.isSuccess()) {
ctx.json(Map.of(
"success", true,
"data", Map.of(
"totalMinutes", result.getData().getTotalMinutes(),
"fromServer", result.getData().isFromServer(),
"formatted", result.getData().getFormattedTime()
)
));
} else {
ctx.status(500).json(Map.of("success", false, "error", result.getError()));
}
});
// Minecraft версии
app.get("/api/versions", ctx -> {
List<String> versions = List.of(
"1.21.4", "1.21.3", "1.21.2", "1.21.1", "1.21",
"1.20.4", "1.20.3", "1.20.2", "1.20.1", "1.20",
"1.19.4", "1.19.3", "1.19.2", "1.19.1", "1.19",
"1.18.2", "1.18.1", "1.18",
"1.17.1", "1.17"
);
ctx.json(Map.of("success", true, "data",
versions.stream().map(v -> Map.of("id", v)).toList()
));
});
// Версии лоадеров для конкретной версии Minecraft
app.get("/api/versions/{version}/loaders/{loader}", ctx -> {
String version = ctx.pathParam("version");
String loader = ctx.pathParam("loader");
List<Map<String, String>> loaderVersions = switch (loader.toLowerCase()) {
case "fabric" -> List.of(
Map.of("version", "0.16.9"),
Map.of("version", "0.16.8"),
Map.of("version", "0.16.7"),
Map.of("version", "0.16.6"),
Map.of("version", "0.16.5"),
Map.of("version", "0.15.11"),
Map.of("version", "0.15.10"),
Map.of("version", "0.15.9")
);
case "forge" -> List.of(
Map.of("version", "1.21-51.0.0"),
Map.of("version", "1.20.4-49.0.0"),
Map.of("version", "1.20.1-47.1.0"),
Map.of("version", "1.19.2-43.2.0"),
Map.of("version", "1.18.2-40.2.0")
);
case "neoforge" -> List.of(
Map.of("version", "21.0.0-beta"),
Map.of("version", "1.21-21.0.0"),
Map.of("version", "1.20.4-21.0.0"),
Map.of("version", "1.20.1-21.0.0")
);
default -> List.of();
};
ctx.json(Map.of("success", true, "data", loaderVersions));
});
// Установка ванильной сборки
app.post("/api/instances/vanilla/install", ctx -> {
Map<String, String> body = ctx.bodyAsClass(Map.class);
String mcVersion = body.get("mcVersion");
String loader = body.get("loader");
String loaderVersion = body.get("loaderVersion");
String instanceName = body.get("instanceName");
if (mcVersion == null || instanceName == null) {
ctx.status(400).json(Map.of("success", false, "error", "Missing required parameters"));
return;
}
// TODO: реализовать установку ванильной сборки
String desc = loader != null ? mcVersion + " + " + loader + " " + loaderVersion : mcVersion + " Vanilla";
ctx.json(Map.of("success", true, "message", "Vanilla installation started: " + desc));
});
// Health check
app.get("/api/health", ctx -> {
ctx.json(Map.of("success", true, "status", "ok"));
});
}
private static void openBrowser(String url) {
try {
if (Desktop.isDesktopSupported() && Desktop.getDesktop().isSupported(Desktop.Action.BROWSE)) {
Desktop.getDesktop().browse(new URI(url));
System.out.println(ZAnsi.cyan("Браузер открыт: " + url));
}
} catch (Exception e) {
System.out.println(ZAnsi.yellow("Не удалось открыть браузер автоматически. Откройте вручную: " + url));
}
}
public static void stop() {
running = false;
if (app != null) {
app.stop();
}
}
}
@@ -0,0 +1,768 @@
:root {
--bg-primary: #0a0a0f;
--bg-secondary: #12121a;
--bg-card: #1a1a24;
--bg-card-hover: #222230;
--bg-sidebar: #0d0d12;
--accent-primary: #e94560;
--accent-secondary: #ff6b6b;
--accent-glow: rgba(233, 69, 96, 0.3);
--text-primary: #ffffff;
--text-secondary: #a0a0b0;
--text-muted: #606070;
--border-color: #2a2a3a;
--success: #4ade80;
--error: #f87171;
--warning: #fbbf24;
--shadow-card: 0 4px 20px rgba(0, 0, 0, 0.4);
--shadow-glow: 0 0 30px var(--accent-glow);
--radius-sm: 8px;
--radius-md: 12px;
--radius-lg: 16px;
--transition-fast: 150ms ease;
--transition-normal: 300ms ease;
--transition-slow: 500ms ease;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
min-height: 100vh;
overflow-x: hidden;
}
#grid-canvas {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 0;
opacity: 0.12;
pointer-events: none;
}
#app {
position: relative;
z-index: 1;
min-height: 100vh;
}
.screen {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
animation: fadeIn var(--transition-slow) forwards;
}
.hidden {
display: none !important;
}
/* ==================== LOGIN SCREEN ==================== */
.login-container {
background: var(--bg-card);
border-radius: var(--radius-lg);
padding: 48px;
width: 100%;
max-width: 400px;
box-shadow: var(--shadow-card);
border: 1px solid var(--border-color);
animation: slideUp var(--transition-slow) forwards;
}
.logo-section {
text-align: center;
margin-bottom: 40px;
}
.logo-placeholder {
display: inline-block;
margin-bottom: 16px;
animation: pulse 2s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.05); }
}
.app-title {
font-size: 28px;
font-weight: 700;
margin-bottom: 8px;
background: linear-gradient(135deg, var(--text-primary), var(--accent-primary));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.app-version {
color: var(--text-muted);
font-size: 14px;
}
.login-form {
display: flex;
flex-direction: column;
gap: 16px;
}
.input-group input {
width: 100%;
padding: 14px 16px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
color: var(--text-primary);
font-size: 16px;
transition: var(--transition-fast);
}
.input-group input:focus {
outline: none;
border-color: var(--accent-primary);
box-shadow: 0 0 0 3px var(--accent-glow);
}
.input-group input::placeholder {
color: var(--text-muted);
}
.btn-primary {
width: 100%;
padding: 14px 24px;
background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary));
border: none;
border-radius: var(--radius-sm);
color: white;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: var(--transition-fast);
position: relative;
overflow: hidden;
}
.btn-primary:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-glow);
}
.btn-primary:active {
transform: translateY(0);
}
.btn-primary:disabled {
opacity: 0.7;
cursor: not-allowed;
transform: none;
}
.btn-loader {
width: 20px;
height: 20px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-top-color: white;
border-radius: 50%;
animation: spin 0.8s linear infinite;
margin: 0 auto;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.error-message {
color: var(--error);
text-align: center;
font-size: 14px;
padding: 12px;
background: rgba(248, 113, 113, 0.1);
border-radius: var(--radius-sm);
animation: shake 0.5s ease;
}
@keyframes shake {
0%, 100% { transform: translateX(0); }
25% { transform: translateX(-5px); }
75% { transform: translateX(5px); }
}
/* ==================== MAIN LAYOUT ==================== */
.main-layout {
display: grid;
grid-template-columns: 280px 1fr 200px;
width: 100%;
max-width: 1600px;
height: calc(100vh - 40px);
gap: 0;
background: var(--bg-secondary);
border-radius: var(--radius-lg);
border: 1px solid var(--border-color);
overflow: hidden;
animation: fadeIn var(--transition-slow) forwards;
}
/* Sidebar */
.sidebar {
background: var(--bg-sidebar);
border-right: 1px solid var(--border-color);
display: flex;
flex-direction: column;
padding: 20px;
}
.sidebar-header {
display: flex;
align-items: center;
gap: 12px;
padding-bottom: 20px;
border-bottom: 1px solid var(--border-color);
margin-bottom: 20px;
}
.logo-small svg {
display: block;
}
.header-info {
display: flex;
flex-direction: column;
}
.header-title {
font-size: 18px;
font-weight: 700;
color: var(--text-primary);
}
.header-version {
font-size: 12px;
color: var(--text-muted);
}
.sidebar-content {
flex: 1;
display: flex;
flex-direction: column;
gap: 24px;
}
.section-label {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 1px;
color: var(--text-muted);
margin-bottom: 12px;
}
.current-instance-section {
flex: 1;
}
.current-instance {
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
padding: 16px;
transition: var(--transition-fast);
}
.current-instance:hover {
border-color: var(--accent-primary);
}
.instance-card-mini {
display: flex;
flex-direction: column;
gap: 8px;
}
.instance-name {
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
}
.instance-version {
font-size: 13px;
color: var(--accent-primary);
background: rgba(233, 69, 96, 0.15);
padding: 4px 8px;
border-radius: 4px;
display: inline-block;
width: fit-content;
}
.btn-download {
width: 100%;
padding: 16px;
background: var(--bg-card);
border: 1px dashed var(--border-color);
border-radius: var(--radius-md);
color: var(--text-secondary);
font-size: 14px;
font-weight: 500;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
transition: var(--transition-fast);
}
.btn-download:hover {
background: var(--bg-card-hover);
border-color: var(--accent-primary);
color: var(--accent-primary);
}
.sidebar-footer {
display: flex;
align-items: center;
justify-content: space-between;
padding-top: 16px;
border-top: 1px solid var(--border-color);
margin-top: 20px;
}
.username-display {
font-size: 13px;
color: var(--text-secondary);
}
.btn-logout {
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
color: var(--text-secondary);
cursor: pointer;
transition: var(--transition-fast);
}
.btn-logout:hover {
background: rgba(248, 113, 113, 0.1);
border-color: var(--error);
color: var(--error);
}
/* Main Content - Logs */
.main-content {
display: flex;
flex-direction: column;
padding: 20px;
background: var(--bg-primary);
}
.logs-section {
flex: 1;
display: flex;
flex-direction: column;
background: var(--bg-card);
border-radius: var(--radius-md);
border: 1px solid var(--border-color);
overflow: hidden;
}
.logs-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
border-bottom: 1px solid var(--border-color);
}
.logs-header h2 {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
}
.btn-clear-logs {
padding: 6px 12px;
background: transparent;
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
color: var(--text-muted);
font-size: 12px;
cursor: pointer;
transition: var(--transition-fast);
}
.btn-clear-logs:hover {
background: var(--bg-card-hover);
color: var(--text-secondary);
}
.logs-container {
flex: 1;
padding: 16px 20px;
overflow-y: auto;
font-family: 'JetBrains Mono', 'Consolas', monospace;
font-size: 12px;
line-height: 1.6;
}
.log-entry {
padding: 4px 0;
color: var(--text-secondary);
animation: fadeIn var(--transition-fast) forwards;
}
.log-entry.info {
color: var(--text-secondary);
}
.log-entry.success {
color: var(--success);
}
.log-entry.warning {
color: var(--warning);
}
.log-entry.error {
color: var(--error);
}
/* Right Panel - Play Button */
.right-panel {
display: flex;
align-items: flex-end;
justify-content: center;
padding: 30px;
border-left: 1px solid var(--border-color);
background: var(--bg-sidebar);
}
.btn-play {
width: 100%;
padding: 20px 30px;
background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary));
border: none;
border-radius: var(--radius-md);
color: white;
font-size: 18px;
font-weight: 700;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
transition: var(--transition-normal);
box-shadow: 0 4px 20px var(--accent-glow);
}
.btn-play:hover {
transform: translateY(-4px) scale(1.02);
box-shadow: 0 8px 40px var(--accent-glow);
}
.btn-play:active {
transform: translateY(0);
}
.btn-play:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
}
/* ==================== MODAL ==================== */
.modal {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(10, 10, 15, 0.9);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
animation: fadeIn var(--transition-fast) forwards;
}
.modal-content {
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
width: 90%;
max-width: 500px;
max-height: 80vh;
overflow-y: auto;
animation: slideUp var(--transition-normal) forwards;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px 24px;
border-bottom: 1px solid var(--border-color);
}
.modal-header h2 {
font-size: 18px;
font-weight: 600;
}
.modal-close {
width: 32px;
height: 32px;
background: transparent;
border: none;
color: var(--text-muted);
font-size: 24px;
cursor: pointer;
transition: var(--transition-fast);
}
.modal-close:hover {
color: var(--text-primary);
}
.modal-tabs {
display: flex;
padding: 16px 24px;
gap: 8px;
border-bottom: 1px solid var(--border-color);
}
.tab-btn {
flex: 1;
padding: 12px;
background: transparent;
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
color: var(--text-secondary);
font-size: 14px;
cursor: pointer;
transition: var(--transition-fast);
}
.tab-btn.active {
background: var(--accent-primary);
border-color: var(--accent-primary);
color: white;
}
.tab-btn:hover:not(.active) {
background: var(--bg-card-hover);
}
.tab-content {
padding: 24px;
display: none;
}
.tab-content.active {
display: block;
}
.form-group {
margin-bottom: 20px;
}
.form-group label {
display: block;
font-size: 13px;
font-weight: 500;
color: var(--text-secondary);
margin-bottom: 8px;
}
.select-input, .text-input {
width: 100%;
padding: 12px 14px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
color: var(--text-primary);
font-size: 14px;
transition: var(--transition-fast);
}
.select-input:focus, .text-input:focus {
outline: none;
border-color: var(--accent-primary);
}
.select-input option {
background: var(--bg-secondary);
}
.btn-install {
width: 100%;
padding: 14px;
background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary));
border: none;
border-radius: var(--radius-sm);
color: white;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: var(--transition-fast);
}
.btn-install:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-glow);
}
.download-progress {
padding: 24px;
border-top: 1px solid var(--border-color);
}
.progress-bar {
height: 8px;
background: var(--bg-secondary);
border-radius: 4px;
overflow: hidden;
margin-bottom: 12px;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, var(--accent-primary), var(--accent-secondary));
border-radius: 4px;
width: 0%;
transition: width var(--transition-normal);
}
.progress-text {
text-align: center;
color: var(--text-secondary);
font-size: 13px;
}
/* ==================== LOADING ==================== */
.loading-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(10, 10, 15, 0.9);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
z-index: 1000;
animation: fadeIn var(--transition-fast) forwards;
}
.loader {
width: 48px;
height: 48px;
border: 3px solid var(--border-color);
border-top-color: var(--accent-primary);
border-radius: 50%;
animation: spin 1s linear infinite;
margin-bottom: 16px;
}
/* ==================== ANIMATIONS ==================== */
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes cardFadeIn {
to {
opacity: 1;
transform: translateY(0);
}
}
/* ==================== RESPONSIVE ==================== */
@media (max-width: 1024px) {
.main-layout {
grid-template-columns: 240px 1fr 160px;
}
}
@media (max-width: 768px) {
.main-layout {
grid-template-columns: 1fr;
grid-template-rows: auto 1fr auto;
}
.sidebar {
flex-direction: row;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-right: none;
border-bottom: 1px solid var(--border-color);
}
.sidebar-header {
padding-bottom: 0;
border-bottom: none;
margin-bottom: 0;
}
.sidebar-content {
display: none;
}
.sidebar-footer {
margin-top: 0;
padding-top: 0;
border-top: none;
}
.right-panel {
padding: 16px;
border-left: none;
border-top: 1px solid var(--border-color);
}
}
/* ==================== SCROLLBAR ==================== */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: var(--bg-secondary);
}
::-webkit-scrollbar-thumb {
background: var(--border-color);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--text-muted);
}
@@ -0,0 +1,204 @@
<!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="/css/styles.css">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
</head>
<body>
<canvas id="grid-canvas"></canvas>
<div id="app">
<!-- Login Screen -->
<div id="login-screen" class="screen hidden">
<div class="login-container">
<div class="logo-section">
<div class="logo-placeholder">
<svg width="80" height="80" viewBox="0 0 80 80" fill="none">
<rect width="80" height="80" rx="20" fill="#e94560"/>
<path d="M25 40 L40 25 L55 40 L40 55 Z" fill="white"/>
</svg>
</div>
<h1 class="app-title">ZernMC Launcher</h1>
<p class="app-version">v<span id="version">1.0.8</span></p>
</div>
<form id="login-form" class="login-form">
<div class="input-group">
<input type="text" id="username" name="username" placeholder="Имя пользователя" required autocomplete="username">
</div>
<div class="input-group">
<input type="password" id="password" name="password" placeholder="Пароль" required autocomplete="current-password">
</div>
<button type="submit" class="btn-primary">
<span class="btn-text">Войти</span>
<div class="btn-loader hidden"></div>
</button>
<p id="login-error" class="error-message hidden"></p>
</form>
</div>
</div>
<!-- Main Screen -->
<div id="main-screen" class="screen hidden">
<div class="main-layout">
<!-- Left Sidebar -->
<aside class="sidebar">
<div class="sidebar-header">
<div class="logo-small">
<svg width="40" height="40" viewBox="0 0 40 40" fill="none">
<rect width="40" height="40" rx="10" fill="#e94560"/>
<path d="M12 20 L20 12 L28 20 L20 28 Z" fill="white"/>
</svg>
</div>
<div class="header-info">
<h1 class="header-title">ZernMC</h1>
<span class="header-version">v<span id="header-version">1.0.8</span></span>
</div>
</div>
<div class="sidebar-content">
<!-- Current Instance -->
<div class="current-instance-section">
<h3 class="section-label">Текущая сборка</h3>
<div id="current-instance" class="current-instance">
<div class="instance-card-mini">
<span class="instance-name">Загрузка...</span>
<span class="instance-version">-</span>
</div>
</div>
</div>
<!-- Download Button -->
<button id="download-btn" class="btn-download">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="7 10 12 15 17 10"/>
<line x1="12" y1="15" x2="12" y2="3"/>
</svg>
Скачать сборку
</button>
</div>
<div class="sidebar-footer">
<span class="username-display" id="username-display"></span>
<button class="btn-logout" id="logout-btn" title="Выйти">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>
<polyline points="16 17 21 12 16 7"/>
<line x1="21" y1="12" x2="9" y2="12"/>
</svg>
</button>
</div>
</aside>
<!-- Main Content -->
<main class="main-content">
<div class="logs-section">
<div class="logs-header">
<h2>Логи</h2>
<button class="btn-clear-logs" id="clear-logs">Очистить</button>
</div>
<div id="logs-container" class="logs-container">
<div class="log-entry info">Ожидание запуска...</div>
</div>
</div>
</main>
<!-- Right Panel - Play Button -->
<div class="right-panel">
<button id="play-btn" class="btn-play">
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<polygon points="5 3 19 12 5 21 5 3"/>
</svg>
ИГРАТЬ
</button>
</div>
</div>
</div>
<!-- Download Modal -->
<div id="download-modal" class="modal hidden">
<div class="modal-content">
<div class="modal-header">
<h2>Скачать сборку</h2>
<button class="modal-close" id="close-download-modal">&times;</button>
</div>
<div class="modal-tabs">
<button class="tab-btn active" data-tab="zernmc">ZernMC сборки</button>
<button class="tab-btn" data-tab="vanilla">Чистый Minecraft</button>
</div>
<!-- ZernMC Tab -->
<div id="tab-zernmc" class="tab-content active">
<div class="form-group">
<label>Выберите сборку</label>
<select id="zernmc-pack-select" class="select-input">
<option value="">Загрузка...</option>
</select>
</div>
<div class="form-group">
<label>Название сборки (системное)</label>
<input type="text" id="zernmc-instance-name" class="text-input" placeholder="my-zernmc-pack">
</div>
<button id="install-zernmc-btn" class="btn-install">
Скачать и установить
</button>
</div>
<!-- Vanilla Tab -->
<div id="tab-vanilla" class="tab-content">
<div class="form-group">
<label>Версия Minecraft</label>
<select id="mc-version-select" class="select-input">
<option value="">Выберите версию</option>
</select>
</div>
<div class="form-group">
<label>Лоадер</label>
<select id="loader-select" class="select-input">
<option value="vanilla">Vanilla (без лоадера)</option>
<option value="fabric">Fabric</option>
<option value="forge">Forge</option>
<option value="neoforge">NeoForge</option>
</select>
</div>
<div id="loader-version-group" class="form-group hidden">
<label>Версия лоадера</label>
<select id="loader-version-select" class="select-input">
<option value="">Загрузка...</option>
</select>
</div>
<div class="form-group">
<label>Название сборки</label>
<input type="text" id="vanilla-instance-name" class="text-input" placeholder="my-minecraft">
</div>
<button id="install-vanilla-btn" class="btn-install">
Скачать и установить
</button>
</div>
<div id="download-progress" class="download-progress hidden">
<div class="progress-bar">
<div class="progress-fill" id="progress-fill"></div>
</div>
<p class="progress-text" id="progress-text">Загрузка...</p>
</div>
</div>
</div>
<!-- Loading Overlay -->
<div id="loading-overlay" class="loading-overlay hidden">
<div class="loader"></div>
<p>Загрузка...</p>
</div>
</div>
<script src="/js/app.js"></script>
</body>
</html>
@@ -0,0 +1,473 @@
const API_BASE = '/api';
class App {
constructor() {
this.state = 'INIT';
this.username = null;
this.currentInstance = null;
this.instances = [];
this.zernmcPacks = [];
this.mcVersions = [];
this.init();
}
async init() {
this.bindEvents();
this.initGridAnimation();
await this.checkAuth();
}
bindEvents() {
// Login form
document.getElementById('login-form').addEventListener('submit', (e) => {
e.preventDefault();
this.handleLogin();
});
// Logout button
document.getElementById('logout-btn').addEventListener('click', () => {
this.handleLogout();
});
// Download button
document.getElementById('download-btn').addEventListener('click', () => {
this.showDownloadModal();
});
// Close modal
document.getElementById('close-download-modal').addEventListener('click', () => {
this.hideDownloadModal();
});
// Modal tabs
document.querySelectorAll('.tab-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
this.switchTab(e.target.dataset.tab);
});
});
// Play button
document.getElementById('play-btn').addEventListener('click', () => {
this.launchInstance();
});
// Clear logs
document.getElementById('clear-logs').addEventListener('click', () => {
this.clearLogs();
});
// Loader selection
document.getElementById('loader-select').addEventListener('change', (e) => {
this.onLoaderChange(e.target.value);
});
// Install buttons
document.getElementById('install-zernmc-btn').addEventListener('click', () => {
this.installZernMCPack();
});
document.getElementById('install-vanilla-btn').addEventListener('click', () => {
this.installVanilla();
});
}
// ==================== GRID ANIMATION ====================
initGridAnimation() {
const canvas = document.getElementById('grid-canvas');
const ctx = canvas.getContext('2d');
let mouseX = 0, mouseY = 0;
let offsetX = 0, offsetY = 0;
const resize = () => {
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
this.drawGrid(ctx, canvas.width, canvas.height, offsetX, offsetY);
};
window.addEventListener('resize', resize);
window.addEventListener('mousemove', (e) => {
mouseX = (e.clientX / window.innerWidth - 0.5) * 2;
mouseY = (e.clientY / window.innerHeight - 0.5) * 2;
});
const animate = () => {
offsetX += (mouseX * 0.5 - offsetX) * 0.05;
offsetY += (mouseY * 0.5 - offsetY) * 0.05;
ctx.clearRect(0, 0, canvas.width, canvas.height);
this.drawGrid(ctx, canvas.width, canvas.height, offsetX, offsetY);
requestAnimationFrame(animate);
};
resize();
animate();
}
drawGrid(ctx, width, height, offsetX, offsetY) {
const gridSize = 50;
const dotSize = 1;
ctx.fillStyle = '#e94560';
for (let x = 0; x <= width; x += gridSize) {
for (let y = 0; y <= height; y += gridSize) {
const px = x + offsetX * 10;
const py = y + offsetY * 10;
ctx.beginPath();
ctx.arc(px, py, dotSize, 0, Math.PI * 2);
ctx.fill();
}
}
}
// ==================== API ====================
async request(endpoint, options = {}) {
try {
const response = await fetch(`${API_BASE}${endpoint}`, {
...options,
headers: {
'Content-Type': 'application/json',
...options.headers
}
});
return await response.json();
} catch (error) {
console.error('API Error:', error);
return { success: false, error: error.message };
}
}
// ==================== AUTH ====================
async checkAuth() {
this.showLoading(true);
const result = await this.request('/auth/status');
if (result.loggedIn) {
this.username = result.username;
this.showMainScreen();
await this.loadCurrentInstance();
} else {
this.showLoginScreen();
}
this.showLoading(false);
}
async handleLogin() {
const username = document.getElementById('username').value;
const password = document.getElementById('password').value;
const errorEl = document.getElementById('login-error');
const btn = document.querySelector('#login-form button[type="submit"]');
const btnText = btn.querySelector('.btn-text');
const btnLoader = btn.querySelector('.btn-loader');
if (!username || !password) {
this.showError('Введите имя пользователя и пароль');
return;
}
btn.disabled = true;
btnText.classList.add('hidden');
btnLoader.classList.remove('hidden');
errorEl.classList.add('hidden');
const result = await this.request('/auth/login', {
method: 'POST',
body: JSON.stringify({ username, password })
});
btn.disabled = false;
btnText.classList.remove('hidden');
btnLoader.classList.add('hidden');
if (result.success) {
this.username = result.username;
this.showMainScreen();
await this.loadCurrentInstance();
} else {
this.showError(result.error || 'Ошибка входа');
}
}
async handleLogout() {
await this.request('/auth/logout', { method: 'POST' });
this.username = null;
this.currentInstance = null;
this.showLoginScreen();
}
showError(message) {
const errorEl = document.getElementById('login-error');
errorEl.textContent = message;
errorEl.classList.remove('hidden');
}
// ==================== INSTANCES ====================
async loadCurrentInstance() {
const result = await this.request('/instances');
if (result.success && result.data && result.data.length > 0) {
this.currentInstance = result.data[0];
this.renderCurrentInstance(this.currentInstance);
this.enablePlayButton(true);
this.addLog('Сборка загружена: ' + this.currentInstance.name, 'success');
} else {
this.renderNoInstance();
this.enablePlayButton(false);
this.addLog('Установите сборку для игры', 'warning');
}
}
renderCurrentInstance(instance) {
const container = document.getElementById('current-instance');
container.innerHTML = `
<div class="instance-card-mini">
<span class="instance-name">${this.escapeHtml(instance.name)}</span>
<span class="instance-version">${this.escapeHtml(instance.version || 'Vanilla')}</span>
</div>
`;
}
renderNoInstance() {
const container = document.getElementById('current-instance');
container.innerHTML = `
<div class="instance-card-mini">
<span class="instance-name" style="color: var(--text-muted)">Нет сборки</span>
<span class="instance-version" style="background: var(--bg-secondary)">Нажмите скачать</span>
</div>
`;
}
enablePlayButton(enabled) {
const btn = document.getElementById('play-btn');
btn.disabled = !enabled;
}
async launchInstance() {
if (!this.currentInstance) return;
this.addLog('Проверка целостности файлов...', 'info');
this.enablePlayButton(false);
const result = await this.request(`/instances/${this.currentInstance.name}/launch`, {
method: 'POST'
});
if (result.success) {
this.addLog('Сборка запущена!', 'success');
} else {
this.addLog('Ошибка: ' + result.error, 'error');
this.enablePlayButton(true);
}
}
// ==================== DOWNLOAD MODAL ====================
async showDownloadModal() {
document.getElementById('download-modal').classList.remove('hidden');
await this.loadZernMCPacks();
await this.loadMCVersions();
}
hideDownloadModal() {
document.getElementById('download-modal').classList.add('hidden');
this.hideProgress();
}
switchTab(tab) {
document.querySelectorAll('.tab-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.tab === tab);
});
document.querySelectorAll('.tab-content').forEach(content => {
content.classList.toggle('active', content.id === 'tab-' + tab);
});
}
async loadZernMCPacks() {
const select = document.getElementById('zernmc-pack-select');
select.innerHTML = '<option value="">Загрузка...</option>';
const result = await this.request('/instances/zernmc');
if (result.success && result.data && result.data.length > 0) {
this.zernmcPacks = result.data;
select.innerHTML = result.data.map(pack =>
`<option value="${this.escapeHtml(pack.name)}">${this.escapeHtml(pack.name)} (v${pack.version})</option>`
).join('');
} else {
select.innerHTML = '<option value="">Нет доступных сборок</option>';
}
}
async loadMCVersions() {
const select = document.getElementById('mc-version-select');
select.innerHTML = '<option value="">Загрузка...</option>';
const result = await this.request('/versions');
if (result.success && result.data) {
this.mcVersions = result.data;
select.innerHTML = '<option value="">Выберите версию</option>' +
result.data.map(v => `<option value="${v.id}">${v.id}</option>`).join('');
} else {
select.innerHTML = '<option value="">Не удалось загрузить</option>';
}
}
async onLoaderChange(loader) {
const loaderVersionGroup = document.getElementById('loader-version-group');
const loaderVersionSelect = document.getElementById('loader-version-select');
if (loader === 'vanilla') {
loaderVersionGroup.classList.add('hidden');
} else {
loaderVersionGroup.classList.remove('hidden');
loaderVersionSelect.innerHTML = '<option value="">Загрузка...</option>';
const result = await this.request(`/versions/${document.getElementById('mc-version-select').value}/loaders/${loader}`);
if (result.success && result.data) {
loaderVersionSelect.innerHTML = result.data.map(v =>
`<option value="${v.version}">${v.version}</option>`
).join('');
} else {
loaderVersionSelect.innerHTML = '<option value="">Нет версий</option>';
}
}
}
async installZernMCPack() {
const packName = document.getElementById('zernmc-pack-select').value;
const instanceName = document.getElementById('zernmc-instance-name').value;
if (!packName) {
alert('Выберите сборку');
return;
}
if (!instanceName) {
alert('Введите название сборки');
return;
}
this.showProgress('Установка ZernMC сборки...');
this.addLog('Начало установки: ' + packName, 'info');
const result = await this.request('/instances/zernmc/install', {
method: 'POST',
body: JSON.stringify({ packName, instanceName })
});
if (result.success) {
this.hideDownloadModal();
await this.loadCurrentInstance();
this.addLog('Сборка установлена!', 'success');
} else {
this.addLog('Ошибка установки: ' + result.error, 'error');
this.hideProgress();
}
}
async installVanilla() {
const mcVersion = document.getElementById('mc-version-select').value;
const loader = document.getElementById('loader-select').value;
const loaderVersion = document.getElementById('loader-version-select').value;
const instanceName = document.getElementById('vanilla-instance-name').value;
if (!mcVersion) {
alert('Выберите версию Minecraft');
return;
}
if (!instanceName) {
alert('Введите название сборки');
return;
}
if (loader !== 'vanilla' && !loaderVersion) {
alert('Выберите версию лоадера');
return;
}
this.showProgress('Установка сборки...');
this.addLog(`Начало установки: Minecraft ${mcVersion} ${loader !== 'vanilla' ? loader + ' ' + loaderVersion : ''}`, 'info');
const result = await this.request('/instances/vanilla/install', {
method: 'POST',
body: JSON.stringify({
mcVersion,
loader: loader === 'vanilla' ? null : loader,
loaderVersion: loader === 'vanilla' ? null : loaderVersion,
instanceName
})
});
if (result.success) {
this.hideDownloadModal();
await this.loadCurrentInstance();
this.addLog('Сборка установлена!', 'success');
} else {
this.addLog('Ошибка установки: ' + result.error, 'error');
this.hideProgress();
}
}
showProgress(text) {
const progress = document.getElementById('download-progress');
const progressText = document.getElementById('progress-text');
const progressFill = document.getElementById('progress-fill');
progress.classList.remove('hidden');
progressText.textContent = text;
progressFill.style.width = '50%';
}
hideProgress() {
document.getElementById('download-progress').classList.add('hidden');
}
// ==================== LOGS ====================
addLog(message, type = 'info') {
const container = document.getElementById('logs-container');
const entry = document.createElement('div');
entry.className = `log-entry ${type}`;
entry.textContent = `[${new Date().toLocaleTimeString()}] ${message}`;
container.appendChild(entry);
container.scrollTop = container.scrollHeight;
}
clearLogs() {
const container = document.getElementById('logs-container');
container.innerHTML = '<div class="log-entry info">Логи очищены</div>';
}
// ==================== UI HELPERS ====================
showLoginScreen() {
document.getElementById('login-screen').classList.remove('hidden');
document.getElementById('main-screen').classList.add('hidden');
}
showMainScreen() {
document.getElementById('login-screen').classList.add('hidden');
document.getElementById('main-screen').classList.remove('hidden');
document.getElementById('username-display').textContent = this.username || '';
}
showLoading(show) {
const overlay = document.getElementById('loading-overlay');
if (show) {
overlay.classList.remove('hidden');
} else {
overlay.classList.add('hidden');
}
}
escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
}
const app = new App();
@@ -0,0 +1,67 @@
package me.sashegdev.zernmc.launcher.api;
import me.sashegdev.zernmc.launcher.api.instance.InstanceService;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class InstanceServiceTest {
@Test
void instanceService_instantiates() {
InstanceService service = new InstanceService();
assertNotNull(service, "InstanceService должен создаваться");
}
@Test
void getAllInstances_returnsResponse() {
InstanceService service = new InstanceService();
ApiResponse<?> response = service.getAllInstances();
assertNotNull(response, "Ответ не должен быть null");
assertTrue(response.isSuccess() || !response.isSuccess(), "Должен быть валидный ответ");
}
@Test
void getAllInstances_returnsList() {
InstanceService service = new InstanceService();
ApiResponse<?> response = service.getAllInstances();
assertNotNull(response.getData(), "Data не должен быть null");
}
@Test
void isInstanceExists_returnsBoolean() {
InstanceService service = new InstanceService();
ApiResponse<Boolean> response = service.isInstanceExists("nonexistent");
assertNotNull(response, "Ответ не должен быть null");
assertTrue(response.isSuccess(), "Проверка должна быть успешной");
assertNotNull(response.getData(), "Data должен быть boolean");
}
@Test
void isInstanceExists_nonexistentReturnsFalse() {
InstanceService service = new InstanceService();
ApiResponse<Boolean> response = service.isInstanceExists("definitely_nonexistent_12345");
assertTrue(response.isSuccess());
assertFalse(response.getData(), "Несуществующая сборка должна вернуть false");
}
@Test
void deleteInstance_invalidName_returnsError() {
InstanceService service = new InstanceService();
ApiResponse<Boolean> response = service.deleteInstance("nonexistent");
assertNotNull(response, "Ответ не должен быть null");
}
@Test
void getInstance_nonexistent_returnsError() {
InstanceService service = new InstanceService();
ApiResponse<?> response = service.getInstance("definitely_nonexistent_12345");
assertNotNull(response, "Ответ не должен быть null");
assertFalse(response.isSuccess(), "Несуществующая сборка должна вернуть ошибку");
}
}
@@ -0,0 +1,33 @@
package me.sashegdev.zernmc.launcher.web;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
import java.awt.GraphicsEnvironment;
class HeadlessDetectionTest {
@Test
void headlessDetection_works() {
boolean isHeadless = GraphicsEnvironment.isHeadless();
assertNotNull(isHeadless, "isHeadless() должен возвращать boolean");
}
@Test
void headlessDetection_consistentResult() {
boolean isHeadless1 = GraphicsEnvironment.isHeadless();
boolean isHeadless2 = GraphicsEnvironment.isHeadless();
assertEquals(isHeadless1, isHeadless2, "Результат должен быть консистентным");
}
@Test
void javaFxCheck_works() {
try {
boolean isHeadless = java.awt.GraphicsEnvironment.getLocalGraphicsEnvironment()
.getDefaultScreenDevice() != null;
assertFalse(isHeadless, "На Linux без дисплея должно быть headless");
} catch (Exception e) {
assertTrue(true, "Ожидаемая ошибка на headless");
}
}
}
@@ -0,0 +1,37 @@
package me.sashegdev.zernmc.launcher.web;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
import java.io.IOException;
import java.net.ServerSocket;
class WebServerTest {
@Test
void findFreePort_returnsValidPort() throws IOException {
int port = WebServer.findFreePort(8080);
assertTrue(port >= 8080, "Порт должен быть >= 8080");
assertTrue(port < 8180, "Порт должен быть < 8180");
}
@Test
void findFreePort_findsDifferentPorts() throws IOException {
int port1 = WebServer.findFreePort(9000);
int port2 = WebServer.findFreePort(9100);
assertNotEquals(port1, port2, "Должены быть разные порты");
}
@Test
void findFreePort_respectsStartPort() throws IOException {
int port = WebServer.findFreePort(9500);
assertTrue(port >= 9500, "Порт должен быть >= указанного startPort");
}
@Test
void portRangeTest() throws IOException {
int port = WebServer.findFreePort(8080);
assertTrue(port >= 8080 && port < 8180, "Порт в допустимом диапазоне 8080-8179");
}
}
+1 -1
View File
@@ -19,7 +19,7 @@ def parse_args():
# Additional options # Additional options
parser.add_argument("--host", default="0.0.0.0", help="Host to bind to (default: 0.0.0.0)") 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("--port", type=int, default=1582, help="Port to bind to (default: 1582)")
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("--workers", type=int, default=4, help="Number of workers for production mode")
parser.add_argument("--reload", action="store_true", help="Enable auto-reload (development)") parser.add_argument("--reload", action="store_true", help="Enable auto-reload (development)")
return parser.parse_args() return parser.parse_args()
+23 -553
View File
@@ -1,11 +1,8 @@
import re import re
import logging
import os
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
from urllib.parse import urlparse from urllib.parse import urlparse
from typing import Optional
import httpx import httpx
import json import json
@@ -15,10 +12,6 @@ from fastapi import Depends, FastAPI, HTTPException, Request, Response
from fastapi.responses import FileResponse, JSONResponse from fastapi.responses import FileResponse, JSONResponse
from uvicorn.protocols.http.httptools_impl import HttpToolsProtocol 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 pack_manager import DATA_DIR, scan_pack, get_cached_manifest, PACKS_DIR
from models import PackMeta from models import PackMeta
from middleware import LoggingMiddleware from middleware import LoggingMiddleware
@@ -35,18 +28,6 @@ logger = structlog.get_logger(__name__)
manifest_cache = TTLCache(maxsize=100, ttl=300) manifest_cache = TTLCache(maxsize=100, ttl=300)
BUILDS_DIR = Path("builds") 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 @asynccontextmanager
@@ -56,60 +37,6 @@ async def lifespan(app: FastAPI):
# Initialize logging # Initialize logging
init_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 # Determine environment
if args.test: if args.test:
env = "test" env = "test"
@@ -148,26 +75,6 @@ async def lifespan(app: FastAPI):
logger.info("All packs ready. Server is running.") 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 # Initialize proxy client
global proxy_client global proxy_client
proxy_client = httpx.AsyncClient(timeout=60.0, follow_redirects=True) proxy_client = httpx.AsyncClient(timeout=60.0, follow_redirects=True)
@@ -776,280 +683,27 @@ async def get_pack_file(pack_name: str, file_path: str, request: Request):
size=full_path.stat().st_size, size=full_path.stat().st_size,
client_ip=client_ip) client_ip=client_ip)
return FileResponse(full_path, direct_passthrough=True) return FileResponse(full_path)
# ====================== ЭНДПОИНТЫ ДЛЯ ЛАУНЧЕРА ====================== # ====================== ЭНДПОИНТЫ ДЛЯ ЛАУНЧЕРА ======================
def get_current_launcher_version() -> str: def get_current_launcher_version() -> str:
"""Get current launcher version from meta system (new format) or build.version (legacy)""" """Get current launcher version from build.version file"""
versions = get_launcher_versions()
if versions:
return versions[0]["meta"]["version"]
# Fallback to build.version for legacy
version_file = BUILDS_DIR / "build.version" version_file = BUILDS_DIR / "build.version"
if version_file.exists(): if version_file.exists():
return version_file.read_text().strip() return version_file.read_text().strip()
return "1.0.0" 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: def get_available_zips() -> list:
"""Get list of available zip archives (new format only)""" """Get list of available zip archives"""
if not BUILDS_DIR.exists(): if not BUILDS_DIR.exists():
return [] return []
zips = [] zips = []
for zip_file in BUILDS_DIR.glob("ZernMCLauncher-*.zip"): for zip_file in BUILDS_DIR.glob("ZernMCLauncher-*.zip"):
if is_new_format(zip_file.name):
continue
version = zip_file.stem.replace("ZernMCLauncher-", "") 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() stat = zip_file.stat()
zips.append({ zips.append({
"version": version, "version": version,
@@ -1058,37 +712,7 @@ def get_new_format_zips() -> list:
"modified": datetime.fromtimestamp(stat.st_mtime).isoformat() "modified": datetime.fromtimestamp(stat.st_mtime).isoformat()
}) })
zips.sort(key=lambda x: parse_version_key(x["version"]), reverse=True) zips.sort(key=lambda x: 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 return zips
@@ -1096,8 +720,7 @@ def get_legacy_zips() -> list:
async def get_launcher_version(): async def get_launcher_version():
"""Return launcher version information""" """Return launcher version information"""
version = get_current_launcher_version() version = get_current_launcher_version()
new_zips = get_new_format_zips() zips = get_available_zips()
legacy_zips = get_legacy_zips()
response = { response = {
"version": version, "version": version,
@@ -1114,15 +737,11 @@ async def get_launcher_version():
response["download_exe"] = "/launcher/download/exe" response["download_exe"] = "/launcher/download/exe"
response["exe_size"] = exe_path.stat().st_size response["exe_size"] = exe_path.stat().st_size
if new_zips: if zips:
response["download_zip"] = f"/launcher/download/zip/{new_zips[0]['filename']}" response["download_zip"] = f"/launcher/download/zip/{zips[0]['filename']}"
response["zip_version"] = new_zips[0]["version"] response["zip_version"] = zips[0]["version"]
response["zip_size"] = new_zips[0]["size"] response["zip_size"] = zips[0]["size"]
response["all_zips"] = new_zips response["all_zips"] = zips
if legacy_zips:
response["legacy_zips"] = legacy_zips
response["legacy_download_url"] = "/launcher/download/legacy"
return response return response
@@ -1130,9 +749,6 @@ async def get_launcher_version():
@app.get("/launcher/download/jar") @app.get("/launcher/download/jar")
async def download_launcher_jar(): async def download_launcher_jar():
"""Download launcher JAR file""" """Download launcher JAR file"""
# Prefer new shaded JAR, fallback to old
file_path = BUILDS_DIR / "zernmclauncher.jar"
if not file_path.exists():
file_path = BUILDS_DIR / "ZernMCLauncher.jar" file_path = BUILDS_DIR / "ZernMCLauncher.jar"
if not file_path.exists(): if not file_path.exists():
@@ -1140,7 +756,7 @@ async def download_launcher_jar():
return FileResponse( return FileResponse(
path=file_path, path=file_path,
filename="zernmclauncher.jar", filename="ZernMCLauncher.jar",
media_type="application/java-archive" media_type="application/java-archive"
) )
@@ -1163,8 +779,7 @@ async def download_launcher_exe():
@app.get("/launcher/download/zip/{filename}") @app.get("/launcher/download/zip/{filename}")
async def download_launcher_zip(filename: str): async def download_launcher_zip(filename: str):
"""Download specific launcher ZIP archive""" """Download specific launcher ZIP archive"""
valid_patterns = ["ZernMCLauncher-", "ZernMC-win-"] if ".." in filename or not filename.startswith("ZernMCLauncher-") or not filename.endswith(".zip"):
if ".." in filename or not any(filename.startswith(p) for p in valid_patterns) or not filename.endswith(".zip"):
raise HTTPException(400, "Invalid filename") raise HTTPException(400, "Invalid filename")
file_path = BUILDS_DIR / filename file_path = BUILDS_DIR / filename
@@ -1181,158 +796,25 @@ async def download_launcher_zip(filename: str):
@app.get("/launcher/download/latest") @app.get("/launcher/download/latest")
async def download_latest_launcher(): async def download_latest_launcher():
"""Download the latest launcher (new format: ZernMC-win-*.zip)""" """Download the latest launcher (prefer ZIP if available, fallback to JAR)"""
zips = get_new_format_zips() zips = get_available_zips()
if zips: if zips:
latest_zip = zips[0]["filename"] latest_zip = zips[0]["filename"]
return await download_launcher_zip(latest_zip) return await download_launcher_zip(latest_zip)
raise HTTPException(404, "No new format launcher files available") jar_path = BUILDS_DIR / "ZernMCLauncher.jar"
if jar_path.exists():
return await download_launcher_jar()
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") @app.get("/launcher/info")
async def get_launcher_full_info(): async def get_launcher_full_info():
"""Full launcher information with all available files""" """Full launcher information with all available files"""
version = get_current_launcher_version() version = get_current_launcher_version()
new_zips = get_new_format_zips() zips = get_available_zips()
legacy_zips = get_legacy_zips()
info = { info = {
"current_version": version, "current_version": version,
@@ -1340,21 +822,9 @@ async def get_launcher_full_info():
"files": { "files": {
"jar": None, "jar": None,
"exe": None, "exe": None,
"zips": new_zips + legacy_zips "zips": zips
}, },
"recommended": "zip" if new_zips else ("exe" if (BUILDS_DIR / "ZernMCLauncher.exe").exists() else "jar"), "recommended": "zip" if 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" jar_path = BUILDS_DIR / "ZernMCLauncher.jar"
@@ -1371,8 +841,8 @@ async def get_launcher_full_info():
"download_url": "/launcher/download/exe" "download_url": "/launcher/download/exe"
} }
if new_zips: if zips:
info["files"]["latest_zip"] = new_zips[0] info["files"]["latest_zip"] = zips[0]
info["files"]["download_latest"] = "/launcher/download/latest" info["files"]["download_latest"] = "/launcher/download/latest"
return info return info
+16 -175
View File
@@ -5,202 +5,43 @@ import logging
import time import time
import uuid import uuid
import traceback import traceback
import httpx
import re
from collections import defaultdict
from typing import Optional
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Public blocklist URLs class LoggingMiddleware(BaseHTTPMiddleware):
BLOCKLIST_URLS = [ async def dispatch(self, request: Request, call_next):
"https://raw.githubusercontent.com/firehol/blocklist-ipsets/master/firehol_level1.netset", # Generate request ID
"https://raw.githubusercontent.com/firehol/blocklist-ipsets/master/iblocklist_isp.netset", request_id = str(uuid.uuid4())[:8]
]
# Get client IP
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" client_ip = request.client.host if request.client else "unknown"
forwarded = request.headers.get("x-forwarded-for") forwarded = request.headers.get("x-forwarded-for")
if forwarded: if forwarded:
client_ip = forwarded.split(",")[0].strip() client_ip = forwarded.split(",")[0].strip()
return client_ip
# Log incoming request
logger.info(f"{request.method} {request.url.path} (IP: {client_ip}, ID: {request_id})")
def is_ip_blocked(client_ip: str) -> bool: # Start timer
"""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):
request_id = str(uuid.uuid4())[:8]
global _stats, _stats_last_log
client_ip = get_client_ip(request)
# Check if IP is blocked (silent)
if is_ip_blocked(client_ip):
_stats["blocked"] += 1
return Response(status_code=404, content="")
# 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() start_time = time.time()
if not is_file_download:
logger.info(f"{request.method} {path} (IP: {client_ip}, ID: {request_id})")
try: try:
response = await call_next(request) response = await call_next(request)
# Calculate duration
duration = (time.time() - start_time) * 1000 duration = (time.time() - start_time) * 1000
if not is_file_download: # Log response
logger.info(f"{request.method} {path}{response.status_code} ({duration:.0f}ms) [ID: {request_id}]") logger.info(f"{request.method} {request.url.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 response.headers["X-Request-ID"] = request_id
return response return response
except Exception as e: except Exception as e:
duration = (time.time() - start_time) * 1000 duration = (time.time() - start_time) * 1000
# Log full traceback
error_traceback = traceback.format_exc() error_traceback = traceback.format_exc()
logger.error(f"{request.method} {path} → ERROR: {str(e)} (ID: {request_id})\n{error_traceback}") logger.error(f"{request.method} {request.url.path} → ERROR: {str(e)} (ID: {request_id})\n{error_traceback}")
raise raise