JCEF migration: add jcefmaven dep, make JavaFX optional, create LogBridge, wire JCEFLauncher entry point
This commit is contained in:
@@ -37,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 boolean isJcefMode;
|
||||||
private static BootstrapUI ui;
|
private static BootstrapUI ui;
|
||||||
|
|
||||||
private static Path getLauncherJar() {
|
private static Path getLauncherJar() {
|
||||||
@@ -54,13 +55,29 @@ public class Bootstrap {
|
|||||||
log("=== ZernMC Launcher ===");
|
log("=== ZernMC Launcher ===");
|
||||||
|
|
||||||
List<String> argList = new ArrayList<>(Arrays.asList(args));
|
List<String> argList = new ArrayList<>(Arrays.asList(args));
|
||||||
isCliMode = argList.contains("--cli") || (System.console() != null && !argList.contains("--jfx"));
|
isCliMode = argList.contains("--cli") || (System.console() != null && !argList.contains("--jfx") && !argList.contains("--jcef"));
|
||||||
isJfxMode = !isCliMode;
|
// Auto-detect UI backend: prefer JCEF, fall back to JavaFX
|
||||||
|
boolean javafxAvailable = false;
|
||||||
|
if (Files.exists(javafxPath)) {
|
||||||
|
try (var list = Files.list(javafxPath)) {
|
||||||
|
javafxAvailable = list.findAny().isPresent();
|
||||||
|
} catch (Exception ignored) {}
|
||||||
|
}
|
||||||
|
if (argList.contains("--jfx")) {
|
||||||
|
isJfxMode = true;
|
||||||
|
isJcefMode = false;
|
||||||
|
} else if (argList.contains("--jcef")) {
|
||||||
|
isJfxMode = false;
|
||||||
|
isJcefMode = true;
|
||||||
|
} else {
|
||||||
|
isJfxMode = javafxAvailable;
|
||||||
|
isJcefMode = !javafxAvailable;
|
||||||
|
}
|
||||||
if (isCliMode && !argList.contains("--cli")) {
|
if (isCliMode && !argList.contains("--cli")) {
|
||||||
argList.add("--cli");
|
argList.add("--cli");
|
||||||
}
|
}
|
||||||
|
|
||||||
log("Mode: " + (isCliMode ? "CLI" : "JFX"));
|
log("Mode: " + (isCliMode ? "CLI" : (isJcefMode ? "JCEF" : "JFX")));
|
||||||
|
|
||||||
if (!isCliMode && !GraphicsEnvironment.isHeadless()) {
|
if (!isCliMode && !GraphicsEnvironment.isHeadless()) {
|
||||||
ui = new BootstrapUI();
|
ui = new BootstrapUI();
|
||||||
@@ -146,16 +163,26 @@ public class Bootstrap {
|
|||||||
cmd.add("-Dsun.stderr.encoding=UTF-8");
|
cmd.add("-Dsun.stderr.encoding=UTF-8");
|
||||||
cmd.add("-Dlauncher.server=" + BASE_URL);
|
cmd.add("-Dlauncher.server=" + BASE_URL);
|
||||||
|
|
||||||
if (Files.exists(javafxPath)) {
|
if (isJfxMode && Files.exists(javafxPath)) {
|
||||||
cmd.add("--module-path");
|
cmd.add("--module-path");
|
||||||
cmd.add(javafxPath.toAbsolutePath().toString());
|
cmd.add(javafxPath.toAbsolutePath().toString());
|
||||||
cmd.add("--add-modules");
|
cmd.add("--add-modules");
|
||||||
cmd.add("javafx.controls,javafx.web");
|
cmd.add("javafx.controls,javafx.web");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isJcefMode) {
|
||||||
|
// JCEF requires --add-opens for macOS
|
||||||
|
cmd.add("--add-opens");
|
||||||
|
cmd.add("java.desktop/sun.awt=ALL-UNNAMED");
|
||||||
|
cmd.add("--add-opens");
|
||||||
|
cmd.add("java.desktop/sun.lwawt=ALL-UNNAMED");
|
||||||
|
cmd.add("--add-opens");
|
||||||
|
cmd.add("java.desktop/sun.lwawt.macosx=ALL-UNNAMED");
|
||||||
|
}
|
||||||
|
|
||||||
cmd.add("-jar");
|
cmd.add("-jar");
|
||||||
cmd.add(getLauncherJar().toAbsolutePath().toString());
|
cmd.add(getLauncherJar().toAbsolutePath().toString());
|
||||||
cmd.add("--jfx");
|
cmd.add(isJcefMode ? "--jcef" : "--jfx");
|
||||||
|
|
||||||
ProcessBuilder pb = new ProcessBuilder(cmd);
|
ProcessBuilder pb = new ProcessBuilder(cmd);
|
||||||
pb.directory(baseDir.toFile());
|
pb.directory(baseDir.toFile());
|
||||||
|
|||||||
+78
-35
@@ -57,36 +57,10 @@
|
|||||||
<artifactId>commons-io</artifactId>
|
<artifactId>commons-io</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
<!-- JavaFX - Windows -->
|
<!-- JCEF (Chromium Embedded Framework) - auto-downloads natives -->
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.openjfx</groupId>
|
<groupId>me.friwi</groupId>
|
||||||
<artifactId>javafx-controls</artifactId>
|
<artifactId>jcefmaven</artifactId>
|
||||||
<version>21</version>
|
|
||||||
<classifier>win</classifier>
|
|
||||||
</dependency>
|
|
||||||
<dependency>
|
|
||||||
<groupId>org.openjfx</groupId>
|
|
||||||
<artifactId>javafx-web</artifactId>
|
|
||||||
<version>21</version>
|
|
||||||
<classifier>win</classifier>
|
|
||||||
</dependency>
|
|
||||||
<dependency>
|
|
||||||
<groupId>org.openjfx</groupId>
|
|
||||||
<artifactId>javafx-graphics</artifactId>
|
|
||||||
<version>21</version>
|
|
||||||
<classifier>win</classifier>
|
|
||||||
</dependency>
|
|
||||||
<dependency>
|
|
||||||
<groupId>org.openjfx</groupId>
|
|
||||||
<artifactId>javafx-base</artifactId>
|
|
||||||
<version>21</version>
|
|
||||||
<classifier>win</classifier>
|
|
||||||
</dependency>
|
|
||||||
<dependency>
|
|
||||||
<groupId>org.openjfx</groupId>
|
|
||||||
<artifactId>javafx-media</artifactId>
|
|
||||||
<version>21</version>
|
|
||||||
<classifier>win</classifier>
|
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
<!-- Test -->
|
<!-- Test -->
|
||||||
@@ -98,8 +72,77 @@
|
|||||||
</dependency>
|
</dependency>
|
||||||
</dependencies>
|
</dependencies>
|
||||||
|
|
||||||
|
<!-- JavaFX profile (optional, activate with -DuseJavaFX=true) -->
|
||||||
|
<profiles>
|
||||||
|
<profile>
|
||||||
|
<id>javafx</id>
|
||||||
|
<activation>
|
||||||
|
<property>
|
||||||
|
<name>useJavaFX</name>
|
||||||
|
<value>true</value>
|
||||||
|
</property>
|
||||||
|
</activation>
|
||||||
|
<dependencies>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.openjfx</groupId>
|
||||||
|
<artifactId>javafx-controls</artifactId>
|
||||||
|
<version>21</version>
|
||||||
|
<classifier>win</classifier>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.openjfx</groupId>
|
||||||
|
<artifactId>javafx-web</artifactId>
|
||||||
|
<version>21</version>
|
||||||
|
<classifier>win</classifier>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.openjfx</groupId>
|
||||||
|
<artifactId>javafx-graphics</artifactId>
|
||||||
|
<version>21</version>
|
||||||
|
<classifier>win</classifier>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.openjfx</groupId>
|
||||||
|
<artifactId>javafx-base</artifactId>
|
||||||
|
<version>21</version>
|
||||||
|
<classifier>win</classifier>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.openjfx</groupId>
|
||||||
|
<artifactId>javafx-media</artifactId>
|
||||||
|
<version>21</version>
|
||||||
|
<classifier>win</classifier>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
<build>
|
||||||
|
<plugins>
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.apache.maven.plugins</groupId>
|
||||||
|
<artifactId>maven-compiler-plugin</artifactId>
|
||||||
|
<version>3.11.0</version>
|
||||||
|
<configuration>
|
||||||
|
<excludes combine.self="override">
|
||||||
|
</excludes>
|
||||||
|
</configuration>
|
||||||
|
</plugin>
|
||||||
|
</plugins>
|
||||||
|
</build>
|
||||||
|
</profile>
|
||||||
|
</profiles>
|
||||||
|
|
||||||
<build>
|
<build>
|
||||||
<plugins>
|
<plugins>
|
||||||
|
<!-- Exclude JFXLauncher from default build (requires JavaFX profile) -->
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.apache.maven.plugins</groupId>
|
||||||
|
<artifactId>maven-compiler-plugin</artifactId>
|
||||||
|
<version>3.11.0</version>
|
||||||
|
<configuration>
|
||||||
|
<excludes>
|
||||||
|
<exclude>**/ui/jfx/JFXLauncher.java</exclude>
|
||||||
|
</excludes>
|
||||||
|
</configuration>
|
||||||
|
</plugin>
|
||||||
<plugin>
|
<plugin>
|
||||||
<groupId>org.apache.maven.plugins</groupId>
|
<groupId>org.apache.maven.plugins</groupId>
|
||||||
<artifactId>maven-shade-plugin</artifactId>
|
<artifactId>maven-shade-plugin</artifactId>
|
||||||
@@ -223,29 +266,29 @@
|
|||||||
</fileset>
|
</fileset>
|
||||||
</copy>
|
</copy>
|
||||||
|
|
||||||
<!-- Копируем JavaFX модули в lib/javafx -->
|
<!-- Копируем JavaFX модули в lib/javafx (если есть, иначе JCEF режим) -->
|
||||||
<mkdir dir="../../server/builds/lib/javafx"/>
|
<mkdir dir="../../server/builds/lib/javafx"/>
|
||||||
<copy todir="../../server/builds/lib/javafx" overwrite="true">
|
<copy todir="../../server/builds/lib/javafx" overwrite="true" failonerror="false">
|
||||||
<fileset dir="${user.home}/.m2/repository/org/openjfx/javafx-controls/21">
|
<fileset dir="${user.home}/.m2/repository/org/openjfx/javafx-controls/21">
|
||||||
<include name="*win.jar"/>
|
<include name="*win.jar"/>
|
||||||
</fileset>
|
</fileset>
|
||||||
</copy>
|
</copy>
|
||||||
<copy todir="../../server/builds/lib/javafx" overwrite="true">
|
<copy todir="../../server/builds/lib/javafx" overwrite="true" failonerror="false">
|
||||||
<fileset dir="${user.home}/.m2/repository/org/openjfx/javafx-graphics/21">
|
<fileset dir="${user.home}/.m2/repository/org/openjfx/javafx-graphics/21">
|
||||||
<include name="*win.jar"/>
|
<include name="*win.jar"/>
|
||||||
</fileset>
|
</fileset>
|
||||||
</copy>
|
</copy>
|
||||||
<copy todir="../../server/builds/lib/javafx" overwrite="true">
|
<copy todir="../../server/builds/lib/javafx" overwrite="true" failonerror="false">
|
||||||
<fileset dir="${user.home}/.m2/repository/org/openjfx/javafx-base/21">
|
<fileset dir="${user.home}/.m2/repository/org/openjfx/javafx-base/21">
|
||||||
<include name="*win.jar"/>
|
<include name="*win.jar"/>
|
||||||
</fileset>
|
</fileset>
|
||||||
</copy>
|
</copy>
|
||||||
<copy todir="../../server/builds/lib/javafx" overwrite="true">
|
<copy todir="../../server/builds/lib/javafx" overwrite="true" failonerror="false">
|
||||||
<fileset dir="${user.home}/.m2/repository/org/openjfx/javafx-web/21">
|
<fileset dir="${user.home}/.m2/repository/org/openjfx/javafx-web/21">
|
||||||
<include name="*win.jar"/>
|
<include name="*win.jar"/>
|
||||||
</fileset>
|
</fileset>
|
||||||
</copy>
|
</copy>
|
||||||
<copy todir="../../server/builds/lib/javafx" overwrite="true">
|
<copy todir="../../server/builds/lib/javafx" overwrite="true" failonerror="false">
|
||||||
<fileset dir="${user.home}/.m2/repository/org/openjfx/javafx-media/21">
|
<fileset dir="${user.home}/.m2/repository/org/openjfx/javafx-media/21">
|
||||||
<include name="*win.jar"/>
|
<include name="*win.jar"/>
|
||||||
</fileset>
|
</fileset>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import me.sashegdev.zernmc.launcher.api.LauncherAPI;
|
|||||||
import me.sashegdev.zernmc.launcher.auth.AuthManager;
|
import me.sashegdev.zernmc.launcher.auth.AuthManager;
|
||||||
import me.sashegdev.zernmc.launcher.menu.*;
|
import me.sashegdev.zernmc.launcher.menu.*;
|
||||||
import me.sashegdev.zernmc.launcher.ui.ArrowMenu;
|
import me.sashegdev.zernmc.launcher.ui.ArrowMenu;
|
||||||
import me.sashegdev.zernmc.launcher.ui.jfx.JFXLauncher;
|
import me.sashegdev.zernmc.launcher.ui.jcef.JCEFLauncher;
|
||||||
import me.sashegdev.zernmc.launcher.utils.*;
|
import me.sashegdev.zernmc.launcher.utils.*;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@@ -35,8 +35,14 @@ public class Main {
|
|||||||
|
|
||||||
List<String> argList = List.of(args);
|
List<String> argList = List.of(args);
|
||||||
boolean jfxMode = argList.contains("--jfx");
|
boolean jfxMode = argList.contains("--jfx");
|
||||||
|
boolean jcefMode = argList.contains("--jcef");
|
||||||
boolean cliMode = argList.contains("--cli");
|
boolean cliMode = argList.contains("--cli");
|
||||||
|
|
||||||
|
if (jcefMode) {
|
||||||
|
launchJCEF();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (jfxMode) {
|
if (jfxMode) {
|
||||||
launchJFX();
|
launchJFX();
|
||||||
return;
|
return;
|
||||||
@@ -48,14 +54,27 @@ public class Main {
|
|||||||
startCLI();
|
startCLI();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static void launchJCEF() {
|
||||||
|
try {
|
||||||
|
JCEFLauncher.main(new String[]{});
|
||||||
|
} catch (Exception e) {
|
||||||
|
System.err.println(ZAnsi.brightRed("Error starting JCEF: " + e.getMessage()));
|
||||||
|
e.printStackTrace();
|
||||||
|
System.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static void launchJFX() {
|
private static void launchJFX() {
|
||||||
try {
|
try {
|
||||||
System.setProperty("javafx.runtime.version", "21");
|
System.setProperty("javafx.runtime.version", "21");
|
||||||
|
Class<?> jfxClass = Class.forName("me.sashegdev.zernmc.launcher.ui.jfx.JFXLauncher");
|
||||||
JFXLauncher.main(new String[]{});
|
java.lang.reflect.Method mainMethod = jfxClass.getMethod("main", String[].class);
|
||||||
|
mainMethod.invoke(null, new Object[]{new String[]{}});
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
System.err.println(ZAnsi.brightRed("Error starting JFX: " + e.getMessage()));
|
System.err.println(ZAnsi.brightRed("Error starting JFX: " + e.getMessage()));
|
||||||
if (e.getMessage() != null && e.getMessage().contains("QuantumRenderer")) {
|
Throwable cause = e.getCause();
|
||||||
|
String msg = cause != null ? cause.getMessage() : e.getMessage();
|
||||||
|
if (msg != null && msg.contains("QuantumRenderer")) {
|
||||||
System.err.println(ZAnsi.yellow("JavaFX is not available. Native libraries may be missing."));
|
System.err.println(ZAnsi.yellow("JavaFX is not available. Native libraries may be missing."));
|
||||||
System.err.println(ZAnsi.yellow("Try CLI mode: --cli"));
|
System.err.println(ZAnsi.yellow("Try CLI mode: --cli"));
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-4
@@ -6,7 +6,7 @@ import me.sashegdev.zernmc.launcher.minecraft.Instance;
|
|||||||
import me.sashegdev.zernmc.launcher.minecraft.InstanceManager;
|
import me.sashegdev.zernmc.launcher.minecraft.InstanceManager;
|
||||||
import me.sashegdev.zernmc.launcher.minecraft.launch.LaunchCommandBuilder;
|
import me.sashegdev.zernmc.launcher.minecraft.launch.LaunchCommandBuilder;
|
||||||
import me.sashegdev.zernmc.launcher.minecraft.model.LaunchOptions;
|
import me.sashegdev.zernmc.launcher.minecraft.model.LaunchOptions;
|
||||||
import me.sashegdev.zernmc.launcher.ui.jfx.JFXLauncher;
|
import me.sashegdev.zernmc.launcher.utils.LogBridge;
|
||||||
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.LauncherLogger;
|
||||||
|
|
||||||
@@ -98,14 +98,14 @@ public class LaunchService {
|
|||||||
String line;
|
String line;
|
||||||
while ((line = reader.readLine()) != null) {
|
while ((line = reader.readLine()) != null) {
|
||||||
String timestamped = "[" + java.time.LocalTime.now().format(java.time.format.DateTimeFormatter.ofPattern("HH:mm:ss")) + "] " + line;
|
String timestamped = "[" + java.time.LocalTime.now().format(java.time.format.DateTimeFormatter.ofPattern("HH:mm:ss")) + "] " + line;
|
||||||
JFXLauncher.appendGameLog(line);
|
LogBridge.appendGameLog(line);
|
||||||
try {
|
try {
|
||||||
logFileOut.write((timestamped + "\n").getBytes(java.nio.charset.StandardCharsets.UTF_8));
|
logFileOut.write((timestamped + "\n").getBytes(java.nio.charset.StandardCharsets.UTF_8));
|
||||||
logFileOut.flush();
|
logFileOut.flush();
|
||||||
} catch (Exception ignored) {}
|
} catch (Exception ignored) {}
|
||||||
}
|
}
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
JFXLauncher.appendGameLog("[Error reading logs: " + e.getMessage() + "]");
|
LogBridge.appendGameLog("[Error reading logs: " + e.getMessage() + "]");
|
||||||
} finally {
|
} finally {
|
||||||
try { logFileOut.close(); } catch (Exception ignored) {}
|
try { logFileOut.close(); } catch (Exception ignored) {}
|
||||||
}
|
}
|
||||||
@@ -115,7 +115,7 @@ public class LaunchService {
|
|||||||
|
|
||||||
process.onExit().thenRun(() -> {
|
process.onExit().thenRun(() -> {
|
||||||
runningProcesses.remove(pid);
|
runningProcesses.remove(pid);
|
||||||
JFXLauncher.appendGameLog("[Minecraft exited with code: " + process.exitValue() + "]");
|
LogBridge.appendGameLog("[Minecraft exited with code: " + process.exitValue() + "]");
|
||||||
});
|
});
|
||||||
|
|
||||||
ProcessInfo info = new ProcessInfo(instanceName, pid, "RUNNING");
|
ProcessInfo info = new ProcessInfo(instanceName, pid, "RUNNING");
|
||||||
|
|||||||
+5
-5
@@ -6,7 +6,7 @@ import me.sashegdev.zernmc.launcher.minecraft.installer.NeoForgeInstaller;
|
|||||||
import me.sashegdev.zernmc.launcher.minecraft.installer.VersionInstaller;
|
import me.sashegdev.zernmc.launcher.minecraft.installer.VersionInstaller;
|
||||||
import me.sashegdev.zernmc.launcher.minecraft.launch.LaunchCommandBuilder;
|
import me.sashegdev.zernmc.launcher.minecraft.launch.LaunchCommandBuilder;
|
||||||
import me.sashegdev.zernmc.launcher.minecraft.model.LaunchOptions;
|
import me.sashegdev.zernmc.launcher.minecraft.model.LaunchOptions;
|
||||||
import me.sashegdev.zernmc.launcher.ui.jfx.JFXLauncher;
|
import me.sashegdev.zernmc.launcher.utils.LogBridge;
|
||||||
import me.sashegdev.zernmc.launcher.utils.ConsoleUtils;
|
import me.sashegdev.zernmc.launcher.utils.ConsoleUtils;
|
||||||
import me.sashegdev.zernmc.launcher.utils.LauncherLogger;
|
import me.sashegdev.zernmc.launcher.utils.LauncherLogger;
|
||||||
import me.sashegdev.zernmc.launcher.utils.ZAnsi;
|
import me.sashegdev.zernmc.launcher.utils.ZAnsi;
|
||||||
@@ -130,10 +130,10 @@ public class MinecraftLib {
|
|||||||
try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) {
|
try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) {
|
||||||
String line;
|
String line;
|
||||||
while ((line = reader.readLine()) != null) {
|
while ((line = reader.readLine()) != null) {
|
||||||
JFXLauncher.appendGameLog(line);
|
LogBridge.appendGameLog(line);
|
||||||
}
|
}
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
JFXLauncher.appendGameLog("[Error reading output: " + e.getMessage() + "]");
|
LogBridge.appendGameLog("[Error reading output: " + e.getMessage() + "]");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
outThread.setDaemon(true);
|
outThread.setDaemon(true);
|
||||||
@@ -144,10 +144,10 @@ public class MinecraftLib {
|
|||||||
try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getErrorStream()))) {
|
try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getErrorStream()))) {
|
||||||
String line;
|
String line;
|
||||||
while ((line = reader.readLine()) != null) {
|
while ((line = reader.readLine()) != null) {
|
||||||
JFXLauncher.appendGameLog("[ERR] " + line);
|
LogBridge.appendGameLog("[ERR] " + line);
|
||||||
}
|
}
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
JFXLauncher.appendGameLog("[Error reading stderr: " + e.getMessage() + "]");
|
LogBridge.appendGameLog("[Error reading stderr: " + e.getMessage() + "]");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
errThread.setDaemon(true);
|
errThread.setDaemon(true);
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -504,6 +504,7 @@ public class JFXLauncher extends Application {
|
|||||||
server.createContext("/api/playtime/stats", this::handlePlaytimeStats);
|
server.createContext("/api/playtime/stats", this::handlePlaytimeStats);
|
||||||
server.createContext("/api/whitelist/mods", this::handleWhitelistMods);
|
server.createContext("/api/whitelist/mods", this::handleWhitelistMods);
|
||||||
server.createContext("/api/whitelist/mods/install", this::handleWhitelistInstallMod);
|
server.createContext("/api/whitelist/mods/install", this::handleWhitelistInstallMod);
|
||||||
|
server.createContext("/api/token", this::handleToken);
|
||||||
server.createContext("/api/admin", this::handleAdmin);
|
server.createContext("/api/admin", this::handleAdmin);
|
||||||
server.createContext("/assets/", this::handleStatic);
|
server.createContext("/assets/", this::handleStatic);
|
||||||
|
|
||||||
@@ -611,6 +612,23 @@ public class JFXLauncher extends Application {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void handleToken(HttpExchange exchange) {
|
||||||
|
try {
|
||||||
|
if (!api.isLoggedIn()) {
|
||||||
|
sendJson(exchange, Map.of("success", false, "error", "Not authenticated"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
String token = AuthManager.getAccessToken();
|
||||||
|
if (token == null || token.isEmpty() || "0".equals(token)) {
|
||||||
|
sendJson(exchange, Map.of("success", false, "error", "No token"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sendJson(exchange, Map.of("success", true, "token", token));
|
||||||
|
} catch (Exception e) {
|
||||||
|
sendJson(exchange, Map.of("success", false, "error", e.getMessage()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void handleAutoLogin(HttpExchange exchange) {
|
private void handleAutoLogin(HttpExchange exchange) {
|
||||||
try {
|
try {
|
||||||
boolean loggedIn = AuthManager.isLoggedIn();
|
boolean loggedIn = AuthManager.isLoggedIn();
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
package me.sashegdev.zernmc.launcher.utils;
|
||||||
|
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.nio.file.StandardOpenOption;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
import java.util.concurrent.CopyOnWriteArrayList;
|
||||||
|
|
||||||
|
public class LogBridge {
|
||||||
|
|
||||||
|
private static final StringBuilder launcherLogBuffer = new StringBuilder();
|
||||||
|
private static final StringBuilder gameLogBuffer = new StringBuilder();
|
||||||
|
private static Path gameLogFile;
|
||||||
|
|
||||||
|
private static final CopyOnWriteArrayList<LogConsumer> logConsumers = new CopyOnWriteArrayList<>();
|
||||||
|
private static final CopyOnWriteArrayList<LogConsumer> gameLogConsumers = new CopyOnWriteArrayList<>();
|
||||||
|
|
||||||
|
public interface LogConsumer {
|
||||||
|
void onLog(String line);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void addLogConsumer(LogConsumer consumer) {
|
||||||
|
logConsumers.add(consumer);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void removeLogConsumer(LogConsumer consumer) {
|
||||||
|
logConsumers.remove(consumer);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void addGameLogConsumer(LogConsumer consumer) {
|
||||||
|
gameLogConsumers.add(consumer);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void removeGameLogConsumer(LogConsumer consumer) {
|
||||||
|
gameLogConsumers.remove(consumer);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void appendLauncherLog(String msg) {
|
||||||
|
synchronized (launcherLogBuffer) {
|
||||||
|
launcherLogBuffer.append(msg).append("\n");
|
||||||
|
}
|
||||||
|
LauncherLogger.info("[EXT] " + msg);
|
||||||
|
for (LogConsumer consumer : logConsumers) {
|
||||||
|
try { consumer.onLog(msg); } catch (Exception ignored) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void appendGameLog(String msg) {
|
||||||
|
synchronized (gameLogBuffer) {
|
||||||
|
gameLogBuffer.append(msg).append("\n");
|
||||||
|
if (gameLogFile != null) {
|
||||||
|
try {
|
||||||
|
String timestamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss"));
|
||||||
|
Files.writeString(gameLogFile, "[" + timestamp + "] " + msg + "\n",
|
||||||
|
StandardOpenOption.CREATE, StandardOpenOption.APPEND);
|
||||||
|
} catch (Exception ignored) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LauncherLogger.info("[GAME] " + msg);
|
||||||
|
for (LogConsumer consumer : gameLogConsumers) {
|
||||||
|
try { consumer.onLog(msg); } catch (Exception ignored) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void initGameLog(Path instanceDir) {
|
||||||
|
synchronized (gameLogBuffer) {
|
||||||
|
gameLogBuffer.setLength(0);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
Path logsDir = instanceDir.resolve("logs");
|
||||||
|
Files.createDirectories(logsDir);
|
||||||
|
gameLogFile = logsDir.resolve("game.log");
|
||||||
|
Files.writeString(gameLogFile, "=== Game Log " + LocalDateTime.now() + " ===\n",
|
||||||
|
StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING);
|
||||||
|
} catch (Exception ignored) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void clearGameLog() {
|
||||||
|
synchronized (gameLogBuffer) {
|
||||||
|
gameLogBuffer.setLength(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String getGameLogs() {
|
||||||
|
synchronized (gameLogBuffer) {
|
||||||
|
return gameLogBuffer.toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String getLauncherLogs() {
|
||||||
|
synchronized (launcherLogBuffer) {
|
||||||
|
return launcherLogBuffer.toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -32,11 +32,15 @@
|
|||||||
|
|
||||||
<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" data-i18n-placeholder="login.username" autocomplete="username" required>
|
<input type="text" id="username" placeholder=" " data-i18n-placeholder="login.username" autocomplete="username" required>
|
||||||
<label for="username" data-i18n="login.username">Username</label>
|
<label for="username" data-i18n="login.username">Username</label>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="field" id="confirm-password-field" style="display:none">
|
||||||
|
<input type="password" id="confirm-password" placeholder=" " autocomplete="new-password">
|
||||||
|
<label>Confirm Password</label>
|
||||||
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<input type="password" id="password" placeholder="Password" data-i18n-placeholder="login.password" autocomplete="current-password" required>
|
<input type="password" id="password" placeholder=" " data-i18n-placeholder="login.password" autocomplete="current-password" required>
|
||||||
<label for="password" data-i18n="login.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>
|
||||||
@@ -44,7 +48,9 @@
|
|||||||
<span class="btn-label" data-i18n="login.title">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" data-i18n="login.hint">New account will be created automatically on first login</p>
|
<div class="login-extra">
|
||||||
|
<a id="login-toggle-link" onclick="app.toggleLoginMode()" data-i18n="login.register">No account? Register</a>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -59,7 +65,7 @@
|
|||||||
<div id="main-screen" class="screen hidden">
|
<div id="main-screen" class="screen hidden">
|
||||||
<div class="shell">
|
<div class="shell">
|
||||||
<aside class="sidebar">
|
<aside class="sidebar">
|
||||||
<div class="sidebar-top">
|
<div class="sidebar-header">
|
||||||
<div class="sidebar-brand">
|
<div class="sidebar-brand">
|
||||||
<svg width="32" height="32" viewBox="0 0 56 56" fill="none">
|
<svg width="32" height="32" viewBox="0 0 56 56" fill="none">
|
||||||
<rect width="56" height="56" rx="14" fill="url(#brandGrad2)"/>
|
<rect width="56" height="56" rx="14" fill="url(#brandGrad2)"/>
|
||||||
@@ -99,7 +105,9 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sidebar-scroll">
|
||||||
<div class="sidebar-section">
|
<div class="sidebar-section">
|
||||||
<div class="section-header">
|
<div class="section-header">
|
||||||
<span class="section-title" data-i18n="sidebar.serverPacks">Server Packs</span>
|
<span class="section-title" data-i18n="sidebar.serverPacks">Server Packs</span>
|
||||||
@@ -248,6 +256,12 @@
|
|||||||
<div class="view-header">
|
<div class="view-header">
|
||||||
<h2 class="view-title" data-i18n="admin.title">Admin Panel</h2>
|
<h2 class="view-title" data-i18n="admin.title">Admin Panel</h2>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="admin-dashboard" id="admin-dashboard">
|
||||||
|
<div class="admin-stat-card"><div class="admin-stat-value" id="ad-total-users">-</div><div class="admin-stat-label">Total Users</div></div>
|
||||||
|
<div class="admin-stat-card"><div class="admin-stat-value" id="ad-active-passes">-</div><div class="admin-stat-label">Active Passes</div></div>
|
||||||
|
<div class="admin-stat-card"><div class="admin-stat-value" id="ad-online-now">-</div><div class="admin-stat-label">Online Now</div></div>
|
||||||
|
<div class="admin-stat-card"><div class="admin-stat-value" id="ad-new-users">-</div><div class="admin-stat-label">New (7d)</div></div>
|
||||||
|
</div>
|
||||||
<div class="admin-tabs">
|
<div class="admin-tabs">
|
||||||
<button class="admin-tab active" data-atab="clients"><span data-i18n="admin.clients">Clients</span></button>
|
<button class="admin-tab active" data-atab="clients"><span data-i18n="admin.clients">Clients</span></button>
|
||||||
<button class="admin-tab" data-atab="mods"><span data-i18n="admin.mods">Mods</span></button>
|
<button class="admin-tab" data-atab="mods"><span data-i18n="admin.mods">Mods</span></button>
|
||||||
@@ -310,6 +324,17 @@
|
|||||||
</div>
|
</div>
|
||||||
<!-- Passes -->
|
<!-- Passes -->
|
||||||
<div id="atab-passes" class="admin-tab-content">
|
<div id="atab-passes" class="admin-tab-content">
|
||||||
|
<div class="admin-card">
|
||||||
|
<div class="admin-card-header">Create Pass Code</div>
|
||||||
|
<div class="pass-create-row">
|
||||||
|
<span style="font-size:12px;color:var(--text-muted)">Expires (days):</span>
|
||||||
|
<input type="number" id="pass-create-days" value="30" min="1" max="365">
|
||||||
|
<span style="font-size:12px;color:var(--text-muted)">Max uses:</span>
|
||||||
|
<input type="number" id="pass-create-uses" value="1" min="1" max="10">
|
||||||
|
<button class="btn-primary btn-sm" onclick="app.adminCreatePass()">Generate</button>
|
||||||
|
</div>
|
||||||
|
<div id="admin-pass-create-result" class="admin-status hidden"></div>
|
||||||
|
</div>
|
||||||
<div class="admin-card">
|
<div class="admin-card">
|
||||||
<div class="admin-card-header"><span data-i18n="admin.passesList">Pass Management</span></div>
|
<div class="admin-card-header"><span data-i18n="admin.passesList">Pass Management</span></div>
|
||||||
<div id="admin-passes-list" class="admin-list"><div class="admin-loading">Loading...</div></div>
|
<div id="admin-passes-list" class="admin-list"><div class="admin-loading">Loading...</div></div>
|
||||||
@@ -331,8 +356,15 @@
|
|||||||
</select>
|
</select>
|
||||||
<input type="text" id="admin-news-version" class="admin-input" placeholder="Version" style="max-width:120px">
|
<input type="text" id="admin-news-version" class="admin-input" placeholder="Version" style="max-width:120px">
|
||||||
</div>
|
</div>
|
||||||
<div class="admin-row">
|
<div class="md-editor-row">
|
||||||
<textarea id="admin-news-body" class="admin-textarea" placeholder="Body (Markdown)" data-i18n-placeholder="admin.newsBodyPlaceholder"></textarea>
|
<div class="md-editor-col">
|
||||||
|
<span style="font-size:11px;color:var(--text-muted);margin-bottom:4px">Markdown</span>
|
||||||
|
<textarea id="admin-news-body" class="admin-textarea" placeholder="Body (Markdown)" data-i18n-placeholder="admin.newsBodyPlaceholder" oninput="app.updateNewsPreview()"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="md-editor-col">
|
||||||
|
<span style="font-size:11px;color:var(--text-muted);margin-bottom:4px">Preview</span>
|
||||||
|
<div id="admin-news-preview" class="md-preview">Preview will appear here...</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="admin-row">
|
<div class="admin-row">
|
||||||
<button class="btn-primary btn-sm" onclick="app.adminCreateNews()"><span data-i18n="admin.newsCreateBtn">Publish</span></button>
|
<button class="btn-primary btn-sm" onclick="app.adminCreateNews()"><span data-i18n="admin.newsCreateBtn">Publish</span></button>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ const LOCALES = {
|
|||||||
'nav.packs': 'Packs', 'nav.news': 'News', 'nav.settings': 'Settings',
|
'nav.packs': 'Packs', 'nav.news': 'News', 'nav.settings': 'Settings',
|
||||||
'login.title': 'Sign In', 'login.username': 'Username', 'login.password': 'Password',
|
'login.title': 'Sign In', 'login.username': 'Username', 'login.password': 'Password',
|
||||||
'login.hint': 'New account will be created automatically on first login',
|
'login.hint': 'New account will be created automatically on first login',
|
||||||
|
'login.register': 'No account? Register',
|
||||||
'login.signingIn': 'Signing in...',
|
'login.signingIn': 'Signing in...',
|
||||||
'loading.text': 'Loading...',
|
'loading.text': 'Loading...',
|
||||||
'sidebar.serverPacks': 'Server Packs', 'sidebar.localPacks': 'Local Packs',
|
'sidebar.serverPacks': 'Server Packs', 'sidebar.localPacks': 'Local Packs',
|
||||||
@@ -171,6 +172,7 @@ const LOCALES = {
|
|||||||
'nav.packs': 'Сборки', 'nav.news': 'Новости', 'nav.settings': 'Настройки',
|
'nav.packs': 'Сборки', 'nav.news': 'Новости', 'nav.settings': 'Настройки',
|
||||||
'login.title': 'Вход', 'login.username': 'Логин', 'login.password': 'Пароль',
|
'login.title': 'Вход', 'login.username': 'Логин', 'login.password': 'Пароль',
|
||||||
'login.hint': 'Новый аккаунт создаётся автоматически при первом входе',
|
'login.hint': 'Новый аккаунт создаётся автоматически при первом входе',
|
||||||
|
'login.register': 'Нет аккаунта? Зарегистрироваться',
|
||||||
'login.signingIn': 'Вход...',
|
'login.signingIn': 'Вход...',
|
||||||
'loading.text': 'Загрузка...',
|
'loading.text': 'Загрузка...',
|
||||||
'sidebar.serverPacks': 'Серверные сборки', 'sidebar.localPacks': 'Локальные сборки',
|
'sidebar.serverPacks': 'Серверные сборки', 'sidebar.localPacks': 'Локальные сборки',
|
||||||
@@ -374,6 +376,11 @@ class ZernMCLauncher {
|
|||||||
this._playtimeAccumulated = 0;
|
this._playtimeAccumulated = 0;
|
||||||
this._playtimeStartTime = null;
|
this._playtimeStartTime = null;
|
||||||
this._currentPlaytimePack = null;
|
this._currentPlaytimePack = null;
|
||||||
|
this._ws = null;
|
||||||
|
this._wsReconnectTimer = null;
|
||||||
|
this._wsFallbackPolling = false;
|
||||||
|
this._wsStatusTimer = null;
|
||||||
|
this._adminClientsTimer = null;
|
||||||
this.init();
|
this.init();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -383,6 +390,142 @@ class ZernMCLauncher {
|
|||||||
await this.checkAuth();
|
await this.checkAuth();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== WEBSOCKET ====================
|
||||||
|
initWebSocket() {
|
||||||
|
if (typeof WebSocket === 'undefined') {
|
||||||
|
console.warn('[WS] WebSocket not supported, using polling');
|
||||||
|
this._startWsFallback();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this._ws && (this._ws.readyState === WebSocket.OPEN || this._ws.readyState === WebSocket.CONNECTING)) return;
|
||||||
|
var self = this;
|
||||||
|
var doConnect = function(token) {
|
||||||
|
if (!token) { self._startWsFallback(); return; }
|
||||||
|
var serverUrl = 'http://87.120.187.36:1582';
|
||||||
|
var el = document.getElementById('server-url');
|
||||||
|
if (el && el.textContent && el.textContent !== '...') serverUrl = el.textContent;
|
||||||
|
var wsUrl = serverUrl.replace(/^http/, 'ws') + '/ws?token=' + encodeURIComponent(token);
|
||||||
|
self._connectWs(wsUrl);
|
||||||
|
};
|
||||||
|
if (this.state.account && this.state.account.token) {
|
||||||
|
doConnect(this.state.account.token);
|
||||||
|
} else {
|
||||||
|
// Token may not be in account (auto-login path), fetch it
|
||||||
|
this.req('/token').then(function(r) {
|
||||||
|
if (r.success && r.token) doConnect(r.token);
|
||||||
|
else self._startWsFallback();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_connectWs(url) {
|
||||||
|
if (this._ws) {
|
||||||
|
try { this._ws.close(); } catch(e) {}
|
||||||
|
this._ws = null;
|
||||||
|
}
|
||||||
|
this._lastWsUrl = url;
|
||||||
|
try {
|
||||||
|
this._ws = new WebSocket(url);
|
||||||
|
} catch(e) {
|
||||||
|
console.warn('[WS] Failed to create WebSocket:', e);
|
||||||
|
this._startWsFallback();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this._ws.onmessage = this._onWsMessage.bind(this);
|
||||||
|
this._ws.onclose = this._onWsClose.bind(this);
|
||||||
|
this._ws.onerror = function(e) {
|
||||||
|
console.warn('[WS] Error');
|
||||||
|
};
|
||||||
|
this._ws.onopen = function() {
|
||||||
|
console.log('[WS] Connected');
|
||||||
|
app._wsFallbackPolling = false;
|
||||||
|
if (app._wsReconnectTimer) {
|
||||||
|
clearTimeout(app._wsReconnectTimer);
|
||||||
|
app._wsReconnectTimer = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
_onWsMessage(e) {
|
||||||
|
try {
|
||||||
|
var msg = JSON.parse(e.data);
|
||||||
|
switch (msg.type) {
|
||||||
|
case 'friends_update':
|
||||||
|
this.state.friends = msg.friends || [];
|
||||||
|
this.renderFriends();
|
||||||
|
break;
|
||||||
|
case 'friend_status':
|
||||||
|
this.loadFriends();
|
||||||
|
break;
|
||||||
|
case 'requests_update':
|
||||||
|
this.state.friendRequests = msg.requests || [];
|
||||||
|
this.renderFriendRequests();
|
||||||
|
break;
|
||||||
|
case 'clients_update':
|
||||||
|
this._renderAdminClients(msg.clients || []);
|
||||||
|
break;
|
||||||
|
case 'pong':
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[WS] Bad message:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_onWsClose() {
|
||||||
|
console.log('[WS] Disconnected');
|
||||||
|
// Don't reconnect if intentionally closed (logout)
|
||||||
|
if (!this._ws && this._lastWsUrl) {
|
||||||
|
this._wsReconnectTimer = setTimeout(function() {
|
||||||
|
if (app.state.account) app._connectWs(app._lastWsUrl);
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
this._ws = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
_startWsFallback() {
|
||||||
|
if (this._wsFallbackPolling) return;
|
||||||
|
this._wsFallbackPolling = true;
|
||||||
|
console.warn('[WS] Falling back to polling');
|
||||||
|
this.loadFriends();
|
||||||
|
if (this._friendsPollInterval) clearInterval(this._friendsPollInterval);
|
||||||
|
this._friendsPollInterval = setInterval(this.loadFriends.bind(this), 10000);
|
||||||
|
}
|
||||||
|
|
||||||
|
_stopWsFallback() {
|
||||||
|
this._wsFallbackPolling = false;
|
||||||
|
if (this._friendsPollInterval) {
|
||||||
|
clearInterval(this._friendsPollInterval);
|
||||||
|
this._friendsPollInterval = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
closeWebSocket() {
|
||||||
|
if (this._ws) {
|
||||||
|
try { this._ws.close(); } catch(e) {}
|
||||||
|
this._ws = null;
|
||||||
|
}
|
||||||
|
if (this._wsReconnectTimer) {
|
||||||
|
clearTimeout(this._wsReconnectTimer);
|
||||||
|
this._wsReconnectTimer = null;
|
||||||
|
}
|
||||||
|
if (this._wsStatusTimer) {
|
||||||
|
clearInterval(this._wsStatusTimer);
|
||||||
|
this._wsStatusTimer = null;
|
||||||
|
}
|
||||||
|
if (this._adminClientsTimer) {
|
||||||
|
clearTimeout(this._adminClientsTimer);
|
||||||
|
this._adminClientsTimer = null;
|
||||||
|
}
|
||||||
|
this._stopWsFallback();
|
||||||
|
}
|
||||||
|
|
||||||
|
_sendWs(msg) {
|
||||||
|
if (this._ws && this._ws.readyState === WebSocket.OPEN) {
|
||||||
|
try { this._ws.send(JSON.stringify(msg)); } catch(e) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
setLocale(lang) {
|
setLocale(lang) {
|
||||||
_currentLocale = lang;
|
_currentLocale = lang;
|
||||||
_localeData = LOCALES[lang] || LOCALES.en;
|
_localeData = LOCALES[lang] || LOCALES.en;
|
||||||
@@ -396,6 +539,9 @@ class ZernMCLauncher {
|
|||||||
const c = document.getElementById('bg-canvas');
|
const c = document.getElementById('bg-canvas');
|
||||||
const ctx = c.getContext('2d');
|
const ctx = c.getContext('2d');
|
||||||
let mx = 0, my = 0, ox = 0, oy = 0;
|
let mx = 0, my = 0, ox = 0, oy = 0;
|
||||||
|
let lastMove = Date.now();
|
||||||
|
let active = true;
|
||||||
|
let lastFrame = 0;
|
||||||
|
|
||||||
const resize = () => { c.width = window.innerWidth; c.height = window.innerHeight; draw(); };
|
const resize = () => { c.width = window.innerWidth; c.height = window.innerHeight; draw(); };
|
||||||
const draw = () => {
|
const draw = () => {
|
||||||
@@ -410,15 +556,23 @@ class ZernMCLauncher {
|
|||||||
window.addEventListener('mousemove', e => {
|
window.addEventListener('mousemove', e => {
|
||||||
mx = (e.clientX / innerWidth - 0.5) * 2;
|
mx = (e.clientX / innerWidth - 0.5) * 2;
|
||||||
my = (e.clientY / innerHeight - 0.5) * 2;
|
my = (e.clientY / innerHeight - 0.5) * 2;
|
||||||
|
lastMove = Date.now();
|
||||||
|
if (!active) { active = true; requestAnimationFrame(anim); }
|
||||||
});
|
});
|
||||||
const anim = () => {
|
const anim = (now) => {
|
||||||
|
if (now - lastFrame < 16) { requestAnimationFrame(anim); return; }
|
||||||
|
lastFrame = now;
|
||||||
|
if (Date.now() - lastMove > 2000) {
|
||||||
|
active = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
ox += (mx * 0.3 - ox) * 0.04;
|
ox += (mx * 0.3 - ox) * 0.04;
|
||||||
oy += (my * 0.3 - oy) * 0.04;
|
oy += (my * 0.3 - oy) * 0.04;
|
||||||
draw();
|
draw();
|
||||||
requestAnimationFrame(anim);
|
requestAnimationFrame(anim);
|
||||||
};
|
};
|
||||||
resize();
|
resize();
|
||||||
anim();
|
requestAnimationFrame(anim);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== API ====================
|
// ==================== API ====================
|
||||||
@@ -489,7 +643,6 @@ class ZernMCLauncher {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const username = document.getElementById('username').value.trim();
|
const username = document.getElementById('username').value.trim();
|
||||||
const password = document.getElementById('password').value;
|
const password = document.getElementById('password').value;
|
||||||
console.log('handleLogin: attempting login for', username);
|
|
||||||
const errEl = document.getElementById('login-error');
|
const errEl = document.getElementById('login-error');
|
||||||
const btn = document.getElementById('login-btn');
|
const btn = document.getElementById('login-btn');
|
||||||
const label = btn.querySelector('.btn-label');
|
const label = btn.querySelector('.btn-label');
|
||||||
@@ -497,6 +650,26 @@ class ZernMCLauncher {
|
|||||||
|
|
||||||
if (!username || !password) { this.showLoginError(t('toast.enterCredentials')); return; }
|
if (!username || !password) { this.showLoginError(t('toast.enterCredentials')); return; }
|
||||||
|
|
||||||
|
if (this._registerMode) {
|
||||||
|
var confirmPw = document.getElementById('confirm-password').value;
|
||||||
|
if (password !== confirmPw) { this.showLoginError('Passwords do not match'); return; }
|
||||||
|
btn.disabled = true;
|
||||||
|
label.textContent = 'Registering...';
|
||||||
|
spinner.classList.remove('hidden');
|
||||||
|
var reg = await this.req('/register', { method: 'POST', body: JSON.stringify({ username, password }) });
|
||||||
|
btn.disabled = false;
|
||||||
|
spinner.classList.add('hidden');
|
||||||
|
if (reg.success) {
|
||||||
|
this.state.account = reg.data;
|
||||||
|
this.enterMain();
|
||||||
|
this.toast(tr('toast.accountCreated', null, {username: reg.data.username}), 'success');
|
||||||
|
} else {
|
||||||
|
label.textContent = 'Register';
|
||||||
|
this.showLoginError(reg.error || t('toast.loginFailed'));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
label.textContent = t('login.signingIn');
|
label.textContent = t('login.signingIn');
|
||||||
spinner.classList.remove('hidden');
|
spinner.classList.remove('hidden');
|
||||||
@@ -534,9 +707,30 @@ class ZernMCLauncher {
|
|||||||
showLogin() {
|
showLogin() {
|
||||||
document.getElementById('login-screen').classList.remove('hidden');
|
document.getElementById('login-screen').classList.remove('hidden');
|
||||||
document.getElementById('main-screen').classList.add('hidden');
|
document.getElementById('main-screen').classList.add('hidden');
|
||||||
|
this._registerMode = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleLoginMode() {
|
||||||
|
this._registerMode = !this._registerMode;
|
||||||
|
var btn = document.getElementById('login-btn');
|
||||||
|
var label = btn.querySelector('.btn-label');
|
||||||
|
var link = document.getElementById('login-toggle-link');
|
||||||
|
var confirmField = document.getElementById('confirm-password-field');
|
||||||
|
if (this._registerMode) {
|
||||||
|
label.textContent = 'Register';
|
||||||
|
link.textContent = 'Already have an account? Sign In';
|
||||||
|
confirmField.style.display = '';
|
||||||
|
document.querySelector('.login-brand .brand-sub').textContent = 'Create a new account';
|
||||||
|
} else {
|
||||||
|
label.textContent = t('login.title');
|
||||||
|
link.textContent = t('login.register');
|
||||||
|
confirmField.style.display = 'none';
|
||||||
|
document.querySelector('.login-brand .brand-sub').textContent = 'Launcher ' + document.getElementById('version').textContent;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async logout() {
|
async logout() {
|
||||||
|
this.closeWebSocket();
|
||||||
this.state.selectedPack = null;
|
this.state.selectedPack = null;
|
||||||
this.state.instances = [];
|
this.state.instances = [];
|
||||||
this.state.account = null;
|
this.state.account = null;
|
||||||
@@ -561,6 +755,7 @@ class ZernMCLauncher {
|
|||||||
this.loadNews();
|
this.loadNews();
|
||||||
this.loadFriends();
|
this.loadFriends();
|
||||||
this.loadPlaytimeStats();
|
this.loadPlaytimeStats();
|
||||||
|
this.initWebSocket();
|
||||||
this.startFriendStatusHeartbeat();
|
this.startFriendStatusHeartbeat();
|
||||||
this.enhanceSelects();
|
this.enhanceSelects();
|
||||||
this.updateAdminNav();
|
this.updateAdminNav();
|
||||||
@@ -953,6 +1148,20 @@ class ZernMCLauncher {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async adminLoadDashboard() {
|
||||||
|
var r = await this.req('/admin/stats');
|
||||||
|
if (r.success) {
|
||||||
|
var el = document.getElementById('ad-total-users');
|
||||||
|
if (el) el.textContent = r.total_users || '-';
|
||||||
|
el = document.getElementById('ad-active-passes');
|
||||||
|
if (el) el.textContent = r.active_passes || '-';
|
||||||
|
el = document.getElementById('ad-online-now');
|
||||||
|
if (el) el.textContent = r.online_now !== undefined ? r.online_now : '-';
|
||||||
|
el = document.getElementById('ad-new-users');
|
||||||
|
if (el) el.textContent = r.recent_registrations_7d || '-';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
switchAdminTab(tab) {
|
switchAdminTab(tab) {
|
||||||
document.querySelectorAll('.admin-tab').forEach(function(t) {
|
document.querySelectorAll('.admin-tab').forEach(function(t) {
|
||||||
t.classList.toggle('active', t.dataset.atab === tab);
|
t.classList.toggle('active', t.dataset.atab === tab);
|
||||||
@@ -960,6 +1169,7 @@ class ZernMCLauncher {
|
|||||||
document.querySelectorAll('.admin-tab-content').forEach(function(c) {
|
document.querySelectorAll('.admin-tab-content').forEach(function(c) {
|
||||||
c.classList.toggle('active', c.id === 'atab-' + tab);
|
c.classList.toggle('active', c.id === 'atab-' + tab);
|
||||||
});
|
});
|
||||||
|
this.adminLoadDashboard();
|
||||||
if (tab === 'clients') this.adminLoadClients();
|
if (tab === 'clients') this.adminLoadClients();
|
||||||
if (tab === 'mods') this.adminLoadMods();
|
if (tab === 'mods') this.adminLoadMods();
|
||||||
if (tab === 'users') this.adminClearUser();
|
if (tab === 'users') this.adminClearUser();
|
||||||
@@ -968,17 +1178,10 @@ class ZernMCLauncher {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// --- Clients ---
|
// --- Clients ---
|
||||||
async adminLoadClients() {
|
_renderAdminClients(clients) {
|
||||||
var el = document.getElementById('admin-clients-list');
|
var el = document.getElementById('admin-clients-list');
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
el.innerHTML = '<div class="admin-loading">' + t('admin.loading') + '</div>';
|
if (!clients || !clients.length) {
|
||||||
var r = await this.req('/admin/clients');
|
|
||||||
if (!r.success) {
|
|
||||||
el.innerHTML = '<div class="admin-empty">' + t('admin.error') + ': ' + (r.error || '') + '</div>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
var clients = r.clients || r.data || [];
|
|
||||||
if (!clients.length) {
|
|
||||||
el.innerHTML = '<div class="admin-empty">' + t('admin.noClients') + '</div>';
|
el.innerHTML = '<div class="admin-empty">' + t('admin.noClients') + '</div>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -990,7 +1193,26 @@ class ZernMCLauncher {
|
|||||||
+ '</div>';
|
+ '</div>';
|
||||||
});
|
});
|
||||||
el.innerHTML = html;
|
el.innerHTML = html;
|
||||||
setTimeout(function() { app.adminLoadClients(); }, 5000);
|
}
|
||||||
|
|
||||||
|
async adminLoadClients() {
|
||||||
|
var el = document.getElementById('admin-clients-list');
|
||||||
|
if (!el) return;
|
||||||
|
// If WS is connected, wait for push updates instead of polling
|
||||||
|
if (this._ws && this._ws.readyState === WebSocket.OPEN) {
|
||||||
|
el.innerHTML = '<div class="admin-loading">' + t('admin.loading') + '</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
el.innerHTML = '<div class="admin-loading">' + t('admin.loading') + '</div>';
|
||||||
|
var r = await this.req('/admin/clients');
|
||||||
|
if (!r.success) {
|
||||||
|
el.innerHTML = '<div class="admin-empty">' + t('admin.error') + ': ' + (r.error || '') + '</div>';
|
||||||
|
this._adminClientsTimer = setTimeout(function() { app.adminLoadClients(); }, 5000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var clients = r.clients || r.data || [];
|
||||||
|
this._renderAdminClients(clients);
|
||||||
|
this._adminClientsTimer = setTimeout(function() { app.adminLoadClients(); }, 5000);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Mods ---
|
// --- Mods ---
|
||||||
@@ -1153,14 +1375,93 @@ class ZernMCLauncher {
|
|||||||
}
|
}
|
||||||
var html = '';
|
var html = '';
|
||||||
passes.forEach(function(p) {
|
passes.forEach(function(p) {
|
||||||
html += '<div class="admin-item">'
|
var isActive = p.is_active !== false;
|
||||||
+ '<span class="admin-item-name">' + app.esc(p.code || p.name || '?') + '</span>'
|
html += '<div class="admin-item" style="opacity:' + (isActive ? '1' : '0.4') + '">'
|
||||||
+ '<span class="admin-item-meta">' + (p.username ? app.esc(p.username) : '') + '</span>'
|
+ '<span class="admin-item-name" style="font-family:var(--mono);font-size:12px">' + app.esc(p.code || '?') + '</span>'
|
||||||
+ '</div>';
|
+ '<span class="admin-item-meta">' + (p.owner ? app.esc(p.owner) : '') + (p.uses !== undefined ? ' uses:' + p.uses + '/' + (p.max_uses || '-') : '') + '</span>'
|
||||||
|
+ '<div class="admin-item-actions">'
|
||||||
|
+ '<button class="btn-secondary btn-sm" onclick="app.adminTogglePassEdit(\'' + app.esc(p.code) + '\', ' + (isActive ? 'true' : 'false') + ', ' + (p.max_uses || 1) + ')">Edit</button>'
|
||||||
|
+ '<button class="btn-secondary btn-sm btn-danger" onclick="app.adminDeletePass(\'' + app.esc(p.code) + '\')">' + t('admin.delete') + '</button>'
|
||||||
|
+ '</div></div>';
|
||||||
});
|
});
|
||||||
el.innerHTML = html;
|
el.innerHTML = html;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async adminCreatePass() {
|
||||||
|
var days = parseInt(document.getElementById('pass-create-days').value) || 30;
|
||||||
|
var maxUses = parseInt(document.getElementById('pass-create-uses').value) || 1;
|
||||||
|
var resultEl = document.getElementById('admin-pass-create-result');
|
||||||
|
var r = await this.req('/admin/passes/create', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ expires_days: days, max_uses: maxUses })
|
||||||
|
});
|
||||||
|
resultEl.classList.remove('hidden');
|
||||||
|
if (r.success) {
|
||||||
|
resultEl.textContent = 'Pass created: ' + r.code;
|
||||||
|
resultEl.className = 'admin-status success';
|
||||||
|
this.adminLoadPasses();
|
||||||
|
} else {
|
||||||
|
resultEl.textContent = r.error || t('admin.error');
|
||||||
|
resultEl.className = 'admin-status error';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
adminTogglePassEdit(code, isActive, maxUses) {
|
||||||
|
var existing = document.getElementById('pass-edit-' + code.replace(/[^A-Z0-9]/g, ''));
|
||||||
|
if (existing) { existing.remove(); return; }
|
||||||
|
var items = document.querySelectorAll('#admin-passes-list .admin-item');
|
||||||
|
var target = null;
|
||||||
|
for (var i = 0; i < items.length; i++) {
|
||||||
|
if (items[i].querySelector('.admin-item-name') && items[i].querySelector('.admin-item-name').textContent.trim() === code) {
|
||||||
|
target = items[i]; break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!target) return;
|
||||||
|
var form = document.createElement('div');
|
||||||
|
form.id = 'pass-edit-' + code.replace(/[^A-Z0-9]/g, '');
|
||||||
|
form.className = 'pass-edit-form';
|
||||||
|
form.innerHTML = '<span class="pass-code-display">' + app.esc(code) + '</span>'
|
||||||
|
+ '<span style="font-size:11px;color:var(--text-muted)">Active:</span>'
|
||||||
|
+ '<select id="pe-active-' + code.replace(/[^A-Z0-9]/g, '') + '">'
|
||||||
|
+ '<option value="1"' + (isActive ? ' selected' : '') + '>Yes</option>'
|
||||||
|
+ '<option value="0"' + (!isActive ? ' selected' : '') + '>No</option>'
|
||||||
|
+ '</select>'
|
||||||
|
+ '<span style="font-size:11px;color:var(--text-muted)">Max uses:</span>'
|
||||||
|
+ '<input type="number" id="pe-uses-' + code.replace(/[^A-Z0-9]/g, '') + '" value="' + maxUses + '" min="1" max="10" style="width:60px">'
|
||||||
|
+ '<button class="btn-primary btn-sm" onclick="app.adminUpdatePass(\'' + app.esc(code) + '\')">Save</button>';
|
||||||
|
target.parentNode.insertBefore(form, target.nextSibling);
|
||||||
|
}
|
||||||
|
|
||||||
|
async adminUpdatePass(code) {
|
||||||
|
var safeKey = code.replace(/[^A-Z0-9]/g, '');
|
||||||
|
var isActive = document.getElementById('pe-active-' + safeKey).value === '1';
|
||||||
|
var maxUses = parseInt(document.getElementById('pe-uses-' + safeKey).value) || 1;
|
||||||
|
var r = await this.req('/admin/passes/update', {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({ code: code, max_uses: maxUses, is_active: isActive, expires_days: null })
|
||||||
|
});
|
||||||
|
if (r.success) {
|
||||||
|
this.toast('Pass updated', 'success');
|
||||||
|
this.adminLoadPasses();
|
||||||
|
} else {
|
||||||
|
this.toast(r.error || t('admin.error'), 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async adminDeletePass(code) {
|
||||||
|
if (!confirm('Delete pass ' + code + '?')) return;
|
||||||
|
var r = await this.req('/admin/passes/delete', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ code: code })
|
||||||
|
});
|
||||||
|
if (r.success) {
|
||||||
|
this.toast('Pass deleted', 'success');
|
||||||
|
this.adminLoadPasses();
|
||||||
|
} else {
|
||||||
|
this.toast(r.error || t('admin.error'), 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// --- News ---
|
// --- News ---
|
||||||
async adminLoadNewsList() {
|
async adminLoadNewsList() {
|
||||||
var el = document.getElementById('admin-news-list');
|
var el = document.getElementById('admin-news-list');
|
||||||
@@ -1209,6 +1510,7 @@ class ZernMCLauncher {
|
|||||||
document.getElementById('admin-news-title').value = '';
|
document.getElementById('admin-news-title').value = '';
|
||||||
document.getElementById('admin-news-version').value = '';
|
document.getElementById('admin-news-version').value = '';
|
||||||
document.getElementById('admin-news-body').value = '';
|
document.getElementById('admin-news-body').value = '';
|
||||||
|
document.getElementById('admin-news-preview').innerHTML = 'Preview will appear here...';
|
||||||
this.adminLoadNewsList();
|
this.adminLoadNewsList();
|
||||||
} else {
|
} else {
|
||||||
status.textContent = r.error || t('admin.error');
|
status.textContent = r.error || t('admin.error');
|
||||||
@@ -1216,6 +1518,17 @@ class ZernMCLauncher {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
updateNewsPreview() {
|
||||||
|
var body = document.getElementById('admin-news-body').value;
|
||||||
|
var preview = document.getElementById('admin-news-preview');
|
||||||
|
if (!preview) return;
|
||||||
|
if (!body.trim()) {
|
||||||
|
preview.innerHTML = 'Preview will appear here...';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
preview.innerHTML = this.mdToHtml(body);
|
||||||
|
}
|
||||||
|
|
||||||
async adminDeleteNews(idx) {
|
async adminDeleteNews(idx) {
|
||||||
var r = await this.req('/admin/news/delete', {
|
var r = await this.req('/admin/news/delete', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -1584,7 +1897,6 @@ class ZernMCLauncher {
|
|||||||
async installZernMCPack() {
|
async installZernMCPack() {
|
||||||
const packName = document.getElementById('zernmc-pack-select').value;
|
const packName = document.getElementById('zernmc-pack-select').value;
|
||||||
if (!packName) { this.toast(t('select.selectPack'), 'error'); return; }
|
if (!packName) { this.toast(t('select.selectPack'), 'error'); return; }
|
||||||
// Use server pack name as instance name (no local naming)
|
|
||||||
const instanceName = packName;
|
const instanceName = packName;
|
||||||
|
|
||||||
const r = await this.req('/install', {
|
const r = await this.req('/install', {
|
||||||
@@ -1595,7 +1907,9 @@ class ZernMCLauncher {
|
|||||||
this.toast(t('toast.installing'), 'info');
|
this.toast(t('toast.installing'), 'info');
|
||||||
this.startProgressPoll();
|
this.startProgressPoll();
|
||||||
} else {
|
} else {
|
||||||
this.toast(r.error || t('toast.launchFailed'), 'error');
|
var errMsg = r.error || t('toast.installFailed');
|
||||||
|
console.error('[INSTALL] Start error:', errMsg);
|
||||||
|
this.toast(errMsg, 'error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1626,7 +1940,8 @@ class ZernMCLauncher {
|
|||||||
if (r.success && r.data) {
|
if (r.success && r.data) {
|
||||||
const p = document.getElementById('install-progress');
|
const p = document.getElementById('install-progress');
|
||||||
p.classList.remove('hidden');
|
p.classList.remove('hidden');
|
||||||
document.getElementById('progress-fill').style.width = (r.data.percent || 0) + '%';
|
var pct = r.data.percent || 0;
|
||||||
|
document.getElementById('progress-fill').style.width = pct + '%';
|
||||||
document.getElementById('progress-label').textContent = r.data.label || 'Installing...';
|
document.getElementById('progress-label').textContent = r.data.label || 'Installing...';
|
||||||
const stageInfo = document.getElementById('progress-stage');
|
const stageInfo = document.getElementById('progress-stage');
|
||||||
if (r.data.stageName && r.data.stageCount > 1) {
|
if (r.data.stageName && r.data.stageCount > 1) {
|
||||||
@@ -1638,10 +1953,15 @@ class ZernMCLauncher {
|
|||||||
if (!r.data.inProgress) {
|
if (!r.data.inProgress) {
|
||||||
this.stopProgressPoll();
|
this.stopProgressPoll();
|
||||||
this.hideInstallModal();
|
this.hideInstallModal();
|
||||||
var failed = r.data.stageName === 'Failed' || r.data.stageName === 'Error' || (r.data.label && r.data.label.startsWith('Error'));
|
var failed = r.data.stageName === 'Failed' || r.data.stageName === 'Error' || (r.data.label && (r.data.label.startsWith('Error') || r.data.label.startsWith('Installation failed')));
|
||||||
this.toast(failed ? (r.data.label || t('toast.installFailed')) : t('toast.installComplete'), failed ? 'error' : 'success');
|
var msg = failed ? (r.data.label || t('toast.installFailed')) : t('toast.installComplete');
|
||||||
|
this.toast(msg, failed ? 'error' : 'success');
|
||||||
|
if (failed) console.error('[INSTALL] Failed:', r.data.stageName, r.data.label);
|
||||||
if (!failed) await this.loadInstances();
|
if (!failed) await this.loadInstances();
|
||||||
}
|
}
|
||||||
|
} else if (!r.success) {
|
||||||
|
this.stopProgressPoll();
|
||||||
|
this.toast(r.error || t('toast.installFailed'), 'error');
|
||||||
}
|
}
|
||||||
}, 500);
|
}, 500);
|
||||||
}
|
}
|
||||||
@@ -1701,10 +2021,23 @@ class ZernMCLauncher {
|
|||||||
if (!el) return;
|
if (!el) return;
|
||||||
var cores = data.cpuCores || '-';
|
var cores = data.cpuCores || '-';
|
||||||
var ramGB = data.totalRamMB ? (data.totalRamMB / 1024).toFixed(1) : '-';
|
var ramGB = data.totalRamMB ? (data.totalRamMB / 1024).toFixed(1) : '-';
|
||||||
|
var os = this._detectOS();
|
||||||
var flags = data.systemBasedJvm ? t('settings.systemJvm.enabled') : t('settings.systemJvm.disabled');
|
var flags = data.systemBasedJvm ? t('settings.systemJvm.enabled') : t('settings.systemJvm.disabled');
|
||||||
|
if (data.systemBasedJvm) {
|
||||||
|
flags += ' | ' + os;
|
||||||
|
}
|
||||||
el.textContent = tr('settings.systemJvm.info', null, {cores: cores, ram: ramGB, flags: flags});
|
el.textContent = tr('settings.systemJvm.info', null, {cores: cores, ram: ramGB, flags: flags});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_detectOS() {
|
||||||
|
var p = navigator.platform || '';
|
||||||
|
if (p.indexOf('Win') !== -1) return 'Windows';
|
||||||
|
if (p.indexOf('Mac') !== -1) return 'macOS';
|
||||||
|
if (p.indexOf('Linux') !== -1) return 'Linux';
|
||||||
|
if (p.indexOf('FreeBSD') !== -1) return 'FreeBSD';
|
||||||
|
return p || 'Unknown';
|
||||||
|
}
|
||||||
|
|
||||||
async saveSettings() {
|
async saveSettings() {
|
||||||
try {
|
try {
|
||||||
const ram = document.getElementById('ram-slider').value;
|
const ram = document.getElementById('ram-slider').value;
|
||||||
@@ -1747,13 +2080,18 @@ class ZernMCLauncher {
|
|||||||
}
|
}
|
||||||
|
|
||||||
startFriendStatusHeartbeat() {
|
startFriendStatusHeartbeat() {
|
||||||
if (this._friendsPollInterval) clearInterval(this._friendsPollInterval);
|
// Send online status via WebSocket if connected, fallback to REST
|
||||||
this._friendsPollInterval = setInterval(this.loadFriends.bind(this), 10000);
|
var sendStatus = function() {
|
||||||
// Send online status
|
var pack = app.state.selectedPack ? app.state.selectedPack.name : null;
|
||||||
this.req('/friends/status', { method: 'POST', body: JSON.stringify({ online: true, current_pack: this.state.selectedPack ? this.state.selectedPack.name : null }) });
|
if (app._ws && app._ws.readyState === WebSocket.OPEN) {
|
||||||
setInterval(function() {
|
app._sendWs({ type: 'status', current_pack: pack || '' });
|
||||||
app.req('/friends/status', { method: 'POST', body: JSON.stringify({ online: true, current_pack: app.state.selectedPack ? app.state.selectedPack.name : null }) });
|
} else {
|
||||||
}, 60000);
|
app.req('/friends/status', { method: 'POST', body: JSON.stringify({ online: true, current_pack: pack }) });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
sendStatus();
|
||||||
|
if (this._wsStatusTimer) clearInterval(this._wsStatusTimer);
|
||||||
|
this._wsStatusTimer = setInterval(sendStatus, 60000);
|
||||||
}
|
}
|
||||||
|
|
||||||
renderFriends() {
|
renderFriends() {
|
||||||
|
|||||||
@@ -176,11 +176,18 @@ body {
|
|||||||
padding: 16px 12px;
|
padding: 16px 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-top { flex: 1; display: flex; flex-direction: column; gap: 16px; overflow-y: auto; }
|
.sidebar-header {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-scroll {
|
||||||
|
flex: 1; display: flex; flex-direction: column; overflow-y: auto;
|
||||||
|
margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
.sidebar-brand {
|
.sidebar-brand {
|
||||||
display: flex; align-items: center; gap: 10px;
|
display: flex; align-items: center; gap: 10px;
|
||||||
padding: 4px 8px 16px; border-bottom: 1px solid var(--border);
|
padding: 0 8px 12px;
|
||||||
}
|
}
|
||||||
.sidebar-brand-text { display: flex; flex-direction: column; }
|
.sidebar-brand-text { display: flex; flex-direction: column; }
|
||||||
.sidebar-brand-name { font-size: 16px; font-weight: 700; }
|
.sidebar-brand-name { font-size: 16px; font-weight: 700; }
|
||||||
@@ -188,7 +195,6 @@ body {
|
|||||||
|
|
||||||
.sidebar-nav {
|
.sidebar-nav {
|
||||||
display: flex; flex-direction: column; gap: 4px;
|
display: flex; flex-direction: column; gap: 4px;
|
||||||
padding-bottom: 16px; border-bottom: 1px solid var(--border);
|
|
||||||
}
|
}
|
||||||
.sidebar-nav-primary, .sidebar-nav-secondary {
|
.sidebar-nav-primary, .sidebar-nav-secondary {
|
||||||
display: flex; gap: 4px;
|
display: flex; gap: 4px;
|
||||||
@@ -214,8 +220,9 @@ body {
|
|||||||
|
|
||||||
.pack-list {
|
.pack-list {
|
||||||
display: flex; flex-direction: column; gap: 3px;
|
display: flex; flex-direction: column; gap: 3px;
|
||||||
overflow-y: auto; max-height: calc((100vh - 460px) / 2);
|
overflow-y: auto;
|
||||||
min-height: 40px;
|
max-height: min(calc(50vh - 80px), 280px);
|
||||||
|
min-height: 60px;
|
||||||
}
|
}
|
||||||
.pack-list:empty::after {
|
.pack-list:empty::after {
|
||||||
content: 'No packs'; display: block; padding: 12px 8px;
|
content: 'No packs'; display: block; padding: 12px 8px;
|
||||||
@@ -291,8 +298,17 @@ body {
|
|||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.view { display: none; flex-direction: column; height: 100%; overflow-y: auto; }
|
.view {
|
||||||
.view.active { display: flex; }
|
display: none; flex-direction: column; height: 100%; overflow-y: auto;
|
||||||
|
opacity: 0; transform: translateY(8px) scale(0.98);
|
||||||
|
transition: opacity 0.25s ease, transform 0.25s ease;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.view.active {
|
||||||
|
display: flex;
|
||||||
|
opacity: 1; transform: translateY(0) scale(1);
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.view-header {
|
.view-header {
|
||||||
display: flex; align-items: flex-start; justify-content: space-between;
|
display: flex; align-items: flex-start; justify-content: space-between;
|
||||||
@@ -881,3 +897,133 @@ body {
|
|||||||
.admin-news-info { flex: 1; min-width: 0; }
|
.admin-news-info { flex: 1; min-width: 0; }
|
||||||
.admin-news-title { font-size: 13px; font-weight: 500; }
|
.admin-news-title { font-size: 13px; font-weight: 500; }
|
||||||
.admin-news-meta { font-size: 11px; color: var(--text-muted); margin-top: 2px; }
|
.admin-news-meta { font-size: 11px; color: var(--text-muted); margin-top: 2px; }
|
||||||
|
|
||||||
|
/* Admin dashboard stat cards */
|
||||||
|
.admin-dashboard {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||||
|
gap: 12px; margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.admin-stat-card {
|
||||||
|
background: var(--bg-card); border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-md); padding: 16px; text-align: center;
|
||||||
|
}
|
||||||
|
.admin-stat-value {
|
||||||
|
font-size: 28px; font-weight: 800; color: var(--accent); line-height: 1.2;
|
||||||
|
}
|
||||||
|
.admin-stat-label {
|
||||||
|
font-size: 11px; color: var(--text-muted); text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px; margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pass CRUD UI */
|
||||||
|
.pass-create-row {
|
||||||
|
display: flex; align-items: center; gap: 8px; margin-bottom: 12px; flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.pass-create-row input[type="number"] {
|
||||||
|
width: 80px; padding: 6px 10px; background: var(--bg-surface);
|
||||||
|
border: 1px solid var(--border-light); border-radius: var(--radius-sm);
|
||||||
|
color: var(--text); font-size: 12px; outline: none;
|
||||||
|
}
|
||||||
|
.pass-create-row input[type="number"]:focus { border-color: var(--accent); }
|
||||||
|
.pass-edit-form {
|
||||||
|
display: flex; align-items: center; gap: 8px; padding: 8px 10px;
|
||||||
|
background: var(--bg-card); border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm); margin-bottom: 6px; flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.pass-edit-form .pass-code-display {
|
||||||
|
font-family: var(--mono); font-size: 12px; color: var(--accent);
|
||||||
|
flex: 1; min-width: 100px;
|
||||||
|
}
|
||||||
|
.pass-edit-form select, .pass-edit-form input[type="number"] {
|
||||||
|
padding: 4px 8px; background: var(--bg-surface);
|
||||||
|
border: 1px solid var(--border-light); border-radius: 4px;
|
||||||
|
color: var(--text); font-size: 11px; outline: none; font-family: var(--font);
|
||||||
|
}
|
||||||
|
.pass-edit-form select:focus, .pass-edit-form input[type="number"]:focus { border-color: var(--accent); }
|
||||||
|
|
||||||
|
/* MD preview split-pane */
|
||||||
|
.md-editor-row {
|
||||||
|
display: flex; gap: 12px; margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.md-editor-col {
|
||||||
|
flex: 1; display: flex; flex-direction: column;
|
||||||
|
}
|
||||||
|
.md-editor-col textarea {
|
||||||
|
flex: 1; min-height: 160px;
|
||||||
|
}
|
||||||
|
.md-preview {
|
||||||
|
flex: 1; min-height: 160px; padding: 10px;
|
||||||
|
background: var(--bg-card); border: 1px solid var(--border-light);
|
||||||
|
border-radius: var(--radius-sm); font-size: 13px; line-height: 1.6;
|
||||||
|
color: var(--text-secondary); overflow-y: auto;
|
||||||
|
}
|
||||||
|
.md-preview p { margin-bottom: 8px; }
|
||||||
|
.md-preview h1, .md-preview h2, .md-preview h3 { color: var(--text); margin: 12px 0 6px; }
|
||||||
|
.md-preview code {
|
||||||
|
background: var(--bg-inset); padding: 2px 6px; border-radius: 3px;
|
||||||
|
font-family: var(--mono); font-size: 12px;
|
||||||
|
}
|
||||||
|
.md-preview pre {
|
||||||
|
background: var(--bg-inset); padding: 10px; border-radius: var(--radius-sm);
|
||||||
|
overflow-x: auto; margin: 8px 0;
|
||||||
|
}
|
||||||
|
.md-preview pre code { background: none; padding: 0; }
|
||||||
|
.md-preview strong { color: var(--text); }
|
||||||
|
.md-preview a { color: var(--accent); text-decoration: underline; }
|
||||||
|
.md-preview ul, .md-preview ol { padding-left: 20px; margin: 8px 0; }
|
||||||
|
.md-preview li { margin-bottom: 4px; }
|
||||||
|
.md-preview blockquote {
|
||||||
|
border-left: 3px solid var(--accent); padding-left: 12px;
|
||||||
|
color: var(--text-muted); margin: 8px 0; font-style: italic;
|
||||||
|
}
|
||||||
|
.md-preview img { max-width: 100%; border-radius: var(--radius-sm); margin: 8px 0; }
|
||||||
|
|
||||||
|
/* Registration */
|
||||||
|
.login-extra {
|
||||||
|
text-align: center; margin-top: 12px;
|
||||||
|
}
|
||||||
|
.login-extra a {
|
||||||
|
color: var(--text-muted); font-size: 12px; cursor: pointer;
|
||||||
|
text-decoration: none; transition: var(--transition);
|
||||||
|
}
|
||||||
|
.login-extra a:hover { color: var(--accent); }
|
||||||
|
.login-extra .sep {
|
||||||
|
color: var(--border-light); margin: 0 8px;
|
||||||
|
}
|
||||||
|
.btn-secondary-full {
|
||||||
|
width: 100%; padding: 12px; border: none; border-radius: var(--radius-sm);
|
||||||
|
background: var(--bg-card); border: 1px solid var(--border-light);
|
||||||
|
color: var(--text-secondary); font-size: 14px; font-weight: 500; cursor: pointer;
|
||||||
|
font-family: var(--font); transition: var(--transition); text-align: center;
|
||||||
|
}
|
||||||
|
.btn-secondary-full:hover { background: var(--bg-card-hover); color: var(--text); border-color: var(--border); }
|
||||||
|
|
||||||
|
/* Login form register fields */
|
||||||
|
.login-form .field-group {
|
||||||
|
display: flex; gap: 8px;
|
||||||
|
}
|
||||||
|
.login-form .field-group .field {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.login-form .field-group input {
|
||||||
|
width: 100%; padding: 14px 14px; font-size: 14px;
|
||||||
|
background: var(--bg-surface); border: 1px solid var(--border-light);
|
||||||
|
border-radius: var(--radius-sm); color: var(--text);
|
||||||
|
font-family: var(--font); transition: var(--transition);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.login-form .field-group input:focus {
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 0 0 3px var(--accent-glow);
|
||||||
|
}
|
||||||
|
.login-form .field-group label {
|
||||||
|
position: absolute; top: 50%; left: 14px; transform: translateY(-50%);
|
||||||
|
font-size: 13px; color: var(--text-muted);
|
||||||
|
transition: var(--transition); pointer-events: none;
|
||||||
|
background: var(--bg-elevated); padding: 0 4px;
|
||||||
|
}
|
||||||
|
.login-form .field-group .field input:focus + label,
|
||||||
|
.login-form .field-group .field input:not(:placeholder-shown) + label {
|
||||||
|
top: 0; font-size: 11px; color: var(--accent);
|
||||||
|
}
|
||||||
|
|||||||
+49
-29
@@ -68,36 +68,11 @@
|
|||||||
<artifactId>commons-io</artifactId>
|
<artifactId>commons-io</artifactId>
|
||||||
<version>2.15.1</version>
|
<version>2.15.1</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
<!-- JavaFX for Windows -->
|
<!-- JCEF (Chromium Embedded Framework) -->
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.openjfx</groupId>
|
<groupId>me.friwi</groupId>
|
||||||
<artifactId>javafx-controls</artifactId>
|
<artifactId>jcefmaven</artifactId>
|
||||||
<version>21</version>
|
<version>146.0.10</version>
|
||||||
<classifier>win</classifier>
|
|
||||||
</dependency>
|
|
||||||
<dependency>
|
|
||||||
<groupId>org.openjfx</groupId>
|
|
||||||
<artifactId>javafx-web</artifactId>
|
|
||||||
<version>21</version>
|
|
||||||
<classifier>win</classifier>
|
|
||||||
</dependency>
|
|
||||||
<dependency>
|
|
||||||
<groupId>org.openjfx</groupId>
|
|
||||||
<artifactId>javafx-graphics</artifactId>
|
|
||||||
<version>21</version>
|
|
||||||
<classifier>win</classifier>
|
|
||||||
</dependency>
|
|
||||||
<dependency>
|
|
||||||
<groupId>org.openjfx</groupId>
|
|
||||||
<artifactId>javafx-base</artifactId>
|
|
||||||
<version>21</version>
|
|
||||||
<classifier>win</classifier>
|
|
||||||
</dependency>
|
|
||||||
<dependency>
|
|
||||||
<groupId>org.openjfx</groupId>
|
|
||||||
<artifactId>javafx-media</artifactId>
|
|
||||||
<version>21</version>
|
|
||||||
<classifier>win</classifier>
|
|
||||||
</dependency>
|
</dependency>
|
||||||
</dependencies>
|
</dependencies>
|
||||||
</dependencyManagement>
|
</dependencyManagement>
|
||||||
@@ -156,6 +131,51 @@
|
|||||||
</properties>
|
</properties>
|
||||||
</profile>
|
</profile>
|
||||||
|
|
||||||
|
<!-- ==================== JAVAFX (optional) ==================== -->
|
||||||
|
<profile>
|
||||||
|
<id>javafx</id>
|
||||||
|
<activation>
|
||||||
|
<property>
|
||||||
|
<name>useJavaFX</name>
|
||||||
|
<value>true</value>
|
||||||
|
</property>
|
||||||
|
</activation>
|
||||||
|
<dependencyManagement>
|
||||||
|
<dependencies>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.openjfx</groupId>
|
||||||
|
<artifactId>javafx-controls</artifactId>
|
||||||
|
<version>21</version>
|
||||||
|
<classifier>win</classifier>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.openjfx</groupId>
|
||||||
|
<artifactId>javafx-web</artifactId>
|
||||||
|
<version>21</version>
|
||||||
|
<classifier>win</classifier>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.openjfx</groupId>
|
||||||
|
<artifactId>javafx-graphics</artifactId>
|
||||||
|
<version>21</version>
|
||||||
|
<classifier>win</classifier>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.openjfx</groupId>
|
||||||
|
<artifactId>javafx-base</artifactId>
|
||||||
|
<version>21</version>
|
||||||
|
<classifier>win</classifier>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.openjfx</groupId>
|
||||||
|
<artifactId>javafx-media</artifactId>
|
||||||
|
<version>21</version>
|
||||||
|
<classifier>win</classifier>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
</dependencyManagement>
|
||||||
|
</profile>
|
||||||
|
|
||||||
<!-- ==================== ZERNMC ==================== -->
|
<!-- ==================== ZERNMC ==================== -->
|
||||||
<profile>
|
<profile>
|
||||||
<id>zernmc</id>
|
<id>zernmc</id>
|
||||||
|
|||||||
+216
-7
@@ -43,6 +43,32 @@ class BanUserRequest(BaseModel):
|
|||||||
days: int = Field(..., ge=1, le=365)
|
days: int = Field(..., ge=1, le=365)
|
||||||
reason: str
|
reason: str
|
||||||
|
|
||||||
|
class CreatePassCodeRequest(BaseModel):
|
||||||
|
expires_days: Optional[int] = Field(None, ge=1, le=365)
|
||||||
|
max_uses: int = Field(1, ge=1, le=10)
|
||||||
|
|
||||||
|
class UpdatePassRequest(BaseModel):
|
||||||
|
code: str
|
||||||
|
expires_days: Optional[int] = None
|
||||||
|
max_uses: int = Field(1, ge=1, le=10)
|
||||||
|
is_active: bool = True
|
||||||
|
|
||||||
|
class DeletePassRequest(BaseModel):
|
||||||
|
code: str
|
||||||
|
|
||||||
|
class CreatePassRequest(BaseModel):
|
||||||
|
expires_days: Optional[int] = Field(None, ge=1, le=365)
|
||||||
|
max_uses: int = Field(1, ge=1, le=10)
|
||||||
|
|
||||||
|
class UpdatePassRequest(BaseModel):
|
||||||
|
code: str
|
||||||
|
expires_days: Optional[int] = None
|
||||||
|
max_uses: int = Field(1, ge=1, le=10)
|
||||||
|
is_active: bool = True
|
||||||
|
|
||||||
|
class DeletePassRequest(BaseModel):
|
||||||
|
code: str
|
||||||
|
|
||||||
# ====================== ЭНДПОИНТЫ ======================
|
# ====================== ЭНДПОИНТЫ ======================
|
||||||
|
|
||||||
@router.get("/users")
|
@router.get("/users")
|
||||||
@@ -532,11 +558,17 @@ async def get_admin_stats(
|
|||||||
recent_registrations = conn.execute("""
|
recent_registrations = conn.execute("""
|
||||||
SELECT COUNT(*) as count FROM users WHERE created_at > ?
|
SELECT COUNT(*) as count FROM users WHERE created_at > ?
|
||||||
""", (week_ago,)).fetchone()["count"]
|
""", (week_ago,)).fetchone()["count"]
|
||||||
|
|
||||||
|
# Онлайн пользователи
|
||||||
|
online_now = conn.execute("""
|
||||||
|
SELECT COUNT(*) as count FROM user_status WHERE is_online = 1
|
||||||
|
""").fetchone()["count"]
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"total_users": total_users,
|
"total_users": total_users,
|
||||||
"active_passes": active_passes,
|
"active_passes": active_passes,
|
||||||
"banned_users": banned_users,
|
"banned_users": banned_users,
|
||||||
|
"online_now": online_now,
|
||||||
"recent_registrations_7d": recent_registrations,
|
"recent_registrations_7d": recent_registrations,
|
||||||
"roles_distribution": [
|
"roles_distribution": [
|
||||||
{"role": r["role"], "role_name": ROLE_NAMES.get(r["role"], "Неизвестно"), "count": r["count"]}
|
{"role": r["role"], "role_name": ROLE_NAMES.get(r["role"], "Неизвестно"), "count": r["count"]}
|
||||||
@@ -550,6 +582,85 @@ async def get_admin_stats(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ====================== PASS CRUD ======================
|
||||||
|
|
||||||
|
@router.post("/passes/create")
|
||||||
|
async def admin_create_pass(
|
||||||
|
body: CreatePassCodeRequest,
|
||||||
|
current_user: dict = Depends(require_role(ROLE_ELDER)),
|
||||||
|
request: Request = None,
|
||||||
|
):
|
||||||
|
"""Create a new pass code"""
|
||||||
|
ip = request.client.host if request.client else "unknown"
|
||||||
|
pass_code = secrets.token_hex(12).upper()
|
||||||
|
now = time.time()
|
||||||
|
expires_at = now + (body.expires_days * 86400) if body.expires_days else None
|
||||||
|
|
||||||
|
with get_db() as conn:
|
||||||
|
conn.execute("""
|
||||||
|
INSERT INTO passes (code, owner, expires_at, max_uses, is_active, uses, activated_at)
|
||||||
|
VALUES (?, ?, ?, ?, 1, 0, ?)
|
||||||
|
""", (pass_code, 'admin', expires_at, body.max_uses, now))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
log_audit(current_user["id"], "pass_create",
|
||||||
|
f"Created pass {pass_code[:8]}... (expires: {body.expires_days}d, max_uses: {body.max_uses})", ip)
|
||||||
|
|
||||||
|
return {"success": True, "code": pass_code, "expires_at": expires_at, "max_uses": body.max_uses}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/passes/update")
|
||||||
|
async def admin_update_pass(
|
||||||
|
body: UpdatePassRequest,
|
||||||
|
current_user: dict = Depends(require_role(ROLE_ELDER)),
|
||||||
|
request: Request = None,
|
||||||
|
):
|
||||||
|
"""Update an existing pass"""
|
||||||
|
ip = request.client.host if request.client else "unknown"
|
||||||
|
|
||||||
|
with get_db() as conn:
|
||||||
|
existing = conn.execute("SELECT code FROM passes WHERE code = ?", (body.code,)).fetchone()
|
||||||
|
if not existing:
|
||||||
|
raise HTTPException(404, "Pass not found")
|
||||||
|
|
||||||
|
expires_at = time.time() + (body.expires_days * 86400) if body.expires_days else None
|
||||||
|
|
||||||
|
conn.execute("""
|
||||||
|
UPDATE passes SET expires_at = ?, max_uses = ?, is_active = ?
|
||||||
|
WHERE code = ?
|
||||||
|
""", (expires_at, body.max_uses, 1 if body.is_active else 0, body.code))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
log_audit(current_user["id"], "pass_update",
|
||||||
|
f"Updated pass {body.code[:8]}... (active: {body.is_active})", ip)
|
||||||
|
|
||||||
|
return {"success": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/passes/delete")
|
||||||
|
async def admin_delete_pass(
|
||||||
|
body: DeletePassRequest,
|
||||||
|
current_user: dict = Depends(require_role(ROLE_ELDER)),
|
||||||
|
request: Request = None,
|
||||||
|
):
|
||||||
|
"""Delete (deactivate) a pass"""
|
||||||
|
ip = request.client.host if request.client else "unknown"
|
||||||
|
|
||||||
|
with get_db() as conn:
|
||||||
|
existing = conn.execute("SELECT code FROM passes WHERE code = ?", (body.code,)).fetchone()
|
||||||
|
if not existing:
|
||||||
|
raise HTTPException(404, "Pass not found")
|
||||||
|
|
||||||
|
conn.execute("UPDATE passes SET is_active = 0 WHERE code = ?", (body.code,))
|
||||||
|
conn.execute("DELETE FROM user_passes WHERE pass_code = ?", (body.code,))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
log_audit(current_user["id"], "pass_delete",
|
||||||
|
f"Deleted pass {body.code[:8]}...", ip)
|
||||||
|
|
||||||
|
return {"success": True}
|
||||||
|
|
||||||
|
|
||||||
# ====================== WHITELIST MODS ======================
|
# ====================== WHITELIST MODS ======================
|
||||||
|
|
||||||
WHITELIST_DIR = Path(__file__).parent / "whitelist"
|
WHITELIST_DIR = Path(__file__).parent / "whitelist"
|
||||||
@@ -656,15 +767,21 @@ async def remove_whitelist_mod(
|
|||||||
|
|
||||||
@router.get("/clients")
|
@router.get("/clients")
|
||||||
async def admin_list_clients(current_user: dict = Depends(require_role(ROLE_MODERATOR))):
|
async def admin_list_clients(current_user: dict = Depends(require_role(ROLE_MODERATOR))):
|
||||||
"""List online clients"""
|
"""List online clients with 5-minute stale timeout"""
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
|
# Auto-clean stale online status (no heartbeat > 5 min)
|
||||||
|
stale_cutoff = time.time() - 300
|
||||||
|
conn.execute("UPDATE user_status SET is_online = 0 WHERE last_seen < ?", (stale_cutoff,))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
rows = conn.execute("""
|
rows = conn.execute("""
|
||||||
SELECT u.id, u.username, us.is_online, us.current_pack, us.last_seen, s.ip_address
|
SELECT u.id, u.username, us.is_online, us.current_pack, us.last_seen,
|
||||||
|
(SELECT s.ip_address FROM user_sessions s
|
||||||
|
WHERE s.user_id = u.id AND s.is_active = 1
|
||||||
|
ORDER BY s.created_at DESC LIMIT 1) as ip_address
|
||||||
FROM users u
|
FROM users u
|
||||||
LEFT JOIN user_status us ON u.id = us.user_id
|
JOIN user_status us ON u.id = us.user_id
|
||||||
LEFT JOIN user_sessions s ON u.id = s.user_id AND s.is_active = 1
|
|
||||||
WHERE us.is_online = 1
|
WHERE us.is_online = 1
|
||||||
GROUP BY u.id
|
|
||||||
ORDER BY us.last_seen DESC
|
ORDER BY us.last_seen DESC
|
||||||
""").fetchall()
|
""").fetchall()
|
||||||
clients = []
|
clients = []
|
||||||
@@ -672,7 +789,7 @@ async def admin_list_clients(current_user: dict = Depends(require_role(ROLE_MODE
|
|||||||
clients.append({
|
clients.append({
|
||||||
"id": row["id"],
|
"id": row["id"],
|
||||||
"username": row["username"],
|
"username": row["username"],
|
||||||
"online": bool(row["is_online"]),
|
"online": True,
|
||||||
"current_pack": row["current_pack"] or "",
|
"current_pack": row["current_pack"] or "",
|
||||||
"last_seen": str(row["last_seen"]) if row["last_seen"] else None,
|
"last_seen": str(row["last_seen"]) if row["last_seen"] else None,
|
||||||
"ip": row["ip_address"] or ""
|
"ip": row["ip_address"] or ""
|
||||||
@@ -811,6 +928,98 @@ async def admin_list_passes(current_user: dict = Depends(require_role(ROLE_MODER
|
|||||||
return {"success": True, "passes": passes}
|
return {"success": True, "passes": passes}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/passes/create")
|
||||||
|
async def create_pass(
|
||||||
|
body: CreatePassRequest,
|
||||||
|
current_user: dict = Depends(require_role(ROLE_ELDER)),
|
||||||
|
request: Request = None
|
||||||
|
):
|
||||||
|
ip = request.client.host if request.client else "unknown"
|
||||||
|
pass_code = secrets.token_hex(12).upper()
|
||||||
|
now = time.time()
|
||||||
|
expires_at = now + (body.expires_days * 86400) if body.expires_days else None
|
||||||
|
|
||||||
|
with get_db() as conn:
|
||||||
|
conn.execute("""
|
||||||
|
INSERT INTO passes (code, owner, expires_at, max_uses, is_active, uses, activated_by, activated_at)
|
||||||
|
VALUES (?, 'admin', ?, ?, 1, 0, NULL, ?)
|
||||||
|
""", (pass_code, expires_at, body.max_uses, now))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
log_audit(
|
||||||
|
current_user["id"],
|
||||||
|
"pass_create",
|
||||||
|
f"Created pass code {pass_code} (expires_days: {body.expires_days}, max_uses: {body.max_uses})",
|
||||||
|
ip
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"success": True, "code": pass_code}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/passes/update")
|
||||||
|
async def update_pass(
|
||||||
|
body: UpdatePassRequest,
|
||||||
|
current_user: dict = Depends(require_role(ROLE_ELDER)),
|
||||||
|
request: Request = None
|
||||||
|
):
|
||||||
|
ip = request.client.host if request.client else "unknown"
|
||||||
|
|
||||||
|
with get_db() as conn:
|
||||||
|
existing = conn.execute(
|
||||||
|
"SELECT code FROM passes WHERE code = ?", (body.code,)
|
||||||
|
).fetchone()
|
||||||
|
if not existing:
|
||||||
|
raise HTTPException(404, "Pass not found")
|
||||||
|
|
||||||
|
expires_at = None
|
||||||
|
if body.expires_days is not None:
|
||||||
|
expires_at = time.time() + (body.expires_days * 86400)
|
||||||
|
|
||||||
|
conn.execute("""
|
||||||
|
UPDATE passes SET expires_at = ?, max_uses = ?, is_active = ?
|
||||||
|
WHERE code = ?
|
||||||
|
""", (expires_at, body.max_uses, 1 if body.is_active else 0, body.code))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
log_audit(
|
||||||
|
current_user["id"],
|
||||||
|
"pass_update",
|
||||||
|
f"Updated pass {body.code} (expires_days: {body.expires_days}, max_uses: {body.max_uses}, is_active: {body.is_active})",
|
||||||
|
ip
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"success": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/passes/delete")
|
||||||
|
async def delete_pass(
|
||||||
|
body: DeletePassRequest,
|
||||||
|
current_user: dict = Depends(require_role(ROLE_ELDER)),
|
||||||
|
request: Request = None
|
||||||
|
):
|
||||||
|
ip = request.client.host if request.client else "unknown"
|
||||||
|
|
||||||
|
with get_db() as conn:
|
||||||
|
existing = conn.execute(
|
||||||
|
"SELECT code FROM passes WHERE code = ?", (body.code,)
|
||||||
|
).fetchone()
|
||||||
|
if not existing:
|
||||||
|
raise HTTPException(404, "Pass not found")
|
||||||
|
|
||||||
|
conn.execute("UPDATE passes SET is_active = 0 WHERE code = ?", (body.code,))
|
||||||
|
conn.execute("DELETE FROM user_passes WHERE pass_code = ?", (body.code,))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
log_audit(
|
||||||
|
current_user["id"],
|
||||||
|
"pass_delete",
|
||||||
|
f"Deactivated pass {body.code}",
|
||||||
|
ip
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"success": True}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/whitelist/mods/{mod_hash}/delete")
|
@router.post("/whitelist/mods/{mod_hash}/delete")
|
||||||
async def admin_delete_whitelist_mod_adapter(
|
async def admin_delete_whitelist_mod_adapter(
|
||||||
mod_hash: str,
|
mod_hash: str,
|
||||||
|
|||||||
@@ -542,6 +542,10 @@ async def logout(current_user: dict = Depends(get_current_user), request: Reques
|
|||||||
"UPDATE refresh_tokens SET revoked = 1 WHERE user_id = ?",
|
"UPDATE refresh_tokens SET revoked = 1 WHERE user_id = ?",
|
||||||
(current_user["id"],)
|
(current_user["id"],)
|
||||||
)
|
)
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE user_status SET is_online = 0 WHERE user_id = ?",
|
||||||
|
(current_user["id"],)
|
||||||
|
)
|
||||||
|
|
||||||
log_audit(current_user["id"], "logout", f"User logged out from {ip}", ip)
|
log_audit(current_user["id"], "logout", f"User logged out from {ip}", ip)
|
||||||
|
|
||||||
|
|||||||
@@ -116,7 +116,12 @@ async def remove_friend(
|
|||||||
@router.get("/friends/list")
|
@router.get("/friends/list")
|
||||||
async def list_friends(current_user: dict = Depends(get_current_user)):
|
async def list_friends(current_user: dict = Depends(get_current_user)):
|
||||||
friends = []
|
friends = []
|
||||||
|
stale_cutoff = time.time() - 300
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
|
# Auto-clean stale online
|
||||||
|
conn.execute("UPDATE user_status SET is_online = 0 WHERE last_seen < ?", (stale_cutoff,))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
rows = conn.execute("""
|
rows = conn.execute("""
|
||||||
SELECT u.id, u.username, u.role,
|
SELECT u.id, u.username, u.role,
|
||||||
COALESCE(us.is_online, 0) as online,
|
COALESCE(us.is_online, 0) as online,
|
||||||
@@ -159,6 +164,17 @@ async def list_friend_requests(current_user: dict = Depends(get_current_user)):
|
|||||||
})
|
})
|
||||||
return {"requests": requests}
|
return {"requests": requests}
|
||||||
|
|
||||||
|
def get_friends_ids(user_id: int) -> list[int]:
|
||||||
|
"""Get list of friend user_ids for a given user"""
|
||||||
|
with get_db() as conn:
|
||||||
|
rows = conn.execute("""
|
||||||
|
SELECT CASE WHEN f.requester_id = ? THEN f.target_id ELSE f.requester_id END as friend_id
|
||||||
|
FROM friendships f
|
||||||
|
WHERE (f.requester_id = ? OR f.target_id = ?) AND f.status = 'accepted'
|
||||||
|
""", (user_id, user_id, user_id)).fetchall()
|
||||||
|
return [r["friend_id"] for r in rows]
|
||||||
|
|
||||||
|
|
||||||
@router.post("/friends/status")
|
@router.post("/friends/status")
|
||||||
async def update_status(
|
async def update_status(
|
||||||
req: StatusUpdateRequest,
|
req: StatusUpdateRequest,
|
||||||
@@ -173,4 +189,11 @@ async def update_status(
|
|||||||
current_pack = COALESCE(excluded.current_pack, user_status.current_pack),
|
current_pack = COALESCE(excluded.current_pack, user_status.current_pack),
|
||||||
last_seen = CURRENT_TIMESTAMP
|
last_seen = CURRENT_TIMESTAMP
|
||||||
""", (current_user["id"], int(req.online), req.current_pack or ""))
|
""", (current_user["id"], int(req.online), req.current_pack or ""))
|
||||||
|
# Broadcast status change to friends via WebSocket
|
||||||
|
try:
|
||||||
|
from ws_manager import manager
|
||||||
|
friend_ids = get_friends_ids(current_user["id"])
|
||||||
|
await manager.broadcast_status_change(current_user["id"], friend_ids)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|||||||
+154
-2
@@ -11,7 +11,7 @@ import httpx
|
|||||||
import json
|
import json
|
||||||
import structlog
|
import structlog
|
||||||
from cachetools import TTLCache
|
from cachetools import TTLCache
|
||||||
from fastapi import Depends, FastAPI, HTTPException, Request, Response
|
from fastapi import Depends, FastAPI, HTTPException, Request, Response, WebSocket, WebSocketDisconnect, Query
|
||||||
from fastapi.responses import FileResponse, JSONResponse, StreamingResponse
|
from fastapi.responses import FileResponse, JSONResponse, StreamingResponse
|
||||||
from uvicorn.protocols.http.httptools_impl import HttpToolsProtocol
|
from uvicorn.protocols.http.httptools_impl import HttpToolsProtocol
|
||||||
|
|
||||||
@@ -28,8 +28,9 @@ 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 friends import router as friends_router, init_friends_db, get_friends_ids
|
||||||
from playtime import router as playtime_router, init_playtime_db
|
from playtime import router as playtime_router, init_playtime_db
|
||||||
|
from ws_manager import manager
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import hashlib
|
import hashlib
|
||||||
@@ -222,8 +223,14 @@ async def lifespan(app: FastAPI):
|
|||||||
|
|
||||||
asyncio.create_task(periodic_sync())
|
asyncio.create_task(periodic_sync())
|
||||||
|
|
||||||
|
# Start WebSocket admin clients broadcaster
|
||||||
|
broadcaster = asyncio.create_task(admin_clients_broadcaster())
|
||||||
|
|
||||||
yield
|
yield
|
||||||
|
|
||||||
|
# Cancel WebSocket broadcaster
|
||||||
|
broadcaster.cancel()
|
||||||
|
|
||||||
# Cleanup proxy client
|
# Cleanup proxy client
|
||||||
if proxy_client:
|
if proxy_client:
|
||||||
await proxy_client.aclose()
|
await proxy_client.aclose()
|
||||||
@@ -925,6 +932,151 @@ app.include_router(friends_router)
|
|||||||
app.include_router(playtime_router)
|
app.include_router(playtime_router)
|
||||||
|
|
||||||
|
|
||||||
|
# ====================== WEBSOCKET ======================
|
||||||
|
|
||||||
|
async def admin_clients_broadcaster():
|
||||||
|
"""Periodically push client list to connected admins via WebSocket"""
|
||||||
|
while True:
|
||||||
|
await asyncio.sleep(5)
|
||||||
|
try:
|
||||||
|
from auth import get_db
|
||||||
|
from roles import ROLE_MODERATOR, ROLE_ELDER, ROLE_CREATOR
|
||||||
|
admin_ids = [uid for uid in list(manager._connections.keys())]
|
||||||
|
if not admin_ids:
|
||||||
|
continue
|
||||||
|
# Check which are actually admins
|
||||||
|
with get_db() as conn:
|
||||||
|
admin_rows = conn.execute(
|
||||||
|
"SELECT id FROM users WHERE id IN ({}) AND role >= ?".format(
|
||||||
|
",".join("?" for _ in admin_ids)),
|
||||||
|
admin_ids + [ROLE_MODERATOR]
|
||||||
|
).fetchall()
|
||||||
|
real_admins = [r["id"] for r in admin_rows]
|
||||||
|
if not real_admins:
|
||||||
|
continue
|
||||||
|
stale_cutoff = time.time() - 300
|
||||||
|
with get_db() as conn:
|
||||||
|
conn.execute("UPDATE user_status SET is_online = 0 WHERE last_seen < ?", (stale_cutoff,))
|
||||||
|
conn.commit()
|
||||||
|
rows = conn.execute("""
|
||||||
|
SELECT u.id, u.username, us.is_online, us.current_pack, us.last_seen,
|
||||||
|
(SELECT s.ip_address FROM user_sessions s
|
||||||
|
WHERE s.user_id = u.id AND s.is_active = 1
|
||||||
|
ORDER BY s.created_at DESC LIMIT 1) as ip_address
|
||||||
|
FROM users u
|
||||||
|
JOIN user_status us ON u.id = us.user_id
|
||||||
|
WHERE us.is_online = 1
|
||||||
|
ORDER BY us.last_seen DESC
|
||||||
|
""").fetchall()
|
||||||
|
clients = []
|
||||||
|
for row in rows:
|
||||||
|
clients.append({
|
||||||
|
"id": row["id"], "username": row["username"],
|
||||||
|
"online": True, "current_pack": row["current_pack"] or "",
|
||||||
|
"last_seen": str(row["last_seen"]) if row["last_seen"] else None,
|
||||||
|
"ip": row["ip_address"] or ""
|
||||||
|
})
|
||||||
|
await manager.broadcast_admin_clients(clients, real_admins)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug("admin_clients_broadcaster error", error=str(exc))
|
||||||
|
|
||||||
|
|
||||||
|
@app.websocket("/ws")
|
||||||
|
async def websocket_endpoint(
|
||||||
|
ws: WebSocket,
|
||||||
|
token: str = Query(...),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
payload = verify_jwt(token)
|
||||||
|
if payload is None:
|
||||||
|
await ws.close(code=4001, reason="Invalid token")
|
||||||
|
return
|
||||||
|
user_id = payload.get("user_id")
|
||||||
|
if user_id is None:
|
||||||
|
await ws.close(code=4001, reason="Invalid token payload")
|
||||||
|
return
|
||||||
|
except Exception:
|
||||||
|
await ws.close(code=4001, reason="Invalid token")
|
||||||
|
return
|
||||||
|
|
||||||
|
await manager.connect(user_id, ws)
|
||||||
|
|
||||||
|
# Send initial friends list
|
||||||
|
try:
|
||||||
|
with get_db() as conn:
|
||||||
|
cutoff = time.time() - 300
|
||||||
|
conn.execute("UPDATE user_status SET is_online = 0 WHERE last_seen < ?", (cutoff,))
|
||||||
|
conn.commit()
|
||||||
|
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'
|
||||||
|
""", (user_id, user_id, user_id))
|
||||||
|
friends = []
|
||||||
|
for row in rows:
|
||||||
|
friends.append({
|
||||||
|
"id": row["id"], "username": row["username"],
|
||||||
|
"role": row["role"], "online": bool(row["online"]),
|
||||||
|
"current_pack": row["current_pack"],
|
||||||
|
"last_seen": str(row["last_seen"]) if row["last_seen"] else None
|
||||||
|
})
|
||||||
|
await manager.send_json(user_id, {"type": "friends_update", "friends": friends})
|
||||||
|
|
||||||
|
# Send initial friend requests
|
||||||
|
with get_db() as conn:
|
||||||
|
req_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'
|
||||||
|
""", (user_id,))
|
||||||
|
requests = []
|
||||||
|
for row in req_rows:
|
||||||
|
requests.append({
|
||||||
|
"id": row["id"], "username": row["username"],
|
||||||
|
"role": row["role"], "created_at": str(row["created_at"]) if row["created_at"] else None
|
||||||
|
})
|
||||||
|
await manager.send_json(user_id, {"type": "requests_update", "requests": requests})
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("WS initial data error", error=str(exc))
|
||||||
|
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
data = await ws.receive_text()
|
||||||
|
try:
|
||||||
|
msg = json.loads(data)
|
||||||
|
msg_type = msg.get("type")
|
||||||
|
if msg_type == "ping":
|
||||||
|
await ws.send_text(json.dumps({"type": "pong"}))
|
||||||
|
elif msg_type == "status":
|
||||||
|
current_pack = msg.get("current_pack", "")
|
||||||
|
with get_db() as conn:
|
||||||
|
conn.execute("""
|
||||||
|
INSERT INTO user_status (user_id, is_online, current_pack, last_seen)
|
||||||
|
VALUES (?, 1, ?, CURRENT_TIMESTAMP)
|
||||||
|
ON CONFLICT(user_id) DO UPDATE SET
|
||||||
|
is_online = 1,
|
||||||
|
current_pack = COALESCE(?, user_status.current_pack),
|
||||||
|
last_seen = CURRENT_TIMESTAMP
|
||||||
|
""", (user_id, current_pack, current_pack))
|
||||||
|
# Notify friends
|
||||||
|
friend_ids = get_friends_ids(user_id)
|
||||||
|
await manager.broadcast_status_change(user_id, friend_ids)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
pass
|
||||||
|
except WebSocketDisconnect:
|
||||||
|
pass
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("WS error", user_id=user_id, error=str(exc))
|
||||||
|
finally:
|
||||||
|
manager.disconnect(user_id, ws)
|
||||||
|
|
||||||
|
|
||||||
# Monkey patch to catch invalid HTTP requests
|
# Monkey patch to catch invalid HTTP requests
|
||||||
original_data_received = HttpToolsProtocol.data_received
|
original_data_received = HttpToolsProtocol.data_received
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import structlog
|
||||||
|
import time
|
||||||
|
from collections import defaultdict
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import WebSocket
|
||||||
|
|
||||||
|
logger = structlog.get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class ConnectionManager:
|
||||||
|
def __init__(self):
|
||||||
|
# user_id -> list of WebSocket connections
|
||||||
|
self._connections: dict[int, list[WebSocket]] = defaultdict(list)
|
||||||
|
# user_id -> set of friend user_ids (cached for broadcast)
|
||||||
|
self._friend_cache: dict[int, set[int]] = {}
|
||||||
|
self._cleanup_lock = asyncio.Lock()
|
||||||
|
|
||||||
|
async def connect(self, user_id: int, ws: WebSocket):
|
||||||
|
await ws.accept()
|
||||||
|
self._connections[user_id].append(ws)
|
||||||
|
logger.info("WS connected", user_id=user_id, total=len(self._connections))
|
||||||
|
|
||||||
|
def disconnect(self, user_id: int, ws: WebSocket):
|
||||||
|
conns = self._connections.get(user_id, [])
|
||||||
|
if ws in conns:
|
||||||
|
conns.remove(ws)
|
||||||
|
if not self._connections.get(user_id):
|
||||||
|
self._connections.pop(user_id, None)
|
||||||
|
logger.info("WS disconnected", user_id=user_id, total=len(self._connections))
|
||||||
|
|
||||||
|
async def send_json(self, user_id: int, data: dict):
|
||||||
|
conns = self._connections.get(user_id, [])
|
||||||
|
if not conns:
|
||||||
|
return
|
||||||
|
msg = json.dumps(data)
|
||||||
|
dead = []
|
||||||
|
for ws in conns:
|
||||||
|
try:
|
||||||
|
await ws.send_text(msg)
|
||||||
|
except Exception:
|
||||||
|
dead.append(ws)
|
||||||
|
for ws in dead:
|
||||||
|
self.disconnect(user_id, ws)
|
||||||
|
|
||||||
|
async def broadcast_friends_update(self, user_id: int, friends_data: list[dict]):
|
||||||
|
"""Broadcast friend list update to a specific user"""
|
||||||
|
await self.send_json(user_id, {
|
||||||
|
"type": "friends_update",
|
||||||
|
"friends": friends_data
|
||||||
|
})
|
||||||
|
|
||||||
|
async def broadcast_status_change(self, user_id: int, friends: list[int]):
|
||||||
|
"""Notify all friends that this user's status changed"""
|
||||||
|
data = {"type": "friend_status", "user_id": user_id}
|
||||||
|
for fid in friends:
|
||||||
|
await self.send_json(fid, data)
|
||||||
|
|
||||||
|
async def broadcast_admin_clients(self, clients_data: list[dict], admin_ids: list[int]):
|
||||||
|
"""Broadcast client list to all connected admins"""
|
||||||
|
data = {"type": "clients_update", "clients": clients_data}
|
||||||
|
for aid in admin_ids:
|
||||||
|
await self.send_json(aid, data)
|
||||||
|
|
||||||
|
|
||||||
|
manager = ConnectionManager()
|
||||||
Reference in New Issue
Block a user