иним чиним чиним чиним а так же новая система друзей и бутстраппера
This commit is contained in:
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -153,26 +154,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;
|
||||||
@@ -73,7 +75,8 @@ public class AuthService {
|
|||||||
public ApiResponse<Boolean> activatePass(String passCode) {
|
public ApiResponse<Boolean> activatePass(String passCode) {
|
||||||
try {
|
try {
|
||||||
String response = post("/auth/pass/activate",
|
String response = post("/auth/pass/activate",
|
||||||
"{\"code\":\"" + passCode + "\"}");
|
"{\"pass_code\":\"" + passCode + "\"}");
|
||||||
|
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());
|
||||||
|
|||||||
+17
-5
@@ -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("\\s+")) {
|
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();
|
||||||
@@ -99,6 +135,10 @@ public class AuthManager {
|
|||||||
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";
|
return session != null ? session.username : "Player";
|
||||||
}
|
}
|
||||||
@@ -110,7 +150,11 @@ public class AuthManager {
|
|||||||
public static String getAccessToken() {
|
public static String getAccessToken() {
|
||||||
if (session == null) return "0";
|
if (session == null) return "0";
|
||||||
if (isAccessTokenExpired()) {
|
if (isAccessTokenExpired()) {
|
||||||
tryRefresh();
|
boolean refreshed = tryRefresh();
|
||||||
|
if (!refreshed) {
|
||||||
|
if (session == null) return "0";
|
||||||
|
return session.accessToken != null ? session.accessToken : "0";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return session != null && session.accessToken != null ? session.accessToken : "0";
|
return session != null && session.accessToken != null ? session.accessToken : "0";
|
||||||
}
|
}
|
||||||
@@ -121,7 +165,14 @@ 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 + "\"}";
|
String body = "{\"refresh_token\":\"" + session.refreshToken + "\"}";
|
||||||
SimpleHttpResponse resp = post("/auth/refresh", body);
|
SimpleHttpResponse resp = post("/auth/refresh", body);
|
||||||
@@ -131,22 +182,45 @@ public class AuthManager {
|
|||||||
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 +254,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 +362,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 +404,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 +440,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 {
|
||||||
|
|||||||
+3
-2
@@ -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;
|
||||||
@@ -118,7 +119,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 +138,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);
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
+549
-59
@@ -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() {
|
||||||
@@ -37,12 +42,15 @@ public class Config {
|
|||||||
}
|
}
|
||||||
|
|
||||||
maxMemory = Integer.parseInt(props.getProperty("maxMemory", "4096"));
|
maxMemory = Integer.parseInt(props.getProperty("maxMemory", "4096"));
|
||||||
|
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"));
|
windowWidth = Integer.parseInt(props.getProperty("windowWidth", "1280"));
|
||||||
windowHeight = Integer.parseInt(props.getProperty("windowHeight", "720"));
|
windowHeight = Integer.parseInt(props.getProperty("windowHeight", "720"));
|
||||||
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 +60,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 +79,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 +134,7 @@ public class Config {
|
|||||||
if (memory > 32768) memory = 32768;
|
if (memory > 32768) memory = 32768;
|
||||||
|
|
||||||
maxMemory = memory;
|
maxMemory = memory;
|
||||||
|
ramManuallySet = true;
|
||||||
save();
|
save();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -163,6 +199,56 @@ 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:+UseContainerSupport");
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()">×</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()">×</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">×</button>
|
<button class="modal-close" id="close-modal-btn">×</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
@@ -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; }
|
||||||
|
|||||||
+274
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -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].isoformat() 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].isoformat() 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"}
|
||||||
+77
-10
@@ -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()
|
||||||
@@ -754,6 +758,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 +849,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({
|
||||||
@@ -1698,6 +1710,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
|
||||||
|
|||||||
@@ -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["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
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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())
|
||||||
Reference in New Issue
Block a user