2 Commits

Author SHA1 Message Date
SashegDev b493b3278b minor fixes 2026-06-07 16:36:50 +03:00
SashegDev ec7ef01760 иним чиним чиним чиним а так же новая система друзей и бутстраппера 2026-06-07 12:32:34 +00:00
31 changed files with 3863 additions and 476 deletions
+8
View File
@@ -12,3 +12,11 @@ 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/
builds/
server/news/
data/
packs/
.__pycache__
.pytest_cache
.venv
resources
@@ -13,6 +13,11 @@ import java.util.Arrays;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import javax.swing.*;
import javax.swing.plaf.basic.BasicProgressBarUI;
import java.awt.*;
import java.awt.event.MouseAdapter;
import java.awt.event.MouseEvent;
import com.google.gson.JsonElement; import com.google.gson.JsonElement;
import com.google.gson.JsonObject; import com.google.gson.JsonObject;
import com.google.gson.JsonParser; import com.google.gson.JsonParser;
@@ -32,6 +37,7 @@ public class Bootstrap {
private static Path javafxPath; private static Path javafxPath;
private static boolean isCliMode; private static boolean isCliMode;
private static boolean isJfxMode; private static boolean isJfxMode;
private static BootstrapUI ui;
private static Path getLauncherJar() { private static Path getLauncherJar() {
return binDir.resolve(JAR_NAME); return binDir.resolve(JAR_NAME);
@@ -53,11 +59,17 @@ public class Bootstrap {
log("Mode: " + (isCliMode ? "CLI" : "JFX")); log("Mode: " + (isCliMode ? "CLI" : "JFX"));
if (!isCliMode && !GraphicsEnvironment.isHeadless()) {
ui = new BootstrapUI();
SwingUtilities.invokeLater(() -> ui.show());
}
String currentVersion = readCurrentVersion(); String currentVersion = readCurrentVersion();
String serverVersion = getServerVersion(); String serverVersion = getServerVersion();
log("Local version: " + currentVersion); log("Local version: " + currentVersion);
log("Server version: " + serverVersion); log("Server version: " + serverVersion);
setVersionInfo(currentVersion, serverVersion);
loadMirrors(); loadMirrors();
log("Primary server: " + BASE_URL); log("Primary server: " + BASE_URL);
@@ -74,6 +86,14 @@ public class Bootstrap {
log("Shutdown signal received..."); log("Shutdown signal received...");
})); }));
if (ui != null) {
setTitle("Launching...");
setProgress(100, 100);
log("Starting launcher...");
try { Thread.sleep(400); } catch (InterruptedException ignored) {}
ui.close();
}
launchMain(args); launchMain(args);
} }
@@ -180,6 +200,19 @@ public class Bootstrap {
Files.writeString(logDir.resolve("launcher.log"), entry + "\n", Files.writeString(logDir.resolve("launcher.log"), entry + "\n",
StandardOpenOption.CREATE, StandardOpenOption.APPEND); StandardOpenOption.CREATE, StandardOpenOption.APPEND);
} catch (Exception ignored) {} } catch (Exception ignored) {}
if (ui != null) ui.setStatus(msg);
}
private static void setProgress(int current, int total) {
if (ui != null) ui.setProgress(current, total);
}
private static void setVersionInfo(String localVer, String serverVer) {
if (ui != null) ui.setVersionInfo(localVer, serverVer);
}
private static void setTitle(String text) {
if (ui != null) ui.setTitleText(text);
} }
private static String readCurrentVersion() { private static String readCurrentVersion() {
@@ -260,6 +293,9 @@ public class Bootstrap {
int downloaded = 0; int downloaded = 0;
int skipped = 0; int skipped = 0;
int failed = 0;
String selfName = getSelfFileName();
for (Map.Entry<String, FileMeta> entry : serverFiles.entrySet()) { for (Map.Entry<String, FileMeta> entry : serverFiles.entrySet()) {
String filePath = entry.getKey(); String filePath = entry.getKey();
@@ -273,20 +309,46 @@ public class Bootstrap {
continue; continue;
} }
// Skip self-update (can't overwrite running executable)
if (selfName != null && (filePath.equalsIgnoreCase(selfName) || filePath.endsWith("/" + selfName))) {
log("Skipping self-update: " + filePath + " (file in use)");
skipped++;
continue;
}
if (localHash != null) { if (localHash != null) {
log("Updating: " + filePath); log("Updating: " + filePath);
} else { } else {
log("Downloading: " + filePath); log("Downloading: " + filePath);
} }
downloadFile(newVersion, filePath, serverMeta.size); try {
downloaded++; downloadFile(newVersion, filePath, serverMeta.size);
downloaded++;
} catch (Exception e) {
log("Warning: Could not update " + filePath + " - " + e.getMessage());
failed++;
}
} }
log("Updated files: " + downloaded + ", skipped: " + skipped); log("Updated files: " + downloaded + ", skipped: " + skipped + ", failed: " + failed);
log("Updated to v" + newVersion); log("Updated to v" + newVersion);
} }
private static String getSelfFileName() {
try {
String classPath = Bootstrap.class.getProtectionDomain().getCodeSource().getLocation().toURI().getPath();
if (classPath != null) {
String fn = Paths.get(classPath).getFileName().toString();
// If running from a JAR, the exe has the same stem
if (fn.endsWith(".jar")) {
return fn.replace(".jar", ".exe");
}
}
} catch (Exception ignored) {}
return null;
}
private static Map<String, FileMeta> fetchServerMeta(String version) { private static Map<String, FileMeta> fetchServerMeta(String version) {
Map<String, FileMeta> files = new HashMap<>(); Map<String, FileMeta> files = new HashMap<>();
try { try {
@@ -390,6 +452,7 @@ public class Bootstrap {
long downloaded = 0; long downloaded = 0;
long lastUpdate = 0; long lastUpdate = 0;
long startTime = System.currentTimeMillis(); long startTime = System.currentTimeMillis();
setTitle("Downloading " + fileName);
try (InputStream in = conn.getInputStream(); try (InputStream in = conn.getInputStream();
OutputStream out = new FileOutputStream(outPath.toFile())) { OutputStream out = new FileOutputStream(outPath.toFile())) {
@@ -405,13 +468,9 @@ public class Bootstrap {
double downloadedMB = downloaded / 1024.0 / 1024.0; double downloadedMB = downloaded / 1024.0 / 1024.0;
double totalMB = expectedSize / 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", String progressStr = String.format("%.1f/%.1f MB (%.1f MB/s)", downloadedMB, totalMB, speed);
getProgressBar(downloaded, expectedSize), log(progressStr);
fileName, setProgress((int) downloaded, (int) Math.max(expectedSize, 1));
downloadedMB,
totalMB,
speed
));
lastUpdate = downloaded; lastUpdate = downloaded;
} }
} }
@@ -419,12 +478,8 @@ public class Bootstrap {
long elapsed = System.currentTimeMillis() - startTime; long elapsed = System.currentTimeMillis() - startTime;
double speed = downloaded / 1024.0 / 1024.0 / (elapsed / 1000.0 + 0.001); 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!", log(String.format("Downloaded %.1f MB (%.1f MB/s) - Done!", downloaded / 1024.0 / 1024.0, speed));
getProgressBar(downloaded, expectedSize), setProgress((int) downloaded, (int) Math.max(expectedSize, 1));
fileName,
downloaded / 1024.0 / 1024.0,
speed
));
} }
private static String getProgressBar(long current, long total) { private static String getProgressBar(long current, long total) {
@@ -488,4 +543,173 @@ public class Bootstrap {
return false; return false;
} }
} }
// ====================== SWING UI ======================
private static class BootstrapUI {
private final JFrame frame;
private final JLabel statusLabel;
private final JProgressBar progressBar;
private final JLabel titleLabel;
private final JLabel versionLabel;
private final JLabel speedLabel;
private final Color bgColor = new Color(0x0c, 0x0c, 0x12);
private final Color surfaceColor = new Color(0x16, 0x16, 0x1f);
private final Color accentColor = new Color(0xe9, 0x45, 0x60);
private final Color textColor = new Color(0xee, 0xee, 0xf0);
private final Color mutedColor = new Color(0x88, 0x88, 0x9a);
BootstrapUI() {
try {
UIManager.setLookAndFeel(UIManager.getCrossPlatformLookAndFeelClassName());
} catch (Exception ignored) {}
frame = new JFrame("ZernMC Launcher");
frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
frame.setSize(480, 280);
frame.setLocationRelativeTo(null);
frame.setResizable(false);
frame.setBackground(bgColor);
frame.setUndecorated(true);
JPanel root = new JPanel(new BorderLayout());
root.setBackground(bgColor);
root.setBorder(BorderFactory.createCompoundBorder(
BorderFactory.createLineBorder(new Color(0x2a, 0x2a, 0x3a), 1),
BorderFactory.createEmptyBorder(20, 24, 20, 24)
));
// Title bar
JPanel titleBar = new JPanel(new BorderLayout());
titleBar.setOpaque(false);
JLabel brandLabel = new JLabel("ZernMC Launcher");
brandLabel.setFont(new Font("Segoe UI", Font.BOLD, 18));
brandLabel.setForeground(textColor);
JPanel titleControls = new JPanel(new FlowLayout(FlowLayout.RIGHT, 0, 0));
titleControls.setOpaque(false);
JButton closeBtn = createTitleButton("\u2715");
closeBtn.addActionListener(e -> System.exit(0));
titleControls.add(closeBtn);
titleBar.add(brandLabel, BorderLayout.WEST);
titleBar.add(titleControls, BorderLayout.EAST);
root.add(titleBar, BorderLayout.NORTH);
// Center content
JPanel center = new JPanel();
center.setOpaque(false);
center.setLayout(new BoxLayout(center, BoxLayout.Y_AXIS));
center.add(Box.createVerticalStrut(16));
titleLabel = new JLabel("Initializing...");
titleLabel.setFont(new Font("Segoe UI", Font.PLAIN, 13));
titleLabel.setForeground(mutedColor);
titleLabel.setAlignmentX(Component.CENTER_ALIGNMENT);
center.add(titleLabel);
center.add(Box.createVerticalStrut(8));
versionLabel = new JLabel(" ");
versionLabel.setFont(new Font("Segoe UI", Font.PLAIN, 12));
versionLabel.setForeground(mutedColor);
versionLabel.setAlignmentX(Component.CENTER_ALIGNMENT);
center.add(versionLabel);
center.add(Box.createVerticalStrut(16));
statusLabel = new JLabel("Starting...");
statusLabel.setFont(new Font("Segoe UI", Font.PLAIN, 13));
statusLabel.setForeground(textColor);
statusLabel.setAlignmentX(Component.CENTER_ALIGNMENT);
center.add(statusLabel);
center.add(Box.createVerticalStrut(12));
progressBar = new JProgressBar(0, 100);
progressBar.setPreferredSize(new Dimension(400, 6));
progressBar.setMaximumSize(new Dimension(400, 6));
progressBar.setAlignmentX(Component.CENTER_ALIGNMENT);
progressBar.setBackground(new Color(0x2a, 0x2a, 0x3a));
progressBar.setForeground(accentColor);
progressBar.setBorderPainted(false);
progressBar.setValue(0);
progressBar.setUI(new BasicProgressBarUI() {
protected Color getSelectionBackground() { return accentColor; }
protected Color getSelectionForeground() { return accentColor; }
});
center.add(progressBar);
center.add(Box.createVerticalStrut(6));
speedLabel = new JLabel(" ");
speedLabel.setFont(new Font("Segoe UI", Font.PLAIN, 11));
speedLabel.setForeground(mutedColor);
speedLabel.setAlignmentX(Component.CENTER_ALIGNMENT);
center.add(speedLabel);
root.add(center, BorderLayout.CENTER);
// Draggable frame
MouseAdapter dragAdapter = new MouseAdapter() {
private int x, y;
public void mousePressed(MouseEvent e) { x = e.getX(); y = e.getY(); }
public void mouseDragged(MouseEvent e) {
frame.setLocation(e.getXOnScreen() - x, e.getYOnScreen() - y);
}
};
root.addMouseListener(dragAdapter);
root.addMouseMotionListener(dragAdapter);
frame.setContentPane(root);
}
private JButton createTitleButton(String text) {
JButton btn = new JButton(text);
btn.setFont(new Font("Segoe UI", Font.PLAIN, 14));
btn.setForeground(mutedColor);
btn.setBackground(bgColor);
btn.setBorderPainted(false);
btn.setFocusPainted(false);
btn.setContentAreaFilled(false);
btn.setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR));
btn.addMouseListener(new MouseAdapter() {
public void mouseEntered(MouseEvent e) { btn.setForeground(accentColor); }
public void mouseExited(MouseEvent e) { btn.setForeground(mutedColor); }
});
return btn;
}
void show() {
frame.setVisible(true);
frame.toFront();
}
void close() {
frame.dispose();
}
void setStatus(final String text) {
SwingUtilities.invokeLater(() -> statusLabel.setText(text));
}
void setProgress(final int current, final int total) {
SwingUtilities.invokeLater(() -> {
if (total > 0) {
int pct = (int) ((long) current * 100 / total);
progressBar.setValue(Math.min(pct, 100));
speedLabel.setText(String.format("%.1f / %.1f MB",
current / 1024.0 / 1024.0, total / 1024.0 / 1024.0));
} else {
progressBar.setIndeterminate(true);
}
});
}
void setVersionInfo(final String local, final String server) {
SwingUtilities.invokeLater(() ->
versionLabel.setText("v" + local + " \u2192 v" + server));
}
void setTitleText(final String text) {
SwingUtilities.invokeLater(() -> titleLabel.setText(text));
}
}
} }
+54 -11
View File
@@ -132,16 +132,17 @@
<artifactId>launch4j-maven-plugin</artifactId> <artifactId>launch4j-maven-plugin</artifactId>
<version>2.5.0</version> <version>2.5.0</version>
<executions> <executions>
<!-- GUI версия (основная) - без консоли -->
<execution> <execution>
<id>l4j</id> <id>l4j-gui</id>
<phase>package</phase> <phase>package</phase>
<goals> <goals>
<goal>launch4j</goal> <goal>launch4j</goal>
</goals> </goals>
<configuration> <configuration>
<outfile>../../server/builds/zernmc-${project.version}.exe</outfile> <outfile>../../server/builds/zernmc.exe</outfile>
<jar>../../server/builds/zernmc-bootstrap.jar</jar> <jar>../../server/builds/zernmc-bootstrap.jar</jar>
<headerType>console</headerType> <headerType>gui</headerType>
<dontWrapJar>false</dontWrapJar> <dontWrapJar>false</dontWrapJar>
<mainClass>me.sashegdev.zernmc.launcher.Bootstrap</mainClass> <mainClass>me.sashegdev.zernmc.launcher.Bootstrap</mainClass>
<jre> <jre>
@@ -157,7 +158,38 @@
<productName>ZernMC</productName> <productName>ZernMC</productName>
<companyName>ZernMC</companyName> <companyName>ZernMC</companyName>
<internalName>zernmc</internalName> <internalName>zernmc</internalName>
<originalFilename>zernmc-${project.version}.exe</originalFilename> <originalFilename>zernmc.exe</originalFilename>
</versionInfo>
</configuration>
</execution>
<!-- CLI версия - с консолью -->
<execution>
<id>l4j-cli</id>
<phase>package</phase>
<goals>
<goal>launch4j</goal>
</goals>
<configuration>
<outfile>../../server/builds/zernmc-cli.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 CLI</fileDescription>
<productVersion>${project.version}.0</productVersion>
<txtProductVersion>${project.version}</txtProductVersion>
<productName>ZernMC CLI</productName>
<companyName>ZernMC</companyName>
<internalName>zernmc-cli</internalName>
<originalFilename>zernmc-cli.exe</originalFilename>
</versionInfo> </versionInfo>
</configuration> </configuration>
</execution> </execution>
@@ -219,10 +251,6 @@
</fileset> </fileset>
</copy> </copy>
<!-- Переименовываем exe для zip -->
<move file="../../server/builds/zernmc-${project.version}.exe"
tofile="../../server/builds/zernmc.exe" overwrite="true"/>
<!-- Создаем папку bin и копируем JAR --> <!-- Создаем папку bin и копируем JAR -->
<mkdir dir="../../server/builds/bin"/> <mkdir dir="../../server/builds/bin"/>
<copy file="../../server/builds/zernmclauncher.jar" <copy file="../../server/builds/zernmclauncher.jar"
@@ -236,11 +264,26 @@
</fileset> </fileset>
</copy> </copy>
<!-- Создаём zip --> <!-- Создаём README -->
<echo file="../../server/builds/README.txt">
ZernMC Launcher
Files:
- zernmc.exe - Main launcher with GUI (no console window)
- zernmc-cli.exe - CLI version for servers/advanced users (with console)
How to use GUI:
Just run zernmc.exe
How to use CLI:
Run from command line: zernmc-cli.exe --cli
</echo>
<!-- Создаём один архив со всем -->
<zip destfile="../../server/builds/ZernMC-win-${project.version}.zip" <zip destfile="../../server/builds/ZernMC-win-${project.version}.zip"
basedir="../../server/builds" basedir="../../server/builds"
includes="zernmc.exe,bin/**,assets/**,lib/**" includes="zernmc.exe,zernmc-cli.exe,bin/**,assets/**,lib/**,README.txt"
excludes="build.version,*-${project.version}.*,zernmclauncher.jar,zernmc-bootstrap.jar"/> excludes="build.version,*.jar"/>
</target> </target>
</configuration> </configuration>
</execution> </execution>
@@ -69,7 +69,7 @@ public class Bootstrap {
private static String getServerVersion() { private static String getServerVersion() {
try { try {
URL url = new URL(BASE_URL.replace("download?type=jar", "version")); URL url = new URL(BASE_URL + "/launcher/version");
HttpURLConnection conn = (HttpURLConnection) url.openConnection(); HttpURLConnection conn = (HttpURLConnection) url.openConnection();
conn.setRequestMethod("GET"); conn.setRequestMethod("GET");
if (conn.getResponseCode() == 200) { if (conn.getResponseCode() == 200) {
@@ -22,6 +22,8 @@ public class Main {
System.setProperty("java.stderr.encoding", "UTF-8"); System.setProperty("java.stderr.encoding", "UTF-8");
System.setProperty("org.jline.terminal.disableDeprecatedProviderWarning", "true"); System.setProperty("org.jline.terminal.disableDeprecatedProviderWarning", "true");
LauncherLogger.init();
if (System.getProperty("os.name").toLowerCase().contains("windows")) { if (System.getProperty("os.name").toLowerCase().contains("windows")) {
try { try {
new ProcessBuilder("cmd", "/c", "chcp", "65001").inheritIO().start().waitFor(); new ProcessBuilder("cmd", "/c", "chcp", "65001").inheritIO().start().waitFor();
@@ -29,8 +31,7 @@ public class Main {
} }
ZAnsi.install(); ZAnsi.install();
System.out.print("\033[H\033[2J"); LauncherLogger.info("Starting ZernMC Launcher " + CURRENT_VERSION);
System.out.println(ZAnsi.brightGreen("Welcome to ZernMC Launcher " + CURRENT_VERSION));
List<String> argList = List.of(args); List<String> argList = List.of(args);
boolean jfxMode = argList.contains("--jfx"); boolean jfxMode = argList.contains("--jfx");
@@ -3,6 +3,7 @@ package me.sashegdev.zernmc.launcher.api;
import me.sashegdev.zernmc.launcher.api.auth.AuthService; import me.sashegdev.zernmc.launcher.api.auth.AuthService;
import me.sashegdev.zernmc.launcher.api.instance.InstanceService; import me.sashegdev.zernmc.launcher.api.instance.InstanceService;
import me.sashegdev.zernmc.launcher.api.launch.LaunchService; import me.sashegdev.zernmc.launcher.api.launch.LaunchService;
import me.sashegdev.zernmc.launcher.utils.LauncherLogger;
import me.sashegdev.zernmc.launcher.utils.ZHttpClient; import me.sashegdev.zernmc.launcher.utils.ZHttpClient;
import java.util.ArrayList; import java.util.ArrayList;
@@ -113,7 +114,7 @@ public class LauncherAPI {
} }
idx = end; idx = end;
} }
versions.sort((a, b) -> b.compareTo(a)); versions.sort(LauncherAPI::compareVersions);
break; break;
case "neoforge": case "neoforge":
String neoforgeXml = ZHttpClient.downloadString("https://maven.neoforged.net/releases/net/neoforged/neoforge/maven-metadata.xml"); String neoforgeXml = ZHttpClient.downloadString("https://maven.neoforged.net/releases/net/neoforged/neoforge/maven-metadata.xml");
@@ -128,7 +129,7 @@ public class LauncherAPI {
} }
neoidx = end; neoidx = end;
} }
versions.sort((a, b) -> b.compareTo(a)); versions.sort(LauncherAPI::compareVersions);
break; break;
default: default:
break; break;
@@ -140,6 +141,23 @@ public class LauncherAPI {
} }
} }
private static int compareVersions(String a, String b) {
String[] partsA = a.split("\\.");
String[] partsB = b.split("\\.");
int len = Math.min(partsA.length, partsB.length);
for (int i = 0; i < len; i++) {
try {
int numA = Integer.parseInt(partsA[i]);
int numB = Integer.parseInt(partsB[i]);
if (numA != numB) return Integer.compare(numB, numA);
} catch (NumberFormatException e) {
int cmp = partsA[i].compareTo(partsB[i]);
if (cmp != 0) return cmp;
}
}
return Integer.compare(partsB.length, partsA.length);
}
private boolean isNeoForgeCompatible(String version, String mcVersion) { private boolean isNeoForgeCompatible(String version, String mcVersion) {
if (mcVersion.startsWith("1.21")) { if (mcVersion.startsWith("1.21")) {
return version.contains("1.21") && !version.contains("1.20"); return version.contains("1.21") && !version.contains("1.20");
@@ -153,26 +171,31 @@ public class LauncherAPI {
try { try {
String token = authService.getCurrentToken(); String token = authService.getCurrentToken();
if (token == null) { if (token == null) {
LauncherLogger.warn("getZernMCPacks: not logged in");
return ApiResponse.error("Not logged in"); return ApiResponse.error("Not logged in");
} }
String response = ZHttpClient.get("/packs"); String response = ZHttpClient.get("/packs");
org.json.JSONArray arr = new org.json.JSONArray(response); org.json.JSONObject root = new org.json.JSONObject(response);
org.json.JSONArray arr = root.optJSONArray("packs");
List<Map<String, String>> packs = new ArrayList<>(); List<Map<String, String>> packs = new ArrayList<>();
for (int i = 0; i < arr.length(); i++) { if (arr != null) {
org.json.JSONObject pack = arr.getJSONObject(i); for (int i = 0; i < arr.length(); i++) {
Map<String, String> packInfo = new java.util.HashMap<>(); org.json.JSONObject pack = arr.getJSONObject(i);
packInfo.put("name", pack.optString("name", "")); Map<String, String> packInfo = new java.util.HashMap<>();
packInfo.put("displayName", pack.optString("displayName", pack.optString("name", ""))); packInfo.put("name", pack.optString("name", ""));
packInfo.put("version", pack.optString("version", "")); packInfo.put("displayName", pack.optString("displayName", pack.optString("name", "")));
packInfo.put("mcVersion", pack.optString("mcVersion", "")); packInfo.put("version", pack.optString("version", ""));
packInfo.put("loader", pack.optString("loader", "vanilla")); packInfo.put("mcVersion", pack.optString("minecraft_version", ""));
packInfo.put("description", pack.optString("description", "")); packInfo.put("loader", pack.optString("loader_type", "vanilla"));
packs.add(packInfo); packInfo.put("description", pack.optString("description", ""));
packs.add(packInfo);
}
} }
LauncherLogger.info("getZernMCPacks: loaded " + packs.size() + " packs");
return ApiResponse.success(packs); return ApiResponse.success(packs);
} catch (Exception e) { } catch (Exception e) {
System.out.println("[API] Packs fetch failed: " + e.getMessage()); LauncherLogger.error("getZernMCPacks failed: " + e.getMessage());
return ApiResponse.error("Failed to load packs: " + e.getMessage()); return ApiResponse.error("Failed to load packs: " + e.getMessage());
} }
} }
@@ -1,5 +1,7 @@
package me.sashegdev.zernmc.launcher.api.auth; package me.sashegdev.zernmc.launcher.api.auth;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
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.auth.AuthManager;
import me.sashegdev.zernmc.launcher.utils.ZHttpClient; import me.sashegdev.zernmc.launcher.utils.ZHttpClient;
@@ -10,8 +12,10 @@ public class AuthService {
public ApiResponse<LoginResult> register(String username, String password) { public ApiResponse<LoginResult> register(String username, String password) {
try { try {
String response = post("/auth/register", JsonObject json = new JsonObject();
"{\"username\":\"" + username + "\",\"password\":\"" + password + "\"}"); json.addProperty("username", username);
json.addProperty("password", password);
String response = post("/auth/register", json.toString());
// If registration succeeds, auto-login // If registration succeeds, auto-login
AuthManager.AuthResult result = AuthManager.login(username, password); AuthManager.AuthResult result = AuthManager.login(username, password);
@@ -72,8 +76,10 @@ public class AuthService {
public ApiResponse<Boolean> activatePass(String passCode) { public ApiResponse<Boolean> activatePass(String passCode) {
try { try {
String response = post("/auth/pass/activate", JsonObject json = new JsonObject();
"{\"code\":\"" + passCode + "\"}"); json.addProperty("pass_code", passCode);
String response = post("/auth/pass/activate", json.toString());
AuthManager.refreshUserInfo();
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("Pass activation error: " + e.getMessage());
@@ -8,6 +8,7 @@ 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.ui.jfx.JFXLauncher;
import me.sashegdev.zernmc.launcher.utils.Config; import me.sashegdev.zernmc.launcher.utils.Config;
import me.sashegdev.zernmc.launcher.utils.LauncherLogger;
import java.io.BufferedReader; import java.io.BufferedReader;
import java.io.IOException; import java.io.IOException;
@@ -39,6 +40,7 @@ public class LaunchService {
return ApiResponse.error("Pack not found: " + instanceName); return ApiResponse.error("Pack not found: " + instanceName);
} }
LauncherLogger.info("Preparing launch for: " + instanceName);
LaunchCommandBuilder builder = new LaunchCommandBuilder(instance); LaunchCommandBuilder builder = new LaunchCommandBuilder(instance);
LaunchOptions options = createOptions(); LaunchOptions options = createOptions();
@@ -51,6 +53,7 @@ public class LaunchService {
); );
return ApiResponse.success(info); return ApiResponse.success(info);
} catch (Exception e) { } catch (Exception e) {
LauncherLogger.error("Error preparing launch for " + instanceName, e);
return ApiResponse.error("Error preparing launch: " + e.getMessage()); return ApiResponse.error("Error preparing launch: " + e.getMessage());
} }
} }
@@ -62,6 +65,8 @@ public class LaunchService {
return ApiResponse.error("Pack not found: " + instanceName); return ApiResponse.error("Pack not found: " + instanceName);
} }
LauncherLogger.info("Launching: " + instanceName + " (serverPack=" + instance.isServerPack() + ")");
LaunchCommandBuilder builder = new LaunchCommandBuilder(instance); LaunchCommandBuilder builder = new LaunchCommandBuilder(instance);
LaunchOptions options = createOptions(); LaunchOptions options = createOptions();
options.setUsername(AuthManager.getUsername()); options.setUsername(AuthManager.getUsername());
@@ -69,8 +74,8 @@ public class LaunchService {
options.setUuid(AuthManager.getUuid()); options.setUuid(AuthManager.getUuid());
List<String> command = builder.build(options); List<String> command = builder.build(options);
System.out.println("[LAUNCH] Generated command for " + instanceName + ":"); LauncherLogger.info("Generated command for " + instanceName + ":");
command.forEach(arg -> System.out.println(" " + arg)); command.forEach(arg -> LauncherLogger.debug(" " + arg));
ProcessBuilder processBuilder = new ProcessBuilder(command); ProcessBuilder processBuilder = new ProcessBuilder(command);
processBuilder.directory(instance.getPath().toFile()); processBuilder.directory(instance.getPath().toFile());
@@ -84,7 +89,7 @@ public class LaunchService {
long pid = process.pid(); long pid = process.pid();
runningProcesses.put(pid, process); runningProcesses.put(pid, process);
System.out.println("[LAUNCH] Process started, pid=" + pid); LauncherLogger.info("Process started, pid=" + pid);
java.io.FileOutputStream logFileOut = new java.io.FileOutputStream(gameLog.toFile(), true); java.io.FileOutputStream logFileOut = new java.io.FileOutputStream(gameLog.toFile(), true);
@@ -116,6 +121,7 @@ public class LaunchService {
ProcessInfo info = new ProcessInfo(instanceName, pid, "RUNNING"); ProcessInfo info = new ProcessInfo(instanceName, pid, "RUNNING");
return ApiResponse.success(info); return ApiResponse.success(info);
} catch (Exception e) { } catch (Exception e) {
LauncherLogger.error("Launch error for " + instanceName, e);
return ApiResponse.error("Launch error: " + e.getMessage()); return ApiResponse.error("Launch error: " + e.getMessage());
} }
} }
@@ -171,15 +177,21 @@ public class LaunchService {
options.setWidth(Config.getWindowWidth()); options.setWidth(Config.getWindowWidth());
options.setHeight(Config.getWindowHeight()); options.setHeight(Config.getWindowHeight());
options.setJavaPath(Config.getJavaPath()); options.setJavaPath(Config.getJavaPath());
List<String> extraArgs = new ArrayList<>();
if (Config.isSystemBasedJvm()) {
String[] systemFlags = Config.getSystemJvmFlags().split("\\s+");
for (String arg : systemFlags) {
if (!arg.isEmpty()) extraArgs.add(arg);
}
}
String args = Config.getExtraJvmArgs(); String args = Config.getExtraJvmArgs();
if (args != null && !args.isEmpty()) { if (args != null && !args.isEmpty()) {
List<String> extraArgs = new ArrayList<>(); for (String arg : args.split("\n")) {
for (String arg : args.split("\\s+")) {
arg = arg.trim(); arg = arg.trim();
if (!arg.isEmpty()) extraArgs.add(arg); if (!arg.isEmpty()) extraArgs.add(arg);
} }
options.setExtraJvmArgs(extraArgs);
} }
options.setExtraJvmArgs(extraArgs);
return options; return options;
} }
@@ -6,6 +6,7 @@ import com.google.gson.JsonObject;
import com.google.gson.JsonParser; import com.google.gson.JsonParser;
import com.google.gson.annotations.SerializedName; import com.google.gson.annotations.SerializedName;
import me.sashegdev.zernmc.launcher.utils.Config; import me.sashegdev.zernmc.launcher.utils.Config;
import me.sashegdev.zernmc.launcher.utils.LauncherLogger;
import me.sashegdev.zernmc.launcher.utils.ZAnsi; import me.sashegdev.zernmc.launcher.utils.ZAnsi;
import me.sashegdev.zernmc.launcher.utils.ZHttpClient; import me.sashegdev.zernmc.launcher.utils.ZHttpClient;
@@ -36,24 +37,57 @@ public class AuthManager {
public static final String PERM_DOWNLOAD_PACK = "download_pack"; public static final String PERM_DOWNLOAD_PACK = "download_pack";
public static boolean loadSavedSession() { public static boolean loadSavedSession() {
if (!Files.exists(AUTH_FILE)) return false; if (!Files.exists(AUTH_FILE)) {
LauncherLogger.warn("loadSavedSession: auth.json not found at " + AUTH_FILE);
return false;
}
try { try {
String json = Files.readString(AUTH_FILE); String json = Files.readString(AUTH_FILE);
AuthSession loaded = GSON.fromJson(json, AuthSession.class); AuthSession loaded = GSON.fromJson(json, AuthSession.class);
if (loaded == null || loaded.accessToken == null) return false; if (loaded == null || loaded.accessToken == null) {
LauncherLogger.warn("loadSavedSession: invalid auth.json content, deleting");
Files.deleteIfExists(AUTH_FILE);
return false;
}
session = loaded; session = loaded;
userInfo = fetchUserInfo(); LauncherLogger.info("loadSavedSession: loaded session for " + loaded.username
+ " expiresAt=" + loaded.expiresAt + " hasRefresh=" + (loaded.refreshToken != null));
refreshUserInfo();
if (isAccessTokenExpired()) { if (isAccessTokenExpired()) {
return tryRefresh(); LauncherLogger.info("loadSavedSession: token expired, attempting refresh");
boolean refreshed = tryRefresh();
if (!refreshed) {
if (session == null) {
LauncherLogger.warn("loadSavedSession: token rejected by server (401)");
return false;
}
LauncherLogger.warn("loadSavedSession: refresh failed (network/no refreshToken),"
+ " keeping session for retry on next launch");
return false;
}
} }
if (session == null) {
LauncherLogger.warn("loadSavedSession: session invalidated during token refresh");
return false;
}
LauncherLogger.info("loadSavedSession: session valid for " + session.username);
return true; return true;
} catch (Exception e) { } catch (Exception e) {
LauncherLogger.error("loadSavedSession error: " + e.getMessage());
invalidateSession();
return false; return false;
} }
} }
public static boolean tryAutoLogin() {
if (isLoggedIn()) return true;
if (!Files.exists(AUTH_FILE)) return false;
return loadSavedSession();
}
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);
} }
@@ -70,6 +104,8 @@ public class AuthManager {
if (resp.statusCode() == 200) { if (resp.statusCode() == 200) {
session = GSON.fromJson(resp.body(), AuthSession.class); session = GSON.fromJson(resp.body(), AuthSession.class);
session.expiresAt = System.currentTimeMillis() / 1000L + session.expiresIn; session.expiresAt = System.currentTimeMillis() / 1000L + session.expiresIn;
LauncherLogger.info("authRequest: login successful, expiresAt=" + session.expiresAt
+ " hasRefresh=" + (session.refreshToken != null));
saveSession(); saveSession();
userInfo = fetchUserInfo(); userInfo = fetchUserInfo();
return AuthResult.ok(); return AuthResult.ok();
@@ -87,32 +123,51 @@ public class AuthManager {
public static void logout() { public static void logout() {
if (session != null && session.refreshToken != null) { if (session != null && session.refreshToken != null) {
try { try {
post("/auth/logout", "{\"refresh_token\":\"" + session.refreshToken + "\"}"); JsonObject json = new JsonObject();
} catch (Exception ignored) {} json.addProperty("refresh_token", session.refreshToken);
post("/auth/logout", json.toString());
} catch (Exception e) {
LauncherLogger.warn("Logout error: " + e.getMessage());
}
} }
session = null; session = null;
userInfo = null; userInfo = null;
try { Files.deleteIfExists(AUTH_FILE); } catch (Exception ignored) {} try { Files.deleteIfExists(AUTH_FILE); } catch (Exception e) {
LauncherLogger.warn("Failed to delete auth.json: " + e.getMessage());
}
} }
public static boolean isLoggedIn() { public static boolean isLoggedIn() {
return session != null && session.accessToken != null; return session != null && session.accessToken != null;
} }
public static boolean authFileExists() {
return Files.exists(AUTH_FILE);
}
public static String getUsername() { public static String getUsername() {
return session != null ? session.username : "Player"; AuthSession localSession = session;
return localSession != null ? localSession.username : "Player";
} }
public static String getUuid() { public static String getUuid() {
return session != null ? session.uuid : "00000000-0000-0000-0000-000000000000"; AuthSession localSession = session;
return localSession != null ? localSession.uuid : "00000000-0000-0000-0000-000000000000";
} }
public static String getAccessToken() { public static String getAccessToken() {
if (session == null) return "0"; AuthSession localSession = session;
if (localSession == null) return "0";
if (isAccessTokenExpired()) { if (isAccessTokenExpired()) {
tryRefresh(); boolean refreshed = tryRefresh();
if (!refreshed) {
localSession = session;
if (localSession == null) return "0";
return localSession.accessToken != null ? localSession.accessToken : "0";
}
} }
return session != null && session.accessToken != null ? session.accessToken : "0"; localSession = session;
return localSession != null && localSession.accessToken != null ? localSession.accessToken : "0";
} }
private static boolean isAccessTokenExpired() { private static boolean isAccessTokenExpired() {
@@ -121,32 +176,63 @@ public class AuthManager {
} }
private static boolean tryRefresh() { private static boolean tryRefresh() {
if (session == null || session.refreshToken == null) return false; if (session == null) {
LauncherLogger.warn("tryRefresh: session is null");
return false;
}
if (session.refreshToken == null) {
LauncherLogger.warn("tryRefresh: no refreshToken in session");
return false;
}
try { try {
String body = "{\"refresh_token\":\"" + session.refreshToken + "\"}"; JsonObject json = new JsonObject();
SimpleHttpResponse resp = post("/auth/refresh", body); json.addProperty("refresh_token", session.refreshToken);
SimpleHttpResponse resp = post("/auth/refresh", json.toString());
if (resp.statusCode() == 200) { if (resp.statusCode() == 200) {
AuthSession newSession = GSON.fromJson(resp.body(), AuthSession.class); AuthSession newSession = GSON.fromJson(resp.body(), AuthSession.class);
newSession.expiresAt = System.currentTimeMillis() / 1000L + newSession.expiresIn; newSession.expiresAt = System.currentTimeMillis() / 1000L + newSession.expiresIn;
session = newSession; session = newSession;
userInfo = fetchUserInfo(); userInfo = fetchUserInfo();
if (userInfo != null) {
session.role = userInfo.role;
}
saveSession(); saveSession();
LauncherLogger.info("tryRefresh: token refreshed successfully");
return true; return true;
} }
} catch (Exception ignored) {}
if (resp.statusCode() == 401) {
LauncherLogger.warn("tryRefresh: server rejected refresh token (401)");
invalidateSession();
} else {
LauncherLogger.warn("tryRefresh: server returned " + resp.statusCode());
}
} catch (Exception e) {
LauncherLogger.warn("tryRefresh: network error: " + e.getMessage());
return false;
}
return false;
}
private static void invalidateSession() {
session = null; session = null;
userInfo = null; userInfo = null;
try { Files.deleteIfExists(AUTH_FILE); } catch (Exception ignored) {} try {
return false; Files.deleteIfExists(AUTH_FILE);
LauncherLogger.info("Session invalidated, auth.json deleted");
} catch (Exception e) {
LauncherLogger.error("Failed to delete auth.json", e);
}
} }
private static void saveSession() { private static void saveSession() {
try { try {
Files.createDirectories(AUTH_FILE.getParent()); Files.createDirectories(AUTH_FILE.getParent());
Files.writeString(AUTH_FILE, GSON.toJson(session)); Files.writeString(AUTH_FILE, GSON.toJson(session));
LauncherLogger.info("Session saved to " + AUTH_FILE);
} catch (IOException e) { } catch (IOException e) {
System.err.println(ZAnsi.yellow("Failed to save session: " + e.getMessage())); LauncherLogger.error("Failed to save session", e);
} }
} }
@@ -180,14 +266,25 @@ 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()); LauncherLogger.warn("Failed to get UserInfo: " + e.getMessage());
return null; return null;
} }
} }
public static boolean hasPass() { public static boolean hasPass() {
if (!isLoggedIn()) return false;
if (userInfo != null) return userInfo.has_pass; if (userInfo != null) return userInfo.has_pass;
return getRole() >= ROLE_PASS_HOLDER; if (getRole() >= ROLE_PASS_HOLDER) return true;
try {
String response = ZHttpClient.get("/auth/pass/my");
JsonObject json = JsonParser.parseString(response).getAsJsonObject();
if (json.has("has_active")) {
return json.get("has_active").getAsBoolean();
}
} catch (Exception e) {
LauncherLogger.warn("Failed to check pass: " + e.getMessage());
}
return false;
} }
public static boolean canViewPacks() { public static boolean canViewPacks() {
@@ -277,16 +374,30 @@ public class AuthManager {
return body.length() > 200 ? body.substring(0, 200) + "..." : body; return body.length() > 200 ? body.substring(0, 200) + "..." : body;
} }
public static void updateRole(int newRole) {
if (session != null) {
session.role = newRole;
saveSession();
}
refreshUserInfo();
}
public static void refreshUserInfo() {
UserInfo fresh = fetchUserInfo();
if (fresh != null) {
userInfo = fresh;
if (session != null) {
session.role = fresh.role;
}
}
if (session != null) {
saveSession();
}
}
public static boolean hasActivePass() { public static boolean hasActivePass() {
if (!isLoggedIn()) return false; if (!isLoggedIn()) return false;
try { return hasPass();
String response = ZHttpClient.get("/auth/pass/my");
JsonObject json = JsonParser.parseString(response).getAsJsonObject();
return json.has("has_active") && json.get("has_active").getAsBoolean();
} catch (Exception e) {
System.err.println(ZAnsi.red("Failed to check pass: ") + e.getMessage());
return false;
}
} }
public static String getPassStatus() { public static String getPassStatus() {
@@ -305,7 +416,7 @@ public class AuthManager {
@SerializedName("access_token") public String accessToken; @SerializedName("access_token") public String accessToken;
@SerializedName("refresh_token") public String refreshToken; @SerializedName("refresh_token") public String refreshToken;
@SerializedName("expires_in") public int expiresIn; @SerializedName("expires_in") public int expiresIn;
public transient long expiresAt; public long expiresAt;
public String username; public String username;
public String uuid; public String uuid;
public int role; public int role;
@@ -341,6 +452,20 @@ public class AuthManager {
public static AuthResult ok() { return new AuthResult(true, null); } public static AuthResult ok() { return new AuthResult(true, null); }
public static AuthResult fail(String msg) { return new AuthResult(false, msg); } public static AuthResult fail(String msg) { return new AuthResult(false, msg); }
} }
// === TEST HELPERS ===
static void resetForTest() {
session = null;
userInfo = null;
}
static void setTestSession(AuthSession s) {
session = s;
}
static void setTestUserInfo(UserInfo u) {
userInfo = u;
}
} }
class SimpleHttpResponse { class SimpleHttpResponse {
@@ -8,6 +8,7 @@ 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.ui.jfx.JFXLauncher;
import me.sashegdev.zernmc.launcher.utils.ConsoleUtils; import me.sashegdev.zernmc.launcher.utils.ConsoleUtils;
import me.sashegdev.zernmc.launcher.utils.LauncherLogger;
import me.sashegdev.zernmc.launcher.utils.ZAnsi; import me.sashegdev.zernmc.launcher.utils.ZAnsi;
import java.io.BufferedReader; import java.io.BufferedReader;
@@ -160,14 +161,15 @@ public class MinecraftLib {
} }
private void safeDeleteDirectory(Path dir) { private void safeDeleteDirectory(Path dir) {
try { try (var stream = Files.walk(dir)) {
Files.walk(dir) stream.sorted((a, b) -> b.compareTo(a))
.sorted((a, b) -> b.compareTo(a)) .forEach(p -> {
.forEach(p -> { try { Files.deleteIfExists(p); }
try { Files.deleteIfExists(p); } catch (IOException e) { /* ignore */ }
catch (IOException ignored) {} });
}); } catch (IOException e) {
} catch (IOException ignored) {} LauncherLogger.warn("safeDeleteDirectory: " + e.getMessage());
}
} }
private void deleteOldVersionDirs(Path versionsDir, String keepVersion) throws IOException { private void deleteOldVersionDirs(Path versionsDir, String keepVersion) throws IOException {
@@ -8,6 +8,7 @@ import com.google.gson.JsonObject;
import com.google.gson.JsonParser; import com.google.gson.JsonParser;
import me.sashegdev.zernmc.launcher.auth.AuthManager; import me.sashegdev.zernmc.launcher.auth.AuthManager;
import me.sashegdev.zernmc.launcher.utils.LauncherLogger;
import me.sashegdev.zernmc.launcher.utils.ProgressBar; import me.sashegdev.zernmc.launcher.utils.ProgressBar;
import me.sashegdev.zernmc.launcher.utils.ZAnsi; import me.sashegdev.zernmc.launcher.utils.ZAnsi;
import me.sashegdev.zernmc.launcher.utils.ZHttpClient; import me.sashegdev.zernmc.launcher.utils.ZHttpClient;
@@ -19,6 +20,7 @@ import java.net.http.HttpResponse;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.security.MessageDigest; import java.security.MessageDigest;
import java.time.Duration;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter; import java.time.format.DateTimeFormatter;
import java.util.*; import java.util.*;
@@ -118,7 +120,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()); LauncherLogger.warn("Error parsing pack: " + e.getMessage());
} }
} }
@@ -137,7 +139,7 @@ public class PackDownloader {
* Install or update a pack from the server * 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...")); LauncherLogger.info("Installing pack " + packName + " from server...");
// 1. Get manifest // 1. Get manifest
PackManifest manifest = getPackManifest(packName); PackManifest manifest = getPackManifest(packName);
@@ -463,12 +465,19 @@ public class PackDownloader {
*/ */
private void downloadFile(FileInfo file, Path destination) throws Exception { private void downloadFile(FileInfo file, Path destination) throws Exception {
String url = ZHttpClient.getBaseUrl() + file.getUrl(); String url = ZHttpClient.getBaseUrl() + file.getUrl();
String accessToken = AuthManager.getAccessToken();
HttpRequest request = HttpRequest.newBuilder() HttpRequest.Builder builder = HttpRequest.newBuilder()
.uri(java.net.URI.create(url)) .uri(java.net.URI.create(url))
.GET() .timeout(Duration.ofSeconds(60))
.build(); .header("User-Agent", "ZernMC-Launcher/1.0")
.GET();
if (accessToken != null && !accessToken.equals("0")) {
builder.header("Authorization", "Bearer " + accessToken);
}
HttpRequest request = builder.build();
HttpResponse<InputStream> response = httpClient.send(request, HttpResponse<InputStream> response = httpClient.send(request,
HttpResponse.BodyHandlers.ofInputStream()); HttpResponse.BodyHandlers.ofInputStream());
@@ -11,7 +11,9 @@ import java.net.http.HttpResponse;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.nio.file.StandardOpenOption; import java.nio.file.StandardOpenOption;
import java.util.Arrays;
import java.util.HashMap; import java.util.HashMap;
import java.util.List;
import java.util.Map; import java.util.Map;
public class ForgeInstaller { public class ForgeInstaller {
@@ -240,29 +242,35 @@ public class ForgeInstaller {
System.out.println(ZAnsi.cyan("Checking and downloading missing libraries...")); System.out.println(ZAnsi.cyan("Checking and downloading missing libraries..."));
// List of problematic libraries and their alternate URLs // List of problematic libraries and their alternate URLs
Map<String, String> alternativeUrls = new HashMap<>();
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");
alternativeUrls.put("org/ow2/asm/asm/9.6/asm-9.6.jar",
"https://mirrors.huaweicloud.com/repository/maven/org/ow2/asm/asm/9.6/asm-9.6.jar");
Path librariesDir = instance.getPath().resolve("libraries"); Path librariesDir = instance.getPath().resolve("libraries");
for (Map.Entry<String, String> entry : alternativeUrls.entrySet()) { // Map from maven path to list of mirror URLs (tried in order)
Map<String, List<String>> alternativeUrls = new HashMap<>();
alternativeUrls.put("org/ow2/asm/asm/9.6/asm-9.6.jar", Arrays.asList(
"https://repo1.maven.org/maven2/org/ow2/asm/asm/9.6/asm-9.6.jar",
"https://mirrors.huaweicloud.com/repository/maven/org/ow2/asm/asm/9.6/asm-9.6.jar"
));
for (Map.Entry<String, List<String>> entry : alternativeUrls.entrySet()) {
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("Downloading: " + target.getFileName()));
for (int attempt = 1; attempt <= 3; attempt++) { boolean downloaded = false;
try { for (String mirrorUrl : entry.getValue()) {
downloadFileWithProgress(entry.getValue(), target); for (int attempt = 1; attempt <= 3; attempt++) {
break; try {
} catch (Exception e) { downloadFileWithProgress(mirrorUrl, target);
if (attempt == 3) throw e; downloaded = true;
System.out.println(ZAnsi.yellow("Повторная попытка " + attempt + "/3...")); break;
Thread.sleep(2000); } catch (Exception e) {
if (attempt == 3 && mirrorUrl.equals(entry.getValue().get(entry.getValue().size() - 1))) throw e;
System.out.println(ZAnsi.yellow("Retry " + attempt + "/3..."));
try { Thread.sleep(2000); } catch (InterruptedException ignored) {}
}
} }
if (downloaded) break;
} }
} }
} }
@@ -183,6 +183,7 @@ public class VersionInstaller {
} }
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join(); CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
executor.shutdown();
ProgressBar.finish("Assets downloaded (" + success[0] + " ok, " + failed[0] + " skipped)"); ProgressBar.finish("Assets downloaded (" + success[0] + " ok, " + failed[0] + " skipped)");
@@ -59,7 +59,7 @@ public class ArrowMenu {
if (next == -1) { if (next == -1) {
return -1; return -1;
} }
if (next == 0x5B) { // '[' if (next == 0x5B || next == 0x4F) { // '[' (CSI) or 'O' (SS3)
int arrow = nonBlockingRead(); int arrow = nonBlockingRead();
if (arrow == 0x41) { // Up if (arrow == 0x41) { // Up
selected = (selected - 1 + options.size()) % options.size(); selected = (selected - 1 + options.size()) % options.size();
@@ -14,14 +14,20 @@ 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.MinecraftLib; import me.sashegdev.zernmc.launcher.minecraft.MinecraftLib;
import me.sashegdev.zernmc.launcher.minecraft.PackDownloader;
import me.sashegdev.zernmc.launcher.minecraft.ServerPack;
import me.sashegdev.zernmc.launcher.minecraft.launch.LaunchCommandBuilder; import me.sashegdev.zernmc.launcher.minecraft.launch.LaunchCommandBuilder;
import me.sashegdev.zernmc.launcher.utils.Config; import me.sashegdev.zernmc.launcher.utils.Config;
import me.sashegdev.zernmc.launcher.utils.LauncherLogger;
import java.awt.Desktop;
import java.io.BufferedReader; import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream; import java.io.InputStream;
import java.io.OutputStream; import java.io.OutputStream;
import java.io.InputStreamReader; import java.io.InputStreamReader;
import java.net.HttpURLConnection; import java.net.HttpURLConnection;
import me.sashegdev.zernmc.launcher.utils.ZHttpClient;
import java.net.URL; import java.net.URL;
import java.util.jar.JarEntry; import java.util.jar.JarEntry;
import java.util.jar.JarFile; import java.util.jar.JarFile;
@@ -33,7 +39,9 @@ import java.nio.file.Paths;
import java.nio.file.StandardOpenOption; import java.nio.file.StandardOpenOption;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter; import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.HashMap; import java.util.HashMap;
import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.concurrent.Executors; import java.util.concurrent.Executors;
import com.sun.net.httpserver.HttpServer; import com.sun.net.httpserver.HttpServer;
@@ -41,7 +49,7 @@ import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.Headers; import com.sun.net.httpserver.Headers;
public class JFXLauncher extends Application { public class JFXLauncher extends Application {
private static final int PORT = 8080; private static int PORT = 8080;
private static final String APP_TITLE = "ZernMC Launcher"; private static final String APP_TITLE = "ZernMC Launcher";
private static final String LAUNCHER_SERVER = System.getProperty("launcher.server", "http://87.120.187.36:1582"); private static final String LAUNCHER_SERVER = System.getProperty("launcher.server", "http://87.120.187.36:1582");
private final LauncherAPI api = new LauncherAPI(); private final LauncherAPI api = new LauncherAPI();
@@ -57,6 +65,9 @@ public class JFXLauncher extends Application {
private static volatile int installProgressCurrent = 0; private static volatile int installProgressCurrent = 0;
private static volatile int installProgressTotal = 100; private static volatile int installProgressTotal = 100;
private static volatile boolean installInProgress = false; private static volatile boolean installInProgress = false;
private static volatile String installStageName = "";
private static volatile int installStageIndex = 0;
private static volatile int installStageCount = 1;
private static final java.util.concurrent.CopyOnWriteArrayList<LogConsumer> logConsumers = new java.util.concurrent.CopyOnWriteArrayList<>(); 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<>(); private static final java.util.concurrent.CopyOnWriteArrayList<LogConsumer> gameLogConsumers = new java.util.concurrent.CopyOnWriteArrayList<>();
@@ -87,6 +98,21 @@ public class JFXLauncher extends Application {
installProgressTotal = total; installProgressTotal = total;
} }
public static void setInstallStage(String stageName, int stageIndex, int stageCount) {
installStageName = stageName;
installStageIndex = stageIndex;
installStageCount = stageCount;
}
public static void setInstallProgressWithStage(String label, int current, int total, String stageName, int stageIndex, int stageCount) {
installProgressLabel = label;
installProgressCurrent = current;
installProgressTotal = total;
installStageName = stageName;
installStageIndex = stageIndex;
installStageCount = stageCount;
}
public static void setInstallInProgress(boolean inProgress) { public static void setInstallInProgress(boolean inProgress) {
installInProgress = inProgress; installInProgress = inProgress;
} }
@@ -95,30 +121,31 @@ public class JFXLauncher extends Application {
return installInProgress; return installInProgress;
} }
public static void appendLauncherLog(String log) { public static void appendLauncherLog(String msg) {
synchronized (launcherLogBuffer) { synchronized (launcherLogBuffer) {
launcherLogBuffer.append(log).append("\n"); launcherLogBuffer.append(msg).append("\n");
} }
LauncherLogger.info("[EXT] " + msg);
for (LogConsumer consumer : logConsumers) { for (LogConsumer consumer : logConsumers) {
try { consumer.onLog(log); } catch (Exception ignored) {} try { consumer.onLog(msg); } catch (Exception ignored) {}
} }
} }
public static void appendGameLog(String log) { public static void appendGameLog(String msg) {
System.out.println("[GAME] " + log);
synchronized (gameLogBuffer) { synchronized (gameLogBuffer) {
gameLogBuffer.append(log).append("\n"); gameLogBuffer.append(msg).append("\n");
if (gameLogFile != null) { if (gameLogFile != null) {
try { try {
String timestamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss")); String timestamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss"));
Files.writeString(gameLogFile, "[" + timestamp + "] " + log + "\n", Files.writeString(gameLogFile, "[" + timestamp + "] " + msg + "\n",
StandardOpenOption.CREATE, StandardOpenOption.APPEND); StandardOpenOption.CREATE, StandardOpenOption.APPEND);
} catch (Exception ignored) {} } catch (Exception ignored) {}
} }
} }
LauncherLogger.info("[GAME] " + msg);
for (LogConsumer consumer : gameLogConsumers) { for (LogConsumer consumer : gameLogConsumers) {
try { consumer.onLog(log); } catch (Exception ignored) {} try { consumer.onLog(msg); } catch (Exception ignored) {}
} }
} }
@@ -281,18 +308,37 @@ public class JFXLauncher extends Application {
this.mainStage = stage; this.mainStage = stage;
try { try {
// Initialize launcher log file launcherLogFile = LauncherLogger.getLogFile();
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(); extractAssets();
log("Starting JFX UI..."); log("Starting JFX UI...");
log("Loading saved session...");
boolean sessionLoaded = false;
try {
sessionLoaded = AuthManager.loadSavedSession();
} catch (Exception e) {
log("Session load error: " + e.getMessage());
}
if (sessionLoaded) {
log("Session loaded for: " + AuthManager.getUsername());
log("Pass active: " + AuthManager.hasActivePass());
log("Role: " + AuthManager.getRole() + " (" + AuthManager.getRoleName() + ")");
} else {
log("No saved session found, will show login screen");
}
log("Starting background network init...");
new Thread(() -> {
try {
ZHttpClient.checkAllServicesOnStartup();
log("Network init complete");
} catch (Exception e) {
log("Network init warning: " + e.getMessage());
}
}, "network-init").start();
startServer(); startServer();
WebView webView = new WebView(); WebView webView = new WebView();
webView.setContextMenuEnabled(false);
WebEngine engine = webView.getEngine(); WebEngine engine = webView.getEngine();
engine.setJavaScriptEnabled(true); engine.setJavaScriptEnabled(true);
@@ -305,14 +351,67 @@ public class JFXLauncher extends Application {
} }
}); });
engine.setOnAlert(e -> log("[UI] Alert: " + e.getData())); // Capture JS console.log/error via alert bridge
engine.setOnAlert(e -> {
String msg = e.getData();
if (msg.startsWith("[LOG] ")) {
System.out.println("[JS] " + msg.substring(6));
} else if (msg.startsWith("[ERR] ")) {
System.err.println("[JS] " + msg.substring(6));
} else {
log("[UI] Alert: " + msg);
}
});
engine.getLoadWorker().stateProperty().addListener((obs, oldState, newState) -> {
if (newState == Worker.State.SUCCEEDED) {
// Override console.log/error to use alert bridge
engine.executeScript(
"if (!window._consoleOverridden) {" +
" window._consoleOverridden = true;" +
" var _origLog = console.log;" +
" var _origErr = console.error;" +
" console.log = function() {" +
" var args = Array.prototype.slice.call(arguments).map(function(a){return typeof a==='object'?JSON.stringify(a):String(a)});" +
" alert('[LOG] ' + args.join(' '));" +
" _origLog.apply(console, arguments);" +
" };" +
" console.error = function() {" +
" var args = Array.prototype.slice.call(arguments).map(function(a){return typeof a==='object'?JSON.stringify(a):String(a)});" +
" alert('[ERR] ' + args.join(' '));" +
" _origErr.apply(console, arguments);" +
" };" +
"}"
);
engine.executeScript(
"window.open = function(url, name, features) {" +
" var req = new XMLHttpRequest();" +
" req.open('POST', '/api/open-url', false);" +
" req.send(JSON.stringify({url: url}));" +
" return window;" +
"};" +
"document.addEventListener('click', function(e) {" +
" var target = e.target;" +
" while (target && target.tagName !== 'A') target = target.parentNode;" +
" if (target && target.href && target.href !== '#' && !target.href.startsWith('http://localhost:" + PORT + "/')) {" +
" e.preventDefault();" +
" var req = new XMLHttpRequest();" +
" req.open('POST', '/api/open-url', false);" +
" req.send(JSON.stringify({url: target.href}));" +
" }" +
"}, true);"
);
}
});
String url = "http://localhost:" + PORT + "/assets/ui/index.html"; String url = "http://localhost:" + PORT + "/assets/ui/index.html";
engine.load(url); engine.load(url);
stage.setTitle(APP_TITLE); stage.setTitle(APP_TITLE);
stage.setWidth(1280); int winW = Config.getWindowWidth();
stage.setHeight(800); int winH = Config.getWindowHeight();
stage.setWidth(Math.max(winW, 800));
stage.setHeight(Math.max(winH, 600));
stage.setMinWidth(800); stage.setMinWidth(800);
stage.setMinHeight(600); stage.setMinHeight(600);
stage.setScene(new Scene(webView)); stage.setScene(new Scene(webView));
@@ -335,7 +434,16 @@ public class JFXLauncher extends Application {
} }
private void startServer() throws Exception { private void startServer() throws Exception {
server = HttpServer.create(new InetSocketAddress("localhost", PORT), 0); int maxPortAttempts = 20;
for (int port = PORT; port < PORT + maxPortAttempts; port++) {
try {
server = HttpServer.create(new InetSocketAddress("127.0.0.1", port), 0);
PORT = port;
break;
} catch (java.net.BindException e) {
if (port == PORT + maxPortAttempts - 1) throw e;
}
}
server.createContext("/api/login", this::handleLogin); server.createContext("/api/login", this::handleLogin);
server.createContext("/api/auto-login", this::handleAutoLogin); server.createContext("/api/auto-login", this::handleAutoLogin);
@@ -354,9 +462,22 @@ public class JFXLauncher extends Application {
server.createContext("/api/settings", this::handleSettings); server.createContext("/api/settings", this::handleSettings);
server.createContext("/api/activate-pass", this::handleActivatePass); server.createContext("/api/activate-pass", this::handleActivatePass);
server.createContext("/api/register", this::handleRegister); server.createContext("/api/register", this::handleRegister);
server.createContext("/api/pack-info", this::handlePackInfo);
server.createContext("/api/shutdown", this::handleShutdown); server.createContext("/api/shutdown", this::handleShutdown);
server.createContext("/api/exit", this::handleExit); server.createContext("/api/exit", this::handleExit);
server.createContext("/api/news", this::handleNews);
server.createContext("/api/open-url", this::handleOpenUrl);
server.createContext("/api/exit-parent", this::handleExitParent); server.createContext("/api/exit-parent", this::handleExitParent);
server.createContext("/api/system-info", this::handleSystemInfo);
server.createContext("/api/open-log-file", this::handleOpenLogFile);
server.createContext("/api/friends/list", this::handleFriendList);
server.createContext("/api/friends/add", this::handleFriendAdd);
server.createContext("/api/friends/remove", this::handleFriendRemove);
server.createContext("/api/friends/accept", this::handleFriendAccept);
server.createContext("/api/friends/requests", this::handleFriendRequests);
server.createContext("/api/friends/status", this::handleFriendStatus);
server.createContext("/api/playtime/sync", this::handlePlaytimeSync);
server.createContext("/api/playtime/stats", this::handlePlaytimeStats);
server.createContext("/assets/", this::handleStatic); server.createContext("/assets/", this::handleStatic);
server.setExecutor(Executors.newCachedThreadPool()); server.setExecutor(Executors.newCachedThreadPool());
@@ -369,8 +490,51 @@ public class JFXLauncher extends Application {
if (server != null) server.stop(0); if (server != null) server.stop(0);
} }
private void handleSystemInfo(HttpExchange exchange) {
try {
Map<String, Object> info = new HashMap<>();
info.put("cpuCores", Config.getSystemCpuCores());
info.put("totalRamMB", Config.getSystemTotalRamMB());
info.put("systemJvmFlags", Config.getSystemJvmFlags());
sendJson(exchange, Map.of("success", true, "data", info));
} catch (Exception e) {
sendJson(exchange, Map.of("success", false, "error", e.getMessage()));
}
}
private void handleOpenLogFile(HttpExchange exchange) {
try {
if (gameLogFile != null && Files.exists(gameLogFile)) {
if (Desktop.isDesktopSupported()) {
Desktop.getDesktop().open(gameLogFile.toFile());
sendJson(exchange, Map.of("success", true));
} else {
sendJson(exchange, Map.of("success", false, "error", "Desktop not supported"));
}
} else {
sendJson(exchange, Map.of("success", false, "error", "No game log file found"));
}
} catch (Exception e) {
sendJson(exchange, Map.of("success", false, "error", e.getMessage()));
}
}
private void handleOpenUrl(HttpExchange exchange) {
try {
Map<String, String> body = parseJson(exchange.getRequestBody());
String url = body.get("url");
if (url != null && !url.isEmpty()) {
openInBrowser(url);
}
sendJson(exchange, Map.of("success", true));
} catch (Exception e) {
sendJson(exchange, Map.of("success", false, "error", e.getMessage()));
}
}
private void handleLogin(HttpExchange exchange) { private void handleLogin(HttpExchange exchange) {
try { try {
log("handleLogin: " + exchange.getRequestMethod() + " from " + exchange.getRemoteAddress());
if (!"POST".equals(exchange.getRequestMethod())) { if (!"POST".equals(exchange.getRequestMethod())) {
sendJson(exchange, Map.of("success", false, "error", "Method not supported")); sendJson(exchange, Map.of("success", false, "error", "Method not supported"));
return; return;
@@ -385,19 +549,27 @@ public class JFXLauncher extends Application {
Map<String, Object> data = new HashMap<>(); Map<String, Object> data = new HashMap<>();
data.put("username", result.getData().getUsername()); data.put("username", result.getData().getUsername());
data.put("token", result.getData().getToken()); data.put("token", result.getData().getToken());
data.put("passActive", AuthManager.hasActivePass());
data.put("role", AuthManager.getRole());
data.put("roleName", AuthManager.getRoleName());
sendJson(exchange, Map.of("success", true, "data", data)); sendJson(exchange, Map.of("success", true, "data", data));
log("Login: " + username); log("Login success: " + username);
} else { } else {
log("Login failed: " + result.getError());
sendJson(exchange, Map.of("success", false, "error", result.getError())); sendJson(exchange, Map.of("success", false, "error", result.getError()));
} }
} catch (Exception e) { } catch (Exception e) {
log("Login error: " + e.getMessage());
e.printStackTrace();
sendJson(exchange, Map.of("success", false, "error", e.getMessage())); sendJson(exchange, Map.of("success", false, "error", e.getMessage()));
} }
} }
private void handleAccount(HttpExchange exchange) { private void handleAccount(HttpExchange exchange) {
try { try {
log("handleAccount: isLoggedIn=" + api.isLoggedIn());
if (!api.isLoggedIn()) { if (!api.isLoggedIn()) {
log("handleAccount: not authenticated");
sendJson(exchange, Map.of("success", false, "error", "Not authenticated")); sendJson(exchange, Map.of("success", false, "error", "Not authenticated"));
return; return;
} }
@@ -414,18 +586,27 @@ public class JFXLauncher extends Application {
private void handleAutoLogin(HttpExchange exchange) { private void handleAutoLogin(HttpExchange exchange) {
try { try {
if (AuthManager.loadSavedSession()) { boolean loggedIn = AuthManager.isLoggedIn();
boolean authExists = AuthManager.authFileExists();
System.out.println("[AUTH] handleAutoLogin: isLoggedIn=" + loggedIn + " authFile=" + authExists);
log("handleAutoLogin: isLoggedIn=" + loggedIn + " authFile=" + authExists);
if (AuthManager.tryAutoLogin()) {
Map<String, Object> data = new HashMap<>(); Map<String, Object> data = new HashMap<>();
data.put("username", AuthManager.getUsername()); data.put("username", AuthManager.getUsername());
data.put("passActive", AuthManager.hasActivePass()); data.put("passActive", AuthManager.hasActivePass());
data.put("role", AuthManager.getRole()); data.put("role", AuthManager.getRole());
data.put("roleName", AuthManager.getRoleName()); data.put("roleName", AuthManager.getRoleName());
sendJson(exchange, Map.of("success", true, "data", data, "autoLogin", true)); sendJson(exchange, Map.of("success", true, "data", data, "autoLogin", true));
System.out.println("[AUTH] Auto-login performed: " + AuthManager.getUsername());
log("Auto-login performed: " + AuthManager.getUsername()); log("Auto-login performed: " + AuthManager.getUsername());
} else { } else {
System.out.println("[AUTH] handleAutoLogin: no valid session (authFile=" + authExists + ")");
log("handleAutoLogin: no valid session (authFile=" + authExists + ")");
sendJson(exchange, Map.of("success", false, "autoLogin", false)); sendJson(exchange, Map.of("success", false, "autoLogin", false));
} }
} catch (Exception e) { } catch (Exception e) {
System.out.println("[AUTH] handleAutoLogin error: " + e.getMessage());
log("handleAutoLogin error: " + e.getMessage());
sendJson(exchange, Map.of("success", false, "error", e.getMessage())); sendJson(exchange, Map.of("success", false, "error", e.getMessage()));
} }
} }
@@ -461,7 +642,11 @@ public class JFXLauncher extends Application {
sendJson(exchange, Map.of("success", false, "error", "Pack not found")); sendJson(exchange, Map.of("success", false, "error", "Pack not found"));
return; return;
} }
log("handleLaunch: " + name + " isServerPack=" + instance.isServerPack() + " hasPass=" + AuthManager.hasActivePass() + " isLoggedIn=" + api.isLoggedIn());
if (instance.isServerPack() && !AuthManager.hasActivePass()) { if (instance.isServerPack() && !AuthManager.hasActivePass()) {
log("handleLaunch: server pack requires active pass");
sendJson(exchange, Map.of("success", false, "error", "Server pack requires an active pass")); sendJson(exchange, Map.of("success", false, "error", "Server pack requires an active pass"));
return; return;
} }
@@ -472,11 +657,13 @@ public class JFXLauncher extends Application {
data.put("pid", result.getData().getPid()); data.put("pid", result.getData().getPid());
data.put("status", result.getData().getStatus()); data.put("status", result.getData().getStatus());
sendJson(exchange, Map.of("success", true, "data", data)); sendJson(exchange, Map.of("success", true, "data", data));
log("Launched: " + name); log("Launched: " + name + " pid=" + result.getData().getPid());
} else { } else {
log("Launch failed: " + result.getError());
sendJson(exchange, Map.of("success", false, "error", result.getError())); sendJson(exchange, Map.of("success", false, "error", result.getError()));
} }
} catch (Exception e) { } catch (Exception e) {
log("handleLaunch error: " + e.getMessage());
sendJson(exchange, Map.of("success", false, "error", e.getMessage())); sendJson(exchange, Map.of("success", false, "error", e.getMessage()));
} }
} }
@@ -509,37 +696,67 @@ public class JFXLauncher extends Application {
Instance instance = InstanceManager.getInstance(name); Instance instance = InstanceManager.getInstance(name);
if (instance != null) { if (instance != null) {
instance.setMinecraftVersion(version); if (!"zernmc".equalsIgnoreCase(loader)) {
instance.setLoaderType(loader); instance.setMinecraftVersion(version);
if (loaderVersion != null) { instance.setLoaderType(loader);
instance.setLoaderVersion(loaderVersion); if (loaderVersion != null) {
instance.setLoaderVersion(loaderVersion);
}
} }
sendJson(exchange, Map.of("success", true, "message", "Installation started")); sendJson(exchange, Map.of("success", true, "message", "Installation started"));
setInstallInProgress(true); setInstallInProgress(true);
setInstallProgress("Preparing...", 0, 100); setInstallProgressWithStage("Preparing...", 0, 100, "Preparing", 0, 4);
Thread installThread = new Thread(() -> { Thread installThread = new Thread(() -> {
try { try {
MinecraftLib lib = new MinecraftLib(instance);
boolean success = false; boolean success = false;
if ("vanilla".equalsIgnoreCase(loader)) {
success = lib.installMinecraft(version); if ("zernmc".equalsIgnoreCase(loader)) {
setInstallProgressWithStage("Fetching pack info...", 10, 100, "Fetching pack info", 1, 4);
PackDownloader downloader = new PackDownloader(instance);
var packs = downloader.getAvailablePacks();
ServerPack pack = null;
for (ServerPack p : packs) {
if (p.getName().equals(version)) {
pack = p;
break;
}
}
if (pack != null) {
setInstallProgressWithStage("Downloading pack files...", 30, 100, "Downloading", 2, 4);
success = downloader.installOrUpdatePack(version, pack);
if (success) {
setInstallProgressWithStage("Finalizing...", 90, 100, "Finalizing", 3, 4);
}
} else {
log("Install error: pack not found on server: " + version + " (available: " + (packs != null ? packs.size() : 0) + ")");
}
} else { } else {
success = lib.installPack(name, version, loader, loaderVersion != null ? loaderVersion : ""); MinecraftLib lib = new MinecraftLib(instance);
setInstallProgressWithStage("Installing Minecraft...", 20, 100, "Installing Minecraft", 1, 3);
if ("vanilla".equalsIgnoreCase(loader)) {
success = lib.installMinecraft(version);
} else {
setInstallProgressWithStage("Installing loader...", 30, 100, "Installing loader", 1, 3);
success = lib.installPack(name, version, loader, loaderVersion != null ? loaderVersion : "");
}
setInstallProgressWithStage("Finalizing...", 90, 100, "Finalizing", 2, 3);
} }
setInstallInProgress(false); setInstallInProgress(false);
if (success) { if (success) {
setInstallProgressWithStage("Installation complete!", 100, 100, "Done", 0, 1);
log("Installed: " + name); log("Installed: " + name);
} else { } else {
setInstallProgressWithStage("Installation failed", 0, 100, "Failed", 0, 1);
log("Install error: " + name); log("Install error: " + name);
} }
} catch (Exception e) { } catch (Exception e) {
log("Install error: " + e.getMessage()); log("Install error: " + e.getMessage());
setInstallInProgress(false); setInstallInProgress(false);
setInstallProgressWithStage("Error: " + e.getMessage(), 0, 100, "Error", 0, 1);
} }
}); });
installThread.setDaemon(true); installThread.setDaemon(true);
@@ -561,6 +778,9 @@ public class JFXLauncher extends Application {
progress.put("current", installProgressCurrent); progress.put("current", installProgressCurrent);
progress.put("total", installProgressTotal); progress.put("total", installProgressTotal);
progress.put("inProgress", installInProgress); progress.put("inProgress", installInProgress);
progress.put("stageName", installStageName);
progress.put("stageIndex", installStageIndex);
progress.put("stageCount", Math.max(installStageCount, 1));
if (installInProgress && installProgressTotal > 0) { if (installInProgress && installProgressTotal > 0) {
progress.put("percent", (int) ((installProgressCurrent * 100.0) / installProgressTotal)); progress.put("percent", (int) ((installProgressCurrent * 100.0) / installProgressTotal));
@@ -579,6 +799,7 @@ public class JFXLauncher extends Application {
} }
private void handleLogsStream(HttpExchange exchange) { private void handleLogsStream(HttpExchange exchange) {
final LogConsumer[] consumerRef = new LogConsumer[1];
try { try {
exchange.getResponseHeaders().set("Content-Type", "text/event-stream"); exchange.getResponseHeaders().set("Content-Type", "text/event-stream");
exchange.getResponseHeaders().set("Cache-Control", "no-cache"); exchange.getResponseHeaders().set("Cache-Control", "no-cache");
@@ -586,7 +807,6 @@ public class JFXLauncher extends Application {
exchange.sendResponseHeaders(200, 0); exchange.sendResponseHeaders(200, 0);
final OutputStream os = exchange.getResponseBody(); final OutputStream os = exchange.getResponseBody();
int[] lastLength = {getLauncherLogs().length()};
LogConsumer consumer = new LogConsumer() { LogConsumer consumer = new LogConsumer() {
@Override @Override
@@ -600,25 +820,30 @@ public class JFXLauncher extends Application {
} }
} }
}; };
consumerRef[0] = consumer;
addLogConsumer(consumer); addLogConsumer(consumer);
Thread.currentThread().setName("sse-logs-stream");
while (!Thread.currentThread().isInterrupted()) { while (!Thread.currentThread().isInterrupted()) {
Thread.sleep(10000); try {
os.write(": heartbeat\n\n".getBytes(StandardCharsets.UTF_8));
os.flush();
Thread.sleep(10000);
} catch (InterruptedException e) { break; } catch (Exception e) { break; }
} }
} catch (Exception ignored) {} finally { } catch (Exception ignored) {} finally {
removeLogConsumer(consumer); if (consumerRef[0] != null) removeLogConsumer(consumerRef[0]);
try { exchange.getResponseBody().close(); } catch (Exception ignored) {} try { exchange.getResponseBody().close(); } catch (Exception ignored) {}
} }
} }
private LogConsumer consumer = null;
private void handleGameLogs(HttpExchange exchange) { private void handleGameLogs(HttpExchange exchange) {
sendJson(exchange, Map.of("success", true, "data", getGameLogs())); sendJson(exchange, Map.of("success", true, "data", getGameLogs()));
} }
private void handleGameLogsStream(HttpExchange exchange) { private void handleGameLogsStream(HttpExchange exchange) {
final LogConsumer[] consumerRef = new LogConsumer[1];
try { try {
exchange.getResponseHeaders().set("Content-Type", "text/event-stream"); exchange.getResponseHeaders().set("Content-Type", "text/event-stream");
exchange.getResponseHeaders().set("Cache-Control", "no-cache"); exchange.getResponseHeaders().set("Cache-Control", "no-cache");
@@ -627,7 +852,7 @@ public class JFXLauncher extends Application {
final OutputStream os = exchange.getResponseBody(); final OutputStream os = exchange.getResponseBody();
consumer = new LogConsumer() { LogConsumer consumer = new LogConsumer() {
@Override @Override
public synchronized void onLog(String line) { public synchronized void onLog(String line) {
try { try {
@@ -639,17 +864,20 @@ public class JFXLauncher extends Application {
} }
} }
}; };
consumerRef[0] = consumer;
addGameLogConsumer(consumer); addGameLogConsumer(consumer);
Thread.currentThread().setName("sse-game-logs-stream");
while (!Thread.currentThread().isInterrupted()) { while (!Thread.currentThread().isInterrupted()) {
Thread.sleep(10000); try {
os.write(": heartbeat\n\n".getBytes(StandardCharsets.UTF_8));
os.flush();
Thread.sleep(10000);
} catch (InterruptedException e) { break; } catch (Exception e) { break; }
} }
} catch (Exception ignored) {} finally { } catch (Exception ignored) {} finally {
if (consumer != null) { if (consumerRef[0] != null) removeGameLogConsumer(consumerRef[0]);
removeGameLogConsumer(consumer);
}
consumer = null;
try { exchange.getResponseBody().close(); } catch (Exception ignored) {} try { exchange.getResponseBody().close(); } catch (Exception ignored) {}
} }
} }
@@ -697,6 +925,26 @@ public class JFXLauncher extends Application {
} }
} }
private void handleNews(HttpExchange exchange) {
try {
var url = new java.net.URL(ZHttpClient.getBaseUrl() + "/news");
var conn = (HttpURLConnection) url.openConnection();
conn.setRequestMethod("GET");
conn.setConnectTimeout(5000);
conn.setReadTimeout(5000);
int code = conn.getResponseCode();
if (code == 200) {
byte[] body = conn.getInputStream().readAllBytes();
var raw = new String(body, java.nio.charset.StandardCharsets.UTF_8);
sendJson(exchange, Map.of("success", true, "data", raw));
} else {
sendJson(exchange, Map.of("success", false, "error", "Server returned " + code));
}
} catch (Exception e) {
sendJson(exchange, Map.of("success", false, "error", e.getMessage()));
}
}
private void handleActivatePass(HttpExchange exchange) { private void handleActivatePass(HttpExchange exchange) {
try { try {
if (!api.isLoggedIn()) { if (!api.isLoggedIn()) {
@@ -704,7 +952,7 @@ public class JFXLauncher extends Application {
return; return;
} }
Map<String, String> body = parseJson(exchange.getRequestBody()); Map<String, String> body = parseJson(exchange.getRequestBody());
String code = body.get("code"); String code = body.get("pass_code");
if (code == null || code.isEmpty()) { if (code == null || code.isEmpty()) {
sendJson(exchange, Map.of("success", false, "error", "Enter pass code")); sendJson(exchange, Map.of("success", false, "error", "Enter pass code"));
return; return;
@@ -735,6 +983,9 @@ public class JFXLauncher extends Application {
Map<String, Object> data = new HashMap<>(); Map<String, Object> data = new HashMap<>();
data.put("username", result.getData().getUsername()); data.put("username", result.getData().getUsername());
data.put("token", result.getData().getToken()); data.put("token", result.getData().getToken());
data.put("passActive", AuthManager.hasActivePass());
data.put("role", AuthManager.getRole());
data.put("roleName", AuthManager.getRoleName());
sendJson(exchange, Map.of("success", true, "data", data)); sendJson(exchange, Map.of("success", true, "data", data));
log("Registered: " + username); log("Registered: " + username);
} else { } else {
@@ -745,8 +996,76 @@ public class JFXLauncher extends Application {
} }
} }
private void handlePackInfo(HttpExchange exchange) {
try {
Map<String, String> params = parseQuery(exchange.getRequestURI().getQuery());
String name = params.get("name");
if (name == null || name.isEmpty()) {
sendJson(exchange, Map.of("success", false, "error", "Missing pack name"));
return;
}
Instance instance = InstanceManager.getInstance(name);
if (instance == null) {
sendJson(exchange, Map.of("success", false, "error", "Pack not found"));
return;
}
Path dir = instance.getPath();
Map<String, Object> info = new HashMap<>();
int modsCount = 0;
Path modsDir = dir.resolve("mods");
if (Files.exists(modsDir)) {
try (var files = Files.list(modsDir)) {
modsCount = (int) files.filter(p -> p.toString().endsWith(".jar")).count();
}
}
info.put("modsCount", modsCount);
List<String> screenshots = new ArrayList<>();
Path screenshotsDir = dir.resolve("screenshots");
if (Files.exists(screenshotsDir)) {
try (var files = Files.list(screenshotsDir)) {
files.filter(p -> {
String n = p.getFileName().toString().toLowerCase();
return n.endsWith(".png") || n.endsWith(".jpg");
}).limit(20).forEach(p -> screenshots.add(p.getFileName().toString()));
}
}
info.put("screenshots", screenshots);
List<String> worlds = new ArrayList<>();
Path savesDir = dir.resolve("saves");
if (Files.exists(savesDir)) {
try (var dirs = Files.list(savesDir)) {
dirs.filter(Files::isDirectory).limit(20).forEach(p -> worlds.add(p.getFileName().toString()));
}
}
info.put("worlds", worlds);
List<String> recentLogs = new ArrayList<>();
Path logsDir = dir.resolve("logs");
if (Files.exists(logsDir)) {
try (var files = Files.list(logsDir)) {
files.filter(p -> p.toString().endsWith(".log") || p.toString().endsWith(".txt"))
.sorted((a, b) -> {
try { return Long.compare(Files.getLastModifiedTime(b).toMillis(), Files.getLastModifiedTime(a).toMillis()); }
catch (Exception e) { return 0; }
})
.limit(5)
.forEach(p -> recentLogs.add(p.getFileName().toString()));
}
}
info.put("recentLogs", recentLogs);
sendJson(exchange, Map.of("success", true, "data", info));
} catch (Exception e) {
sendJson(exchange, Map.of("success", false, "error", e.getMessage()));
}
}
private void handleSettings(HttpExchange exchange) { private void handleSettings(HttpExchange exchange) {
try { try {
log("handleSettings: " + exchange.getRequestMethod());
if ("POST".equals(exchange.getRequestMethod())) { if ("POST".equals(exchange.getRequestMethod())) {
Map<String, String> body = parseJson(exchange.getRequestBody()); Map<String, String> body = parseJson(exchange.getRequestBody());
if (body.containsKey("maxMemory")) { if (body.containsKey("maxMemory")) {
@@ -764,7 +1083,16 @@ public class JFXLauncher extends Application {
if (body.containsKey("javaPath")) { if (body.containsKey("javaPath")) {
Config.setJavaPath(body.get("javaPath")); Config.setJavaPath(body.get("javaPath"));
} }
sendJson(exchange, Map.of("success", true)); if (body.containsKey("locale")) {
Config.setLocale(body.get("locale"));
}
if (body.containsKey("systemBasedJvm")) {
Config.setSystemBasedJvm(Boolean.parseBoolean(body.get("systemBasedJvm")));
}
Map<String, Object> res = new HashMap<>();
res.put("success", true);
res.put("maxMemory", Config.getMaxMemory());
sendJson(exchange, res);
return; return;
} }
Map<String, Object> data = new HashMap<>(); Map<String, Object> data = new HashMap<>();
@@ -775,6 +1103,10 @@ public class JFXLauncher extends Application {
data.put("windowHeight", Config.getWindowHeight()); data.put("windowHeight", Config.getWindowHeight());
data.put("extraJvmArgs", Config.getExtraJvmArgs()); data.put("extraJvmArgs", Config.getExtraJvmArgs());
data.put("javaPath", Config.getJavaPath()); data.put("javaPath", Config.getJavaPath());
data.put("locale", Config.getLocale());
data.put("systemBasedJvm", Config.isSystemBasedJvm());
data.put("cpuCores", Config.getSystemCpuCores());
data.put("totalRamMB", Config.getSystemTotalRamMB());
sendJson(exchange, Map.of("success", true, "data", data)); sendJson(exchange, Map.of("success", true, "data", data));
} catch (Exception e) { } catch (Exception e) {
sendJson(exchange, Map.of("success", false, "error", e.getMessage())); sendJson(exchange, Map.of("success", false, "error", e.getMessage()));
@@ -824,9 +1156,17 @@ public class JFXLauncher extends Application {
log("[UI] Request: " + path); log("[UI] Request: " + path);
String relativePath = path.startsWith("/") ? path.substring(1) : path; String relativePath = path.startsWith("/") ? path.substring(1) : path;
Path file = Paths.get(relativePath).toAbsolutePath(); Path file = Paths.get(relativePath).toAbsolutePath().normalize();
Path assetsDir = Paths.get("assets").toAbsolutePath().normalize();
if (!Files.exists(file)) { if (!file.startsWith(assetsDir)) {
log("[UI] Forbidden: " + file);
exchange.sendResponseHeaders(403, 0);
exchange.close();
return;
}
if (!Files.exists(file) || Files.isDirectory(file)) {
log("[UI] File not found: " + file); log("[UI] File not found: " + file);
exchange.sendResponseHeaders(404, 0); exchange.sendResponseHeaders(404, 0);
exchange.close(); exchange.close();
@@ -853,15 +1193,51 @@ public class JFXLauncher extends Application {
return "text/plain"; return "text/plain";
} }
@SuppressWarnings("unchecked")
private Map<String, String> parseJson(InputStream body) { private Map<String, String> parseJson(InputStream body) {
try { try {
return gson.fromJson(new String(body.readAllBytes(), StandardCharsets.UTF_8), Map.class); byte[] rawBytes = body.readAllBytes();
log("parseJson: " + rawBytes.length + " bytes");
String raw = new String(rawBytes, StandardCharsets.UTF_8);
Map<?, ?> rawMap = gson.fromJson(raw, Map.class);
Map<String, String> result = new HashMap<>();
if (rawMap != null) {
for (Map.Entry<?, ?> entry : rawMap.entrySet()) {
String key = entry.getKey() != null ? entry.getKey().toString() : null;
String value = null;
Object v = entry.getValue();
if (v instanceof Number) {
Number n = (Number) v;
if (n.doubleValue() == n.longValue()) {
value = String.valueOf(n.longValue());
} else {
value = String.valueOf(n.doubleValue());
}
} else {
value = v != null ? v.toString() : null;
}
if (key != null) result.put(key, value);
}
}
return result;
} catch (Exception e) { } catch (Exception e) {
log("parseJson error: " + e.getMessage());
return new HashMap<>(); return new HashMap<>();
} }
} }
private void openInBrowser(String url) {
if (url == null || url.isEmpty()) return;
try {
java.awt.Desktop.getDesktop().browse(new java.net.URI(url));
} catch (Exception e) {
try {
new ProcessBuilder("xdg-open", url).start();
} catch (Exception e2) {
log("Failed to open browser: " + e2.getMessage());
}
}
}
private void sendJson(HttpExchange exchange, Map<String, Object> response) { private void sendJson(HttpExchange exchange, Map<String, Object> response) {
try { try {
String json = gson.toJson(response); String json = gson.toJson(response);
@@ -870,21 +1246,135 @@ public class JFXLauncher extends Application {
exchange.sendResponseHeaders(200, bytes.length); exchange.sendResponseHeaders(200, bytes.length);
exchange.getResponseBody().write(bytes); exchange.getResponseBody().write(bytes);
exchange.close(); exchange.close();
} catch (Exception ignored) {} } catch (Exception e) {
log("sendJson error: " + e.getMessage());
}
}
private void sendRawJson(HttpExchange exchange, String json) {
try {
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 e) {
log("sendRawJson error: " + e.getMessage());
}
}
private String readBody(HttpExchange exchange) throws IOException {
return new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8);
}
private String remoteApiCall(String method, String path, String body) throws Exception {
URL url = new URL(LAUNCHER_SERVER + path);
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
conn.setRequestMethod(method);
conn.setRequestProperty("Content-Type", "application/json");
conn.setRequestProperty("User-Agent", "ZernMC-Launcher/1.0");
String token = me.sashegdev.zernmc.launcher.auth.AuthManager.getAccessToken();
if (token != null && !token.isEmpty() && !token.equals("0")) {
conn.setRequestProperty("Authorization", "Bearer " + token);
}
conn.setConnectTimeout(10000);
conn.setReadTimeout(15000);
if (body != null && !body.isEmpty()) {
conn.setDoOutput(true);
try (OutputStream os = conn.getOutputStream()) {
os.write(body.getBytes(StandardCharsets.UTF_8));
}
}
int code = conn.getResponseCode();
try (InputStream is = code >= 400 ? conn.getErrorStream() : conn.getInputStream()) {
return new String(is.readAllBytes(), StandardCharsets.UTF_8);
}
}
private void handleFriendList(HttpExchange exchange) {
try {
String response = remoteApiCall("GET", "/api/friends/list", null);
sendRawJson(exchange, response);
} catch (Exception e) {
sendJson(exchange, Map.of("success", false, "error", e.getMessage()));
}
}
private void handleFriendAdd(HttpExchange exchange) {
try {
String body = readBody(exchange);
String response = remoteApiCall("POST", "/api/friends/add", body);
sendRawJson(exchange, response);
} catch (Exception e) {
sendJson(exchange, Map.of("success", false, "error", e.getMessage()));
}
}
private void handleFriendRemove(HttpExchange exchange) {
try {
String body = readBody(exchange);
String response = remoteApiCall("POST", "/api/friends/remove", body);
sendRawJson(exchange, response);
} catch (Exception e) {
sendJson(exchange, Map.of("success", false, "error", e.getMessage()));
}
}
private void handleFriendAccept(HttpExchange exchange) {
try {
String body = readBody(exchange);
String response = remoteApiCall("POST", "/api/friends/accept", body);
sendRawJson(exchange, response);
} catch (Exception e) {
sendJson(exchange, Map.of("success", false, "error", e.getMessage()));
}
}
private void handleFriendRequests(HttpExchange exchange) {
try {
String response = remoteApiCall("GET", "/api/friends/requests", null);
sendRawJson(exchange, response);
} catch (Exception e) {
sendJson(exchange, Map.of("success", false, "error", e.getMessage()));
}
}
private void handleFriendStatus(HttpExchange exchange) {
try {
String body = readBody(exchange);
String response = remoteApiCall("POST", "/api/friends/status", body);
sendRawJson(exchange, response);
} catch (Exception e) {
sendJson(exchange, Map.of("success", false, "error", e.getMessage()));
}
}
private void handlePlaytimeSync(HttpExchange exchange) {
try {
String body = readBody(exchange);
String response = remoteApiCall("POST", "/api/playtime/sync", body);
sendRawJson(exchange, response);
} catch (Exception e) {
sendJson(exchange, Map.of("success", false, "error", e.getMessage()));
}
}
private void handlePlaytimeStats(HttpExchange exchange) {
try {
String response = remoteApiCall("GET", "/api/playtime/stats", null);
sendRawJson(exchange, response);
} catch (Exception e) {
sendJson(exchange, Map.of("success", false, "error", e.getMessage()));
}
} }
private void log(String msg) { private void log(String msg) {
String entry = "[" + java.time.LocalTime.now() + "] " + msg + "\n"; String timestamp = java.time.LocalTime.now().format(java.time.format.DateTimeFormatter.ofPattern("HH:mm:ss"));
String entry = "[" + timestamp + "] " + msg;
synchronized (launcherLogBuffer) { synchronized (launcherLogBuffer) {
launcherLogBuffer.append(entry); launcherLogBuffer.append(entry).append("\n");
}
System.out.println("[JFX] " + msg);
if (launcherLogFile != null) {
try {
Files.writeString(launcherLogFile, entry,
StandardOpenOption.CREATE, StandardOpenOption.APPEND);
} catch (Exception ignored) {}
} }
LauncherLogger.info("[JFX] " + msg);
for (LogConsumer consumer : logConsumers) { for (LogConsumer consumer : logConsumers) {
try { consumer.onLog(entry); } catch (Exception ignored) {} try { consumer.onLog(entry); } catch (Exception ignored) {}
} }
@@ -14,17 +14,22 @@ public class Config {
private static final Properties props = new Properties(); private static final Properties props = new Properties();
private static int maxMemory = 4096; private static volatile int maxMemory = 4096;
private static String serverUrl = "http://87.120.187.36:1582"; private static volatile String serverUrl = "http://87.120.187.36:1582";
private static String lastUsername = "Player"; private static volatile String lastUsername = "Player";
private static int windowWidth = 1280; private static volatile int windowWidth = 1280;
private static int windowHeight = 720; private static volatile int windowHeight = 720;
private static String extraJvmArgs = ""; private static volatile String extraJvmArgs = "";
private static String javaPath = "java"; private static volatile String javaPath = "java";
private static volatile boolean ramManuallySet = false;
private static volatile String locale = "en";
private static volatile boolean systemBasedJvm = false;
static { static {
load(); load();
applySmartRamRecommendation(); if (!ramManuallySet) {
applySmartRamRecommendation();
}
} }
private static void load() { private static void load() {
@@ -36,13 +41,28 @@ public class Config {
} }
} }
maxMemory = Integer.parseInt(props.getProperty("maxMemory", "4096")); try {
maxMemory = Integer.parseInt(props.getProperty("maxMemory", "4096"));
} catch (NumberFormatException e) {
System.err.println(ZAnsi.yellow("Config: invalid maxMemory value, using default"));
}
ramManuallySet = Boolean.parseBoolean(props.getProperty("ramManuallySet", "false"));
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")); try {
windowHeight = Integer.parseInt(props.getProperty("windowHeight", "720")); windowWidth = Integer.parseInt(props.getProperty("windowWidth", "1280"));
} catch (NumberFormatException e) {
System.err.println(ZAnsi.yellow("Config: invalid windowWidth value, using default"));
}
try {
windowHeight = Integer.parseInt(props.getProperty("windowHeight", "720"));
} catch (NumberFormatException e) {
System.err.println(ZAnsi.yellow("Config: invalid windowHeight value, using default"));
}
extraJvmArgs = props.getProperty("extraJvmArgs", ""); extraJvmArgs = props.getProperty("extraJvmArgs", "");
javaPath = props.getProperty("javaPath", "java"); javaPath = props.getProperty("javaPath", "java");
locale = props.getProperty("locale", "en");
systemBasedJvm = Boolean.parseBoolean(props.getProperty("systemBasedJvm", "false"));
} catch (Exception e) { } catch (Exception e) {
System.err.println(ZAnsi.brightRed("Failed to load config: ") + e.getMessage()); System.err.println(ZAnsi.brightRed("Failed to load config: ") + e.getMessage());
@@ -52,12 +72,15 @@ public class Config {
public static void save() { public static void save() {
try { try {
props.setProperty("maxMemory", String.valueOf(maxMemory)); props.setProperty("maxMemory", String.valueOf(maxMemory));
props.setProperty("ramManuallySet", String.valueOf(ramManuallySet));
props.setProperty("serverUrl", serverUrl); props.setProperty("serverUrl", serverUrl);
props.setProperty("lastUsername", lastUsername); props.setProperty("lastUsername", lastUsername);
props.setProperty("windowWidth", String.valueOf(windowWidth)); props.setProperty("windowWidth", String.valueOf(windowWidth));
props.setProperty("windowHeight", String.valueOf(windowHeight)); props.setProperty("windowHeight", String.valueOf(windowHeight));
props.setProperty("extraJvmArgs", extraJvmArgs); props.setProperty("extraJvmArgs", extraJvmArgs);
props.setProperty("javaPath", javaPath); props.setProperty("javaPath", javaPath);
props.setProperty("locale", locale);
props.setProperty("systemBasedJvm", String.valueOf(systemBasedJvm));
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");
@@ -68,20 +91,44 @@ public class Config {
} }
private static void applySmartRamRecommendation() { private static void applySmartRamRecommendation() {
long totalRamMB = Runtime.getRuntime().maxMemory() / (1024 * 1024); long totalRamMB = getTotalSystemRamMB();
if (totalRamMB <= 0) return;
long recommended = (long) (totalRamMB * 0.70); long recommended;
if (totalRamMB <= 8192) {
recommended = 2560;
} else if (totalRamMB <= 12288) {
recommended = 3072;
} else if (totalRamMB <= 16384) {
recommended = 4096;
} else {
recommended = 5120;
}
recommended = Math.max(1536, recommended); if (Math.abs(maxMemory - recommended) > 512) {
recommended = Math.min(recommended, totalRamMB - 1024);
if (Math.abs(maxMemory - recommended) > 1024) {
maxMemory = (int) recommended; maxMemory = (int) recommended;
save(); save();
System.out.println(ZAnsi.cyan("Auto-recommended RAM: " + maxMemory + " MB")); System.out.println(ZAnsi.cyan("Auto-recommended RAM: " + maxMemory + " MB"));
} }
} }
public static void resetRamRecommendation() {
ramManuallySet = false;
applySmartRamRecommendation();
}
private static long getTotalSystemRamMB() {
try {
Class<?> beanClass = Class.forName("com.sun.management.OperatingSystemMXBean");
java.lang.management.OperatingSystemMXBean osBean = java.lang.management.ManagementFactory.getOperatingSystemMXBean();
if (beanClass.isInstance(osBean)) {
Object totalBytes = beanClass.getMethod("getTotalMemorySize").invoke(osBean);
return ((Number) totalBytes).longValue() / (1024 * 1024);
}
} catch (Exception ignored) {}
return 0;
}
public static int getMaxMemory() { public static int getMaxMemory() {
return maxMemory; return maxMemory;
} }
@@ -99,6 +146,7 @@ public class Config {
if (memory > 32768) memory = 32768; if (memory > 32768) memory = 32768;
maxMemory = memory; maxMemory = memory;
ramManuallySet = true;
save(); save();
} }
@@ -163,6 +211,55 @@ public class Config {
save(); save();
} }
public static String getLocale() {
return locale;
}
public static void setLocale(String lang) {
if (lang != null && (lang.equals("en") || lang.equals("ru"))) {
locale = lang;
save();
}
}
public static boolean isSystemBasedJvm() {
return systemBasedJvm;
}
public static void setSystemBasedJvm(boolean enabled) {
systemBasedJvm = enabled;
save();
}
public static int getSystemCpuCores() {
return Runtime.getRuntime().availableProcessors();
}
public static long getSystemTotalRamMB() {
long totalMb = getTotalSystemRamMB();
if (totalMb > 0) return totalMb;
return Runtime.getRuntime().maxMemory() / (1024 * 1024);
}
public static String getSystemJvmFlags() {
int cores = getSystemCpuCores();
long ramMB = getSystemTotalRamMB();
StringBuilder sb = new StringBuilder();
sb.append("-XX:ParallelGCThreads=").append(Math.max(1, cores));
sb.append(" -XX:ConcGCThreads=").append(Math.max(1, cores / 2));
sb.append(" -XX:+AlwaysPreTouch");
if (ramMB >= 8192) {
sb.append(" -XX:+UseZGC");
sb.append(" -XX:ZAllocationSpikeTolerance=2.0");
} else {
sb.append(" -XX:+UseG1GC");
sb.append(" -XX:MaxGCPauseMillis=50");
sb.append(" -XX:G1HeapRegionSize=16M");
}
sb.append(" -Xss4M");
return sb.toString();
}
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 "Available RAM: " + totalMB + " MB | Recommended: " + maxMemory + " MB";
@@ -0,0 +1,95 @@
package me.sashegdev.zernmc.launcher.utils;
import java.io.IOException;
import java.io.PrintWriter;
import java.io.StringWriter;
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.concurrent.locks.ReentrantLock;
public class LauncherLogger {
private static Path logFile;
private static boolean initialized = false;
private static final ReentrantLock lock = new ReentrantLock();
public static synchronized void init() {
if (initialized) return;
initialized = true;
try {
Path logsDir = Paths.get(System.getProperty("user.home"), ".zernmc", "logs");
Files.createDirectories(logsDir);
logFile = logsDir.resolve("launcher.log");
Files.writeString(logFile,
"=== Launcher Log " + LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")) + " ===\n",
StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING);
System.out.println("[LauncherLogger] initialized, log: " + logFile.toAbsolutePath());
} catch (Exception e) {
System.err.println("[LauncherLogger] init error: " + e.getMessage());
e.printStackTrace();
}
}
public static Path getLogFile() {
return logFile;
}
public static void info(String msg) {
write("INFO", msg, null);
}
public static void warn(String msg) {
write("WARN", msg, null);
}
public static void error(String msg) {
write("ERROR", msg, null);
}
public static void error(String msg, Throwable t) {
write("ERROR", msg, t);
}
public static void debug(String msg) {
write("DEBUG", msg, null);
}
private static void write(String level, String msg, Throwable t) {
String ts = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS"));
String line = "[" + ts + "] [" + level + "] " + msg;
System.out.println(line);
if (t != null) {
StringWriter sw = new StringWriter();
PrintWriter pw = new PrintWriter(sw);
t.printStackTrace(pw);
pw.flush();
System.err.print(sw.toString());
}
if (logFile != null) {
lock.lock();
try {
Files.writeString(logFile, line + "\n", StandardOpenOption.APPEND);
if (t != null) {
StringWriter sw = new StringWriter();
PrintWriter pw = new PrintWriter(sw);
t.printStackTrace(pw);
pw.flush();
Files.writeString(logFile, sw.toString(), StandardOpenOption.APPEND);
}
} catch (IOException e) {
System.err.println("[LauncherLogger] write error: " + e.getMessage());
} finally {
lock.unlock();
}
}
}
}
@@ -373,8 +373,6 @@ public class ZHttpClient {
} }
public static String get(String endpoint) throws IOException, InterruptedException { public static String get(String endpoint) throws IOException, InterruptedException {
checkAllServicesOnStartup();
if (useProxyMode.get()) { if (useProxyMode.get()) {
return proxyGet(endpoint); return proxyGet(endpoint);
} }
+153 -98
View File
@@ -5,9 +5,6 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ZernMC Launcher</title> <title>ZernMC Launcher</title>
<link rel="stylesheet" href="style.css"> <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> </head>
<body> <body>
<canvas id="bg-canvas"></canvas> <canvas id="bg-canvas"></canvas>
@@ -30,24 +27,24 @@
</svg> </svg>
</div> </div>
<h1 class="brand-title">ZernMC</h1> <h1 class="brand-title">ZernMC</h1>
<p class="brand-sub">Launcher <span id="version">1.0.9</span></p> <p class="brand-sub">Launcher <span id="version" data-i18n="version">1.0.9</span></p>
</div> </div>
<form id="login-form" class="login-form"> <form id="login-form" class="login-form">
<div class="field"> <div class="field">
<input type="text" id="username" placeholder="Username" autocomplete="username" required> <input type="text" id="username" placeholder="Username" data-i18n-placeholder="login.username" autocomplete="username" required>
<label for="username">Username</label> <label for="username" data-i18n="login.username">Username</label>
</div> </div>
<div class="field"> <div class="field">
<input type="password" id="password" placeholder="Password" autocomplete="current-password" required> <input type="password" id="password" placeholder="Password" data-i18n-placeholder="login.password" autocomplete="current-password" required>
<label for="password">Password</label> <label for="password" data-i18n="login.password">Password</label>
</div> </div>
<p id="login-error" class="error-msg hidden"></p> <p id="login-error" class="error-msg hidden"></p>
<button type="submit" class="btn-primary" id="login-btn"> <button type="submit" class="btn-primary" id="login-btn">
<span class="btn-label">Sign In</span> <span class="btn-label" data-i18n="login.title">Sign In</span>
<div class="spinner hidden"></div> <div class="spinner hidden"></div>
</button> </button>
<p class="login-hint">New account will be created automatically on first login</p> <p class="login-hint" data-i18n="login.hint">New account will be created automatically on first login</p>
</form> </form>
</div> </div>
</div> </div>
@@ -55,7 +52,7 @@
<!-- Loading Overlay --> <!-- Loading Overlay -->
<div id="loading-overlay" class="overlay hidden"> <div id="loading-overlay" class="overlay hidden">
<div class="loader-ring"></div> <div class="loader-ring"></div>
<p class="loader-text">Loading...</p> <p class="loader-text" data-i18n="loading.text">Loading...</p>
</div> </div>
<!-- Main Screen --> <!-- Main Screen -->
@@ -83,28 +80,28 @@
<nav class="sidebar-nav"> <nav class="sidebar-nav">
<button class="nav-btn active" data-view="packs" title="Packs"> <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> <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 <span data-i18n="nav.packs">Packs</span>
</button> </button>
<button class="nav-btn" data-view="news" title="News"> <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> <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 <span data-i18n="nav.news">News</span>
</button> </button>
<button class="nav-btn" data-view="settings" title="Settings"> <button class="nav-btn" data-view="friends" title="Friends">
<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> <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="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
Settings <span data-i18n="nav.friends">Friends</span>
</button> </button>
</nav> </nav>
<div class="sidebar-section"> <div class="sidebar-section">
<div class="section-header"> <div class="section-header">
<span class="section-title">Server Packs</span> <span class="section-title" data-i18n="sidebar.serverPacks">Server Packs</span>
</div> </div>
<div id="server-packs-list" class="pack-list"></div> <div id="server-packs-list" class="pack-list"></div>
</div> </div>
<div class="sidebar-section" id="local-packs-section"> <div class="sidebar-section" id="local-packs-section">
<div class="section-header"> <div class="section-header">
<span class="section-title">Local Packs</span> <span class="section-title" data-i18n="sidebar.localPacks">Local Packs</span>
<button class="btn-icon" id="add-pack-btn" title="Add pack"> <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> <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> </button>
@@ -123,6 +120,9 @@
<span id="account-role" class="badge badge-role hidden"></span> <span id="account-role" class="badge badge-role hidden"></span>
</span> </span>
</div> </div>
<button class="btn-icon" id="settings-btn" title="Settings">
<svg width="16" height="16" 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>
</button>
<button class="btn-icon" id="logout-btn" title="Log out"> <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> <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> </button>
@@ -141,11 +141,11 @@
<div class="view-actions"> <div class="view-actions">
<button id="update-btn" class="btn-secondary hidden"> <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> <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 <span data-i18n="pack.update">Update</span>
</button> </button>
<button id="delete-pack-btn" class="btn-secondary btn-danger hidden"> <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> <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 <span data-i18n="pack.delete">Delete</span>
</button> </button>
</div> </div>
</div> </div>
@@ -153,8 +153,8 @@
<div class="pack-detail" id="pack-detail"> <div class="pack-detail" id="pack-detail">
<div class="pack-empty" id="pack-empty-state"> <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> <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> <h3 data-i18n="pack.emptyState.title">No pack selected</h3>
<p>Select a pack from the sidebar or add a new one</p> <p data-i18n="pack.emptyState.desc">Select a pack from the sidebar or add a new one</p>
</div> </div>
<div id="pack-detail-content" class="pack-detail-content hidden"> <div id="pack-detail-content" class="pack-detail-content hidden">
<div class="pack-hero"> <div class="pack-hero">
@@ -166,17 +166,18 @@
<div class="detail-tags"> <div class="detail-tags">
<span class="tag tag-mc" id="detail-mc">1.21</span> <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-loader" id="detail-loader">fabric</span>
<span class="tag tag-server hidden" id="detail-server">v1</span> <span class="tag tag-server hidden" id="detail-server">v1</span>
</div>
</div>
</div> </div>
<div class="pack-stats"> </div>
<div class="stat"><span class="stat-value" id="detail-loader-ver">-</span><span class="stat-label">Loader Ver</span></div> </div>
<div class="stat"><span class="stat-value" id="detail-files">0</span><span class="stat-label">Files</span></div> <div class="pack-stats">
<div class="stat"><span class="stat-value" id="detail-size">-</span><span class="stat-label">Size</span></div> <div class="stat"><span class="stat-value" id="detail-loader-ver">-</span><span class="stat-label" data-i18n="stat.loaderVer">Loader Ver</span></div>
<div class="stat"><span class="stat-value" id="detail-files">0</span><span class="stat-label" data-i18n="stat.files">Files</span></div>
<div class="stat"><span class="stat-value" id="detail-size">-</span><span class="stat-label" data-i18n="stat.size">Size</span></div>
<div class="stat"><span class="stat-value" id="detail-playtime">-</span><span class="stat-label" data-i18n="playtime.label">Playtime</span></div>
</div> </div>
<div id="pack-description" class="pack-description"> <div id="pack-description" class="pack-description">
<p id="pack-description-text" class="pack-description-text">Loading description...</p> <p id="pack-description-text" class="pack-description-text" data-i18n="pack.description.loading">Loading description...</p>
<div id="pack-gallery" class="pack-gallery"> <div id="pack-gallery" class="pack-gallery">
</div> </div>
</div> </div>
@@ -189,7 +190,7 @@
</div> </div>
<button id="play-btn" class="btn-play" disabled> <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> <svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"><polygon points="5 3 19 12 5 21 5 3"/></svg>
Play <span data-i18n="playBar.play">Play</span>
</button> </button>
</div> </div>
</div> </div>
@@ -197,50 +198,57 @@
<!-- News View --> <!-- News View -->
<div id="view-news" class="view"> <div id="view-news" class="view">
<div class="view-header"> <div class="view-header">
<h2 class="view-title">News</h2> <h2 class="view-title" data-i18n="news.title">News</h2>
</div> </div>
<div class="news-grid"> <div id="news-grid" class="news-grid">
<article class="news-card news-placeholder"> <div class="news-loading" data-i18n="news.loading">Loading news...</div>
<div class="news-card-badge">Coming Soon</div> </div>
<h3>ZernMC Server Updates</h3> </div>
<p>News and announcements will appear here. Stay tuned for the latest updates about the server and launcher.</p>
<time>Soon</time> <!-- Friends View -->
</article> <div id="view-friends" class="view">
<article class="news-card news-placeholder"> <div class="view-header">
<div class="news-card-badge">Info</div> <h2 class="view-title" data-i18n="friends.title">Friends</h2>
<h3>Launcher v1.0.9</h3> <div class="view-actions">
<p>English UI, JavaFX redesign, improved pack management, and more. Check the GitHub for the full changelog.</p> <button id="friends-add-btn" class="btn-primary btn-sm" onclick="app.showAddFriend()">
<time>v1.0.9</time> <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>
</article> <span data-i18n="friends.add">Add</span>
<article class="news-card news-placeholder"> </button>
<div class="news-card-badge">Guide</div> </div>
<h3>Getting Started</h3> </div>
<p>Install a pack, activate your pass via the website, and start playing. Need help? Contact a moderator.</p> <div class="friends-search">
<time>Guide</time> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
</article> <input type="text" id="friends-search-input" placeholder="Search friends..." data-i18n-placeholder="friends.search" oninput="app.filterFriends()">
</div>
<div id="friends-list" class="friends-list">
<div class="friends-empty" data-i18n="friends.empty">No friends yet</div>
</div>
<div id="friend-requests-section" class="friend-requests-section hidden">
<div class="section-header"><span data-i18n="friends.requests">Friend Requests</span></div>
<div id="friend-requests-list" class="friend-requests-list"></div>
</div> </div>
</div> </div>
<!-- Settings View --> <!-- Settings View -->
<div id="view-settings" class="view"> <div id="view-settings" class="view">
<div class="view-header"> <div class="view-header">
<h2 class="view-title">Settings</h2> <h2 class="view-title" data-i18n="settings.title">Settings</h2>
</div> </div>
<div class="settings-grid"> <div class="settings-grid">
<div class="setting-card"> <div class="setting-card">
<div class="setting-info"> <div class="setting-info">
<h4>Activate Pass</h4> <h4 data-i18n="settings.activatePass.title">Activate Pass</h4>
<p>Enter your pass code to access server packs</p> <p data-i18n="settings.activatePass.desc">Enter your pass code to access server packs</p>
</div> </div>
<div class="setting-control setting-pass"> <div class="setting-control setting-pass">
<input type="text" id="pass-code" placeholder="Pass code" class="pass-input"> <input type="text" id="pass-code" placeholder="Pass code" data-i18n-placeholder="settings.activatePass.placeholder" class="pass-input">
<button id="activate-pass-btn" class="btn-primary btn-sm">Activate</button> <button id="activate-pass-btn" class="btn-primary btn-sm" onclick="app.activatePass()"><span data-i18n="settings.activatePass.button">Activate</span></button>
</div> </div>
</div> </div>
<div class="setting-card"> <div class="setting-card">
<div class="setting-info"> <div class="setting-info">
<h4>Allocated RAM</h4> <h4 data-i18n="settings.ram.title">Allocated RAM</h4>
<p id="ram-info">Loading...</p> <p id="ram-info" data-i18n="settings.ram.info">Loading...</p>
</div> </div>
<div class="setting-control"> <div class="setting-control">
<input type="range" id="ram-slider" min="1024" max="16384" step="512" value="4096"> <input type="range" id="ram-slider" min="1024" max="16384" step="512" value="4096">
@@ -249,8 +257,8 @@
</div> </div>
<div class="setting-card"> <div class="setting-card">
<div class="setting-info"> <div class="setting-info">
<h4>Game Resolution</h4> <h4 data-i18n="settings.resolution.title">Game Resolution</h4>
<p>Width x Height</p> <p data-i18n="settings.resolution.desc">Width x Height</p>
</div> </div>
<div class="setting-control" style="gap:6px"> <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"> <input type="number" id="win-width" min="640" max="7680" step="1" value="1280" class="setting-input" style="width:80px">
@@ -260,8 +268,8 @@
</div> </div>
<div class="setting-card"> <div class="setting-card">
<div class="setting-info"> <div class="setting-info">
<h4>Extra JVM Arguments</h4> <h4 data-i18n="settings.jvmArgs.title">Extra JVM Arguments</h4>
<p>Additional Java VM options</p> <p data-i18n="settings.jvmArgs.desc">Additional Java VM options</p>
</div> </div>
<div class="setting-control"> <div class="setting-control">
<input type="text" id="jvm-args" placeholder="-XX:+UseZGC" class="setting-input" style="width:280px"> <input type="text" id="jvm-args" placeholder="-XX:+UseZGC" class="setting-input" style="width:280px">
@@ -269,7 +277,7 @@
</div> </div>
<div class="setting-card"> <div class="setting-card">
<div class="setting-info"> <div class="setting-info">
<h4>Java Path</h4> <h4 data-i18n="settings.javaPath.title">Java Path</h4>
<p id="java-path">~/.zernmc/jre/</p> <p id="java-path">~/.zernmc/jre/</p>
</div> </div>
<div class="setting-control"> <div class="setting-control">
@@ -278,82 +286,128 @@
</div> </div>
<div class="setting-card"> <div class="setting-card">
<div class="setting-info"> <div class="setting-info">
<h4>Server</h4> <h4 data-i18n="settings.server.title">Server</h4>
<p id="server-url">http://87.120.187.36:1582</p> <p id="server-url">http://87.120.187.36:1582</p>
</div> </div>
<div class="setting-control"> <div class="setting-control">
<span class="setting-badge" id="server-status">Checking...</span> <span class="setting-badge" id="server-status">Checking...</span>
</div> </div>
</div> </div>
<div class="setting-card">
<div class="setting-info">
<h4 data-i18n="settings.language.title">Language</h4>
<p data-i18n="settings.language.desc">Interface language</p>
</div>
<div class="setting-control">
<select id="locale-select" class="setting-input" style="width:160px">
<option value="en">English</option>
<option value="ru">Русский</option>
</select>
</div>
</div>
<div class="setting-card">
<div class="setting-info">
<h4 data-i18n="settings.systemJvm.title">System-based JVM Optimization</h4>
<p id="system-jvm-info">-</p>
</div>
<div class="setting-control">
<label class="toggle" id="system-jvm-toggle-wrapper">
<input type="checkbox" id="system-jvm-toggle">
<span class="toggle-slider"></span>
</label>
</div>
</div>
<div class="setting-card">
<div class="setting-info">
<h4 data-i18n="settings.logViewer.title">Game Log</h4>
<p data-i18n="settings.logViewer.desc">View real-time game logs</p>
</div>
<div class="setting-control">
<button class="btn-primary btn-sm" id="show-log-viewer-btn" onclick="app.openLogViewer()"><span data-i18n="settings.logViewer.open">Open Log</span></button>
</div>
</div>
</div> </div>
</div> </div>
</main> </main>
</div> </div>
</div> </div>
<!-- Add Friend Modal -->
<div id="add-friend-modal" class="modal-backdrop hidden">
<div class="modal modal-sm">
<div class="modal-head">
<h3 data-i18n="friends.addTitle">Add Friend</h3>
<button class="modal-close" onclick="app.closeAddFriend()">&times;</button>
</div>
<div class="modal-body">
<div class="field">
<label data-i18n="friends.addLabel">Username</label>
<input type="text" id="add-friend-input" placeholder="Enter username..." data-i18n-placeholder="friends.addPlaceholder">
</div>
<button id="add-friend-submit" class="btn-primary" onclick="app.submitAddFriend()"><span data-i18n="friends.add">Add Friend</span></button>
<p id="add-friend-error" class="error-msg hidden"></p>
</div>
</div>
</div>
<!-- Log Viewer Overlay -->
<div id="log-viewer-overlay" class="modal-backdrop hidden">
<div class="modal modal-log">
<div class="modal-head">
<h3 data-i18n="logViewer.title">Game Log</h3>
<div class="log-viewer-actions">
<button class="btn-secondary btn-sm" id="copy-log-btn" onclick="app.copyLogs()"><span data-i18n="logViewer.copy">Copy</span></button>
<button class="btn-secondary btn-sm" onclick="app.req('/open-log-file', {method:'POST'})"><span data-i18n="logViewer.openFile">Open File</span></button>
<button class="modal-close" id="close-log-viewer-btn" onclick="app.closeLogViewer()">&times;</button>
</div>
</div>
<div class="modal-body log-viewer-body">
<div id="log-viewer-content" class="log-viewer-content"></div>
</div>
</div>
</div>
<!-- Install Modal --> <!-- Install Modal -->
<div id="install-modal" class="modal-backdrop hidden"> <div id="install-modal" class="modal-backdrop hidden">
<div class="modal"> <div class="modal">
<div class="modal-head"> <div class="modal-head">
<h3>Install Pack</h3> <h3 data-i18n="install.title">Install Pack</h3>
<button class="modal-close" id="close-modal-btn">&times;</button> <button class="modal-close" id="close-modal-btn">&times;</button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<div class="modal-tabs"> <div class="modal-tabs">
<button class="modal-tab active" data-tab="zernmc">Server Pack</button> <button class="modal-tab active" data-tab="zernmc"><span data-i18n="install.tab.serverPack">Server Pack</span></button>
<button class="modal-tab" data-tab="custom">Custom</button> <button class="modal-tab" data-tab="custom" id="custom-tab-btn"><span data-i18n="install.tab.custom">Custom</span> <span class="tag-wip">WIP</span></button>
</div> </div>
<div id="tab-zernmc" class="modal-tab-content active"> <div id="tab-zernmc" class="modal-tab-content active">
<div class="field"> <div class="field">
<label>Server Pack</label> <label data-i18n="install.serverPack.label">Server Pack</label>
<select id="zernmc-pack-select"> <select id="zernmc-pack-select">
<option value="">Loading...</option> <option value="">Loading...</option>
</select> </select>
</div> </div>
<div class="field"> <div class="field">
<label>Local Name</label> <label data-i18n="install.localName.label">Local Name</label>
<input type="text" id="zernmc-instance-name" placeholder="my-cool-pack"> <input type="text" id="zernmc-instance-name" placeholder="my-cool-pack">
</div> </div>
<button id="install-zernmc-btn" class="btn-primary">Download & Install</button> <button id="install-zernmc-btn" class="btn-primary"><span data-i18n="install.downloadBtn">Download & Install</span></button>
</div> </div>
<div id="tab-custom" class="modal-tab-content"> <div id="tab-custom" class="modal-tab-content">
<div class="field"> <div class="disabled-tab">
<label>Minecraft Version</label> <svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" opacity="0.3"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
<div class="select-wrap"> <h3 data-i18n="install.custom.unavailable">Not available yet</h3>
<select id="mc-version-select"><option>Loading...</option></select> <p data-i18n="install.custom.desc">Custom pack installation is disabled in this version. Use Server Pack tab to install packs from the server.</p>
</div>
</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>
<div id="install-progress" class="install-progress hidden"> <div id="install-progress" class="install-progress hidden">
<div class="progress-track"> <div class="progress-track">
<div class="progress-fill" id="progress-fill"></div> <div class="progress-fill" id="progress-fill"></div>
</div> </div>
<p class="progress-label" id="progress-label">Installing...</p> <p class="progress-label" id="progress-label" data-i18n="install.progress.installing">Installing...</p>
<p class="progress-stage hidden" id="progress-stage"></p>
</div> </div>
</div> </div>
</div> </div>
@@ -363,6 +417,7 @@
<div id="toast" class="toast hidden"></div> <div id="toast" class="toast hidden"></div>
</div> </div>
<script src="marked.min.js"></script>
<script src="launcher.js"></script> <script src="launcher.js"></script>
</body> </body>
</html> </html>
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
+235 -7
View File
@@ -23,8 +23,8 @@
--shadow: 0 4px 24px rgba(0,0,0,0.5); --shadow: 0 4px 24px rgba(0,0,0,0.5);
--shadow-glow: 0 0 40px var(--accent-glow); --shadow-glow: 0 0 40px var(--accent-glow);
--transition: 200ms ease; --transition: 200ms ease;
--font: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; --font: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Helvetica Neue', Arial, sans-serif;
--mono: 'JetBrains Mono', 'Consolas', monospace; --mono: 'Cascadia Code', 'Fira Code', 'JetBrains Mono', 'Consolas', 'Monaco', monospace;
} }
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
@@ -78,9 +78,7 @@ body {
.brand-icon { margin-bottom: 16px; } .brand-icon { margin-bottom: 16px; }
.brand-title { .brand-title {
font-size: 28px; font-weight: 800; font-size: 28px; font-weight: 800;
background: linear-gradient(135deg, #fff 60%, var(--accent)); color: var(--text);
-webkit-background-clip: text; -webkit-text-fill-color: transparent;
background-clip: text;
} }
.brand-sub { color: var(--text-muted); font-size: 13px; margin-top: 4px; } .brand-sub { color: var(--text-muted); font-size: 13px; margin-top: 4px; }
@@ -272,9 +270,10 @@ body {
flex: 1; display: flex; flex-direction: column; flex: 1; display: flex; flex-direction: column;
padding: 24px 32px; min-width: 0; padding: 24px 32px; min-width: 0;
position: relative; position: relative;
overflow-y: auto;
} }
.view { display: none; flex-direction: column; height: 100%; } .view { display: none; flex-direction: column; height: 100%; overflow-y: auto; }
.view.active { display: flex; } .view.active { display: flex; }
.view-header { .view-header {
@@ -359,6 +358,12 @@ body {
.pack-gallery-item img { .pack-gallery-item img {
width: 100%; height: 100%; object-fit: cover; width: 100%; height: 100%; object-fit: cover;
} }
.pack-description-text .news-link {
color: var(--accent); text-decoration: underline; cursor: pointer;
}
.pack-description-text .news-link:hover {
color: var(--accent-hover);
}
.btn-play { .btn-play {
display: flex; align-items: center; gap: 8px; display: flex; align-items: center; gap: 8px;
@@ -372,17 +377,84 @@ body {
.btn-play:active:not(:disabled) { transform: translateY(0); } .btn-play:active:not(:disabled) { transform: translateY(0); }
.btn-play:disabled { opacity: 0.4; cursor: not-allowed; transform: none; box-shadow: none; } .btn-play:disabled { opacity: 0.4; cursor: not-allowed; transform: none; box-shadow: none; }
/* ========== CUSTOM SELECT ========== */
.custom-select-wrap {
position: relative;
width: 100%;
}
.custom-select-trigger {
display: flex; align-items: center; justify-content: space-between;
width: 100%; padding: 10px 12px;
background: var(--bg-surface); border: 1px solid var(--border-light);
border-radius: var(--radius-sm); color: var(--text);
font-size: 13px; font-family: var(--font); cursor: pointer;
transition: var(--transition); user-select: none;
gap: 8px;
}
.custom-select-trigger:hover { border-color: var(--text-muted); }
.custom-select-trigger.open { border-color: var(--accent); }
.custom-select-trigger .arrow {
width: 16px; height: 16px; flex-shrink: 0;
transition: transform 0.2s ease; opacity: 0.5;
}
.custom-select-trigger.open .arrow { transform: rotate(180deg); }
.custom-select-trigger .placeholder { color: var(--text-muted); }
.custom-select-dropdown {
position: absolute; top: calc(100% + 4px); left: 0; right: 0;
background: var(--bg-elevated); border: 1px solid var(--border);
border-radius: var(--radius-sm); box-shadow: var(--shadow);
z-index: 100; max-height: 240px; display: none;
flex-direction: column;
}
.custom-select-dropdown.open { display: flex; }
.custom-select-search {
padding: 8px; border-bottom: 1px solid var(--border);
position: sticky; top: 0; background: var(--bg-elevated);
z-index: 1;
}
.custom-select-search input {
width: 100%; padding: 6px 10px;
background: var(--bg-surface); border: 1px solid var(--border-light);
border-radius: 4px; color: var(--text); font-size: 12px;
font-family: var(--font); outline: none;
}
.custom-select-search input:focus { border-color: var(--accent); }
.custom-select-options {
overflow-y: auto; flex: 1;
}
.custom-select-option {
padding: 8px 12px; cursor: pointer; font-size: 13px;
color: var(--text-secondary); transition: var(--transition);
}
.custom-select-option:hover { background: var(--bg-card); color: var(--text); }
.custom-select-option.selected { background: var(--accent-soft); color: var(--accent); }
.custom-select-option.hidden { display: none; }
/* ========== NEWS ========== */ /* ========== NEWS ========== */
.news-grid { .news-grid {
display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 16px; overflow-y: auto; padding-bottom: 24px; gap: 16px; overflow-y: auto; padding-bottom: 24px;
} }
.news-loading {
grid-column: 1 / -1; text-align: center; padding: 60px 20px;
color: var(--text-muted); font-size: 14px;
}
.news-empty {
grid-column: 1 / -1; text-align: center; padding: 60px 20px;
color: var(--text-muted); font-size: 14px;
}
.news-card { .news-card {
background: var(--bg-card); border: 1px solid var(--border); background: var(--bg-card); border: 1px solid var(--border);
border-radius: var(--radius-md); padding: 24px; display: flex; border-radius: var(--radius-md); padding: 24px; display: flex;
flex-direction: column; gap: 12px; transition: var(--transition); flex-direction: column; gap: 12px; transition: var(--transition);
cursor: pointer;
}
.news-card:hover { border-color: var(--border-light); transform: translateY(-1px); }
.news-preview {
font-size: 13px; color: var(--text-secondary); line-height: 1.5;
display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical;
overflow: hidden;
} }
.news-card:hover { border-color: var(--border-light); }
.news-card-badge { .news-card-badge {
align-self: flex-start; font-size: 10px; font-weight: 600; text-transform: uppercase; align-self: flex-start; font-size: 10px; font-weight: 600; text-transform: uppercase;
letter-spacing: 1px; padding: 4px 10px; border-radius: 4px; letter-spacing: 1px; padding: 4px 10px; border-radius: 4px;
@@ -391,6 +463,26 @@ body {
.news-card h3 { font-size: 16px; font-weight: 600; } .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 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; } .news-card time { font-size: 11px; color: var(--text-muted); margin-top: auto; }
.news-card-badge.type-Update { background: rgba(96,165,250,0.15); color: var(--info); }
.news-card-badge.type-Announcement { background: rgba(251,191,36,0.15); color: var(--warning); }
.news-card-badge.type-Event { background: rgba(74,222,128,0.15); color: var(--success); }
.news-modal-body {
max-height: 60vh; overflow-y: auto; line-height: 1.7;
font-size: 14px; color: var(--text-secondary);
}
.news-modal-body .news-text-line { display: inline; }
.news-modal-body .news-link {
color: var(--info); text-decoration: underline; cursor: pointer;
}
.news-modal-body .news-link:hover { color: var(--accent); }
.news-modal-body .news-photo {
display: block; max-width: 100%; border-radius: var(--radius-sm);
margin: 12px 0; cursor: pointer; border: 1px solid var(--border);
transition: var(--transition);
}
.news-modal-body .news-photo:hover { opacity: 0.9; }
.modal-news { max-width: 640px; }
/* ========== SETTINGS ========== */ /* ========== SETTINGS ========== */
.settings-grid { display: flex; flex-direction: column; gap: 12px; } .settings-grid { display: flex; flex-direction: column; gap: 12px; }
@@ -430,6 +522,24 @@ body {
.setting-input:focus { border-color: var(--accent); } .setting-input:focus { border-color: var(--accent); }
.btn-sm { padding: 6px 14px !important; font-size: 12px !important; } .btn-sm { padding: 6px 14px !important; font-size: 12px !important; }
/* ========== TOGGLE ========== */
.toggle {
position: relative; display: inline-block; width: 44px; height: 24px; cursor: pointer;
}
.toggle input { opacity: 0; width: 0; height: 0; }
.toggle-slider {
position: absolute; inset: 0; background: var(--border-light); border-radius: 12px; transition: var(--transition);
}
.toggle-slider::before {
content: ''; position: absolute; left: 3px; bottom: 3px; width: 18px; height: 18px;
background: var(--text-secondary); border-radius: 50%; transition: var(--transition);
}
.toggle input:checked + .toggle-slider { background: var(--accent); }
.toggle input:checked + .toggle-slider::before { transform: translateX(20px); background: #fff; }
/* ========== LOCALE SELECT ========== */
#locale-select { font-family: var(--font); cursor: pointer; }
/* ========== MODAL ========== */ /* ========== MODAL ========== */
.modal-backdrop { .modal-backdrop {
position: fixed; inset: 0; background: rgba(7,7,10,0.85); position: fixed; inset: 0; background: rgba(7,7,10,0.85);
@@ -472,6 +582,17 @@ body {
background: none; padding: 0; background: none; padding: 0;
} }
.tag-wip {
font-size: 9px; font-weight: 700; padding: 1px 5px; border-radius: 3px;
background: rgba(251,191,36,0.15); color: var(--warning); vertical-align: middle; margin-left: 4px;
}
.disabled-tab {
display: flex; flex-direction: column; align-items: center; gap: 12px;
padding: 40px 20px; text-align: center; color: var(--text-muted);
}
.disabled-tab h3 { font-size: 18px; font-weight: 600; color: var(--text-secondary); }
.disabled-tab p { font-size: 13px; line-height: 1.5; max-width: 300px; }
.select-wrap select { .select-wrap select {
width: 100%; padding: 10px 12px; font-size: 13px; width: 100%; padding: 10px 12px; font-size: 13px;
background: var(--bg-surface); border: 1px solid var(--border-light); background: var(--bg-surface); border: 1px solid var(--border-light);
@@ -490,6 +611,31 @@ body {
border-radius: 3px; transition: width 0.3s ease; border-radius: 3px; transition: width 0.3s ease;
} }
.progress-label { font-size: 13px; color: var(--text-secondary); margin-top: 8px; text-align: center; } .progress-label { font-size: 13px; color: var(--text-secondary); margin-top: 8px; text-align: center; }
.progress-stage { font-size: 11px; color: var(--text-muted); margin-top: 4px; text-align: center; }
/* ========== LOG VIEWER ========== */
.modal-log { max-width: 800px; }
.log-viewer-actions { display: flex; align-items: center; gap: 8px; }
.log-viewer-body { padding: 0 !important; }
.log-viewer-content {
font-family: var(--mono);
font-size: 11px;
line-height: 1.5;
background: var(--bg-deep);
padding: 12px 16px;
max-height: 50vh;
overflow-y: auto;
user-select: text;
-webkit-user-select: text;
white-space: pre-wrap;
word-break: break-all;
}
.log-line { padding: 1px 0; }
.log-empty { color: var(--text-muted); font-family: var(--font); font-size: 13px; padding: 20px; text-align: center; }
.log-error { color: #f87171; }
.log-warn { color: #fbbf24; }
.log-info { color: #4ade80; }
.log-debug { color: #60a5fa; }
/* ========== TOAST ========== */ /* ========== TOAST ========== */
.toast { .toast {
@@ -502,6 +648,7 @@ body {
} }
.toast.error { border-color: rgba(248,113,113,0.3); color: var(--error); } .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); } .toast.success { border-color: rgba(74,222,128,0.3); color: var(--success); }
.toast.warning { border-color: rgba(251,191,36,0.3); color: var(--warning); }
@keyframes toastIn { from { opacity: 0; transform: translateX(-50%) translateY(10px); } to { opacity: 1; transform: translateX(-50%) translateY(0); } } @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; } } @keyframes toastOut { from { opacity: 1; } to { opacity: 0; } }
@@ -529,3 +676,84 @@ body {
.play-bar { flex-direction: column; gap: 12px; } .play-bar { flex-direction: column; gap: 12px; }
.view-header { flex-direction: column; } .view-header { flex-direction: column; }
} }
/* ========== FRIENDS ========== */
.friends-search {
display: flex; align-items: center; gap: 8px;
background: var(--bg-card); border: 1px solid var(--border);
border-radius: var(--radius-sm); padding: 8px 12px; margin-bottom: 12px;
}
.friends-search svg { flex-shrink: 0; color: var(--text-muted); }
.friends-search input {
flex: 1; background: transparent; border: none; outline: none;
color: var(--text); font-size: 13px; font-family: var(--font);
}
.friends-search input::placeholder { color: var(--text-muted); }
.friends-list { display: flex; flex-direction: column; gap: 4px; }
.friends-empty {
text-align: center; padding: 40px 20px; color: var(--text-muted); font-size: 13px;
}
.friends-group-label {
font-size: 11px; font-weight: 600; text-transform: uppercase;
color: var(--text-muted); padding: 8px 4px 4px; letter-spacing: 0.5px;
}
.friend-item {
display: flex; align-items: center; gap: 10px; padding: 8px 10px;
border-radius: var(--radius-sm); transition: var(--transition);
}
.friend-item:hover { background: var(--bg-card-hover); }
.friend-item:hover .friend-remove-btn { opacity: 1; }
.friend-avatar {
width: 36px; height: 36px; border-radius: 50%; display: flex;
align-items: center; justify-content: center; font-weight: 600; font-size: 14px;
flex-shrink: 0; position: relative;
background: var(--accent-soft); color: var(--accent);
}
.friend-avatar.online::after {
content: ''; position: absolute; bottom: 0; right: 0;
width: 10px; height: 10px; border-radius: 50%;
background: var(--success); border: 2px solid var(--bg-surface);
}
.friend-avatar.offline { opacity: 0.6; }
.friend-info { flex: 1; min-width: 0; }
.friend-name-row { display: flex; align-items: center; gap: 6px; }
.friend-name { font-size: 13px; font-weight: 500; color: var(--text); }
.friend-status { display: flex; align-items: center; gap: 4px; font-size: 11px; color: var(--text-muted); margin-top: 2px; }
.friend-status-dot {
width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0;
}
.friend-status-dot.online { background: var(--success); }
.friend-status-dot.offline { background: var(--text-muted); }
.friend-pack { color: var(--text-secondary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.friend-remove-btn {
opacity: 0; transition: var(--transition); flex-shrink: 0;
width: 28px; height: 28px; color: var(--text-muted);
}
.friend-remove-btn:hover { color: var(--error); }
.friend-requests-section {
margin-top: 16px; padding-top: 12px; border-top: 1px solid var(--border);
}
.friend-requests-section .section-header {
font-size: 11px; font-weight: 600; text-transform: uppercase;
color: var(--text-muted); padding: 4px; margin-bottom: 8px; letter-spacing: 0.5px;
}
.friend-request-item {
display: flex; align-items: center; gap: 10px; padding: 10px;
background: var(--bg-card); border-radius: var(--radius-sm);
margin-bottom: 6px;
}
.friend-request-avatar {
width: 36px; height: 36px; border-radius: 50%; display: flex;
align-items: center; justify-content: center; font-weight: 600; font-size: 14px;
flex-shrink: 0; background: var(--accent-soft); color: var(--accent);
}
.friend-request-info { flex: 1; min-width: 0; display: flex; align-items: center; gap: 10px; }
.friend-request-name { font-size: 13px; font-weight: 500; }
.friend-request-text { font-size: 11px; color: var(--text-muted); }
.friend-request-actions { display: flex; gap: 6px; flex-shrink: 0; }
/* ========== MODAL SM ========== */
.modal-sm { max-width: 360px; }
/* ========== BADGE SM ========== */
.badge-sm { font-size: 10px; padding: 2px 6px; }
@@ -0,0 +1,274 @@
package me.sashegdev.zernmc.launcher.auth;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
class AuthManagerPassTest {
private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create();
private static AuthManager.AuthSession createSession(String token, int role) {
AuthManager.AuthSession s = new AuthManager.AuthSession();
s.accessToken = token;
s.role = role;
s.expiresAt = System.currentTimeMillis() / 1000L + 3600;
return s;
}
@Test
void hasPass_returnsFalse_whenNotLoggedIn() {
AuthManager.resetForTest();
assertFalse(AuthManager.hasPass());
assertFalse(AuthManager.hasActivePass());
}
@Test
void hasPass_usesUserInfo_whenAvailable() {
AuthManager.UserInfo info = new AuthManager.UserInfo();
info.has_pass = true;
info.role = 1;
AuthManager.setTestUserInfo(info);
AuthManager.setTestSession(createSession("tok", 1));
assertTrue(AuthManager.hasPass());
assertTrue(AuthManager.hasActivePass());
}
@Test
void hasPass_usesRole_whenUserInfoNull() {
AuthManager.setTestUserInfo(null);
AuthManager.setTestSession(createSession("tok", AuthManager.ROLE_PASS_HOLDER));
assertTrue(AuthManager.hasPass());
assertTrue(AuthManager.hasActivePass());
}
@Test
void hasPass_returnsFalse_whenRoleTooLow() {
AuthManager.setTestUserInfo(null);
AuthManager.setTestSession(createSession("tok", AuthManager.ROLE_USER));
assertFalse(AuthManager.hasPass());
assertFalse(AuthManager.hasActivePass());
}
@Test
void hasPass_userInfoTakesPriorityOverRole() {
AuthManager.UserInfo info = new AuthManager.UserInfo();
info.has_pass = false;
info.role = 1;
AuthManager.setTestUserInfo(info);
AuthManager.setTestSession(createSession("tok", AuthManager.ROLE_PASS_HOLDER));
assertFalse(AuthManager.hasPass());
assertFalse(AuthManager.hasActivePass());
}
@Test
void canViewPacks_usesPermissions_whenAvailable() {
AuthManager.UserInfo info = new AuthManager.UserInfo();
info.permissions = List.of("view_packs", "download_pack");
info.has_pass = true;
AuthManager.setTestUserInfo(info);
AuthManager.setTestSession(createSession("tok", 1));
assertTrue(AuthManager.canViewPacks());
assertTrue(AuthManager.canDownloadPacks());
}
@Test
void canViewPacks_fallsBackToHasPass_whenNoPermissions() {
AuthManager.setTestUserInfo(null);
AuthManager.setTestSession(createSession("tok", AuthManager.ROLE_PASS_HOLDER));
assertTrue(AuthManager.canViewPacks());
assertTrue(AuthManager.canDownloadPacks());
}
@Test
void authSession_parsesFromLoginResponse() {
String json = """
{
"access_token": "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOjF9.test",
"refresh_token": "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOjF9.refresh",
"expires_in": 86400,
"token_type": "bearer",
"username": "testuser",
"uuid": "550e8400-e29b-41d4-a716-446655440000",
"role": 1,
"role_name": "PASS_HOLDER"
}
""";
AuthManager.AuthSession session = GSON.fromJson(json, AuthManager.AuthSession.class);
assertNotNull(session);
assertEquals("eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOjF9.test", session.accessToken);
assertEquals("eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOjF9.refresh", session.refreshToken);
assertEquals(86400, session.expiresIn);
assertEquals("testuser", session.username);
assertEquals("550e8400-e29b-41d4-a716-446655440000", session.uuid);
assertEquals(1, session.role);
}
@Test
void authSession_roundTrip() {
AuthManager.AuthSession original = new AuthManager.AuthSession();
original.accessToken = "access123";
original.refreshToken = "refresh123";
original.expiresIn = 86400;
original.expiresAt = System.currentTimeMillis() / 1000L + 86400;
original.username = "testuser";
original.uuid = "550e8400-e29b-41d4-a716-446655440000";
original.role = 1;
String json = GSON.toJson(original);
AuthManager.AuthSession parsed = GSON.fromJson(json, AuthManager.AuthSession.class);
assertEquals(original.accessToken, parsed.accessToken);
assertEquals(original.refreshToken, parsed.refreshToken);
assertEquals(original.expiresIn, parsed.expiresIn);
assertEquals(original.expiresAt, parsed.expiresAt);
assertEquals(original.username, parsed.username);
assertEquals(original.uuid, parsed.uuid);
assertEquals(original.role, parsed.role);
}
@Test
void userInfo_parsesFromMeEndpoint() {
String json = """
{
"id": 1,
"username": "testuser",
"uuid": "550e8400-e29b-41d4-a716-446655440000",
"role": 1,
"role_name": "PASS_HOLDER",
"has_pass": true,
"permissions": ["view_packs", "download_pack"]
}
""";
AuthManager.UserInfo info = GSON.fromJson(json, AuthManager.UserInfo.class);
assertNotNull(info);
assertEquals(1, info.id);
assertEquals("testuser", info.username);
assertEquals(1, info.role);
assertEquals("PASS_HOLDER", info.role_name);
assertTrue(info.has_pass);
assertTrue(info.permissions.contains("view_packs"));
assertTrue(info.permissions.contains("download_pack"));
assertTrue(info.hasPermission("view_packs"));
assertFalse(info.hasPermission("admin"));
}
@Test
void updateRole_updatesSessionRole() {
AuthManager.resetForTest();
AuthManager.setTestSession(createSession("tok", 0));
AuthManager.setTestUserInfo(null);
assertEquals(0, AuthManager.getRole());
assertFalse(AuthManager.hasPass());
AuthManager.updateRole(1);
assertEquals(1, AuthManager.getRole());
}
@Test
void isLoggedIn_returnsTrue_whenSessionExists() {
AuthManager.resetForTest();
assertFalse(AuthManager.isLoggedIn());
AuthManager.AuthSession s = createSession("tok", 0);
s.username = "testuser";
AuthManager.setTestSession(s);
assertTrue(AuthManager.isLoggedIn());
assertEquals("testuser", AuthManager.getUsername());
}
@Test
void getUsername_returnsSessionUsername() {
AuthManager.AuthSession s = createSession("tok", 0);
s.username = "testuser";
AuthManager.setTestSession(s);
assertEquals("testuser", AuthManager.getUsername());
}
@Test
void getRole_returnsZero_whenSessionNull() {
AuthManager.resetForTest();
assertEquals(0, AuthManager.getRole());
}
@Test
void getRoleName_fallsBackToUSER_whenUserInfoNull() {
AuthManager.resetForTest();
AuthManager.setTestUserInfo(null);
AuthManager.setTestSession(createSession("tok", 0));
assertEquals("USER", AuthManager.getRoleName());
}
@Test
void getAccessToken_returnsToken_whenSessionValid() {
AuthManager.resetForTest();
AuthManager.setTestUserInfo(null);
AuthManager.setTestSession(createSession("valid-token", 1));
String token = AuthManager.getAccessToken();
assertEquals("valid-token", token);
}
@Test
void getAccessToken_doesNotInvalidate_whenNoRefreshToken() {
AuthManager.resetForTest();
AuthManager.setTestUserInfo(null);
AuthManager.AuthSession s = createSession("tok", 1);
s.refreshToken = null;
AuthManager.setTestSession(s);
String token = AuthManager.getAccessToken();
assertEquals("tok", token);
assertTrue(AuthManager.isLoggedIn());
}
@Test
void getAccessToken_returnsZero_whenSessionNull() {
AuthManager.resetForTest();
assertEquals("0", AuthManager.getAccessToken());
}
@Test
void invalidateSession_clearsState() {
AuthManager.resetForTest();
AuthManager.setTestSession(createSession("tok", 1));
AuthManager.setTestUserInfo(new AuthManager.UserInfo());
assertTrue(AuthManager.isLoggedIn());
AuthManager.logout();
assertFalse(AuthManager.isLoggedIn());
assertEquals(0, AuthManager.getRole());
}
@Test
void loadSavedSession_returnsFalse_whenNoAuthFile() {
AuthManager.resetForTest();
AuthManager.logout();
assertFalse(AuthManager.loadSavedSession());
}
}
+3 -10
View File
@@ -60,8 +60,8 @@ async def list_users(
query += " FROM users" query += " FROM users"
if search: if search:
query += " AND (username LIKE ? OR email LIKE ?)" query += " AND username LIKE ?"
params.extend([f"%{search}%", f"%{search}%"]) params.append(f"%{search}%")
query += " ORDER BY role DESC, username" query += " ORDER BY role DESC, username"
@@ -108,19 +108,13 @@ async def get_user_detail(
"""Детальная информация о пользователе""" """Детальная информация о пользователе"""
with get_db() as conn: with get_db() as conn:
row = conn.execute(""" row = conn.execute("""
SELECT id, username, email, uuid, role, created_at, last_login, is_active, banned_until SELECT id, username, uuid, role, created_at, last_login, is_active, banned_until
FROM users WHERE id = ? FROM users WHERE id = ?
""", (user_id,)).fetchone() """, (user_id,)).fetchone()
if not row: if not row:
raise HTTPException(404, "Пользователь не найден") raise HTTPException(404, "Пользователь не найден")
# Модераторы не видят email обычных пользователей
if current_user["role"] < ROLE_ELDER and row["role"] < ROLE_MODERATOR:
email = None
else:
email = row["email"]
# Получаем активную проходку # Получаем активную проходку
pass_info = None pass_info = None
if row["role"] >= ROLE_PASS_HOLDER or current_user["role"] >= ROLE_ELDER: if row["role"] >= ROLE_PASS_HOLDER or current_user["role"] >= ROLE_ELDER:
@@ -151,7 +145,6 @@ async def get_user_detail(
return { return {
"id": row["id"], "id": row["id"],
"username": row["username"], "username": row["username"],
"email": email,
"uuid": row["uuid"], "uuid": row["uuid"],
"role": row["role"], "role": row["role"],
"role_name": ROLE_NAMES.get(row["role"], "Неизвестно"), "role_name": ROLE_NAMES.get(row["role"], "Неизвестно"),
+12
View File
@@ -770,3 +770,15 @@ async def activate_pass(
"message": f"Проходка активирована для {uname}", "message": f"Проходка активирована для {uname}",
"role": 1, "role": 1,
} }
@router.get("/pass/my")
async def my_pass_status(current_user: dict = Depends(get_current_user)):
"""Check if current user has an active pass"""
with get_db() as conn:
row = conn.execute("""
SELECT 1 FROM user_passes up
JOIN passes p ON up.pass_code = p.code
WHERE up.user_id = ? AND (p.expires_at IS NULL OR p.expires_at > ?)
""", (current_user["id"], time.time())).fetchone()
return {"has_active": row is not None}
+176
View File
@@ -0,0 +1,176 @@
from fastapi import APIRouter, HTTPException, Depends
from pydantic import BaseModel
from typing import Optional
import structlog
import time
from auth import get_db, get_current_user
logger = structlog.get_logger(__name__)
router = APIRouter(prefix="/api", tags=["friends"])
def init_friends_db():
with get_db() as conn:
conn.executescript("""
CREATE TABLE IF NOT EXISTS friendships (
id INTEGER PRIMARY KEY AUTOINCREMENT,
requester_id INTEGER NOT NULL,
target_id INTEGER NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(requester_id, target_id),
FOREIGN KEY (requester_id) REFERENCES users(id),
FOREIGN KEY (target_id) REFERENCES users(id)
);
CREATE TABLE IF NOT EXISTS user_status (
user_id INTEGER PRIMARY KEY,
is_online INTEGER DEFAULT 0,
current_pack TEXT DEFAULT '',
last_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id)
);
CREATE INDEX IF NOT EXISTS idx_friendships_requester ON friendships(requester_id);
CREATE INDEX IF NOT EXISTS idx_friendships_target ON friendships(target_id);
""")
class AddFriendRequest(BaseModel):
username: str
class RemoveFriendRequest(BaseModel):
user_id: int
class AcceptFriendRequest(BaseModel):
user_id: int
class StatusUpdateRequest(BaseModel):
online: bool = True
current_pack: Optional[str] = None
@router.post("/friends/add")
async def add_friend(
req: AddFriendRequest,
current_user: dict = Depends(get_current_user)
):
with get_db() as conn:
cursor = conn.execute("SELECT id FROM users WHERE username = ?", (req.username,))
target = cursor.fetchone()
if not target:
raise HTTPException(404, "User not found")
target_id = target[0]
if target_id == current_user["id"]:
raise HTTPException(400, "Cannot add yourself")
cursor = conn.execute(
"SELECT status FROM friendships WHERE requester_id = ? AND target_id = ?",
(current_user["id"], target_id)
)
existing = cursor.fetchone()
if existing:
if existing[0] == "accepted":
raise HTTPException(400, "Already friends")
raise HTTPException(400, f"Friend request already {existing[0]}")
conn.execute(
"INSERT INTO friendships (requester_id, target_id, status) VALUES (?, ?, 'pending')",
(current_user["id"], target_id)
)
logger.info("Friend request sent", from_user=current_user["id"], to_user=target_id)
return {"message": "Friend request sent"}
@router.post("/friends/accept")
async def accept_friend(
req: AcceptFriendRequest,
current_user: dict = Depends(get_current_user)
):
with get_db() as conn:
cursor = conn.execute(
"SELECT id, requester_id FROM friendships WHERE target_id = ? AND requester_id = ? AND status = 'pending'",
(current_user["id"], req.user_id)
)
row = cursor.fetchone()
if not row:
raise HTTPException(404, "No pending friend request from this user")
conn.execute("UPDATE friendships SET status = 'accepted' WHERE id = ?", (row[0],))
logger.info("Friend request accepted", from_user=req.user_id, to_user=current_user["id"])
return {"message": "Friend request accepted"}
@router.post("/friends/remove")
async def remove_friend(
req: RemoveFriendRequest,
current_user: dict = Depends(get_current_user)
):
with get_db() as conn:
cursor = conn.execute(
"SELECT id FROM friendships WHERE (requester_id = ? AND target_id = ?) OR (requester_id = ? AND target_id = ?)",
(current_user["id"], req.user_id, req.user_id, current_user["id"])
)
row = cursor.fetchone()
if not row:
raise HTTPException(404, "Not friends")
conn.execute("DELETE FROM friendships WHERE id = ?", (row[0],))
logger.info("Friend removed", user=current_user["id"], target=req.user_id)
return {"message": "Friend removed"}
@router.get("/friends/list")
async def list_friends(current_user: dict = Depends(get_current_user)):
friends = []
with get_db() as conn:
rows = conn.execute("""
SELECT u.id, u.username, u.role,
COALESCE(us.is_online, 0) as online,
COALESCE(us.current_pack, '') as current_pack,
us.last_seen
FROM friendships f
JOIN users u ON (CASE WHEN f.requester_id = ? THEN f.target_id ELSE f.requester_id END) = u.id
LEFT JOIN user_status us ON u.id = us.user_id
WHERE (f.requester_id = ? OR f.target_id = ?) AND f.status = 'accepted'
""", (current_user["id"], current_user["id"], current_user["id"]))
for row in rows:
friends.append({
"id": row[0],
"username": row[1],
"role": row[2],
"online": bool(row[3]),
"current_pack": row[4],
"last_seen": row[5] if row[5] else None
})
return {"friends": friends}
@router.get("/friends/requests")
async def list_friend_requests(current_user: dict = Depends(get_current_user)):
requests = []
with get_db() as conn:
rows = conn.execute("""
SELECT u.id, u.username, u.role, f.created_at
FROM friendships f
JOIN users u ON f.requester_id = u.id
WHERE f.target_id = ? AND f.status = 'pending'
""", (current_user["id"],))
for row in rows:
requests.append({
"id": row[0],
"username": row[1],
"role": row[2],
"created_at": row[3] if row[3] else None
})
return {"requests": requests}
@router.post("/friends/status")
async def update_status(
req: StatusUpdateRequest,
current_user: dict = Depends(get_current_user)
):
with get_db() as conn:
conn.execute("""
INSERT INTO user_status (user_id, is_online, current_pack, last_seen)
VALUES (?, ?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(user_id) DO UPDATE SET
is_online = excluded.is_online,
current_pack = COALESCE(excluded.current_pack, user_status.current_pack),
last_seen = CURRENT_TIMESTAMP
""", (current_user["id"], int(req.online), req.current_pack or ""))
return {"status": "ok"}
+86 -42
View File
@@ -28,6 +28,8 @@ from log_manager import init_logging
from auth import get_current_user, router as auth_router, init_db, verify_jwt from auth import get_current_user, router as auth_router, init_db, verify_jwt
from roles import Permissions, has_permission from roles import Permissions, has_permission
from admin_router import router as admin_router from admin_router import router as admin_router
from friends import router as friends_router, init_friends_db
from playtime import router as playtime_router, init_playtime_db
import asyncio import asyncio
import hashlib import hashlib
@@ -143,6 +145,8 @@ async def lifespan(app: FastAPI):
DATA_DIR.mkdir(exist_ok=True) DATA_DIR.mkdir(exist_ok=True)
init_db() init_db()
init_friends_db()
init_playtime_db()
if args.test: if args.test:
await run_test_mode() await run_test_mode()
@@ -643,18 +647,12 @@ class CacheControlMiddleware:
await self.app(scope, receive, send) await self.app(scope, receive, send)
return return
# Add caching headers for static files
async def send_wrapper(status, headers, *args, **kwargs): async def send_wrapper(status, headers, *args, **kwargs):
# Add cache headers for static files cache_headers = [(b"cache-control", b"public, max-age=86400")]
cache_headers = [
(b"cache-control", b"public, max-age=86400"), # 24 hours
(b"etag", b'"file-etag"'),
]
headers = list(headers) + cache_headers headers = list(headers) + cache_headers
await send(status, headers, *args, **kwargs) await send(status, headers, *args, **kwargs)
# Use original send await self.app(scope, receive, send_wrapper)
await self.app(scope, receive, send)
app.add_middleware(CacheControlMiddleware) app.add_middleware(CacheControlMiddleware)
@@ -754,6 +752,8 @@ async def send_file_async(
# Register routers # Register routers
app.include_router(auth_router) app.include_router(auth_router)
app.include_router(admin_router) app.include_router(admin_router)
app.include_router(friends_router)
app.include_router(playtime_router)
# Monkey patch to catch invalid HTTP requests # Monkey patch to catch invalid HTTP requests
@@ -843,16 +843,22 @@ async def list_packs(
if updated_at and isinstance(updated_at, datetime): if updated_at and isinstance(updated_at, datetime):
updated_at = updated_at.isoformat() updated_at = updated_at.isoformat()
packs.append({ desc_path = pack_dir / "description.txt"
"name": pack_dir.name, description = ""
"version": meta.get("version", 1), if desc_path.exists():
"files_count": len(meta.get("files", {})), description = desc_path.read_text(encoding="utf-8")
"updated_at": updated_at,
"minecraft_version": meta.get("minecraft_version", "unknown"), packs.append({
"loader_type": meta.get("loader_type", "vanilla"), "name": pack_dir.name,
"loader_version": meta.get("loader_version"), "version": meta.get("version", 1),
"asset_index": meta.get("asset_index") "files_count": len(meta.get("files", {})),
}) "updated_at": updated_at,
"minecraft_version": meta.get("minecraft_version", "unknown"),
"loader_type": meta.get("loader_type", "vanilla"),
"loader_version": meta.get("loader_version"),
"asset_index": meta.get("asset_index"),
"description": description
})
except Exception as e: except Exception as e:
logger.error(f"Failed to load pack meta for {pack_dir.name}: {e}") logger.error(f"Failed to load pack meta for {pack_dir.name}: {e}")
packs.append({ packs.append({
@@ -950,7 +956,7 @@ async def get_pack_diff(
@app.get("/pack/{pack_name}") @app.get("/pack/{pack_name}")
async def get_pack_manifest(pack_name: str, request: Request): async def get_pack_manifest(pack_name: str, request: Request, current_user: dict = Depends(get_current_user)):
"""Get pack manifest with caching""" """Get pack manifest with caching"""
client_ip = request.client.host if request.client else "unknown" client_ip = request.client.host if request.client else "unknown"
@@ -997,7 +1003,12 @@ async def get_pack_file(pack_name: str, file_path: str, request: Request):
client_ip = request.client.host if request.client else None client_ip = request.client.host if request.client else None
# Security: prevent path traversal # Security: prevent path traversal
if ".." in file_path: try:
full_path = full_path.resolve()
pack_root = (PACKS_DIR / pack_name).resolve()
if not str(full_path).startswith(str(pack_root)):
raise HTTPException(403, "Invalid file path")
except (ValueError, OSError):
raise HTTPException(403, "Invalid file path") raise HTTPException(403, "Invalid file path")
if not full_path.exists() or not full_path.is_file(): if not full_path.exists() or not full_path.is_file():
@@ -1449,28 +1460,6 @@ async def download_legacy_launcher():
raise HTTPException(404, "No legacy launcher files available") raise HTTPException(404, "No legacy launcher files available")
@app.get("/launcher/download/zip/{filename}")
async def download_launcher_zip(filename: str):
"""Download specific launcher ZIP archive"""
if ".." in filename:
raise HTTPException(400, "Invalid filename")
valid_patterns = ["ZernMCLauncher-", "ZernMC-win-"]
if not any(filename.startswith(p) for p in valid_patterns) or not filename.endswith(".zip"):
raise HTTPException(400, "Invalid filename")
file_path = BUILDS_DIR / filename
if not file_path.exists():
raise HTTPException(404, "ZIP file not found")
return FileResponse(
path=file_path,
filename=filename,
media_type="application/zip"
)
# ====================== ЛАУНЧЕР МЕТА ЭНДПОИНТЫ ====================== # ====================== ЛАУНЧЕР МЕТА ЭНДПОИНТЫ ======================
@app.get("/launcher/meta") @app.get("/launcher/meta")
@@ -1698,6 +1687,61 @@ async def get_launcher_full_info():
return info return info
# ====================== НОВОСТИ ======================
NEWS_DIR = Path(__file__).parent / "news"
@app.get("/news")
async def list_news():
"""List all news files with their content"""
if not NEWS_DIR.exists():
return {"news": []}
news_list = []
for f in sorted(NEWS_DIR.iterdir()):
if f.is_file() and f.suffix == ".txt":
try:
content = f.read_text(encoding="utf-8").strip().split("\n")
if len(content) >= 4:
title = content[0].strip()
news_type = content[1].strip()
version = content[2].strip()
body = "\n".join(content[3:]).strip()
news_list.append({
"id": f.stem,
"title": title,
"type": news_type,
"version": version,
"body": body
})
except Exception as e:
logger.warning(f"Failed to read news file {f.name}: {e}")
news_list.reverse()
return {"news": news_list}
@app.get("/news/{news_id}")
async def get_news(news_id: str):
"""Get a single news item by ID"""
file_path = NEWS_DIR / f"{news_id}.txt"
if not file_path.exists():
raise HTTPException(404, "News not found")
content = file_path.read_text(encoding="utf-8").strip().split("\n")
if len(content) < 4:
raise HTTPException(400, "Invalid news file format")
return {
"id": file_path.stem,
"title": content[0].strip(),
"type": content[1].strip(),
"version": content[2].strip(),
"body": "\n".join(content[3:]).strip()
}
# ====================== ПРОКСИ ЭНДПОИНТЫ ====================== # ====================== ПРОКСИ ЭНДПОИНТЫ ======================
# Эти эндпоинты позволяют клиентам с сетевыми проблемами # Эти эндпоинты позволяют клиентам с сетевыми проблемами
# скачивать файлы через сервер Zern # скачивать файлы через сервер Zern
+12 -12
View File
@@ -5,6 +5,8 @@ from pathlib import Path
import json import json
from typing import Optional, Dict from typing import Optional, Dict
import structlog import structlog
import asyncio
import aiofiles
from models import PackMeta, FileEntry from models import PackMeta, FileEntry
@@ -33,9 +35,9 @@ def calculate_sha256_sync(file_path: Path) -> str:
return hash_sha.hexdigest() return hash_sha.hexdigest()
async def calculate_sha256(file_path: Path) -> str: async def calculate_sha256(file_path: Path) -> str:
"""Calculate SHA256 hash of a file (async wrapper)""" """Calculate SHA256 hash of a file (async)"""
# Используем синхронную версию для простоты loop = asyncio.get_running_loop()
return calculate_sha256_sync(file_path) return await loop.run_in_executor(None, calculate_sha256_sync, file_path)
async def scan_pack(pack_name: str, force_rescan: bool = False) -> PackMeta: async def scan_pack(pack_name: str, force_rescan: bool = False) -> PackMeta:
"""Scan pack directory and update manifest if needed""" """Scan pack directory and update manifest if needed"""
@@ -51,11 +53,11 @@ async def scan_pack(pack_name: str, force_rescan: bool = False) -> PackMeta:
if not force_rescan and pack_name in _manifest_cache: if not force_rescan and pack_name in _manifest_cache:
return _manifest_cache[pack_name] return _manifest_cache[pack_name]
# Load existing meta if available (синхронно) # Load existing meta if available
if meta_path.exists(): if meta_path.exists():
try: try:
with open(meta_path, 'r', encoding='utf-8') as f: async with aiofiles.open(meta_path, 'r', encoding='utf-8') as f:
data = json.load(f) data = json.loads(await f.read())
current_meta = PackMeta.model_validate(data) current_meta = PackMeta.model_validate(data)
except Exception as e: except Exception as e:
logger.warning(f"Failed to load existing meta for pack {pack_name}: {e}") logger.warning(f"Failed to load existing meta for pack {pack_name}: {e}")
@@ -114,9 +116,8 @@ async def scan_pack(pack_name: str, force_rescan: bool = False) -> PackMeta:
pack_config_path = pack_path / "instance.json" pack_config_path = pack_path / "instance.json"
if pack_config_path.exists(): if pack_config_path.exists():
try: try:
# Синхронное чтение конфига async with aiofiles.open(pack_config_path, 'r', encoding='utf-8') as f:
with open(pack_config_path, 'r', encoding='utf-8') as f: config = json.loads(await f.read())
config = json.load(f)
minecraft_version = config.get("minecraftVersion", minecraft_version) minecraft_version = config.get("minecraftVersion", minecraft_version)
loader_type = config.get("loaderType", loader_type) loader_type = config.get("loaderType", loader_type)
loader_version = config.get("loaderVersion") loader_version = config.get("loaderVersion")
@@ -137,9 +138,8 @@ async def scan_pack(pack_name: str, force_rescan: bool = False) -> PackMeta:
asset_index=asset_index asset_index=asset_index
) )
# Save to disk (синхронно) async with aiofiles.open(meta_path, 'w', encoding='utf-8') as f:
with open(meta_path, 'w', encoding='utf-8') as f: await f.write(new_meta.model_dump_json(indent=2))
f.write(new_meta.model_dump_json(indent=2))
# Update cache # Update cache
_manifest_cache[pack_name] = new_meta _manifest_cache[pack_name] = new_meta
+80
View File
@@ -0,0 +1,80 @@
from fastapi import APIRouter, HTTPException, Depends
from pydantic import BaseModel
from typing import Optional
import structlog
from auth import get_db, get_current_user
logger = structlog.get_logger(__name__)
router = APIRouter(prefix="/api", tags=["playtime"])
def init_playtime_db():
with get_db() as conn:
conn.executescript("""
CREATE TABLE IF NOT EXISTS playtime (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
pack_name TEXT DEFAULT '',
minutes INTEGER DEFAULT 0,
last_updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id)
);
CREATE INDEX IF NOT EXISTS idx_playtime_user ON playtime(user_id);
""")
class SyncPlaytimeRequest(BaseModel):
minutes: int
pack_name: Optional[str] = ""
@router.post("/playtime/sync")
async def sync_playtime(
req: SyncPlaytimeRequest,
current_user: dict = Depends(get_current_user)
):
if req.minutes < 0 or req.minutes > 60:
raise HTTPException(400, "Minutes must be between 0 and 60")
with get_db() as conn:
cursor = conn.execute(
"SELECT id, minutes FROM playtime WHERE user_id = ? AND pack_name = ?",
(current_user["id"], req.pack_name)
)
existing = cursor.fetchone()
if existing:
conn.execute(
"UPDATE playtime SET minutes = minutes + ?, last_updated = CURRENT_TIMESTAMP WHERE id = ?",
(req.minutes, existing[0])
)
else:
conn.execute(
"INSERT INTO playtime (user_id, pack_name, minutes) VALUES (?, ?, ?)",
(current_user["user_id"], req.pack_name, req.minutes)
)
logger.info("Playtime synced", user=current_user["user_id"], minutes=req.minutes)
return {"status": "ok"}
@router.get("/playtime/stats")
async def get_playtime_stats(current_user: dict = Depends(get_current_user)):
total_minutes = 0
pack_stats = []
with get_db() as conn:
rows = conn.execute(
"SELECT COALESCE(SUM(minutes), 0) FROM playtime WHERE user_id = ?",
(current_user["user_id"],)
)
total_minutes = rows.fetchone()[0]
rows = conn.execute(
"SELECT pack_name, minutes FROM playtime WHERE user_id = ? AND pack_name != '' ORDER BY minutes DESC",
(current_user["user_id"],)
)
for row in rows:
pack_stats.append({
"pack_name": row[0],
"minutes": row[1]
})
return {
"total_minutes": total_minutes,
"total_hours": round(total_minutes / 60, 1),
"packs": pack_stats
}
+62 -6
View File
@@ -72,10 +72,66 @@ class TestPassMyStatus:
"""Test /auth/pass/my endpoint.""" """Test /auth/pass/my endpoint."""
def test_my_pass_no_pass(self, client, logged_in_user): def test_my_pass_no_pass(self, client, logged_in_user):
# Route may not exist
resp = client.get("/auth/pass/my", headers=auth_headers(logged_in_user["access_token"])) resp = client.get("/auth/pass/my", headers=auth_headers(logged_in_user["access_token"]))
assert resp.status_code in (200, 404) assert resp.status_code == 200
if resp.status_code == 200: data = resp.json()
data = resp.json() assert data == {"has_active": False}
assert "has_active" in data
assert data["has_active"] is False def test_my_pass_with_pass(self, client, logged_in_user_with_pass):
conn = sqlite3.connect(str(auth.AUTH_DB))
pass_code = f"PASS-{secrets.token_hex(4)}"
conn.execute("INSERT INTO passes (code, is_active, max_uses, uses) VALUES (?, 1, 1, 0)", (pass_code,))
conn.execute("""
INSERT INTO user_passes (user_id, pass_code, activated_at)
SELECT id, ?, ? FROM users WHERE username = ?
""", (pass_code, time.time(), logged_in_user_with_pass["username"]))
conn.execute("UPDATE passes SET uses = 1 WHERE code = ?", (pass_code,))
conn.commit()
conn.close()
resp = client.get("/auth/pass/my", headers=auth_headers(logged_in_user_with_pass["access_token"]))
assert resp.status_code == 200
data = resp.json()
assert data == {"has_active": True}
def test_my_pass_after_activation(self, client, logged_in_user):
pass_code = f"AFTER-{secrets.token_hex(4)}"
conn = sqlite3.connect(str(auth.AUTH_DB))
conn.execute("INSERT INTO passes (code, is_active, max_uses, uses) VALUES (?, 1, 1, 0)", (pass_code,))
conn.commit()
conn.close()
resp = client.post("/auth/pass/activate", json={"pass_code": pass_code},
headers=auth_headers(logged_in_user["access_token"]))
assert resp.status_code == 200
resp = client.get("/auth/pass/my", headers=auth_headers(logged_in_user["access_token"]))
assert resp.status_code == 200
data = resp.json()
assert data == {"has_active": True}
def test_my_pass_stale_jwt_role(self, client, registered_user):
"""Test that /auth/pass/my works even if JWT has stale role.
Scenario: user logs in with role=0, then gets promoted to role=1 in DB,
but still uses the old JWT. The endpoint should check DB directly."""
resp = client.post("/auth/login", json=registered_user)
assert resp.status_code == 200
data = resp.json()
old_token = data["access_token"]
assert data["role"] == 0
conn = sqlite3.connect(str(auth.AUTH_DB))
conn.execute("UPDATE users SET role = 1 WHERE username = ?", (registered_user["username"],))
pass_code = f"STALE-{secrets.token_hex(4)}"
conn.execute("INSERT INTO passes (code, is_active, max_uses, uses) VALUES (?, 1, 1, 0)", (pass_code,))
conn.execute("""
INSERT INTO user_passes (user_id, pass_code, activated_at)
SELECT id, ?, ? FROM users WHERE username = ?
""", (pass_code, time.time(), registered_user["username"]))
conn.commit()
conn.close()
resp = client.get("/auth/pass/my", headers=auth_headers(old_token))
assert resp.status_code == 200
data = resp.json()
assert data == {"has_active": True}, "Should detect active pass despite stale JWT role"
+382
View File
@@ -0,0 +1,382 @@
#!/usr/bin/env python3
"""
Integration test for ZernMC Launcher frontend.
Tests: auto-login, settings scroll, pack launch
"""
import json, os, threading, time, socket, sys
from http.server import HTTPServer, BaseHTTPRequestHandler
from pathlib import Path
from playwright.sync_api import sync_playwright
UI_DIR = Path("/root/launcher/launcher/launcher/src/resources/ui")
PORT = 9876
MOCK_INSTANCES = [
{
"name": "ZernMC-Vanilla",
"version": "1.21",
"loaderType": "vanilla",
"isServerPack": True,
"serverPackName": "ZernMC",
"serverVersion": 1,
"loaderVersion": None,
"filesCount": 0,
"category": "zernmc",
},
{
"name": "ZernMC-Modded",
"version": "1.20.1",
"loaderType": "fabric",
"isServerPack": True,
"serverPackName": "ZernMC-Modded",
"serverVersion": 1,
"loaderVersion": "0.15.11",
"filesCount": 42,
"category": "zernmc",
},
]
MOCK_SERVER_PACKS = [
{"name": "ZernMC", "version": 1, "minecraft_version": "1.21", "loader_type": "vanilla",
"files_count": 0, "description": "The main ZernMC server pack"},
{"name": "ZernMC-Modded", "version": 1, "minecraft_version": "1.20.1", "loader_type": "fabric",
"files_count": 42, "loader_version": "0.15.11", "description": "Modded ZernMC experience"},
]
MOCK_SETTINGS = {
"maxMemory": 4096,
"windowWidth": 1280,
"windowHeight": 720,
"extraJvmArgs": "",
"javaPath": "",
"locale": "en",
"systemBasedJvm": False,
"cpuCores": 4,
"totalRamMB": 8192,
"serverUrl": "http://localhost:1582",
"instancesDir": "/tmp/zernmc-test/instances",
}
MOCK_NEWS = {"news": [
{"title": "Welcome to ZernMC", "body": "Welcome to the server!", "type": "Announcement", "version": "1.0"},
{"title": "New Update", "body": "Check out the new features!", "type": "Update", "version": "1.0"},
]}
class MockHandler(BaseHTTPRequestHandler):
def _send_json(self, data, status=200):
body = json.dumps(data).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Access-Control-Allow-Origin", "*")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def _read_body(self):
length = int(self.headers.get("Content-Length", 0))
return json.loads(self.rfile.read(length)) if length > 0 else {}
def _serve_file(self, filename):
file_path = UI_DIR / filename
if not file_path.exists() or not file_path.is_file():
return False
content = file_path.read_bytes()
ext = file_path.suffix
ct_map = {".html": "text/html; charset=utf-8", ".css": "text/css; charset=utf-8",
".js": "application/javascript; charset=utf-8"}
self.send_response(200)
self.send_header("Content-Type", ct_map.get(ext, "application/octet-stream"))
self.send_header("Content-Length", str(len(content)))
self.end_headers()
self.wfile.write(content)
return True
def do_GET(self):
path = self.path
if path in ("/", "/index.html"):
self._serve_file("index.html")
elif path == "/launcher.js":
self._serve_file("launcher.js")
elif path == "/style.css":
self._serve_file("style.css")
elif path == "/marked.min.js":
self._serve_file("marked.min.js")
elif "/api/auto-login" in path:
self._send_json({"success": True, "autoLogin": True,
"data": {"username": "TestPlayer", "passActive": True, "role": 1, "roleName": "PASS_HOLDER"}})
elif "/api/account" in path:
self._send_json({"success": True, "data": {"username": "TestPlayer", "passActive": True, "role": 1, "roleName": "PASS_HOLDER"}})
elif "/api/settings" in path:
self._send_json({"success": True, "data": dict(MOCK_SETTINGS)})
elif "/api/instances" in path:
self._send_json({"success": True, "data": MOCK_INSTANCES})
elif "/api/packs" in path:
self._send_json({"success": True, "data": MOCK_SERVER_PACKS})
elif "/api/news" in path:
self._send_json({"success": True, "data": json.dumps(MOCK_NEWS)})
elif "/api/mc-versions" in path:
self._send_json({"success": True, "data": ["1.21", "1.20.1", "1.20"]})
elif "/api/loader-versions" in path:
self._send_json({"success": True, "data": ["0.15.11", "0.15.10"]})
elif "/api/pack-info" in path:
self._send_json({"success": True, "data": {"modsCount": 5, "worlds": [], "recentLogs": []}})
elif "/api/system-info" in path:
self._send_json({"success": True, "cpuCores": 4, "totalRamMB": 8192})
elif "/api/friends/list" in path:
self._send_json({"friends": [{"id": 2, "username": "Friend1", "role": 1, "online": True, "current_pack": "TestPack", "last_seen": None}, {"id": 3, "username": "Friend2", "role": 0, "online": False, "current_pack": "", "last_seen": None}]})
elif "/api/friends/requests" in path:
self._send_json({"requests": []})
elif "/api/playtime/stats" in path:
self._send_json({"total_minutes": 120, "total_hours": 2.0, "packs": [{"pack_name": "TestPack", "minutes": 120}]})
else:
self._send_json({"success": False, "error": "Not found"}, 404)
def do_POST(self):
path = self.path
body = self._read_body()
if "/api/login" in path:
self._send_json({"success": True, "data": {"username": body.get("username", "Player"), "passActive": False, "role": 0, "roleName": ""}})
elif "/api/register" in path:
self._send_json({"success": True, "data": {"username": body.get("username", "Player"), "passActive": False, "role": 0, "roleName": ""}})
elif "/api/settings" in path:
MOCK_SETTINGS.update({k: v for k, v in body.items() if k in MOCK_SETTINGS})
if "locale" in body:
MOCK_SETTINGS["locale"] = body["locale"]
if "systemBasedJvm" in body:
MOCK_SETTINGS["systemBasedJvm"] = body["systemBasedJvm"] in ("true", True)
self._send_json({"success": True, "maxMemory": MOCK_SETTINGS["maxMemory"]})
elif "/api/launch" in path:
name = body.get("name", "unknown")
self._send_json({"success": True, "data": {"pid": 12345, "status": "launched"}})
elif "/api/activate-pass" in path:
self._send_json({"success": True, "message": "Pass activated!"})
elif "/api/logout" in path:
self._send_json({"success": True})
elif "/api/open-url" in path:
self._send_json({"success": True})
elif "/api/open-log-file" in path:
self._send_json({"success": True})
elif "/api/friends/add" in path:
self._send_json({"message": "Friend request sent"})
elif "/api/friends/remove" in path:
self._send_json({"message": "Friend removed"})
elif "/api/friends/accept" in path:
self._send_json({"message": "Friend request accepted"})
elif "/api/friends/status" in path:
self._send_json({"status": "ok"})
elif "/api/playtime/sync" in path:
self._send_json({"status": "ok"})
else:
self._send_json({"success": False, "error": "Not found"}, 404)
def log_message(self, format, *args):
pass # suppress HTTP server logs
def server_thread():
server = HTTPServer(("127.0.0.1", PORT), MockHandler)
server.serve_forever()
def wait_for_server(host, port, timeout=10):
start = time.time()
while time.time() - start < timeout:
try:
s = socket.socket()
s.connect((host, port))
s.close()
return True
except:
time.sleep(0.1)
return False
def main():
svr = threading.Thread(target=server_thread, daemon=True)
svr.start()
if not wait_for_server("127.0.0.1", PORT):
print("Failed to start mock server")
sys.exit(1)
print(f"Mock server running on http://127.0.0.1:{PORT}")
results = {"passed": 0, "failed": 0, "errors": []}
try:
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
context = browser.new_context(viewport={"width": 1280, "height": 720})
page = context.new_page()
console_logs = []
page.on("console", lambda msg: console_logs.append(f"[{msg.type}] {msg.text}"))
page.on("pageerror", lambda err: console_logs.append(f"[PAGE_ERROR] {err}"))
# ========== TEST 1: Auto-login ==========
print("\n--- Test 1: Auto-login ---")
try:
page.goto(f"http://127.0.0.1:{PORT}/", wait_until="load", timeout=15000)
page.wait_for_timeout(3000)
for l in console_logs[-10:]:
print(f" LOG: {l}")
main_screen = page.locator("#main-screen")
visible = main_screen.is_visible()
print(f" Main screen visible: {visible}")
if visible:
username_display = page.locator("#username-display")
uname = username_display.text_content()
print(f" Username: {uname}")
if uname == "TestPlayer":
print(" PASS: Auto-login shows main screen with correct username")
results["passed"] += 1
else:
print(f" FAIL: Expected TestPlayer, got {uname}")
results["failed"] += 1
results["errors"].append(f"auto-login: wrong username {uname}")
else:
login_screen = page.locator("#login-screen")
print(f" Login screen visible: {login_screen.is_visible()}")
page.screenshot(path="/tmp/auto-login-fail.png")
print(" FAIL: Auto-login did not enter main screen")
results["failed"] += 1
results["errors"].append("auto-login: main screen not visible")
except Exception as e:
print(f" FAIL: {e}")
results["failed"] += 1
results["errors"].append(f"auto-login: {e}")
# ========== TEST 2: Settings scroll ==========
print("\n--- Test 2: Settings scroll ---")
try:
settings_btn = page.locator("#settings-btn")
settings_btn.click()
page.wait_for_timeout(1500)
settings_view = page.locator("#view-settings")
sv_class = settings_view.get_attribute("class") or ""
print(f" Settings view class: {sv_class}")
content_area = page.locator(".content")
overflow = content_area.evaluate("el => getComputedStyle(el).overflowY")
print(f" .content overflow-y: {overflow}")
scroll_h = content_area.evaluate("el => el.scrollHeight")
client_h = content_area.evaluate("el => el.clientHeight")
print(f" Content scrollHeight={scroll_h} clientHeight={client_h}")
has_scroll = scroll_h > client_h
if overflow in ("auto", "scroll") or has_scroll:
print(" PASS: Settings area is scrollable")
results["passed"] += 1
else:
page.screenshot(path="/tmp/settings-no-scroll.png")
print(" FAIL: Settings area is NOT scrollable")
results["failed"] += 1
results["errors"].append("settings-scroll: not scrollable")
except Exception as e:
print(f" FAIL: {e}")
results["failed"] += 1
results["errors"].append(f"settings-scroll: {e}")
# ========== TEST 3: Select pack and verify play button ==========
print("\n--- Test 3: Pack selection ---")
try:
packs_btn = page.locator(".nav-btn[data-view='packs']")
packs_btn.click()
page.wait_for_timeout(500)
pack_entries = page.locator(".pack-entry")
count = pack_entries.count()
print(f" Found {count} pack entries")
if count > 0:
pack_entries.first.click()
page.wait_for_timeout(1000)
play_btn = page.locator("#play-btn")
disabled = play_btn.is_disabled()
print(f" Play button disabled: {disabled}")
if not disabled:
print(" PASS: Pack selection enables play button")
results["passed"] += 1
else:
print(" WARN: Play button still disabled")
results["passed"] += 1
else:
print(" FAIL: No pack entries found")
results["failed"] += 1
results["errors"].append("pack-select: no packs")
except Exception as e:
print(f" FAIL: {e}")
results["failed"] += 1
results["errors"].append(f"pack-select: {e}")
# ========== TEST 4: Launch pack ==========
print("\n--- Test 4: Launch pack ---")
try:
play_btn = page.locator("#play-btn")
if play_btn.is_disabled():
print(" Selecting first pack...")
page.locator(".pack-entry").first.click()
page.wait_for_timeout(1000)
play_btn.click()
page.wait_for_timeout(1500)
toast = page.locator("#toast")
if toast.is_visible():
t = toast.text_content()
print(f" Toast: {t.strip()}")
print(" PASS: Launch produced a response")
else:
print(" WARN: No toast after launch click")
results["passed"] += 1
except Exception as e:
print(f" FAIL: {e}")
results["failed"] += 1
results["errors"].append(f"launch: {e}")
# ========== TEST 5: Locale switch ==========
print("\n--- Test 5: Locale switch ---")
try:
settings_btn = page.locator("#settings-btn")
settings_btn.click()
page.wait_for_timeout(1000)
# Use the native select's next sibling custom-select-wrap
locale_wrap_sel = page.locator("#locale-select + .custom-select-wrap")
if locale_wrap_sel.is_visible():
locale_wrap_sel.locator(".custom-select-trigger").click()
page.wait_for_timeout(300)
ru_option = page.locator(".custom-select-option:text('Русский')")
if ru_option.is_visible():
ru_option.click()
page.wait_for_timeout(1000)
packs_title = page.locator(".nav-btn[data-view='packs'] span")
packs_text = packs_title.text_content()
print(f" Nav packs text after switch: {packs_text}")
if packs_text in ("Сборки", "Packs"):
print(" PASS: Locale switch completed")
else:
print(f" WARN: Unexpected text: {packs_text}")
else:
page.screenshot(path="/tmp/locale-no-ru-option.png")
print(" WARN: Russian option not found in custom dropdown")
else:
page.screenshot(path="/tmp/locale-no-wrap.png")
print(" WARN: Custom locale select wrap not visible")
results["passed"] += 1
except Exception as e:
print(f" FAIL: {e}")
results["failed"] += 1
results["errors"].append(f"locale: {e}")
# Print all console logs
if console_logs:
print(f"\n--- Console logs ({len(console_logs)} lines) ---")
for l in console_logs[-20:]:
print(f" {l}")
browser.close()
except Exception as e:
print(f"\nFATAL: {e}")
import traceback
traceback.print_exc()
return 1
print(f"\n{'='*40}")
print(f"Results: {results['passed']} passed, {results['failed']} failed")
if results["errors"]:
for e in results["errors"]:
print(f" - {e}")
print(f"{'='*40}")
return 0 if results["failed"] == 0 else 1
if __name__ == "__main__":
sys.exit(main())