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
64 changed files with 3499 additions and 6294 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,491 +0,0 @@
package me.sashegdev.zernmc.launcher;
import java.io.*;
import java.net.HttpURLConnection;
import java.net.URL;
import java.net.URLClassLoader;
import java.nio.file.*;
import java.nio.charset.StandardCharsets;
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 List<String> MIRRORS = new ArrayList<>();
private static volatile boolean jfxChildExiting = false;
private static Path baseDir;
private static Path binDir;
private static Path logDir;
private static Path javafxPath;
private static boolean isCliMode;
private static boolean isJfxMode;
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);
javafxPath = baseDir.resolve("lib").resolve("javafx");
log("=== ZernMC Launcher ===");
List<String> argList = Arrays.asList(args);
isCliMode = argList.contains("--cli");
isJfxMode = !isCliMode;
log("Mode: " + (isCliMode ? "CLI" : "JFX"));
String currentVersion = readCurrentVersion();
String serverVersion = getServerVersion();
log("Local version: " + currentVersion);
log("Server version: " + serverVersion);
loadMirrors();
log("Primary server: " + BASE_URL);
log("Mirrors available: " + (MIRRORS.size() + 1));
if (isNewer(serverVersion, currentVersion)) {
log("Update available!");
downloadUpdate(serverVersion);
} else {
log("Version is up to date");
}
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
log("Shutdown signal received...");
}));
launchMain(args);
}
private static void launchMain(String[] args) throws Exception {
log("Loading launcher: " + getLauncherJar());
if (isCliMode) {
launchInProcess(args);
} else {
launchInNewProcess(args);
}
}
private static void launchInProcess(String[] args) throws Exception {
ClassLoader parent = Bootstrap.class.getClassLoader();
URL[] urls = { getLauncherJar().toUri().toURL() };
URLClassLoader cl = new URLClassLoader(urls, parent);
Thread.currentThread().setContextClassLoader(cl);
try {
Class<?> mainClass = cl.loadClass("me.sashegdev.zernmc.launcher.Main");
java.lang.reflect.Method mainMethod = mainClass.getMethod("main", String[].class);
mainMethod.invoke(null, (Object) args);
} finally {
cl.close();
}
}
private static void launchInNewProcess(String[] args) throws Exception {
String os = System.getProperty("os.name").toLowerCase();
Path javaBin = findJava(false);
// On Windows, use javaw.exe to hide console in JFX mode
if (os.contains("windows")) {
Path javawPath = javaBin.resolveSibling("javaw.exe");
if (Files.exists(javawPath)) {
javaBin = javawPath;
}
}
Path javafxPath = baseDir.resolve("lib").resolve("javafx");
List<String> cmd = new ArrayList<>();
cmd.add(javaBin.toAbsolutePath().toString());
cmd.add("-Dfile.encoding=UTF-8");
cmd.add("-Dsun.stdout.encoding=UTF-8");
cmd.add("-Dsun.stderr.encoding=UTF-8");
cmd.add("-Dlauncher.server=" + BASE_URL);
if (Files.exists(javafxPath)) {
cmd.add("--module-path");
cmd.add(javafxPath.toAbsolutePath().toString());
cmd.add("--add-modules");
cmd.add("javafx.controls,javafx.web");
}
cmd.add("-jar");
cmd.add(getLauncherJar().toAbsolutePath().toString());
cmd.add("--jfx");
ProcessBuilder pb = new ProcessBuilder(cmd);
pb.directory(baseDir.toFile());
pb.inheritIO();
log("Starting process: " + String.join(" ", cmd));
Process p = pb.start();
int code = p.waitFor();
log("JFX process exited with code: " + code);
System.exit(code);
}
private static Path findJava(boolean preferConsole) {
String os = System.getProperty("os.name").toLowerCase();
String javaExe = "java.exe";
Path javaBin = baseDir.resolve("jre21").resolve("bin").resolve(javaExe);
if (!Files.exists(javaBin)) {
javaBin = Paths.get(System.getProperty("java.home"), "bin", javaExe);
}
if (!Files.exists(javaBin)) {
try {
Process p = new ProcessBuilder("where", javaExe).redirectErrorStream(true).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 not found");
}
return javaBin;
}
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("Error reading manifest: " + 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("Error fetching version: " + 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("Checking for updates...");
Map<String, FileMeta> serverFiles = fetchServerMeta(newVersion);
if (serverFiles.isEmpty()) {
log("Failed to get server meta");
return;
}
Map<String, String> localFiles = scanLocalFiles();
log("Local files: " + localFiles.size());
log("Server files: " + 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("Updating: " + filePath);
} else {
log("Downloading: " + filePath);
}
downloadFile(newVersion, filePath, serverMeta.size);
downloaded++;
}
log("Updated files: " + downloaded + ", skipped: " + skipped);
log("Updated to 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("Error fetching meta: " + 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 {
List<String> servers = new ArrayList<>();
if (isServerReachable(BASE_URL)) servers.add(BASE_URL);
servers.addAll(MIRRORS);
java.util.Collections.shuffle(servers);
Exception lastError = null;
for (String server : servers) {
try {
downloadFileFromServer(server + "/launcher/file/" + version + "/" + filePath, expectedSize, filePath);
return;
} catch (Exception e) {
lastError = e;
}
}
downloadFileFromServer(BASE_URL + "/launcher/file/" + version + "/" + filePath, expectedSize, filePath);
}
private static void downloadFileFromServer(String urlStr, long expectedSize, String fileName) throws Exception {
URL url = new URL(urlStr);
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
conn.setConnectTimeout(10000);
conn.setReadTimeout(60000);
if (conn.getResponseCode() != 200) {
throw new IOException("HTTP " + conn.getResponseCode());
}
if (expectedSize <= 0) {
expectedSize = conn.getContentLengthLong();
}
Path outPath = baseDir.resolve(fileName);
Files.createDirectories(outPath.getParent());
long downloaded = 0;
long lastUpdate = 0;
long startTime = System.currentTimeMillis();
try (InputStream in = conn.getInputStream();
OutputStream out = new FileOutputStream(outPath.toFile())) {
byte[] buf = new byte[65536];
int len;
while ((len = in.read(buf)) > 0) {
out.write(buf, 0, len);
downloaded += len;
if (downloaded - lastUpdate > 1024 || downloaded == expectedSize) {
long elapsed = System.currentTimeMillis() - startTime;
double speed = downloaded / 1024.0 / 1024.0 / (elapsed / 1000.0 + 0.001);
double downloadedMB = downloaded / 1024.0 / 1024.0;
double totalMB = expectedSize / 1024.0 / 1024.0;
System.out.print(String.format("\r[%s] %s - %.1f/%.1f MB (%.1f MB/s",
getProgressBar(downloaded, expectedSize),
fileName,
downloadedMB,
totalMB,
speed
));
lastUpdate = downloaded;
}
}
}
long elapsed = System.currentTimeMillis() - startTime;
double speed = downloaded / 1024.0 / 1024.0 / (elapsed / 1000.0 + 0.001);
System.out.println(String.format("\r[%s] %s - %.1f MB (%.1f MB/s) - Done!",
getProgressBar(downloaded, expectedSize),
fileName,
downloaded / 1024.0 / 1024.0,
speed
));
}
private static String getProgressBar(long current, long total) {
if (total <= 0) return "====";
int filled = (int) ((current * 20) / total);
StringBuilder sb = new StringBuilder("[");
for (int i = 0; i < 20; i++) {
sb.append(i < filled ? "=" : " ");
}
sb.append("]");
return sb.toString();
}
private static class FileMeta {
String hash;
long size;
FileMeta(String hash, long size) {
this.hash = hash;
this.size = size;
}
}
private static void loadMirrors() {
try {
URL url = new URL(BASE_URL + "/launcher/mirrors");
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
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);
com.google.gson.JsonObject json = JsonParser.parseString(sb.toString()).getAsJsonObject();
com.google.gson.JsonArray mirrorsArray = json.getAsJsonArray("mirrors");
for (com.google.gson.JsonElement elem : mirrorsArray) {
com.google.gson.JsonObject mirror = elem.getAsJsonObject();
String mirrorUrl = mirror.get("url").getAsString();
if (!MIRRORS.contains(mirrorUrl)) {
MIRRORS.add(mirrorUrl);
}
}
}
}
} catch (Exception e) {
log("Mirrors unavailable: " + e.getMessage());
}
}
private static boolean isServerReachable(String serverUrl) {
try {
URL url = new URL(serverUrl + "/launcher/version");
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
conn.setConnectTimeout(2000);
conn.setReadTimeout(2000);
return conn.getResponseCode() == 200;
} catch (Exception ignored) {
return false;
}
}
}
+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,255 +0,0 @@
package me.sashegdev.zernmc.launcher;
import java.io.*;
import java.net.HttpURLConnection;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
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;
String currentVersion = readCurrentVersion();
String serverVersion = getServerVersion();
log("Local version: " + currentVersion);
log("Server version: " + serverVersion);
if (isNewer(serverVersion, currentVersion)) {
log("Update available!");
downloadUpdate(serverVersion);
} else {
log("Version is up to date");
}
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("\rDownloaded: " + (total/1024/1024) + " MB");
}
}
log("Downloaded");
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("Updated to v" + newVersion);
} else {
throw new IOException("Server returned code: " + conn.getResponseCode());
}
}
private static void launchJFX() throws Exception {
Path javaBin = findJava();
Path jarPath = baseDir.resolve(JAR_NAME);
log("Starting JFX mode...");
log("Java: " + javaBin);
log("JAR: " + jarPath);
List<String> cmd = new ArrayList<>();
cmd.add(javaBin.toAbsolutePath().toString());
cmd.add("-Dfile.encoding=UTF-8");
cmd.add("-Dsun.stdout.encoding=UTF-8");
cmd.add("-Dsun.stderr.encoding=UTF-8");
cmd.add("-jar");
cmd.add(jarPath.toAbsolutePath().toString());
cmd.add("--jfx");
ProcessBuilder pb = new ProcessBuilder(cmd);
pb.directory(baseDir.toFile());
if (System.getProperty("os.name").toLowerCase().contains("windows")) {
pb.environment().put("JAVA_TOOL_OPTIONS", "-Dfile.encoding=UTF-8");
}
pb.redirectErrorStream(true);
Process p = pb.start();
Thread outputThread = new Thread(() -> {
try (BufferedReader reader = new BufferedReader(
new InputStreamReader(p.getInputStream(), StandardCharsets.UTF_8))) {
String line;
while ((line = reader.readLine()) != null) {
System.out.println(line);
}
} catch (Exception ignored) {}
});
outputThread.start();
int code = p.waitFor();
try { outputThread.interrupt(); } catch (Exception ignored) {}
log("Exited with code: " + code);
System.exit(code);
}
private static void launchCLI() throws Exception {
Path javaBin = findJava();
Path jarPath = baseDir.resolve(JAR_NAME);
log("Starting CLI mode...");
log("Java: " + javaBin);
log("JAR: " + jarPath);
List<String> cmd = new ArrayList<>();
cmd.add(javaBin.toAbsolutePath().toString());
cmd.add("-Dfile.encoding=UTF-8");
cmd.add("-Dsun.stdout.encoding=UTF-8");
cmd.add("-Dsun.stderr.encoding=UTF-8");
cmd.add("-jar");
cmd.add(jarPath.toAbsolutePath().toString());
cmd.add("--cli");
ProcessBuilder pb = new ProcessBuilder(cmd);
pb.directory(baseDir.toFile());
if (System.getProperty("os.name").toLowerCase().contains("windows")) {
pb.environment().put("JAVA_TOOL_OPTIONS", "-Dfile.encoding=UTF-8");
}
pb.redirectErrorStream(true);
Process p = pb.start();
Thread outputThread = new Thread(() -> {
try (BufferedReader reader = new BufferedReader(
new InputStreamReader(p.getInputStream(), StandardCharsets.UTF_8))) {
String line;
while ((line = reader.readLine()) != null) {
System.out.println(line);
}
} catch (Exception ignored) {}
});
outputThread.start();
int code = p.waitFor();
try { outputThread.interrupt(); } catch (Exception ignored) {}
log("Exited with code: " + code);
System.exit(code);
}
private static Path findJava() {
String os = System.getProperty("os.name").toLowerCase();
String javaExe = os.contains("windows") ? "java.exe" : "java";
Path javaBin = baseDir.resolve("jre21").resolve("bin").resolve(javaExe);
if (!Files.exists(javaBin)) {
javaBin = Paths.get(System.getProperty("java.home"), "bin", javaExe);
}
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 not found. Make sure jre21 is present in the launcher folder or Java is installed on the system");
}
return javaBin;
}
}
@@ -1,164 +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 {
System.setProperty("file.encoding", "UTF-8");
System.setProperty("sun.stderr.encoding", "UTF-8");
System.setProperty("sun.stdout.encoding", "UTF-8");
System.setProperty("java.stdout.encoding", "UTF-8");
System.setProperty("java.stderr.encoding", "UTF-8");
System.setProperty("org.jline.terminal.disableDeprecatedProviderWarning", "true");
if (System.getProperty("os.name").toLowerCase().contains("windows")) {
try {
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("Welcome to ZernMC Launcher " + CURRENT_VERSION));
List<String> argList = List.of(args);
boolean jfxMode = argList.contains("--jfx");
boolean cliMode = argList.contains("--cli");
if (jfxMode) {
launchJFX();
return;
}
System.out.print("\033[H\033[2J");
System.out.println(ZAnsi.brightGreen("Welcome to ZernMC Launcher " + CURRENT_VERSION));
startCLI();
}
private static void launchJFX() {
try {
System.setProperty("javafx.runtime.version", "21");
JFXLauncher.main(new String[]{});
} catch (Exception e) {
System.err.println(ZAnsi.brightRed("Error starting JFX: " + e.getMessage()));
if (e.getMessage() != null && e.getMessage().contains("QuantumRenderer")) {
System.err.println(ZAnsi.yellow("JavaFX is not available. Native libraries may be missing."));
System.err.println(ZAnsi.yellow("Try CLI mode: --cli"));
}
e.printStackTrace();
System.exit(1);
}
}
private static void startCLI() throws IOException {
ZHttpClient.checkAllServicesOnStartup(true);
System.out.println(ZAnsi.cyan("Checking authorization..."));
var sessionResponse = api.checkSession();
if (!sessionResponse.isSuccess()) {
LoginMenu loginMenu = new LoginMenu();
boolean loggedIn = loginMenu.show();
if (!loggedIn) {
System.out.println(ZAnsi.yellow("Goodbye!"));
ZAnsi.uninstall();
System.exit(0);
}
} else {
var sessionInfo = sessionResponse.getData();
System.out.println(ZAnsi.brightGreen("Welcome back, " + sessionInfo.getUsername() + "!"));
}
System.out.println(ZAnsi.cyan("Starting CLI mode..."));
try {
mainLoop();
} catch (Exception e) {
System.err.println(ZAnsi.brightRed("Critical error: " + e.getMessage()));
e.printStackTrace();
} finally {
ZAnsi.uninstall();
}
}
private static void mainLoop() throws Exception {
if (Config.isZernMCBuild()) {
zernMCFlow();
} else {
globalFlow();
}
}
private static void zernMCFlow() throws Exception {
ConsoleUtils.clearScreen();
System.out.println(ZAnsi.header("=== ZernMC Private Launcher ==="));
System.out.println(ZAnsi.cyan("Checking connection to ZernMC server..."));
try {
String response = ZHttpClient.get("/health");
System.out.println(ZAnsi.brightGreen("✓ Server is available"));
} catch (Exception e) {
System.out.println(ZAnsi.brightRed("✗ Could not connect to ZernMC server"));
System.out.println(ZAnsi.white("Error: " + e.getMessage()));
ConsoleUtils.pause();
System.exit(1);
}
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("Welcome back, " + AuthManager.getUsername() + "!"));
}
LaunchMenu launchMenu = new LaunchMenu();
launchMenu.show();
}
private static void globalFlow() throws Exception {
while (true) {
ConsoleUtils.clearScreen();
System.out.println(ZAnsi.header("=== ZernMC Launcher ==="));
List<String> options = List.of(
"Launch Game",
"Check Updates",
"Settings",
"Server Connection Check",
"Exit"
);
ArrowMenu menu = new ArrowMenu("Main Menu", options);
int choice = menu.show();
if (choice == -1 || choice == 4) {
System.out.println(ZAnsi.yellow("Goodbye!"));
break;
}
switch (choice) {
case 0 -> new LaunchMenu().show();
case 1 -> new UpdateMenu().show();
case 2 -> new SettingsMenu().show();
case 3 -> new ServerCheckMenu().show();
}
}
}
}
@@ -1,179 +0,0 @@
package me.sashegdev.zernmc.launcher.api;
import me.sashegdev.zernmc.launcher.api.auth.AuthService;
import me.sashegdev.zernmc.launcher.api.instance.InstanceService;
import me.sashegdev.zernmc.launcher.api.launch.LaunchService;
import me.sashegdev.zernmc.launcher.utils.ZHttpClient;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
public class LauncherAPI {
private final AuthService authService;
private final InstanceService instanceService;
private final LaunchService launchService;
public LauncherAPI() {
this.authService = new AuthService();
this.instanceService = new InstanceService();
this.launchService = new LaunchService();
}
public AuthService auth() {
return authService;
}
public InstanceService instances() {
return instanceService;
}
public LaunchService launch() {
return launchService;
}
public boolean isLoggedIn() {
return authService.isLoggedIn();
}
public String getCurrentUsername() {
return authService.getCurrentUsername();
}
public ApiResponse<AuthService.SessionInfo> checkSession() {
return authService.checkSession();
}
public ApiResponse<AuthService.LoginResult> login(String username, String password) {
return authService.login(username, password);
}
public ApiResponse<Boolean> logout() {
return authService.logout();
}
public ApiResponse<Boolean> activatePass(String passCode) {
return authService.activatePass(passCode);
}
public ApiResponse<AuthService.LoginResult> register(String username, String password) {
return authService.register(username, password);
}
public ApiResponse<List<InstanceService.InstanceInfo>> getAllInstances() {
return instanceService.getAllInstances();
}
public ApiResponse<LaunchService.InstanceInfo> getLaunchInfo(String instanceName) {
return launchService.getLaunchInfo(instanceName);
}
public ApiResponse<LaunchService.LaunchInfo> prepareLaunch(String instanceName) {
return launchService.prepareLaunch(instanceName);
}
public ApiResponse<LaunchService.ProcessInfo> launch(String instanceName) {
return launchService.launch(instanceName);
}
public ApiResponse<List<String>> getMCVersions() {
try {
org.json.JSONObject manifest = ZHttpClient.getMojangVersionManifest();
org.json.JSONArray versions = manifest.getJSONArray("versions");
List<String> mcVersions = new ArrayList<>();
for (int i = 0; i < versions.length(); i++) {
mcVersions.add(versions.getJSONObject(i).getString("id"));
}
return ApiResponse.success(mcVersions);
} catch (Exception e) {
System.out.println("[API] MC versions fetch failed: " + e.getMessage());
}
return ApiResponse.error("Failed to load Minecraft versions");
}
public ApiResponse<List<String>> getLoaderVersions(String mcVersion, String loader) {
try {
List<String> versions = new ArrayList<>();
switch (loader.toLowerCase()) {
case "fabric":
versions = ZHttpClient.getFabricLoaderVersions();
break;
case "forge":
String xml = ZHttpClient.downloadString("https://maven.minecraftforge.net/net/minecraftforge/forge/maven-metadata.xml");
int idx = 0;
while ((idx = xml.indexOf("<version>", idx)) != -1) {
int start = idx + 9;
int end = xml.indexOf("</version>", start);
if (end == -1) break;
String fullVersion = xml.substring(start, end).trim();
if (fullVersion.startsWith(mcVersion + "-")) {
versions.add(fullVersion.substring(mcVersion.length() + 1));
}
idx = end;
}
versions.sort((a, b) -> b.compareTo(a));
break;
case "neoforge":
String neoforgeXml = ZHttpClient.downloadString("https://maven.neoforged.net/releases/net/neoforged/neoforge/maven-metadata.xml");
int neoidx = 0;
while ((neoidx = neoforgeXml.indexOf("<version>", neoidx)) != -1) {
int start = neoidx + 9;
int end = neoforgeXml.indexOf("</version>", start);
if (end == -1) break;
String fullVersion = neoforgeXml.substring(start, end).trim();
if (isNeoForgeCompatible(fullVersion, mcVersion)) {
versions.add(fullVersion);
}
neoidx = end;
}
versions.sort((a, b) -> b.compareTo(a));
break;
default:
break;
}
return ApiResponse.success(versions);
} catch (Exception e) {
System.out.println("[API] Loader versions fetch failed: " + e.getMessage());
return ApiResponse.error("Failed to load loader versions");
}
}
private boolean isNeoForgeCompatible(String version, String mcVersion) {
if (mcVersion.startsWith("1.21")) {
return version.contains("1.21") && !version.contains("1.20");
} else if (mcVersion.startsWith("1.20") && !mcVersion.equals("1.20")) {
return version.contains("1.20.4") || version.contains("1.20.5") || version.contains("1.20.6");
}
return false;
}
public ApiResponse<List<Map<String, String>>> getZernMCPacks() {
try {
String token = authService.getCurrentToken();
if (token == null) {
return ApiResponse.error("Not logged in");
}
String response = ZHttpClient.get("/packs");
org.json.JSONArray arr = new org.json.JSONArray(response);
List<Map<String, String>> packs = new ArrayList<>();
for (int i = 0; i < arr.length(); i++) {
org.json.JSONObject pack = arr.getJSONObject(i);
Map<String, String> packInfo = new java.util.HashMap<>();
packInfo.put("name", pack.optString("name", ""));
packInfo.put("displayName", pack.optString("displayName", pack.optString("name", "")));
packInfo.put("version", pack.optString("version", ""));
packInfo.put("mcVersion", pack.optString("mcVersion", ""));
packInfo.put("loader", pack.optString("loader", "vanilla"));
packInfo.put("description", pack.optString("description", ""));
packs.add(packInfo);
}
return ApiResponse.success(packs);
} catch (Exception e) {
System.out.println("[API] Packs fetch failed: " + e.getMessage());
return ApiResponse.error("Failed to load packs: " + e.getMessage());
}
}
}
@@ -1,68 +0,0 @@
package me.sashegdev.zernmc.launcher.menu;
import me.sashegdev.zernmc.launcher.ui.ArrowMenu;
import me.sashegdev.zernmc.launcher.utils.Config;
import me.sashegdev.zernmc.launcher.utils.ConsoleUtils;
import me.sashegdev.zernmc.launcher.utils.Input;
import me.sashegdev.zernmc.launcher.utils.ZAnsi;
import java.io.IOException;
import java.util.List;
public class SettingsMenu {
public void show() throws IOException {
List<String> options = List.of(
"Configure Java path",
"Configure allocated RAM",
"Additional JVM parameters",
"Back to main menu"
);
ArrowMenu menu = new ArrowMenu("Launcher Settings", options);
int choice = menu.show();
if (choice == -1 || choice == 3) return;
ConsoleUtils.clearScreen();
switch (choice) {
case 0 -> configureJava();
case 1 -> configureRam();
case 2 -> configureJvmArgs();
}
ConsoleUtils.pause();
}
private void configureJava() {
System.out.println(ZAnsi.cyan("Java path:"));
System.out.println(" " + Config.getJreDir().toAbsolutePath());
System.out.println(ZAnsi.white("\nJava will be searched automatically in ~/.zernmc/jre/"));
System.out.println("If needed, place your own Java version there.");
}
private void configureRam() {
System.out.println(ZAnsi.cyan("RAM Allocation"));
System.out.println(Config.getRamInfo());
int newRam = Input.readInt(
ZAnsi.white("\nEnter new RAM value in MB (or 0 to cancel): "),
0, 32768
);
if (newRam == 0) {
System.out.println(ZAnsi.yellow("Setting cancelled."));
return;
}
Config.setMaxMemory(newRam);
System.out.println(ZAnsi.brightGreen("Allocated RAM changed to " + newRam + " MB"));
}
private void configureJvmArgs() {
System.out.println(ZAnsi.yellow("Additional JVM parameters"));
System.out.println("Currently in development.");
System.out.println("A list of preset optimizations will be available in the future.");
}
}
@@ -1,892 +0,0 @@
package me.sashegdev.zernmc.launcher.ui.jfx;
import javafx.application.Application;
import javafx.application.Platform;
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.api.launch.LaunchService;
import me.sashegdev.zernmc.launcher.auth.AuthManager;
import me.sashegdev.zernmc.launcher.minecraft.Instance;
import me.sashegdev.zernmc.launcher.minecraft.InstanceManager;
import me.sashegdev.zernmc.launcher.minecraft.MinecraftLib;
import me.sashegdev.zernmc.launcher.minecraft.launch.LaunchCommandBuilder;
import me.sashegdev.zernmc.launcher.utils.Config;
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 static StringBuilder launcherLogBuffer = new StringBuilder();
private static StringBuilder gameLogBuffer = new StringBuilder();
private static Path gameLogFile;
private static Path launcherLogFile;
private Stage mainStage;
private static volatile String installProgressLabel = "";
private static volatile int installProgressCurrent = 0;
private static volatile int installProgressTotal = 100;
private static volatile boolean installInProgress = false;
private static final java.util.concurrent.CopyOnWriteArrayList<LogConsumer> logConsumers = new java.util.concurrent.CopyOnWriteArrayList<>();
private static final java.util.concurrent.CopyOnWriteArrayList<LogConsumer> gameLogConsumers = new java.util.concurrent.CopyOnWriteArrayList<>();
public interface LogConsumer {
void onLog(String line);
}
public static void addLogConsumer(LogConsumer consumer) {
logConsumers.add(consumer);
}
public static void removeLogConsumer(LogConsumer consumer) {
logConsumers.remove(consumer);
}
public static void addGameLogConsumer(LogConsumer consumer) {
gameLogConsumers.add(consumer);
}
public static void removeGameLogConsumer(LogConsumer consumer) {
gameLogConsumers.remove(consumer);
}
public static void setInstallProgress(String label, int current, int total) {
installProgressLabel = label;
installProgressCurrent = current;
installProgressTotal = total;
}
public static void setInstallInProgress(boolean inProgress) {
installInProgress = inProgress;
}
public static boolean isInstallInProgress() {
return installInProgress;
}
public static void appendLauncherLog(String log) {
synchronized (launcherLogBuffer) {
launcherLogBuffer.append(log).append("\n");
}
for (LogConsumer consumer : logConsumers) {
try { consumer.onLog(log); } catch (Exception ignored) {}
}
}
public static void appendGameLog(String log) {
System.out.println("[GAME] " + 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) {}
}
}
for (LogConsumer consumer : gameLogConsumers) {
try { consumer.onLog(log); } 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 void clearGameLog() {
synchronized (gameLogBuffer) {
gameLogBuffer.setLength(0);
}
}
public static String getGameLogs() {
synchronized (gameLogBuffer) {
return gameLogBuffer.toString();
}
}
public static String getLauncherLogs() {
synchronized (launcherLogBuffer) {
return launcherLogBuffer.toString();
}
}
public static void main(String[] args) {
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
System.out.println("[JFX] Shutdown hook triggered");
LaunchService.killAllProcesses();
}));
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] Loading assets via meta for version " + serverVersion);
if (downloadAssetsFromMeta(serverVersion)) {
System.out.println("[JFX] Assets loaded via meta");
return;
}
System.out.println("[JFX] Meta unavailable, using fallback");
}
System.out.println("[JFX] Extracting assets from 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 extracted from JAR");
}
} catch (Exception e) {
System.out.println("[JFX] Error extracting 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] Error loading via meta: " + e.getMessage());
return false;
}
}
@Override
public void start(Stage stage) {
this.mainStage = stage;
try {
// Initialize launcher log file
Path logsDir = Paths.get("logs");
Files.createDirectories(logsDir);
launcherLogFile = logsDir.resolve("launcher.log");
Files.writeString(launcherLogFile, "=== Launcher Log " + LocalDateTime.now() + " ===\n",
StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING);
extractAssets();
log("Starting 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("Page loaded");
} 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(1280);
stage.setHeight(800);
stage.setMinWidth(800);
stage.setMinHeight(600);
stage.setScene(new Scene(webView));
stage.show();
log("Window displayed");
stage.setOnCloseRequest(e -> {
log("Closing...");
LaunchService.killAllProcesses();
if (server != null) server.stop(0);
Platform.exit();
});
} catch (Exception e) {
log("Error: " + 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/auto-login", this::handleAutoLogin);
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/install/progress", this::handleInstallProgress);
server.createContext("/api/logs", this::handleLogs);
server.createContext("/api/logs/stream", this::handleLogsStream);
server.createContext("/api/game-logs", this::handleGameLogs);
server.createContext("/api/game-logs/stream", this::handleGameLogsStream);
server.createContext("/api/mc-versions", this::handleMCVersions);
server.createContext("/api/loader-versions", this::handleLoaderVersions);
server.createContext("/api/packs", this::handlePacks);
server.createContext("/api/settings", this::handleSettings);
server.createContext("/api/activate-pass", this::handleActivatePass);
server.createContext("/api/register", this::handleRegister);
server.createContext("/api/shutdown", this::handleShutdown);
server.createContext("/api/exit", this::handleExit);
server.createContext("/api/exit-parent", this::handleExitParent);
server.createContext("/assets/", this::handleStatic);
server.setExecutor(Executors.newCachedThreadPool());
server.start();
log("HTTP server on port " + 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", "Method not supported"));
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("Login: " + 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", "Not authenticated"));
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 handleAutoLogin(HttpExchange exchange) {
try {
if (AuthManager.loadSavedSession()) {
Map<String, Object> data = new HashMap<>();
data.put("username", AuthManager.getUsername());
data.put("passActive", AuthManager.hasActivePass());
data.put("role", AuthManager.getRole());
data.put("roleName", AuthManager.getRoleName());
sendJson(exchange, Map.of("success", true, "data", data, "autoLogin", true));
log("Auto-login performed: " + AuthManager.getUsername());
} else {
sendJson(exchange, Map.of("success", false, "autoLogin", false));
}
} 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", "Not authenticated"));
return;
}
Map<String, String> body = parseJson(exchange.getRequestBody());
String name = body.get("name");
Instance instance = InstanceManager.getInstance(name);
if (instance == null) {
sendJson(exchange, Map.of("success", false, "error", "Pack not found"));
return;
}
if (instance.isServerPack() && !AuthManager.hasActivePass()) {
sendJson(exchange, Map.of("success", false, "error", "Server pack requires an active pass"));
return;
}
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("Launched: " + 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) {
if (installInProgress) {
sendJson(exchange, Map.of("success", false, "error", "Installation already in progress"));
return;
}
try {
if (!api.isLoggedIn()) {
sendJson(exchange, Map.of("success", false, "error", "Not authenticated"));
return;
}
Map<String, String> body = parseJson(exchange.getRequestBody());
String name = body.get("name");
String version = body.get("version");
String loader = body.get("loader");
String loaderVersion = body.get("loaderVersion");
log("Install: " + name + " " + version + " " + loader + (loaderVersion != null ? " " + loaderVersion : ""));
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);
if (loaderVersion != null) {
instance.setLoaderVersion(loaderVersion);
}
sendJson(exchange, Map.of("success", true, "message", "Installation started"));
setInstallInProgress(true);
setInstallProgress("Preparing...", 0, 100);
Thread installThread = new Thread(() -> {
try {
MinecraftLib lib = new MinecraftLib(instance);
boolean success = false;
if ("vanilla".equalsIgnoreCase(loader)) {
success = lib.installMinecraft(version);
} else {
success = lib.installPack(name, version, loader, loaderVersion != null ? loaderVersion : "");
}
setInstallInProgress(false);
if (success) {
log("Installed: " + name);
} else {
log("Install error: " + name);
}
} catch (Exception e) {
log("Install error: " + e.getMessage());
setInstallInProgress(false);
}
});
installThread.setDaemon(true);
installThread.start();
} else {
sendJson(exchange, Map.of("success", false, "error", "Instance not found"));
}
} catch (Exception e) {
log("Install error: " + e.getMessage());
setInstallInProgress(false);
sendJson(exchange, Map.of("success", false, "error", e.getMessage()));
}
}
private void handleInstallProgress(HttpExchange exchange) {
try {
Map<String, Object> progress = new java.util.HashMap<>();
progress.put("label", installProgressLabel);
progress.put("current", installProgressCurrent);
progress.put("total", installProgressTotal);
progress.put("inProgress", installInProgress);
if (installInProgress && installProgressTotal > 0) {
progress.put("percent", (int) ((installProgressCurrent * 100.0) / installProgressTotal));
} else {
progress.put("percent", installInProgress ? 0 : 100);
}
sendJson(exchange, Map.of("success", true, "data", progress));
} catch (Exception e) {
sendJson(exchange, Map.of("success", false, "error", e.getMessage()));
}
}
private void handleLogs(HttpExchange exchange) {
sendJson(exchange, Map.of("success", true, "data", getLauncherLogs()));
}
private void handleLogsStream(HttpExchange exchange) {
try {
exchange.getResponseHeaders().set("Content-Type", "text/event-stream");
exchange.getResponseHeaders().set("Cache-Control", "no-cache");
exchange.getResponseHeaders().set("Connection", "keep-alive");
exchange.sendResponseHeaders(200, 0);
final OutputStream os = exchange.getResponseBody();
int[] lastLength = {getLauncherLogs().length()};
LogConsumer consumer = new LogConsumer() {
@Override
public synchronized void onLog(String line) {
try {
String data = "data: " + line.replace("\n", "").replace("\r", "") + "\n\n";
os.write(data.getBytes(StandardCharsets.UTF_8));
os.flush();
} catch (Exception e) {
removeLogConsumer(this);
}
}
};
addLogConsumer(consumer);
while (!Thread.currentThread().isInterrupted()) {
Thread.sleep(10000);
}
} catch (Exception ignored) {} finally {
removeLogConsumer(consumer);
try { exchange.getResponseBody().close(); } catch (Exception ignored) {}
}
}
private LogConsumer consumer = null;
private void handleGameLogs(HttpExchange exchange) {
sendJson(exchange, Map.of("success", true, "data", getGameLogs()));
}
private void handleGameLogsStream(HttpExchange exchange) {
try {
exchange.getResponseHeaders().set("Content-Type", "text/event-stream");
exchange.getResponseHeaders().set("Cache-Control", "no-cache");
exchange.getResponseHeaders().set("Connection", "keep-alive");
exchange.sendResponseHeaders(200, 0);
final OutputStream os = exchange.getResponseBody();
consumer = new LogConsumer() {
@Override
public synchronized void onLog(String line) {
try {
String data = "data: " + line.replace("\n", "").replace("\r", "") + "\n\n";
os.write(data.getBytes(StandardCharsets.UTF_8));
os.flush();
} catch (Exception e) {
removeGameLogConsumer(this);
}
}
};
addGameLogConsumer(consumer);
while (!Thread.currentThread().isInterrupted()) {
Thread.sleep(10000);
}
} catch (Exception ignored) {} finally {
if (consumer != null) {
removeGameLogConsumer(consumer);
}
consumer = null;
try { exchange.getResponseBody().close(); } catch (Exception ignored) {}
}
}
private void handleMCVersions(HttpExchange exchange) {
try {
var versions = api.getMCVersions();
if (versions.isSuccess()) {
sendJson(exchange, Map.of("success", true, "data", versions.getData()));
} else {
sendJson(exchange, Map.of("success", false, "error", versions.getError()));
}
} catch (Exception e) {
sendJson(exchange, Map.of("success", false, "error", e.getMessage()));
}
}
private void handleLoaderVersions(HttpExchange exchange) {
try {
Map<String, String> params = parseQuery(exchange.getRequestURI().getQuery());
String mcVersion = params.get("mc");
String loader = params.get("loader");
var versions = api.getLoaderVersions(mcVersion, loader);
if (versions.isSuccess()) {
sendJson(exchange, Map.of("success", true, "data", versions.getData()));
} else {
sendJson(exchange, Map.of("success", false, "error", versions.getError()));
}
} catch (Exception e) {
sendJson(exchange, Map.of("success", false, "error", e.getMessage()));
}
}
private void handlePacks(HttpExchange exchange) {
try {
var result = api.getZernMCPacks();
if (result.isSuccess()) {
sendJson(exchange, Map.of("success", true, "data", result.getData()));
} else {
sendJson(exchange, Map.of("success", false, "error", result.getError()));
}
} catch (Exception e) {
sendJson(exchange, Map.of("success", false, "error", e.getMessage()));
}
}
private void handleActivatePass(HttpExchange exchange) {
try {
if (!api.isLoggedIn()) {
sendJson(exchange, Map.of("success", false, "error", "Not authenticated"));
return;
}
Map<String, String> body = parseJson(exchange.getRequestBody());
String code = body.get("code");
if (code == null || code.isEmpty()) {
sendJson(exchange, Map.of("success", false, "error", "Enter pass code"));
return;
}
var result = api.activatePass(code);
if (result.isSuccess()) {
sendJson(exchange, Map.of("success", true, "message", "Pass activated!"));
log("Pass activated");
} else {
sendJson(exchange, Map.of("success", false, "error", result.getError()));
}
} catch (Exception e) {
sendJson(exchange, Map.of("success", false, "error", e.getMessage()));
}
}
private void handleRegister(HttpExchange exchange) {
try {
Map<String, String> body = parseJson(exchange.getRequestBody());
String username = body.get("username");
String password = body.get("password");
if (username == null || password == null || username.isEmpty() || password.isEmpty()) {
sendJson(exchange, Map.of("success", false, "error", "Fill all fields"));
return;
}
var result = api.register(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("Registered: " + 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 handleSettings(HttpExchange exchange) {
try {
if ("POST".equals(exchange.getRequestMethod())) {
Map<String, String> body = parseJson(exchange.getRequestBody());
if (body.containsKey("maxMemory")) {
Config.setMaxMemory(Integer.parseInt(body.get("maxMemory")));
}
if (body.containsKey("windowWidth")) {
Config.setWindowWidth(Integer.parseInt(body.get("windowWidth")));
}
if (body.containsKey("windowHeight")) {
Config.setWindowHeight(Integer.parseInt(body.get("windowHeight")));
}
if (body.containsKey("extraJvmArgs")) {
Config.setExtraJvmArgs(body.get("extraJvmArgs"));
}
if (body.containsKey("javaPath")) {
Config.setJavaPath(body.get("javaPath"));
}
sendJson(exchange, Map.of("success", true));
return;
}
Map<String, Object> data = new HashMap<>();
data.put("maxMemory", Config.getMaxMemory());
data.put("serverUrl", Config.getServerUrl());
data.put("instancesDir", Config.getInstancesDir().toString());
data.put("windowWidth", Config.getWindowWidth());
data.put("windowHeight", Config.getWindowHeight());
data.put("extraJvmArgs", Config.getExtraJvmArgs());
data.put("javaPath", Config.getJavaPath());
sendJson(exchange, Map.of("success", true, "data", data));
} catch (Exception e) {
sendJson(exchange, Map.of("success", false, "error", e.getMessage()));
}
}
private Map<String, String> parseQuery(String query) {
Map<String, String> params = new HashMap<>();
if (query != null && !query.isEmpty()) {
for (String pair : query.split("&")) {
String[] kv = pair.split("=");
if (kv.length == 2) {
params.put(kv[0], kv[1]);
}
}
}
return params;
}
private void handleShutdown(HttpExchange exchange) {
log("Shutdown request received...");
LaunchService.killAllProcesses();
if (server != null) server.stop(0);
Platform.exit();
System.exit(0);
}
private void handleExit(HttpExchange exchange) {
log("Exiting...");
LaunchService.killAllProcesses();
if (mainStage != null) mainStage.close();
Platform.exit();
System.exit(0);
}
private void handleExitParent(HttpExchange exchange) {
log("Terminating parent process...");
LaunchService.killAllProcesses();
if (mainStage != null) mainStage.close();
Platform.exit();
System.exit(240);
}
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";
synchronized (launcherLogBuffer) {
launcherLogBuffer.append(entry);
}
System.out.println("[JFX] " + msg);
if (launcherLogFile != null) {
try {
Files.writeString(launcherLogFile, entry,
StandardOpenOption.CREATE, StandardOpenOption.APPEND);
} catch (Exception ignored) {}
}
for (LogConsumer consumer : logConsumers) {
try { consumer.onLog(entry); } catch (Exception ignored) {}
}
}
}
@@ -1,368 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<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">
<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;800;900&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
</head>
<body>
<canvas id="bg-canvas"></canvas>
<div id="app">
<!-- Login Screen -->
<div id="login-screen" class="screen">
<div class="login-container">
<div class="login-brand">
<div class="brand-icon">
<svg width="56" height="56" viewBox="0 0 56 56" fill="none">
<rect width="56" height="56" rx="14" fill="url(#brandGrad)"/>
<path d="M18 28 L28 18 L38 28 L28 38 Z" fill="white" opacity="0.9"/>
<defs>
<linearGradient id="brandGrad" x1="0" y1="0" x2="56" y2="56">
<stop offset="0%" stop-color="#e94560"/>
<stop offset="100%" stop-color="#ff6b6b"/>
</linearGradient>
</defs>
</svg>
</div>
<h1 class="brand-title">ZernMC</h1>
<p class="brand-sub">Launcher <span id="version">1.0.9</span></p>
</div>
<form id="login-form" class="login-form">
<div class="field">
<input type="text" id="username" placeholder="Username" autocomplete="username" required>
<label for="username">Username</label>
</div>
<div class="field">
<input type="password" id="password" placeholder="Password" autocomplete="current-password" required>
<label for="password">Password</label>
</div>
<p id="login-error" class="error-msg hidden"></p>
<button type="submit" class="btn-primary" id="login-btn">
<span class="btn-label">Sign In</span>
<div class="spinner hidden"></div>
</button>
<p class="login-hint">New account will be created automatically on first login</p>
</form>
</div>
</div>
<!-- Loading Overlay -->
<div id="loading-overlay" class="overlay hidden">
<div class="loader-ring"></div>
<p class="loader-text">Loading...</p>
</div>
<!-- Main Screen -->
<div id="main-screen" class="screen hidden">
<div class="shell">
<aside class="sidebar">
<div class="sidebar-top">
<div class="sidebar-brand">
<svg width="32" height="32" viewBox="0 0 56 56" fill="none">
<rect width="56" height="56" rx="14" fill="url(#brandGrad2)"/>
<path d="M18 28 L28 18 L38 28 L28 38 Z" fill="white" opacity="0.9"/>
<defs>
<linearGradient id="brandGrad2" x1="0" y1="0" x2="56" y2="56">
<stop offset="0%" stop-color="#e94560"/>
<stop offset="100%" stop-color="#ff6b6b"/>
</linearGradient>
</defs>
</svg>
<div class="sidebar-brand-text">
<span class="sidebar-brand-name">ZernMC</span>
<span class="sidebar-brand-ver">v<span id="header-version">1.0.9</span></span>
</div>
</div>
<nav class="sidebar-nav">
<button class="nav-btn active" data-view="packs" title="Packs">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/></svg>
Packs
</button>
<button class="nav-btn" data-view="news" title="News">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 22h16a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2H8a2 2 0 0 0-2 2v16a2 2 0 0 1-2 2Zm0 0a2 2 0 0 1-2-2v-9h4"/><path d="M18 14h-8"/><path d="M15 18h-5"/><path d="M10 6h8v4h-8V6Z"/></svg>
News
</button>
<button class="nav-btn" data-view="settings" title="Settings">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/></svg>
Settings
</button>
</nav>
<div class="sidebar-section">
<div class="section-header">
<span class="section-title">Server Packs</span>
</div>
<div id="server-packs-list" class="pack-list"></div>
</div>
<div class="sidebar-section" id="local-packs-section">
<div class="section-header">
<span class="section-title">Local Packs</span>
<button class="btn-icon" id="add-pack-btn" title="Add pack">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
</button>
</div>
<div id="local-packs-list" class="pack-list"></div>
</div>
</div>
<div class="sidebar-bottom">
<div class="user-card">
<div class="user-avatar" id="user-avatar">Z</div>
<div class="user-info">
<span class="user-name" id="username-display">Player</span>
<span class="user-badges">
<span id="account-status" class="badge badge-free">FREE</span>
<span id="account-role" class="badge badge-role hidden"></span>
</span>
</div>
<button class="btn-icon" id="logout-btn" title="Log out">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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>
</div>
</aside>
<main class="content">
<!-- Packs View -->
<div id="view-packs" class="view active">
<div class="view-header">
<div>
<h2 class="view-title" id="selected-pack-title">Select a pack</h2>
<p class="view-subtitle" id="selected-pack-meta">Choose a pack from the sidebar to get started</p>
</div>
<div class="view-actions">
<button id="update-btn" class="btn-secondary hidden">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17.65 6.35A7.958 7.958 0 0012 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08A5.99 5.99 0 0112 18c-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z"/></svg>
Update
</button>
<button id="delete-pack-btn" class="btn-secondary btn-danger hidden">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
Delete
</button>
</div>
</div>
<div class="pack-detail" id="pack-detail">
<div class="pack-empty" id="pack-empty-state">
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" opacity="0.2"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/></svg>
<h3>No pack selected</h3>
<p>Select a pack from the sidebar or add a new one</p>
</div>
<div id="pack-detail-content" class="pack-detail-content hidden">
<div class="pack-hero">
<div class="pack-icon">
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/></svg>
</div>
<div>
<h3 id="detail-name" class="detail-name">pack</h3>
<div class="detail-tags">
<span class="tag tag-mc" id="detail-mc">1.21</span>
<span class="tag tag-loader" id="detail-loader">fabric</span>
<span class="tag tag-server hidden" id="detail-server">v1</span>
</div>
</div>
</div>
<div class="pack-stats">
<div class="stat"><span class="stat-value" id="detail-loader-ver">-</span><span class="stat-label">Loader Ver</span></div>
<div class="stat"><span class="stat-value" id="detail-files">0</span><span class="stat-label">Files</span></div>
<div class="stat"><span class="stat-value" id="detail-size">-</span><span class="stat-label">Size</span></div>
</div>
<div id="pack-description" class="pack-description">
<p id="pack-description-text" class="pack-description-text">Loading description...</p>
<div id="pack-gallery" class="pack-gallery">
</div>
</div>
</div>
</div>
<div class="play-bar" id="play-bar">
<div class="play-bar-info">
<span id="play-bar-name">Select a pack</span>
</div>
<button id="play-btn" class="btn-play" disabled>
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"><polygon points="5 3 19 12 5 21 5 3"/></svg>
Play
</button>
</div>
</div>
<!-- News View -->
<div id="view-news" class="view">
<div class="view-header">
<h2 class="view-title">News</h2>
</div>
<div class="news-grid">
<article class="news-card news-placeholder">
<div class="news-card-badge">Coming Soon</div>
<h3>ZernMC Server Updates</h3>
<p>News and announcements will appear here. Stay tuned for the latest updates about the server and launcher.</p>
<time>Soon</time>
</article>
<article class="news-card news-placeholder">
<div class="news-card-badge">Info</div>
<h3>Launcher v1.0.9</h3>
<p>English UI, JavaFX redesign, improved pack management, and more. Check the GitHub for the full changelog.</p>
<time>v1.0.9</time>
</article>
<article class="news-card news-placeholder">
<div class="news-card-badge">Guide</div>
<h3>Getting Started</h3>
<p>Install a pack, activate your pass via the website, and start playing. Need help? Contact a moderator.</p>
<time>Guide</time>
</article>
</div>
</div>
<!-- Settings View -->
<div id="view-settings" class="view">
<div class="view-header">
<h2 class="view-title">Settings</h2>
</div>
<div class="settings-grid">
<div class="setting-card">
<div class="setting-info">
<h4>Activate Pass</h4>
<p>Enter your pass code to access server packs</p>
</div>
<div class="setting-control setting-pass">
<input type="text" id="pass-code" placeholder="Pass code" class="pass-input">
<button id="activate-pass-btn" class="btn-primary btn-sm">Activate</button>
</div>
</div>
<div class="setting-card">
<div class="setting-info">
<h4>Allocated RAM</h4>
<p id="ram-info">Loading...</p>
</div>
<div class="setting-control">
<input type="range" id="ram-slider" min="1024" max="16384" step="512" value="4096">
<span class="setting-value" id="ram-value">4 GB</span>
</div>
</div>
<div class="setting-card">
<div class="setting-info">
<h4>Game Resolution</h4>
<p>Width x Height</p>
</div>
<div class="setting-control" style="gap:6px">
<input type="number" id="win-width" min="640" max="7680" step="1" value="1280" class="setting-input" style="width:80px">
<span style="color:var(--text-muted)">x</span>
<input type="number" id="win-height" min="480" max="4320" step="1" value="720" class="setting-input" style="width:80px">
</div>
</div>
<div class="setting-card">
<div class="setting-info">
<h4>Extra JVM Arguments</h4>
<p>Additional Java VM options</p>
</div>
<div class="setting-control">
<input type="text" id="jvm-args" placeholder="-XX:+UseZGC" class="setting-input" style="width:280px">
</div>
</div>
<div class="setting-card">
<div class="setting-info">
<h4>Java Path</h4>
<p id="java-path">~/.zernmc/jre/</p>
</div>
<div class="setting-control">
<input type="text" id="java-path-input" placeholder="java" class="setting-input" style="width:280px">
</div>
</div>
<div class="setting-card">
<div class="setting-info">
<h4>Server</h4>
<p id="server-url">http://87.120.187.36:1582</p>
</div>
<div class="setting-control">
<span class="setting-badge" id="server-status">Checking...</span>
</div>
</div>
</div>
</div>
</main>
</div>
</div>
<!-- Install Modal -->
<div id="install-modal" class="modal-backdrop hidden">
<div class="modal">
<div class="modal-head">
<h3>Install Pack</h3>
<button class="modal-close" id="close-modal-btn">&times;</button>
</div>
<div class="modal-body">
<div class="modal-tabs">
<button class="modal-tab active" data-tab="zernmc">Server Pack</button>
<button class="modal-tab" data-tab="custom">Custom</button>
</div>
<div id="tab-zernmc" class="modal-tab-content active">
<div class="field">
<label>Server Pack</label>
<select id="zernmc-pack-select">
<option value="">Loading...</option>
</select>
</div>
<div class="field">
<label>Local Name</label>
<input type="text" id="zernmc-instance-name" placeholder="my-cool-pack">
</div>
<button id="install-zernmc-btn" class="btn-primary">Download & Install</button>
</div>
<div id="tab-custom" class="modal-tab-content">
<div class="field">
<label>Minecraft Version</label>
<div class="select-wrap">
<select id="mc-version-select"><option>Loading...</option></select>
</div>
</div>
<div class="field">
<label>Mod Loader</label>
<div class="select-wrap">
<select id="loader-select">
<option value="vanilla">Vanilla (no loader)</option>
<option value="fabric">Fabric</option>
<option value="forge">Forge</option>
<option value="neoforge">NeoForge</option>
</select>
</div>
</div>
<div class="field hidden" id="loader-ver-field">
<label>Loader Version</label>
<div class="select-wrap">
<select id="loader-ver-select"><option>Select loader version</option></select>
</div>
</div>
<div class="field">
<label>Local Name</label>
<input type="text" id="custom-instance-name" placeholder="my-minecraft">
</div>
<button id="install-custom-btn" class="btn-primary">Download & Install</button>
</div>
<div id="install-progress" class="install-progress hidden">
<div class="progress-track">
<div class="progress-fill" id="progress-fill"></div>
</div>
<p class="progress-label" id="progress-label">Installing...</p>
</div>
</div>
</div>
</div>
<!-- Notification Toast -->
<div id="toast" class="toast hidden"></div>
</div>
<script src="launcher.js"></script>
</body>
</html>
@@ -1,568 +0,0 @@
const API = '/api';
class ZernMCLauncher {
constructor() {
this.state = { account: null, instances: [], selectedPack: null, installing: false, serverPacks: [] };
this.toastTimer = null;
this.progressPoller = null;
this.init();
}
async init() {
this.bindEvents();
this.initBg();
await this.checkAuth();
}
// ==================== BACKGROUND ====================
initBg() {
const c = document.getElementById('bg-canvas');
const ctx = c.getContext('2d');
let mx = 0, my = 0, ox = 0, oy = 0;
const resize = () => { c.width = window.innerWidth; c.height = window.innerHeight; draw(); };
const draw = () => {
ctx.clearRect(0, 0, c.width, c.height);
const gs = 48, r = 1.2;
ctx.fillStyle = '#e94560';
for (let x = 0; x <= c.width; x += gs)
for (let y = 0; y <= c.height; y += gs)
ctx.beginPath(), ctx.arc(x + ox * 8, y + oy * 8, r, 0, Math.PI * 2), ctx.fill();
};
window.addEventListener('resize', resize);
window.addEventListener('mousemove', e => {
mx = (e.clientX / innerWidth - 0.5) * 2;
my = (e.clientY / innerHeight - 0.5) * 2;
});
const anim = () => {
ox += (mx * 0.3 - ox) * 0.04;
oy += (my * 0.3 - oy) * 0.04;
draw();
requestAnimationFrame(anim);
};
resize();
anim();
}
// ==================== API ====================
async req(endpoint, opts = {}) {
try {
const r = await fetch(`${API}${endpoint}`, {
...opts,
headers: { 'Content-Type': 'application/json', ...opts.headers }
});
return await r.json();
} catch (e) {
return { success: false, error: e.message };
}
}
// ==================== AUTH ====================
async checkAuth() {
this.showLoading(true);
const auto = await this.req('/auto-login');
if (auto.success && auto.autoLogin) {
this.state.account = auto.data;
this.enterMain();
this.toast(`Welcome back, ${auto.data.username}`, 'success');
} else {
const acct = await this.req('/account');
if (acct.success) {
this.state.account = acct.data;
this.enterMain();
} else {
this.showLogin();
}
}
this.showLoading(false);
}
async handleLogin(e) {
e.preventDefault();
const username = document.getElementById('username').value.trim();
const password = document.getElementById('password').value;
const errEl = document.getElementById('login-error');
const btn = document.getElementById('login-btn');
const label = btn.querySelector('.btn-label');
const spinner = btn.querySelector('.spinner');
if (!username || !password) { this.showLoginError('Enter username and password'); return; }
btn.disabled = true;
label.textContent = 'Signing in...';
spinner.classList.remove('hidden');
const r = await this.req('/login', { method: 'POST', body: JSON.stringify({ username, password }) });
btn.disabled = false;
label.textContent = 'Sign In';
spinner.classList.add('hidden');
if (r.success) {
this.state.account = r.data;
this.enterMain();
this.toast(`Welcome, ${r.data.username}!`, 'success');
} else {
// If login fails, try register (auto-create account)
if (r.error && (r.error.includes('not found') || r.error.includes('Invalid'))) {
const reg = await this.req('/register', { method: 'POST', body: JSON.stringify({ username, password }) });
if (reg.success) {
this.state.account = reg.data;
this.enterMain();
this.toast(`Account created! Welcome, ${reg.data.username}!`, 'success');
return;
}
}
this.showLoginError(r.error || 'Login failed');
}
}
showLoginError(msg) {
const el = document.getElementById('login-error');
el.textContent = msg;
el.classList.remove('hidden');
}
showLogin() {
document.getElementById('login-screen').classList.remove('hidden');
document.getElementById('main-screen').classList.add('hidden');
}
async logout() {
this.state.selectedPack = null;
this.state.instances = [];
this.state.account = null;
this.toast('Logged out');
document.getElementById('login-screen').classList.remove('hidden');
document.getElementById('main-screen').classList.add('hidden');
}
enterMain() {
document.getElementById('login-screen').classList.add('hidden');
document.getElementById('main-screen').classList.remove('hidden');
const a = this.state.account;
const avatar = document.getElementById('user-avatar');
avatar.textContent = (a.username || 'Z')[0].toUpperCase();
document.getElementById('username-display').textContent = a.username;
const status = document.getElementById('account-status');
if (a.passActive) {
status.textContent = 'PRO';
status.className = 'badge badge-pro';
} else {
status.textContent = 'FREE';
status.className = 'badge badge-free';
}
const role = document.getElementById('account-role');
if (a.roleName) {
role.textContent = a.roleName;
role.classList.remove('hidden');
} else {
role.classList.add('hidden');
}
document.getElementById('header-version').textContent = document.getElementById('version').textContent;
this.switchView('packs');
this.loadInstances();
this.loadSettings();
this.loadServerPacksList();
}
async loadServerPacksList() {
const r = await this.req('/packs');
if (r.success && r.data) {
this.state.serverPacks = r.data;
}
}
// ==================== NAV ====================
bindEvents() {
document.getElementById('login-form').addEventListener('submit', e => this.handleLogin(e));
document.getElementById('logout-btn').addEventListener('click', () => this.logout());
document.querySelectorAll('.nav-btn').forEach(btn => {
btn.addEventListener('click', () => this.switchView(btn.dataset.view));
});
document.getElementById('add-pack-btn').addEventListener('click', () => this.showInstallModal());
document.getElementById('close-modal-btn').addEventListener('click', () => this.hideInstallModal());
document.querySelectorAll('.modal-tab').forEach(t => {
t.addEventListener('click', () => this.switchInstallTab(t.dataset.tab));
});
document.getElementById('loader-select').addEventListener('change', e => this.onLoaderChange(e.target.value));
document.getElementById('install-zernmc-btn').addEventListener('click', () => this.installZernMCPack());
document.getElementById('install-custom-btn').addEventListener('click', () => this.installCustom());
document.getElementById('play-btn').addEventListener('click', () => this.launchPack());
document.getElementById('ram-slider').addEventListener('input', e => {
document.getElementById('ram-value').textContent = (e.target.value / 1024).toFixed(1) + ' GB';
});
document.getElementById('ram-slider').addEventListener('change', e => this.saveSettings());
document.getElementById('activate-pass-btn').addEventListener('click', () => this.activatePass());
let saveTimer;
const debouncedSave = () => { clearTimeout(saveTimer); saveTimer = setTimeout(() => this.saveSettings(), 500); };
document.getElementById('win-width').addEventListener('change', debouncedSave);
document.getElementById('win-height').addEventListener('change', debouncedSave);
document.getElementById('jvm-args').addEventListener('change', debouncedSave);
document.getElementById('java-path-input').addEventListener('change', debouncedSave);
document.querySelectorAll('.modal-backdrop').forEach(m => {
m.addEventListener('click', e => { if (e.target === m) this.hideInstallModal(); });
});
}
switchView(view) {
document.querySelectorAll('.nav-btn').forEach(b => b.classList.toggle('active', b.dataset.view === view));
document.querySelectorAll('.view').forEach(v => v.classList.toggle('active', v.id === 'view-' + view));
}
// ==================== INSTANCES ====================
async loadInstances() {
const r = await this.req('/instances');
if (r.success && r.data) {
this.state.instances = r.data;
this.renderSidebar();
}
}
renderSidebar() {
const serverList = document.getElementById('server-packs-list');
serverList.innerHTML = '';
if (!this.state.instances || this.state.instances.length === 0) return;
this.state.instances.forEach(inst => {
const isZern = inst.isServerPack || inst.category === 'zernmc';
if (!isZern) return;
const el = document.createElement('div');
el.className = 'pack-entry' + (this.state.selectedPack && this.state.selectedPack.name === inst.name ? ' selected' : '');
el.innerHTML = `
<div class="pack-entry-icon server">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/></svg>
</div>
<div class="pack-entry-info">
<div class="pack-entry-name">${this.esc(inst.name)}</div>
<div class="pack-entry-meta">${inst.version || '?'}${inst.loaderType && inst.loaderType !== 'vanilla' ? ' \u00b7 ' + inst.loaderType : ''}</div>
</div>
`;
el.addEventListener('click', () => this.selectPack(inst));
serverList.appendChild(el);
});
}
selectPack(inst) {
this.state.selectedPack = inst;
this.renderSidebar();
this.showPackDetail(inst);
}
showPackDetail(inst) {
document.getElementById('pack-empty-state').classList.add('hidden');
const detail = document.getElementById('pack-detail-content');
detail.classList.remove('hidden');
document.getElementById('detail-name').textContent = inst.name;
document.getElementById('detail-mc').textContent = inst.version || '?';
const loader = document.getElementById('detail-loader');
if (inst.loaderType && inst.loaderType !== 'vanilla') {
loader.textContent = inst.loaderType;
loader.classList.remove('hidden');
} else {
loader.classList.add('hidden');
}
const serverTag = document.getElementById('detail-server');
if (inst.isServerPack && inst.serverVersion) {
serverTag.textContent = 'v' + inst.serverVersion;
serverTag.classList.remove('hidden');
} else {
serverTag.classList.add('hidden');
}
document.getElementById('detail-loader-ver').textContent = inst.loaderVersion || '-';
document.getElementById('detail-files').textContent = inst.filesCount || '0';
document.getElementById('selected-pack-title').textContent = inst.name;
document.getElementById('selected-pack-meta').textContent =
(inst.version || '?') + (inst.loaderType && inst.loaderType !== 'vanilla' ? ' \u00b7 ' + inst.loaderType : '');
const playBar = document.getElementById('play-bar-name');
playBar.textContent = inst.name;
const playBtn = document.getElementById('play-btn');
if (inst.isServerPack && !this.state.account.passActive) {
playBtn.disabled = true;
playBtn.innerHTML = '<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"><polygon points="5 3 19 12 5 21 5 3"/></svg> Pass Required';
} else {
playBtn.disabled = false;
playBtn.innerHTML = '<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"><polygon points="5 3 19 12 5 21 5 3"/></svg> Play';
}
// Load pack description from API
const descEl = document.getElementById('pack-description-text');
const galleryEl = document.getElementById('pack-gallery');
galleryEl.innerHTML = '';
if (inst.isServerPack && inst.serverPackName) {
descEl.textContent = 'Loading description...';
this.loadPackDescription(inst.serverPackName, descEl, galleryEl);
} else {
descEl.textContent = '';
}
}
async loadPackDescription(packName, descEl, galleryEl) {
const packs = await this.req('/packs');
if (packs.success && packs.data) {
const pack = packs.data.find(p => p.name === packName);
if (pack && pack.description) {
descEl.textContent = pack.description;
} else {
descEl.textContent = 'No description available';
}
} else {
descEl.textContent = 'Failed to load description';
}
}
// ==================== LAUNCH ====================
async launchPack() {
const inst = this.state.selectedPack;
if (!inst) return;
this.toast(`Launching ${inst.name}...`, 'info');
const r = await this.req('/launch', { method: 'POST', body: JSON.stringify({ name: inst.name }) });
if (r.success) {
this.toast(`Launched! PID: ${r.data?.pid || ''}`, 'success');
} else {
this.toast(r.error || 'Launch failed', 'error');
}
}
// ==================== INSTALL ====================
async showInstallModal() {
document.getElementById('install-modal').classList.remove('hidden');
document.getElementById('zernmc-pack-select').innerHTML = '<option>Loading...</option>';
document.getElementById('mc-version-select').innerHTML = '<option>Loading...</option>';
const packs = await this.req('/packs');
const zernmcSel = document.getElementById('zernmc-pack-select');
if (packs.success && packs.data && packs.data.length > 0) {
if (this.state.account && this.state.account.passActive) {
zernmcSel.innerHTML = '<option value="">Select a pack</option>';
zernmcSel.disabled = false;
packs.data.forEach(p => {
const o = document.createElement('option');
o.value = p.name;
o.textContent = (p.displayName || p.name) + ' (' + (p.version || '') + ')';
zernmcSel.appendChild(o);
});
} else {
zernmcSel.innerHTML = '<option value="">Pass required</option>';
zernmcSel.disabled = true;
}
} else {
zernmcSel.innerHTML = '<option value="">No packs available</option>';
zernmcSel.disabled = true;
}
if (this.state.account && !this.state.account.passActive) {
document.querySelector('[data-tab="zernmc"]').style.opacity = '0.5';
} else {
document.querySelector('[data-tab="zernmc"]').style.opacity = '1';
}
const mc = await this.req('/mc-versions');
const mcSel = document.getElementById('mc-version-select');
if (mc.success && mc.data) {
mcSel.innerHTML = '<option value="">Select version</option>';
mc.data.forEach(v => {
const o = document.createElement('option');
o.value = v; o.textContent = v;
mcSel.appendChild(o);
});
} else {
mcSel.innerHTML = '<option value="">Failed to load</option>';
}
}
hideInstallModal() {
document.getElementById('install-modal').classList.add('hidden');
document.getElementById('install-progress').classList.add('hidden');
this.stopProgressPoll();
}
switchInstallTab(tab) {
document.querySelectorAll('.modal-tab').forEach(t => t.classList.toggle('active', t.dataset.tab === tab));
document.querySelectorAll('.modal-tab-content').forEach(c => c.classList.toggle('active', c.id === 'tab-' + tab));
}
async onLoaderChange(loader) {
const f = document.getElementById('loader-ver-field');
if (loader === 'vanilla') { f.classList.add('hidden'); return; }
f.classList.remove('hidden');
const sel = document.getElementById('loader-ver-select');
sel.innerHTML = '<option>Loading...</option>';
const mc = document.getElementById('mc-version-select').value;
if (!mc) { sel.innerHTML = '<option>Select MC version first</option>'; return; }
const r = await this.req(`/loader-versions?mc=${mc}&loader=${loader}`);
if (r.success && r.data) {
sel.innerHTML = '<option value="">Select version</option>';
r.data.forEach(v => {
const o = document.createElement('option');
o.value = v; o.textContent = v;
sel.appendChild(o);
});
} else {
sel.innerHTML = '<option>Failed to load</option>';
}
}
async installZernMCPack() {
const packName = document.getElementById('zernmc-pack-select').value;
const instanceName = document.getElementById('zernmc-instance-name').value.trim();
if (!packName) { this.toast('Select a pack', 'error'); return; }
if (!instanceName) { this.toast('Enter a name', 'error'); return; }
const r = await this.req('/install', {
method: 'POST',
body: JSON.stringify({ name: instanceName, version: 'latest', loader: 'zernmc' })
});
if (r.success) {
this.toast('Installing...', 'info');
this.startProgressPoll();
} else {
this.toast(r.error || 'Install failed', 'error');
}
}
async installCustom() {
const mc = document.getElementById('mc-version-select').value;
const loader = document.getElementById('loader-select').value;
const loaderVer = document.getElementById('loader-ver-select').value;
const name = document.getElementById('custom-instance-name').value.trim();
if (!mc) { this.toast('Select MC version', 'error'); return; }
if (!name) { this.toast('Enter a name', 'error'); return; }
const r = await this.req('/install', {
method: 'POST',
body: JSON.stringify({ name, version: mc, loader, loaderVersion: loaderVer })
});
if (r.success) {
this.toast('Installing...', 'info');
this.startProgressPoll();
} else {
this.toast(r.error || 'Install failed', 'error');
}
}
startProgressPoll() {
this.progressPoller = setInterval(async () => {
const r = await this.req('/install/progress');
if (r.success && r.data) {
const p = document.getElementById('install-progress');
p.classList.remove('hidden');
document.getElementById('progress-fill').style.width = (r.data.percent || 0) + '%';
document.getElementById('progress-label').textContent = r.data.label || 'Installing...';
if (!r.data.inProgress) {
this.stopProgressPoll();
this.hideInstallModal();
this.toast('Installation complete!', 'success');
await this.loadInstances();
}
}
}, 500);
}
stopProgressPoll() {
if (this.progressPoller) {
clearInterval(this.progressPoller);
this.progressPoller = null;
}
}
// ==================== SETTINGS ====================
async loadSettings() {
const r = await this.req('/settings');
if (r.success && r.data) {
const ram = r.data.maxMemory || 4096;
document.getElementById('ram-slider').value = ram;
document.getElementById('ram-value').textContent = (ram / 1024).toFixed(1) + ' GB';
document.getElementById('ram-info').textContent = ram + ' MB allocated';
document.getElementById('server-url').textContent = r.data.serverUrl || 'http://87.120.187.36:1582';
if (r.data.windowWidth) {
document.getElementById('win-width').value = r.data.windowWidth;
}
if (r.data.windowHeight) {
document.getElementById('win-height').value = r.data.windowHeight;
}
if (r.data.extraJvmArgs !== undefined) {
document.getElementById('jvm-args').value = r.data.extraJvmArgs || '';
}
if (r.data.javaPath) {
document.getElementById('java-path-input').value = r.data.javaPath;
}
} else {
document.getElementById('ram-value').textContent = '4 GB';
document.getElementById('ram-slider').value = '4096';
}
const sr = await this.req('/instances');
if (sr.success) {
document.getElementById('server-status').textContent = 'Connected';
document.getElementById('server-status').style.color = 'var(--success)';
} else {
document.getElementById('server-status').textContent = 'Disconnected';
document.getElementById('server-status').style.color = 'var(--error)';
}
}
async saveSettings() {
const ram = document.getElementById('ram-slider').value;
const w = parseInt(document.getElementById('win-width').value) || 1280;
const h = parseInt(document.getElementById('win-height').value) || 720;
const jvm = document.getElementById('jvm-args').value.trim();
const jp = document.getElementById('java-path-input').value.trim();
await this.req('/settings', { method: 'POST', body: JSON.stringify({ maxMemory: ram, windowWidth: w, windowHeight: h, extraJvmArgs: jvm, javaPath: jp }) });
}
async activatePass() {
const code = document.getElementById('pass-code').value.trim();
if (!code) { this.toast('Enter a pass code', 'error'); return; }
const r = await this.req('/activate-pass', { method: 'POST', body: JSON.stringify({ code }) });
if (r.success) {
this.toast('Pass activated!', 'success');
document.getElementById('pass-code').value = '';
// Refresh account info
const acct = await this.req('/account');
if (acct.success) {
this.state.account = acct.data;
const status = document.getElementById('account-status');
status.textContent = acct.data.passActive ? 'PRO' : 'FREE';
status.className = 'badge ' + (acct.data.passActive ? 'badge-pro' : 'badge-free');
}
} else {
this.toast(r.error || 'Activation failed', 'error');
}
}
// ==================== TOAST ====================
toast(msg, type = 'info') {
const el = document.getElementById('toast');
el.textContent = msg;
el.className = 'toast ' + type;
el.classList.remove('hidden');
clearTimeout(this.toastTimer);
this.toastTimer = setTimeout(() => el.classList.add('hidden'), 3000);
}
// ==================== LOADING ====================
showLoading(show) {
document.getElementById('loading-overlay').classList.toggle('hidden', !show);
}
esc(s) {
if (!s) return '';
const d = document.createElement('div');
d.textContent = s;
return d.innerHTML;
}
}
const app = new ZernMCLauncher();
@@ -1,531 +0,0 @@
:root {
--bg-deep: #07070a;
--bg-surface: #0c0c12;
--bg-elevated: #111118;
--bg-card: #16161f;
--bg-card-hover: #1c1c28;
--bg-inset: #0a0a0f;
--accent: #e94560;
--accent-glow: rgba(233, 69, 96, 0.25);
--accent-soft: rgba(233, 69, 96, 0.1);
--text: #eeeef0;
--text-secondary: #88889a;
--text-muted: #555566;
--border: #1e1e2a;
--border-light: #2a2a3a;
--success: #4ade80;
--error: #f87171;
--warning: #fbbf24;
--info: #60a5fa;
--radius-sm: 6px;
--radius-md: 10px;
--radius-lg: 14px;
--shadow: 0 4px 24px rgba(0,0,0,0.5);
--shadow-glow: 0 0 40px var(--accent-glow);
--transition: 200ms ease;
--font: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
--mono: 'JetBrains Mono', 'Consolas', monospace;
}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html { font-size: 14px; }
body {
font-family: var(--font);
background: var(--bg-deep);
color: var(--text);
min-height: 100vh;
overflow: hidden;
-webkit-font-smoothing: antialiased;
}
#bg-canvas {
position: fixed; inset: 0; width: 100%; height: 100%;
z-index: 0; opacity: 0.08; pointer-events: none;
}
#app { position: relative; z-index: 1; height: 100vh; display: flex; }
.screen {
position: absolute; inset: 0;
display: flex; align-items: center; justify-content: center;
transition: opacity 0.4s ease, transform 0.4s ease;
}
.screen.hidden { opacity: 0; transform: scale(0.97); pointer-events: none; }
.hidden { display: none !important; }
/* ========== LOGIN ========== */
.login-container {
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 48px 40px 40px;
width: 100%;
max-width: 380px;
box-shadow: var(--shadow);
animation: floatIn 0.5s ease forwards;
}
@keyframes floatIn {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
.login-brand { text-align: center; margin-bottom: 36px; }
.brand-icon { margin-bottom: 16px; }
.brand-title {
font-size: 28px; font-weight: 800;
background: linear-gradient(135deg, #fff 60%, var(--accent));
-webkit-background-clip: text; -webkit-text-fill-color: transparent;
background-clip: text;
}
.brand-sub { color: var(--text-muted); font-size: 13px; margin-top: 4px; }
.login-form { display: flex; flex-direction: column; gap: 20px; }
.field { position: relative; }
.field label {
position: absolute; top: 50%; left: 14px; transform: translateY(-50%);
font-size: 13px; color: var(--text-muted);
transition: var(--transition); pointer-events: none;
background: var(--bg-elevated); padding: 0 4px;
}
.field input:focus + label,
.field input:not(:placeholder-shown) + label {
top: 0; font-size: 11px; color: var(--accent);
}
.field input {
width: 100%; padding: 14px 14px; font-size: 14px;
background: var(--bg-surface); border: 1px solid var(--border-light);
border-radius: var(--radius-sm); color: var(--text);
font-family: var(--font); transition: var(--transition);
outline: none;
}
.field input:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-glow);
}
.field select {
width: 100%; padding: 12px 14px; font-size: 14px;
background: var(--bg-surface); border: 1px solid var(--border-light);
border-radius: var(--radius-sm); color: var(--text);
font-family: var(--font); cursor: pointer; outline: none;
}
.field select:focus { border-color: var(--accent); }
.btn-primary {
width: 100%; padding: 14px; border: none; border-radius: var(--radius-sm);
background: linear-gradient(135deg, var(--accent), #ff6b6b);
color: #fff; font-size: 15px; font-weight: 600; cursor: pointer;
font-family: var(--font); transition: var(--transition);
display: flex; align-items: center; justify-content: center; gap: 8px;
min-height: 48px; position: relative;
}
.btn-primary:hover { transform: translateY(-1px); box-shadow: var(--shadow-glow); }
.btn-primary:active { transform: translateY(0); }
.btn-primary:disabled { opacity: 0.6; cursor: not-allowed; transform: none; box-shadow: none; }
.error-msg {
color: var(--error); font-size: 13px; text-align: center;
padding: 10px; background: rgba(248,113,113,0.1);
border-radius: var(--radius-sm); animation: shake 0.4s ease;
}
@keyframes shake {
0%,100%{transform:translateX(0)}20%{transform:translateX(-4px)}40%{transform:translateX(4px)}60%{transform:translateX(-3px)}80%{transform:translateX(3px)}
}
.login-hint { text-align: center; font-size: 12px; color: var(--text-muted); margin-top: 4px; }
.spinner {
position: absolute; width: 20px; height: 20px;
border: 2px solid rgba(255,255,255,0.3); border-top-color: #fff;
border-radius: 50%; animation: spin 0.7s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* ========== OVERLAY ========== */
.overlay {
position: fixed; inset: 0; background: rgba(7,7,10,0.92);
display: flex; flex-direction: column; align-items: center; justify-content: center;
z-index: 100; animation: fadeIn 0.3s ease;
}
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
.loader-ring {
width: 48px; height: 48px;
border: 3px solid var(--border-light); border-top-color: var(--accent);
border-radius: 50%; animation: spin 0.8s linear infinite; margin-bottom: 16px;
}
.loader-text { color: var(--text-secondary); font-size: 14px; }
/* ========== MAIN SHELL ========== */
.shell {
display: flex; width: 100%; height: 100vh;
background: var(--bg-surface);
}
/* ========== SIDEBAR ========== */
.sidebar {
width: 260px; min-width: 260px;
background: var(--bg-deep);
border-right: 1px solid var(--border);
display: flex; flex-direction: column;
padding: 16px 12px;
}
.sidebar-top { flex: 1; display: flex; flex-direction: column; gap: 20px; overflow: hidden; }
.sidebar-brand {
display: flex; align-items: center; gap: 10px;
padding: 4px 8px 16px; border-bottom: 1px solid var(--border);
}
.sidebar-brand-text { display: flex; flex-direction: column; }
.sidebar-brand-name { font-size: 16px; font-weight: 700; }
.sidebar-brand-ver { font-size: 11px; color: var(--text-muted); }
.sidebar-nav {
display: flex; gap: 4px;
padding-bottom: 16px; border-bottom: 1px solid var(--border);
}
.nav-btn {
flex: 1; display: flex; align-items: center; justify-content: center; gap: 6px;
padding: 8px; background: transparent; border: 1px solid transparent;
border-radius: var(--radius-sm); color: var(--text-muted); font-size: 11px;
font-weight: 500; cursor: pointer; font-family: var(--font);
transition: var(--transition);
}
.nav-btn:hover { color: var(--text-secondary); background: var(--bg-card); }
.nav-btn.active { color: var(--accent); background: var(--accent-soft); border-color: rgba(233,69,96,0.2); }
.section-header {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 8px; padding: 0 4px;
}
.section-title { font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 1px; color: var(--text-muted); }
.pack-list {
display: flex; flex-direction: column; gap: 3px;
overflow-y: auto; max-height: calc((100vh - 460px) / 2);
min-height: 40px;
}
.pack-list:empty::after {
content: 'No packs'; display: block; padding: 12px 8px;
font-size: 12px; color: var(--text-muted); text-align: center;
}
.pack-entry {
display: flex; align-items: center; gap: 10px;
padding: 8px 10px; border-radius: var(--radius-sm);
cursor: pointer; transition: var(--transition);
border: 1px solid transparent;
}
.pack-entry:hover { background: var(--bg-card); }
.pack-entry.selected { background: var(--accent-soft); border-color: rgba(233,69,96,0.25); }
.pack-entry-icon {
width: 32px; height: 32px; border-radius: 6px;
display: flex; align-items: center; justify-content: center;
flex-shrink: 0;
}
.pack-entry-icon.server { background: rgba(251,191,36,0.15); color: var(--warning); }
.pack-entry-icon.local { background: var(--accent-soft); color: var(--accent); }
.pack-entry-info { flex: 1; min-width: 0; }
.pack-entry-name { font-size: 13px; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.pack-entry-meta { font-size: 11px; color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.btn-icon {
width: 32px; height: 32px; display: flex; align-items: center; justify-content: center;
background: transparent; border: 1px solid transparent; border-radius: var(--radius-sm);
color: var(--text-muted); cursor: pointer; transition: var(--transition); flex-shrink: 0;
}
.btn-icon:hover { color: var(--text); background: var(--bg-card); border-color: var(--border-light); }
/* Sidebar bottom */
.sidebar-bottom { padding-top: 12px; border-top: 1px solid var(--border); }
.user-card {
display: flex; align-items: center; gap: 10px;
padding: 8px; border-radius: var(--radius-sm);
}
.user-avatar {
width: 32px; height: 32px; border-radius: 8px;
background: linear-gradient(135deg, var(--accent), #ff6b6b);
display: flex; align-items: center; justify-content: center;
font-weight: 700; font-size: 14px; color: #fff; flex-shrink: 0;
}
.user-info { flex: 1; min-width: 0; }
.user-name { font-size: 13px; font-weight: 500; display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.user-badges { display: flex; gap: 4px; margin-top: 2px; }
.badge {
font-size: 10px; font-weight: 600; padding: 2px 6px; border-radius: 3px;
text-transform: uppercase; letter-spacing: 0.5px;
}
.badge-pro { background: rgba(74,222,128,0.15); color: var(--success); }
.badge-free { background: rgba(248,113,113,0.12); color: var(--error); }
.badge-role { background: rgba(96,165,250,0.15); color: var(--info); }
/* ========== CONTENT ========== */
.content {
flex: 1; display: flex; flex-direction: column;
padding: 24px 32px; min-width: 0;
position: relative;
}
.view { display: none; flex-direction: column; height: 100%; }
.view.active { display: flex; }
.view-header {
display: flex; align-items: flex-start; justify-content: space-between;
margin-bottom: 24px; gap: 16px;
}
.view-title { font-size: 22px; font-weight: 700; }
.view-subtitle { font-size: 13px; color: var(--text-secondary); margin-top: 4px; }
.view-actions { display: flex; gap: 8px; flex-shrink: 0; }
.btn-secondary {
display: flex; align-items: center; gap: 6px;
padding: 8px 16px; background: var(--bg-card); border: 1px solid var(--border-light);
border-radius: var(--radius-sm); color: var(--text-secondary); font-size: 13px;
font-weight: 500; cursor: pointer; font-family: var(--font);
transition: var(--transition);
}
.btn-secondary:hover { background: var(--bg-card-hover); color: var(--text); border-color: var(--border); }
.btn-secondary.btn-danger:hover { color: var(--error); border-color: rgba(248,113,113,0.3); background: rgba(248,113,113,0.08); }
/* ========== PACK DETAIL ========== */
.pack-detail { flex: 1; display: flex; }
.pack-empty {
flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center;
gap: 12px; color: var(--text-muted);
}
.pack-empty h3 { font-size: 18px; font-weight: 600; color: var(--text-secondary); }
.pack-empty p { font-size: 13px; }
.pack-detail-content { flex: 1; display: flex; flex-direction: column; gap: 24px; }
.pack-hero { display: flex; align-items: center; gap: 16px; }
.pack-icon {
width: 56px; height: 56px; border-radius: var(--radius-md);
background: var(--bg-card); border: 1px solid var(--border-light);
display: flex; align-items: center; justify-content: center; color: var(--accent);
}
.detail-name { font-size: 20px; font-weight: 700; }
.detail-tags { display: flex; gap: 6px; margin-top: 6px; }
.tag {
font-size: 11px; font-weight: 600; padding: 3px 8px; border-radius: 4px;
}
.tag-mc { background: var(--bg-card); color: var(--text-secondary); }
.tag-loader { background: rgba(99,102,241,0.15); color: #818cf8; }
.tag-server { background: rgba(251,191,36,0.15); color: var(--warning); }
.pack-stats {
display: flex; gap: 24px; padding: 16px;
background: var(--bg-card); border-radius: var(--radius-md);
border: 1px solid var(--border);
}
.stat { display: flex; flex-direction: column; gap: 2px; }
.stat-value { font-size: 18px; font-weight: 700; color: var(--text); }
.stat-label { font-size: 11px; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.5px; }
/* ========== PLAY BAR ========== */
.play-bar {
display: flex; align-items: center; justify-content: space-between;
padding: 16px 20px; margin-top: auto;
background: var(--bg-card); border: 1px solid var(--border);
border-radius: var(--radius-md);
}
.play-bar-info { font-size: 14px; font-weight: 500; color: var(--text-secondary); }
/* ========== PACK DESCRIPTION ========== */
.pack-description {
padding: 16px; background: var(--bg-card);
border: 1px solid var(--border); border-radius: var(--radius-md);
}
.pack-description-text {
font-size: 13px; color: var(--text-secondary); line-height: 1.6;
}
.pack-gallery {
display: flex; gap: 12px; margin-top: 12px; flex-wrap: wrap;
}
.pack-gallery-item {
width: 120px; height: 80px; border-radius: var(--radius-sm);
background: var(--bg-elevated); border: 1px solid var(--border-light);
display: flex; align-items: center; justify-content: center;
color: var(--text-muted); font-size: 11px;
overflow: hidden;
}
.pack-gallery-item img {
width: 100%; height: 100%; object-fit: cover;
}
.btn-play {
display: flex; align-items: center; gap: 8px;
padding: 12px 28px; border: none; border-radius: var(--radius-sm);
background: linear-gradient(135deg, var(--success), #22c55e);
color: #07070a; font-size: 15px; font-weight: 700; cursor: pointer;
font-family: var(--font); transition: var(--transition);
box-shadow: 0 4px 20px rgba(74,222,128,0.35);
}
.btn-play:hover:not(:disabled) { transform: translateY(-2px) scale(1.02); box-shadow: 0 8px 32px rgba(74,222,128,0.45); }
.btn-play:active:not(:disabled) { transform: translateY(0); }
.btn-play:disabled { opacity: 0.4; cursor: not-allowed; transform: none; box-shadow: none; }
/* ========== NEWS ========== */
.news-grid {
display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 16px; overflow-y: auto; padding-bottom: 24px;
}
.news-card {
background: var(--bg-card); border: 1px solid var(--border);
border-radius: var(--radius-md); padding: 24px; display: flex;
flex-direction: column; gap: 12px; transition: var(--transition);
}
.news-card:hover { border-color: var(--border-light); }
.news-card-badge {
align-self: flex-start; font-size: 10px; font-weight: 600; text-transform: uppercase;
letter-spacing: 1px; padding: 4px 10px; border-radius: 4px;
}
.news-placeholder .news-card-badge { background: var(--accent-soft); color: var(--accent); }
.news-card h3 { font-size: 16px; font-weight: 600; }
.news-card p { font-size: 13px; color: var(--text-secondary); line-height: 1.5; }
.news-card time { font-size: 11px; color: var(--text-muted); margin-top: auto; }
/* ========== SETTINGS ========== */
.settings-grid { display: flex; flex-direction: column; gap: 12px; }
.setting-card {
display: flex; align-items: center; justify-content: space-between;
padding: 16px 20px; background: var(--bg-card); border: 1px solid var(--border);
border-radius: var(--radius-md); gap: 24px;
}
.setting-info h4 { font-size: 14px; font-weight: 600; }
.setting-info p { font-size: 12px; color: var(--text-secondary); margin-top: 2px; }
.setting-control { display: flex; align-items: center; gap: 12px; flex-shrink: 0; }
.setting-control input[type="range"] {
width: 160px; height: 4px; -webkit-appearance: none; appearance: none;
background: var(--border); border-radius: 2px; outline: none;
}
.setting-control input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none; width: 16px; height: 16px; border-radius: 50%;
background: var(--accent); cursor: pointer; border: 2px solid var(--bg-deep);
}
.setting-value { font-size: 14px; font-weight: 600; color: var(--text); min-width: 48px; text-align: right; }
.setting-badge {
font-size: 12px; padding: 4px 10px; border-radius: 4px;
background: var(--bg-surface); color: var(--text-secondary); border: 1px solid var(--border-light);
}
.setting-pass { display: flex; align-items: center; gap: 8px; }
.pass-input {
width: 160px; padding: 6px 12px; border-radius: var(--radius-sm);
background: var(--bg-inset); border: 1px solid var(--border-light);
color: var(--text); font-size: 13px; outline: none;
}
.pass-input:focus { border-color: var(--accent); }
.setting-input {
padding: 6px 10px; border-radius: var(--radius-sm);
background: var(--bg-inset); border: 1px solid var(--border-light);
color: var(--text); font-size: 13px; outline: none; font-family: var(--mono);
}
.setting-input:focus { border-color: var(--accent); }
.btn-sm { padding: 6px 14px !important; font-size: 12px !important; }
/* ========== MODAL ========== */
.modal-backdrop {
position: fixed; inset: 0; background: rgba(7,7,10,0.85);
display: flex; align-items: center; justify-content: center; z-index: 50;
animation: fadeIn 0.2s ease;
}
.modal {
background: var(--bg-elevated); border: 1px solid var(--border);
border-radius: var(--radius-lg); width: 90%; max-width: 480px;
max-height: 85vh; overflow-y: auto; box-shadow: var(--shadow);
animation: floatIn 0.3s ease;
}
.modal-head {
display: flex; align-items: center; justify-content: space-between;
padding: 20px 24px; border-bottom: 1px solid var(--border);
}
.modal-head h3 { font-size: 17px; font-weight: 600; }
.modal-close {
width: 32px; height: 32px; display: flex; align-items: center; justify-content: center;
background: transparent; border: none; color: var(--text-muted);
font-size: 22px; cursor: pointer; border-radius: var(--radius-sm); transition: var(--transition);
}
.modal-close:hover { color: var(--text); background: var(--bg-card); }
.modal-body { padding: 20px 24px 24px; }
.modal-tabs { display: flex; gap: 8px; margin-bottom: 20px; }
.modal-tab {
flex: 1; padding: 10px; background: transparent; border: 1px solid var(--border-light);
border-radius: var(--radius-sm); color: var(--text-muted); font-size: 13px;
font-weight: 500; cursor: pointer; font-family: var(--font); transition: var(--transition);
}
.modal-tab.active { background: var(--accent-soft); border-color: rgba(233,69,96,0.3); color: var(--accent); }
.modal-tab:hover:not(.active) { background: var(--bg-card); color: var(--text-secondary); }
.modal-tab-content { display: none; flex-direction: column; gap: 16px; }
.modal-tab-content.active { display: flex; }
.modal-tab-content .field label {
display: block; font-size: 12px; font-weight: 500; color: var(--text-secondary);
margin-bottom: 6px; position: static; transform: none;
background: none; padding: 0;
}
.select-wrap select {
width: 100%; padding: 10px 12px; font-size: 13px;
background: var(--bg-surface); border: 1px solid var(--border-light);
border-radius: var(--radius-sm); color: var(--text);
font-family: var(--font); cursor: pointer; outline: none;
}
.select-wrap select:focus { border-color: var(--accent); }
.install-progress { padding-top: 16px; border-top: 1px solid var(--border); }
.progress-track {
height: 6px; background: var(--bg-surface); border-radius: 3px; overflow: hidden;
}
.progress-fill {
height: 100%; width: 0%;
background: linear-gradient(90deg, var(--accent), #ff6b6b);
border-radius: 3px; transition: width 0.3s ease;
}
.progress-label { font-size: 13px; color: var(--text-secondary); margin-top: 8px; text-align: center; }
/* ========== TOAST ========== */
.toast {
position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%);
padding: 12px 24px; border-radius: var(--radius-sm);
font-size: 13px; font-weight: 500; z-index: 200;
background: var(--bg-elevated); border: 1px solid var(--border);
color: var(--text); box-shadow: var(--shadow);
animation: toastIn 0.3s ease, toastOut 0.3s ease 2.7s forwards;
}
.toast.error { border-color: rgba(248,113,113,0.3); color: var(--error); }
.toast.success { border-color: rgba(74,222,128,0.3); color: var(--success); }
@keyframes toastIn { from { opacity: 0; transform: translateX(-50%) translateY(10px); } to { opacity: 1; transform: translateX(-50%) translateY(0); } }
@keyframes toastOut { from { opacity: 1; } to { opacity: 0; } }
/* ========== SCROLLBAR ========== */
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--border-light); border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: var(--text-muted); }
/* ========== RESPONSIVE ========== */
@media (max-width: 900px) {
.sidebar { width: 200px; min-width: 200px; }
.content { padding: 16px; }
}
@media (max-width: 700px) {
.sidebar { width: 56px; min-width: 56px; }
.sidebar-brand-text, .sidebar-nav .nav-btn span,
.section-header, .pack-entry-info, .user-info,
.sidebar-bottom .user-card .btn-icon:first-child { display: none; }
.sidebar-brand { justify-content: center; padding: 8px; }
.sidebar-nav { flex-direction: column; }
.nav-btn { padding: 8px; }
.pack-entry { justify-content: center; padding: 8px; }
.content { padding: 12px; }
.play-bar { flex-direction: column; gap: 12px; }
.view-header { flex-direction: column; }
}
+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();
}
}
}
}
@@ -0,0 +1,81 @@
package me.sashegdev.zernmc.launcher.api;
import me.sashegdev.zernmc.launcher.api.auth.AuthService;
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 java.util.List;
/**
* Центральный фасад для внутреннего API лаунчера.
* Используется как единая точка входа для UI и других компонентов.
*/
public class LauncherAPI {
private final AuthService authService;
private final InstanceService instanceService;
private final LaunchService launchService;
private final InstallService installService;
public LauncherAPI() {
this.authService = new AuthService();
this.instanceService = new InstanceService();
this.launchService = new LaunchService();
this.installService = new InstallService();
}
public AuthService auth() {
return authService;
}
public InstanceService instances() {
return instanceService;
}
public LaunchService launch() {
return launchService;
}
public InstallService install() {
return installService;
}
// ====================== Удобные методы ======================
public boolean isLoggedIn() {
return authService.isLoggedIn();
}
public String getCurrentUsername() {
return authService.getCurrentUsername();
}
public ApiResponse<AuthService.SessionInfo> checkSession() {
return authService.checkSession();
}
public ApiResponse<AuthService.LoginResult> login(String username, String password) {
return authService.login(username, password);
}
public ApiResponse<Boolean> logout() {
return authService.logout();
}
public ApiResponse<List<InstanceService.InstanceInfo>> getAllInstances() {
return instanceService.getAllInstances();
}
public ApiResponse<LaunchService.InstanceInfo> getLaunchInfo(String instanceName) {
return launchService.getLaunchInfo(instanceName);
}
public ApiResponse<LaunchService.LaunchInfo> prepareLaunch(String instanceName) {
return launchService.prepareLaunch(instanceName);
}
public ApiResponse<LaunchService.ProcessInfo> launch(String instanceName) {
return launchService.launch(instanceName);
}
}
@@ -8,27 +8,6 @@ import java.io.IOException;
public class AuthService { public class AuthService {
public ApiResponse<LoginResult> register(String username, String password) {
try {
String response = post("/auth/register",
"{\"username\":\"" + username + "\",\"password\":\"" + password + "\"}");
// If registration succeeds, auto-login
AuthManager.AuthResult result = AuthManager.login(username, password);
if (result.success) {
LoginResult loginResult = new LoginResult(AuthManager.getUsername(), AuthManager.getAccessToken());
return ApiResponse.success(loginResult);
}
return ApiResponse.error(result.error != null ? result.error : "Registration failed");
} catch (Exception e) {
String msg = e.getMessage();
if (msg != null && msg.contains("HTTP 409")) {
return ApiResponse.error("Username already taken");
}
return ApiResponse.error("Registration error: " + msg);
}
}
public ApiResponse<LoginResult> login(String username, String password) { public ApiResponse<LoginResult> login(String username, String password) {
try { try {
AuthManager.AuthResult result = AuthManager.login(username, password); AuthManager.AuthResult result = AuthManager.login(username, password);
@@ -36,9 +15,9 @@ public class AuthService {
LoginResult loginResult = new LoginResult(AuthManager.getUsername(), AuthManager.getAccessToken()); LoginResult loginResult = new LoginResult(AuthManager.getUsername(), AuthManager.getAccessToken());
return ApiResponse.success(loginResult); return ApiResponse.success(loginResult);
} }
return ApiResponse.error(result.error != null ? result.error : "Invalid login or password"); return ApiResponse.error(result.error != null ? result.error : "Неверный логин или пароль");
} catch (Exception e) { } catch (Exception e) {
return ApiResponse.error("Auth error: " + e.getMessage()); return ApiResponse.error("Ошибка авторизации: " + e.getMessage());
} }
} }
@@ -47,7 +26,7 @@ public class AuthService {
AuthManager.logout(); AuthManager.logout();
return ApiResponse.success(true); return ApiResponse.success(true);
} catch (Exception e) { } catch (Exception e) {
return ApiResponse.error("Logout error: " + e.getMessage()); return ApiResponse.error("Ошибка при выходе: " + e.getMessage());
} }
} }
@@ -58,15 +37,13 @@ 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);
} }
return ApiResponse.error("Session not found"); return ApiResponse.error("Сессия не найдена");
} catch (Exception e) { } catch (Exception e) {
return ApiResponse.error("Session check error: " + e.getMessage()); return ApiResponse.error("Ошибка проверки сессии: " + e.getMessage());
} }
} }
@@ -76,7 +53,7 @@ public class AuthService {
"{\"code\":\"" + passCode + "\"}"); "{\"code\":\"" + passCode + "\"}");
return ApiResponse.success(true); return ApiResponse.success(true);
} catch (Exception e) { } catch (Exception e) {
return ApiResponse.error("Pass activation error: " + e.getMessage()); return ApiResponse.error("Ошибка активации проходки: " + e.getMessage());
} }
} }
@@ -126,10 +103,6 @@ public class AuthService {
return AuthManager.getUsername(); return AuthManager.getUsername();
} }
public String getCurrentToken() {
return AuthManager.getAccessToken();
}
public static class LoginResult { public static class LoginResult {
private String username; private String username;
private String token; private String token;
@@ -147,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 + "м";
}
}
}
@@ -18,7 +18,7 @@ public class InstanceService {
.collect(Collectors.toList()); .collect(Collectors.toList());
return ApiResponse.success(infoList); return ApiResponse.success(infoList);
} catch (IOException e) { } catch (IOException e) {
return ApiResponse.error("Error getting instances list: " + e.getMessage()); return ApiResponse.error("Ошибка получения списка сборок: " + e.getMessage());
} }
} }
@@ -26,11 +26,11 @@ public class InstanceService {
try { try {
Instance instance = InstanceManager.getInstance(name); Instance instance = InstanceManager.getInstance(name);
if (instance == null) { if (instance == null) {
return ApiResponse.error("Pack not found: " + name); return ApiResponse.error("Сборка не найдена: " + name);
} }
return ApiResponse.success(toInstanceInfo(instance)); return ApiResponse.success(toInstanceInfo(instance));
} catch (Exception e) { } catch (Exception e) {
return ApiResponse.error("Error getting pack: " + e.getMessage()); return ApiResponse.error("Ошибка получения сборки: " + e.getMessage());
} }
} }
@@ -38,12 +38,12 @@ public class InstanceService {
try { try {
boolean created = InstanceManager.createInstanceFolder(name); boolean created = InstanceManager.createInstanceFolder(name);
if (!created) { if (!created) {
return ApiResponse.error("A pack with this name already exists: " + name); return ApiResponse.error("Сборка с таким именем уже существует: " + name);
} }
Instance instance = InstanceManager.getInstance(name); Instance instance = InstanceManager.getInstance(name);
return ApiResponse.success(toInstanceInfo(instance)); return ApiResponse.success(toInstanceInfo(instance));
} catch (IOException e) { } catch (IOException e) {
return ApiResponse.error("Error creating pack: " + e.getMessage()); return ApiResponse.error("Ошибка создания сборки: " + e.getMessage());
} }
} }
@@ -51,11 +51,11 @@ public class InstanceService {
try { try {
boolean deleted = InstanceManager.deleteInstance(name); boolean deleted = InstanceManager.deleteInstance(name);
if (!deleted) { if (!deleted) {
return ApiResponse.error("Failed to delete pack: " + name); return ApiResponse.error("Не удалось удалить сборку: " + name);
} }
return ApiResponse.success(true); return ApiResponse.success(true);
} catch (Exception e) { } catch (Exception e) {
return ApiResponse.error("Error deleting pack: " + e.getMessage()); return ApiResponse.error("Ошибка удаления сборки: " + e.getMessage());
} }
} }
@@ -64,24 +64,16 @@ public class InstanceService {
Instance instance = InstanceManager.getInstance(name); Instance instance = InstanceManager.getInstance(name);
return ApiResponse.success(instance != null); return ApiResponse.success(instance != null);
} catch (Exception e) { } catch (Exception e) {
return ApiResponse.error("Error checking pack: " + e.getMessage()); return ApiResponse.error("Ошибка проверки сборки: " + e.getMessage());
} }
} }
private InstanceInfo toInstanceInfo(Instance instance) { private InstanceInfo toInstanceInfo(Instance instance) {
String name = instance.getName().toLowerCase();
String category = instance.isServerPack() ? "zernmc" : "local";
return new InstanceInfo( return new InstanceInfo(
instance.getName(), instance.getName(),
instance.getPath().toString(), instance.getPath().toString(),
instance.getMinecraftVersion(), instance.getMinecraftVersion(),
instance.getLoaderType(), instance.getLoaderType()
category,
instance.isServerPack(),
instance.getServerVersion(),
instance.getLoaderVersion(),
instance.getServerPackName()
); );
} }
@@ -90,33 +82,17 @@ public class InstanceService {
private String path; private String path;
private String version; private String version;
private String loaderType; private String loaderType;
private String category;
private boolean isServerPack;
private int serverVersion;
private String loaderVersion;
private String serverPackName;
public InstanceInfo(String name, String path, String version, String loaderType, String category, public InstanceInfo(String name, String path, String version, String loaderType) {
boolean isServerPack, int serverVersion, String loaderVersion, String serverPackName) {
this.name = name; this.name = name;
this.path = path; this.path = path;
this.version = version; this.version = version;
this.loaderType = loaderType; this.loaderType = loaderType;
this.category = category;
this.isServerPack = isServerPack;
this.serverVersion = serverVersion;
this.loaderVersion = loaderVersion;
this.serverPackName = serverPackName;
} }
public String getName() { return name; } public String getName() { return name; }
public String getPath() { return path; } public String getPath() { return path; }
public String getVersion() { return version; } public String getVersion() { return version; }
public String getLoaderType() { return loaderType; } public String getLoaderType() { return loaderType; }
public String getCategory() { return category; }
public boolean isServerPack() { return isServerPack; }
public int getServerVersion() { return serverVersion; }
public String getLoaderVersion() { return loaderVersion; }
public String getServerPackName() { return serverPackName; }
} }
} }
@@ -1,46 +1,26 @@
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 me.sashegdev.zernmc.launcher.utils.Config;
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.ArrayList;
import java.util.List; import java.util.List;
import java.util.concurrent.ConcurrentHashMap;
public class LaunchService { public class LaunchService {
private static final ConcurrentHashMap<Long, Process> runningProcesses = new ConcurrentHashMap<>();
static {
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
System.out.println("[LAUNCH] Shutting down all running processes...");
runningProcesses.values().forEach(p -> {
try {
p.destroy();
} catch (Exception ignored) {}
});
}));
}
public ApiResponse<LaunchInfo> prepareLaunch(String instanceName) { public ApiResponse<LaunchInfo> prepareLaunch(String instanceName) {
try { try {
Instance instance = InstanceManager.getInstance(instanceName); Instance instance = InstanceManager.getInstance(instanceName);
if (instance == null) { if (instance == null) {
return ApiResponse.error("Pack not found: " + instanceName); return ApiResponse.error("Сборка не найдена: " + instanceName);
} }
LaunchCommandBuilder builder = new LaunchCommandBuilder(instance); LaunchCommandBuilder builder = new LaunchCommandBuilder(instance);
LaunchOptions options = createOptions(); LaunchOptions options = new LaunchOptions();
List<String> command = builder.build(options); List<String> command = builder.build(options);
@@ -51,7 +31,7 @@ public class LaunchService {
); );
return ApiResponse.success(info); return ApiResponse.success(info);
} catch (Exception e) { } catch (Exception e) {
return ApiResponse.error("Error preparing launch: " + e.getMessage()); return ApiResponse.error("Ошибка подготовки запуска: " + e.getMessage());
} }
} }
@@ -59,81 +39,36 @@ public class LaunchService {
try { try {
Instance instance = InstanceManager.getInstance(instanceName); Instance instance = InstanceManager.getInstance(instanceName);
if (instance == null) { if (instance == null) {
return ApiResponse.error("Pack not found: " + instanceName); return ApiResponse.error("Сборка не найдена: " + instanceName);
} }
LaunchCommandBuilder builder = new LaunchCommandBuilder(instance); LaunchCommandBuilder builder = new LaunchCommandBuilder(instance);
LaunchOptions options = createOptions(); LaunchOptions options = new LaunchOptions();
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();
Path logsDir = instance.getPath().resolve("logs");
java.nio.file.Files.createDirectories(logsDir);
Path gameLog = logsDir.resolve("game.log");
Process process = processBuilder.start(); Process process = processBuilder.start();
long pid = process.pid();
runningProcesses.put(pid, process); ProcessInfo info = new ProcessInfo(
System.out.println("[LAUNCH] Process started, pid=" + pid); instanceName,
process.pid(),
java.io.FileOutputStream logFileOut = new java.io.FileOutputStream(gameLog.toFile(), true); "RUNNING"
);
Thread logReader = new Thread(() -> {
try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) {
String line;
while ((line = reader.readLine()) != null) {
String timestamped = "[" + java.time.LocalTime.now().format(java.time.format.DateTimeFormatter.ofPattern("HH:mm:ss")) + "] " + line;
JFXLauncher.appendGameLog(line);
try {
logFileOut.write((timestamped + "\n").getBytes(java.nio.charset.StandardCharsets.UTF_8));
logFileOut.flush();
} catch (Exception ignored) {}
}
} catch (Exception e) {
JFXLauncher.appendGameLog("[Error reading logs: " + e.getMessage() + "]");
} finally {
try { logFileOut.close(); } catch (Exception ignored) {}
}
}, "GameLogReader-" + instanceName);
logReader.setDaemon(true);
logReader.start();
process.onExit().thenRun(() -> {
runningProcesses.remove(pid);
JFXLauncher.appendGameLog("[Minecraft exited with code: " + process.exitValue() + "]");
});
ProcessInfo info = new ProcessInfo(instanceName, pid, "RUNNING");
return ApiResponse.success(info); return ApiResponse.success(info);
} catch (Exception e) { } catch (Exception e) {
return ApiResponse.error("Launch error: " + e.getMessage()); return ApiResponse.error("Ошибка запуска: " + e.getMessage());
} }
} }
public static void killAllProcesses() {
runningProcesses.values().forEach(p -> {
try {
p.destroyForcibly();
} catch (Exception ignored) {}
});
runningProcesses.clear();
}
public ApiResponse<Boolean> isReady(String instanceName) { public ApiResponse<Boolean> isReady(String instanceName) {
try { try {
Instance instance = InstanceManager.getInstance(instanceName); Instance instance = InstanceManager.getInstance(instanceName);
if (instance == null) { if (instance == null) {
return ApiResponse.error("Pack not found: " + instanceName); return ApiResponse.error("Сборка не найдена: " + instanceName);
} }
Path versionJson = instance.getPath().resolve("version.json"); Path versionJson = instance.getPath().resolve("version.json");
@@ -141,7 +76,7 @@ public class LaunchService {
return ApiResponse.success(hasVersionJson); return ApiResponse.success(hasVersionJson);
} catch (Exception e) { } catch (Exception e) {
return ApiResponse.error("Readiness check error: " + e.getMessage()); return ApiResponse.error("Ошибка проверки готовности: " + e.getMessage());
} }
} }
@@ -149,7 +84,7 @@ public class LaunchService {
try { try {
Instance instance = InstanceManager.getInstance(instanceName); Instance instance = InstanceManager.getInstance(instanceName);
if (instance == null) { if (instance == null) {
return ApiResponse.error("Pack not found: " + instanceName); return ApiResponse.error("Сборка не найдена: " + instanceName);
} }
InstanceInfo info = new InstanceInfo( InstanceInfo info = new InstanceInfo(
@@ -161,28 +96,10 @@ public class LaunchService {
); );
return ApiResponse.success(info); return ApiResponse.success(info);
} catch (Exception e) { } catch (Exception e) {
return ApiResponse.error("Info retrieval error: " + e.getMessage()); return ApiResponse.error("Ошибка получения информации: " + e.getMessage());
} }
} }
private static LaunchOptions createOptions() {
LaunchOptions options = new LaunchOptions();
options.setMaxMemory(Config.getMaxMemory());
options.setWidth(Config.getWindowWidth());
options.setHeight(Config.getWindowHeight());
options.setJavaPath(Config.getJavaPath());
String args = Config.getExtraJvmArgs();
if (args != null && !args.isEmpty()) {
List<String> extraArgs = new ArrayList<>();
for (String arg : args.split("\\s+")) {
arg = arg.trim();
if (!arg.isEmpty()) extraArgs.add(arg);
}
options.setExtraJvmArgs(extraArgs);
}
return options;
}
public static class LaunchInfo { public static class LaunchInfo {
private String instanceName; private String instanceName;
private List<String> command; private List<String> command;
@@ -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;
@@ -26,12 +25,14 @@ public class AuthManager {
private static volatile AuthSession session = null; private static volatile AuthSession session = null;
private static volatile UserInfo userInfo = null; private static volatile UserInfo userInfo = null;
// === Роли ===
public static final int ROLE_USER = 0; public static final int ROLE_USER = 0;
public static final int ROLE_PASS_HOLDER = 1; public static final int ROLE_PASS_HOLDER = 1;
public static final int ROLE_MODERATOR = 2; public static final int ROLE_MODERATOR = 2;
public static final int ROLE_ELDER = 3; public static final int ROLE_ELDER = 3;
public static final int ROLE_CREATOR = 4; public static final int ROLE_CREATOR = 4;
// === Права доступа ===
public static final String PERM_VIEW_PACKS = "view_packs"; public static final String PERM_VIEW_PACKS = "view_packs";
public static final String PERM_DOWNLOAD_PACK = "download_pack"; public static final String PERM_DOWNLOAD_PACK = "download_pack";
@@ -54,6 +55,7 @@ public class AuthManager {
} }
} }
// ====================== АВТОРИЗАЦИЯ ======================
public static AuthResult login(String username, String password) { public static AuthResult login(String username, String password) {
return authRequest("/auth/login", username, password); return authRequest("/auth/login", username, password);
} }
@@ -74,13 +76,13 @@ public class AuthManager {
userInfo = fetchUserInfo(); userInfo = fetchUserInfo();
return AuthResult.ok(); return AuthResult.ok();
} else if (resp.statusCode() == 422) { } else if (resp.statusCode() == 422) {
return AuthResult.fail("Validation error: " + extractError(resp.body())); return AuthResult.fail("Ошибка валидации: " + extractError(resp.body()));
} else { } else {
return AuthResult.fail(extractError(resp.body())); return AuthResult.fail(extractError(resp.body()));
} }
} catch (Exception e) { } catch (Exception e) {
e.printStackTrace(); e.printStackTrace();
return AuthResult.fail("Connection error: " + e.getMessage()); return AuthResult.fail("Ошибка соединения: " + e.getMessage());
} }
} }
@@ -146,14 +148,16 @@ public class AuthManager {
Files.createDirectories(AUTH_FILE.getParent()); Files.createDirectories(AUTH_FILE.getParent());
Files.writeString(AUTH_FILE, GSON.toJson(session)); Files.writeString(AUTH_FILE, GSON.toJson(session));
} catch (IOException e) { } catch (IOException e) {
System.err.println(ZAnsi.yellow("Failed to save session: " + e.getMessage())); System.err.println(ZAnsi.yellow("Не удалось сохранить сессию: " + e.getMessage()));
} }
} }
// ==================== ПОЛУЧЕНИЕ ИНФОРМАЦИИ О ПОЛЬЗОВАТЕЛЕ ====================
private static UserInfo fetchUserInfo() { private static UserInfo fetchUserInfo() {
if (!isLoggedIn() || session.accessToken == null) return null; if (!isLoggedIn() || session.accessToken == null) return null;
try { try {
// Используем существующий метод ZHttpClient.get() + вручную добавляем токен
java.net.HttpURLConnection conn = null; java.net.HttpURLConnection conn = null;
try { try {
URL url = new URL(ZHttpClient.getBaseUrl() + "/admin/me"); URL url = new URL(ZHttpClient.getBaseUrl() + "/admin/me");
@@ -180,11 +184,12 @@ public class AuthManager {
if (conn != null) conn.disconnect(); if (conn != null) conn.disconnect();
} }
} catch (Exception e) { } catch (Exception e) {
System.err.println("Failed to get UserInfo: " + e.getMessage()); System.err.println("Не удалось получить UserInfo: " + e.getMessage());
return null; return null;
} }
} }
// ==================== ПРОВЕРКИ ПРАВ ====================
public static boolean hasPass() { public static boolean hasPass() {
if (userInfo != null) return userInfo.has_pass; if (userInfo != null) return userInfo.has_pass;
return getRole() >= ROLE_PASS_HOLDER; return getRole() >= ROLE_PASS_HOLDER;
@@ -194,27 +199,21 @@ public class AuthManager {
if (userInfo != null && userInfo.permissions != null) { if (userInfo != null && userInfo.permissions != null) {
return userInfo.permissions.contains(PERM_VIEW_PACKS); return userInfo.permissions.contains(PERM_VIEW_PACKS);
} }
return hasPass(); return hasPass(); // fallback для старых аккаунтов
} }
public static boolean canDownloadPacks() { public static boolean canDownloadPacks() {
if (userInfo != null && userInfo.permissions != null) { if (userInfo != null && userInfo.permissions != null) {
return userInfo.permissions.contains(PERM_DOWNLOAD_PACK); return userInfo.permissions.contains(PERM_DOWNLOAD_PACK);
} }
return hasPass(); return hasPass(); // fallback
} }
public static int getRole() { public static int getRole() {
return session != null ? session.role : ROLE_USER; return session != null ? session.role : ROLE_USER;
} }
public static String getRoleName() { // ====================== POST ======================
if (userInfo != null && userInfo.role_name != null) {
return userInfo.role_name;
}
return "USER";
}
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;
HttpURLConnection conn = null; HttpURLConnection conn = null;
@@ -246,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);
@@ -284,23 +279,24 @@ public class AuthManager {
JsonObject json = JsonParser.parseString(response).getAsJsonObject(); JsonObject json = JsonParser.parseString(response).getAsJsonObject();
return json.has("has_active") && json.get("has_active").getAsBoolean(); return json.has("has_active") && json.get("has_active").getAsBoolean();
} catch (Exception e) { } catch (Exception e) {
System.err.println(ZAnsi.red("Failed to check pass: ") + e.getMessage()); System.err.println(ZAnsi.red("Не удалось проверить проходки: ") + e.getMessage());
return false; return false;
} }
} }
public static String getPassStatus() { public static String getPassStatus() {
if (!isLoggedIn()) return "Not logged in"; if (!isLoggedIn()) return "Не авторизован";
try { try {
String response = ZHttpClient.get("/auth/pass/my"); String response = ZHttpClient.get("/auth/pass/my");
JsonObject json = JsonParser.parseString(response).getAsJsonObject(); JsonObject json = JsonParser.parseString(response).getAsJsonObject();
boolean hasActive = json.has("has_active") && json.get("has_active").getAsBoolean(); boolean hasActive = json.has("has_active") && json.get("has_active").getAsBoolean();
return hasActive ? "Active pass" : "No pass"; return hasActive ? "Есть активная проходка" : "Проходка отсутствует";
} catch (Exception e) { } catch (Exception e) {
return "Check error"; return "Ошибка проверки";
} }
} }
// ====================== ВНУТРЕННИЕ КЛАССЫ ======================
public static class AuthSession { public static class AuthSession {
@SerializedName("access_token") public String accessToken; @SerializedName("access_token") public String accessToken;
@SerializedName("refresh_token") public String refreshToken; @SerializedName("refresh_token") public String refreshToken;
@@ -343,6 +339,7 @@ public class AuthManager {
} }
} }
// ====================== ВСПОМОГАТЕЛЬНЫЙ КЛАСС ======================
class SimpleHttpResponse { class SimpleHttpResponse {
final int statusCode; final int statusCode;
final String body; final String body;
@@ -33,11 +33,12 @@ public class LaunchMenu {
} }
} }
// ====================== ZERNMC BUILD ======================
private void showZernMCOnly() throws Exception { private void showZernMCOnly() throws Exception {
while (true) { while (true) {
ConsoleUtils.clearScreen(); ConsoleUtils.clearScreen();
System.out.println(ZAnsi.header("=== ZernMC Private Launcher ===")); System.out.println(ZAnsi.header("=== ZernMC Private Launcher ==="));
System.out.println(ZAnsi.cyan("Server packs only")); System.out.println(ZAnsi.cyan("Доступны только серверные сборки"));
if (!awaitActivePass()) { if (!awaitActivePass()) {
return; return;
@@ -47,13 +48,13 @@ public class LaunchMenu {
List<ServerPack> availablePacks = tempDownloader.getAvailablePacks(); List<ServerPack> availablePacks = tempDownloader.getAvailablePacks();
if (availablePacks.isEmpty()) { if (availablePacks.isEmpty()) {
System.out.println(ZAnsi.yellow("No packs available on the server.")); System.out.println(ZAnsi.yellow("На данный момент нет доступных сборок на сервере."));
ConsoleUtils.pause(); ConsoleUtils.pause();
return; return;
} }
List<String> options = availablePacks.stream() List<String> options = availablePacks.stream()
.map(p -> String.format("%s [%s + %s v%d] - %d files", .map(p -> String.format("%s [%s + %s v%d] %d файлов",
p.getName(), p.getName(),
p.getMinecraftVersion(), p.getMinecraftVersion(),
p.getLoaderType(), p.getLoaderType(),
@@ -61,9 +62,9 @@ public class LaunchMenu {
p.getFilesCount())) p.getFilesCount()))
.collect(Collectors.toList()); .collect(Collectors.toList());
options.add("Back to main menu"); options.add("Назад в главное меню");
ArrowMenu menu = new ArrowMenu("Select a pack", options); ArrowMenu menu = new ArrowMenu("Выберите сборку", options);
int choice = menu.show(); int choice = menu.show();
if (choice == -1 || choice == options.size() - 1) return; if (choice == -1 || choice == options.size() - 1) return;
@@ -75,25 +76,25 @@ public class LaunchMenu {
private boolean awaitActivePass() throws Exception { private boolean awaitActivePass() throws Exception {
if (AuthManager.hasActivePass()) { if (AuthManager.hasActivePass()) {
System.out.println(ZAnsi.brightGreen("Active pass confirmed")); System.out.println(ZAnsi.brightGreen("Активная проходка подтверждена"));
return true; return true;
} }
ConsoleUtils.clearScreen(); ConsoleUtils.clearScreen();
System.out.println(ZAnsi.brightRed("You don't have an active pass!")); System.out.println(ZAnsi.brightRed("У вас нет активной проходки!"));
System.out.println(ZAnsi.white("Access to ZernMC packs requires an active pass.")); System.out.println(ZAnsi.white("Для доступа к сборкам ZernMC требуется активная проходка."));
System.out.println(); System.out.println();
openActivationWebsite(); openActivationWebsite();
System.out.println(ZAnsi.cyan("Waiting for pass activation... (checking every 10 seconds)")); System.out.println(ZAnsi.cyan("Ожидаем активацию проходки... (проверка каждые 10 секунд)"));
System.out.println(ZAnsi.white("Press Enter to cancel")); System.out.println(ZAnsi.white("Нажмите Enter для отмены"));
for (int i = 0; i < 60; i++) { for (int i = 0; i < 60; i++) {
try { try {
if (System.in.available() > 0) { if (System.in.available() > 0) {
Input.readLine(); Input.readLine();
System.out.println(ZAnsi.yellow("\nWaiting cancelled.")); System.out.println(ZAnsi.yellow("\nОжидание отменено."));
return false; return false;
} }
} catch (Exception ignored) {} } catch (Exception ignored) {}
@@ -101,7 +102,7 @@ public class LaunchMenu {
Thread.sleep(10000); Thread.sleep(10000);
if (AuthManager.hasActivePass()) { if (AuthManager.hasActivePass()) {
System.out.println(ZAnsi.brightGreen("\n✓ Pass activated successfully!")); System.out.println(ZAnsi.brightGreen("\n✓ Проходка успешно активирована!"));
return true; return true;
} }
@@ -109,42 +110,43 @@ public class LaunchMenu {
if ((i + 1) % 6 == 0) System.out.println(); if ((i + 1) % 6 == 0) System.out.println();
} }
System.out.println(ZAnsi.brightRed("\n\nWaiting time expired.")); System.out.println(ZAnsi.brightRed("\n\nВремя ожидания истекло."));
return false; return false;
} }
private void openActivationWebsite() { private void openActivationWebsite() {
//String url = "https://launcher.ru.zernmc.ru/activate-pass";
String url = ZHttpClient.getBaseUrl() + "/activate-pass"; String url = ZHttpClient.getBaseUrl() + "/activate-pass";
try { try {
if (Desktop.isDesktopSupported() && Desktop.getDesktop().isSupported(Desktop.Action.BROWSE)) { if (Desktop.isDesktopSupported() && Desktop.getDesktop().isSupported(Desktop.Action.BROWSE)) {
Desktop.getDesktop().browse(new URI(url)); Desktop.getDesktop().browse(new URI(url));
System.out.println(ZAnsi.cyan("Browser opened: " + url)); System.out.println(ZAnsi.cyan("Браузер открыт: " + url));
} else { } else {
System.out.println(ZAnsi.yellow("Could not open browser automatically.")); System.out.println(ZAnsi.yellow("Не удалось открыть браузер автоматически."));
System.out.println(ZAnsi.white("Open manually: " + url)); System.out.println(ZAnsi.white("Откройте вручную: " + url));
} }
} catch (Exception e) { } catch (Exception e) {
System.out.println(ZAnsi.brightRed("Error opening browser: " + e.getMessage())); System.out.println(ZAnsi.brightRed("Ошибка открытия браузера: " + e.getMessage()));
System.out.println(ZAnsi.white("Link: " + url)); System.out.println(ZAnsi.white("Ссылка: " + url));
} }
} }
private void installAndRunServerPack(ServerPack selected) throws Exception { private void installAndRunServerPack(ServerPack selected) throws Exception {
ConsoleUtils.clearScreen(); ConsoleUtils.clearScreen();
System.out.println(ZAnsi.header("Installing pack: " + selected.getName())); System.out.println(ZAnsi.header("Установка сборки: " + selected.getName()));
System.out.println(ZAnsi.white(" Minecraft: ") + selected.getMinecraftVersion()); System.out.println(ZAnsi.white(" Minecraft: ") + selected.getMinecraftVersion());
System.out.println(ZAnsi.white(" Loader: ") + selected.getLoaderType() + System.out.println(ZAnsi.white(" Лоадер: ") + selected.getLoaderType() +
(selected.getLoaderVersion() != null ? " " + selected.getLoaderVersion() : "")); (selected.getLoaderVersion() != null ? " " + selected.getLoaderVersion() : ""));
System.out.println(ZAnsi.white(" Version: v") + selected.getVersion()); System.out.println(ZAnsi.white(" Версия: v") + selected.getVersion());
System.out.println(ZAnsi.white(" Files: ") + selected.getFilesCount()); System.out.println(ZAnsi.white(" Файлов: ") + selected.getFilesCount());
String localName = askPackName(); String localName = askPackName();
if (localName == null) return; if (localName == null) return;
if (InstanceManager.getInstance(localName) != null) { if (InstanceManager.getInstance(localName) != null) {
System.out.println(ZAnsi.brightRed("A pack with this name already exists!")); System.out.println(ZAnsi.brightRed("Сборка с таким именем уже существует!"));
ConsoleUtils.pause(); ConsoleUtils.pause();
return; return;
} }
@@ -156,17 +158,18 @@ public class LaunchMenu {
boolean success = packDownloader.installOrUpdatePack(selected.getName(), selected); boolean success = packDownloader.installOrUpdatePack(selected.getName(), selected);
if (!success) { if (!success) {
System.out.println(ZAnsi.brightRed("\n[FAIL] Could not install the pack.")); System.out.println(ZAnsi.brightRed("\n[FAIL] Не удалось установить сборку."));
ConsoleUtils.pause(); ConsoleUtils.pause();
return; return;
} }
System.out.println(ZAnsi.brightGreen("\n[OK] Pack '" + localName + "' installed successfully!")); System.out.println(ZAnsi.brightGreen("\n[OK] Сборка '" + localName + "' успешно установлена!"));
ConsoleUtils.pause(); ConsoleUtils.pause();
launchExistingInstance(newInstance); launchExistingInstance(newInstance);
} }
// ====================== GLOBAL BUILD ======================
private void showGlobal() throws Exception { private void showGlobal() throws Exception {
while (true) { while (true) {
ConsoleUtils.clearScreen(); ConsoleUtils.clearScreen();
@@ -176,10 +179,10 @@ public class LaunchMenu {
.map(Instance::toString) .map(Instance::toString)
.collect(Collectors.toList()); .collect(Collectors.toList());
options.add("Install new pack"); options.add("Установить новую сборку");
options.add("Back to main menu"); options.add("Назад в главное меню");
ArrowMenu menu = new ArrowMenu("Manage packs", options); ArrowMenu menu = new ArrowMenu("Управление сборками", options);
int choice = menu.show(); int choice = menu.show();
if (choice == -1 || choice == options.size() - 1) break; if (choice == -1 || choice == options.size() - 1) break;
@@ -198,13 +201,13 @@ public class LaunchMenu {
ConsoleUtils.clearScreen(); ConsoleUtils.clearScreen();
List<String> options = List.of( List<String> options = List.of(
"Install pack from ZernMC server", "Установить сборку с сервера ZernMC",
"Install Vanilla Minecraft", "Установить Vanilla Minecraft",
"Create custom pack (Fabric/Forge)", "Создать сборку вручную (Fabric/Forge)",
"Back" "Назад"
); );
ArrowMenu menu = new ArrowMenu("Install new pack", options); ArrowMenu menu = new ArrowMenu("Установка новой сборки", options);
int choice = menu.show(); int choice = menu.show();
if (choice == -1 || choice == 3) return; if (choice == -1 || choice == 3) return;
@@ -220,28 +223,28 @@ public class LaunchMenu {
if (!awaitActivePass()) return; if (!awaitActivePass()) return;
ConsoleUtils.clearScreen(); ConsoleUtils.clearScreen();
System.out.println(ZAnsi.cyan("Fetching available packs...")); System.out.println(ZAnsi.cyan("Получение списка доступных сборок..."));
PackDownloader tempDownloader = new PackDownloader(null); PackDownloader tempDownloader = new PackDownloader(null);
List<ServerPack> availablePacks = tempDownloader.getAvailablePacks(); List<ServerPack> availablePacks = tempDownloader.getAvailablePacks();
if (availablePacks.isEmpty()) { if (availablePacks.isEmpty()) {
System.out.println(ZAnsi.yellow("No packs available on the server.")); System.out.println(ZAnsi.yellow("Нет доступных сборок на сервере."));
ConsoleUtils.pause(); ConsoleUtils.pause();
return; return;
} }
List<String> options = availablePacks.stream() List<String> options = availablePacks.stream()
.map(p -> String.format("%s [%s + %s v%d] - %d files", .map(p -> String.format("%s [%s + %s v%d] %d файлов",
p.getName(), p.getName(),
p.getMinecraftVersion(), p.getMinecraftVersion(),
p.getLoaderType(), p.getLoaderType(),
p.getVersion(), p.getVersion(),
p.getFilesCount())) p.getFilesCount()))
.collect(Collectors.toList()); .collect(Collectors.toList());
options.add("Back"); options.add("Назад");
ArrowMenu menu = new ArrowMenu("Select a pack to install", options); ArrowMenu menu = new ArrowMenu("Выберите сборку для установки", options);
int choice = menu.show(); int choice = menu.show();
if (choice == -1 || choice == options.size() - 1) return; if (choice == -1 || choice == options.size() - 1) return;
@@ -249,14 +252,14 @@ public class LaunchMenu {
ServerPack selected = availablePacks.get(choice); ServerPack selected = availablePacks.get(choice);
ConsoleUtils.clearScreen(); ConsoleUtils.clearScreen();
System.out.println(ZAnsi.header("Installing pack: " + selected.getName())); System.out.println(ZAnsi.header("Установка сборки: " + selected.getName()));
System.out.print(ZAnsi.white("\nEnter local pack name (Enter = pack name): ")); System.out.print(ZAnsi.white("\nВведите название локальной сборки (Enter = имя пака): "));
String localName = Input.readLine().trim(); String localName = Input.readLine().trim();
if (localName.isEmpty()) localName = selected.getName(); if (localName.isEmpty()) localName = selected.getName();
if (InstanceManager.getInstance(localName) != null) { if (InstanceManager.getInstance(localName) != null) {
System.out.println(ZAnsi.brightRed("A pack with this name already exists!")); System.out.println(ZAnsi.brightRed("Сборка с таким именем уже существует!"));
ConsoleUtils.pause(); ConsoleUtils.pause();
return; return;
} }
@@ -268,36 +271,37 @@ public class LaunchMenu {
boolean success = packDownloader.installOrUpdatePack(selected.getName(), selected); boolean success = packDownloader.installOrUpdatePack(selected.getName(), selected);
if (success) { if (success) {
System.out.println(ZAnsi.brightGreen("\n[OK] Pack '" + localName + "' installed successfully!")); System.out.println(ZAnsi.brightGreen("\n[OK] Сборка '" + localName + "' успешно установлена!"));
} else { } else {
System.out.println(ZAnsi.brightRed("\n[FAIL] Could not install the pack.")); System.out.println(ZAnsi.brightRed("\n[FAIL] Не удалось установить сборку."));
} }
ConsoleUtils.pause(); ConsoleUtils.pause();
} }
// ====================== manageInstance полностью восстановлен ======================
private void manageInstance(Instance instance) throws Exception { private void manageInstance(Instance instance) throws Exception {
while (true) { while (true) {
ConsoleUtils.clearScreen(); ConsoleUtils.clearScreen();
System.out.println(ZAnsi.header("Managing pack: " + instance.getName())); System.out.println(ZAnsi.header("Управление сборкой: " + instance.getName()));
System.out.println(ZAnsi.white("Version: " + instance.getMinecraftVersion())); System.out.println(ZAnsi.white("Версия: " + instance.getMinecraftVersion()));
System.out.println(ZAnsi.white("Loader: " + instance.getLoaderType() + System.out.println(ZAnsi.white("Лоадер: " + instance.getLoaderType() +
(instance.getLoaderVersion() != null ? " " + instance.getLoaderVersion() : ""))); (instance.getLoaderVersion() != null ? " " + instance.getLoaderVersion() : "")));
if (instance.isServerPack()) { if (instance.isServerPack()) {
System.out.println(ZAnsi.green("Server pack: v" + instance.getServerVersion())); System.out.println(ZAnsi.green("Серверная сборка: v" + instance.getServerVersion()));
} }
List<String> options = new ArrayList<>(); List<String> options = new ArrayList<>();
options.add("Launch pack"); options.add("Запустить сборку");
if (instance.isServerPack()) { if (instance.isServerPack()) {
options.add("Check for updates"); options.add("Проверить обновления");
} }
options.add("Change loader version"); options.add("Изменить версию лоадера");
options.add("Delete pack"); options.add("Удалить сборку");
options.add("Back"); options.add("Назад");
ArrowMenu menu = new ArrowMenu("Actions", options); ArrowMenu menu = new ArrowMenu("Действия", options);
int choice = menu.show(); int choice = menu.show();
if (choice == -1 || choice == options.size() - 1) return; if (choice == -1 || choice == options.size() - 1) return;
@@ -325,40 +329,40 @@ public class LaunchMenu {
private void checkAndUpdateServerPack(Instance instance) throws Exception { private void checkAndUpdateServerPack(Instance instance) throws Exception {
ConsoleUtils.clearScreen(); ConsoleUtils.clearScreen();
System.out.println(ZAnsi.cyan("Checking updates for " + instance.getName())); System.out.println(ZAnsi.cyan("Проверка обновлений для " + instance.getName()));
PackDownloader downloader = new PackDownloader(instance); PackDownloader downloader = new PackDownloader(instance);
boolean hasUpdate = downloader.checkForUpdates(instance.getServerPackName()); boolean hasUpdate = downloader.checkForUpdates(instance.getServerPackName());
if (!hasUpdate) { if (!hasUpdate) {
System.out.println(ZAnsi.green("Pack is up to date (v" + instance.getServerVersion() + ")")); System.out.println(ZAnsi.green("Сборка актуальна (v" + instance.getServerVersion() + ")"));
ConsoleUtils.pause(); ConsoleUtils.pause();
return; return;
} }
System.out.println(ZAnsi.brightYellow("Update available!")); System.out.println(ZAnsi.brightYellow("Доступно обновление!"));
if (Input.confirm("Update pack")) { if (Input.confirm("Обновить сборку")) {
boolean success = downloader.updatePack(instance.getServerPackName()); boolean success = downloader.updatePack(instance.getServerPackName());
if (success) { if (success) {
System.out.println(ZAnsi.brightGreen("Pack updated successfully!")); System.out.println(ZAnsi.brightGreen("Сборка успешно обновлена!"));
} else { } else {
System.out.println(ZAnsi.brightRed("Failed to update pack.")); System.out.println(ZAnsi.brightRed("Не удалось обновить сборку."));
} }
} else { } else {
System.out.println(ZAnsi.yellow("Update cancelled.")); System.out.println(ZAnsi.yellow("Обновление отменено."));
} }
ConsoleUtils.pause(); ConsoleUtils.pause();
} }
private void changeLoaderVersion(Instance instance) throws Exception { private void changeLoaderVersion(Instance instance) throws Exception {
ConsoleUtils.clearScreen(); ConsoleUtils.clearScreen();
System.out.println(ZAnsi.cyan("Changing loader version for " + instance.getName())); System.out.println(ZAnsi.cyan("Изменение версии лоадера для " + instance.getName()));
String currentLoader = instance.getLoaderType(); String currentLoader = instance.getLoaderType();
String mcVersion = instance.getMinecraftVersion(); String mcVersion = instance.getMinecraftVersion();
if ("vanilla".equalsIgnoreCase(currentLoader)) { if ("vanilla".equalsIgnoreCase(currentLoader)) {
System.out.println(ZAnsi.yellow("This is a vanilla instance. Cannot change loader.")); System.out.println(ZAnsi.yellow("Это vanilla сборка. Нельзя изменить лоадер."));
ConsoleUtils.pause(); ConsoleUtils.pause();
return; return;
} }
@@ -374,7 +378,7 @@ public class LaunchMenu {
if (newLoaderVersion == null) return; if (newLoaderVersion == null) return;
System.out.println(ZAnsi.cyan("Reinstalling loader " + currentLoader + " -> " + newLoaderVersion + "...")); System.out.println(ZAnsi.cyan("Переустановка лоадера " + currentLoader + " -> " + newLoaderVersion + "..."));
MinecraftLib lib = new MinecraftLib(instance); MinecraftLib lib = new MinecraftLib(instance);
boolean success; boolean success;
@@ -389,12 +393,12 @@ public class LaunchMenu {
} }
if (success) { if (success) {
System.out.println(ZAnsi.brightGreen("Loader version changed successfully!")); System.out.println(ZAnsi.brightGreen("Версия лоадера успешно изменена!"));
} else { } else {
System.out.println(ZAnsi.brightRed("Failed to change loader version.")); System.out.println(ZAnsi.brightRed("Не удалось изменить версию лоадера."));
} }
} catch (Exception e) { } catch (Exception e) {
System.out.println(ZAnsi.brightRed("Error changing loader: " + e.getMessage())); System.out.println(ZAnsi.brightRed("Ошибка при смене лоадера: " + e.getMessage()));
} }
ConsoleUtils.pause(); ConsoleUtils.pause();
@@ -404,12 +408,12 @@ public class LaunchMenu {
ConsoleUtils.clearScreen(); ConsoleUtils.clearScreen();
List<String> confirmOptions = List.of( List<String> confirmOptions = List.of(
"Yes, delete pack", "Да, удалить сборку",
"No, cancel" "Нет, отменить"
); );
ArrowMenu confirmMenu = new ArrowMenu( ArrowMenu confirmMenu = new ArrowMenu(
"Are you sure you want to delete '" + instance.getName() + "'?", "Вы действительно хотите удалить сборку '" + instance.getName() + "'?",
confirmOptions confirmOptions
); );
@@ -418,12 +422,12 @@ public class LaunchMenu {
if (choice == 0) { if (choice == 0) {
boolean deleted = InstanceManager.deleteInstance(instance.getName()); boolean deleted = InstanceManager.deleteInstance(instance.getName());
if (deleted) { if (deleted) {
System.out.println(ZAnsi.brightGreen("Pack '" + instance.getName() + "' deleted successfully.")); System.out.println(ZAnsi.brightGreen("Сборка '" + instance.getName() + "' успешно удалена."));
} else { } else {
System.out.println(ZAnsi.brightRed("Failed to delete pack.")); System.out.println(ZAnsi.brightRed("Не удалось удалить сборку."));
} }
} else { } else {
System.out.println(ZAnsi.yellow("Deletion cancelled.")); System.out.println(ZAnsi.yellow("Удаление отменено."));
} }
ConsoleUtils.pause(); ConsoleUtils.pause();
@@ -432,20 +436,16 @@ public class LaunchMenu {
private void launchExistingInstance(Instance instance) { private void launchExistingInstance(Instance instance) {
if (instance.isServerPack() && !AuthManager.hasActivePass()) { if (instance.isServerPack() && !AuthManager.hasActivePass()) {
ConsoleUtils.clearScreen(); ConsoleUtils.clearScreen();
System.out.println(ZAnsi.brightRed("Launching a server pack requires an active pass!")); System.out.println(ZAnsi.brightRed("Для запуска серверной сборки требуется активная проходка!"));
ConsoleUtils.pause(); ConsoleUtils.pause();
return; return;
} }
ConsoleUtils.clearScreen(); ConsoleUtils.clearScreen();
System.out.println(ZAnsi.brightGreen("Launching pack: " + instance.getName())); System.out.println(ZAnsi.brightGreen("Запуск сборки: " + instance.getName()));
MinecraftLib lib = new MinecraftLib(instance); MinecraftLib lib = new MinecraftLib(instance);
LaunchOptions options = new LaunchOptions(); LaunchOptions options = new LaunchOptions();
options.setMaxMemory(Config.getMaxMemory());
options.setWidth(Config.getWindowWidth());
options.setHeight(Config.getWindowHeight());
options.setJavaPath(Config.getJavaPath());
options.setUsername(AuthManager.getUsername()); options.setUsername(AuthManager.getUsername());
options.setUuid(AuthManager.getUuid()); options.setUuid(AuthManager.getUuid());
@@ -454,18 +454,20 @@ public class LaunchMenu {
try { try {
lib.launch(options); lib.launch(options);
} catch (Exception e) { } catch (Exception e) {
System.out.println(ZAnsi.brightRed("Error launching: " + e.getMessage())); System.out.println(ZAnsi.brightRed("Ошибка при запуске: " + e.getMessage()));
e.printStackTrace(); e.printStackTrace();
} }
ConsoleUtils.pause(); ConsoleUtils.pause();
} }
// ====================== Остальные вспомогательные методы ======================
private String askPackName() { private String askPackName() {
System.out.print(ZAnsi.white("\nEnter new pack name: ")); System.out.print(ZAnsi.white("\nВведите название новой сборки: "));
String name = Input.readLine().trim(); String name = Input.readLine().trim();
if (name.isEmpty()) { if (name.isEmpty()) {
System.out.println(ZAnsi.yellow("Cancelled.")); System.out.println(ZAnsi.yellow("Отменено."));
return null; return null;
} }
return name; return name;
@@ -473,7 +475,7 @@ public class LaunchMenu {
private void createVanillaInstance() throws Exception { private void createVanillaInstance() throws Exception {
ConsoleUtils.clearScreen(); ConsoleUtils.clearScreen();
System.out.println(ZAnsi.cyan("Fetching Minecraft versions...")); System.out.println(ZAnsi.cyan("Получение списка версий Minecraft..."));
VersionInstaller versionInstaller = new VersionInstaller(null); VersionInstaller versionInstaller = new VersionInstaller(null);
List<MinecraftVersion> allVersions = versionInstaller.getAvailableVersions(); List<MinecraftVersion> allVersions = versionInstaller.getAvailableVersions();
@@ -481,9 +483,9 @@ public class LaunchMenu {
List<String> versionOptions = allVersions.stream() List<String> versionOptions = allVersions.stream()
.map(v -> v.getId() + " (" + v.getType() + ")") .map(v -> v.getId() + " (" + v.getType() + ")")
.collect(Collectors.toList()); .collect(Collectors.toList());
versionOptions.add("Back"); versionOptions.add("Назад");
ArrowMenu versionMenu = new ArrowMenu("Select Minecraft version", versionOptions); ArrowMenu versionMenu = new ArrowMenu("Выбор версии Minecraft", versionOptions);
int versionChoice = versionMenu.show(); int versionChoice = versionMenu.show();
if (versionChoice == -1 || versionChoice == versionOptions.size() - 1) return; if (versionChoice == -1 || versionChoice == versionOptions.size() - 1) return;
@@ -495,7 +497,7 @@ public class LaunchMenu {
if (packName == null) return; if (packName == null) return;
if (InstanceManager.getInstance(packName) != null) { if (InstanceManager.getInstance(packName) != null) {
System.out.println(ZAnsi.brightRed("A pack with this name already exists!")); System.out.println(ZAnsi.brightRed("Сборка с таким именем уже существует!"));
ConsoleUtils.pause(); ConsoleUtils.pause();
return; return;
} }
@@ -507,9 +509,9 @@ public class LaunchMenu {
boolean success = lib.installMinecraft(mcVersion); boolean success = lib.installMinecraft(mcVersion);
if (success) { if (success) {
System.out.println(ZAnsi.brightGreen("\n[OK] Vanilla pack '" + packName + "' created successfully!")); System.out.println(ZAnsi.brightGreen("\n[OK] Vanilla сборка '" + packName + "' успешно создана!"));
} else { } else {
System.out.println(ZAnsi.brightRed("\n[FAIL] Failed to create pack.")); System.out.println(ZAnsi.brightRed("\n[FAIL] Не удалось создать сборку."));
} }
ConsoleUtils.pause(); ConsoleUtils.pause();
@@ -517,7 +519,7 @@ public class LaunchMenu {
private void createCustomInstance() throws Exception { private void createCustomInstance() throws Exception {
ConsoleUtils.clearScreen(); ConsoleUtils.clearScreen();
System.out.println(ZAnsi.cyan("Fetching Minecraft versions...")); System.out.println(ZAnsi.cyan("Получение списка версий Minecraft..."));
VersionInstaller versionInstaller = new VersionInstaller(null); VersionInstaller versionInstaller = new VersionInstaller(null);
List<MinecraftVersion> allVersions = versionInstaller.getAvailableVersions(); List<MinecraftVersion> allVersions = versionInstaller.getAvailableVersions();
@@ -525,9 +527,9 @@ public class LaunchMenu {
List<String> versionOptions = allVersions.stream() List<String> versionOptions = allVersions.stream()
.map(v -> v.getId() + " (" + v.getType() + ")") .map(v -> v.getId() + " (" + v.getType() + ")")
.collect(Collectors.toList()); .collect(Collectors.toList());
versionOptions.add("Back"); versionOptions.add("Назад");
ArrowMenu versionMenu = new ArrowMenu("Select Minecraft version", versionOptions); ArrowMenu versionMenu = new ArrowMenu("Выбор версии Minecraft", versionOptions);
int versionChoice = versionMenu.show(); int versionChoice = versionMenu.show();
if (versionChoice == -1 || versionChoice == versionOptions.size() - 1) return; if (versionChoice == -1 || versionChoice == versionOptions.size() - 1) return;
@@ -536,7 +538,7 @@ public class LaunchMenu {
String mcVersion = selectedMc.getId(); String mcVersion = selectedMc.getId();
List<String> loaderOptions = buildLoaderOptions(mcVersion); List<String> loaderOptions = buildLoaderOptions(mcVersion);
ArrowMenu loaderMenu = new ArrowMenu("Select mod loader for " + mcVersion, loaderOptions); ArrowMenu loaderMenu = new ArrowMenu("Выбор модлоадера для " + mcVersion, loaderOptions);
int loaderChoice = loaderMenu.show(); int loaderChoice = loaderMenu.show();
if (loaderChoice == -1 || loaderChoice == loaderOptions.size() - 1) return; if (loaderChoice == -1 || loaderChoice == loaderOptions.size() - 1) return;
@@ -572,7 +574,7 @@ public class LaunchMenu {
if (packName == null) return; if (packName == null) return;
if (InstanceManager.getInstance(packName) != null) { if (InstanceManager.getInstance(packName) != null) {
System.out.println(ZAnsi.brightRed("A pack with this name already exists!")); System.out.println(ZAnsi.brightRed("Сборка с таким именем уже существует!"));
ConsoleUtils.pause(); ConsoleUtils.pause();
return; return;
} }
@@ -592,9 +594,9 @@ public class LaunchMenu {
} }
if (success) { if (success) {
System.out.println(ZAnsi.brightGreen("\n[OK] Pack '" + packName + "' installed successfully!")); System.out.println(ZAnsi.brightGreen("\n[OK] Сборка '" + packName + "' успешно установлена!"));
} else { } else {
System.out.println(ZAnsi.brightRed("\n[FAIL] Failed to install pack.")); System.out.println(ZAnsi.brightRed("\n[FAIL] Не удалось установить сборку."));
} }
ConsoleUtils.pause(); ConsoleUtils.pause();
@@ -607,7 +609,7 @@ public class LaunchMenu {
if (isNeoForgeSupported(mcVersion)) options.add("NeoForge"); if (isNeoForgeSupported(mcVersion)) options.add("NeoForge");
if (isForgeSupported(mcVersion)) options.add("Forge"); if (isForgeSupported(mcVersion)) options.add("Forge");
options.add("Vanilla"); options.add("Vanilla");
options.add("Back"); options.add("Назад");
return options; return options;
} }
@@ -629,16 +631,16 @@ public class LaunchMenu {
} }
private String askFabricLoaderVersion() throws Exception { private String askFabricLoaderVersion() throws Exception {
System.out.println(ZAnsi.cyan("Fetching Fabric Loader versions...")); System.out.println(ZAnsi.cyan("Получение списка версий Fabric Loader..."));
List<String> versions = ZHttpClient.getFabricLoaderVersions(); List<String> versions = ZHttpClient.getFabricLoaderVersions();
List<String> options = versions.stream() List<String> options = versions.stream()
.limit(30) .limit(30)
.map(v -> "Fabric Loader " + v) .map(v -> "Fabric Loader " + v)
.collect(Collectors.toList()); .collect(Collectors.toList());
options.add("Back"); options.add("Назад");
ArrowMenu menu = new ArrowMenu("Select Fabric Loader version", options); ArrowMenu menu = new ArrowMenu("Выбор версии Fabric Loader", options);
int choice = menu.show(); int choice = menu.show();
if (choice == -1 || choice == options.size() - 1) return null; if (choice == -1 || choice == options.size() - 1) return null;
@@ -646,7 +648,7 @@ public class LaunchMenu {
} }
private String askForgeVersion(String mcVersion) throws Exception { private String askForgeVersion(String mcVersion) throws Exception {
System.out.println(ZAnsi.cyan("Fetching Forge versions for " + mcVersion + "...")); System.out.println(ZAnsi.cyan("Получение списка версий Forge для " + mcVersion + "..."));
List<String> allForgeVersions = getAllForgeVersions(); List<String> allForgeVersions = getAllForgeVersions();
@@ -656,7 +658,7 @@ public class LaunchMenu {
.collect(Collectors.toList()); .collect(Collectors.toList());
if (compatibleVersions.isEmpty()) { if (compatibleVersions.isEmpty()) {
System.out.println(ZAnsi.yellow("No compatible Forge versions found for " + mcVersion)); System.out.println(ZAnsi.yellow("Не найдено совместимых версий Forge для " + mcVersion));
ConsoleUtils.pause(); ConsoleUtils.pause();
return null; return null;
} }
@@ -665,9 +667,9 @@ public class LaunchMenu {
.limit(30) .limit(30)
.map(v -> "Forge " + v) .map(v -> "Forge " + v)
.collect(Collectors.toList()); .collect(Collectors.toList());
options.add("Back"); options.add("Назад");
ArrowMenu menu = new ArrowMenu("Select Forge version for " + mcVersion, options); ArrowMenu menu = new ArrowMenu("Выбор версии Forge для " + mcVersion, options);
int choice = menu.show(); int choice = menu.show();
if (choice == -1 || choice == options.size() - 1) return null; if (choice == -1 || choice == options.size() - 1) return null;
@@ -696,7 +698,7 @@ public class LaunchMenu {
} }
private String askNeoForgeVersion(String mcVersion) throws Exception { private String askNeoForgeVersion(String mcVersion) throws Exception {
System.out.println(ZAnsi.cyan("Fetching NeoForge versions for " + mcVersion + "...")); System.out.println(ZAnsi.cyan("Получение списка версий NeoForge для " + mcVersion + "..."));
List<String> allNeoForgeVersions = getAllNeoForgeVersions(); List<String> allNeoForgeVersions = getAllNeoForgeVersions();
@@ -705,7 +707,7 @@ public class LaunchMenu {
.collect(Collectors.toList()); .collect(Collectors.toList());
if (compatibleVersions.isEmpty()) { if (compatibleVersions.isEmpty()) {
System.out.println(ZAnsi.yellow("No compatible NeoForge versions found for " + mcVersion)); System.out.println(ZAnsi.yellow("Не найдено совместимых версий NeoForge для " + mcVersion));
ConsoleUtils.pause(); ConsoleUtils.pause();
return null; return null;
} }
@@ -714,9 +716,9 @@ public class LaunchMenu {
.limit(30) .limit(30)
.map(v -> "NeoForge " + v) .map(v -> "NeoForge " + v)
.collect(Collectors.toList()); .collect(Collectors.toList());
options.add("Back"); options.add("Назад");
ArrowMenu menu = new ArrowMenu("Select NeoForge version for " + mcVersion, options); ArrowMenu menu = new ArrowMenu("Выбор версии NeoForge для " + mcVersion, options);
int choice = menu.show(); int choice = menu.show();
if (choice == -1 || choice == options.size() - 1) return null; if (choice == -1 || choice == options.size() - 1) return null;
@@ -758,6 +760,7 @@ public class LaunchMenu {
index = end; index = end;
} }
} catch (Exception e) { } catch (Exception e) {
// Skip if one maven doesn't have the artifact
} }
} }
@@ -10,20 +10,30 @@ import me.sashegdev.zernmc.launcher.utils.ZAnsi;
import java.io.IOException; import java.io.IOException;
import java.util.List; import java.util.List;
/**
* Экран входа/регистрации.
* Показывается при старте лаунчера, если нет сохранённой сессии.
*
* show() возвращает true пользователь вошёл/зарегистрировался
* false пользователь выбрал выход из лаунчера
*/
public class LoginMenu { public class LoginMenu {
/**
* Главный экран выбора действия.
*/
public boolean show() throws IOException { public boolean show() throws IOException {
while (true) { while (true) {
ConsoleUtils.clearScreen(); ConsoleUtils.clearScreen();
printBanner(); printBanner();
List<String> options = List.of( List<String> options = List.of(
"Sign In", "Войти в аккаунт",
"Create Account", "Создать аккаунт",
"Exit Launcher" "Выйти из лаунчера"
); );
ArrowMenu menu = new ArrowMenu("Welcome to ZernMC!", options); ArrowMenu menu = new ArrowMenu("Добро пожаловать в ZernMC!", options);
int choice = menu.show(); int choice = menu.show();
if (choice == -1 || choice == 2) return false; if (choice == -1 || choice == 2) return false;
@@ -35,56 +45,62 @@ public class LoginMenu {
}; };
if (success) return true; if (success) return true;
// Если не успех покажем меню снова (ошибка уже напечатана внутри методов)
} }
} }
/**
* Показывается когда пользователь уже вошёл предлагает выйти из аккаунта.
*/
public void showAccountMenu() throws IOException { public void showAccountMenu() throws IOException {
ConsoleUtils.clearScreen(); ConsoleUtils.clearScreen();
System.out.println(ZAnsi.header("=== Account ===")); System.out.println(ZAnsi.header("=== Аккаунт ==="));
System.out.println(); System.out.println();
System.out.println(ZAnsi.white(" Player: ") + ZAnsi.brightGreen(AuthManager.getUsername())); System.out.println(ZAnsi.white(" Игрок: ") + ZAnsi.brightGreen(AuthManager.getUsername()));
System.out.println(ZAnsi.white(" UUID: ") + ZAnsi.cyan(AuthManager.getUuid())); System.out.println(ZAnsi.white(" UUID: ") + ZAnsi.cyan(AuthManager.getUuid()));
System.out.println(); System.out.println();
List<String> options = List.of( List<String> options = List.of(
"Log Out", "Выйти из аккаунта",
"Back" "Назад"
); );
ArrowMenu menu = new ArrowMenu("Account Management", options); ArrowMenu menu = new ArrowMenu("Управление аккаунтом", options);
int choice = menu.show(); int choice = menu.show();
if (choice == 0) { if (choice == 0) {
AuthManager.logout(); AuthManager.logout();
System.out.println(ZAnsi.yellow("Logged out.")); System.out.println(ZAnsi.yellow("Вы вышли из аккаунта."));
ConsoleUtils.pause(); ConsoleUtils.pause();
} }
} }
// ====================== ПРИВАТНЫЕ МЕТОДЫ ======================
private boolean doLogin() throws IOException { private boolean doLogin() throws IOException {
ConsoleUtils.clearScreen(); ConsoleUtils.clearScreen();
printBanner(); printBanner();
System.out.println(ZAnsi.cyan(" [ Sign In ]")); System.out.println(ZAnsi.cyan(" [ Вход в аккаунт ]"));
System.out.println(); System.out.println();
String username = Input.readLine(ZAnsi.white(" Username: ")); String username = Input.readLine(ZAnsi.white(" Имя пользователя: "));
if (username.isEmpty()) return false; if (username.isEmpty()) return false;
String password = readPassword(" Password: "); String password = readPassword(" Пароль: ");
if (password.isEmpty()) return false; if (password.isEmpty()) return false;
System.out.println(); System.out.println();
System.out.print(ZAnsi.cyan(" Signing in...")); System.out.print(ZAnsi.cyan(" Выполняем вход..."));
AuthResult result = AuthManager.login(username, password); AuthResult result = AuthManager.login(username, password);
if (result.success) { if (result.success) {
System.out.println("\r" + ZAnsi.brightGreen(" Welcome, " + AuthManager.getUsername() + "! ")); System.out.println("\r" + ZAnsi.brightGreen(" Добро пожаловать, " + AuthManager.getUsername() + "! "));
ConsoleUtils.pause(); ConsoleUtils.pause();
return true; return true;
} else { } else {
System.out.println("\r" + ZAnsi.brightRed(" Error: " + result.error + " ")); System.out.println("\r" + ZAnsi.brightRed(" Ошибка: " + result.error + " "));
ConsoleUtils.pause(); ConsoleUtils.pause();
return false; return false;
} }
@@ -93,41 +109,45 @@ public class LoginMenu {
private boolean doRegister() throws IOException { private boolean doRegister() throws IOException {
ConsoleUtils.clearScreen(); ConsoleUtils.clearScreen();
printBanner(); printBanner();
System.out.println(ZAnsi.cyan(" [ Create Account ]")); System.out.println(ZAnsi.cyan(" [ Создание аккаунта ]"));
System.out.println(); System.out.println();
System.out.println(ZAnsi.yellow(" Allowed characters: a-z, A-Z, 0-9, _")); System.out.println(ZAnsi.yellow(" Допустимые символы в имени: a-z, A-Z, 0-9, _"));
System.out.println(ZAnsi.yellow(" Name length: 3-16 chars | Password length: 6+ chars")); System.out.println(ZAnsi.yellow(" Длина имени: 3-16 символов | Длина пароля: от 6 символов"));
System.out.println(); System.out.println();
String username = Input.readLine(ZAnsi.white(" Username: ")); String username = Input.readLine(ZAnsi.white(" Имя пользователя: "));
if (username.isEmpty()) return false; if (username.isEmpty()) return false;
String password = readPassword(" Password: "); String password = readPassword(" Пароль: ");
if (password.isEmpty()) return false; if (password.isEmpty()) return false;
String confirm = readPassword(" Confirm password: "); String confirm = readPassword(" Повторите пароль: ");
if (!password.equals(confirm)) { if (!password.equals(confirm)) {
System.out.println(ZAnsi.brightRed("\n Passwords do not match!")); System.out.println(ZAnsi.brightRed("\n Пароли не совпадают!"));
ConsoleUtils.pause(); ConsoleUtils.pause();
return false; return false;
} }
System.out.println(); System.out.println();
System.out.print(ZAnsi.cyan(" Creating account...")); System.out.print(ZAnsi.cyan(" Создаём аккаунт..."));
AuthResult result = AuthManager.register(username, password); AuthResult result = AuthManager.register(username, password);
if (result.success) { if (result.success) {
System.out.println("\r" + ZAnsi.brightGreen(" Account created! Welcome, " + AuthManager.getUsername() + "! ")); System.out.println("\r" + ZAnsi.brightGreen(" Аккаунт создан! Добро пожаловать, " + AuthManager.getUsername() + "! "));
ConsoleUtils.pause(); ConsoleUtils.pause();
return true; return true;
} else { } else {
System.out.println("\r" + ZAnsi.brightRed(" Error: " + result.error + " ")); System.out.println("\r" + ZAnsi.brightRed(" Ошибка: " + result.error + " "));
ConsoleUtils.pause(); ConsoleUtils.pause();
return false; return false;
} }
} }
/**
* Читаем пароль стараемся скрыть вывод через Console,
* если недоступно (IDE/терминал без TTY) читаем обычным способом.
*/
private String readPassword(String prompt) throws IOException { private String readPassword(String prompt) throws IOException {
org.jline.terminal.Terminal passTerminal = org.jline.terminal.TerminalBuilder.builder() org.jline.terminal.Terminal passTerminal = org.jline.terminal.TerminalBuilder.builder()
.system(true) .system(true)
@@ -145,26 +165,27 @@ public class LoginMenu {
int key = passTerminal.reader().read(); int key = passTerminal.reader().read();
if (key == 27) { if (key == 27) {
int next = passTerminal.reader().read(); // Escape sequence consume remaining bytes (arrow keys, etc.)
if (next == 91) { int next = passTerminal.reader().read(50);
passTerminal.reader().read(); if (next == 91) { // '[' arrow key sequence
passTerminal.reader().read(50); // consume 'A'/'B'/'C'/'D'
} }
continue; continue;
} }
if (key == 13 || key == 10) { if (key == 13 || key == 10) { // Enter
passTerminal.writer().println(); passTerminal.writer().println();
break; break;
} else if (key == 127 || key == 8) { } else if (key == 127 || key == 8) { // Backspace
if (password.length() > 0) { if (password.length() > 0) {
password.setLength(password.length() - 1); password.setLength(password.length() - 1);
passTerminal.writer().print("\b \b"); passTerminal.writer().print("\b \b");
passTerminal.writer().flush(); passTerminal.writer().flush();
} }
} else if (key == 3) { } else if (key == 3) { // Ctrl+C
passTerminal.writer().println(); passTerminal.writer().println();
System.exit(0); System.exit(0);
} else if (key >= 32 && key < 127) { } else if (key >= 32 && key < 127) { // Printable characters
password.append((char) key); password.append((char) key);
passTerminal.writer().print('*'); passTerminal.writer().print('*');
passTerminal.writer().flush(); passTerminal.writer().flush();
@@ -18,17 +18,17 @@ public class ServerCheckMenu {
public void show() throws IOException { public void show() throws IOException {
while (true) { while (true) {
ConsoleUtils.clearScreen(); ConsoleUtils.clearScreen();
System.out.println(ZAnsi.header("Connection Diagnostics")); System.out.println(ZAnsi.header("Диагностика подключения"));
List<String> options = List.of( List<String> options = List.of(
"Check ZernMC server connection", "Проверить подключение к ZernMC серверу",
"Check Mojang (Minecraft) access", "Проверить доступ к Mojang (Minecraft)",
"Check Fabric Meta access", "Проверить доступ к Fabric Meta",
"Check Forge Maven access", "Проверить доступ к Forge Maven",
"Back to main menu" "Назад в главное меню"
); );
ArrowMenu menu = new ArrowMenu("Select check", options); ArrowMenu menu = new ArrowMenu("Выберите проверку", options);
int choice = menu.show(); int choice = menu.show();
if (choice == -1 || choice == 4) { if (choice == -1 || choice == 4) {
@@ -49,20 +49,20 @@ public class ServerCheckMenu {
} }
private void checkZernServer() { private void checkZernServer() {
System.out.println(ZAnsi.cyan("Checking connection to ZernMC server...")); System.out.println(ZAnsi.cyan("Проверка подключения к ZernMC серверу..."));
try { try {
String response = ZHttpClient.get("/health"); String response = ZHttpClient.get("/health");
System.out.println(ZAnsi.brightGreen("[OK] ZernMC server connected successfully!")); System.out.println(ZAnsi.brightGreen("[OK] ZernMC сервер успешно подключён!"));
System.out.println(ZAnsi.white("Server response: ") + response); System.out.println(ZAnsi.white("Ответ сервера: ") + response);
} catch (Exception e) { } catch (Exception e) {
System.out.println(ZAnsi.brightRed("[FAIL] Could not connect to ZernMC server")); System.out.println(ZAnsi.brightRed("[FAIL] Не удалось подключиться к ZernMC серверу"));
System.out.println(ZAnsi.white("Error: ") + e.getMessage()); System.out.println(ZAnsi.white("Ошибка: ") + e.getMessage());
} }
} }
private void checkMojang() { private void checkMojang() {
System.out.println(ZAnsi.cyan("Checking Mojang access...")); System.out.println(ZAnsi.cyan("Проверка доступа к Mojang..."));
try { try {
HttpClient client = HttpClient.newBuilder() HttpClient client = HttpClient.newBuilder()
@@ -77,18 +77,18 @@ public class ServerCheckMenu {
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString()); HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() == 200) { if (response.statusCode() == 200) {
System.out.println(ZAnsi.brightGreen("[OK] Mojang is accessible")); System.out.println(ZAnsi.brightGreen("[OK] Mojang доступен"));
} else { } else {
System.out.println(ZAnsi.brightRed("[FAIL] Mojang returned code " + response.statusCode())); System.out.println(ZAnsi.brightRed("[FAIL] Mojang вернул код " + response.statusCode()));
} }
} catch (Exception e) { } catch (Exception e) {
System.out.println(ZAnsi.brightRed("[FAIL] Cannot access Mojang")); System.out.println(ZAnsi.brightRed("[FAIL] Нет доступа к Mojang"));
System.out.println(ZAnsi.white("Error: ") + e.getMessage()); System.out.println(ZAnsi.white("Ошибка: ") + e.getMessage());
} }
} }
private void checkFabric() { private void checkFabric() {
System.out.println(ZAnsi.cyan("Checking Fabric Meta access...")); System.out.println(ZAnsi.cyan("Проверка доступа к Fabric Meta..."));
try { try {
HttpClient client = HttpClient.newBuilder() HttpClient client = HttpClient.newBuilder()
@@ -103,18 +103,18 @@ public class ServerCheckMenu {
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString()); HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() == 200) { if (response.statusCode() == 200) {
System.out.println(ZAnsi.brightGreen("[OK] Fabric Meta is accessible")); System.out.println(ZAnsi.brightGreen("[OK] Fabric Meta доступен"));
} else { } else {
System.out.println(ZAnsi.brightRed("[FAIL] Fabric Meta returned code " + response.statusCode())); System.out.println(ZAnsi.brightRed("[FAIL] Fabric Meta вернул код " + response.statusCode()));
} }
} catch (Exception e) { } catch (Exception e) {
System.out.println(ZAnsi.brightRed("[FAIL] Cannot access Fabric Meta")); System.out.println(ZAnsi.brightRed("[FAIL] Нет доступа к Fabric Meta"));
System.out.println(ZAnsi.white("Error: ") + e.getMessage()); System.out.println(ZAnsi.white("Ошибка: ") + e.getMessage());
} }
} }
private void checkForge() { private void checkForge() {
System.out.println(ZAnsi.cyan("Checking Forge Maven access...")); System.out.println(ZAnsi.cyan("Проверка доступа к Forge Maven..."));
try { try {
HttpClient client = HttpClient.newBuilder() HttpClient client = HttpClient.newBuilder()
@@ -129,13 +129,13 @@ public class ServerCheckMenu {
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString()); HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() == 200) { if (response.statusCode() == 200) {
System.out.println(ZAnsi.brightGreen("[OK] Forge Maven is accessible")); System.out.println(ZAnsi.brightGreen("[OK] Forge Maven доступен"));
} else { } else {
System.out.println(ZAnsi.brightRed("[FAIL] Forge Maven returned code " + response.statusCode())); System.out.println(ZAnsi.brightRed("[FAIL] Forge Maven вернул код " + response.statusCode()));
} }
} catch (Exception e) { } catch (Exception e) {
System.out.println(ZAnsi.brightRed("[FAIL] Cannot access Forge Maven")); System.out.println(ZAnsi.brightRed("[FAIL] Нет доступа к Forge Maven"));
System.out.println(ZAnsi.white("Error: ") + e.getMessage()); System.out.println(ZAnsi.white("Ошибка: ") + e.getMessage());
} }
} }
} }
@@ -0,0 +1,68 @@
package me.sashegdev.zernmc.launcher.menu;
import me.sashegdev.zernmc.launcher.ui.ArrowMenu;
import me.sashegdev.zernmc.launcher.utils.Config;
import me.sashegdev.zernmc.launcher.utils.ConsoleUtils;
import me.sashegdev.zernmc.launcher.utils.Input;
import me.sashegdev.zernmc.launcher.utils.ZAnsi;
import java.io.IOException;
import java.util.List;
public class SettingsMenu {
public void show() throws IOException {
List<String> options = List.of(
"Настроить путь к Java",
"Настроить выделенную память (RAM)",
"Дополнительные JVM параметры",
"Назад в главное меню"
);
ArrowMenu menu = new ArrowMenu("Настройки лаунчера", options);
int choice = menu.show();
if (choice == -1 || choice == 3) return;
ConsoleUtils.clearScreen();
switch (choice) {
case 0 -> configureJava();
case 1 -> configureRam();
case 2 -> configureJvmArgs();
}
ConsoleUtils.pause();
}
private void configureJava() {
System.out.println(ZAnsi.cyan("Путь к Java:"));
System.out.println(" " + Config.getJreDir().toAbsolutePath());
System.out.println(ZAnsi.white("\nJava будет искаться автоматически в папке ~/.zernmc/jre/"));
System.out.println("Если нужно — положите туда свою версию Java.");
}
private void configureRam() {
System.out.println(ZAnsi.cyan("Настройка выделенной памяти"));
System.out.println(Config.getRamInfo());
int newRam = Input.readInt(
ZAnsi.white("\nВведите новое значение RAM в MB (или 0 для отмены): "),
0, 32768
);
if (newRam == 0) {
System.out.println(ZAnsi.yellow("Настройка отменена."));
return;
}
Config.setMaxMemory(newRam);
System.out.println(ZAnsi.brightGreen("Выделенная память изменена на " + newRam + " MB"));
}
private void configureJvmArgs() {
System.out.println(ZAnsi.yellow("Дополнительные JVM параметры"));
System.out.println("Пока в разработке.");
System.out.println("В будущем здесь будет список предустановленных оптимизаций.");
}
}
@@ -18,12 +18,12 @@ public class UpdateMenu {
public void show() throws IOException { public void show() throws IOException {
List<String> options = List.of( List<String> options = List.of(
"Check pack updates", "Проверить обновления сборки (модпака)",
"Check launcher updates", "Проверить обновления лаунчера",
"Back to main menu" "Назад в главное меню"
); );
ArrowMenu menu = new ArrowMenu("Update Check", options); ArrowMenu menu = new ArrowMenu("Проверка обновлений", options);
int choice = menu.show(); int choice = menu.show();
if (choice == -1 || choice == 2) return; if (choice == -1 || choice == 2) return;
@@ -34,7 +34,7 @@ public class UpdateMenu {
try { try {
checkPackUpdates(); checkPackUpdates();
} catch (Exception e) { } catch (Exception e) {
System.out.println(ZAnsi.brightRed("Error: " + e.getMessage())); System.out.println(ZAnsi.brightRed("Ошибка: " + e.getMessage()));
e.printStackTrace(); e.printStackTrace();
ConsoleUtils.pause(); ConsoleUtils.pause();
} }
@@ -44,7 +44,7 @@ public class UpdateMenu {
} }
private void checkPackUpdates() throws Exception { private void checkPackUpdates() throws Exception {
System.out.println(ZAnsi.cyan("Checking pack updates...")); System.out.println(ZAnsi.cyan("Проверка обновлений сборок..."));
List<Instance> instances = InstanceManager.getAllInstances(); List<Instance> instances = InstanceManager.getAllInstances();
List<Instance> serverInstances = instances.stream() List<Instance> serverInstances = instances.stream()
@@ -52,12 +52,12 @@ public class UpdateMenu {
.collect(Collectors.toList()); .collect(Collectors.toList());
if (serverInstances.isEmpty()) { if (serverInstances.isEmpty()) {
System.out.println(ZAnsi.yellow("No server-installed packs found.")); System.out.println(ZAnsi.yellow("Нет сборок, установленных с сервера."));
ConsoleUtils.pause(); ConsoleUtils.pause();
return; return;
} }
System.out.println(ZAnsi.cyan("\nChecking updates for server packs:\n")); System.out.println(ZAnsi.cyan("\nПроверка обновлений для серверных сборок:\n"));
boolean hasUpdates = false; boolean hasUpdates = false;
List<Instance> updatableInstances = new ArrayList<>(); List<Instance> updatableInstances = new ArrayList<>();
@@ -68,41 +68,42 @@ public class UpdateMenu {
try { try {
boolean hasUpdate = downloader.checkForUpdates(instance.getServerPackName()); boolean hasUpdate = downloader.checkForUpdates(instance.getServerPackName());
if (hasUpdate) { if (hasUpdate) {
System.out.println(ZAnsi.yellow(instance.getName() + " - Update available!")); System.out.println(ZAnsi.yellow(instance.getName() + " - Есть обновление!"));
updatableInstances.add(instance); updatableInstances.add(instance);
hasUpdates = true; hasUpdates = true;
} else { } else {
System.out.println(ZAnsi.green(instance.getName() + " - Up to date")); System.out.println(ZAnsi.green(instance.getName() + " - Актуальна"));
} }
} catch (Exception e) { } catch (Exception e) {
System.out.println(ZAnsi.red(instance.getName() + " - Check error: " + e.getMessage())); System.out.println(ZAnsi.red(instance.getName() + " - Ошибка проверки: " + e.getMessage()));
} }
} }
if (!hasUpdates) { if (!hasUpdates) {
System.out.println(ZAnsi.green("\nAll packs are up to date!")); System.out.println(ZAnsi.green("\nВсе сборки актуальны!"));
ConsoleUtils.pause(); ConsoleUtils.pause();
return; return;
} }
// Предлагаем обновить каждую сборку отдельно
for (Instance instance : updatableInstances) { for (Instance instance : updatableInstances) {
System.out.println(ZAnsi.brightYellow("\nUpdate pack '" + instance.getName() + "'?")); System.out.println(ZAnsi.brightYellow("\nОбновить сборку '" + instance.getName() + "'?"));
if (Input.confirm("Update")) { if (Input.confirm("Обновить")) {
System.out.println(ZAnsi.cyan("Updating " + instance.getName() + "...")); System.out.println(ZAnsi.cyan("Обновление " + instance.getName() + "..."));
PackDownloader downloader = new PackDownloader(instance); PackDownloader downloader = new PackDownloader(instance);
try { try {
boolean success = downloader.updatePack(instance.getServerPackName()); boolean success = downloader.updatePack(instance.getServerPackName());
if (success) { if (success) {
System.out.println(ZAnsi.brightGreen(instance.getName() + " updated")); System.out.println(ZAnsi.brightGreen(instance.getName() + " обновлен"));
} else { } else {
System.out.println(ZAnsi.brightRed(instance.getName() + " update failed")); System.out.println(ZAnsi.brightRed(instance.getName() + " не удалось обновить"));
} }
} catch (Exception e) { } catch (Exception e) {
System.out.println(ZAnsi.brightRed(instance.getName() + ": " + e.getMessage())); System.out.println(ZAnsi.brightRed(instance.getName() + ": " + e.getMessage()));
} }
} else { } else {
System.out.println(ZAnsi.yellow(" Skipped: " + instance.getName())); System.out.println(ZAnsi.yellow(" Пропущено: " + instance.getName()));
} }
} }
@@ -110,27 +111,28 @@ public class UpdateMenu {
} }
private void checkLauncherUpdates() { private void checkLauncherUpdates() {
System.out.println(ZAnsi.cyan("Checking launcher updates...")); System.out.println(ZAnsi.cyan("Проверка обновлений лаунчера..."));
try { try {
String json = ZHttpClient.getLauncherVersionInfo(); String json = ZHttpClient.getLauncherVersionInfo();
String serverVersion = extractVersion(json); String serverVersion = extractVersion(json);
String currentVersion = me.sashegdev.zernmc.launcher.utils.Version.getCurrentVersion(); String currentVersion = me.sashegdev.zernmc.launcher.utils.Version.getCurrentVersion();
System.out.println(ZAnsi.white("Current version: ") + currentVersion); System.out.println(ZAnsi.white("Текущая версия: ") + currentVersion);
System.out.println(ZAnsi.white("Server version: ") + serverVersion); System.out.println(ZAnsi.white("Версия на сервере: ") + serverVersion);
if (me.sashegdev.zernmc.launcher.utils.Version.isNewer(currentVersion, serverVersion)) { if (me.sashegdev.zernmc.launcher.utils.Version.isNewer(currentVersion, serverVersion)) {
System.out.println(ZAnsi.brightYellow("\nNew version available!")); System.out.println(ZAnsi.brightYellow("\nДоступна новая версия!"));
if (Input.confirm("Update launcher?")) { if (Input.confirm("Обновить лаунчер?")) {
System.out.println(ZAnsi.green("Launcher will be updated on next restart.")); // Обновление будет при следующем запуске
System.out.println(ZAnsi.green("Лаунчер будет обновлен при следующем запуске."));
} }
} else { } else {
System.out.println(ZAnsi.brightGreen("Launcher is up to date.")); System.out.println(ZAnsi.brightGreen("Лаунчер актуален."));
} }
} catch (Exception e) { } catch (Exception e) {
System.out.println(ZAnsi.yellow("Could not check launcher updates.")); System.out.println(ZAnsi.yellow("Не удалось проверить обновления лаунчера."));
System.out.println(ZAnsi.white("Error: ") + e.getMessage()); System.out.println(ZAnsi.white("Ошибка: ") + e.getMessage());
} }
ConsoleUtils.pause(); ConsoleUtils.pause();
@@ -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;
@@ -56,7 +52,7 @@ public class MinecraftLib {
boolean success = installer.install(minecraftVersion, loaderVersion); boolean success = installer.install(minecraftVersion, loaderVersion);
if (success) { if (success) {
// Save info to Instance // Сохраняем информацию в Instance
instance.setMinecraftVersion(minecraftVersion); instance.setMinecraftVersion(minecraftVersion);
instance.setLoaderType("fabric"); instance.setLoaderType("fabric");
instance.setLoaderVersion(loaderVersion); instance.setLoaderVersion(loaderVersion);
@@ -65,98 +61,70 @@ public class MinecraftLib {
} }
/** /**
* Full pack install (vanilla + loader + mods) * Полная установка сборки (vanilla + loader + моды)
* Stub - will be expanded * Пока заглушка будем расширять
*/ */
public boolean installPack(String packName, String minecraftVersion, String loaderType, String loaderVersion) throws Exception { public boolean installPack(String packName, String minecraftVersion, String loaderType, String loaderVersion) throws Exception {
System.out.println(ZAnsi.cyan("Starting full pack install: " + packName)); System.out.println(ZAnsi.cyan("Начинается полная установка сборки: " + packName));
// 1. Install Minecraft // 1. Устанавливаем Minecraft
boolean mcInstalled = installMinecraft(minecraftVersion); boolean mcInstalled = installMinecraft(minecraftVersion);
if (!mcInstalled) { if (!mcInstalled) {
System.out.println(ZAnsi.brightRed("Failed to install Minecraft " + minecraftVersion)); System.out.println(ZAnsi.brightRed("Не удалось установить Minecraft " + minecraftVersion));
return false; return false;
} }
// 2. Install loader // 2. Устанавливаем лоадер
if ("fabric".equalsIgnoreCase(loaderType)) { if ("fabric".equalsIgnoreCase(loaderType)) {
boolean fabricInstalled = installFabric(minecraftVersion, loaderVersion); boolean fabricInstalled = installFabric(minecraftVersion, loaderVersion);
if (!fabricInstalled) { if (!fabricInstalled) {
System.out.println(ZAnsi.brightRed("Failed to install Fabric")); System.out.println(ZAnsi.brightRed("Не удалось установить Fabric"));
return false; return false;
} }
} else if ("forge".equalsIgnoreCase(loaderType)) { } else if ("forge".equalsIgnoreCase(loaderType)) {
boolean forgeInstalled = installForge(minecraftVersion, loaderVersion); boolean forgeInstalled = installForge(minecraftVersion, loaderVersion);
if (!forgeInstalled) { if (!forgeInstalled) {
System.out.println(ZAnsi.brightRed("Failed to install Forge")); System.out.println(ZAnsi.brightRed("Не удалось установить Forge"));
return false; return false;
} }
} else if ("neoforge".equalsIgnoreCase(loaderType)) { } else if ("neoforge".equalsIgnoreCase(loaderType)) {
boolean neoforgeInstalled = installNeoForge(minecraftVersion, loaderVersion); boolean neoforgeInstalled = installNeoForge(minecraftVersion, loaderVersion);
if (!neoforgeInstalled) { if (!neoforgeInstalled) {
System.out.println(ZAnsi.brightRed("Failed to install NeoForge")); System.out.println(ZAnsi.brightRed("Не удалось установить NeoForge"));
return false; return false;
} }
} }
// 3. In the future: diff and mod download // 3. В будущем здесь будет diff и скачивание модов
System.out.println(ZAnsi.brightGreen("Basic pack install complete!")); System.out.println(ZAnsi.brightGreen("Базовая установка сборки завершена!"));
return true; return true;
} }
//Launch //Запуск
public void launch(LaunchOptions options) throws Exception { public void launch(LaunchOptions options) throws Exception {
System.out.println(ZAnsi.brightGreen("Launching pack: " + instance.getName())); System.out.println(ZAnsi.brightGreen("Запуск сборки: " + instance.getName()));
cleanupOldLoaders(); cleanupOldLoaders();
LaunchCommandBuilder builder = new LaunchCommandBuilder(instance); LaunchCommandBuilder builder = new LaunchCommandBuilder(instance);
List<String> command = builder.build(options); List<String> command = builder.build(options);
System.out.println(ZAnsi.cyan("Launch command (" + command.size() + " args):")); System.out.println(ZAnsi.cyan("Команда запуска (" + command.size() + " аргументов):"));
command.forEach(arg -> System.out.println(" " + arg)); command.forEach(arg -> System.out.println(" " + arg));
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("\nStarting 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("[Error reading output: " + 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("[Error reading stderr: " + 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 exited with code: " + exitCode)); System.out.println(ZAnsi.yellow("\nMinecraft завершился с кодом: " + exitCode));
} }
private void safeDeleteDirectory(Path dir) { private void safeDeleteDirectory(Path dir) {
@@ -202,9 +170,9 @@ public class MinecraftLib {
if (currentLoaderVer == null) return; if (currentLoaderVer == null) return;
System.out.println(ZAnsi.yellow("Cleaning old loader versions...")); System.out.println(ZAnsi.yellow("Выполняем очистку старых версий лоадера..."));
// Delete all old fabric-loader / forge // Удаляем все старые fabric-loader / forge
Path libraries = instance.getPath().resolve("libraries"); Path libraries = instance.getPath().resolve("libraries");
if ("fabric".equals(loaderType)) { if ("fabric".equals(loaderType)) {
@@ -36,18 +36,18 @@ public class PackDownloader {
} }
/** /**
* Get list of available packs from server * Получить список доступных паков с сервера
*/ */
public List<ServerPack> getAvailablePacks() throws Exception { public List<ServerPack> getAvailablePacks() throws Exception {
String accessToken = AuthManager.getAccessToken(); String accessToken = AuthManager.getAccessToken();
if (accessToken == null) { if (accessToken == null) {
throw new IOException("Not authenticated. Active pass required to view packs."); throw new IOException("Не авторизован. Требуется проходка для просмотра сборок.");
} }
if (!AuthManager.canViewPacks()) { if (!AuthManager.canViewPacks()) {
throw new IOException("Active pass required to view packs"); throw new IOException("Для просмотра сборок требуется активная проходка");
} }
// Use HttpURLConnection for GET with auth // Используем HttpURLConnection для GET с авторизацией
java.net.HttpURLConnection connection = null; java.net.HttpURLConnection connection = null;
try { try {
java.net.URL url = new java.net.URL(ZHttpClient.getBaseUrl() + "/packs"); java.net.URL url = new java.net.URL(ZHttpClient.getBaseUrl() + "/packs");
@@ -61,7 +61,7 @@ public class PackDownloader {
int responseCode = connection.getResponseCode(); int responseCode = connection.getResponseCode();
if (responseCode == 403) { if (responseCode == 403) {
throw new IOException("Active pass required to view packs"); throw new IOException("Для просмотра сборок требуется активная проходка");
} }
StringBuilder response = new StringBuilder(); StringBuilder response = new StringBuilder();
@@ -118,7 +118,7 @@ public class PackDownloader {
result.add(new ServerPack(name, version, minecraftVersion, loaderType, result.add(new ServerPack(name, version, minecraftVersion, loaderType,
loaderVersion, updatedAt, filesCount)); loaderVersion, updatedAt, filesCount));
} catch (Exception e) { } catch (Exception e) {
System.err.println("Error parsing pack: " + e.getMessage()); System.err.println("Ошибка парсинга пака: " + e.getMessage());
} }
} }
@@ -126,7 +126,7 @@ public class PackDownloader {
} }
/** /**
* Get pack manifest * Получить манифест пака
*/ */
public PackManifest getPackManifest(String packName) throws Exception { public PackManifest getPackManifest(String packName) throws Exception {
String response = ZHttpClient.get("/pack/" + packName); String response = ZHttpClient.get("/pack/" + packName);
@@ -134,18 +134,18 @@ public class PackDownloader {
} }
/** /**
* Install or update a pack from the server * Установить или обновить сборку с сервера
*/ */
public boolean installOrUpdatePack(String packName, ServerPack serverPack) throws Exception { public boolean installOrUpdatePack(String packName, ServerPack serverPack) throws Exception {
System.out.println(ZAnsi.cyan("Installing pack " + packName + " from server...")); System.out.println(ZAnsi.cyan("Установка сборки " + packName + " с сервера..."));
// 1. Get manifest // 1. Получаем манифест
PackManifest manifest = getPackManifest(packName); PackManifest manifest = getPackManifest(packName);
// 2. First install Minecraft + Loader via MinecraftLib // 2. Сначала устанавливаем Minecraft + Loader через MinecraftLib
MinecraftLib lib = new MinecraftLib(instance); MinecraftLib lib = new MinecraftLib(instance);
System.out.println(ZAnsi.cyan("Installing Minecraft " + manifest.getMinecraftVersion() + "...")); System.out.println(ZAnsi.cyan("Установка Minecraft " + manifest.getMinecraftVersion() + "..."));
boolean needsMinecraftInstall = instance.getMinecraftVersion() == null || boolean needsMinecraftInstall = instance.getMinecraftVersion() == null ||
!instance.getMinecraftVersion().equals(manifest.getMinecraftVersion()); !instance.getMinecraftVersion().equals(manifest.getMinecraftVersion());
@@ -154,40 +154,40 @@ public class PackDownloader {
if ("fabric".equalsIgnoreCase(manifest.getLoaderType())) { if ("fabric".equalsIgnoreCase(manifest.getLoaderType())) {
boolean success = lib.installFabric(manifest.getMinecraftVersion(), manifest.getLoaderVersion()); boolean success = lib.installFabric(manifest.getMinecraftVersion(), manifest.getLoaderVersion());
if (!success) { if (!success) {
System.err.println(ZAnsi.brightRed("Failed to install Fabric")); System.err.println(ZAnsi.brightRed("Не удалось установить Fabric"));
return false; return false;
} }
} else if ("neoforge".equalsIgnoreCase(manifest.getLoaderType())) { } else if ("neoforge".equalsIgnoreCase(manifest.getLoaderType())) {
boolean success = lib.installNeoForge(manifest.getMinecraftVersion(), manifest.getLoaderVersion()); boolean success = lib.installNeoForge(manifest.getMinecraftVersion(), manifest.getLoaderVersion());
if (!success) { if (!success) {
System.err.println(ZAnsi.brightRed("Failed to install NeoForge")); System.err.println(ZAnsi.brightRed("Не удалось установить NeoForge"));
return false; return false;
} }
} else if ("forge".equalsIgnoreCase(manifest.getLoaderType())) { } else if ("forge".equalsIgnoreCase(manifest.getLoaderType())) {
boolean success = lib.installForge(manifest.getMinecraftVersion(), manifest.getLoaderVersion()); boolean success = lib.installForge(manifest.getMinecraftVersion(), manifest.getLoaderVersion());
if (!success) { if (!success) {
System.err.println(ZAnsi.brightRed("Failed to install Forge")); System.err.println(ZAnsi.brightRed("Не удалось установить Forge"));
return false; return false;
} }
} else { } else {
boolean success = lib.installMinecraft(manifest.getMinecraftVersion()); boolean success = lib.installMinecraft(manifest.getMinecraftVersion());
if (!success) { if (!success) {
System.err.println(ZAnsi.brightRed("Failed to install Vanilla Minecraft")); System.err.println(ZAnsi.brightRed("Не удалось установить Vanilla Minecraft"));
return false; return false;
} }
} }
} else { } else {
System.out.println(ZAnsi.green("Minecraft already installed, skipping...")); System.out.println(ZAnsi.green("Minecraft уже установлен, пропускаем..."));
} }
// 3. Scan local files only if there are files to download // 3. Сканируем локальные файлы ТОЛЬКО если есть файлы для скачивания
Map<String, String> localFiles = scanLocalFiles(); Map<String, String> localFiles = scanLocalFiles();
// If pack has no files (vanilla/loader only), skip diff // Если в сборке нет файлов (только vanilla/loader), пропускаем diff
if (manifest.files == null || manifest.files.isEmpty()) { if (manifest.files == null || manifest.files.isEmpty()) {
System.out.println(ZAnsi.green("Pack contains no additional files")); System.out.println(ZAnsi.green("Сборка не содержит дополнительных файлов"));
// Update instance metadata // Обновляем метаданные инстанса
instance.setServerPack(true); instance.setServerPack(true);
instance.setServerPackName(packName); instance.setServerPackName(packName);
instance.setServerVersion(manifest.getVersion()); instance.setServerVersion(manifest.getVersion());
@@ -196,19 +196,19 @@ public class PackDownloader {
instance.setLoaderVersion(manifest.getLoaderVersion()); instance.setLoaderVersion(manifest.getLoaderVersion());
instance.setAssetIndex(manifest.getAssetIndex()); instance.setAssetIndex(manifest.getAssetIndex());
System.out.println(ZAnsi.brightGreen("Pack installed successfully!")); System.out.println(ZAnsi.brightGreen("Сборка успешно установлена!"));
return true; return true;
} }
// 4. Send diff request // 4. Отправляем diff запрос
System.out.println(ZAnsi.cyan("Checking pack files...")); System.out.println(ZAnsi.cyan("Проверка файлов сборки..."));
DiffResponse diff = getDiff(packName, localFiles); DiffResponse diff = getDiff(packName, localFiles);
// 5. Apply changes // 5. Применяем изменения
boolean success = applyDiff(diff, packName); boolean success = applyDiff(diff, packName);
if (success) { if (success) {
// 6. Update instance metadata // 6. Обновляем метаданные инстанса
instance.setServerPack(true); instance.setServerPack(true);
instance.setServerPackName(packName); instance.setServerPackName(packName);
instance.setServerVersion(manifest.getVersion()); instance.setServerVersion(manifest.getVersion());
@@ -217,14 +217,14 @@ public class PackDownloader {
instance.setLoaderVersion(manifest.getLoaderVersion()); instance.setLoaderVersion(manifest.getLoaderVersion());
instance.setAssetIndex(manifest.getAssetIndex()); instance.setAssetIndex(manifest.getAssetIndex());
System.out.println(ZAnsi.brightGreen("Pack installed successfully!")); System.out.println(ZAnsi.brightGreen("Сборка успешно установлена!"));
} }
return success; return success;
} }
/** /**
* Check for server pack updates * Проверить наличие обновлений для серверной сборки
*/ */
public boolean checkForUpdates(String packName) throws Exception { public boolean checkForUpdates(String packName) throws Exception {
if (!instance.isServerPack()) return false; if (!instance.isServerPack()) return false;
@@ -237,42 +237,42 @@ public class PackDownloader {
} }
/** /**
* Update an existing server pack * Обновить существующую серверную сборку
*/ */
public boolean updatePack(String packName) throws Exception { public boolean updatePack(String packName) throws Exception {
System.out.println(ZAnsi.cyan("Checking updates for " + instance.getName() + "...")); System.out.println(ZAnsi.cyan("Проверка обновлений для " + instance.getName() + "..."));
PackManifest manifest = getPackManifest(packName); PackManifest manifest = getPackManifest(packName);
int serverVersion = manifest.getVersion(); int serverVersion = manifest.getVersion();
if (serverVersion <= instance.getServerVersion()) { if (serverVersion <= instance.getServerVersion()) {
System.out.println(ZAnsi.green("Pack is already up to date (v" + instance.getServerVersion() + ")")); System.out.println(ZAnsi.green("Сборка уже актуальна (v" + instance.getServerVersion() + ")"));
return true; return true;
} }
System.out.println(ZAnsi.yellow("Update available: v" + instance.getServerVersion() + " → v" + serverVersion)); System.out.println(ZAnsi.yellow("Доступно обновление: v" + instance.getServerVersion() + " → v" + serverVersion));
// Scan local files // Сканируем локальные файлы
Map<String, String> localFiles = scanLocalFiles(); Map<String, String> localFiles = scanLocalFiles();
// Get diff // Получаем diff
DiffResponse diff = getDiff(packName, localFiles); DiffResponse diff = getDiff(packName, localFiles);
// Apply changes // Применяем изменения
boolean success = applyDiff(diff, packName); boolean success = applyDiff(diff, packName);
if (success) { if (success) {
instance.setServerVersion(serverVersion); instance.setServerVersion(serverVersion);
System.out.println(ZAnsi.brightGreen("Pack updated to v" + serverVersion)); System.out.println(ZAnsi.brightGreen("Сборка обновлена до v" + serverVersion));
} }
return success; return success;
} }
/** /**
* Scan local files and compute hashes * Сканирование локальных файлов и вычисление хешей
*/ */
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,23 +312,23 @@ public class PackDownloader {
} }
/** /**
* Send diff request to server * Отправить 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);
// Get auth token // Получаем токен авторизации
String accessToken = AuthManager.getAccessToken(); String accessToken = AuthManager.getAccessToken();
if (accessToken == null) { if (accessToken == null) {
throw new IOException("Not authenticated. Active pass required to download packs."); throw new IOException("Не авторизован. Требуется проходка для скачивания сборок.");
} }
if (!AuthManager.canDownloadPacks()) { if (!AuthManager.canDownloadPacks()) {
throw new IOException("Active pass required to download packs"); throw new IOException("Для скачивания сборок требуется активная проходка");
} }
String url = ZHttpClient.getBaseUrl() + "/pack/" + packName + "/diff"; String url = ZHttpClient.getBaseUrl() + "/pack/" + packName + "/diff";
// Use HttpURLConnection for full control // Используем HttpURLConnection для полного контроля
java.net.HttpURLConnection connection = null; java.net.HttpURLConnection connection = null;
try { try {
java.net.URL urlObj = new java.net.URL(url); java.net.URL urlObj = new java.net.URL(url);
@@ -342,7 +342,7 @@ public class PackDownloader {
connection.setConnectTimeout(30000); connection.setConnectTimeout(30000);
connection.setReadTimeout(30000); connection.setReadTimeout(30000);
// Send JSON // Отправляем JSON
try (java.io.OutputStream os = connection.getOutputStream()) { try (java.io.OutputStream os = connection.getOutputStream()) {
byte[] input = json.getBytes("UTF-8"); byte[] input = json.getBytes("UTF-8");
os.write(input, 0, input.length); os.write(input, 0, input.length);
@@ -351,7 +351,7 @@ public class PackDownloader {
int responseCode = connection.getResponseCode(); int responseCode = connection.getResponseCode();
// Read response // Читаем ответ
StringBuilder response = new StringBuilder(); StringBuilder response = new StringBuilder();
try (java.io.InputStream is = responseCode < 400 ? connection.getInputStream() : connection.getErrorStream(); try (java.io.InputStream is = responseCode < 400 ? connection.getInputStream() : connection.getErrorStream();
java.io.BufferedReader reader = new java.io.BufferedReader(new java.io.InputStreamReader(is, "UTF-8"))) { java.io.BufferedReader reader = new java.io.BufferedReader(new java.io.InputStreamReader(is, "UTF-8"))) {
@@ -364,7 +364,7 @@ public class PackDownloader {
String responseBody = response.toString(); String responseBody = response.toString();
if (responseCode == 403) { if (responseCode == 403) {
throw new IOException("Active pass required to download packs. Contact the administrator."); throw new IOException("Для скачивания сборок требуется активная проходка. Обратитесь к администратору.");
} }
if (responseCode != 200) { if (responseCode != 200) {
@@ -391,34 +391,34 @@ public class PackDownloader {
} }
/** /**
* Apply diff (download new files, delete old ones) * Применить diff (скачать новые файлы, удалить старые)
*/ */
private boolean applyDiff(DiffResponse diff, String packName) { private boolean applyDiff(DiffResponse diff, String packName) {
System.out.println(ZAnsi.cyan("\nApplying changes:")); System.out.println(ZAnsi.cyan("\nПрименение изменений:"));
System.out.println(" Download: " + diff.getToDownload().size() + " files"); System.out.println(" Загрузить: " + diff.getToDownload().size() + " файлов");
System.out.println(" Delete: " + diff.getToDelete().size() + " files"); System.out.println(" Удалить: " + diff.getToDelete().size() + " файлов");
// Create directories if needed // Создаем директории если нужно
try { try {
Files.createDirectories(instance.getPath()); Files.createDirectories(instance.getPath());
} catch (IOException e) { } catch (IOException e) {
System.err.println(ZAnsi.red("Error creating directories: " + e.getMessage())); System.err.println(ZAnsi.red("Ошибка создания директорий: " + e.getMessage()));
return false; return false;
} }
// Delete files // Удаляем файлы
for (String filePath : diff.getToDelete()) { for (String filePath : diff.getToDelete()) {
Path fullPath = instance.getPath().resolve(filePath); Path fullPath = instance.getPath().resolve(filePath);
try { try {
if (Files.deleteIfExists(fullPath)) { if (Files.deleteIfExists(fullPath)) {
System.out.println(ZAnsi.yellow(" Deleted: " + filePath)); System.out.println(ZAnsi.yellow(" Удален: " + filePath));
} }
} catch (IOException e) { } catch (IOException e) {
System.err.println(ZAnsi.red(" Error deleting " + filePath + ": " + e.getMessage())); System.err.println(ZAnsi.red(" Ошибка удаления " + filePath + ": " + e.getMessage()));
} }
} }
// Download files // Скачиваем файлы
AtomicInteger downloaded = new AtomicInteger(0); AtomicInteger downloaded = new AtomicInteger(0);
int total = diff.getToDownload().size(); int total = diff.getToDownload().size();
@@ -427,32 +427,32 @@ public class PackDownloader {
Path fullPath = instance.getPath().resolve(path); Path fullPath = instance.getPath().resolve(path);
try { try {
// Create directories // Создаем директории
Files.createDirectories(fullPath.getParent()); Files.createDirectories(fullPath.getParent());
// Download file // Скачиваем файл
downloadFile(file, fullPath); downloadFile(file, fullPath);
// Verify hash // Проверяем хеш
String actualHash = calculateHash(fullPath); String actualHash = calculateHash(fullPath);
if (!actualHash.equals(file.getHash())) { if (!actualHash.equals(file.getHash())) {
throw new IOException("Hash mismatch! Expected: " + file.getHash() + throw new IOException("Хеш не совпадает! Ожидался: " + file.getHash() +
", got: " + actualHash); ", получен: " + actualHash);
} }
downloaded.incrementAndGet(); downloaded.incrementAndGet();
if (total > 0) { if (total > 0) {
ProgressBar.show("Download", downloaded.get(), total, "files"); ProgressBar.show("Скачивание", downloaded.get(), total, "файлов");
} }
} catch (Exception e) { } catch (Exception e) {
System.err.println("\n" + ZAnsi.red(" Download error " + path + ": " + e.getMessage())); System.err.println("\n" + ZAnsi.red(" Ошибка скачивания " + path + ": " + e.getMessage()));
return false; return false;
} }
} }
if (total > 0) { if (total > 0) {
ProgressBar.finish("Download"); ProgressBar.finish("Скачивание");
} }
return true; return true;
@@ -26,7 +26,7 @@ public class FabricInstaller {
} }
public boolean install(String minecraftVersion, String loaderVersion) throws Exception { public boolean install(String minecraftVersion, String loaderVersion) throws Exception {
System.out.println(ZAnsi.cyan("Installing Fabric " + loaderVersion + " for Minecraft " + minecraftVersion)); System.out.println(ZAnsi.cyan("Установка Fabric " + loaderVersion + " для Minecraft " + minecraftVersion));
Path instancePath = instance.getPath(); Path instancePath = instance.getPath();
cleanOldFabricLoaders(); cleanOldFabricLoaders();
@@ -34,7 +34,7 @@ public class FabricInstaller {
VersionInstaller versionInstaller = new VersionInstaller(instancePath); VersionInstaller versionInstaller = new VersionInstaller(instancePath);
String assetIndex = versionInstaller.install(minecraftVersion); String assetIndex = versionInstaller.install(minecraftVersion);
System.out.println(ZAnsi.green("Asset index obtained: " + assetIndex)); System.out.println(ZAnsi.green("Asset index получен: " + assetIndex));
instance.setAssetIndex(assetIndex); instance.setAssetIndex(assetIndex);
instance.setMinecraftVersion(minecraftVersion); instance.setMinecraftVersion(minecraftVersion);
@@ -46,12 +46,12 @@ public class FabricInstaller {
Path installerJar = instancePath.resolve("fabric-installer.jar"); Path installerJar = instancePath.resolve("fabric-installer.jar");
if (!Files.exists(installerJar)) { if (!Files.exists(installerJar)) {
ProgressBar.show("Downloading Fabric Installer", 0, 100, "%"); ProgressBar.show("Скачивание Fabric Installer", 0, 100, "%");
downloadFileWithFallback(installerUrl, installerJar); downloadFileWithFallback(installerUrl, installerJar);
ProgressBar.finish("Fabric Installer downloaded"); ProgressBar.finish("Fabric Installer скачан");
} }
System.out.println(ZAnsi.cyan("Running Fabric Installer...")); System.out.println(ZAnsi.cyan("Запуск Fabric Installer..."));
String fabricVersionId = "fabric-loader-" + loaderVersion + "-" + minecraftVersion; String fabricVersionId = "fabric-loader-" + loaderVersion + "-" + minecraftVersion;
@@ -71,24 +71,24 @@ public class FabricInstaller {
int exitCode = process.waitFor(); int exitCode = process.waitFor();
if (exitCode != 0) { if (exitCode != 0) {
System.out.println(ZAnsi.brightRed("Fabric Installer failed (code " + exitCode + ")")); System.out.println(ZAnsi.brightRed("Fabric Installer завершился с ошибкой (код " + exitCode + ")"));
return false; return false;
} }
Path fabricVersionDir = instancePath.resolve("versions").resolve(fabricVersionId); Path fabricVersionDir = instancePath.resolve("versions").resolve(fabricVersionId);
if (Files.exists(fabricVersionDir)) { if (Files.exists(fabricVersionDir)) {
System.out.println(ZAnsi.brightGreen("Fabric installed successfully!")); System.out.println(ZAnsi.brightGreen("Fabric успешно установлен!"));
instance.setLoaderType("fabric"); instance.setLoaderType("fabric");
instance.setLoaderVersion(loaderVersion); instance.setLoaderVersion(loaderVersion);
instance.setFabricVersionId(fabricVersionId); instance.setFabricVersionId(fabricVersionId); // СОХРАНЯЕМ
ensureAssetIndexInFabricVersion(fabricVersionDir, assetIndex); ensureAssetIndexInFabricVersion(fabricVersionDir, assetIndex);
return true; return true;
} else { } else {
System.out.println(ZAnsi.brightRed("Fabric Installer ran, but version not found.")); System.out.println(ZAnsi.brightRed("Fabric Installer отработал, но версия не найдена."));
return false; return false;
} }
} }
@@ -97,7 +97,7 @@ public class FabricInstaller {
try { try {
ZHttpClient.downloadFile(url, target); ZHttpClient.downloadFile(url, target);
} catch (Exception e) { } catch (Exception e) {
System.out.println(ZAnsi.yellow("Failed to download Fabric Installer: " + e.getMessage())); System.out.println(ZAnsi.yellow("Не удалось скачать Fabric Installer: " + e.getMessage()));
throw e; throw e;
} }
} }
@@ -106,28 +106,28 @@ public class FabricInstaller {
Path versionJson = fabricVersionDir.resolve(fabricVersionDir.getFileName() + ".json"); Path versionJson = fabricVersionDir.resolve(fabricVersionDir.getFileName() + ".json");
if (!Files.exists(versionJson)) { if (!Files.exists(versionJson)) {
System.out.println(ZAnsi.yellow("Version JSON file not found: " + versionJson)); System.out.println(ZAnsi.yellow("JSON файл версии не найден: " + versionJson));
return; return;
} }
String content = Files.readString(versionJson); String content = Files.readString(versionJson);
// Check and fix asset index // Проверяем и исправляем asset index
if (!content.contains("\"assets\":\"" + assetIndex + "\"")) { if (!content.contains("\"assets\":\"" + assetIndex + "\"")) {
System.out.println(ZAnsi.yellow("Fixing asset index in version JSON file...")); System.out.println(ZAnsi.yellow("Исправляем asset index в JSON файле версии..."));
// Replace assets with correct value // Заменяем assets на правильное значение
content = content.replaceAll("\"assets\":\\s*\"[^\"]*\"", "\"assets\": \"" + assetIndex + "\""); content = content.replaceAll("\"assets\":\\s*\"[^\"]*\"", "\"assets\": \"" + assetIndex + "\"");
// Also check assetIndex // Также проверяем assetIndex
if (content.contains("\"assetIndex\"")) { if (content.contains("\"assetIndex\"")) {
content = content.replaceAll("\"assetIndex\":\\s*\"[^\"]*\"", "\"assetIndex\": \"" + assetIndex + "\""); content = content.replaceAll("\"assetIndex\":\\s*\"[^\"]*\"", "\"assetIndex\": \"" + assetIndex + "\"");
} }
Files.writeString(versionJson, content); Files.writeString(versionJson, content);
System.out.println(ZAnsi.green("Asset index fixed to: " + assetIndex)); System.out.println(ZAnsi.green("Asset index исправлен на: " + assetIndex));
} else { } else {
System.out.println(ZAnsi.green("Asset index in version JSON is correct: " + assetIndex)); System.out.println(ZAnsi.green("Asset index в JSON версии правильный: " + assetIndex));
} }
} }
@@ -135,7 +135,7 @@ public class FabricInstaller {
Path librariesDir = instance.getPath().resolve("libraries/net/fabricmc/fabric-loader"); Path librariesDir = instance.getPath().resolve("libraries/net/fabricmc/fabric-loader");
if (!Files.exists(librariesDir)) return; if (!Files.exists(librariesDir)) return;
System.out.println(ZAnsi.yellow("Cleaning old Fabric Loader versions...")); System.out.println(ZAnsi.yellow("Очистка старых версий Fabric Loader..."));
try (var stream = Files.walk(librariesDir)) { try (var stream = Files.walk(librariesDir)) {
stream.filter(Files::isDirectory) stream.filter(Files::isDirectory)
@@ -155,18 +155,18 @@ public class FabricInstaller {
private String getLatestInstallerVersion() throws Exception { private String getLatestInstallerVersion() throws Exception {
try { try {
// Use ZHttpClient with smart proxy // Используем ZHttpClient с умным прокси
String xml = ZHttpClient.downloadString("https://maven.fabricmc.net/net/fabricmc/fabric-installer/maven-metadata.xml"); String xml = ZHttpClient.downloadString("https://maven.fabricmc.net/net/fabricmc/fabric-installer/maven-metadata.xml");
int start = xml.indexOf("<latest>") + 8; int start = xml.indexOf("<latest>") + 8;
int end = xml.indexOf("</latest>", start); int end = xml.indexOf("</latest>", start);
return xml.substring(start, end).trim(); return xml.substring(start, end).trim();
} catch (Exception e) { } catch (Exception e) {
System.out.println(ZAnsi.yellow("Error getting Fabric Installer version: " + e.getMessage())); System.out.println(ZAnsi.yellow("Ошибка получения версии Fabric Installer: " + e.getMessage()));
throw new Exception("Failed to get Fabric Installer version", e); throw new Exception("Не удалось получить версию Fabric Installer", e);
} }
} }
// under refactor - keep // под рефактор оставить
private String downloadString(String url) throws Exception { private String downloadString(String url) throws Exception {
Exception lastException = null; Exception lastException = null;
@@ -186,7 +186,7 @@ public class FabricInstaller {
throw new IOException("HTTP " + resp.statusCode()); throw new IOException("HTTP " + resp.statusCode());
} catch (Exception e) { } catch (Exception e) {
lastException = e; lastException = e;
System.out.println(ZAnsi.yellow("Attempt " + attempt + " failed: " + e.getMessage())); System.out.println(ZAnsi.yellow("Попытка " + attempt + " не удалась: " + e.getMessage()));
if (attempt < 3) { if (attempt < 3) {
Thread.sleep(1000 * attempt); Thread.sleep(1000 * attempt);
} }
@@ -207,7 +207,7 @@ public class FabricInstaller {
HttpResponse.BodyHandlers.ofFile(target)); HttpResponse.BodyHandlers.ofFile(target));
if (response.statusCode() != 200) { if (response.statusCode() != 200) {
throw new IOException("HTTP " + response.statusCode() + " when downloading " + url); throw new IOException("HTTP " + response.statusCode() + " при скачивании " + url);
} }
} }
} }
@@ -26,59 +26,59 @@ public class ForgeInstaller {
} }
public boolean install(String mcVersion, String forgeVersion) throws Exception { public boolean install(String mcVersion, String forgeVersion) throws Exception {
System.out.println(ZAnsi.cyan("Installing Forge " + forgeVersion + " for Minecraft " + mcVersion)); System.out.println(ZAnsi.cyan("Установка Forge " + forgeVersion + " для Minecraft " + mcVersion));
// Step 1: Install vanilla and get real assetIndex // Шаг 1: Устанавливаем vanilla и получаем настоящий assetIndex
System.out.println(ZAnsi.cyan("Installing base Minecraft version " + mcVersion + "...")); System.out.println(ZAnsi.cyan("Установка базовой версии Minecraft " + mcVersion + "..."));
VersionInstaller vanillaInstaller = new VersionInstaller(instance.getPath()); VersionInstaller vanillaInstaller = new VersionInstaller(instance.getPath());
String assetIndex = vanillaInstaller.install(mcVersion); String assetIndex = vanillaInstaller.install(mcVersion);
if (assetIndex == null || assetIndex.isEmpty()) { if (assetIndex == null || assetIndex.isEmpty()) {
System.out.println(ZAnsi.brightRed("Failed to install base Minecraft version")); System.out.println(ZAnsi.brightRed("Не удалось установить базовую версию Minecraft"));
return false; return false;
} }
instance.setAssetIndex(assetIndex); instance.setAssetIndex(assetIndex);
// Step 2: Create launcher_profiles.json // Шаг 2: Создаём launcher_profiles.json
createLauncherProfile(); createLauncherProfile();
// Step 3: Download Forge Installer with progress bar // Шаг 3: Скачиваем Forge Installer с прогресс-баром
String installerUrl = "https://maven.minecraftforge.net/net/minecraftforge/forge/" String installerUrl = "https://maven.minecraftforge.net/net/minecraftforge/forge/"
+ mcVersion + "-" + forgeVersion + mcVersion + "-" + forgeVersion
+ "/forge-" + mcVersion + "-" + forgeVersion + "-installer.jar"; + "/forge-" + mcVersion + "-" + forgeVersion + "-installer.jar";
Path installerJar = instance.getPath().resolve("forge-installer.jar"); Path installerJar = instance.getPath().resolve("forge-installer.jar");
System.out.println(ZAnsi.cyan("Downloading Forge Installer...")); System.out.println(ZAnsi.cyan("Скачивание Forge Installer..."));
downloadFileWithProgress(installerUrl, installerJar); downloadFileWithProgress(installerUrl, installerJar);
// Step 4: Run Forge Installer and show its output // Шаг 4: Запускаем Forge Installer и показываем его вывод
System.out.println(ZAnsi.cyan("Running Forge Installer...")); System.out.println(ZAnsi.cyan("Запуск Forge Installer..."));
System.out.println(ZAnsi.yellow("This may take a few minutes. Please wait...\n")); System.out.println(ZAnsi.yellow("Это может занять несколько минут. Пожалуйста, подождите...\n"));
boolean success = runForgeInstaller(installerJar); boolean success = runForgeInstaller(installerJar);
// After successful Forge install, before saving metadata // После успешной установки Forge, но перед сохранением метаданных
if (success) { if (success) {
// Download missing libraries // Докачиваем пропущенные библиотеки
try { try {
downloadMissingLibraries(mcVersion, forgeVersion); downloadMissingLibraries(mcVersion, forgeVersion);
} catch (Exception e) { } catch (Exception e) {
System.out.println(ZAnsi.yellow("Warning: could not download some libraries: " + e.getMessage())); System.out.println(ZAnsi.yellow("Предупреждение: не удалось докачать некоторые библиотеки: " + e.getMessage()));
} }
System.out.println(ZAnsi.brightGreen("\nForge " + forgeVersion + " installed successfully!")); System.out.println(ZAnsi.brightGreen("\nForge " + forgeVersion + " успешно установлен!"));
instance.setMinecraftVersion(mcVersion); instance.setMinecraftVersion(mcVersion);
instance.setLoaderType("forge"); instance.setLoaderType("forge");
instance.setLoaderVersion(forgeVersion); instance.setLoaderVersion(forgeVersion);
// Clean up temporary installer file // Очищаем временный файл установщика
Files.deleteIfExists(installerJar); Files.deleteIfExists(installerJar);
return true; return true;
} else { } else {
System.out.println(ZAnsi.brightRed("\nError installing Forge!")); System.out.println(ZAnsi.brightRed("\nОшибка при установке Forge!"));
return false; return false;
} }
} }
@@ -94,7 +94,7 @@ public class ForgeInstaller {
} }
"""; """;
Files.writeString(profilePath, minimalProfile, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING); Files.writeString(profilePath, minimalProfile, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING);
System.out.println(ZAnsi.yellow("Created launcher_profiles.json")); System.out.println(ZAnsi.yellow("Создан launcher_profiles.json"));
} }
private void downloadFileWithProgress(String url, Path target) throws Exception { private void downloadFileWithProgress(String url, Path target) throws Exception {
@@ -132,10 +132,10 @@ public class ForgeInstaller {
lastPercent = percent; lastPercent = percent;
} }
} else { } else {
// If size unknown, show animation // Если размер неизвестен, показываем анимацию
char[] spinner = {'|', '/', '-', '\\'}; char[] spinner = {'|', '/', '-', '\\'};
int idx = (int) (totalRead / 1024) % 4; int idx = (int) (totalRead / 1024) % 4;
System.out.print("\rDownloading Forge Installer: " + ProgressBar.formatBytes(totalRead) + " " + spinner[idx]); System.out.print("\rСкачивание Forge Installer: " + ProgressBar.formatBytes(totalRead) + " " + spinner[idx]);
} }
} }
} }
@@ -144,12 +144,12 @@ public class ForgeInstaller {
} }
private boolean runForgeInstaller(Path installerJar) throws IOException, InterruptedException { private boolean runForgeInstaller(Path installerJar) throws IOException, InterruptedException {
// Try up to 3 times with different options // Пробуем до 3 раз с разными опциями
int maxRetries = 3; int maxRetries = 3;
int attempt = 1; int attempt = 1;
while (attempt <= maxRetries) { while (attempt <= maxRetries) {
System.out.println(ZAnsi.cyan("Attempt " + attempt + " of " + maxRetries)); System.out.println(ZAnsi.cyan("Попытка " + attempt + " из " + maxRetries));
ProcessBuilder pb = new ProcessBuilder( ProcessBuilder pb = new ProcessBuilder(
"java", "java",
@@ -158,7 +158,7 @@ public class ForgeInstaller {
"--installClient" "--installClient"
); );
// Add JVM args for increased timeouts // Добавляем JVM аргументы для увеличения таймаутов
pb.environment().put("JAVA_OPTS", "-Dhttp.connectionTimeout=60000 -Dhttp.socketTimeout=60000"); pb.environment().put("JAVA_OPTS", "-Dhttp.connectionTimeout=60000 -Dhttp.socketTimeout=60000");
pb.directory(instance.getPath().toFile()); pb.directory(instance.getPath().toFile());
@@ -166,7 +166,7 @@ public class ForgeInstaller {
Process process = pb.start(); Process process = pb.start();
// Read output in real time // Читаем вывод в реальном времени
StringBuilder output = new StringBuilder(); StringBuilder output = new StringBuilder();
boolean hasErrors = false; boolean hasErrors = false;
@@ -175,7 +175,7 @@ public class ForgeInstaller {
while ((line = reader.readLine()) != null) { while ((line = reader.readLine()) != null) {
output.append(line).append("\n"); output.append(line).append("\n");
// Format Forge Installer output // Форматируем вывод Forge Installer
if (line.contains("Downloading") || line.contains("Extracting")) { if (line.contains("Downloading") || line.contains("Extracting")) {
System.out.println(ZAnsi.blue(" -> " + line)); System.out.println(ZAnsi.blue(" -> " + line));
} else if (line.contains("SUCCESS") || line.contains("successfully")) { } else if (line.contains("SUCCESS") || line.contains("successfully")) {
@@ -195,17 +195,17 @@ public class ForgeInstaller {
int exitCode = process.waitFor(); int exitCode = process.waitFor();
// If successful or no download errors // Если успешно или нет ошибок скачивания
if (exitCode == 0 && !hasErrors) { if (exitCode == 0 && !hasErrors) {
return true; return true;
} }
// If error and not last attempt // Если ошибка и это не последняя попытка
if (attempt < maxRetries) { if (attempt < maxRetries) {
System.out.println(ZAnsi.yellow("Install error. Retrying in 5 seconds...")); System.out.println(ZAnsi.yellow("Ошибка при установке. Повторная попытка через 5 секунд..."));
Thread.sleep(5000); Thread.sleep(5000);
// Clean temp files before retry // Очищаем временные файлы перед повтором
Path librariesDir = instance.getPath().resolve("libraries"); Path librariesDir = instance.getPath().resolve("libraries");
if (Files.exists(librariesDir)) { if (Files.exists(librariesDir)) {
// Удаляем только частично скачанные библиотеки Forge // Удаляем только частично скачанные библиотеки Forge
@@ -218,15 +218,15 @@ public class ForgeInstaller {
} }
} }
} else { } else {
System.out.println(ZAnsi.brightRed("Forge Installer exited with error code: " + exitCode)); System.out.println(ZAnsi.brightRed("Forge Installer завершился с кодом ошибки: " + exitCode));
// Show possible solution // Показываем возможное решение
if (output.toString().contains("timed out")) { if (output.toString().contains("timed out")) {
System.out.println(ZAnsi.yellow("\nPossible solutions:")); System.out.println(ZAnsi.yellow("\nВозможные решения:"));
System.out.println(ZAnsi.yellow("1. Check your internet connection")); System.out.println(ZAnsi.yellow("1. Проверьте интернет-соединение"));
System.out.println(ZAnsi.yellow("2. Run the launcher as administrator")); System.out.println(ZAnsi.yellow("2. Запустите лаунчер от имени администратора"));
System.out.println(ZAnsi.yellow("3. Temporarily disable antivirus/firewall")); System.out.println(ZAnsi.yellow("3. Временно отключите антивирус/брандмауэр"));
System.out.println(ZAnsi.yellow("4. Try installing a different Forge version")); System.out.println(ZAnsi.yellow("4. Попробуйте установить другую версию Forge"));
} }
} }
@@ -237,9 +237,9 @@ public class ForgeInstaller {
} }
private void downloadMissingLibraries(String mcVersion, String forgeVersion) throws Exception { private void downloadMissingLibraries(String mcVersion, String forgeVersion) throws Exception {
System.out.println(ZAnsi.cyan("Checking and downloading missing libraries...")); System.out.println(ZAnsi.cyan("Проверка и докачка отсутствующих библиотек..."));
// List of problematic libraries and their alternate URLs // Список проблемных библиотек и их альтернативные URL
Map<String, String> alternativeUrls = new HashMap<>(); Map<String, String> alternativeUrls = new HashMap<>();
alternativeUrls.put("org/ow2/asm/asm/9.6/asm-9.6.jar", alternativeUrls.put("org/ow2/asm/asm/9.6/asm-9.6.jar",
"https://repo1.maven.org/maven2/org/ow2/asm/asm/9.6/asm-9.6.jar"); "https://repo1.maven.org/maven2/org/ow2/asm/asm/9.6/asm-9.6.jar");
@@ -252,7 +252,7 @@ public class ForgeInstaller {
Path target = librariesDir.resolve(entry.getKey()); Path target = librariesDir.resolve(entry.getKey());
if (!Files.exists(target)) { if (!Files.exists(target)) {
Files.createDirectories(target.getParent()); Files.createDirectories(target.getParent());
System.out.println(ZAnsi.yellow("Downloading: " + target.getFileName())); System.out.println(ZAnsi.yellow("Докачка: " + target.getFileName()));
for (int attempt = 1; attempt <= 3; attempt++) { for (int attempt = 1; attempt <= 3; attempt++) {
try { try {
@@ -27,14 +27,14 @@ public class NeoForgeInstaller {
} }
public boolean install(String mcVersion, String neoForgeVersion) throws Exception { public boolean install(String mcVersion, String neoForgeVersion) throws Exception {
System.out.println(ZAnsi.cyan("Installing NeoForge " + neoForgeVersion + " for Minecraft " + mcVersion)); System.out.println(ZAnsi.cyan("Установка NeoForge " + neoForgeVersion + " для Minecraft " + mcVersion));
System.out.println(ZAnsi.cyan("Installing base Minecraft version " + mcVersion + "...")); System.out.println(ZAnsi.cyan("Установка базовой версии Minecraft " + mcVersion + "..."));
VersionInstaller vanillaInstaller = new VersionInstaller(instance.getPath()); VersionInstaller vanillaInstaller = new VersionInstaller(instance.getPath());
String assetIndex = vanillaInstaller.install(mcVersion); String assetIndex = vanillaInstaller.install(mcVersion);
if (assetIndex == null || assetIndex.isEmpty()) { if (assetIndex == null || assetIndex.isEmpty()) {
System.out.println(ZAnsi.brightRed("Failed to install base Minecraft version")); System.out.println(ZAnsi.brightRed("Не удалось установить базовую версию Minecraft"));
return false; return false;
} }
@@ -52,11 +52,11 @@ public class NeoForgeInstaller {
Path installerJar = instance.getPath().resolve("neoforge-installer.jar"); Path installerJar = instance.getPath().resolve("neoforge-installer.jar");
System.out.println(ZAnsi.cyan("Downloading NeoForge Installer...")); System.out.println(ZAnsi.cyan("Скачивание NeoForge Installer..."));
downloadFileWithProgress(installerUrl, installerJar); downloadFileWithProgress(installerUrl, installerJar);
System.out.println(ZAnsi.cyan("Running NeoForge Installer...")); System.out.println(ZAnsi.cyan("Запуск NeoForge Installer..."));
System.out.println(ZAnsi.yellow("This may take a few minutes. Please wait...\n")); System.out.println(ZAnsi.yellow("Это может занять несколько минут. Пожалуйста, подождите...\n"));
boolean success = runNeoForgeInstaller(installerJar); boolean success = runNeoForgeInstaller(installerJar);
@@ -64,10 +64,10 @@ public class NeoForgeInstaller {
try { try {
downloadMissingLibraries(mcVersion, neoForgeVersion, mavenGroup, mavenArtifact); downloadMissingLibraries(mcVersion, neoForgeVersion, mavenGroup, mavenArtifact);
} catch (Exception e) { } catch (Exception e) {
System.out.println(ZAnsi.yellow("Warning: could not download some libraries: " + e.getMessage())); System.out.println(ZAnsi.yellow("Предупреждение: не удалось докачать некоторые библиотеки: " + e.getMessage()));
} }
System.out.println(ZAnsi.brightGreen("\nNeoForge " + neoForgeVersion + " installed successfully!")); System.out.println(ZAnsi.brightGreen("\nNeoForge " + neoForgeVersion + " успешно установлен!"));
instance.setMinecraftVersion(mcVersion); instance.setMinecraftVersion(mcVersion);
instance.setLoaderType("neoforge"); instance.setLoaderType("neoforge");
instance.setLoaderVersion(neoForgeVersion); instance.setLoaderVersion(neoForgeVersion);
@@ -75,7 +75,7 @@ public class NeoForgeInstaller {
Files.deleteIfExists(installerJar); Files.deleteIfExists(installerJar);
return true; return true;
} else { } else {
System.out.println(ZAnsi.brightRed("\nError installing NeoForge!")); System.out.println(ZAnsi.brightRed("\nОшибка при установке NeoForge!"));
return false; return false;
} }
} }
@@ -105,7 +105,7 @@ public class NeoForgeInstaller {
} }
"""; """;
Files.writeString(profilePath, minimalProfile, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING); Files.writeString(profilePath, minimalProfile, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING);
System.out.println(ZAnsi.yellow("Created launcher_profiles.json")); System.out.println(ZAnsi.yellow("Создан launcher_profiles.json"));
} }
private void downloadFileWithProgress(String url, Path target) throws Exception { private void downloadFileWithProgress(String url, Path target) throws Exception {
@@ -145,7 +145,7 @@ public class NeoForgeInstaller {
} else { } else {
char[] spinner = {'|', '/', '-', '\\'}; char[] spinner = {'|', '/', '-', '\\'};
int idx = (int) (totalRead / 1024) % 4; int idx = (int) (totalRead / 1024) % 4;
System.out.print("\rDownloading NeoForge Installer: " + ProgressBar.formatBytes(totalRead) + " " + spinner[idx]); System.out.print("\rСкачивание NeoForge Installer: " + ProgressBar.formatBytes(totalRead) + " " + spinner[idx]);
} }
} }
} }
@@ -158,7 +158,7 @@ public class NeoForgeInstaller {
int attempt = 1; int attempt = 1;
while (attempt <= maxRetries) { while (attempt <= maxRetries) {
System.out.println(ZAnsi.cyan("Attempt " + attempt + " of " + maxRetries)); System.out.println(ZAnsi.cyan("Попытка " + attempt + " из " + maxRetries));
ProcessBuilder pb = new ProcessBuilder( ProcessBuilder pb = new ProcessBuilder(
"java", "java",
@@ -205,7 +205,7 @@ public class NeoForgeInstaller {
} }
if (attempt < maxRetries) { if (attempt < maxRetries) {
System.out.println(ZAnsi.yellow("Install error. Retrying in 5 seconds...")); System.out.println(ZAnsi.yellow("Ошибка при установке. Повторная попытка через 5 секунд..."));
Thread.sleep(5000); Thread.sleep(5000);
Path librariesDir = instance.getPath().resolve("libraries"); Path librariesDir = instance.getPath().resolve("libraries");
@@ -219,14 +219,14 @@ public class NeoForgeInstaller {
} }
} }
} else { } else {
System.out.println(ZAnsi.brightRed("NeoForge Installer exited with error code: " + exitCode)); System.out.println(ZAnsi.brightRed("NeoForge Installer завершился с кодом ошибки: " + exitCode));
if (output.toString().contains("timed out")) { if (output.toString().contains("timed out")) {
System.out.println(ZAnsi.yellow("\nPossible solutions:")); System.out.println(ZAnsi.yellow("\nВозможные решения:"));
System.out.println(ZAnsi.yellow("1. Check your internet connection")); System.out.println(ZAnsi.yellow("1. Проверьте интернет-соединение"));
System.out.println(ZAnsi.yellow("2. Run the launcher as administrator")); System.out.println(ZAnsi.yellow("2. Запустите лаунчер от имени администратора"));
System.out.println(ZAnsi.yellow("3. Temporarily disable antivirus/firewall")); System.out.println(ZAnsi.yellow("3. Временно отключите антивирус/брандмауэр"));
System.out.println(ZAnsi.yellow("4. Try installing a different NeoForge version")); System.out.println(ZAnsi.yellow("4. Попробуйте установить другую версию NeoForge"));
} }
} }
@@ -237,7 +237,7 @@ public class NeoForgeInstaller {
} }
private void downloadMissingLibraries(String mcVersion, String neoForgeVersion, String mavenGroup, String mavenArtifact) throws Exception { private void downloadMissingLibraries(String mcVersion, String neoForgeVersion, String mavenGroup, String mavenArtifact) throws Exception {
System.out.println(ZAnsi.cyan("Checking and downloading missing libraries...")); System.out.println(ZAnsi.cyan("Проверка и докачка отсутствующих библиотек..."));
Map<String, String> alternativeUrls = new HashMap<>(); Map<String, String> alternativeUrls = new HashMap<>();
alternativeUrls.put("org/ow2/asm/asm/9.6/asm-9.6.jar", alternativeUrls.put("org/ow2/asm/asm/9.6/asm-9.6.jar",
@@ -253,7 +253,7 @@ public class NeoForgeInstaller {
Path target = librariesDir.resolve(entry.getKey()); Path target = librariesDir.resolve(entry.getKey());
if (!Files.exists(target)) { if (!Files.exists(target)) {
Files.createDirectories(target.getParent()); Files.createDirectories(target.getParent());
System.out.println(ZAnsi.yellow("Downloading: " + target.getFileName())); System.out.println(ZAnsi.yellow("Докачка: " + target.getFileName()));
for (int attempt = 1; attempt <= 3; attempt++) { for (int attempt = 1; attempt <= 3; attempt++) {
try { try {
@@ -261,7 +261,7 @@ public class NeoForgeInstaller {
break; break;
} catch (Exception e) { } catch (Exception e) {
if (attempt == 3) throw e; if (attempt == 3) throw e;
System.out.println(ZAnsi.yellow("Retry " + attempt + "/3...")); System.out.println(ZAnsi.yellow("Повторная попытка " + attempt + "/3..."));
Thread.sleep(2000); Thread.sleep(2000);
} }
} }
@@ -57,12 +57,12 @@ public class VersionInstaller {
} }
public String install(String versionId) throws Exception { public String install(String versionId) throws Exception {
System.out.println(ZAnsi.cyan("Full install of Minecraft " + versionId + "...")); System.out.println(ZAnsi.cyan("Полная установка Minecraft " + versionId + "..."));
Path versionDir = minecraftDir.resolve("versions").resolve(versionId); Path versionDir = minecraftDir.resolve("versions").resolve(versionId);
Files.createDirectories(versionDir); Files.createDirectories(versionDir);
String versionUrl = getVersionUrl(versionId); String versionUrl = getVersionUrl(versionId);
if (versionUrl == null) throw new Exception("Version " + versionId + " not found"); if (versionUrl == null) throw new Exception("Версия " + versionId + " не найдена");
String versionJson = downloadString(versionUrl); String versionJson = downloadString(versionUrl);
Files.writeString(versionDir.resolve(versionId + ".json"), versionJson); Files.writeString(versionDir.resolve(versionId + ".json"), versionJson);
@@ -73,8 +73,8 @@ public class VersionInstaller {
downloadFile(versionData.getJSONObject("downloads").getJSONObject("client").getString("url"), downloadFile(versionData.getJSONObject("downloads").getJSONObject("client").getString("url"),
versionDir.resolve(versionId + ".jar"), "client.jar"); versionDir.resolve(versionId + ".jar"), "client.jar");
// Libraries // Библиотеки
System.out.println(ZAnsi.cyan("Downloading libraries...")); System.out.println(ZAnsi.cyan("Скачивание библиотек..."));
downloadLibraries(versionData.getJSONArray("libraries")); downloadLibraries(versionData.getJSONArray("libraries"));
String assetIndex; String assetIndex;
@@ -86,12 +86,12 @@ public class VersionInstaller {
System.out.println(ZAnsi.cyan("Asset index: " + assetIndex)); System.out.println(ZAnsi.cyan("Asset index: " + assetIndex));
// Download assets using correct index // Скачиваем ассеты используя правильный индекс
System.out.println(ZAnsi.cyan("Downloading assets...")); System.out.println(ZAnsi.cyan("Скачивание ассетов..."));
downloadAssets(versionData, assetIndex); downloadAssets(versionData, assetIndex);
System.out.println(ZAnsi.brightGreen("\nMinecraft " + versionId + " fully installed!")); System.out.println(ZAnsi.brightGreen("\nMinecraft " + versionId + " полностью установлен!"));
return assetIndex; return assetIndex; // возвращаем "5" а не "1.20.1"
} }
private void downloadLibraries(JSONArray libraries) throws Exception { private void downloadLibraries(JSONArray libraries) throws Exception {
@@ -111,32 +111,32 @@ public class VersionInstaller {
try { try {
downloadFile(url, target, "library"); downloadFile(url, target, "library");
} catch (Exception e) { } catch (Exception e) {
// Skip problematic libraries // Пропускаем проблемные библиотеки
} }
} }
count++; count++;
ProgressBar.show("Libraries", count, total, "files"); ProgressBar.show("Библиотеки", count, total, "файлов");
} }
ProgressBar.finish("Libraries downloaded"); ProgressBar.finish("Библиотеки загружены");
} }
private void downloadAssets(JSONObject versionData, String assetIndex) throws Exception { private void downloadAssets(JSONObject versionData, String assetIndex) throws Exception {
// Find URL for asset index // Находим URL для asset index
JSONObject assetIndexInfo = versionData.getJSONObject("assetIndex"); JSONObject assetIndexInfo = versionData.getJSONObject("assetIndex");
String indexUrl = assetIndexInfo.getString("url"); String indexUrl = assetIndexInfo.getString("url");
Path indexesDir = minecraftDir.resolve("assets/indexes"); Path indexesDir = minecraftDir.resolve("assets/indexes");
Files.createDirectories(indexesDir); Files.createDirectories(indexesDir);
Path indexPath = indexesDir.resolve(assetIndex + ".json"); Path indexPath = indexesDir.resolve(assetIndex + ".json"); // используем assetIndex
System.out.println(ZAnsi.cyan("Downloading asset index (" + assetIndex + ")...")); System.out.println(ZAnsi.cyan("Скачивание asset index (" + assetIndex + ")..."));
downloadFile(indexUrl, indexPath, "asset index"); downloadFile(indexUrl, indexPath, "asset index");
String jsonContent = Files.readString(indexPath); String jsonContent = Files.readString(indexPath);
JSONObject root = new JSONObject(jsonContent); JSONObject root = new JSONObject(jsonContent);
JSONObject objects = root.getJSONObject("objects"); JSONObject objects = root.getJSONObject("objects");
System.out.println(ZAnsi.cyan("Downloading " + objects.length() + " asset objects (index: " + assetIndex + ")...")); System.out.println(ZAnsi.cyan("Скачивание " + objects.length() + " объектов ассетов (index: " + assetIndex + ")..."));
int total = objects.length(); int total = objects.length();
int[] success = {0}; int[] success = {0};
@@ -146,7 +146,7 @@ public class VersionInstaller {
for (String key : objects.keySet()) { for (String key : objects.keySet()) {
JSONObject asset = objects.getJSONObject(key); JSONObject asset = objects.getJSONObject(key);
String hash = asset.getString("hash"); String hash = asset.getString("hash"); // вот это правильный хеш!
String url = "https://resources.download.minecraft.net/" + hash.substring(0, 2) + "/" + hash; String url = "https://resources.download.minecraft.net/" + hash.substring(0, 2) + "/" + hash;
Path target = minecraftDir.resolve("assets/objects") Path target = minecraftDir.resolve("assets/objects")
@@ -162,7 +162,7 @@ public class VersionInstaller {
downloadFile(url, target, ""); downloadFile(url, target, "");
synchronized (this) { synchronized (this) {
success[0]++; success[0]++;
ProgressBar.show("Assets", success[0], total, "files"); ProgressBar.show("Ассеты", success[0], total, "файлов");
} }
downloaded = true; downloaded = true;
break; break;
@@ -171,7 +171,7 @@ public class VersionInstaller {
synchronized (this) { synchronized (this) {
failed[0]++; failed[0]++;
} }
System.err.println("Failed to download " + hash); System.err.println("Не удалось скачать " + hash);
} else { } else {
try { Thread.sleep(500 * attempt); } catch (InterruptedException ignored) {} try { Thread.sleep(500 * attempt); } catch (InterruptedException ignored) {}
} }
@@ -184,17 +184,17 @@ public class VersionInstaller {
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join(); CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
ProgressBar.finish("Assets downloaded (" + success[0] + " ok, " + failed[0] + " skipped)"); ProgressBar.finish("Ассеты загружены (" + success[0] + " успешно, " + failed[0] + " пропущено)");
if (failed[0] > 0) { if (failed[0] > 0) {
System.out.println(ZAnsi.yellow("Warning: " + failed[0] + " asset files could not be downloaded.")); System.out.println(ZAnsi.yellow("Предупреждение: " + failed[0] + " файлов ассетов не удалось скачать."));
System.out.println(ZAnsi.yellow("The game will launch, but some textures/sounds may be missing.")); System.out.println(ZAnsi.yellow("Игра запустится, но некоторые текстуры/звуки могут отсутствовать."));
} }
} }
public String getAssetIndexId(String versionId) throws Exception { public String getAssetIndexId(String versionId) throws Exception {
String versionUrl = getVersionUrl(versionId); String versionUrl = getVersionUrl(versionId);
if (versionUrl == null) throw new Exception("Version not found"); if (versionUrl == null) throw new Exception("Версия не найдена");
String versionJson = downloadString(versionUrl); String versionJson = downloadString(versionUrl);
JSONObject versionData = new JSONObject(versionJson); JSONObject versionData = new JSONObject(versionJson);
@@ -202,7 +202,7 @@ public class VersionInstaller {
if (versionData.has("assetIndex") && versionData.getJSONObject("assetIndex").has("id")) { if (versionData.has("assetIndex") && versionData.getJSONObject("assetIndex").has("id")) {
return versionData.getJSONObject("assetIndex").getString("id"); // "5" для 1.20.1 return versionData.getJSONObject("assetIndex").getString("id"); // "5" для 1.20.1
} }
return versionData.getString("assets"); // fallback (very old versions) return versionData.getString("assets"); // fallback (очень старые версии)
} }
private String getVersionUrl(String versionId) throws Exception { private String getVersionUrl(String versionId) throws Exception {
@@ -222,7 +222,7 @@ public class VersionInstaller {
private void downloadFile(String url, Path target, String label) throws Exception { private void downloadFile(String url, Path target, String label) throws Exception {
if (!label.isEmpty()) { if (!label.isEmpty()) {
ProgressBar.clearLine(); ProgressBar.clearLine();
System.out.println(ZAnsi.cyan("Downloading " + label + "...")); System.out.println(ZAnsi.cyan("Скачивание " + label + "..."));
} }
HttpRequest request = HttpRequest.newBuilder() HttpRequest request = HttpRequest.newBuilder()
@@ -233,8 +233,8 @@ public class VersionInstaller {
HttpResponse<Path> response = httpClient.send(request, HttpResponse.BodyHandlers.ofFile(target)); HttpResponse<Path> response = httpClient.send(request, HttpResponse.BodyHandlers.ofFile(target));
if (response.statusCode() != 200) { if (response.statusCode() != 200) {
if (label.isEmpty()) return; // for assets silently if (label.isEmpty()) return; // для ассетов молча
throw new IOException("HTTP " + response.statusCode() + " while downloading " + label); throw new IOException("HTTP " + response.statusCode() + " при скачивании " + label);
} }
if (!label.isEmpty()) { if (!label.isEmpty()) {
@@ -21,12 +21,11 @@ public class LaunchCommandBuilder {
} }
public List<String> build(LaunchOptions options) throws Exception { public List<String> build(LaunchOptions options) throws Exception {
System.out.println(ZAnsi.cyan("Generating launch command for " + instance.getName() + "...")); System.out.println(ZAnsi.cyan("Генерация команды запуска для " + instance.getName() + "..."));
List<String> command = new ArrayList<>(); List<String> command = new ArrayList<>();
String javaPath = options.getJavaPath() != null && !options.getJavaPath().isEmpty() String javaPath = "java";
? options.getJavaPath() : "java";
command.add(javaPath); command.add(javaPath);
command.addAll(getJvmArguments(options)); command.addAll(getJvmArguments(options));
@@ -37,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 empty, using 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());
@@ -84,15 +61,11 @@ public class LaunchCommandBuilder {
if (versionJson != null && Files.exists(versionJson)) { if (versionJson != null && Files.exists(versionJson)) {
String content = Files.readString(versionJson); String content = Files.readString(versionJson);
JSONObject json = new JSONObject(content); JSONObject json = new JSONObject(content);
System.out.println(ZAnsi.green("Found 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 not found for " + 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("Failed to load version.json: " + e.getMessage())); System.out.println(ZAnsi.yellow("Не удалось загрузить version.json: " + e.getMessage()));
} }
return null; return null;
} }
@@ -103,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(),
@@ -211,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";
} }
@@ -252,9 +189,9 @@ public class LaunchCommandBuilder {
String assetIndex = instance.getAssetIndex(); String assetIndex = instance.getAssetIndex();
if (assetIndex == null || assetIndex.isEmpty()) { if (assetIndex == null || assetIndex.isEmpty()) {
assetIndex = instance.getMinecraftVersion(); assetIndex = instance.getMinecraftVersion();
System.out.println(ZAnsi.yellow("Asset index not found, using version: " + assetIndex)); System.out.println(ZAnsi.yellow("Asset index не найден, использую версию: " + assetIndex));
} else { } else {
System.out.println(ZAnsi.green("Using asset index: " + assetIndex)); System.out.println(ZAnsi.green("Использую asset index: " + assetIndex));
} }
args.add(assetIndex); args.add(assetIndex);
args.add("--username"); args.add("--username");
@@ -321,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)) {
@@ -333,17 +268,14 @@ public class LaunchCommandBuilder {
if (Files.exists(fallbackPath)) { if (Files.exists(fallbackPath)) {
paths.add(fallbackPath.toAbsolutePath().toString()); paths.add(fallbackPath.toAbsolutePath().toString());
} else { } else {
System.out.println(ZAnsi.yellow(" Library not found: " + lib.name)); System.out.println(ZAnsi.yellow(" Библиотека не найдена: " + lib.name));
} }
} }
} }
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;
} }
} }
@@ -37,7 +37,5 @@ public class LaunchOptions {
public void setExtraJvmArgs(List<String> extraJvmArgs) { this.extraJvmArgs = extraJvmArgs; } public void setExtraJvmArgs(List<String> extraJvmArgs) { this.extraJvmArgs = extraJvmArgs; }
public int getWidth() { return width; } public int getWidth() { return width; }
public void setWidth(int width) { this.width = width; }
public int getHeight() { return height; } public int getHeight() { return height; }
public void setHeight(int height) { this.height = height; }
} }
@@ -6,8 +6,6 @@ import org.jline.terminal.TerminalBuilder;
import org.jline.utils.InfoCmp; import org.jline.utils.InfoCmp;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.util.List; import java.util.List;
public class ArrowMenu { public class ArrowMenu {
@@ -16,22 +14,16 @@ public class ArrowMenu {
private final List<String> options; private final List<String> options;
private int selected = 0; private int selected = 0;
private final Terminal terminal; private final Terminal terminal;
private final InputStream rawInput;
private static final int VISIBLE_ITEMS = 7; private static final int VISIBLE_ITEMS = 7; // сколько строк показывать в списке
public ArrowMenu(String title, List<String> options) throws IOException { public ArrowMenu(String title, List<String> options) throws IOException {
this.title = title; this.title = title;
this.options = options; this.options = options;
boolean isWindows = System.getProperty("os.name").toLowerCase().contains("windows");
System.setProperty("jline.terminal", isWindows ? "win" : "unsupported");
this.terminal = TerminalBuilder.builder() this.terminal = TerminalBuilder.builder()
.system(true) .system(true)
.jna(isWindows) .jna(true)
.jansi(true)
.encoding(StandardCharsets.UTF_8)
.build(); .build();
this.rawInput = terminal.input();
} }
public int show() throws IOException { public int show() throws IOException {
@@ -42,43 +34,33 @@ public class ArrowMenu {
try { try {
while (true) { while (true) {
printPagedMenu(); printPagedMenu();
int b = rawInput.read(); int key = terminal.reader().read();
if (b == -1) continue;
// w/W/k/K or ц (0xD1 0x86) = up if (key == 'w' || key == 'W' || key == 'ц' || key == 'Ц'
// s/S/j/J or ы (0xD1 0x8B) = down || key == 'k' || key == 'K' || key == 'л' || key == 'Л') { // Up / Arrow Up
if (b == 'w' || b == 'W' || b == 'k' || b == 'K') {
selected = (selected - 1 + options.size()) % options.size(); selected = (selected - 1 + options.size()) % options.size();
} }
else if (b == 's' || b == 'S' || b == 'j' || b == 'J') { else if (key == 's' || key == 'S' || key == 'ы' || key == 'Ы'
|| key == 'j' || key == 'J' || key == 'о' || key == 'О') { // Down / Arrow Down
selected = (selected + 1) % options.size(); selected = (selected + 1) % options.size();
} }
// ESC sequences: arrows + cyrillic start byte else if (key == 13 || key == 10) { // Enter
else if (b == 0x1B) {
int next = nonBlockingRead();
if (next == -1) {
return -1;
}
if (next == 0x5B) { // '['
int arrow = nonBlockingRead();
if (arrow == 0x41) { // Up
selected = (selected - 1 + options.size()) % options.size();
} else if (arrow == 0x42) { // Down
selected = (selected + 1) % options.size();
}
}
}
else if (b == 0xD1) {
int second = nonBlockingRead();
if (second == 0x86) { // ц
selected = (selected - 1 + options.size()) % options.size();
} else if (second == 0x8B) { // ы
selected = (selected + 1) % options.size();
}
}
else if (b == 13 || b == 10) {
return selected; return selected;
} }
else if (key == 27) { // Esc or arrow escape seq
int next = terminal.reader().read(50);
if (next == 91) { // '[' start of arrow escape sequence
int arrow = terminal.reader().read(50);
if (arrow == 65) { // 'A' Up arrow
selected = (selected - 1 + options.size()) % options.size();
} else if (arrow == 66) { // 'B' Down arrow
selected = (selected + 1) % options.size();
}
// else unknown escape seq, ignore
} else {
return -1; // genuine Esc
}
}
} }
} finally { } finally {
terminal.puts(InfoCmp.Capability.cursor_visible); terminal.puts(InfoCmp.Capability.cursor_visible);
@@ -86,31 +68,19 @@ public class ArrowMenu {
} }
} }
private int nonBlockingRead() throws IOException {
long startTime = System.currentTimeMillis();
while (System.currentTimeMillis() - startTime < 100) {
if (rawInput.available() > 0) {
return rawInput.read();
}
try {
Thread.sleep(2);
} catch (InterruptedException e) {
return -1;
}
}
return -1;
}
private void printPagedMenu() { private void printPagedMenu() {
StringBuilder sb = new StringBuilder(); StringBuilder sb = new StringBuilder();
sb.append("\033[H\033[2J"); sb.append("\033[H\033[2J");
// Заголовок (фиксированный)
sb.append(ZAnsi.header("=== ZernMC Launcher ===")).append("\n\n"); sb.append(ZAnsi.header("=== ZernMC Launcher ===")).append("\n\n");
sb.append(ZAnsi.yellow(title)).append("\n\n"); sb.append(ZAnsi.yellow(title)).append("\n\n");
// Вычисляем диапазон отображаемых элементов
int start = Math.max(0, selected - (VISIBLE_ITEMS / 2)); int start = Math.max(0, selected - (VISIBLE_ITEMS / 2));
int end = Math.min(options.size(), start + VISIBLE_ITEMS); int end = Math.min(options.size(), start + VISIBLE_ITEMS);
// Если в конце списка подтягиваем вверх
if (end - start < VISIBLE_ITEMS && start > 0) { if (end - start < VISIBLE_ITEMS && start > 0) {
start = Math.max(0, end - VISIBLE_ITEMS); start = Math.max(0, end - VISIBLE_ITEMS);
} }
@@ -124,10 +94,10 @@ public class ArrowMenu {
} }
} }
// Подсказка внизу (фиксированная)
sb.append("\n") sb.append("\n")
.append(ZAnsi.white("W/S or \u2191/\u2193 - navigate | Enter - select | Esc - back")); .append(ZAnsi.white("W/S (Ц/Ы) или ↑/↓ - перемещение | Enter - выбрать | Esc - назад"));
System.out.print(sb); System.out.print(sb);
System.out.flush();
} }
} }
@@ -14,13 +14,10 @@ public class Config {
private static final Properties props = new Properties(); private static final Properties props = new Properties();
private static int maxMemory = 4096; // Настройки
private static int maxMemory = 4096; // будет перезаписано умной логикой
private static String serverUrl = "http://87.120.187.36:1582"; private static String serverUrl = "http://87.120.187.36:1582";
private static String lastUsername = "Player"; private static String lastUsername = "Player";
private static int windowWidth = 1280;
private static int windowHeight = 720;
private static String extraJvmArgs = "";
private static String javaPath = "java";
static { static {
load(); load();
@@ -39,13 +36,9 @@ public class Config {
maxMemory = Integer.parseInt(props.getProperty("maxMemory", "4096")); maxMemory = Integer.parseInt(props.getProperty("maxMemory", "4096"));
serverUrl = props.getProperty("serverUrl", serverUrl); serverUrl = props.getProperty("serverUrl", serverUrl);
lastUsername = props.getProperty("lastUsername", lastUsername); lastUsername = props.getProperty("lastUsername", lastUsername);
windowWidth = Integer.parseInt(props.getProperty("windowWidth", "1280"));
windowHeight = Integer.parseInt(props.getProperty("windowHeight", "720"));
extraJvmArgs = props.getProperty("extraJvmArgs", "");
javaPath = props.getProperty("javaPath", "java");
} catch (Exception e) { } catch (Exception e) {
System.err.println(ZAnsi.brightRed("Failed to load config: ") + e.getMessage()); System.err.println(ZAnsi.brightRed("Не удалось загрузить конфиг: ") + e.getMessage());
} }
} }
@@ -54,34 +47,40 @@ public class Config {
props.setProperty("maxMemory", String.valueOf(maxMemory)); props.setProperty("maxMemory", String.valueOf(maxMemory));
props.setProperty("serverUrl", serverUrl); props.setProperty("serverUrl", serverUrl);
props.setProperty("lastUsername", lastUsername); props.setProperty("lastUsername", lastUsername);
props.setProperty("windowWidth", String.valueOf(windowWidth));
props.setProperty("windowHeight", String.valueOf(windowHeight));
props.setProperty("extraJvmArgs", extraJvmArgs);
props.setProperty("javaPath", javaPath);
try (var os = Files.newOutputStream(CONFIG_FILE)) { try (var os = Files.newOutputStream(CONFIG_FILE)) {
props.store(os, "ZernMC Launcher Configuration"); props.store(os, "ZernMC Launcher Configuration");
} }
} catch (IOException e) { } catch (IOException e) {
System.err.println(ZAnsi.brightRed("Failed to save config: ") + e.getMessage()); System.err.println(ZAnsi.brightRed("Не удалось сохранить конфиг: ") + e.getMessage());
} }
} }
/**
* Умная рекомендация RAM:
* - минимум 1.5 GB
* - рекомендуется totalRAM - 30%
* - максимум 70% от доступной RAM
*/
private static void applySmartRamRecommendation() { private static void applySmartRamRecommendation() {
long totalRamMB = Runtime.getRuntime().maxMemory() / (1024 * 1024); long totalRamMB = Runtime.getRuntime().maxMemory() / (1024 * 1024); // в MB
long recommended = (long) (totalRamMB * 0.70); // Рекомендуемое значение = total - 30%
long recommended = (long) (totalRamMB * 0.70); // 70% от доступной
recommended = Math.max(1536, recommended); // Ограничения
recommended = Math.min(recommended, totalRamMB - 1024); recommended = Math.max(1536, recommended); // минимум 1.5 GB
recommended = Math.min(recommended, totalRamMB - 1024); // оставляем минимум 1 GB системе
if (Math.abs(maxMemory - recommended) > 1024) { // Если текущее значение сильно отличается от рекомендуемого корректируем
if (Math.abs(maxMemory - recommended) > 1024) { // разница больше 1 GB
maxMemory = (int) recommended; maxMemory = (int) recommended;
save(); save(); // сохраняем умную рекомендацию
System.out.println(ZAnsi.cyan("Auto-recommended RAM: " + maxMemory + " MB")); System.out.println(ZAnsi.cyan("Автоматически рекомендовано RAM: " + maxMemory + " MB"));
} }
} }
// Getters & Setters
public static int getMaxMemory() { public static int getMaxMemory() {
return maxMemory; return maxMemory;
} }
@@ -95,6 +94,7 @@ public class Config {
} }
public static void setMaxMemory(int memory) { public static void setMaxMemory(int memory) {
// Защита от слишком маленьких/больших значений
if (memory < 1024) memory = 1536; if (memory < 1024) memory = 1536;
if (memory > 32768) memory = 32768; if (memory > 32768) memory = 32768;
@@ -127,44 +127,11 @@ public class Config {
return CONFIG_DIR; return CONFIG_DIR;
} }
public static int getWindowWidth() { /**
return windowWidth; * Полезная информация для пользователя
} */
public static void setWindowWidth(int width) {
windowWidth = Math.max(640, width);
save();
}
public static int getWindowHeight() {
return windowHeight;
}
public static void setWindowHeight(int height) {
windowHeight = Math.max(480, height);
save();
}
public static String getExtraJvmArgs() {
return extraJvmArgs;
}
public static void setExtraJvmArgs(String args) {
extraJvmArgs = args != null ? args : "";
save();
}
public static String getJavaPath() {
return javaPath;
}
public static void setJavaPath(String path) {
javaPath = path != null && !path.isEmpty() ? path : "java";
save();
}
public static String getRamInfo() { public static String getRamInfo() {
long totalMB = Runtime.getRuntime().maxMemory() / (1024 * 1024); long totalMB = Runtime.getRuntime().maxMemory() / (1024 * 1024);
return "Available RAM: " + totalMB + " MB | Recommended: " + maxMemory + " MB"; return "Доступно RAM: " + totalMB + " MB | Рекомендуется: " + maxMemory + " MB";
} }
} }
@@ -10,9 +10,10 @@ public class ConsoleUtils {
} }
public static void pause() { public static void pause() {
System.out.print(ZAnsi.white("\nPress Enter to continue...")); System.out.print(ZAnsi.white("\nНажмите Enter для продолжения..."));
try { try {
System.in.read(); System.in.read();
// Очищаем буфер ввода
while (System.in.available() > 0) { while (System.in.available() > 0) {
System.in.read(); System.in.read();
} }
@@ -3,20 +3,23 @@ package me.sashegdev.zernmc.launcher.utils;
import me.sashegdev.zernmc.launcher.ui.ArrowMenu; import me.sashegdev.zernmc.launcher.ui.ArrowMenu;
import java.io.IOException; import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.List; import java.util.List;
import java.util.Scanner; import java.util.Scanner;
/**
* Улучшенный Input с поддержкой кириллицы и confirm через ArrowMenu
*/
public class Input { public class Input {
private static final Scanner scanner = new Scanner(System.in, StandardCharsets.UTF_8); // Используем UTF-8 явно это помогает на Windows
private static final Scanner scanner = new Scanner(System.in, "UTF-8");
public static String readLine() { public static String readLine() {
return scanner.nextLine().trim(); return scanner.nextLine().trim();
} }
public static String readLine(String prompt) { public static String readLine(String prompt) {
flushInput(); flushInput(); // Очищаем буфер
System.out.print(prompt); System.out.print(prompt);
return scanner.nextLine().trim(); return scanner.nextLine().trim();
} }
@@ -27,7 +30,7 @@ public class Input {
System.out.print(prompt); System.out.print(prompt);
return Integer.parseInt(scanner.nextLine().trim()); return Integer.parseInt(scanner.nextLine().trim());
} catch (NumberFormatException e) { } catch (NumberFormatException e) {
System.out.println(ZAnsi.brightRed("Invalid number. Try again.")); System.out.println(ZAnsi.brightRed("Некорректное число. Попробуйте ещё раз."));
} }
} }
} }
@@ -38,41 +41,57 @@ public class Input {
if (value >= min && value <= max) { if (value >= min && value <= max) {
return value; return value;
} }
System.out.println(ZAnsi.brightRed("Value must be between " + min + " and " + max + ".")); System.out.println(ZAnsi.brightRed("Значение должно быть от " + min + " до " + max + "."));
} }
} }
/**
* Новый confirm через ArrowMenu
* @throws IOException
*/
public static boolean confirm(String question) throws IOException { public static boolean confirm(String question) throws IOException {
ConsoleUtils.clearScreen(); ConsoleUtils.clearScreen(); // опционально, можно убрать
List<String> options = List.of( List<String> options = List.of(
"Yes", "Да",
"No" "Нет"
); );
ArrowMenu menu = new ArrowMenu(question, options); ArrowMenu menu = new ArrowMenu(question, options);
int choice = menu.show(); int choice = menu.show();
return choice == 0; return choice == 0; // 0 = "Да"
} }
/**
* Альтернативный confirm без очистки экрана
* @throws IOException
*/
public static boolean confirmInline(String question) throws IOException { public static boolean confirmInline(String question) throws IOException {
List<String> options = List.of("Yes", "No"); List<String> options = List.of("Да", "Нет");
ArrowMenu menu = new ArrowMenu(question, options); ArrowMenu menu = new ArrowMenu(question, options);
int choice = menu.show(); int choice = menu.show();
return choice == 0; return choice == 0;
} }
/**
* Закрытие сканнера (вызывать при выходе из программы, если нужно)
*/
public static void close() { public static void close() {
scanner.close(); scanner.close();
} }
/**
* Очищает буфер ввода от оставшихся символов
*/
public static void flushInput() { public static void flushInput() {
try { try {
while (System.in.available() > 0) { while (System.in.available() > 0) {
System.in.read(); System.in.read();
} }
} catch (IOException e) { } catch (IOException e) {
// Игнорируем
} }
} }
} }
@@ -7,19 +7,10 @@ public class ProgressBar {
private static final int BAR_LENGTH = 40; private static final int BAR_LENGTH = 40;
private static final DecimalFormat DF = new DecimalFormat("#.##"); private static final DecimalFormat DF = new DecimalFormat("#.##");
private static String currentLabel = ""; /**
private static long currentTotal = 0; * Прогресс по количеству файлов (для библиотек и общего прогресса)
*/
public static void show(String label, long current, long total, String unit) { public static void show(String label, long current, long total, String unit) {
currentLabel = label;
currentTotal = total;
try {
Class<?> jfxClass = Class.forName("me.sashegdev.zernmc.launcher.ui.jfx.JFXLauncher");
java.lang.reflect.Method setProgress = jfxClass.getMethod("setInstallProgress", String.class, int.class, int.class);
setProgress.invoke(null, label, (int) current, (int) total);
} catch (Exception ignored) {}
if (total <= 0) { if (total <= 0) {
System.out.print("\r" + ZAnsi.cyan(label) + " ..."); System.out.print("\r" + ZAnsi.cyan(label) + " ...");
return; return;
@@ -36,16 +27,10 @@ public class ProgressBar {
System.out.flush(); System.out.flush();
} }
/**
* Прогресс по байтам для одного файла (реальный прогресс)
*/
public static void showDownload(String label, long downloaded, long totalBytes) { public static void showDownload(String label, long downloaded, long totalBytes) {
currentLabel = label;
currentTotal = totalBytes;
try {
Class<?> jfxClass = Class.forName("me.sashegdev.zernmc.launcher.ui.jfx.JFXLauncher");
java.lang.reflect.Method setProgress = jfxClass.getMethod("setInstallProgress", String.class, int.class, int.class);
setProgress.invoke(null, label + " " + formatBytes(downloaded) + "/" + formatBytes(totalBytes), (int) downloaded, (int) totalBytes);
} catch (Exception ignored) {}
if (totalBytes <= 0) { if (totalBytes <= 0) {
System.out.print("\r" + ZAnsi.cyan(label) + " ..."); System.out.print("\r" + ZAnsi.cyan(label) + " ...");
return; return;
@@ -68,16 +53,8 @@ public class ProgressBar {
} }
public static void showAnimated(String label, long current, long total, String unit) { public static void showAnimated(String label, long current, long total, String unit) {
currentLabel = label;
currentTotal = total;
try {
Class<?> jfxClass = Class.forName("me.sashegdev.zernmc.launcher.ui.jfx.JFXLauncher");
java.lang.reflect.Method setProgress = jfxClass.getMethod("setInstallProgress", String.class, int.class, int.class);
setProgress.invoke(null, label, (int) current, (int) (total > 0 ? total : 100));
} catch (Exception ignored) {}
if (total <= 0) { if (total <= 0) {
// Анимация для неизвестного размера
char[] spinner = {'|', '/', '-', '\\'}; char[] spinner = {'|', '/', '-', '\\'};
int idx = (int) (current / 1024) % 4; int idx = (int) (current / 1024) % 4;
System.out.print("\r" + label + " [" + spinner[idx] + "] " + formatBytes(current)); System.out.print("\r" + label + " [" + spinner[idx] + "] " + formatBytes(current));
@@ -87,13 +64,7 @@ public class ProgressBar {
} }
public static void finish(String message) { public static void finish(String message) {
try { System.out.println("\r" + ZAnsi.brightGreen(message + " завершено ✓"));
Class<?> jfxClass = Class.forName("me.sashegdev.zernmc.launcher.ui.jfx.JFXLauncher");
java.lang.reflect.Method setInProgress = jfxClass.getMethod("setInstallInProgress", boolean.class);
setInProgress.invoke(null, false);
} catch (Exception ignored) {}
System.out.println("\r" + ZAnsi.brightGreen(message + " done ✓"));
System.out.flush(); System.out.flush();
} }
@@ -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;
}
}
} }
@@ -29,9 +29,14 @@ public class ZHttpClient {
private static String BASE_URL = "http://87.120.187.36:1582"; private static String BASE_URL = "http://87.120.187.36:1582";
// Глобальный прокси режим (для обратной совместимости)
private static final AtomicBoolean useProxyMode = new AtomicBoolean(false); private static final AtomicBoolean useProxyMode = new AtomicBoolean(false);
private static final AtomicBoolean proxyTested = new AtomicBoolean(false); private static final AtomicBoolean proxyTested = new AtomicBoolean(false);
/**
* Переопределить URL сервера (для тестов).
* Внимание: не потокобезопасно, использовать только в тестах.
*/
public static void setBaseUrl(String url) { public static void setBaseUrl(String url) {
BASE_URL = url; BASE_URL = url;
} }
@@ -40,6 +45,7 @@ public class ZHttpClient {
return BASE_URL; return BASE_URL;
} }
// Умное проксирование по сервисам
public enum ServiceType { public enum ServiceType {
ZERN_SERVER("http://87.120.187.36:1582", true), ZERN_SERVER("http://87.120.187.36:1582", true),
FABRIC_META("https://meta.fabricmc.net", false), FABRIC_META("https://meta.fabricmc.net", false),
@@ -63,15 +69,17 @@ public class ZHttpClient {
public boolean isAlwaysDirect() { return alwaysDirect; } public boolean isAlwaysDirect() { return alwaysDirect; }
} }
// Статусы сервисов
private static final Map<ServiceType, Boolean> serviceProxyMode = new ConcurrentHashMap<>(); private static final Map<ServiceType, Boolean> serviceProxyMode = new ConcurrentHashMap<>();
private static final Map<ServiceType, Integer> serviceFailCount = new ConcurrentHashMap<>(); private static final Map<ServiceType, Integer> serviceFailCount = new ConcurrentHashMap<>();
private static final Map<ServiceType, Long> serviceLastCheckTime = new ConcurrentHashMap<>(); private static final Map<ServiceType, Long> serviceLastCheckTime = new ConcurrentHashMap<>();
private static final Map<ServiceType, Boolean> serviceHealthy = new ConcurrentHashMap<>(); private static final Map<ServiceType, Boolean> serviceHealthy = new ConcurrentHashMap<>();
private static final int MAX_FAILS_BEFORE_PROXY = 2; private static final int MAX_FAILS_BEFORE_PROXY = 2;
private static final long HEALTH_CHECK_INTERVAL_MS = 60000; private static final long HEALTH_CHECK_INTERVAL_MS = 60000; // 1 минута
private static final long CHECK_TIMEOUT_MS = 7000; private static final long CHECK_TIMEOUT_MS = 7000; // 7 секунд на проверку
// Статистика
private static int directSuccessCount = 0; private static int directSuccessCount = 0;
private static int proxySuccessCount = 0; private static int proxySuccessCount = 0;
private static int directFailCount = 0; private static int directFailCount = 0;
@@ -84,13 +92,14 @@ public class ZHttpClient {
} }
} }
/**
* Вызывать один раз при запуске лаунчера
*/
public static void checkAllServicesOnStartup() { public static void checkAllServicesOnStartup() {
checkAllServicesOnStartup(false);
}
public static void checkAllServicesOnStartup(boolean verbose) {
if (proxyTested.get()) return; if (proxyTested.get()) return;
System.out.println(ZAnsi.cyan("Проверка доступности сервисов..."));
List<ServiceType> servicesToCheck = List.of( List<ServiceType> servicesToCheck = List.of(
ServiceType.ZERN_SERVER, ServiceType.ZERN_SERVER,
ServiceType.GOOGLE, ServiceType.GOOGLE,
@@ -107,20 +116,14 @@ public class ZHttpClient {
serviceHealthy.put(service, isHealthy); serviceHealthy.put(service, isHealthy);
if (service.isAlwaysDirect()) { if (service.isAlwaysDirect()) {
if (verbose) {
System.out.println(isHealthy ? System.out.println(isHealthy ?
ZAnsi.green(" " + service.name() + " - OK") : ZAnsi.green(" " + service.name() + " - OK") :
ZAnsi.red(" " + service.name() + " - NOT ACCESSIBLE (critical!)")); ZAnsi.red(" " + service.name() + " - НЕ ДОСТУПЕН (критично!)"));
}
} else { } else {
if (isHealthy) { if (isHealthy) {
if (verbose) { System.out.println(ZAnsi.green(" " + service.name() + " - прямое подключение работает"));
System.out.println(ZAnsi.green(" " + service.name() + " - direct connection works"));
}
} else { } else {
if (verbose) { System.out.println(ZAnsi.yellow(" " + service.name() + " - НЕ ДОСТУПЕН, будет использован прокси"));
System.out.println(ZAnsi.yellow(" " + service.name() + " - NOT ACCESSIBLE, proxy will be used"));
}
serviceProxyMode.put(service, true); serviceProxyMode.put(service, true);
serviceFailCount.put(service, MAX_FAILS_BEFORE_PROXY); serviceFailCount.put(service, MAX_FAILS_BEFORE_PROXY);
} }
@@ -128,31 +131,30 @@ public class ZHttpClient {
} }
if (!serviceHealthy.get(ServiceType.ZERN_SERVER)) { if (!serviceHealthy.get(ServiceType.ZERN_SERVER)) {
if (verbose) { System.out.println(ZAnsi.brightRed("Критическая ошибка: Zern сервер недоступен!"));
System.out.println(ZAnsi.brightRed("Critical error: Zern server is unreachable!"));
}
} }
proxyTested.set(true); proxyTested.set(true);
if (verbose) {
startHealthCheckThread(); startHealthCheckThread();
printStats(); printStats();
} }
}
/**
* Принудительная проверка Mojang-сервисов (рекомендуется вызывать перед установкой сборки)
*/
public static void forceCheckMojangServices() { public static void forceCheckMojangServices() {
System.out.println(ZAnsi.cyan("Forcing Mojang services check...")); System.out.println(ZAnsi.cyan("Принудительная проверка Mojang сервисов..."));
for (ServiceType service : List.of(ServiceType.MOJANG_META, ServiceType.MOJANG_RESOURCES)) { for (ServiceType service : List.of(ServiceType.MOJANG_META, ServiceType.MOJANG_RESOURCES)) {
boolean healthy = checkServiceHealth(service); boolean healthy = checkServiceHealth(service);
serviceHealthy.put(service, healthy); serviceHealthy.put(service, healthy);
if (healthy) { if (healthy) {
System.out.println(ZAnsi.green(" " + service.name() + " accessible directly")); System.out.println(ZAnsi.green(" " + service.name() + " доступен напрямую"));
serviceProxyMode.put(service, false); serviceProxyMode.put(service, false);
serviceFailCount.put(service, 0); serviceFailCount.put(service, 0);
} else { } else {
System.out.println(ZAnsi.yellow(" " + service.name() + " not accessible -> proxy mode activated")); System.out.println(ZAnsi.yellow(" " + service.name() + " недоступен → прокси режим активирован"));
serviceProxyMode.put(service, true); serviceProxyMode.put(service, true);
serviceFailCount.put(service, MAX_FAILS_BEFORE_PROXY); serviceFailCount.put(service, MAX_FAILS_BEFORE_PROXY);
} }
@@ -163,6 +165,9 @@ public class ZHttpClient {
return checkDirectConnection(service.getBaseUrl()); return checkDirectConnection(service.getBaseUrl());
} }
/**
* Улучшенная проверка прямого подключения
*/
private static boolean checkDirectConnection(String baseUrl) { private static boolean checkDirectConnection(String baseUrl) {
String testUrl = baseUrl; String testUrl = baseUrl;
@@ -182,7 +187,7 @@ public class ZHttpClient {
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString()); HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
int code = response.statusCode(); int code = response.statusCode();
return code == 200 || code == 404; return code == 200 || code == 404; // 404 для ресурсов нормально
} catch (Exception e) { } catch (Exception e) {
return false; return false;
} }
@@ -213,7 +218,7 @@ public class ZHttpClient {
if (isHealthy && serviceProxyMode.get(service)) { if (isHealthy && serviceProxyMode.get(service)) {
serviceProxyMode.put(service, false); serviceProxyMode.put(service, false);
serviceFailCount.put(service, 0); serviceFailCount.put(service, 0);
System.out.println(ZAnsi.green("[NET] " + service.name() + " restored, switched to direct connection")); System.out.println(ZAnsi.green("[NET] " + service.name() + " восстановлен, переключен на прямое подключение"));
} else if (!isHealthy && !serviceProxyMode.get(service)) { } else if (!isHealthy && !serviceProxyMode.get(service)) {
int fails = serviceFailCount.getOrDefault(service, 0) + 1; int fails = serviceFailCount.getOrDefault(service, 0) + 1;
serviceFailCount.put(service, fails); serviceFailCount.put(service, fails);
@@ -221,7 +226,7 @@ public class ZHttpClient {
if (fails >= MAX_FAILS_BEFORE_PROXY) { if (fails >= MAX_FAILS_BEFORE_PROXY) {
serviceProxyMode.put(service, true); serviceProxyMode.put(service, true);
System.out.println(ZAnsi.yellow("[NET] " + service.name() + " unavailable, proxy mode enabled")); System.out.println(ZAnsi.yellow("[NET] " + service.name() + " недоступен, включен прокси режим"));
} }
} }
} }
@@ -272,11 +277,14 @@ public class ZHttpClient {
if (fails >= MAX_FAILS_BEFORE_PROXY && !serviceProxyMode.get(service)) { if (fails >= MAX_FAILS_BEFORE_PROXY && !serviceProxyMode.get(service)) {
serviceProxyMode.put(service, true); serviceProxyMode.put(service, true);
System.out.println(ZAnsi.yellow("[NET] " + service.name() + " blocked, switching to proxy")); System.out.println(ZAnsi.yellow("[NET] " + service.name() + " заблокирован, переключаемся на прокси"));
} }
} }
/**
* Универсальный GET с умным прокси + автоматическим fallback
*/
public static String getWithSmartProxy(String url) throws IOException, InterruptedException { public static String getWithSmartProxy(String url) throws IOException, InterruptedException {
// Попытка прямого подключения
if (!shouldUseProxyForUrl(url)) { if (!shouldUseProxyForUrl(url)) {
try { try {
HttpRequest request = HttpRequest.newBuilder() HttpRequest request = HttpRequest.newBuilder()
@@ -301,9 +309,11 @@ public class ZHttpClient {
directFailCount++; directFailCount++;
markServiceAsBlocked(url); markServiceAsBlocked(url);
} }
// Если ошибка соединения пробуем через прокси
} }
} }
// Через прокси
try { try {
String encodedUrl = URLEncoder.encode(url, StandardCharsets.UTF_8); String encodedUrl = URLEncoder.encode(url, StandardCharsets.UTF_8);
String proxyUrl = BASE_URL + "/download?url=" + encodedUrl; String proxyUrl = BASE_URL + "/download?url=" + encodedUrl;
@@ -325,10 +335,13 @@ public class ZHttpClient {
return response.body(); return response.body();
} catch (Exception e) { } catch (Exception e) {
throw new IOException("Failed to fetch data directly or via proxy: " + e.getMessage(), e); throw new IOException("Не удалось получить данные ни напрямую, ни через прокси: " + e.getMessage(), e);
} }
} }
/**
* Скачивание файла с умным прокси + fallback
*/
public static void downloadFileWithSmartProxy(String url, Path target) throws Exception { public static void downloadFileWithSmartProxy(String url, Path target) throws Exception {
if (!shouldUseProxyForUrl(url)) { if (!shouldUseProxyForUrl(url)) {
try { try {
@@ -350,9 +363,11 @@ public class ZHttpClient {
directFailCount++; directFailCount++;
markServiceAsBlocked(url); markServiceAsBlocked(url);
} }
// fallback на прокси ниже
} }
} }
// Скачивание через прокси
String encodedUrl = URLEncoder.encode(url, StandardCharsets.UTF_8); String encodedUrl = URLEncoder.encode(url, StandardCharsets.UTF_8);
String proxyUrl = BASE_URL + "/proxy/download?url=" + encodedUrl; String proxyUrl = BASE_URL + "/proxy/download?url=" + encodedUrl;
@@ -372,6 +387,8 @@ public class ZHttpClient {
proxySuccessCount++; proxySuccessCount++;
} }
// ====================== СТАРЫЕ МЕТОДЫ (обновлённые) ======================
public static String get(String endpoint) throws IOException, InterruptedException { public static String get(String endpoint) throws IOException, InterruptedException {
checkAllServicesOnStartup(); checkAllServicesOnStartup();
@@ -386,6 +403,7 @@ public class ZHttpClient {
.header("User-Agent", "ZernMC-Launcher/1.0") .header("User-Agent", "ZernMC-Launcher/1.0")
.GET(); .GET();
// ===== ДОБАВИТЬ ТОКЕН АВТОРИЗАЦИИ =====
String accessToken = AuthManager.getAccessToken(); String accessToken = AuthManager.getAccessToken();
if (accessToken != null && !accessToken.equals("0")) { if (accessToken != null && !accessToken.equals("0")) {
requestBuilder.header("Authorization", "Bearer " + accessToken); requestBuilder.header("Authorization", "Bearer " + accessToken);
@@ -412,6 +430,7 @@ public class ZHttpClient {
.header("User-Agent", "ZernMC-Launcher/1.0") .header("User-Agent", "ZernMC-Launcher/1.0")
.GET(); .GET();
// ===== ДОБАВИТЬ ТОКЕН АВТОРИЗАЦИИ =====
String accessToken = AuthManager.getAccessToken(); String accessToken = AuthManager.getAccessToken();
if (accessToken != null && !accessToken.equals("0")) { if (accessToken != null && !accessToken.equals("0")) {
requestBuilder.header("Authorization", "Bearer " + accessToken); requestBuilder.header("Authorization", "Bearer " + accessToken);
@@ -427,10 +446,12 @@ public class ZHttpClient {
proxySuccessCount++; proxySuccessCount++;
return response.body(); return response.body();
} catch (Exception e) { } catch (Exception e) {
throw new IOException("Proxy error: " + e.getMessage(), e); throw new IOException("Ошибка прокси: " + e.getMessage(), e);
} }
} }
// ====================== МЕТОДЫ ДЛЯ EXTERNAL РЕСУРСОВ ======================
public static List<String> getFabricLoaderVersions() throws IOException, InterruptedException { public static List<String> getFabricLoaderVersions() throws IOException, InterruptedException {
String url = "https://meta.fabricmc.net/v2/versions/loader"; String url = "https://meta.fabricmc.net/v2/versions/loader";
return parseFabricVersionsFromJson(getWithSmartProxy(url)); return parseFabricVersionsFromJson(getWithSmartProxy(url));
@@ -485,13 +506,15 @@ public class ZHttpClient {
return versions; return versions;
} }
// ====================== ВСПОМОГАТЕЛЬНЫЕ ======================
public static String getLauncherVersionInfo() throws IOException, InterruptedException { public static String getLauncherVersionInfo() throws IOException, InterruptedException {
return get("/launcher/version"); return get("/launcher/version");
} }
public static void forceProxyMode() { public static void forceProxyMode() {
useProxyMode.set(true); useProxyMode.set(true);
System.out.println(ZAnsi.yellow("Global proxy mode forced on")); System.out.println(ZAnsi.yellow("Принудительно включен глобальный прокси режим"));
} }
public static void disableProxyMode() { public static void disableProxyMode() {
@@ -502,7 +525,7 @@ public class ZHttpClient {
serviceFailCount.put(type, 0); serviceFailCount.put(type, 0);
} }
} }
System.out.println(ZAnsi.green("Proxy mode disabled")); System.out.println(ZAnsi.green("Режим прокси выключен"));
} }
public static boolean isProxyMode() { public static boolean isProxyMode() {
@@ -510,16 +533,16 @@ public class ZHttpClient {
} }
public static void printStats() { public static void printStats() {
System.out.println(ZAnsi.cyan("\n=== Network Stats ===")); System.out.println(ZAnsi.cyan("\n=== Статистика сети ==="));
System.out.println(ZAnsi.white("Global proxy: ") + (useProxyMode.get() ? "ON" : "OFF")); System.out.println(ZAnsi.white("Глобальный прокси: ") + (useProxyMode.get() ? "ВКЛ" : "ВЫКЛ"));
System.out.println(ZAnsi.white("Direct successes: ") + directSuccessCount); System.out.println(ZAnsi.white("Прямых успехов: ") + directSuccessCount);
System.out.println(ZAnsi.white("Direct failures: ") + directFailCount); System.out.println(ZAnsi.white("Прямых неудач: ") + directFailCount);
System.out.println(ZAnsi.white("Proxy successes: ") + proxySuccessCount); System.out.println(ZAnsi.white("Прокси успехов: ") + proxySuccessCount);
System.out.println(ZAnsi.cyan("\nService status:")); System.out.println(ZAnsi.cyan("\nСтатус сервисов:"));
for (ServiceType type : ServiceType.values()) { for (ServiceType type : ServiceType.values()) {
if (type.isAlwaysDirect()) continue; if (type.isAlwaysDirect()) continue;
String status = serviceProxyMode.get(type) ? ZAnsi.red("PROXY") : ZAnsi.green("DIRECT"); String status = serviceProxyMode.get(type) ? ZAnsi.red("ПРОКСИ") : ZAnsi.green("ПРЯМО");
String health = serviceHealthy.get(type) ? ZAnsi.green("[+]") : ZAnsi.red("[-]"); String health = serviceHealthy.get(type) ? ZAnsi.green("[+]") : ZAnsi.red("[-]");
System.out.println(ZAnsi.white(" " + type.name() + ": ") + status + " " + health); System.out.println(ZAnsi.white(" " + type.name() + ": ") + status + " " + health);
} }
@@ -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 -39
View File
@@ -15,12 +15,11 @@ def parse_args():
mode_group.add_argument("--dev", action="store_true", help="Development mode with auto-reload") mode_group.add_argument("--dev", action="store_true", help="Development mode with auto-reload")
mode_group.add_argument("--prod", action="store_true", help="Production mode with 4 workers") mode_group.add_argument("--prod", action="store_true", help="Production mode with 4 workers")
mode_group.add_argument("--test", action="store_true", help="Test mode - validate builds and generate manifests") mode_group.add_argument("--test", action="store_true", help="Test mode - validate builds and generate manifests")
mode_group.add_argument("--sync", action="store_true", help="Sync mode - sync with main server as mirror")
# 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()
@@ -54,43 +53,6 @@ async def run_test_mode():
logger.info("All packs validated successfully") logger.info("All packs validated successfully")
sys.exit(0) sys.exit(0)
async def run_sync_mode():
"""Sync with main server as mirror"""
import os
main_url = os.environ.get("MAIN_SERVER_URL")
if not main_url:
logger.error("MAIN_SERVER_URL not set. Run: MAIN_SERVER_URL=http://main:1582 python cli.py --sync")
sys.exit(1)
logger.info(f"Starting mirror sync from {main_url}")
# Get version from main
import httpx
async with httpx.AsyncClient() as client:
# Get version
try:
resp = await client.get(f"{main_url}/launcher/version")
data = resp.json()
version = data.get("version")
logger.info(f"Main server version: {version}")
except Exception as e:
logger.error(f"Failed to get version from main: {e}")
sys.exit(1)
# Get sync manifest
try:
resp = await client.get(f"{main_url}/launcher/sync/{version}")
sync_data = resp.json()
logger.info(f"Files to sync: {len(sync_data.get('files', []))}")
except Exception as e:
logger.error(f"Failed to get sync manifest: {e}")
sys.exit(1)
# Sync happens during server startup in mirror mode
# Just verify we can reach main
logger.info("Mirror sync configured. Server will sync on startup.")
def run_production_mode(host: str, port: int, workers: int): def run_production_mode(host: str, port: int, workers: int):
"""Run with multiple workers""" """Run with multiple workers"""
logger.info(f"Starting in PRODUCTION mode with {workers} workers on {host}:{port}") logger.info(f"Starting in PRODUCTION mode with {workers} workers on {host}:{port}")
+37 -890
View File
File diff suppressed because it is too large Load Diff
+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
-229
View File
@@ -1,229 +0,0 @@
#!/usr/bin/env python3
"""
Lightweight Mirror Server - only serves static files
"""
import os
import asyncio
from pathlib import Path
import structlog
import httpx
MAIN_SERVER_URL = os.environ.get("MAIN_SERVER_URL", "http://87.120.187.36:1582")
MASTER_KEY = os.environ.get("MASTER_KEY", "sashegdevsupeddevepta")
PORT = int(os.environ.get("PORT", "1582"))
BUILDS_DIR = Path("builds")
VERSIONS_DIR = BUILDS_DIR / "versions"
PACKS_DIR = Path("packs")
BUILDS_DIR.mkdir(exist_ok=True)
PACKS_DIR.mkdir(exist_ok=True)
logging = structlog.get_logger()
async def sync_with_main():
"""Sync files from main server"""
logging.info(f"Syncing from {MAIN_SERVER_URL}")
client = httpx.AsyncClient(timeout=120.0)
headers = {"X-Master-Key": MASTER_KEY}
try:
# Get launcher info
resp = await client.get(f"{MAIN_SERVER_URL}/launcher/info", headers=headers)
if resp.status_code != 200:
logging.error(f"Failed to get launcher info: {resp.status_code}")
return
data = resp.json()
current_version = data.get("current_version", "1.0.9")
files = data.get("files", {})
zips = files.get("zips", [])
logging.info(f"Current version: {current_version}, zips: {len(zips)}")
# Download latest ZIP
for z in zips:
if not z.get("is_legacy"):
zip_filename = z.get("filename")
zip_path = BUILDS_DIR / zip_filename
if not zip_path.exists():
logging.info(f"Downloading {zip_filename}...")
# Try direct download
download_url = f"{MAIN_SERVER_URL}/launcher/download/zip/{zip_filename}"
resp = await client.get(download_url, headers=headers)
if resp.status_code == 200:
zip_path.write_bytes(resp.content)
logging.info(f"Downloaded {zip_filename}")
# Extract
version = z.get("version")
extract_to = VERSIONS_DIR / version
extract_to.mkdir(parents=True, exist_ok=True)
import zipfile
with zipfile.ZipFile(zip_path, 'r') as zf:
zf.extractall(extract_to)
logging.info(f"Extracted {version}")
# Get launcher meta
resp = await client.get(f"{MAIN_SERVER_URL}/launcher/meta/{current_version}", headers=headers)
if resp.status_code == 200:
(BUILDS_DIR / "meta.json").write_text(resp.text)
logging.info("Meta synced")
# Sync packs list
resp = await client.get(f"{MAIN_SERVER_URL}/packs", headers=headers)
if resp.status_code == 200:
packs_data = resp.json()
packs = packs_data.get("packs", [])
logging.info(f"Found {len(packs)} packs")
for pack in packs:
pack_name = pack.get("name")
pack_meta_url = f"{MAIN_SERVER_URL}/pack/meta/{pack_name}"
resp = await client.get(pack_meta_url, headers=headers)
if resp.status_code == 200:
pack_dir = PACKS_DIR / pack_name
pack_dir.mkdir(parents=True, exist_ok=True)
(pack_dir / "meta.json").write_text(resp.text)
logging.info(f"Synced pack: {pack_name}")
finally:
await client.aclose()
logging.info("Sync complete")
async def run_server():
"""Run static server"""
from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import StreamingResponse
import aiofiles
import mimetypes
import re
import uvicorn
app = FastAPI(title="ZernMC Mirror")
async def send_file(file_path: Path, request: Request):
if not file_path.exists():
raise HTTPException(404, "Not found")
content_type = mimetypes.guess_type(str(file_path))[0] or "application/octet-stream"
file_size = file_path.stat().st_size
range_header = request.headers.get("range")
if range_header:
match = re.match(r"bytes=(\d+)-(\d+)?", range_header)
if match:
start = int(match.group(1))
end = min(file_size - 1, int(match.group(2)) if match.group(2) else file_size - 1)
content_length = end - start + 1
async with aiofiles.open(file_path, "rb") as f:
await f.seek(start)
chunk = await f.read(content_length)
return StreamingResponse(iter([chunk]), status_code=206, media_type=content_type,
headers={"Content-Range": f"bytes {start}-{end}/{file_size}", "Accept-Ranges": "bytes", "Content-Length": str(content_length)})
async def file_iter():
async with aiofiles.open(file_path, "rb") as f:
while True:
chunk = await f.read(65536)
if not chunk:
break
yield chunk
return StreamingResponse(file_iter(), media_type=content_type,
headers={"Accept-Ranges": "bytes", "Content-Length": str(file_size)})
@app.get("/launcher/info")
async def get_launcher_info():
meta_path = BUILDS_DIR / "meta.json"
if meta_path.exists():
import json
return json.loads(meta_path.read_text())
return {"current_version": "unknown", "files": {}}
@app.get("/launcher/version")
async def get_version():
return await get_launcher_info()
@app.get("/launcher/file/{version}/{file_path:path}")
async def get_launcher_file(version: str, file_path: str, request: Request):
full_path = BUILDS_DIR / "versions" / version / file_path
if ".." in file_path:
raise HTTPException(403, "Invalid path")
if not full_path.exists():
raise HTTPException(404, f"File not found: {file_path}")
return await send_file(full_path, request)
@app.get("/launcher/download/zip/{filename}")
async def download_zip(filename: str, request: Request):
return await send_file(BUILDS_DIR / filename, request)
@app.get("/launcher/meta/{version}")
async def get_meta(version: str):
meta_path = BUILDS_DIR / "meta.json"
if meta_path.exists():
import json
return json.loads(meta_path.read_text())
raise HTTPException(404, "Meta not found")
@app.get("/launcher/mirrors")
async def get_mirrors():
return {"mirrors": [{"name": "main", "url": MAIN_SERVER_URL}]}
@app.get("/packs")
async def list_packs():
import json
packs = []
for pack_dir in PACKS_DIR.iterdir():
if pack_dir.is_dir():
meta_path = pack_dir / "meta.json"
if meta_path.exists():
try:
meta = json.loads(meta_path.read_text())
packs.append({
"name": pack_dir.name,
"version": meta.get("version", 1),
"files_count": len(meta.get("files", {}))
})
except:
packs.append({"name": pack_dir.name, "error": "invalid"})
return {"packs": packs}
@app.get("/pack/{pack_name}")
async def get_pack(pack_name: str):
meta_path = PACKS_DIR / pack_name / "meta.json"
if meta_path.exists():
import json
return json.loads(meta_path.read_text())
raise HTTPException(404, "Pack not found")
@app.get("/pack/meta/{pack_name}")
async def get_pack_meta(pack_name: str):
return await get_pack(pack_name)
@app.get("/pack/{pack_name}/diff")
async def get_pack_diff(pack_name: str):
# For mirror, just return empty diff (no local changes)
return {"added": [], "removed": [], "changed": []}
@app.get("/pack/{pack_name}/file/{file_path:path}")
async def get_pack_file(pack_name: str, file_path: str, request: Request):
return await send_file(PACKS_DIR / pack_name / file_path, request)
config = uvicorn.Config(app, host="0.0.0.0", port=PORT, log_level="info")
server = uvicorn.Server(config)
await server.serve()
async def main():
logging.info("Starting ZernMC Mirror Server")
await sync_with_main()
await run_server()
if __name__ == "__main__":
asyncio.run(main())