feat(webui): implement HTTP-server-backed WebUI module

Recreate the shit.zen.network.webui handlers (categories, modules,
toggle, get/set setting, static files) from Recaf and wire them into
the WebUI module so it serves a control panel from a configurable
local port. Ships a Tailwind-based panel under resources/webui/.
This commit is contained in:
Shirona1337
2026-05-23 04:15:28 +08:00
parent cd0bc4f27b
commit 9f790ee492
12 changed files with 540 additions and 4 deletions
-1
View File
@@ -16,7 +16,6 @@
## TODO
- 客户端的Shader渲染明显还有很多问题。
- Web Click GUI尚未实现。
- 部分UI错位。
- 直接构建为热注入DLL。
- 你来提出Issue,我来解决。
@@ -1,22 +1,74 @@
package shit.zen.modules.impl.world;
import com.sun.net.httpserver.HttpServer;
import java.awt.Desktop;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.net.URI;
import java.net.URISyntaxException;
import shit.zen.modules.Category;
import shit.zen.modules.Module;
import shit.zen.network.webui.CategoriesHandler;
import shit.zen.network.webui.ModulesHandler;
import shit.zen.network.webui.SetSettingHandler;
import shit.zen.network.webui.SettingsHandler;
import shit.zen.network.webui.StaticFileHandler;
import shit.zen.network.webui.ToggleModuleHandler;
import shit.zen.settings.impl.BooleanSetting;
import shit.zen.settings.impl.NumberSetting;
import shit.zen.utils.misc.ChatUtil;
public class WebUI extends Module {
private final NumberSetting port = new NumberSetting("Port", 8089, 1024, 65535, 1);
private final BooleanSetting openBrowser = new BooleanSetting("Open Browser", true);
private HttpServer httpServer;
public WebUI() {
super("WebUI", Category.WORLD);
this.setEnabled(false);
}
@Override
public void onEnable() {
ChatUtil.print("WebUI is unavailable in this build.");
this.setEnabled(false);
try {
this.httpServer = this.createHttpServer();
ChatUtil.print("WebUI started at http://127.0.0.1:" + this.port.getValue().intValue());
if (this.openBrowser.getValue() && mc.player != null) {
try {
System.setProperty("java.awt.headless", "false");
if (Desktop.isDesktopSupported()
&& Desktop.getDesktop().isSupported(Desktop.Action.BROWSE)) {
Desktop.getDesktop().browse(new URI("http://127.0.0.1:" + this.port.getValue().intValue()));
}
} catch (URISyntaxException | IOException ex) {
ChatUtil.print("Failed to open browser: " + ex.getMessage());
}
}
} catch (IOException ioException) {
ChatUtil.print("Failed to start http server because " + ioException.getMessage());
ioException.printStackTrace();
this.setEnabled(false);
}
}
@Override
public void onDisable() {
if (this.httpServer != null) {
this.httpServer.stop(0);
this.httpServer = null;
ChatUtil.print("WebUI stopped");
}
}
private HttpServer createHttpServer() throws IOException {
HttpServer server = HttpServer.create(new InetSocketAddress(this.port.getValue().intValue()), 0);
server.createContext("/api/modulesList", new ModulesHandler());
server.createContext("/api/categoriesList", new CategoriesHandler());
server.createContext("/api/setStatus", new ToggleModuleHandler());
server.createContext("/api/setModuleSettingValue", new SetSettingHandler());
server.createContext("/api/getModuleSetting", new SettingsHandler());
server.createContext("/", new StaticFileHandler("/webui", "/"));
server.start();
return server;
}
}
@@ -0,0 +1,50 @@
package shit.zen.network.webui;
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpHandler;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.OutputStreamWriter;
import java.io.PrintWriter;
import java.nio.charset.StandardCharsets;
public abstract class AbstractHttpHandler implements HttpHandler {
@Override
public final void handle(HttpExchange exchange) throws IOException {
ByteArrayOutputStream buffer = new ByteArrayOutputStream();
try {
int status = this.handleRequest(exchange.getRequestBody(), buffer, exchange);
if (buffer.size() == 0) {
this.sendResponse(status, buffer, exchange);
}
if (!(this instanceof StaticFileHandler)
&& exchange.getResponseHeaders().getFirst("Content-Type") == null) {
exchange.getResponseHeaders().add("Content-Type", "application/json; charset=UTF-8");
}
exchange.sendResponseHeaders(status, buffer.size());
if (buffer.size() != 0) {
exchange.getResponseBody().write(buffer.toByteArray());
}
} catch (Throwable throwable) {
int status = 500;
buffer = new ByteArrayOutputStream();
exchange.getResponseHeaders().add("Content-Type", "text/html; charset=utf-8");
OutputStreamWriter writer = new OutputStreamWriter(buffer, StandardCharsets.UTF_8);
writer.append("<!DOCTYPE html>\r\n<html>\r\n<head>\r\n\t<title>Sorry!</title>\r\n\t<meta charset=\"utf-8\">\r\n</head>\r\n<body>\r\n\t<h1>Sorry!</h1>\r\n\tThis request could not be completed due to an unexpected exception.<br/>\r\n\tHere is stack trace:\r\n\t<pre>");
throwable.printStackTrace(new PrintWriter(writer));
writer.append("</pre>\r\n</body>\r\n</html>");
writer.flush();
exchange.sendResponseHeaders(status, buffer.size());
exchange.getResponseBody().write(buffer.toByteArray());
}
exchange.close();
}
public abstract int handleRequest(InputStream in, OutputStream out, HttpExchange exchange) throws Throwable;
public void sendResponse(int status, OutputStream out, HttpExchange exchange) throws IOException {
}
}
@@ -0,0 +1,26 @@
package shit.zen.network.webui;
import com.google.gson.annotations.SerializedName;
import lombok.Getter;
@Getter
public class ActionEntry {
@SerializedName("a")
private final String name;
@SerializedName("b")
private final ActionType type;
@SerializedName("c")
private final double current;
@SerializedName("d")
private final double min;
@SerializedName("e")
private final double max;
public ActionEntry(String name, ActionType type, double current, double min, double max) {
this.name = name;
this.type = type;
this.current = current;
this.min = min;
this.max = max;
}
}
@@ -0,0 +1,12 @@
package shit.zen.network.webui;
import com.google.gson.annotations.SerializedName;
public enum ActionType {
@SerializedName("a")
BUTTON,
@SerializedName("b")
SLIDER,
@SerializedName("c")
TOGGLE
}
@@ -0,0 +1,32 @@
package shit.zen.network.webui;
import com.google.gson.Gson;
import com.sun.net.httpserver.HttpExchange;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.OutputStreamWriter;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import shit.zen.modules.Category;
public class CategoriesHandler extends AbstractHttpHandler {
@Override
public int handleRequest(InputStream in, OutputStream out, HttpExchange exchange) throws Throwable {
exchange.getResponseHeaders().add("Content-Type", "application/json; charset=UTF-8");
OutputStreamWriter writer = new OutputStreamWriter(out, StandardCharsets.UTF_8);
Map<String, Object> response = new HashMap<>();
List<String> categories = new ArrayList<>();
response.put("success", true);
response.put("result", categories);
for (Category category : Category.values()) {
categories.add(category.displayName);
}
writer.write(new Gson().toJson(response));
writer.flush();
return 200;
}
}
@@ -0,0 +1,52 @@
package shit.zen.network.webui;
import com.google.gson.Gson;
import com.sun.net.httpserver.HttpExchange;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.OutputStreamWriter;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.Map;
import shit.zen.ZenClient;
import shit.zen.modules.Category;
import shit.zen.modules.Module;
import shit.zen.utils.render.TextureUtil;
public class ModulesHandler extends AbstractHttpHandler {
private final Gson gson = new Gson();
@Override
public int handleRequest(InputStream in, OutputStream out, HttpExchange exchange) throws Throwable {
exchange.getResponseHeaders().add("Content-Type", "application/json; charset=UTF-8");
OutputStreamWriter writer = new OutputStreamWriter(out, StandardCharsets.UTF_8);
Map<String, Object> response = new HashMap<>();
response.put("success", true);
Category filter = null;
try {
Map<String, String> query = TextureUtil.parseQueryString(exchange.getRequestURI().getQuery());
String requested = query.get("category");
if (requested != null && !"null".equalsIgnoreCase(requested)) {
filter = Category.fromString(requested);
}
} catch (Throwable ignored) {
}
Map<String, Map<String, Object>> result = new HashMap<>();
for (Module module : ZenClient.getInstance().getModuleManager().getModules()) {
if (filter != null && module.getCategory() != filter) {
continue;
}
Map<String, Object> entry = new HashMap<>();
entry.put("desc", "");
entry.put("state", module.isEnabled());
entry.put("settings", !module.getSettings().isEmpty());
result.put(module.getName(), entry);
}
response.put("result", result);
writer.write(this.gson.toJson(response));
writer.flush();
return 200;
}
}
@@ -0,0 +1,98 @@
package shit.zen.network.webui;
import com.google.gson.Gson;
import com.sun.net.httpserver.HttpExchange;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
import java.util.stream.Stream;
import shit.zen.ZenClient;
import shit.zen.exception.ModuleNotFoundException;
import shit.zen.modules.Module;
import shit.zen.modules.impl.world.WebUI;
import shit.zen.settings.Setting;
import shit.zen.settings.impl.BooleanSetting;
import shit.zen.settings.impl.ModeSetting;
import shit.zen.settings.impl.NumberSetting;
import shit.zen.utils.render.TextureUtil;
public class SetSettingHandler extends AbstractHttpHandler {
@Override
@SuppressWarnings({ "unchecked", "rawtypes" })
public int handleRequest(InputStream in, OutputStream out, HttpExchange exchange) throws Throwable {
Map<String, String> query = TextureUtil.parseQueryString(exchange.getRequestURI().getQuery());
Map<String, Object> response = new HashMap<>();
boolean success = false;
String reason = null;
Object result = null;
if (query.containsKey("module") && query.containsKey("name") && query.containsKey("value")) {
try {
Module module = lookupModule(query.get("module"));
if (module == null) {
reason = "找不到模块";
} else if (module instanceof WebUI) {
reason = "sb";
} else {
String settingName = query.get("name");
Optional<Setting<?>> match = module.getSettings().stream()
.filter(setting -> setting.getName().equals(settingName))
.findFirst();
if (match.isEmpty()) {
reason = "找不到参数";
} else {
Setting setting = match.get();
String raw = query.get("value");
if (setting instanceof NumberSetting) {
try {
setting.setValue(Double.valueOf(raw));
success = true;
} catch (NumberFormatException ignored) {
}
} else if (setting instanceof BooleanSetting) {
setting.setValue(Boolean.valueOf(raw));
success = true;
} else if (setting instanceof ModeSetting modeSetting) {
String matchedMode = Stream.of(modeSetting.getModes())
.filter(mode -> mode.equals(raw))
.findFirst()
.orElse(null);
if (matchedMode != null) {
modeSetting.setValue(matchedMode);
success = true;
}
}
if (success) {
result = setting.getValue();
} else {
reason = "无效的值";
}
}
}
} catch (Throwable throwable) {
throwable.printStackTrace();
success = false;
reason = throwable.toString();
}
} else {
result = false;
reason = "参数不足";
}
response.put("success", success);
response.put("reason", reason);
response.put("result", result);
out.write(new Gson().toJson(response).getBytes(StandardCharsets.UTF_8));
return 200;
}
private static Module lookupModule(String name) {
try {
return ZenClient.getInstance().getModuleManager().getModule(name);
} catch (ModuleNotFoundException e) {
return null;
}
}
}
@@ -0,0 +1,81 @@
package shit.zen.network.webui;
import com.google.gson.Gson;
import com.sun.net.httpserver.HttpExchange;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import shit.zen.ZenClient;
import shit.zen.exception.ModuleNotFoundException;
import shit.zen.modules.Module;
import shit.zen.settings.Setting;
import shit.zen.settings.impl.BooleanSetting;
import shit.zen.settings.impl.ModeSetting;
import shit.zen.settings.impl.NumberSetting;
import shit.zen.utils.render.TextureUtil;
public class SettingsHandler extends AbstractHttpHandler {
@Override
public int handleRequest(InputStream in, OutputStream out, HttpExchange exchange) throws Throwable {
Map<String, String> query = TextureUtil.parseQueryString(exchange.getRequestURI().getQuery());
Map<String, Object> response = new HashMap<>();
boolean success = false;
String reason = null;
if (query.containsKey("module")) {
try {
Module module = lookupModule(query.get("module"));
if (module == null) {
reason = "找不到模块";
} else {
List<Setting<?>> settings = module.getSettings();
List<Map<String, Object>> entries = new ArrayList<>();
for (Setting<?> setting : settings) {
Map<String, Object> entry = new HashMap<>();
entry.put("name", setting.getName());
entry.put("displayName", setting.getName());
if (setting instanceof NumberSetting numberSetting) {
entry.put("type", "slider");
entry.put("max", numberSetting.getMax());
entry.put("min", numberSetting.getMin());
entry.put("step", numberSetting.getStep());
entry.put("value", numberSetting.getValue());
} else if (setting instanceof BooleanSetting) {
entry.put("type", "checkbox");
entry.put("value", setting.getValue());
} else if (setting instanceof ModeSetting modeSetting) {
entry.put("type", "selection");
entry.put("value", modeSetting.getValue());
entry.put("values", modeSetting.getModes());
}
entries.add(entry);
}
response.put("result", entries);
success = true;
}
} catch (Throwable throwable) {
throwable.printStackTrace();
success = false;
reason = throwable.toString();
}
} else {
reason = "参数不足";
}
response.put("success", success);
response.put("reason", reason);
out.write(new Gson().toJson(response).getBytes(StandardCharsets.UTF_8));
return 200;
}
private static Module lookupModule(String name) {
try {
return ZenClient.getInstance().getModuleManager().getModule(name);
} catch (ModuleNotFoundException e) {
return null;
}
}
}
@@ -0,0 +1,69 @@
package shit.zen.network.webui;
import com.sun.net.httpserver.HttpExchange;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.util.Locale;
public class StaticFileHandler extends AbstractHttpHandler {
private final String resourcePath;
private final String urlPrefix;
public StaticFileHandler(String resourcePath, String urlPrefix) {
this.resourcePath = resourcePath;
this.urlPrefix = urlPrefix;
}
@Override
public int handleRequest(InputStream in, OutputStream out, HttpExchange exchange) throws Throwable {
String requestPath = exchange.getRequestURI().getPath();
if (!requestPath.startsWith(this.urlPrefix)) {
return 404;
}
String relative = requestPath.substring(this.urlPrefix.length());
relative = URLDecoder.decode(relative, StandardCharsets.UTF_8);
if (relative.isEmpty() || relative.endsWith("/")) {
relative += "index.html";
}
String classpath = this.resourcePath + "/" + relative;
InputStream resource = StaticFileHandler.class.getResourceAsStream(classpath);
if (resource == null) {
return 404;
}
try (resource) {
exchange.getResponseHeaders().add("Content-Type", guessContentType(relative));
resource.transferTo(out);
}
return 200;
}
@Override
public void sendResponse(int status, OutputStream out, HttpExchange exchange) throws IOException {
InputStream resource = StaticFileHandler.class.getResourceAsStream(this.resourcePath + "/" + status + ".html");
if (resource != null) {
try (resource) {
exchange.getResponseHeaders().add("Content-Type", "text/html; charset=utf-8");
resource.transferTo(out);
}
}
}
private static String guessContentType(String path) {
String lower = path.toLowerCase(Locale.ROOT);
if (lower.endsWith(".html") || lower.endsWith(".htm")) return "text/html; charset=utf-8";
if (lower.endsWith(".css")) return "text/css; charset=utf-8";
if (lower.endsWith(".js")) return "application/javascript; charset=utf-8";
if (lower.endsWith(".json")) return "application/json; charset=utf-8";
if (lower.endsWith(".svg")) return "image/svg+xml";
if (lower.endsWith(".png")) return "image/png";
if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return "image/jpeg";
if (lower.endsWith(".ico")) return "image/x-icon";
if (lower.endsWith(".woff")) return "font/woff";
if (lower.endsWith(".woff2")) return "font/woff2";
if (lower.endsWith(".ttf")) return "font/ttf";
return "application/octet-stream";
}
}
@@ -0,0 +1,65 @@
package shit.zen.network.webui;
import com.google.gson.Gson;
import com.sun.net.httpserver.HttpExchange;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.Map;
import shit.zen.ZenClient;
import shit.zen.exception.ModuleNotFoundException;
import shit.zen.modules.Module;
import shit.zen.modules.impl.world.WebUI;
import shit.zen.utils.render.TextureUtil;
public class ToggleModuleHandler extends AbstractHttpHandler {
@Override
public int handleRequest(InputStream in, OutputStream out, HttpExchange exchange) throws Throwable {
Map<String, String> query = TextureUtil.parseQueryString(exchange.getRequestURI().getQuery());
Map<String, Object> response = new HashMap<>();
String reason = null;
boolean state;
boolean success;
if (query.containsKey("module") && query.containsKey("state")) {
try {
Module module = lookupModule(query.get("module"));
if (module == null) {
state = false;
success = false;
reason = "找不到模块";
} else if (module instanceof WebUI) {
state = true;
success = true;
} else {
module.setEnabled(Boolean.parseBoolean(query.get("state")));
state = module.isEnabled();
success = true;
}
} catch (Throwable throwable) {
throwable.printStackTrace();
state = false;
success = false;
reason = throwable.toString();
}
} else {
state = false;
success = false;
reason = "参数不足";
}
response.put("success", success);
response.put("reason", reason);
response.put("result", state);
out.write(new Gson().toJson(response).getBytes(StandardCharsets.UTF_8));
return 200;
}
private static Module lookupModule(String name) {
try {
return ZenClient.getInstance().getModuleManager().getModule(name);
} catch (ModuleNotFoundException e) {
return null;
}
}
}
View File