Fix: Multiple launcher issues

- Fix CLI arrow keys: remove 50ms timeout in escape sequence handling (ArrowMenu, LoginMenu)
- Add network logs polling to UI via /api/logs endpoint
- Display user role in launcher header (AuthManager, AuthService, JFXLauncher, UI)
- Capture and display game logs in launcher via /api/game-logs endpoint
- Fix demo mode bug in VersionManifest.ruleMatches() - was incorrectly adding --demo flag
- Fix modloader launch: pass proper auth info (accessToken, uuid) from AuthManager
- Add game log capture in MinecraftLib and LaunchService
This commit is contained in:
SashegDev
2026-05-08 10:11:49 +00:00
parent 5a826c8511
commit e5948b5337
16 changed files with 271 additions and 28 deletions
+1 -1
View File
@@ -7,7 +7,7 @@
<parent>
<groupId>me.sashegdev</groupId>
<artifactId>ZernMCLauncher</artifactId>
<version>1.0.8</version>
<version>1.0.9</version>
</parent>
<artifactId>zernmc-bootstrap</artifactId>
@@ -8,6 +8,8 @@ import java.security.MessageDigest;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.*;
import java.util.jar.Attributes;
import java.util.jar.Manifest;
public class Bootstrap {
private static final String VERSION_FILE = "build.version";
@@ -62,10 +64,26 @@ public class Bootstrap {
}
private static String readCurrentVersion() {
// Читаем версию из манифеста zernmclauncher.jar в папке bin/
Path launcherJar = baseDir.resolve("bin").resolve("zernmclauncher.jar");
try {
if (Files.exists(launcherJar)) {
try (FileInputStream fis = new FileInputStream(launcherJar.toFile())) {
Manifest manifest = new Manifest(fis);
String version = manifest.getMainAttributes().getValue(Attributes.Name.IMPLEMENTATION_VERSION);
if (version != null && !version.isBlank()) {
return version;
}
}
}
} catch (Exception ignored) {}
// Fallback: из build.version
Path f = baseDir.resolve(VERSION_FILE);
try {
if (Files.exists(f)) return Files.readString(f).trim();
} catch (Exception ignored) {}
return "0.0.0";
}
+17 -4
View File
@@ -7,7 +7,7 @@
<parent>
<groupId>me.sashegdev</groupId>
<artifactId>ZernMCLauncher</artifactId>
<version>1.0.8</version>
<version>1.0.9</version>
</parent>
<artifactId>zernmclauncher</artifactId>
@@ -223,11 +223,24 @@
<move file="../../server/builds/zernmc-${project.version}.exe"
tofile="../../server/builds/zernmc.exe" overwrite="true"/>
<!-- Создаём zip с exe, jar и lib -->
<!-- Создаем папку bin и копируем JAR -->
<mkdir dir="../../server/builds/bin"/>
<copy file="../../server/builds/zernmclauncher.jar"
tofile="../../server/builds/bin/zernmclauncher.jar" overwrite="true"/>
<!-- Копируем UI в assets -->
<mkdir dir="../../server/builds/assets"/>
<copy todir="../../server/builds/assets/ui" overwrite="true">
<fileset dir="${project.basedir}/src/resources/ui">
<include name="**/*"/>
</fileset>
</copy>
<!-- Создаём zip -->
<zip destfile="../../server/builds/ZernMC-win-${project.version}.zip"
basedir="../../server/builds"
includes="zernmc.exe,zernmclauncher.jar,zernmc-bootstrap.jar,lib/**"
excludes="build.version,*-${project.version}.*"/>
includes="zernmc.exe,bin/**,assets/**,lib/**"
excludes="build.version,*-${project.version}.*,zernmclauncher.jar,zernmc-bootstrap.jar"/>
</target>
</configuration>
</execution>
@@ -37,7 +37,9 @@ public class AuthService {
SessionInfo info = new SessionInfo(
AuthManager.getUsername(),
AuthManager.getAccessToken(),
AuthManager.hasActivePass()
AuthManager.hasActivePass(),
AuthManager.getRole(),
AuthManager.getRoleName()
);
return ApiResponse.success(info);
}
@@ -120,15 +122,21 @@ public class AuthService {
private String username;
private String token;
private boolean passActive;
private int role;
private String roleName;
public SessionInfo(String username, String token, boolean passActive) {
public SessionInfo(String username, String token, boolean passActive, int role, String roleName) {
this.username = username;
this.token = token;
this.passActive = passActive;
this.role = role;
this.roleName = roleName;
}
public String getUsername() { return username; }
public String getToken() { return token; }
public boolean isPassActive() { return passActive; }
public int getRole() { return role; }
public String getRoleName() { return roleName; }
}
}
@@ -1,12 +1,16 @@
package me.sashegdev.zernmc.launcher.api.launch;
import me.sashegdev.zernmc.launcher.api.ApiResponse;
import me.sashegdev.zernmc.launcher.auth.AuthManager;
import me.sashegdev.zernmc.launcher.minecraft.Instance;
import me.sashegdev.zernmc.launcher.minecraft.InstanceManager;
import me.sashegdev.zernmc.launcher.minecraft.launch.LaunchCommandBuilder;
import me.sashegdev.zernmc.launcher.minecraft.model.LaunchOptions;
import me.sashegdev.zernmc.launcher.ui.jfx.JFXLauncher;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.nio.file.Path;
import java.util.List;
@@ -45,14 +49,46 @@ public class LaunchService {
LaunchCommandBuilder builder = new LaunchCommandBuilder(instance);
LaunchOptions options = new LaunchOptions();
// Set auth info
options.setUsername(AuthManager.getUsername());
options.setAccessToken(AuthManager.getAccessToken());
options.setUuid(AuthManager.getUuid());
List<String> command = builder.build(options);
ProcessBuilder processBuilder = new ProcessBuilder(command);
processBuilder.directory(instance.getPath().toFile());
processBuilder.inheritIO();
Process process = processBuilder.start();
// Capture output
Thread outThread = new Thread(() -> {
try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) {
String line;
while ((line = reader.readLine()) != null) {
JFXLauncher.appendGameLog(line);
}
} catch (Exception e) {
JFXLauncher.appendGameLog("[Ошибка чтения вывода: " + e.getMessage() + "]");
}
});
outThread.setDaemon(true);
outThread.start();
// Capture errors
Thread errThread = new Thread(() -> {
try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getErrorStream()))) {
String line;
while ((line = reader.readLine()) != null) {
JFXLauncher.appendGameLog("[ERR] " + line);
}
} catch (Exception e) {
JFXLauncher.appendGameLog("[Ошибка чтения ошибок: " + e.getMessage() + "]");
}
});
errThread.setDaemon(true);
errThread.start();
ProcessInfo info = new ProcessInfo(
instanceName,
process.pid(),
@@ -213,6 +213,13 @@ public class AuthManager {
return session != null ? session.role : ROLE_USER;
}
public static String getRoleName() {
if (userInfo != null && userInfo.role_name != null) {
return userInfo.role_name;
}
return "USER";
}
// ====================== POST ======================
private static SimpleHttpResponse post(String endpoint, String jsonBody) throws Exception {
String fullUrl = ZHttpClient.getBaseUrl() + endpoint;
@@ -166,9 +166,9 @@ public class LoginMenu {
if (key == 27) {
// Escape sequence — consume remaining bytes (arrow keys, etc.)
int next = passTerminal.reader().read(50);
int next = passTerminal.reader().read();
if (next == 91) { // '[' — arrow key sequence
passTerminal.reader().read(50); // consume 'A'/'B'/'C'/'D'
passTerminal.reader().read(); // consume 'A'/'B'/'C'/'D'
}
continue;
}
@@ -6,10 +6,14 @@ import me.sashegdev.zernmc.launcher.minecraft.installer.NeoForgeInstaller;
import me.sashegdev.zernmc.launcher.minecraft.installer.VersionInstaller;
import me.sashegdev.zernmc.launcher.minecraft.launch.LaunchCommandBuilder;
import me.sashegdev.zernmc.launcher.minecraft.model.LaunchOptions;
import me.sashegdev.zernmc.launcher.ui.jfx.JFXLauncher;
import me.sashegdev.zernmc.launcher.utils.ConsoleUtils;
import me.sashegdev.zernmc.launcher.utils.ZAnsi;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
@@ -114,15 +118,43 @@ public class MinecraftLib {
ProcessBuilder pb = new ProcessBuilder(command);
pb.directory(instance.getPath().toFile());
pb.redirectOutput(ProcessBuilder.Redirect.INHERIT);
pb.redirectError(ProcessBuilder.Redirect.INHERIT);
pb.redirectInput(ProcessBuilder.Redirect.INHERIT);
System.out.println(ZAnsi.brightGreen("\nЗапускаем Minecraft...\n"));
ConsoleUtils.clearScreen();
Process process = pb.start();
// Capture output
Thread outThread = new Thread(() -> {
try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) {
String line;
while ((line = reader.readLine()) != null) {
JFXLauncher.appendGameLog(line);
}
} catch (Exception e) {
JFXLauncher.appendGameLog("[Ошибка чтения вывода: " + e.getMessage() + "]");
}
});
outThread.setDaemon(true);
outThread.start();
// Capture errors
Thread errThread = new Thread(() -> {
try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getErrorStream()))) {
String line;
while ((line = reader.readLine()) != null) {
JFXLauncher.appendGameLog("[ERR] " + line);
}
} catch (Exception e) {
JFXLauncher.appendGameLog("[Ошибка чтения ошибок: " + e.getMessage() + "]");
}
});
errThread.setDaemon(true);
errThread.start();
int exitCode = process.waitFor();
outThread.join(1000);
errThread.join(1000);
System.out.println(ZAnsi.yellow("\nMinecraft завершился с кодом: " + exitCode));
}
@@ -99,8 +99,17 @@ public class VersionManifest {
if (rule.has("features")) {
JSONObject features = rule.getJSONObject("features");
for (String key : features.keySet()) {
if (key.startsWith("is_demo_user") || key.startsWith("has_custom_resolution")) continue;
if (key.startsWith("has_custom_resolution")) {
continue; // Лаунчер сам обрабатывает разрешение
}
if (key.startsWith("is_demo_user")) {
// Лаунчер не использует demo режим, считаем фичу false
matches = false;
break;
}
// Неизвестная фича — считаем false
matches = false;
break;
}
}
@@ -48,9 +48,9 @@ public class ArrowMenu {
return selected;
}
else if (key == 27) { // Esc or arrow escape seq
int next = terminal.reader().read(50);
int next = terminal.reader().read();
if (next == 91) { // '[' — start of arrow escape sequence
int arrow = terminal.reader().read(50);
int arrow = terminal.reader().read();
if (arrow == 65) { // 'A' — Up arrow
selected = (selected - 1 + options.size()) % options.size();
} else if (arrow == 66) { // 'B' — Down arrow
@@ -16,6 +16,9 @@ import java.io.InputStream;
import java.io.OutputStream;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.Executors;
@@ -30,8 +33,21 @@ public class JFXLauncher extends Application {
private final Gson gson = new Gson();
private HttpServer server;
private StringBuilder logBuffer = new StringBuilder();
private static StringBuilder gameLogBuffer = new StringBuilder();
private Stage mainStage;
public static void appendGameLog(String log) {
synchronized (gameLogBuffer) {
gameLogBuffer.append(log).append("\n");
}
}
public static String getGameLogs() {
synchronized (gameLogBuffer) {
return gameLogBuffer.toString();
}
}
public static void main(String[] args) {
launch(args);
}
@@ -49,12 +65,17 @@ public class JFXLauncher extends Application {
engine.setJavaScriptEnabled(true);
engine.getLoadWorker().stateProperty().addListener((obs, oldState, newState) -> {
log("[UI] Load state: " + oldState + " -> " + newState);
if (newState == Worker.State.SUCCEEDED) {
log("Страница загружена");
} else if (newState == Worker.State.FAILED) {
log("[UI] Load FAILED: " + engine.getLoadWorker().getException());
}
});
String url = "http://localhost:" + PORT + "/ui/";
engine.setOnAlert(e -> log("[UI] Alert: " + e.getData()));
String url = "http://localhost:" + PORT + "/assets/ui/index.html";
engine.load(url);
stage.setTitle(APP_TITLE);
@@ -86,8 +107,9 @@ public class JFXLauncher extends Application {
server.createContext("/api/launch", this::handleLaunch);
server.createContext("/api/install", this::handleInstall);
server.createContext("/api/logs", this::handleLogs);
server.createContext("/api/game-logs", this::handleGameLogs);
server.createContext("/api/exit", this::handleExit);
server.createContext("/ui/", this::handleStatic);
server.createContext("/assets/", this::handleStatic);
server.setExecutor(Executors.newCachedThreadPool());
server.start();
@@ -134,6 +156,8 @@ public class JFXLauncher extends Application {
Map<String, Object> data = new HashMap<>();
data.put("username", api.getCurrentUsername());
data.put("passActive", AuthManager.hasActivePass());
data.put("role", AuthManager.getRole());
data.put("roleName", AuthManager.getRoleName());
sendJson(exchange, Map.of("success", true, "data", data));
} catch (Exception e) {
sendJson(exchange, Map.of("success", false, "error", e.getMessage()));
@@ -218,6 +242,10 @@ public class JFXLauncher extends Application {
sendJson(exchange, Map.of("success", true, "data", logBuffer.toString()));
}
private void handleGameLogs(HttpExchange exchange) {
sendJson(exchange, Map.of("success", true, "data", getGameLogs()));
}
private void handleExit(HttpExchange exchange) {
log("Выход...");
if (mainStage != null) mainStage.close();
@@ -227,23 +255,29 @@ public class JFXLauncher extends Application {
private void handleStatic(HttpExchange exchange) {
try {
String path = exchange.getRequestURI().getPath();
if (path.equals("/ui/") || path.equals("/ui")) path = "/ui/index.html";
log("[UI] Request: " + path);
var resource = JFXLauncher.class.getResource(path);
if (resource == null) {
String relativePath = path.startsWith("/") ? path.substring(1) : path;
Path file = Paths.get(relativePath).toAbsolutePath();
if (!Files.exists(file)) {
log("[UI] File not found: " + file);
exchange.sendResponseHeaders(404, 0);
exchange.close();
return;
}
byte[] content = resource.openStream().readAllBytes();
byte[] content = Files.readAllBytes(file);
log("[UI] Loaded " + content.length + " bytes: " + path);
String ct = getContentType(path);
exchange.getResponseHeaders().set("Content-Type", ct);
exchange.sendResponseHeaders(200, content.length);
exchange.getResponseBody().write(content);
exchange.close();
} catch (Exception ignored) {}
} catch (Exception e) {
log("[UI] Error serving: " + e.getMessage());
}
}
private String getContentType(String path) {
@@ -30,6 +30,7 @@
<div class="account-info">
<span id="account-name">-</span>
<span id="account-status" class="badge">-</span>
<span id="account-role" class="badge role-badge">-</span>
</div>
</header>
@@ -70,6 +70,11 @@ async function loadAccountInfo() {
const statusEl = document.getElementById('account-status');
statusEl.textContent = result.data.passActive ? 'PRO' : 'FREE';
statusEl.className = 'badge ' + (result.data.passActive ? 'active' : 'inactive');
const roleEl = document.getElementById('account-role');
if (roleEl && result.data.roleName) {
roleEl.textContent = result.data.roleName;
}
} else {
showLoginScreen();
}
@@ -239,8 +244,41 @@ document.addEventListener('DOMContentLoaded', async () => {
showMainScreen();
await loadInstances();
}
// Start polling for server logs
startLogPolling();
});
let lastLogLength = 0;
let lastGameLogLength = 0;
function startLogPolling() {
setInterval(async () => {
// Launcher logs
const result = await apiCall('/logs');
if (result.success && result.data && result.data.length > lastLogLength) {
const newLogs = result.data.substring(lastLogLength);
const lines = newLogs.split('\n').filter(l => l.trim());
lines.forEach(line => {
if (line.includes('[JFX]')) {
log(line.replace('[JFX] ', ''), 'info');
}
});
lastLogLength = result.data.length;
}
// Game logs
const gameResult = await apiCall('/game-logs');
if (gameResult.success && gameResult.data && gameResult.data.length > lastGameLogLength) {
const newLogs = gameResult.data.substring(lastGameLogLength);
const lines = newLogs.split('\n').filter(l => l.trim());
lines.forEach(line => {
log('[GAME] ' + line, 'info');
});
lastGameLogLength = gameResult.data.length;
}
}, 2000);
}
// ============ Form Handlers ============
document.getElementById('login-form').addEventListener('submit', async (e) => {
@@ -194,6 +194,11 @@ input::placeholder {
color: var(--error);
}
.role-badge {
background: rgba(99, 102, 241, 0.2);
color: #818cf8;
}
/* Main Content */
.main-content {
flex: 1;
+1 -1
View File
@@ -6,7 +6,7 @@
<modelVersion>4.0.0</modelVersion>
<groupId>me.sashegdev</groupId>
<artifactId>ZernMCLauncher</artifactId>
<version>1.0.8</version>
<version>1.0.9</version>
<packaging>pom</packaging>
<name>ZernMC Launcher Parent</name>
+46 -4
View File
@@ -150,6 +150,11 @@ async def lifespan(app: FastAPI):
# Scan launcher versions and generate meta
logger.info("Scanning launcher versions...")
# Extract new format ZIPs to versions directory
logger.info("Extracting new format versions...")
extract_new_format_versions()
launcher_versions = get_launcher_versions()
if launcher_versions:
latest = launcher_versions[0]
@@ -773,7 +778,12 @@ async def get_pack_file(pack_name: str, file_path: str, request: Request):
# ====================== ЭНДПОИНТЫ ДЛЯ ЛАУНЧЕРА ======================
def get_current_launcher_version() -> str:
"""Get current launcher version from build.version file"""
"""Get current launcher version from meta system (new format) or build.version (legacy)"""
versions = get_launcher_versions()
if versions:
return versions[0]["meta"]["version"]
# Fallback to build.version for legacy
version_file = BUILDS_DIR / "build.version"
if version_file.exists():
return version_file.read_text().strip()
@@ -892,6 +902,35 @@ def get_launcher_version_meta(version: str) -> Optional[dict]:
return scan_launcher_version(version)
def extract_new_format_versions():
"""Extract new format ZIPs to versions directory"""
VERSIONS_DIR.mkdir(exist_ok=True)
# Find all ZernMC-win-*.zip files
new_format_zips = list(BUILDS_DIR.glob("ZernMC-win-*.zip"))
for zip_file in new_format_zips:
version = zip_file.stem.replace("ZernMC-win-", "")
extract_dir = VERSIONS_DIR / version
# Skip if already extracted and meta exists
if extract_dir.exists() and (extract_dir / "meta.json").exists():
logger.debug(f"Version {version} already extracted")
continue
logger.info(f"Extracting {zip_file.name} to versions/{version}/...")
try:
import zipfile
with zipfile.ZipFile(zip_file, 'r') as zf:
# Extract all files
zf.extractall(extract_dir)
logger.info(f"Extracted {zip_file.name} successfully")
except Exception as e:
logger.error(f"Failed to extract {zip_file.name}: {e}")
# ====================== END ЛАУНЧЕР МЕТА СИСТЕМА ======================
@@ -1008,14 +1047,17 @@ async def get_launcher_version():
@app.get("/launcher/download/jar")
async def download_launcher_jar():
"""Download launcher JAR file"""
file_path = BUILDS_DIR / "ZernMCLauncher.jar"
# Prefer new shaded JAR, fallback to old
file_path = BUILDS_DIR / "zernmclauncher.jar"
if not file_path.exists():
file_path = BUILDS_DIR / "ZernMCLauncher.jar"
if not file_path.exists():
raise HTTPException(404, "JAR file not found")
return FileResponse(
path=file_path,
filename="ZernMCLauncher.jar",
filename="zernmclauncher.jar",
media_type="application/java-archive"
)
@@ -1114,7 +1156,7 @@ async def get_launcher_meta_list():
@app.get("/launcher/meta/{version}")
async def get_launcher_version_meta(version: str):
async def get_launcher_version_meta_handler(version: str):
"""Get meta for specific launcher version"""
meta = get_launcher_version_meta(version)
if not meta: