From 8daa2e842437f0c5dbaf4de4a9762d66d9154151 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9A=D0=B8=D1=80=D0=B8=D0=BB=D0=BB=20=D0=93=D1=80=D0=B8?= =?UTF-8?q?=D0=BD=D0=B5=D0=B2?= Date: Wed, 9 Sep 2026 00:20:20 +0500 Subject: [PATCH 01/16] Keep Rinku out of the server run once the client has run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fix in #33 was incomplete. It kept Rinku off the runtime classpath and installed it into the client run's mods folder instead — but both dev runs share versions//run, so once anyone has started the dev client, the jar sitting in mods/ is loaded by the dev server too and it dies exactly as before: ModLoadingException: Rinku (rinku) has failed to load correctly java.lang.NoClassDefFoundError: net/minecraft/client/gui/screens/Screen That is why it looked fixed: the run directories were untouched when I verified it, so mods/ was empty and the server booted. It breaks again the moment a developer runs the client first, which is always. Separate game directories per run would be tidier, but they move worlds, configs and log paths, and every tool around them. Deleting the jar before the server starts says what is actually meant — the server must not see Rinku — and touches nothing else. Refs #29 --- build.gradle | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/build.gradle b/build.gradle index 6cba9bf..c9a7378 100644 --- a/build.gradle +++ b/build.gradle @@ -81,6 +81,10 @@ if (sc_loader == "fabric") { into layout.projectDirectory.dir('run/mods') } + def removeRinkuForServerRun = tasks.register('removeRinkuForServerRun', Delete) { + delete fileTree(layout.projectDirectory.dir('run/mods')) { include 'rinku*.jar' } + } + neoForge { version = project.neoforge_version // Register the mod so moddev's JUnit runtime can load it during tests. @@ -107,6 +111,10 @@ if (sc_loader == "fabric") { } server { server() + // Both dev runs share versions//run, so the jar installed + // for the client is visible to the server too — which is what + // kills it. Take it back out before the server starts. + taskBefore(removeRinkuForServerRun) } } } From 4aa13c286b2588bdf3a33c794cab726468396dea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9A=D0=B8=D1=80=D0=B8=D0=BB=D0=BB=20=D0=93=D1=80=D0=B8?= =?UTF-8?q?=D0=BD=D0=B5=D0=B2?= Date: Wed, 9 Sep 2026 00:21:07 +0500 Subject: [PATCH 02/16] Add an opt-in custom death screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Set deathScreenUrl in server.json and the vanilla death screen is replaced by that page; leave it empty and nothing changes. Opt-in on purpose: replacing that screen takes away the player's only way to respawn, so it happens only when a server explicitly asks for it. The page is told what happened through a webgui:death event — killer (player, mob or environment), damage cause, the vanilla death message, and hardcore/canRespawn flags — and gets window.webgui.respawn() to stand in for the button it replaced. Where to hook in was the main design decision. The obvious target is the death packet handler, but that is onDeathMessage on Yarn and handlePlayerCombatKill on Mojang mappings and has moved between versions, which is the shape of target that has broken MouseMixin twice. Instead Fabric injects into MinecraftClient.setScreen, which has one signature across every Fabric target here, and NeoForge uses ScreenEvent.Opening and needs no mixin at all — so neither side carries a single version conditional, and 26.2 renaming setScreen to setScreenAndShow costs nothing. Three details that only showed up in a running game: The url is pushed on join, not at death. Deciding whether to suppress the vanilla screen has to happen the instant it opens, and learning the url at death time races the vanilla death packet. The death payload is held on the client and replayed after the document loads. It arrives while the page is still being created, so an immediate emit lands before any listener exists. Respawn does not clear the screen itself. The client still counts as dead until the server answers, so setScreen(null) makes vanilla reopen the death screen, which this very interceptor then replaces with a second browser nothing closes. Vanilla closes the screen on its own once the respawn lands. The screen is also left alone while Chromium is still starting. WebViewScreen closes itself when there is no browser, vanilla reopens the death screen because the player is still dead, and replacing it again recursed until the client crashed — reachable by dying in the first seconds of a session. Verified in a running game on all seven targets: real dedicated server, join, death, screen replacement, killer data in the page, respawn from the page, and the browser actually released afterwards. --- CHANGELOG.md | 9 ++ build.gradle | 12 +- src/main/java/land/webgui/WebGUIClient.java | 56 ++++++++ .../java/land/webgui/WebGUIDeathScreen.java | 84 +++++++++++ src/main/java/land/webgui/WebGUIMod.java | 2 + src/main/java/land/webgui/WebViewScreen.java | 21 ++- .../java/land/webgui/WebviewCommands.java | 10 ++ src/main/java/land/webgui/WebviewJoinHud.java | 8 ++ .../java/land/webgui/WebviewNetworking.java | 29 ++++ .../land/webgui/WebviewPageLoadHooks.java | 17 +++ .../webgui/WebviewPageToClientBridge.java | 33 +++++ .../java/land/webgui/WebviewPayloads.java | 46 ++++++ .../java/land/webgui/WebviewScriptInject.java | 5 + .../land/webgui/mixin/DeathScreenMixin.java | 52 +++++++ .../webgui/server/WebviewDeathListener.java | 131 ++++++++++++++++++ .../webgui/server/WebviewServerConfig.java | 16 +++ src/main/resources/fabric.mod.json | 5 +- src/main/resources/webgui.fabric.mixins.json | 12 ++ 18 files changed, 541 insertions(+), 7 deletions(-) create mode 100644 src/main/java/land/webgui/WebGUIDeathScreen.java create mode 100644 src/main/java/land/webgui/mixin/DeathScreenMixin.java create mode 100644 src/main/java/land/webgui/server/WebviewDeathListener.java create mode 100644 src/main/resources/webgui.fabric.mixins.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 19d2932..c74e5eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## Unreleased + +### Added +- **Custom death screen.** Set `deathScreenUrl` in `config/webgui/server.json` and the vanilla death screen is replaced by your page. Leave it empty and nothing changes — the feature is opt-in because replacing that screen takes away the player's only way to respawn. + - The page receives a `webgui:death` event describing what happened: `killer` (`{type: "player" | "mob" | "environment", name, uuid}`), the damage `cause`, the vanilla `deathMessage`, and `hardcore` / `canRespawn` flags. + - `window.webgui.respawn()` respawns the player, standing in for the vanilla Respawn button. + - Escape does not close the page, exactly as the vanilla screen refuses to close. If the page fails to load, Escape starts working again and respawns instead, so a broken page can never trap a player. + - The vanilla screen is left alone while the browser is still starting, so dying during the first seconds of a session shows the normal screen rather than nothing. + ## 1.7.0 - 2026-09-08 ### Changed diff --git a/build.gradle b/build.gradle index c9a7378..c610db5 100644 --- a/build.gradle +++ b/build.gradle @@ -111,9 +111,9 @@ if (sc_loader == "fabric") { } server { server() - // Both dev runs share versions//run, so the jar installed - // for the client is visible to the server too — which is what - // kills it. Take it back out before the server starts. + // Both dev runs share versions//run, so the jar dropped in + // for the client is also visible to the server — which is exactly + // what kills it. Take it back out before the server starts. taskBefore(removeRinkuForServerRun) } } @@ -218,8 +218,12 @@ processResources { expand version: project.version, minecraft_range: project.minecraft_range } } else { - // Likewise, keep Fabric metadata out of the NeoForge jar. + // Likewise, keep Fabric metadata out of the NeoForge jar. The death-screen + // mixin goes with it: NeoForge replaces the screen through ScreenEvent + // instead, and the mixin targets Yarn's MinecraftClient, which does not + // exist under Mojang mappings. exclude 'fabric.mod.json' + exclude 'webgui.fabric.mixins.json' filesMatching('META-INF/neoforge.mods.toml') { expand version: project.version, minecraft_range: project.minecraft_range } diff --git a/src/main/java/land/webgui/WebGUIClient.java b/src/main/java/land/webgui/WebGUIClient.java index 6b41065..db3b7d2 100644 --- a/src/main/java/land/webgui/WebGUIClient.java +++ b/src/main/java/land/webgui/WebGUIClient.java @@ -11,6 +11,8 @@ import net.neoforged.bus.api.IEventBus; import net.neoforged.neoforge.client.event.ClientPlayerNetworkEvent; import net.neoforged.neoforge.client.event.ClientTickEvent; +import net.neoforged.neoforge.client.event.ScreenEvent; +import net.minecraft.client.gui.screens.DeathScreen; import net.neoforged.neoforge.common.NeoForge; import net.neoforged.neoforge.network.event.RegisterPayloadHandlersEvent;*/ //? } @@ -58,6 +60,10 @@ public void onInitializeClient() { ClientPlayNetworking.registerGlobalReceiver(WebviewPayloads.WebviewTrustedOriginsS2CPayload.ID, (payload, context) -> { context.client().execute(() -> WebGUITrustedOrigins.set(payload.origins())); }); + + ClientPlayNetworking.registerGlobalReceiver(WebviewPayloads.WebviewDeathS2CPayload.ID, (payload, context) -> { + context.client().execute(() -> onDeathPayload(payload.url(), payload.infoJson())); + }); //? } else { /*ClientPlayNetworking.registerGlobalReceiver(WebviewPayloads.OPEN_WEB_CHANNEL, (client, handler, buf, responseSender) -> { int protocolVersion = buf.readVarInt(); @@ -83,6 +89,12 @@ public void onInitializeClient() { ClientPlayNetworking.registerGlobalReceiver(WebviewPayloads.TRUSTED_ORIGINS_CHANNEL, (client, handler, buf, responseSender) -> { String origins = buf.readString(WebviewPayloads.MAX_EVENT_DATA_LENGTH); client.execute(() -> WebGUITrustedOrigins.set(origins)); + }); + + ClientPlayNetworking.registerGlobalReceiver(WebviewPayloads.DEATH_SCREEN_CHANNEL, (client, handler, buf, responseSender) -> { + String url = buf.readString(WebviewNetworking.MAX_URL_LENGTH); + String info = buf.readString(WebviewPayloads.MAX_EVENT_DATA_LENGTH); + client.execute(() -> onDeathPayload(url, info)); });*/ //? } @@ -105,6 +117,19 @@ private static void onLeaveWorld() { WebSession.dispose(); WebGUIMainMenuUrl.setUrl(""); WebGUITrustedOrigins.clear(); + WebGUIDeathScreen.clear(); + } + + /** + * A url with no info is the join-time push; an info payload means the player + * just died. Both arrive on the same channel because the client needs the url + * in hand before the death, not alongside it. + */ + static void onDeathPayload(String url, String infoJson) { + WebGUIDeathScreen.setUrl(url); + if (infoJson != null && !infoJson.isBlank()) { + WebGUIDeathScreen.setInfo(infoJson); + } } private static void handleOpenPayload(net.minecraft.client.MinecraftClient client, int mode, String url) { @@ -140,6 +165,7 @@ private static void handleOpenPayload(net.minecraft.client.MinecraftClient clien WebHudOverlay.register(); NeoForge.EVENT_BUS.addListener(WebGUIClient::onClientTick); NeoForge.EVENT_BUS.addListener(WebGUIClient::onLoggingOut); + NeoForge.EVENT_BUS.addListener(WebGUIClient::onScreenOpening); } private static void onLoggingOut(ClientPlayerNetworkEvent.LoggingOut event) { @@ -153,6 +179,17 @@ private static void onLeaveWorld() { WebSession.dispose(); WebGUIMainMenuUrl.setUrl(""); WebGUITrustedOrigins.clear(); + WebGUIDeathScreen.clear(); + } + + // A url with no info is the join-time push; an info payload means the player + // just died. Both share a channel because the client needs the url in hand + // before the death rather than alongside it. + static void onDeathPayload(String url, String infoJson) { + WebGUIDeathScreen.setUrl(url); + if (infoJson != null && !infoJson.isBlank()) { + WebGUIDeathScreen.setInfo(infoJson); + } } // Called only on the client (from WebviewNetworking.registerPayloadTypes) so @@ -172,6 +209,25 @@ public static void registerClientReceivers(RegisterPayloadHandlersEvent event) { (payload, ctx) -> ctx.enqueueWork(() -> WebviewClientBridge.setEntityContext(payload.entityJson()))); reg.playToClient(WebviewPayloads.WebviewTrustedOriginsS2CPayload.TYPE, WebviewPayloads.WebviewTrustedOriginsS2CPayload.STREAM_CODEC, (payload, ctx) -> ctx.enqueueWork(() -> WebGUITrustedOrigins.set(payload.origins()))); + reg.playToClient(WebviewPayloads.WebviewDeathS2CPayload.TYPE, WebviewPayloads.WebviewDeathS2CPayload.STREAM_CODEC, + (payload, ctx) -> ctx.enqueueWork(() -> onDeathPayload(payload.url(), payload.infoJson()))); + } + + // Swaps the vanilla death screen for the server's page. Fabric needs a mixin + // for this; NeoForge hands us the screen before it opens, which also spares + // us Mojang mappings renaming setScreen to setScreenAndShow in 26.2. + private static void onScreenOpening(ScreenEvent.Opening event) { + if (!(event.getNewScreen() instanceof DeathScreen) || !WebGUIDeathScreen.configured()) { + return; + } + // No browser, no page. WebViewScreen closes itself when Chromium is not + // ready, vanilla reopens the death screen because the player is still + // dead, and we would replace it again — a loop that crashes the client. + if (!Rinku.isInitialized()) { + return; + } + WebGUIDeathScreen.setActive(true); + event.setNewScreen(new WebViewScreen(WebGUIDeathScreen.url())); } private static void onClientTick(ClientTickEvent.Post event) { diff --git a/src/main/java/land/webgui/WebGUIDeathScreen.java b/src/main/java/land/webgui/WebGUIDeathScreen.java new file mode 100644 index 0000000..729427d --- /dev/null +++ b/src/main/java/land/webgui/WebGUIDeathScreen.java @@ -0,0 +1,84 @@ +package land.webgui; + +/** + * Client-side state for the custom death screen. + * + * The URL arrives on join, long before anyone dies, because the decision to + * suppress the vanilla screen has to be made the instant Minecraft tries to + * open it. Learning the URL at death time would be a race against the vanilla + * death packet, and losing that race means the player sees the wrong screen. + * + * The info JSON arrives with the death itself and is held here rather than + * pushed straight at the page: the page is only just being created at that + * point, so an immediate emit would land before any listener exists. It is + * replayed once the document has loaded instead. + */ +public final class WebGUIDeathScreen { + + private static String url = ""; + private static String pendingInfo = ""; + private static boolean active; + private static boolean loadFailed; + + private WebGUIDeathScreen() {} + + public static void setUrl(String newUrl) { + url = newUrl == null ? "" : newUrl.trim(); + } + + public static String url() { + return url; + } + + /** Whether the server asked for a custom death screen at all. */ + public static boolean configured() { + return !url.isBlank(); + } + + public static void setInfo(String json) { + pendingInfo = (json == null || json.isBlank()) ? "null" : json; + } + + + /** The death payload to hand the page, or null when there is nothing to replay. */ + public static String info() { + return pendingInfo.isBlank() ? null : pendingInfo; + } + + /** + * True while our page is standing in for the vanilla death screen. Guards the + * respawn channel so a page cannot respawn a player who is alive, and lets the + * screen refuse to close on Escape the way the vanilla one does. + */ + public static boolean active() { + return active; + } + + public static void setActive(boolean value) { + active = value; + if (!value) { + loadFailed = false; + } + } + + /** + * Set when the death page fails to load. Without an escape hatch a broken or + * unreachable page would trap the player on a blank screen with no way to + * respawn, which is worse than having no custom death screen at all. + */ + public static boolean loadFailed() { + return loadFailed; + } + + public static void setLoadFailed(boolean value) { + loadFailed = value; + } + + /** Called on disconnect: none of this may carry across servers. */ + public static void clear() { + url = ""; + pendingInfo = ""; + active = false; + loadFailed = false; + } +} diff --git a/src/main/java/land/webgui/WebGUIMod.java b/src/main/java/land/webgui/WebGUIMod.java index e77910b..f93d6d1 100644 --- a/src/main/java/land/webgui/WebGUIMod.java +++ b/src/main/java/land/webgui/WebGUIMod.java @@ -43,6 +43,7 @@ public void onInitialize() { }); WebviewCommands.register(); WebviewJoinHud.register(); + land.webgui.server.WebviewDeathListener.register(); LOGGER.info("WebGUI common init (S2C payloads, commands)."); } //? } else { @@ -53,6 +54,7 @@ public void onInitialize() { EntityInteractionListener.register(); WebviewCommands.register(); WebviewJoinHud.register(); + land.webgui.server.WebviewDeathListener.register(); NeoForge.EVENT_BUS.addListener(this::onServerStarting); NeoForge.EVENT_BUS.addListener(this::onServerStarted); //? if >=1.21.5 { diff --git a/src/main/java/land/webgui/WebViewScreen.java b/src/main/java/land/webgui/WebViewScreen.java index 8a88506..1a8a12d 100644 --- a/src/main/java/land/webgui/WebViewScreen.java +++ b/src/main/java/land/webgui/WebViewScreen.java @@ -159,6 +159,13 @@ public void resize(net.minecraft.client.Minecraft minecraft, int width, int heig @Override public void close() { guiPageReady = false; + // Escaping a death page that never loaded has to respawn, or the player + // is dropped into the world still dead with nothing to click. + boolean respawnOnClose = WebGUIDeathScreen.active() && WebGUIDeathScreen.loadFailed(); + WebGUIDeathScreen.setActive(false); + if (respawnOnClose && this.client != null && this.client.player != null) { + this.client.player.requestRespawn(); + } WebSession.closeGuiAndRestoreHud(); super.close(); if (this.client != null) { @@ -169,6 +176,13 @@ public void close() { /*@Override public void onClose() { guiPageReady = false; + // Escaping a death page that never loaded has to respawn, or the player + // is dropped into the world still dead with nothing to click. + boolean respawnOnClose = WebGUIDeathScreen.active() && WebGUIDeathScreen.loadFailed(); + WebGUIDeathScreen.setActive(false); + if (respawnOnClose && this.minecraft != null && this.minecraft.player != null) { + this.minecraft.player.respawn(); + } WebSession.closeGuiAndRestoreHud(); super.onClose(); if (this.minecraft != null) { @@ -472,8 +486,13 @@ public boolean shouldPause() { } //? } + /** + * Escape closes a normal web GUI, but not one standing in for the death + * screen — vanilla does not let a dead player walk away from it either, and + * doing so would drop them into the world with no way back to respawn. + */ @Override public boolean shouldCloseOnEsc() { - return true; + return !WebGUIDeathScreen.active() || WebGUIDeathScreen.loadFailed(); } } diff --git a/src/main/java/land/webgui/WebviewCommands.java b/src/main/java/land/webgui/WebviewCommands.java index 902b31e..e260941 100644 --- a/src/main/java/land/webgui/WebviewCommands.java +++ b/src/main/java/land/webgui/WebviewCommands.java @@ -131,6 +131,11 @@ public static void register() { .executes(ctx -> { String msg = WebviewServerConfig.reload(); EntityBindingStore.load(); + // The death page url lives on the client, so a reload + // has to re-push it or online players keep the old one. + for (ServerPlayerEntity p : ctx.getSource().getServer().getPlayerManager().getPlayerList()) { + WebviewNetworking.sendDeathScreen(p, WebviewServerConfig.deathScreenUrl(), ""); + } ctx.getSource().sendFeedback(() -> Text.literal(msg), true); return 1; })) @@ -199,6 +204,11 @@ private static void onRegisterCommands(RegisterCommandsEvent event) { .executes(ctx -> { String msg = WebviewServerConfig.reload(); EntityBindingStore.load(); + // The death page url lives on the client, so a reload has to + // re-push it or online players keep the old one. + for (ServerPlayer p : ctx.getSource().getServer().getPlayerList().getPlayers()) { + WebviewNetworking.sendDeathScreen(p, WebviewServerConfig.deathScreenUrl(), ""); + } ctx.getSource().sendSuccess(() -> Component.literal(msg), true); return 1; })) diff --git a/src/main/java/land/webgui/WebviewJoinHud.java b/src/main/java/land/webgui/WebviewJoinHud.java index d414ca9..819af61 100644 --- a/src/main/java/land/webgui/WebviewJoinHud.java +++ b/src/main/java/land/webgui/WebviewJoinHud.java @@ -25,6 +25,14 @@ public static void register() { WebviewNetworking.sendTrustedOrigins(player, WebviewServerConfig.trustedCommandOriginsJoined()); + // Pushed now, not at death: the client has to know whether to suppress the + // vanilla death screen the instant it is opened. + WebviewNetworking.sendDeathScreen(player, WebviewServerConfig.deathScreenUrl(), ""); + + // Pushed now, not at death: the client has to know whether to + // suppress the vanilla death screen the instant it is opened. + WebviewNetworking.sendDeathScreen(player, WebviewServerConfig.deathScreenUrl(), ""); + String mainMenuUrl = WebviewServerConfig.mainMenuUrl(); if (!mainMenuUrl.isEmpty()) { WebviewNetworking.sendMainMenuUrl(player, mainMenuUrl); diff --git a/src/main/java/land/webgui/WebviewNetworking.java b/src/main/java/land/webgui/WebviewNetworking.java index 9ad8840..839b948 100644 --- a/src/main/java/land/webgui/WebviewNetworking.java +++ b/src/main/java/land/webgui/WebviewNetworking.java @@ -37,6 +37,7 @@ public static void registerPayloadTypes() { PayloadTypeRegistry.playS2C().register(WebviewPayloads.WebviewEmitS2CPayload.ID, WebviewPayloads.WebviewEmitS2CPayload.CODEC); PayloadTypeRegistry.playS2C().register(WebviewPayloads.WebviewEntityContextS2CPayload.ID, WebviewPayloads.WebviewEntityContextS2CPayload.CODEC); PayloadTypeRegistry.playS2C().register(WebviewPayloads.WebviewTrustedOriginsS2CPayload.ID, WebviewPayloads.WebviewTrustedOriginsS2CPayload.CODEC); + PayloadTypeRegistry.playS2C().register(WebviewPayloads.WebviewDeathS2CPayload.ID, WebviewPayloads.WebviewDeathS2CPayload.CODEC); PayloadTypeRegistry.playC2S().register(WebviewPayloads.WebviewPageEventC2SPayload.ID, WebviewPayloads.WebviewPageEventC2SPayload.CODEC); //? } } @@ -71,6 +72,8 @@ public static void registerPayloadTypes() { WebviewPayloads.WebviewEntityContextS2CPayload.STREAM_CODEC, (payload, ctx) -> {}); reg.playToClient(WebviewPayloads.WebviewTrustedOriginsS2CPayload.TYPE, WebviewPayloads.WebviewTrustedOriginsS2CPayload.STREAM_CODEC, (payload, ctx) -> {}); + reg.playToClient(WebviewPayloads.WebviewDeathS2CPayload.TYPE, + WebviewPayloads.WebviewDeathS2CPayload.STREAM_CODEC, (payload, ctx) -> {}); } }); }*/ @@ -175,6 +178,26 @@ public static void sendMainMenuUrl(ServerPlayerEntity player, String url) { //? } } + /** + * Tells the client which page replaces the vanilla death screen, and — when the + * player has just died — what killed them. + * + * The URL carries a per-player token like any other page the mod opens, so the + * death page can verify who it is talking about. + */ + public static void sendDeathScreen(ServerPlayerEntity player, String url, String infoJson) { + String u = (url == null || url.isBlank()) ? "" : withPlayerToken(player, url); + String info = (infoJson == null || infoJson.isBlank()) ? "" : sanitizeStr(infoJson, WebviewPayloads.MAX_EVENT_DATA_LENGTH); + //? if >=1.20.5 { + ServerPlayNetworking.send(player, new WebviewPayloads.WebviewDeathS2CPayload(u, info)); + //? } else { + /*PacketByteBuf buf = PacketByteBufs.create(); + buf.writeString(u, MAX_URL_LENGTH); + buf.writeString(info, WebviewPayloads.MAX_EVENT_DATA_LENGTH); + ServerPlayNetworking.send(player, WebviewPayloads.DEATH_SCREEN_CHANNEL, buf);*/ + //? } + } + public static void sendTrustedOrigins(ServerPlayerEntity player, String origins) { String o = origins == null ? "" : origins; //? if >=1.20.5 { @@ -231,6 +254,12 @@ public static void sendMainMenuUrl(ServerPlayer player, String url) { PacketDistributor.sendToPlayer(player, new WebviewPayloads.WebUIMainMenuPayload(sanitizeUrl(url))); } + public static void sendDeathScreen(ServerPlayer player, String url, String infoJson) { + String u = (url == null || url.isBlank()) ? "" : withPlayerToken(player, url); + String info = (infoJson == null || infoJson.isBlank()) ? "" : sanitizeStr(infoJson, WebviewPayloads.MAX_EVENT_DATA_LENGTH); + PacketDistributor.sendToPlayer(player, new WebviewPayloads.WebviewDeathS2CPayload(u, info)); + } + public static void sendTrustedOrigins(ServerPlayer player, String origins) { PacketDistributor.sendToPlayer(player, new WebviewPayloads.WebviewTrustedOriginsS2CPayload(origins == null ? "" : origins)); } diff --git a/src/main/java/land/webgui/WebviewPageLoadHooks.java b/src/main/java/land/webgui/WebviewPageLoadHooks.java index 6130257..e645cbe 100644 --- a/src/main/java/land/webgui/WebviewPageLoadHooks.java +++ b/src/main/java/land/webgui/WebviewPageLoadHooks.java @@ -46,6 +46,14 @@ public void onLoadEnd(CefBrowser browser, CefFrame frame, int httpStatusCode) { mc.execute(() -> WebviewClientBridge.pushAfterDocumentLoad(mc)); } + // The death payload lands while this page is still being created, so + // an immediate emit would arrive before any listener exists. Replay it + // now that the document is up. + String death = WebGUIDeathScreen.info(); + if (death != null && WebGUIDeathScreen.active()) { + WebviewClientEmit.dispatch("death", death); + } + if (WebSession.mode() == WebSession.Mode.HUD_OVERLAY) { WebHudOverlay.onHudBrowserLoadFinished(active); } else if (WebSession.mode() == WebSession.Mode.GUI_SCREEN) { @@ -59,6 +67,15 @@ public void onLoadError(CefBrowser browser, CefFrame frame, String errorText, String failedUrl) { RinkuBrowser active = WebSession.browser(); if (active == null || browser != active) return; + + // A death page that will not load would otherwise leave the player + // staring at nothing with no way to respawn, so let Escape out. + if (WebGUIDeathScreen.active()) { + WebGUIDeathScreen.setLoadFailed(true); + WebGUIMod.LOGGER.warn("webgui: death page failed to load ({}): {} — Escape will respawn instead", + errorText, failedUrl); + } + if (WebSession.mode() == WebSession.Mode.HUD_OVERLAY) { WebHudOverlay.onHudBrowserLoadFinished(active); } else if (WebSession.mode() == WebSession.Mode.GUI_SCREEN) { diff --git a/src/main/java/land/webgui/WebviewPageToClientBridge.java b/src/main/java/land/webgui/WebviewPageToClientBridge.java index f1abac3..80ef5a1 100644 --- a/src/main/java/land/webgui/WebviewPageToClientBridge.java +++ b/src/main/java/land/webgui/WebviewPageToClientBridge.java @@ -98,6 +98,39 @@ private static void dispatch(CefFrame frame, String request, CefQueryCallback ca } }); } + // Replacing the death screen takes away the vanilla Respawn button, so + // the page has to be able to press it. Only honoured while our screen is + // actually standing in for the death screen — otherwise any page could + // respawn a living player. + // Replacing the death screen takes away the vanilla Respawn button, so + // the page has to be able to press it. Only honoured while our screen is + // actually standing in for the death screen — otherwise any page could + // respawn a living player. + case "respawn" -> { + //? if fabric { + MinecraftClient mc = MinecraftClient.getInstance(); + //? } else { + /*Minecraft mc = Minecraft.getInstance();*/ + //? } + mc.execute(() -> { + if (!WebGUIDeathScreen.active() || mc.player == null) { + return; + } + WebGUIDeathScreen.setActive(false); + // Respawn first, and never clear the screen ourselves. The + // client still counts as dead until the server answers, so + // setScreen(null) makes vanilla reopen the death screen right + // away — which this very interceptor then replaces with a + // second browser nothing closes. Vanilla closes the screen on + // its own once the respawn lands. + //? if fabric { + mc.player.requestRespawn(); + //? } else { + /*mc.player.respawn();*/ + //? } + WebSession.dispose(); + }); + } case "command" -> { String cmd = obj.has("command") && !obj.get("command").isJsonNull() ? obj.get("command").getAsString() : null; diff --git a/src/main/java/land/webgui/WebviewPayloads.java b/src/main/java/land/webgui/WebviewPayloads.java index 4636d22..9806514 100644 --- a/src/main/java/land/webgui/WebviewPayloads.java +++ b/src/main/java/land/webgui/WebviewPayloads.java @@ -37,6 +37,7 @@ private WebviewPayloads() {} public static final Identifier PAGE_EVENT_CHANNEL = Identifier.of(WebGUIMod.MOD_ID, "page_event"); public static final Identifier ENTITY_CONTEXT_CHANNEL = Identifier.of(WebGUIMod.MOD_ID, "entity_context"); public static final Identifier TRUSTED_ORIGINS_CHANNEL = Identifier.of(WebGUIMod.MOD_ID, "trusted_origins"); + public static final Identifier DEATH_SCREEN_CHANNEL = Identifier.of(WebGUIMod.MOD_ID, "death_screen"); //? } else { /*//? if >=1.21.5 { public static final Identifier OPEN_WEB_CHANNEL = Identifier.fromNamespaceAndPath(WebGUIMod.MOD_ID, "open_web"); @@ -45,6 +46,7 @@ private WebviewPayloads() {} public static final Identifier PAGE_EVENT_CHANNEL = Identifier.fromNamespaceAndPath(WebGUIMod.MOD_ID, "page_event"); public static final Identifier ENTITY_CONTEXT_CHANNEL = Identifier.fromNamespaceAndPath(WebGUIMod.MOD_ID, "entity_context"); public static final Identifier TRUSTED_ORIGINS_CHANNEL = Identifier.fromNamespaceAndPath(WebGUIMod.MOD_ID, "trusted_origins"); + public static final Identifier DEATH_SCREEN_CHANNEL = Identifier.fromNamespaceAndPath(WebGUIMod.MOD_ID, "death_screen"); //? } else { public static final ResourceLocation OPEN_WEB_CHANNEL = ResourceLocation.fromNamespaceAndPath(WebGUIMod.MOD_ID, "open_web"); public static final ResourceLocation MAIN_MENU_CHANNEL = ResourceLocation.fromNamespaceAndPath(WebGUIMod.MOD_ID, "set_main_menu"); @@ -52,6 +54,7 @@ private WebviewPayloads() {} public static final ResourceLocation PAGE_EVENT_CHANNEL = ResourceLocation.fromNamespaceAndPath(WebGUIMod.MOD_ID, "page_event"); public static final ResourceLocation ENTITY_CONTEXT_CHANNEL = ResourceLocation.fromNamespaceAndPath(WebGUIMod.MOD_ID, "entity_context"); public static final ResourceLocation TRUSTED_ORIGINS_CHANNEL = ResourceLocation.fromNamespaceAndPath(WebGUIMod.MOD_ID, "trusted_origins"); + public static final ResourceLocation DEATH_SCREEN_CHANNEL = ResourceLocation.fromNamespaceAndPath(WebGUIMod.MOD_ID, "death_screen"); //? }*/ //? } @@ -135,6 +138,30 @@ public Id getId() { } } + /** + * S2C: the page that replaces the vanilla death screen, plus what killed the player. + * + * The url is pushed on join and on config reload with an empty info field, so the + * client already knows whether to suppress the vanilla screen by the time someone + * dies — deciding that at death time would race the vanilla death packet. On death + * the same payload carries the info JSON. + */ + public record WebviewDeathS2CPayload(String url, String infoJson) implements CustomPayload { + public static final CustomPayload.Id ID = + new CustomPayload.Id<>(DEATH_SCREEN_CHANNEL); + public static final PacketCodec CODEC = PacketCodec.tuple( + PacketCodecs.string(WebviewNetworking.MAX_URL_LENGTH), + WebviewDeathS2CPayload::url, + PacketCodecs.string(MAX_EVENT_DATA_LENGTH), + WebviewDeathS2CPayload::infoJson, + WebviewDeathS2CPayload::new); + + @Override + public Id getId() { + return ID; + } + } + /** S2C: newline-joined origins whose pages may run commands as the player. */ public record WebviewTrustedOriginsS2CPayload(String origins) implements CustomPayload { public static final CustomPayload.Id ID = @@ -219,6 +246,25 @@ public record WebviewEntityContextS2CPayload(String entityJson) implements Custo public CustomPacketPayload.Type type() { return TYPE; } } + // S2C: the page that replaces the vanilla death screen, plus what killed the player. + // The url arrives on join and on config reload with an empty info field so the client + // already knows whether to suppress the vanilla screen before anyone dies; deciding + // that at death time would race the vanilla death packet. + public record WebviewDeathS2CPayload(String url, String infoJson) implements CustomPacketPayload { + public static final CustomPacketPayload.Type TYPE = + new CustomPacketPayload.Type<>(DEATH_SCREEN_CHANNEL); + public static final StreamCodec STREAM_CODEC = + StreamCodec.composite( + ByteBufCodecs.stringUtf8(WebviewNetworking.MAX_URL_LENGTH), + WebviewDeathS2CPayload::url, + ByteBufCodecs.stringUtf8(MAX_EVENT_DATA_LENGTH), + WebviewDeathS2CPayload::infoJson, + WebviewDeathS2CPayload::new); + + @Override + public CustomPacketPayload.Type type() { return TYPE; } + } + // S2C: newline-joined origins whose pages may run commands as the player. public record WebviewTrustedOriginsS2CPayload(String origins) implements CustomPacketPayload { public static final CustomPacketPayload.Type TYPE = diff --git a/src/main/java/land/webgui/WebviewScriptInject.java b/src/main/java/land/webgui/WebviewScriptInject.java index 9590320..b4015c1 100644 --- a/src/main/java/land/webgui/WebviewScriptInject.java +++ b/src/main/java/land/webgui/WebviewScriptInject.java @@ -27,6 +27,11 @@ public static String bridgeSetup() { window.webgui.postToGame({ channel: 'close' }); }; } + if (typeof window.webgui.respawn !== 'function') { + window.webgui.respawn = function () { + window.webgui.postToGame({ channel: 'respawn' }); + }; + } if (typeof window.webgui.on !== 'function') { window.webgui._hs = window.webgui._hs || {}; window.webgui.on = function (name, fn) { diff --git a/src/main/java/land/webgui/mixin/DeathScreenMixin.java b/src/main/java/land/webgui/mixin/DeathScreenMixin.java new file mode 100644 index 0000000..934a338 --- /dev/null +++ b/src/main/java/land/webgui/mixin/DeathScreenMixin.java @@ -0,0 +1,52 @@ +package land.webgui.mixin; + +// Fabric only. NeoForge replaces the screen through ScreenEvent.Opening, and +// this class targets Yarn types that do not exist under Mojang mappings, so +// the whole body is compiled out there. +//? if fabric { +import de.keksuccino.rinku.Rinku; +import land.webgui.WebGUIDeathScreen; +import land.webgui.WebViewScreen; +import net.minecraft.client.MinecraftClient; +import net.minecraft.client.gui.screen.DeathScreen; +import net.minecraft.client.gui.screen.Screen; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.ModifyVariable; +import org.spongepowered.asm.mixin.injection.At; + +/** + * Swaps the vanilla death screen for the server's page, on Fabric. + * + * Injecting into setScreen rather than the death packet handler is deliberate. + * That handler is onDeathMessage on Yarn and handlePlayerCombatKill on Mojang + * mappings and has moved between versions — exactly the shape of target that + * has broken MouseMixin twice. {@code setScreen(Screen)} has one signature + * across every Fabric target this mod builds for, so this mixin carries no + * version conditionals, and it catches the screen whichever code path opened + * it. + * + * Rewriting the argument rather than cancelling and calling setScreen again + * keeps it out of a re-entrant call into the method it is injecting into. + * + * NeoForge needs no mixin here: it has ScreenEvent.Opening. + */ +@Mixin(MinecraftClient.class) +public class DeathScreenMixin { + + @ModifyVariable(method = "setScreen", at = @At("HEAD"), argsOnly = true) + private Screen webgui$replaceDeathScreen(Screen screen) { + if (!(screen instanceof DeathScreen) || !WebGUIDeathScreen.configured()) { + return screen; + } + // No browser, no page. WebViewScreen closes itself when Chromium is not + // ready, vanilla reopens the death screen because the player is still + // dead, and we would replace it again — a loop that crashes the client. + // Dying before Chromium finishes starting is exactly when that happens. + if (!Rinku.isInitialized()) { + return screen; + } + WebGUIDeathScreen.setActive(true); + return new WebViewScreen(WebGUIDeathScreen.url()); + } +} +//? } diff --git a/src/main/java/land/webgui/server/WebviewDeathListener.java b/src/main/java/land/webgui/server/WebviewDeathListener.java new file mode 100644 index 0000000..a86d6df --- /dev/null +++ b/src/main/java/land/webgui/server/WebviewDeathListener.java @@ -0,0 +1,131 @@ +package land.webgui.server; + +import com.google.gson.JsonObject; +import land.webgui.WebviewNetworking; +//? if fabric { +import net.fabricmc.fabric.api.entity.event.v1.ServerLivingEntityEvents; +import net.minecraft.entity.Entity; +import net.minecraft.entity.LivingEntity; +import net.minecraft.entity.damage.DamageSource; +import net.minecraft.server.network.ServerPlayerEntity; +//? } else { +/*import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.damagesource.DamageSource; +import net.minecraft.server.level.ServerPlayer; +import net.neoforged.neoforge.common.NeoForge; +import net.neoforged.neoforge.event.entity.living.LivingDeathEvent;*/ +//? } + +/** + * Reports a player's death to their custom death page. + * + * Runs only when the server configured {@code deathScreenUrl}; with no page to + * show there is nothing to report and the vanilla screen handles it. + */ +public final class WebviewDeathListener { + + private WebviewDeathListener() {} + + //? if fabric { + public static void register() { + ServerLivingEntityEvents.AFTER_DEATH.register((entity, source) -> { + if (entity instanceof ServerPlayerEntity player) { + onPlayerDeath(player, source); + } + }); + } + + private static void onPlayerDeath(ServerPlayerEntity player, DamageSource source) { + String url = WebviewServerConfig.deathScreenUrl(); + if (url.isEmpty()) { + return; + } + WebviewNetworking.sendDeathScreen(player, url, describe(player, source).toString()); + } + + private static JsonObject describe(ServerPlayerEntity player, DamageSource source) { + JsonObject o = new JsonObject(); + o.addProperty("cause", source == null ? "generic" : source.getName()); + o.addProperty("deathMessage", source == null + ? "" + : source.getDeathMessage(player).getString()); + boolean hardcore = player.getEntityWorld().getLevelProperties().isHardcore(); + o.addProperty("hardcore", hardcore); + // Hardcore has no respawn button, so the page must not offer one either. + o.addProperty("canRespawn", !hardcore); + o.add("killer", killer(source)); + return o; + } + + private static JsonObject killer(DamageSource source) { + JsonObject k = new JsonObject(); + Entity attacker = source == null ? null : source.getAttacker(); + if (attacker == null) { + // Fell, drowned, burned — no entity to name. + k.addProperty("type", "environment"); + k.add("name", com.google.gson.JsonNull.INSTANCE); + k.add("uuid", com.google.gson.JsonNull.INSTANCE); + return k; + } + boolean isPlayer = attacker instanceof ServerPlayerEntity; + k.addProperty("type", isPlayer ? "player" : "mob"); + k.addProperty("name", attacker.getName().getString()); + k.addProperty("uuid", attacker.getUuid().toString()); + if (!isPlayer) { + k.addProperty("entityType", attacker.getType().toString()); + } + return k; + } + //? } else { + /*public static void register() { + NeoForge.EVENT_BUS.addListener(WebviewDeathListener::onLivingDeath); + } + + private static void onLivingDeath(LivingDeathEvent event) { + LivingEntity entity = event.getEntity(); + if (!(entity instanceof ServerPlayer player)) { + return; + } + String url = WebviewServerConfig.deathScreenUrl(); + if (url.isEmpty()) { + return; + } + WebviewNetworking.sendDeathScreen(player, url, describe(player, event.getSource()).toString()); + } + + private static JsonObject describe(ServerPlayer player, DamageSource source) { + JsonObject o = new JsonObject(); + o.addProperty("cause", source == null ? "generic" : source.getMsgId()); + o.addProperty("deathMessage", source == null + ? "" + : source.getLocalizedDeathMessage(player).getString()); + boolean hardcore = player.level().getLevelData().isHardcore(); + o.addProperty("hardcore", hardcore); + // Hardcore has no respawn button, so the page must not offer one either. + o.addProperty("canRespawn", !hardcore); + o.add("killer", killer(source)); + return o; + } + + private static JsonObject killer(DamageSource source) { + JsonObject k = new JsonObject(); + Entity attacker = source == null ? null : source.getEntity(); + if (attacker == null) { + // Fell, drowned, burned — no entity to name. + k.addProperty("type", "environment"); + k.add("name", com.google.gson.JsonNull.INSTANCE); + k.add("uuid", com.google.gson.JsonNull.INSTANCE); + return k; + } + boolean isPlayer = attacker instanceof ServerPlayer; + k.addProperty("type", isPlayer ? "player" : "mob"); + k.addProperty("name", attacker.getName().getString()); + k.addProperty("uuid", attacker.getStringUUID()); + if (!isPlayer) { + k.addProperty("entityType", attacker.getType().toString()); + } + return k; + }*/ + //? } +} diff --git a/src/main/java/land/webgui/server/WebviewServerConfig.java b/src/main/java/land/webgui/server/WebviewServerConfig.java index 6dc40cb..00cf420 100644 --- a/src/main/java/land/webgui/server/WebviewServerConfig.java +++ b/src/main/java/land/webgui/server/WebviewServerConfig.java @@ -44,6 +44,9 @@ private static Path configPath() { private String mainMenuUrl = ""; + // Page shown instead of the vanilla death screen. Empty = keep the vanilla screen. + private String deathScreenUrl = ""; + // {"version":"1.2.3"} or GitHub Releases {"tag_name":"v1.2.3","html_url":"..."}; empty = disabled private String updateCheckUrl = ""; @@ -76,6 +79,8 @@ private static void writeExample() { o.addProperty("mainMenuUrl", "http://your-site.example/menu"); + o.addProperty("deathScreenUrl", ""); + o.addProperty("updateCheckUrl", ""); JsonArray origins = new JsonArray(); @@ -197,6 +202,17 @@ public static String mainMenuUrl() { return u == null ? "" : u.trim(); } + /** + * Page to show instead of the vanilla death screen; empty keeps the vanilla one. + * + * Opt-in on purpose: replacing the death screen takes away the player's only + * way to respawn, so it happens only when a server explicitly asks for it. + */ + public static String deathScreenUrl() { + String u = instance.deathScreenUrl; + return u == null ? "" : u.trim(); + } + public static String updateCheckUrl() { String u = instance.updateCheckUrl; return u == null ? "" : u.trim(); diff --git a/src/main/resources/fabric.mod.json b/src/main/resources/fabric.mod.json index 2b85e08..4e9dd41 100644 --- a/src/main/resources/fabric.mod.json +++ b/src/main/resources/fabric.mod.json @@ -3,7 +3,7 @@ "id": "webgui", "version": "${version}", "name": "WebGUI", - "description": "Embed any web page (React, Vue, plain HTML) as a full-screen GUI or transparent HUD overlay inside Minecraft. Powered by MCEF (Chromium).", + "description": "Embed any web page (React, Vue, plain HTML) as a full-screen GUI or transparent HUD overlay inside Minecraft. Powered by Rinku (Chromium).", "authors": [ "kosher" ], @@ -28,6 +28,7 @@ ] }, "mixins": [ - "webgui.mixins.json" + "webgui.mixins.json", + "webgui.fabric.mixins.json" ] } diff --git a/src/main/resources/webgui.fabric.mixins.json b/src/main/resources/webgui.fabric.mixins.json new file mode 100644 index 0000000..3ebbd6d --- /dev/null +++ b/src/main/resources/webgui.fabric.mixins.json @@ -0,0 +1,12 @@ +{ + "required": true, + "minVersion": "0.8", + "package": "land.webgui.mixin", + "compatibilityLevel": "JAVA_21", + "client": [ + "DeathScreenMixin" + ], + "injectors": { + "defaultRequire": 1 + } +} From 25e62e7f553be45ed8fc494cf83e5cf9fb4e2157 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9A=D0=B8=D1=80=D0=B8=D0=BB=D0=BB=20=D0=93=D1=80=D0=B8?= =?UTF-8?q?=D0=BD=D0=B5=D0=B2?= Date: Wed, 9 Sep 2026 13:38:43 +0500 Subject: [PATCH 03/16] Report the killer's entity type as a registry id MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit entityType came from EntityType.toString(), which yields the translation key — "entity.minecraft.zombie". A page matching on the killer wants the id it would use anywhere else in the game, so send "minecraft:zombie". Yarn spells the lookup EntityType.getId and Mojang mappings spell it getKey; both are static and both return the identifier. Verified on 1.21.11-fabric and 26.2-neoforge with a real attributed mob kill, since the two mappings build this payload through separate code. --- CHANGELOG.md | 2 +- .../java/land/webgui/server/WebviewDeathListener.java | 10 ++++++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c74e5eb..343d6b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ ### Added - **Custom death screen.** Set `deathScreenUrl` in `config/webgui/server.json` and the vanilla death screen is replaced by your page. Leave it empty and nothing changes — the feature is opt-in because replacing that screen takes away the player's only way to respawn. - - The page receives a `webgui:death` event describing what happened: `killer` (`{type: "player" | "mob" | "environment", name, uuid}`), the damage `cause`, the vanilla `deathMessage`, and `hardcore` / `canRespawn` flags. + - The page receives a `webgui:death` event describing what happened: `killer` (`{type: "player" | "mob" | "environment", name, uuid}`, plus `entityType` such as `minecraft:zombie` for mobs), the damage `cause`, the vanilla `deathMessage`, and `hardcore` / `canRespawn` flags. - `window.webgui.respawn()` respawns the player, standing in for the vanilla Respawn button. - Escape does not close the page, exactly as the vanilla screen refuses to close. If the page fails to load, Escape starts working again and respawns instead, so a broken page can never trap a player. - The vanilla screen is left alone while the browser is still starting, so dying during the first seconds of a session shows the normal screen rather than nothing. diff --git a/src/main/java/land/webgui/server/WebviewDeathListener.java b/src/main/java/land/webgui/server/WebviewDeathListener.java index a86d6df..064e20e 100644 --- a/src/main/java/land/webgui/server/WebviewDeathListener.java +++ b/src/main/java/land/webgui/server/WebviewDeathListener.java @@ -5,11 +5,13 @@ //? if fabric { import net.fabricmc.fabric.api.entity.event.v1.ServerLivingEntityEvents; import net.minecraft.entity.Entity; +import net.minecraft.entity.EntityType; import net.minecraft.entity.LivingEntity; import net.minecraft.entity.damage.DamageSource; import net.minecraft.server.network.ServerPlayerEntity; //? } else { /*import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.EntityType; import net.minecraft.world.entity.LivingEntity; import net.minecraft.world.damagesource.DamageSource; import net.minecraft.server.level.ServerPlayer; @@ -73,7 +75,9 @@ private static JsonObject killer(DamageSource source) { k.addProperty("name", attacker.getName().getString()); k.addProperty("uuid", attacker.getUuid().toString()); if (!isPlayer) { - k.addProperty("entityType", attacker.getType().toString()); + // The registry id ("minecraft:zombie"), not EntityType.toString(), + // which yields the translation key and is awkward to match on. + k.addProperty("entityType", EntityType.getId(attacker.getType()).toString()); } return k; } @@ -123,7 +127,9 @@ private static JsonObject killer(DamageSource source) { k.addProperty("name", attacker.getName().getString()); k.addProperty("uuid", attacker.getStringUUID()); if (!isPlayer) { - k.addProperty("entityType", attacker.getType().toString()); + // The registry id ("minecraft:zombie"), not EntityType.toString(), + // which yields the translation key and is awkward to match on. + k.addProperty("entityType", EntityType.getKey(attacker.getType()).toString()); } return k; }*/ From 21de5de325e51998f478a3f26e5aac8427b8c113 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9A=D0=B8=D1=80=D0=B8=D0=BB=D0=BB=20=D0=93=D1=80=D0=B8?= =?UTF-8?q?=D0=BD=D0=B5=D0=B2?= Date: Wed, 9 Sep 2026 18:12:37 +0500 Subject: [PATCH 04/16] Leave the death payload on window.webgui.death The death event fires once, right after the document loads, so a page whose bundle only subscribes from a mounted component never hears it. Snapshot it on the namespace the way client and entity already are, and the late subscriber has something to read. --- .../java/land/webgui/WebviewClientEmit.java | 22 +++++++++++++++++++ .../land/webgui/WebviewPageLoadHooks.java | 2 +- 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/src/main/java/land/webgui/WebviewClientEmit.java b/src/main/java/land/webgui/WebviewClientEmit.java index 4ff4967..60323e1 100644 --- a/src/main/java/land/webgui/WebviewClientEmit.java +++ b/src/main/java/land/webgui/WebviewClientEmit.java @@ -30,6 +30,28 @@ public static void dispatch(String eventName, String jsonPayload) { if (hud != null && hud != main) executeJs(hud, js); } + /** + * Emits the death payload and leaves it on {@code window.webgui.death}. + * + * The event fires once, right after the document loads, so a bundle that only starts + * subscribing from a component would miss it entirely. The snapshot gives such code + * something to read, exactly as {@code window.webgui.client} and {@code .entity} do. + */ + public static void dispatchDeath(String jsonPayload) { + RinkuBrowser main = WebSession.browser(); + if (main == null) return; + String data = (jsonPayload == null || jsonPayload.isBlank()) ? "null" : jsonPayload; + String js = "(function(){" + + "var d=" + data + ";" + + "if(typeof window.webgui==='undefined')window.webgui={};" + + "window.webgui.death=d;" + + "window.dispatchEvent(new CustomEvent('webgui:death',{detail:d}));" + + "if(window.webgui._hs&&window.webgui._hs['death'])" + + "{window.webgui._hs['death'].forEach(function(e){try{e.w({detail:d});}catch(x){}});}" + + "})();"; + executeJs(main, js); + } + private static void executeJs(RinkuBrowser browser, String js) { try { String url = browser.getURL(); diff --git a/src/main/java/land/webgui/WebviewPageLoadHooks.java b/src/main/java/land/webgui/WebviewPageLoadHooks.java index e645cbe..6f33033 100644 --- a/src/main/java/land/webgui/WebviewPageLoadHooks.java +++ b/src/main/java/land/webgui/WebviewPageLoadHooks.java @@ -51,7 +51,7 @@ public void onLoadEnd(CefBrowser browser, CefFrame frame, int httpStatusCode) { // now that the document is up. String death = WebGUIDeathScreen.info(); if (death != null && WebGUIDeathScreen.active()) { - WebviewClientEmit.dispatch("death", death); + WebviewClientEmit.dispatchDeath(death); } if (WebSession.mode() == WebSession.Mode.HUD_OVERLAY) { From 843a657a4b16e87bf80668762a2be074cecfeaf7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9A=D0=B8=D1=80=D0=B8=D0=BB=D0=BB=20=D0=93=D1=80=D0=B8?= =?UTF-8?q?=D0=BD=D0=B5=D0=B2?= Date: Wed, 9 Sep 2026 18:12:54 +0500 Subject: [PATCH 05/16] Tell clients what the server runs instead of refusing them A NeoForge server registered its channels as required, so a client without WebGUI - or with an older build of it - was disconnected with "Incompatible client! Please use NeoForge ", which names a mod that is not the problem. Fabric did the opposite and admitted the player, then silently dropped every packet, so nothing opened and nothing said why. The channels are optional now. The server introduces itself on join, and a client whose protocol differs gets a message naming both versions. Servers that really must require the mod set requireClientMod and get a kick message that explains itself. Two things found along the way: the death screen URL was pushed on join on Fabric but not on NeoForge, where it only arrived with the death packet itself, and Fabric pushed it twice. Also caps page events per player per second. That channel is the only thing a client can push at the server, every message is dispatched on the server thread, and a page is web content - a loop in someone's JavaScript can fire it as fast as the socket allows. --- src/main/java/land/webgui/WebGUIClient.java | 19 +++- .../java/land/webgui/WebGUIHandshake.java | 87 +++++++++++++++ src/main/java/land/webgui/WebGUIVersion.java | 46 ++++++++ src/main/java/land/webgui/WebviewJoinHud.java | 28 ++++- .../java/land/webgui/WebviewNetworking.java | 104 ++++++++++++++++- .../java/land/webgui/WebviewPayloads.java | 47 ++++++++ .../webgui/server/WebviewClientCheck.java | 105 ++++++++++++++++++ .../webgui/server/WebviewRateLimiter.java | 88 +++++++++++++++ .../webgui/server/WebviewServerConfig.java | 27 +++++ 9 files changed, 543 insertions(+), 8 deletions(-) create mode 100644 src/main/java/land/webgui/WebGUIHandshake.java create mode 100644 src/main/java/land/webgui/WebGUIVersion.java create mode 100644 src/main/java/land/webgui/server/WebviewClientCheck.java create mode 100644 src/main/java/land/webgui/server/WebviewRateLimiter.java diff --git a/src/main/java/land/webgui/WebGUIClient.java b/src/main/java/land/webgui/WebGUIClient.java index db3b7d2..08882c8 100644 --- a/src/main/java/land/webgui/WebGUIClient.java +++ b/src/main/java/land/webgui/WebGUIClient.java @@ -64,6 +64,10 @@ public void onInitializeClient() { ClientPlayNetworking.registerGlobalReceiver(WebviewPayloads.WebviewDeathS2CPayload.ID, (payload, context) -> { context.client().execute(() -> onDeathPayload(payload.url(), payload.infoJson())); }); + + ClientPlayNetworking.registerGlobalReceiver(WebviewPayloads.WebviewHelloS2CPayload.ID, (payload, context) -> { + context.client().execute(() -> WebGUIHandshake.onHello(payload.protocolVersion(), payload.modVersion())); + }); //? } else { /*ClientPlayNetworking.registerGlobalReceiver(WebviewPayloads.OPEN_WEB_CHANNEL, (client, handler, buf, responseSender) -> { int protocolVersion = buf.readVarInt(); @@ -95,6 +99,12 @@ public void onInitializeClient() { String url = buf.readString(WebviewNetworking.MAX_URL_LENGTH); String info = buf.readString(WebviewPayloads.MAX_EVENT_DATA_LENGTH); client.execute(() -> onDeathPayload(url, info)); + }); + + ClientPlayNetworking.registerGlobalReceiver(WebviewPayloads.HELLO_CHANNEL, (client, handler, buf, responseSender) -> { + int protocol = buf.readVarInt(); + String version = buf.readString(WebviewPayloads.MAX_VERSION_LENGTH); + client.execute(() -> WebGUIHandshake.onHello(protocol, version)); });*/ //? } @@ -118,6 +128,7 @@ private static void onLeaveWorld() { WebGUIMainMenuUrl.setUrl(""); WebGUITrustedOrigins.clear(); WebGUIDeathScreen.clear(); + WebGUIHandshake.clear(); } /** @@ -180,6 +191,7 @@ private static void onLeaveWorld() { WebGUIMainMenuUrl.setUrl(""); WebGUITrustedOrigins.clear(); WebGUIDeathScreen.clear(); + WebGUIHandshake.clear(); } // A url with no info is the join-time push; an info payload means the player @@ -195,7 +207,10 @@ static void onDeathPayload(String url, String infoJson) { // Called only on the client (from WebviewNetworking.registerPayloadTypes) so // these client-only handlers never load on a dedicated server. public static void registerClientReceivers(RegisterPayloadHandlersEvent event) { - final var reg = event.registrar("1"); + // Optional for the same reason the server side is: a required channel here would + // make this client refuse any server running an older WebGUI, and NeoForge would + // blame its own version for it. + final var reg = event.registrar("1").optional(); reg.playToClient(WebviewPayloads.OpenWebS2CPayload.TYPE, WebviewPayloads.OpenWebS2CPayload.STREAM_CODEC, (payload, ctx) -> { if (payload.protocolVersion() != WebviewNetworking.PROTOCOL_VERSION) return; @@ -211,6 +226,8 @@ public static void registerClientReceivers(RegisterPayloadHandlersEvent event) { (payload, ctx) -> ctx.enqueueWork(() -> WebGUITrustedOrigins.set(payload.origins()))); reg.playToClient(WebviewPayloads.WebviewDeathS2CPayload.TYPE, WebviewPayloads.WebviewDeathS2CPayload.STREAM_CODEC, (payload, ctx) -> ctx.enqueueWork(() -> onDeathPayload(payload.url(), payload.infoJson()))); + reg.playToClient(WebviewPayloads.WebviewHelloS2CPayload.TYPE, WebviewPayloads.WebviewHelloS2CPayload.STREAM_CODEC, + (payload, ctx) -> ctx.enqueueWork(() -> WebGUIHandshake.onHello(payload.protocolVersion(), payload.modVersion()))); } // Swaps the vanilla death screen for the server's page. Fabric needs a mixin diff --git a/src/main/java/land/webgui/WebGUIHandshake.java b/src/main/java/land/webgui/WebGUIHandshake.java new file mode 100644 index 0000000..e4324d7 --- /dev/null +++ b/src/main/java/land/webgui/WebGUIHandshake.java @@ -0,0 +1,87 @@ +package land.webgui; + +//? if fabric { +import net.minecraft.client.MinecraftClient; +import net.minecraft.text.Text; +import net.minecraft.util.Formatting; +//? } else { +/*import net.minecraft.ChatFormatting; +import net.minecraft.client.Minecraft; +import net.minecraft.network.chat.Component;*/ +//? } + +/** + * Client half of the join handshake: what the server said it is running. + * + * A protocol mismatch is silent by design everywhere else — payloads whose version does + * not match are dropped so a newer server cannot drive an older client into undefined + * behaviour — which leaves the player watching nothing happen. This is the one place that + * says why, and names both versions so it is obvious which side to update. + */ +public final class WebGUIHandshake { + + private static String serverVersion = ""; + private static int serverProtocol = -1; + + private WebGUIHandshake() {} + + /** The server's WebGUI version, or empty if it never introduced itself. */ + public static String serverVersion() { + return serverVersion; + } + + public static int serverProtocol() { + return serverProtocol; + } + + public static void clear() { + serverVersion = ""; + serverProtocol = -1; + } + + private static String mismatchText(int protocol, String version) { + return "[WebGUI] This server runs WebGUI " + version + " (protocol " + protocol + + "), you have " + WebGUIVersion.current() + " (protocol " + + WebviewNetworking.PROTOCOL_VERSION + "). Its pages will not open until the" + + " versions match."; + } + + //? if fabric { + public static void onHello(int protocol, String version) { + serverProtocol = protocol; + serverVersion = version == null ? "" : version; + if (protocol == WebviewNetworking.PROTOCOL_VERSION) { + return; + } + WebGUIMod.LOGGER.warn("webgui: server runs {} (protocol {}), client is {} (protocol {})", + serverVersion, protocol, WebGUIVersion.current(), WebviewNetworking.PROTOCOL_VERSION); + MinecraftClient client = MinecraftClient.getInstance(); + if (client.player != null) { + client.player.sendMessage( + Text.literal(mismatchText(protocol, serverVersion)).formatted(Formatting.RED), false); + } + } + //? } else { + /*public static void onHello(int protocol, String version) { + serverProtocol = protocol; + serverVersion = version == null ? "" : version; + if (protocol == WebviewNetworking.PROTOCOL_VERSION) { + return; + } + WebGUIMod.LOGGER.warn("webgui: server runs {} (protocol {}), client is {} (protocol {})", + serverVersion, protocol, WebGUIVersion.current(), WebviewNetworking.PROTOCOL_VERSION); + Minecraft client = Minecraft.getInstance(); + if (client.player != null) { + // 26 dropped Player.displayClientMessage and gave LocalPlayer its own + // sendSystemMessage instead. + //? if >=26 { + client.player.sendSystemMessage( + Component.literal(mismatchText(protocol, serverVersion)).withStyle(ChatFormatting.RED)); + //? } else { + client.player.displayClientMessage( + Component.literal(mismatchText(protocol, serverVersion)).withStyle(ChatFormatting.RED), false); + //? } + } + }*/ + //? } +} diff --git a/src/main/java/land/webgui/WebGUIVersion.java b/src/main/java/land/webgui/WebGUIVersion.java new file mode 100644 index 0000000..6fc11e8 --- /dev/null +++ b/src/main/java/land/webgui/WebGUIVersion.java @@ -0,0 +1,46 @@ +package land.webgui; + +//? if fabric { +import net.fabricmc.loader.api.FabricLoader; +//? } else { +/*import net.neoforged.fml.ModList;*/ +//? } + +/** + * The mod's own version string, as the loader reports it. + * + * Read once: both loaders build this from mod metadata that cannot change while the + * game runs, and the handshake asks for it on every join. + */ +public final class WebGUIVersion { + + private static final String CURRENT = read(); + + private WebGUIVersion() {} + + /** e.g. {@code 1.7.0+mc1.21.11}, or {@code "unknown"} if the loader has no metadata for us. */ + public static String current() { + return CURRENT; + } + + private static String read() { + String v; + //? if fabric { + v = FabricLoader.getInstance() + .getModContainer(WebGUIMod.MOD_ID) + .map(c -> c.getMetadata().getVersion().getFriendlyString()) + .orElse(""); + //? } else { + /*v = ModList.get() + .getModContainerById(WebGUIMod.MOD_ID) + .map(c -> c.getModInfo().getVersion().toString()) + .orElse("");*/ + //? } + if (v == null || v.isBlank()) { + return "unknown"; + } + return v.length() > WebviewPayloads.MAX_VERSION_LENGTH + ? v.substring(0, WebviewPayloads.MAX_VERSION_LENGTH) + : v; + } +} diff --git a/src/main/java/land/webgui/WebviewJoinHud.java b/src/main/java/land/webgui/WebviewJoinHud.java index 819af61..ded82af 100644 --- a/src/main/java/land/webgui/WebviewJoinHud.java +++ b/src/main/java/land/webgui/WebviewJoinHud.java @@ -23,11 +23,13 @@ public static void register() { return; } - WebviewNetworking.sendTrustedOrigins(player, WebviewServerConfig.trustedCommandOriginsJoined()); + // First: everything below is pointless for a client that cannot receive it, + // and this is also where such a client is told so. + if (!land.webgui.server.WebviewClientCheck.onJoin(player)) { + return; + } - // Pushed now, not at death: the client has to know whether to suppress the - // vanilla death screen the instant it is opened. - WebviewNetworking.sendDeathScreen(player, WebviewServerConfig.deathScreenUrl(), ""); + WebviewNetworking.sendTrustedOrigins(player, WebviewServerConfig.trustedCommandOriginsJoined()); // Pushed now, not at death: the client has to know whether to // suppress the vanilla death screen the instant it is opened. @@ -50,17 +52,35 @@ public static void register() { WebviewNetworking.openHud(player, url); WebGUIMod.LOGGER.info("webgui: auto HUD for {} → {}", player.getName().getString(), url); }); + + ServerPlayConnectionEvents.DISCONNECT.register((handler, server) -> { + if (handler.player != null) { + land.webgui.server.WebviewRateLimiter.forget(handler.player.getUuid()); + } + }); } //? } else { /*public static void register() { NeoForge.EVENT_BUS.addListener(WebviewJoinHud::onPlayerJoin); + NeoForge.EVENT_BUS.addListener((PlayerEvent.PlayerLoggedOutEvent event) -> + land.webgui.server.WebviewRateLimiter.forget(event.getEntity().getUUID())); } private static void onPlayerJoin(PlayerEvent.PlayerLoggedInEvent event) { ServerPlayer player = (ServerPlayer) event.getEntity(); + // First: everything below is pointless for a client that cannot receive it, and + // this is also where such a client is told so. + if (!land.webgui.server.WebviewClientCheck.onJoin(player)) { + return; + } + WebviewNetworking.sendTrustedOrigins(player, WebviewServerConfig.trustedCommandOriginsJoined()); + // Pushed now, not at death: the client has to know whether to suppress the + // vanilla death screen the instant it is opened. + WebviewNetworking.sendDeathScreen(player, WebviewServerConfig.deathScreenUrl(), ""); + String mainMenuUrl = WebviewServerConfig.mainMenuUrl(); if (!mainMenuUrl.isEmpty()) { WebviewNetworking.sendMainMenuUrl(player, mainMenuUrl); diff --git a/src/main/java/land/webgui/WebviewNetworking.java b/src/main/java/land/webgui/WebviewNetworking.java index 839b948..f12bdf2 100644 --- a/src/main/java/land/webgui/WebviewNetworking.java +++ b/src/main/java/land/webgui/WebviewNetworking.java @@ -27,6 +27,16 @@ public final class WebviewNetworking { public static final int MODE_HUD = 1; public static final int MAX_URL_LENGTH = 16384; + /** What the far end of a connection turned out to be. */ + public enum ClientKind { + /** Speaks this protocol: the handshake channel is registered. */ + CURRENT, + /** Has WebGUI, but an older build without the handshake channel. */ + OUTDATED, + /** No WebGUI at all — a vanilla client, or one that simply does not have it. */ + ABSENT, + } + private WebviewNetworking() {} //? if fabric { @@ -38,17 +48,32 @@ public static void registerPayloadTypes() { PayloadTypeRegistry.playS2C().register(WebviewPayloads.WebviewEntityContextS2CPayload.ID, WebviewPayloads.WebviewEntityContextS2CPayload.CODEC); PayloadTypeRegistry.playS2C().register(WebviewPayloads.WebviewTrustedOriginsS2CPayload.ID, WebviewPayloads.WebviewTrustedOriginsS2CPayload.CODEC); PayloadTypeRegistry.playS2C().register(WebviewPayloads.WebviewDeathS2CPayload.ID, WebviewPayloads.WebviewDeathS2CPayload.CODEC); + PayloadTypeRegistry.playS2C().register(WebviewPayloads.WebviewHelloS2CPayload.ID, WebviewPayloads.WebviewHelloS2CPayload.CODEC); PayloadTypeRegistry.playC2S().register(WebviewPayloads.WebviewPageEventC2SPayload.ID, WebviewPayloads.WebviewPageEventC2SPayload.CODEC); //? } } //? } else { /*public static void registerPayloadTypes(IEventBus modBus) { modBus.addListener((RegisterPayloadHandlersEvent event) -> { - final var reg = event.registrar("1"); + // Optional, or NeoForge refuses any client that does not register every one + // of these channels — including a client running an older WebGUI — and the + // player is told "Incompatible client! Please use NeoForge ", which names + // the wrong mod entirely. WebGUI enhances a client; it does not gate entry. + // Servers that really do require it set requireClientMod in server.json, and + // then the kick message says so in as many words. + final var reg = event.registrar("1").optional(); reg.playToServer(WebviewPayloads.WebviewPageEventC2SPayload.TYPE, WebviewPayloads.WebviewPageEventC2SPayload.STREAM_CODEC, - (payload, ctx) -> ctx.enqueueWork(() -> - WebviewServerEvents.firePageEvent((net.minecraft.server.level.ServerPlayer) ctx.player(), payload.channel(), payload.jsonPayload()))); + (payload, ctx) -> { + net.minecraft.server.level.ServerPlayer sender = (net.minecraft.server.level.ServerPlayer) ctx.player(); + // Checked here, off the server thread: enqueueing first would put the + // flood on the tick loop, which is the thing being protected. + if (sender == null || !land.webgui.server.WebviewRateLimiter.allow(sender.getUUID(), sender.getName().getString())) { + return; + } + ctx.enqueueWork(() -> + WebviewServerEvents.firePageEvent(sender, payload.channel(), payload.jsonPayload())); + }); // S2C types register on both sides (the server sends them), but their // handlers touch client-only classes — loading those on a dedicated @@ -74,6 +99,8 @@ public static void registerPayloadTypes() { WebviewPayloads.WebviewTrustedOriginsS2CPayload.STREAM_CODEC, (payload, ctx) -> {}); reg.playToClient(WebviewPayloads.WebviewDeathS2CPayload.TYPE, WebviewPayloads.WebviewDeathS2CPayload.STREAM_CODEC, (payload, ctx) -> {}); + reg.playToClient(WebviewPayloads.WebviewHelloS2CPayload.TYPE, + WebviewPayloads.WebviewHelloS2CPayload.STREAM_CODEC, (payload, ctx) -> {}); } }); }*/ @@ -86,6 +113,11 @@ public static void registerServerReceivers() { ServerPlayerEntity player = context.player(); String channel = payload.channel(); String json = payload.jsonPayload(); + // Checked here, off the server thread: scheduling first would put the flood on + // the tick loop, which is the thing being protected. + if (player == null || !land.webgui.server.WebviewRateLimiter.allow(player.getUuid(), player.getName().getString())) { + return; + } context.server().execute(() -> WebviewServerEvents.PAGE_EVENT.invoker().onPageEvent(player, channel, json)); }); @@ -93,6 +125,11 @@ public static void registerServerReceivers() { /*ServerPlayNetworking.registerGlobalReceiver(WebviewPayloads.PAGE_EVENT_CHANNEL, (server, player, handler, buf, responseSender) -> { String channel = buf.readString(WebviewPayloads.MAX_EVENT_NAME_LENGTH); String json = buf.readString(WebviewPayloads.MAX_EVENT_DATA_LENGTH); + // Checked here, off the server thread: scheduling first would put the flood on + // the tick loop, which is the thing being protected. + if (player == null || !land.webgui.server.WebviewRateLimiter.allow(player.getUuid(), player.getName().getString())) { + return; + } server.execute(() -> WebviewServerEvents.PAGE_EVENT.invoker().onPageEvent(player, channel, json)); });*/ //? } @@ -198,6 +235,47 @@ public static void sendDeathScreen(ServerPlayerEntity player, String url, String //? } } + /** + * Tells the client which WebGUI the server runs. Silently skipped for a client that + * has no such channel — that case is reported to the player as text instead. + */ + public static void sendHello(ServerPlayerEntity player) { + //? if >=1.20.5 { + if (!ServerPlayNetworking.canSend(player, WebviewPayloads.WebviewHelloS2CPayload.ID)) { + return; + } + ServerPlayNetworking.send(player, new WebviewPayloads.WebviewHelloS2CPayload(PROTOCOL_VERSION, WebGUIVersion.current())); + //? } else { + /*if (!ServerPlayNetworking.canSend(player, WebviewPayloads.HELLO_CHANNEL)) { + return; + } + PacketByteBuf buf = PacketByteBufs.create(); + buf.writeVarInt(PROTOCOL_VERSION); + buf.writeString(WebGUIVersion.current(), WebviewPayloads.MAX_VERSION_LENGTH); + ServerPlayNetworking.send(player, WebviewPayloads.HELLO_CHANNEL, buf);*/ + //? } + } + + /** Whether the player's client has WebGUI, and whether it is new enough to talk to us. */ + public static ClientKind clientKind(ServerPlayerEntity player) { + //? if >=1.20.5 { + if (ServerPlayNetworking.canSend(player, WebviewPayloads.WebviewHelloS2CPayload.ID)) { + return ClientKind.CURRENT; + } + // No handshake channel but the original one is there: WebGUI, just an older build. + return ServerPlayNetworking.canSend(player, WebviewPayloads.OpenWebS2CPayload.ID) + ? ClientKind.OUTDATED + : ClientKind.ABSENT; + //? } else { + /*if (ServerPlayNetworking.canSend(player, WebviewPayloads.HELLO_CHANNEL)) { + return ClientKind.CURRENT; + } + return ServerPlayNetworking.canSend(player, WebviewPayloads.OPEN_WEB_CHANNEL) + ? ClientKind.OUTDATED + : ClientKind.ABSENT;*/ + //? } + } + public static void sendTrustedOrigins(ServerPlayerEntity player, String origins) { String o = origins == null ? "" : origins; //? if >=1.20.5 { @@ -264,6 +342,26 @@ public static void sendTrustedOrigins(ServerPlayer player, String origins) { PacketDistributor.sendToPlayer(player, new WebviewPayloads.WebviewTrustedOriginsS2CPayload(origins == null ? "" : origins)); } + // Tells the client which WebGUI the server runs. Silently skipped for a client that + // has no such channel — that case is reported to the player as text instead. + public static void sendHello(ServerPlayer player) { + if (!player.connection.hasChannel(WebviewPayloads.WebviewHelloS2CPayload.TYPE)) { + return; + } + PacketDistributor.sendToPlayer(player, new WebviewPayloads.WebviewHelloS2CPayload(PROTOCOL_VERSION, WebGUIVersion.current())); + } + + // Whether the player's client has WebGUI, and whether it is new enough to talk to us. + public static ClientKind clientKind(ServerPlayer player) { + if (player.connection.hasChannel(WebviewPayloads.WebviewHelloS2CPayload.TYPE)) { + return ClientKind.CURRENT; + } + // No handshake channel but the original one is there: WebGUI, just an older build. + return player.connection.hasChannel(WebviewPayloads.OpenWebS2CPayload.TYPE) + ? ClientKind.OUTDATED + : ClientKind.ABSENT; + } + private static String withPlayerToken(ServerPlayer player, String url) { if (!WebviewServerConfig.enableTokens()) { return sanitizeUrl(url); diff --git a/src/main/java/land/webgui/WebviewPayloads.java b/src/main/java/land/webgui/WebviewPayloads.java index 9806514..a643746 100644 --- a/src/main/java/land/webgui/WebviewPayloads.java +++ b/src/main/java/land/webgui/WebviewPayloads.java @@ -28,6 +28,7 @@ private WebviewPayloads() {} public static final int MAX_EVENT_NAME_LENGTH = 256; public static final int MAX_EVENT_DATA_LENGTH = 32_768; + public static final int MAX_VERSION_LENGTH = 64; // Channel identifiers — used in both legacy (1.20.1) and modern networking //? if fabric { @@ -38,6 +39,7 @@ private WebviewPayloads() {} public static final Identifier ENTITY_CONTEXT_CHANNEL = Identifier.of(WebGUIMod.MOD_ID, "entity_context"); public static final Identifier TRUSTED_ORIGINS_CHANNEL = Identifier.of(WebGUIMod.MOD_ID, "trusted_origins"); public static final Identifier DEATH_SCREEN_CHANNEL = Identifier.of(WebGUIMod.MOD_ID, "death_screen"); + public static final Identifier HELLO_CHANNEL = Identifier.of(WebGUIMod.MOD_ID, "hello"); //? } else { /*//? if >=1.21.5 { public static final Identifier OPEN_WEB_CHANNEL = Identifier.fromNamespaceAndPath(WebGUIMod.MOD_ID, "open_web"); @@ -47,6 +49,7 @@ private WebviewPayloads() {} public static final Identifier ENTITY_CONTEXT_CHANNEL = Identifier.fromNamespaceAndPath(WebGUIMod.MOD_ID, "entity_context"); public static final Identifier TRUSTED_ORIGINS_CHANNEL = Identifier.fromNamespaceAndPath(WebGUIMod.MOD_ID, "trusted_origins"); public static final Identifier DEATH_SCREEN_CHANNEL = Identifier.fromNamespaceAndPath(WebGUIMod.MOD_ID, "death_screen"); + public static final Identifier HELLO_CHANNEL = Identifier.fromNamespaceAndPath(WebGUIMod.MOD_ID, "hello"); //? } else { public static final ResourceLocation OPEN_WEB_CHANNEL = ResourceLocation.fromNamespaceAndPath(WebGUIMod.MOD_ID, "open_web"); public static final ResourceLocation MAIN_MENU_CHANNEL = ResourceLocation.fromNamespaceAndPath(WebGUIMod.MOD_ID, "set_main_menu"); @@ -55,6 +58,7 @@ private WebviewPayloads() {} public static final ResourceLocation ENTITY_CONTEXT_CHANNEL = ResourceLocation.fromNamespaceAndPath(WebGUIMod.MOD_ID, "entity_context"); public static final ResourceLocation TRUSTED_ORIGINS_CHANNEL = ResourceLocation.fromNamespaceAndPath(WebGUIMod.MOD_ID, "trusted_origins"); public static final ResourceLocation DEATH_SCREEN_CHANNEL = ResourceLocation.fromNamespaceAndPath(WebGUIMod.MOD_ID, "death_screen"); + public static final ResourceLocation HELLO_CHANNEL = ResourceLocation.fromNamespaceAndPath(WebGUIMod.MOD_ID, "hello"); //? }*/ //? } @@ -162,6 +166,31 @@ public Id getId() { } } + /** + * S2C: what the server is running, sent the moment a player joins. + * + * The client cannot otherwise tell a server that speaks an older WebGUI protocol + * from one that speaks none at all, and the loaders disagree about what happens + * then: NeoForge refuses the connection outright with a message about its own + * version, Fabric lets the player in and silently drops every packet. Either way + * the player is left guessing. This says plainly who is running what. + */ + public record WebviewHelloS2CPayload(int protocolVersion, String modVersion) implements CustomPayload { + public static final CustomPayload.Id ID = + new CustomPayload.Id<>(HELLO_CHANNEL); + public static final PacketCodec CODEC = PacketCodec.tuple( + PacketCodecs.VAR_INT, + WebviewHelloS2CPayload::protocolVersion, + PacketCodecs.string(MAX_VERSION_LENGTH), + WebviewHelloS2CPayload::modVersion, + WebviewHelloS2CPayload::new); + + @Override + public Id getId() { + return ID; + } + } + /** S2C: newline-joined origins whose pages may run commands as the player. */ public record WebviewTrustedOriginsS2CPayload(String origins) implements CustomPayload { public static final CustomPayload.Id ID = @@ -265,6 +294,24 @@ public record WebviewDeathS2CPayload(String url, String infoJson) implements Cus public CustomPacketPayload.Type type() { return TYPE; } } + // S2C: what the server is running, sent the moment a player joins. The client cannot + // otherwise tell a server that speaks an older WebGUI protocol from one that speaks + // none at all, and the loaders disagree about what happens then: NeoForge refuses the + // connection with a message about its own version, Fabric lets the player in and + // silently drops every packet. This says plainly who is running what. + public record WebviewHelloS2CPayload(int protocolVersion, String modVersion) implements CustomPacketPayload { + public static final CustomPacketPayload.Type TYPE = + new CustomPacketPayload.Type<>(HELLO_CHANNEL); + public static final StreamCodec STREAM_CODEC = + StreamCodec.composite( + ByteBufCodecs.VAR_INT, WebviewHelloS2CPayload::protocolVersion, + ByteBufCodecs.stringUtf8(MAX_VERSION_LENGTH), WebviewHelloS2CPayload::modVersion, + WebviewHelloS2CPayload::new); + + @Override + public CustomPacketPayload.Type type() { return TYPE; } + } + // S2C: newline-joined origins whose pages may run commands as the player. public record WebviewTrustedOriginsS2CPayload(String origins) implements CustomPacketPayload { public static final CustomPacketPayload.Type TYPE = diff --git a/src/main/java/land/webgui/server/WebviewClientCheck.java b/src/main/java/land/webgui/server/WebviewClientCheck.java new file mode 100644 index 0000000..60f39cd --- /dev/null +++ b/src/main/java/land/webgui/server/WebviewClientCheck.java @@ -0,0 +1,105 @@ +package land.webgui.server; + +import land.webgui.WebGUIMod; +import land.webgui.WebGUIVersion; +import land.webgui.WebviewNetworking; +//? if fabric { +import net.minecraft.server.network.ServerPlayerEntity; +import net.minecraft.text.Text; +import net.minecraft.util.Formatting; +//? } else { +/*import net.minecraft.network.chat.Component; +import net.minecraft.ChatFormatting; +import net.minecraft.server.level.ServerPlayer;*/ +//? } + +/** + * Tells a joining player, in plain words, whether their client can see this server's pages. + * + * Without this the two loaders fail in two unhelpful ways: NeoForge rejects the connection + * with "Incompatible client! Please use NeoForge <x>" — naming a mod that is not the + * problem — while Fabric admits the player and then silently drops every WebGUI packet, so + * nothing ever opens and nothing says why. Both now end with a message naming this mod and + * the version the server runs. + */ +public final class WebviewClientCheck { + + private WebviewClientCheck() {} + + /** Whether the server has anything configured that a player without the mod would miss. */ + private static boolean serverUsesPages() { + return !WebviewServerConfig.mainMenuUrl().isEmpty() + || !WebviewServerConfig.deathScreenUrl().isEmpty() + || WebviewServerConfig.autoHudOnJoin(); + } + + private static String outdatedText() { + return "This server runs WebGUI " + WebGUIVersion.current() + + " (protocol " + WebviewNetworking.PROTOCOL_VERSION + "). Your WebGUI is older and" + + " cannot receive its pages — update it to " + WebGUIVersion.current() + "."; + } + + private static String absentText() { + return "This server runs WebGUI " + WebGUIVersion.current() + + " and shows some of its interface through it. Your client does not have WebGUI," + + " so those pages will not appear."; + } + + private static String kickText(WebviewNetworking.ClientKind kind) { + return kind == WebviewNetworking.ClientKind.OUTDATED + ? "Your WebGUI is out of date.\nThis server runs WebGUI " + WebGUIVersion.current() + + " (protocol " + WebviewNetworking.PROTOCOL_VERSION + ")." + : "This server requires the WebGUI mod.\nIt runs WebGUI " + WebGUIVersion.current() + + " (protocol " + WebviewNetworking.PROTOCOL_VERSION + ")."; + } + + //? if fabric { + public static boolean onJoin(ServerPlayerEntity player) { + WebviewNetworking.ClientKind kind = WebviewNetworking.clientKind(player); + if (kind == WebviewNetworking.ClientKind.CURRENT) { + WebviewNetworking.sendHello(player); + return true; + } + + String name = player.getName().getString(); + if (WebviewServerConfig.requireClientMod()) { + WebGUIMod.LOGGER.info("webgui: refused {} — client is {}", name, kind); + player.networkHandler.disconnect(Text.literal(kickText(kind))); + return false; + } + + if (kind == WebviewNetworking.ClientKind.OUTDATED) { + WebGUIMod.LOGGER.warn("webgui: {} has an outdated WebGUI; server runs {}", name, WebGUIVersion.current()); + player.sendMessage(Text.literal("[WebGUI] " + outdatedText()).formatted(Formatting.YELLOW), false); + } else if (serverUsesPages()) { + WebGUIMod.LOGGER.info("webgui: {} joined without WebGUI; server runs {}", name, WebGUIVersion.current()); + player.sendMessage(Text.literal("[WebGUI] " + absentText()).formatted(Formatting.GRAY), false); + } + return true; + } + //? } else { + /*public static boolean onJoin(ServerPlayer player) { + WebviewNetworking.ClientKind kind = WebviewNetworking.clientKind(player); + if (kind == WebviewNetworking.ClientKind.CURRENT) { + WebviewNetworking.sendHello(player); + return true; + } + + String name = player.getName().getString(); + if (WebviewServerConfig.requireClientMod()) { + WebGUIMod.LOGGER.info("webgui: refused {} - client is {}", name, kind); + player.connection.disconnect(Component.literal(kickText(kind))); + return false; + } + + if (kind == WebviewNetworking.ClientKind.OUTDATED) { + WebGUIMod.LOGGER.warn("webgui: {} has an outdated WebGUI; server runs {}", name, WebGUIVersion.current()); + player.sendSystemMessage(Component.literal("[WebGUI] " + outdatedText()).withStyle(ChatFormatting.YELLOW)); + } else if (serverUsesPages()) { + WebGUIMod.LOGGER.info("webgui: {} joined without WebGUI; server runs {}", name, WebGUIVersion.current()); + player.sendSystemMessage(Component.literal("[WebGUI] " + absentText()).withStyle(ChatFormatting.GRAY)); + } + return true; + }*/ + //? } +} diff --git a/src/main/java/land/webgui/server/WebviewRateLimiter.java b/src/main/java/land/webgui/server/WebviewRateLimiter.java new file mode 100644 index 0000000..f288364 --- /dev/null +++ b/src/main/java/land/webgui/server/WebviewRateLimiter.java @@ -0,0 +1,88 @@ +package land.webgui.server; + +import land.webgui.WebGUIMod; + +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicLong; + +/** + * Caps how fast one player's pages may talk to the server. + * + * The page-event channel is the only thing a client can push at a WebGUI server, and a + * page is web content: a loop in someone's JavaScript — or a hostile page a player was + * talked into opening — can fire it as fast as the socket allows, and every message is + * dispatched on the server thread. Without a ceiling that is a tick-rate denial of + * service that any player can trigger from a browser. + * + * A fixed window rather than a token bucket: it needs one long per player, and the + * failure it guards against is a flood, not a precisely-shaped burst. + */ +public final class WebviewRateLimiter { + + /** Stops a churn of joining players from growing the map without bound. */ + private static final int MAX_TRACKED = 1000; + + /** + * nanoTime is only meaningful as a difference — its absolute value may be negative — + * and a negative second would not survive being packed into the high bits below, + * leaving every call looking like a fresh window and the limit never firing. + */ + private static final long START_NANOS = System.nanoTime(); + + private static final Map WINDOWS = new ConcurrentHashMap<>(); + + private WebviewRateLimiter() {} + + private static final class Window { + final AtomicLong state = new AtomicLong(); // high 42 bits: second; low 22: count + volatile boolean warned; + } + + /** + * Whether this event may be handled. Counts the call either way, so a page that keeps + * hammering stays blocked for the rest of its second. + */ + public static boolean allow(UUID playerId, String playerName) { + int limit = WebviewServerConfig.pageEventsPerSecond(); + if (limit <= 0 || playerId == null) { + return true; + } + if (WINDOWS.size() > MAX_TRACKED) { + WINDOWS.clear(); + } + + Window w = WINDOWS.computeIfAbsent(playerId, id -> new Window()); + long nowSecond = (System.nanoTime() - START_NANOS) / 1_000_000_000L; + long updated = w.state.updateAndGet(prev -> { + long second = prev >>> 22; + long count = prev & 0x3F_FFFF; + if (second != nowSecond) { + return (nowSecond << 22) | 1L; + } + // Saturate rather than wrap into the second field. + return (second << 22) | Math.min(count + 1, 0x3F_FFFF); + }); + + long count = updated & 0x3F_FFFF; + if (count <= limit) { + w.warned = false; + return true; + } + if (!w.warned) { + w.warned = true; + WebGUIMod.LOGGER.warn( + "webgui: dropping page events from {} — over {}/s. Raise or disable pageEventsPerSecond in config/webgui/server.json if this is legitimate.", + playerName, limit); + } + return false; + } + + /** Called when a player leaves, so their window does not outlive the session. */ + public static void forget(UUID playerId) { + if (playerId != null) { + WINDOWS.remove(playerId); + } + } +} diff --git a/src/main/java/land/webgui/server/WebviewServerConfig.java b/src/main/java/land/webgui/server/WebviewServerConfig.java index 00cf420..d2992c7 100644 --- a/src/main/java/land/webgui/server/WebviewServerConfig.java +++ b/src/main/java/land/webgui/server/WebviewServerConfig.java @@ -47,6 +47,13 @@ private static Path configPath() { // Page shown instead of the vanilla death screen. Empty = keep the vanilla screen. private String deathScreenUrl = ""; + // Refuse players whose client has no compatible WebGUI, instead of letting them in + // with a warning. Off by default: WebGUI enhances a client, it does not gate entry. + private Boolean requireClientMod; + + // Page events accepted from one player per second; the rest are dropped. 0 = no limit. + private int pageEventsPerSecond = 20; + // {"version":"1.2.3"} or GitHub Releases {"tag_name":"v1.2.3","html_url":"..."}; empty = disabled private String updateCheckUrl = ""; @@ -81,6 +88,9 @@ private static void writeExample() { o.addProperty("deathScreenUrl", ""); + o.addProperty("requireClientMod", false); + o.addProperty("pageEventsPerSecond", 20); + o.addProperty("updateCheckUrl", ""); JsonArray origins = new JsonArray(); @@ -213,6 +223,23 @@ public static String deathScreenUrl() { return u == null ? "" : u.trim(); } + /** + * Whether a client without a compatible WebGUI is refused rather than warned. + * + * Default off. A server that opens pages the player cannot see still works — they + * just miss the pages — so kicking them is a policy the owner opts into, not + * something the mod decides for them. + */ + public static boolean requireClientMod() { + Boolean v = instance.requireClientMod; + return v != null && v; + } + + /** Page events accepted from one player per second; 0 means no limit. */ + public static int pageEventsPerSecond() { + return Math.max(0, instance.pageEventsPerSecond); + } + public static String updateCheckUrl() { String u = instance.updateCheckUrl; return u == null ? "" : u.trim(); From ca29e869cc16294ee61ba1592f9dd0527166647f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9A=D0=B8=D1=80=D0=B8=D0=BB=D0=BB=20=D0=93=D1=80=D0=B8?= =?UTF-8?q?=D0=BD=D0=B5=D0=B2?= Date: Wed, 9 Sep 2026 18:13:01 +0500 Subject: [PATCH 06/16] release: 1.7.1 --- CHANGELOG.md | 14 +++++++++++++- gradle.properties | 2 +- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 343d6b6..6f76e08 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## Unreleased +## 1.7.1 - 2026-09-09 ### Added - **Custom death screen.** Set `deathScreenUrl` in `config/webgui/server.json` and the vanilla death screen is replaced by your page. Leave it empty and nothing changes — the feature is opt-in because replacing that screen takes away the player's only way to respawn. @@ -8,6 +8,18 @@ - `window.webgui.respawn()` respawns the player, standing in for the vanilla Respawn button. - Escape does not close the page, exactly as the vanilla screen refuses to close. If the page fails to load, Escape starts working again and respawns instead, so a broken page can never trap a player. - The vanilla screen is left alone while the browser is still starting, so dying during the first seconds of a session shows the normal screen rather than nothing. +- **Version mismatches now say so.** The server introduces itself on join, and a client whose WebGUI speaks a different protocol gets a message naming both versions instead of watching pages quietly fail to open. +- **`requireClientMod`** in `config/webgui/server.json` (default `false`). When enabled, players without a compatible WebGUI are refused with a message that names this mod and the version the server runs. +- **`pageEventsPerSecond`** in `config/webgui/server.json` (default `20`, `0` disables). Caps how many `postToGame` messages one player's pages may send per second. +- **One JavaScript library: [`@webgui/client`](https://www.npmjs.com/package/@webgui/client).** Plain JS, React, Vue and Svelte in a single package with subpath imports (`@webgui/client/react` and so on). `@webgui/react`, `@webgui/vue` and `@webgui/svelte` were three parallel implementations of the same logic that had to be updated together for every new event; they still install and work, now as re-exports. +- The mod leaves the last death payload on `window.webgui.death`, the way it already did for `client` and `entity`, so a page whose bundle starts listening after the event has fired can still read it. + +### Changed +- **A NeoForge server no longer refuses clients that lack WebGUI.** Its channels were registered as required, so anyone without the mod — or with an older build of it — was disconnected with *"Incompatible client! Please use NeoForge <version>"*, which blames the wrong mod. They now join and are told, in chat, what the server runs and what they are missing. Set `requireClientMod: true` to keep turning them away, with a message that actually explains why. + +### Fixed +- **Fixed the death screen arriving late on NeoForge.** The page URL was pushed on join on Fabric but not on NeoForge, where the client only learned about it from the death packet itself. +- Fixed a page event flood from one player being able to saturate the server thread. ## 1.7.0 - 2026-09-08 diff --git a/gradle.properties b/gradle.properties index 5a52918..06b3ec0 100644 --- a/gradle.properties +++ b/gradle.properties @@ -2,7 +2,7 @@ org.gradle.jvmargs=-Xmx4G org.gradle.parallel=true dev.kikugie.stonecutter.hard_mode=true -mod_version=1.7.0 +mod_version=1.7.1 maven_group=land.webgui archives_base_name=webgui From c57c02912a854dc2f987f7f5539bb16692c8f3a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9A=D0=B8=D1=80=D0=B8=D0=BB=D0=BB=20=D0=93=D1=80=D0=B8?= =?UTF-8?q?=D0=BD=D0=B5=D0=B2?= Date: Wed, 9 Sep 2026 19:41:29 +0500 Subject: [PATCH 07/16] Let a server host its own pages Getting an interface in front of players meant finding a web host, a domain and a deploy pipeline before you could change a button. Now the files go in config/webgui/web/ and a URL setting points at them with webgui:/index.html. They travel down the connection the player is already on, so there is no port to open and it works behind NAT like everything else a server sends. Content is addressed by SHA-256: a file the client already has is never fetched again, and a changed file is a different name, so a page cannot be stale. External hosting is untouched - http(s) URLs behave exactly as before and the two mix freely. The browser reads them over loopback rather than a custom scheme. A scheme would avoid the socket, but non-standard schemes are second-class in Chromium and debugging that through a game window is miserable. The port is fixed by default because a bundled page's origin is what a backend has to name in CORS, and one that moved every launch could not be configured at all. Serving the pages ourselves also fixes something older: window.webgui was injected only after a document finished loading, so a plain inline script calling it threw "Cannot read properties of undefined" - only pages that waited for DOMContentLoaded ever worked. Injecting at load start is not enough on its own, because executeJavaScript is a message to another process and the page's own scripts routinely win that race. For pages served here the bridge is woven into the document itself, which cannot race. Limits, so a mistake here cannot take a server down: 8 MiB per file, 64 MiB in total, 2000 files, a file list that fits one packet, and a per-player bandwidth budget alongside the existing page-event one. --- CHANGELOG.md | 15 + .../java/land/webgui/WebGUIAssetCache.java | 256 +++++++++++++ .../java/land/webgui/WebGUIAssetServer.java | 273 ++++++++++++++ src/main/java/land/webgui/WebGUIClient.java | 106 ++++++ src/main/java/land/webgui/WebGUIMod.java | 2 + .../land/webgui/WebGUITrustedOrigins.java | 17 + src/main/java/land/webgui/WebSession.java | 6 +- src/main/java/land/webgui/WebviewJoinHud.java | 8 + .../java/land/webgui/WebviewNetworking.java | 132 ++++++- .../land/webgui/WebviewPageLoadHooks.java | 12 +- .../java/land/webgui/WebviewPayloads.java | 127 +++++++ .../java/land/webgui/WebviewScriptInject.java | 22 +- .../land/webgui/server/WebviewAssetStore.java | 343 ++++++++++++++++++ .../land/webgui/server/WebviewAssets.java | 148 ++++++++ .../webgui/server/WebviewRateLimiter.java | 105 ++++-- .../webgui/server/WebviewServerConfig.java | 47 +++ .../webgui/server/WebviewAssetStoreTest.java | 179 +++++++++ .../webgui/server/WebviewRateLimiterTest.java | 115 ++++++ 18 files changed, 1876 insertions(+), 37 deletions(-) create mode 100644 src/main/java/land/webgui/WebGUIAssetCache.java create mode 100644 src/main/java/land/webgui/WebGUIAssetServer.java create mode 100644 src/main/java/land/webgui/server/WebviewAssetStore.java create mode 100644 src/main/java/land/webgui/server/WebviewAssets.java create mode 100644 src/test/java/land/webgui/server/WebviewAssetStoreTest.java create mode 100644 src/test/java/land/webgui/server/WebviewRateLimiterTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f76e08..ee618b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,20 @@ # Changelog +## Unreleased + +### Added +- **The server can host its own pages.** Drop files in `config/webgui/web/` and point any URL setting at them with `webgui:/index.html` — no web host, no domain, no port to open. They travel down the connection the player is already on, so it works behind NAT like everything else a server sends. + - Addressed by SHA-256: a client that already has a file never asks for it again, and a changed file is simply a different name, so a page can never be stale. + - The folder is created on first start with a working page in it, and `/webgui reload` picks up edits without a restart. + - Limits, so a mistake here cannot take a server down: 8 MiB per file, 64 MiB in total, 2000 files, and `assetBytesPerSecond` (default 4 MiB/s per player). + - **External hosting is unchanged.** `http://` and `https://` URLs behave exactly as before, and the two mix freely. + - One thing to know: a bundled page is served from `http://127.0.0.1:25580`, not from your domain, so your API needs to allow that origin in CORS. +- `window.webgui.assetsBase` — where the server's own files are served from, so a page can build a URL to one without knowing the port. +- `serveBundledPages`, `bundledPagesDir` and `assetBytesPerSecond` in `config/webgui/server.json`. + +### Fixed +- **Fixed `window.webgui` being undefined while a page was still loading.** The bridge was injected only after the document finished, so a plain inline script calling it threw `Cannot read properties of undefined`; only pages that waited for `DOMContentLoaded` or later ever worked. + ## 1.7.1 - 2026-09-09 ### Added diff --git a/src/main/java/land/webgui/WebGUIAssetCache.java b/src/main/java/land/webgui/WebGUIAssetCache.java new file mode 100644 index 0000000..e1d29a4 --- /dev/null +++ b/src/main/java/land/webgui/WebGUIAssetCache.java @@ -0,0 +1,256 @@ +package land.webgui; + +import land.webgui.server.WebviewAssetStore; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +/** + * The client's copy of the pages a server ships. + * + * Files arrive over the game connection rather than from a web host, so a server owner + * needs nothing but a folder to put an interface in front of their players. That choice + * has one consequence worth stating: a fetch is a round trip through the Minecraft + * connection, so it is slow the first time and free afterwards. + * + * Free afterwards because everything is addressed by hash. A file already on disk from + * an earlier session — or from another server shipping the same library — is never asked + * for again, and a changed file is simply a different name, so nothing is ever stale. + */ +public final class WebGUIAssetCache { + + /** Long enough for a slow connection, short enough that a broken server is not a hang. */ + private static final long FETCH_TIMEOUT_MS = 20_000; + + private static final Map MANIFEST = new ConcurrentHashMap<>(); + private static final Map PENDING = new ConcurrentHashMap<>(); + private static volatile String revision = ""; + private static volatile Path cacheDir; + + private WebGUIAssetCache() {} + + /** One in-flight download, awaited by the thread serving the browser's request. */ + private static final class Pending { + final CountDownLatch done = new CountDownLatch(1); + byte[][] chunks; + volatile boolean failed; + } + + // --- state from the server ---------------------------------------------- + + public static void setManifest(String newRevision, String manifest) { + MANIFEST.clear(); + MANIFEST.putAll(WebviewAssetStore.parseManifest(manifest)); + revision = newRevision == null ? "" : newRevision; + failAllPending(); + WebGUIMod.LOGGER.info("webgui: server ships {} page file(s), revision {}", MANIFEST.size(), revision); + } + + /** Called on disconnect: another server's pages are not ours to serve. */ + public static void clear() { + MANIFEST.clear(); + revision = ""; + failAllPending(); + } + + public static boolean isEmpty() { + return MANIFEST.isEmpty(); + } + + public static String revision() { + return revision; + } + + public static WebviewAssetStore.Asset lookup(String path) { + return MANIFEST.get(WebviewAssetStore.normalizeRequest(path)); + } + + // --- chunks in ----------------------------------------------------------- + + /** + * Takes one slice of a file the server is sending back. + * + * A {@code chunkCount} of zero is the server saying it has no such file. That still + * has to wake the waiting thread, or the browser request never completes. + */ + public static void onChunk(String path, int chunkIndex, int chunkCount, byte[] bytes) { + WebGUIMod.LOGGER.debug("webgui: chunk {} {}/{} ({} bytes)", path, chunkIndex, chunkCount, bytes.length); + Pending pending = PENDING.get(WebviewAssetStore.normalizeRequest(path)); + if (pending == null) { + // Nobody is waiting: a late chunk after a timeout, or a server sending + // something that was never asked for. + return; + } + if (chunkCount <= 0) { + pending.failed = true; + pending.done.countDown(); + return; + } + synchronized (pending) { + if (pending.chunks == null || pending.chunks.length != chunkCount) { + pending.chunks = new byte[chunkCount][]; + } + if (chunkIndex < 0 || chunkIndex >= chunkCount) { + pending.failed = true; + pending.done.countDown(); + return; + } + pending.chunks[chunkIndex] = bytes; + for (byte[] chunk : pending.chunks) { + if (chunk == null) { + return; + } + } + } + pending.done.countDown(); + } + + private static void failAllPending() { + for (Pending pending : PENDING.values()) { + pending.failed = true; + pending.done.countDown(); + } + PENDING.clear(); + } + + // --- the read the browser is waiting on ---------------------------------- + + /** + * The bytes for a path, from disk if they are already there and from the server if + * not. Null when the server does not have it, or did not answer in time. + * + * Called on the local HTTP server's thread, never on the game's: it blocks. + */ + public static byte[] fetch(String path) { + String key = WebviewAssetStore.normalizeRequest(path); + WebviewAssetStore.Asset asset = MANIFEST.get(key); + if (asset == null) { + return null; + } + + byte[] cached = readFromDisk(asset.sha256(), asset.size()); + if (cached != null) { + return cached; + } + + Pending pending = new Pending(); + Pending existing = PENDING.putIfAbsent(key, pending); + boolean weAsked = existing == null; + if (!weAsked) { + // Two browser requests for the same file race constantly - a page and its + // stylesheet, say. The second waits on the first rather than asking again. + pending = existing; + } else { + WebGUIMod.LOGGER.debug("webgui: asking the server for {}", key); + WebGUIClient.requestAsset(key); + } + + try { + if (!pending.done.await(FETCH_TIMEOUT_MS, TimeUnit.MILLISECONDS)) { + WebGUIMod.LOGGER.warn("webgui: timed out fetching {} from the server", key); + return null; + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return null; + } finally { + if (weAsked) { + PENDING.remove(key, pending); + } + } + + if (pending.failed || pending.chunks == null) { + return null; + } + + byte[] joined = join(pending.chunks); + String actual = WebviewAssetStore.sha256(joined); + if (!actual.equals(asset.sha256())) { + // The manifest is the only thing saying what this file should be. Writing + // mismatched bytes under that hash would poison the cache for every future + // session, including on other servers shipping the same file. + WebGUIMod.LOGGER.warn("webgui: {} did not match its hash; discarding", key); + return null; + } + writeToDisk(actual, joined); + return joined; + } + + private static byte[] join(byte[][] chunks) { + int total = 0; + for (byte[] chunk : chunks) { + total += chunk.length; + } + byte[] out = new byte[total]; + int offset = 0; + for (byte[] chunk : chunks) { + System.arraycopy(chunk, 0, out, offset, chunk.length); + offset += chunk.length; + } + return out; + } + + // --- disk ---------------------------------------------------------------- + + /** Where files live between sessions. Set once, from the client's game directory. */ + public static void useCacheDir(Path dir) { + cacheDir = dir; + try { + Files.createDirectories(dir); + } catch (IOException e) { + WebGUIMod.LOGGER.warn("webgui: no page cache at {} ({}); files will be refetched each time", + dir, e.getMessage()); + cacheDir = null; + } + } + + private static Path fileFor(String sha256) { + Path dir = cacheDir; + if (dir == null || sha256 == null || sha256.length() != 64 || !sha256.matches("[0-9a-f]{64}")) { + return null; + } + // Two levels of fan-out: a busy client can accumulate thousands of files, and + // some filesystems get slow long before that in one directory. + return dir.resolve(sha256.substring(0, 2)).resolve(sha256); + } + + private static byte[] readFromDisk(String sha256, long expectedSize) { + Path file = fileFor(sha256); + if (file == null || !Files.isRegularFile(file)) { + return null; + } + try { + byte[] bytes = Files.readAllBytes(file); + if (bytes.length != expectedSize || !WebviewAssetStore.sha256(bytes).equals(sha256)) { + // A truncated write from a previous crash. Drop it and refetch. + Files.deleteIfExists(file); + return null; + } + return bytes; + } catch (IOException e) { + return null; + } + } + + private static void writeToDisk(String sha256, byte[] bytes) { + Path file = fileFor(sha256); + if (file == null) { + return; + } + try { + Files.createDirectories(file.getParent()); + // Written aside and moved into place: a crash mid-write would otherwise + // leave a short file under a hash that says it is complete. + Path temp = file.resolveSibling(sha256 + ".part"); + Files.write(temp, bytes); + Files.move(temp, file, java.nio.file.StandardCopyOption.REPLACE_EXISTING); + } catch (IOException e) { + WebGUIMod.LOGGER.debug("webgui: could not cache {}: {}", sha256, e.getMessage()); + } + } +} diff --git a/src/main/java/land/webgui/WebGUIAssetServer.java b/src/main/java/land/webgui/WebGUIAssetServer.java new file mode 100644 index 0000000..ad26b1c --- /dev/null +++ b/src/main/java/land/webgui/WebGUIAssetServer.java @@ -0,0 +1,273 @@ +package land.webgui; + +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; +import land.webgui.server.WebviewAssetStore; + +import java.io.IOException; +import java.io.OutputStream; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.security.SecureRandom; +import java.util.HexFormat; +import java.util.concurrent.Executors; + +/** + * Hands the server's pages to the in-game browser over loopback. + * + * The browser has to read them from somewhere, and a plain HTTP origin is the only form + * every web API treats as ordinary. A custom scheme would avoid the socket, but + * non-standard schemes are second-class in Chromium — storage, fetch and service workers + * all behave differently — and debugging that through a game window is miserable. + * + * Bound to loopback only, so nothing is exposed to the network, and every URL carries a + * per-session token: another process on the machine cannot enumerate what a server sent + * us, and a page left over from a previous server cannot read the current one's files. + * + * The port is fixed by default rather than random, because a page served from here has + * origin {@code http://127.0.0.1:25580} — and a backend that wants to accept its API + * calls has to name that origin in CORS. A port that moved every launch would make that + * impossible to configure. + */ +public final class WebGUIAssetServer { + + /** The host the socket binds and every URL names. They must be the same string. */ + private static final String LOOPBACK = "127.0.0.1"; + + public static final int DEFAULT_PORT = 25580; + private static final int PORT_ATTEMPTS = 20; + + private static final SecureRandom RANDOM = new SecureRandom(); + + private static HttpServer server; + private static int port; + private static String sessionToken = ""; + + private WebGUIAssetServer() {} + + /** The origin pages are served from, or empty when the server is not running. */ + public static String origin() { + return server == null ? "" : "http://" + LOOPBACK + ":" + port; + } + + /** The base a page prepends to a bundled path, with the session token. */ + public static String base() { + return server == null ? "" : origin() + "/" + sessionToken; + } + + public static boolean running() { + return server != null; + } + + /** + * Starts the server if it is not already up, and rotates the session token. + * + * Called when a manifest arrives rather than at launch: a client that never joins a + * server that ships pages should not be listening on anything at all. + */ + public static synchronized void ensureStarted() { + rotateToken(); + if (server != null) { + return; + } + for (int candidate = DEFAULT_PORT; candidate < DEFAULT_PORT + PORT_ATTEMPTS; candidate++) { + try { + // Explicitly IPv4 loopback, not getLoopbackAddress(): on a machine with + // IPv6 that returns ::1, so the socket would listen on [::1] while every + // URL built below says 127.0.0.1. The port shows as listening and every + // connection to it is refused — which looked exactly like the mod's page + // server not working at all, on one loader and not the other. + HttpServer started = HttpServer.create( + new InetSocketAddress(InetAddress.getByName(LOOPBACK), candidate), 0); + started.createContext("/", WebGUIAssetServer::handle); + // A pool, not the caller thread: every request blocks while its file + // comes down the game connection, and a page loads dozens at once. + started.setExecutor(Executors.newFixedThreadPool(8, r -> { + Thread t = new Thread(r, "webgui-assets"); + t.setDaemon(true); + return t; + })); + started.start(); + server = started; + port = candidate; + WebGUIMod.LOGGER.info("webgui: serving the server's pages at {}", origin()); + return; + } catch (IOException e) { + // Port taken - most likely a second instance of the game on this machine. + } + } + WebGUIMod.LOGGER.error("webgui: no free port in {}..{} for the page server; bundled pages will not load", + DEFAULT_PORT, DEFAULT_PORT + PORT_ATTEMPTS - 1); + } + + /** Called on disconnect: the previous server's token must stop working immediately. */ + public static synchronized void invalidateSession() { + sessionToken = ""; + } + + private static void rotateToken() { + byte[] raw = new byte[16]; + RANDOM.nextBytes(raw); + sessionToken = HexFormat.of().formatHex(raw); + } + + /** + * Turns {@code webgui:/path} into a URL the browser can load. + * + * Anything else is returned untouched — an http(s) URL is a page hosted the ordinary + * way, and that has to keep working exactly as it did. + */ + public static String resolve(String url) { + if (url == null || url.isBlank()) { + return url; + } + String trimmed = url.trim(); + if (!trimmed.regionMatches(true, 0, "webgui:", 0, 7)) { + return trimmed; + } + String rest = trimmed.substring(7); + // Accept webgui:/index.html, webgui://index.html and webgui:index.html alike: + // the difference is invisible in a config file and nobody would guess right. + String query = ""; + int mark = rest.indexOf('?'); + if (mark >= 0) { + query = rest.substring(mark); + rest = rest.substring(0, mark); + } + String path = WebviewAssetStore.normalizeRequest(rest); + if (path.isEmpty()) { + path = "index.html"; + } + if (!running()) { + WebGUIMod.LOGGER.warn("webgui: {} cannot be opened - this server ships no pages", url); + return trimmed; + } + return base() + "/" + path + query; + } + + // --- serving -------------------------------------------------------------- + + private static void handle(HttpExchange exchange) { + try (exchange) { + String method = exchange.getRequestMethod(); + if (!"GET".equals(method) && !"HEAD".equals(method)) { + respond(exchange, 405, "text/plain; charset=utf-8", "Only GET".getBytes(StandardCharsets.UTF_8), null, false); + return; + } + + String raw = exchange.getRequestURI().getPath(); + WebGUIMod.LOGGER.debug("webgui: http request {}", raw); + String token = sessionToken; + String prefix = "/" + token + "/"; + if (token.isEmpty() || !raw.startsWith(prefix)) { + // Either nothing is connected, or this is a stale page from the last + // server. Both are "not yours to read", and both are a 404 rather than a + // 403 so nothing here confirms what does exist. + respond(exchange, 404, "text/plain; charset=utf-8", "Not found".getBytes(StandardCharsets.UTF_8), null, false); + return; + } + + String path = WebviewAssetStore.normalizeRequest(raw.substring(prefix.length())); + if (path.isEmpty()) { + path = "index.html"; + } + + WebviewAssetStore.Asset asset = WebGUIAssetCache.lookup(path); + if (asset == null) { + respond(exchange, 404, "text/plain; charset=utf-8", + ("Not in this server's pages: " + path).getBytes(StandardCharsets.UTF_8), null, false); + return; + } + + // The hash is the strongest validator there is, so a reload of an unchanged + // page costs a round trip to loopback and nothing else. + String ifNoneMatch = exchange.getRequestHeaders().getFirst("If-None-Match"); + if (ifNoneMatch != null && ifNoneMatch.contains(asset.sha256())) { + exchange.getResponseHeaders().add("ETag", "\"" + asset.sha256() + "\""); + exchange.sendResponseHeaders(304, -1); + return; + } + + byte[] bytes = WebGUIAssetCache.fetch(path); + if (bytes == null) { + respond(exchange, 504, "text/plain; charset=utf-8", + ("The server did not send " + path).getBytes(StandardCharsets.UTF_8), null, false); + return; + } + + if (asset.contentType().startsWith("text/html")) { + // No ETag on HTML: the bridge woven in below carries a per-session value, + // so a 304 could hand back a document pointing at a dead session. + respond(exchange, 200, asset.contentType(), withBridge(bytes), null, "HEAD".equals(method)); + return; + } + respond(exchange, 200, asset.contentType(), bytes, asset.sha256(), "HEAD".equals(method)); + } catch (Exception e) { + // Warn, not debug. This catch once swallowed the only evidence that bundled + // pages were failing outright on one loader, and the symptom above it was a + // blank screen with nothing in the log to explain it. + WebGUIMod.LOGGER.warn("webgui: page request failed for {}: {}", + exchange.getRequestURI(), e.toString()); + } + } + + /** + * Weaves the game bridge into a page before its own scripts can run. + * + * The mod also injects the bridge through CEF when a page starts loading, but that + * is a message to another process and the document's scripts routinely win the race + * — measurably so: the same page failed or succeeded depending on how long its + * subresources took to arrive. Serving these files ourselves is what makes a + * guarantee possible, so a page here can call {@code window.webgui} from a plain + * inline script and simply have it work. + * + * Inserted at the end of {@code }: late enough to leave the charset + * declaration in the first bytes where the parser looks for it, early enough to + * precede anything in the body. + */ + private static byte[] withBridge(byte[] html) { + String script = ""; + String document = new String(html, StandardCharsets.UTF_8); + + int head = indexOfIgnoreCase(document, ""); + if (head >= 0) { + return (document.substring(0, head) + script + document.substring(head)) + .getBytes(StandardCharsets.UTF_8); + } + // No head to speak of. Before the first script is still before anything that + // could ask for the bridge; failing that, the top of the document. + int firstScript = indexOfIgnoreCase(document, "= 0 ? firstScript : 0; + return (document.substring(0, at) + script + document.substring(at)) + .getBytes(StandardCharsets.UTF_8); + } + + private static int indexOfIgnoreCase(String haystack, String needle) { + return haystack.toLowerCase(java.util.Locale.ROOT).indexOf(needle); + } + + private static void respond(HttpExchange exchange, int status, String contentType, + byte[] body, String etag, boolean headOnly) throws IOException { + exchange.getResponseHeaders().add("Content-Type", contentType); + // Revalidate rather than cache: the path is stable while its content is not, and + // an operator editing a file expects a reload to show it. + exchange.getResponseHeaders().add("Cache-Control", "no-cache"); + if (etag != null) { + exchange.getResponseHeaders().add("ETag", "\"" + etag + "\""); + } + // These files go to every player on the server, so there is nothing here that + // reading from another origin could leak - and without this, a page hosted the + // ordinary way could not pull an image out of the bundled set. + exchange.getResponseHeaders().add("Access-Control-Allow-Origin", "*"); + + if (headOnly) { + exchange.sendResponseHeaders(status, -1); + return; + } + exchange.sendResponseHeaders(status, body.length); + try (OutputStream out = exchange.getResponseBody()) { + out.write(body); + } + } +} diff --git a/src/main/java/land/webgui/WebGUIClient.java b/src/main/java/land/webgui/WebGUIClient.java index 08882c8..96de44f 100644 --- a/src/main/java/land/webgui/WebGUIClient.java +++ b/src/main/java/land/webgui/WebGUIClient.java @@ -68,6 +68,16 @@ public void onInitializeClient() { ClientPlayNetworking.registerGlobalReceiver(WebviewPayloads.WebviewHelloS2CPayload.ID, (payload, context) -> { context.client().execute(() -> WebGUIHandshake.onHello(payload.protocolVersion(), payload.modVersion())); }); + + ClientPlayNetworking.registerGlobalReceiver(WebviewPayloads.WebviewAssetManifestS2CPayload.ID, (payload, context) -> { + context.client().execute(() -> onAssetManifest(payload.revision(), payload.manifest())); + }); + + ClientPlayNetworking.registerGlobalReceiver(WebviewPayloads.WebviewAssetChunkS2CPayload.ID, (payload, context) -> { + // Straight through, off the render thread: a page pulls dozens of files and + // every one of them would otherwise queue behind a frame. + WebGUIAssetCache.onChunk(payload.path(), payload.chunkIndex(), payload.chunkCount(), payload.bytes()); + }); //? } else { /*ClientPlayNetworking.registerGlobalReceiver(WebviewPayloads.OPEN_WEB_CHANNEL, (client, handler, buf, responseSender) -> { int protocolVersion = buf.readVarInt(); @@ -105,6 +115,22 @@ public void onInitializeClient() { int protocol = buf.readVarInt(); String version = buf.readString(WebviewPayloads.MAX_VERSION_LENGTH); client.execute(() -> WebGUIHandshake.onHello(protocol, version)); + }); + + ClientPlayNetworking.registerGlobalReceiver(WebviewPayloads.ASSET_MANIFEST_CHANNEL, (client, handler, buf, responseSender) -> { + String rev = buf.readString(WebviewPayloads.MAX_VERSION_LENGTH); + String manifest = buf.readString(WebviewPayloads.MAX_MANIFEST_LENGTH); + client.execute(() -> onAssetManifest(rev, manifest)); + }); + + ClientPlayNetworking.registerGlobalReceiver(WebviewPayloads.ASSET_CHUNK_CHANNEL, (client, handler, buf, responseSender) -> { + String path = buf.readString(WebviewPayloads.MAX_ASSET_PATH_LENGTH); + int index = buf.readVarInt(); + int count = buf.readVarInt(); + byte[] data = buf.readByteArray(WebviewPayloads.ASSET_CHUNK_BYTES); + // Straight through, off the render thread: a page pulls dozens of files and + // every one of them would otherwise queue behind a frame. + WebGUIAssetCache.onChunk(path, index, count, data); });*/ //? } @@ -129,6 +155,8 @@ private static void onLeaveWorld() { WebGUITrustedOrigins.clear(); WebGUIDeathScreen.clear(); WebGUIHandshake.clear(); + WebGUIAssetCache.clear(); + WebGUIAssetServer.invalidateSession(); } /** @@ -192,6 +220,8 @@ private static void onLeaveWorld() { WebGUITrustedOrigins.clear(); WebGUIDeathScreen.clear(); WebGUIHandshake.clear(); + WebGUIAssetCache.clear(); + WebGUIAssetServer.invalidateSession(); } // A url with no info is the join-time push; an info payload means the player @@ -228,6 +258,12 @@ public static void registerClientReceivers(RegisterPayloadHandlersEvent event) { (payload, ctx) -> ctx.enqueueWork(() -> onDeathPayload(payload.url(), payload.infoJson()))); reg.playToClient(WebviewPayloads.WebviewHelloS2CPayload.TYPE, WebviewPayloads.WebviewHelloS2CPayload.STREAM_CODEC, (payload, ctx) -> ctx.enqueueWork(() -> WebGUIHandshake.onHello(payload.protocolVersion(), payload.modVersion()))); + reg.playToClient(WebviewPayloads.WebviewAssetManifestS2CPayload.TYPE, WebviewPayloads.WebviewAssetManifestS2CPayload.STREAM_CODEC, + (payload, ctx) -> ctx.enqueueWork(() -> onAssetManifest(payload.revision(), payload.manifest()))); + reg.playToClient(WebviewPayloads.WebviewAssetChunkS2CPayload.TYPE, WebviewPayloads.WebviewAssetChunkS2CPayload.STREAM_CODEC, + // Straight through, not enqueued: a page pulls dozens of files and every + // one of them would otherwise queue behind a frame. + (payload, ctx) -> WebGUIAssetCache.onChunk(payload.path(), payload.chunkIndex(), payload.chunkCount(), payload.bytes())); } // Swaps the vanilla death screen for the server's page. Fabric needs a mixin @@ -279,4 +315,74 @@ private static void handleOpenPayload(Minecraft client, int mode, String url) { } }*/ //? } + + /** + * Takes the list of pages a server ships and gets ready to serve them. + * + * The local server starts here rather than at launch: a client that never joins a + * server using this feature has no reason to be listening on anything. + */ + private static void onAssetManifest(String revision, String manifest) { + WebGUIAssetCache.useCacheDir(assetCacheDir()); + WebGUIAssetCache.setManifest(revision, manifest); + if (WebGUIAssetCache.isEmpty()) { + WebGUIAssetServer.invalidateSession(); + return; + } + WebGUIAssetServer.ensureStarted(); + // These pages come from the server the player is on, so refusing them the command + // channel would leave the one place a server fully controls as the one place it + // cannot use — and unlike a web host, nobody else can put a file there. + WebGUITrustedOrigins.allowAlso(WebGUIAssetServer.origin()); + } + + /** + * Where downloaded pages are kept between sessions. + * + * Under the game directory rather than the config directory: this is a cache, it can + * be deleted at any time, and nothing in it is meant to be edited by hand. + */ + private static java.nio.file.Path assetCacheDir() { + //? if fabric { + return net.fabricmc.loader.api.FabricLoader.getInstance().getGameDir().resolve("webgui-cache"); + //? } else { + /*return net.neoforged.fml.loading.FMLPaths.GAMEDIR.get().resolve("webgui-cache");*/ + //? } + } + + /** + * Asks the server for one file. + * + * Called from the local page server's thread, so the send is handed to the client + * thread rather than done here: NeoForge's distributor reaches for the connection + * belonging to the calling thread and comes up empty off it, which showed up as + * every bundled page silently failing to load on NeoForge while Fabric was fine. + */ + public static void requestAsset(String path) { + //? if fabric { + net.minecraft.client.MinecraftClient.getInstance().execute(() -> sendAssetRequest(path)); + //? } else { + /*net.minecraft.client.Minecraft.getInstance().execute(() -> sendAssetRequest(path));*/ + //? } + } + + private static void sendAssetRequest(String path) { + //? if fabric { + //? if >=1.20.5 { + if (ClientPlayNetworking.canSend(WebviewPayloads.WebviewAssetRequestC2SPayload.ID)) { + ClientPlayNetworking.send(new WebviewPayloads.WebviewAssetRequestC2SPayload(path)); + } + //? } else { + /*net.minecraft.network.PacketByteBuf buf = net.fabricmc.fabric.api.networking.v1.PacketByteBufs.create(); + buf.writeString(path, WebviewPayloads.MAX_ASSET_PATH_LENGTH); + ClientPlayNetworking.send(WebviewPayloads.ASSET_REQUEST_CHANNEL, buf);*/ + //? } + //? } else { + /*//? if >=1.21.5 { + net.neoforged.neoforge.client.network.ClientPacketDistributor.sendToServer(new WebviewPayloads.WebviewAssetRequestC2SPayload(path)); + //? } else { + net.neoforged.neoforge.network.PacketDistributor.sendToServer(new WebviewPayloads.WebviewAssetRequestC2SPayload(path)); + //? }*/ + //? } + } } diff --git a/src/main/java/land/webgui/WebGUIMod.java b/src/main/java/land/webgui/WebGUIMod.java index f93d6d1..2e0f686 100644 --- a/src/main/java/land/webgui/WebGUIMod.java +++ b/src/main/java/land/webgui/WebGUIMod.java @@ -36,6 +36,7 @@ public void onInitialize() { ServerLifecycleEvents.SERVER_STARTING.register(server -> { WebviewServerConfig.load(); EntityBindingStore.load(); + land.webgui.server.WebviewAssets.reload(); }); EntityInteractionListener.register(); ServerLifecycleEvents.SERVER_STARTED.register(server -> { @@ -71,6 +72,7 @@ public void onInitialize() { private void onServerStarting(ServerStartingEvent event) { WebviewServerConfig.load(); EntityBindingStore.load(); + land.webgui.server.WebviewAssets.reload(); } private void onServerStarted(ServerStartedEvent event) { diff --git a/src/main/java/land/webgui/WebGUITrustedOrigins.java b/src/main/java/land/webgui/WebGUITrustedOrigins.java index 8b3bce6..b6b71c2 100644 --- a/src/main/java/land/webgui/WebGUITrustedOrigins.java +++ b/src/main/java/land/webgui/WebGUITrustedOrigins.java @@ -28,6 +28,23 @@ public static void set(String joined) { origins = next; } + /** + * Adds one more origin to the set the server sent. + * + * Used for the pages the server ships itself, which are served from loopback and so + * have an origin the server cannot know in advance. Nobody but that server can put a + * file there, so trusting it is no wider than trusting the list it just sent. + */ + public static void allowAlso(String url) { + String o = normalize(url); + if (o == null) { + return; + } + Set next = new HashSet<>(origins); + next.add(o); + origins = next; + } + public static void clear() { origins = Collections.emptySet(); } diff --git a/src/main/java/land/webgui/WebSession.java b/src/main/java/land/webgui/WebSession.java index e0dd0e4..6c4b7eb 100644 --- a/src/main/java/land/webgui/WebSession.java +++ b/src/main/java/land/webgui/WebSession.java @@ -78,7 +78,7 @@ public static RinkuBrowser openForGui(String url) { } else { closeActiveBrowser(); } - browser = Rinku.createBrowser(url, true); + browser = Rinku.createBrowser(WebGUIAssetServer.resolve(url), true); mode = Mode.GUI_SCREEN; WebviewClientBridge.clearCache(); return browser; @@ -87,12 +87,12 @@ public static RinkuBrowser openForGui(String url) { public static RinkuBrowser openForHud(String url) { closeSuspendedHudBrowser(); if (mode == Mode.HUD_OVERLAY && browser != null) { - browser.loadURL(url); + browser.loadURL(WebGUIAssetServer.resolve(url)); WebviewClientBridge.clearCache(); return browser; } closeActiveBrowser(); - browser = Rinku.createBrowser(url, true); + browser = Rinku.createBrowser(WebGUIAssetServer.resolve(url), true); mode = Mode.HUD_OVERLAY; WebviewClientBridge.clearCache(); return browser; diff --git a/src/main/java/land/webgui/WebviewJoinHud.java b/src/main/java/land/webgui/WebviewJoinHud.java index ded82af..df1ddd1 100644 --- a/src/main/java/land/webgui/WebviewJoinHud.java +++ b/src/main/java/land/webgui/WebviewJoinHud.java @@ -31,6 +31,10 @@ public static void register() { WebviewNetworking.sendTrustedOrigins(player, WebviewServerConfig.trustedCommandOriginsJoined()); + // Before any page URL: a page pointed at webgui:/ cannot resolve + // anything until the client knows what the server ships. + WebviewNetworking.sendAssetManifest(player); + // Pushed now, not at death: the client has to know whether to // suppress the vanilla death screen the instant it is opened. WebviewNetworking.sendDeathScreen(player, WebviewServerConfig.deathScreenUrl(), ""); @@ -77,6 +81,10 @@ private static void onPlayerJoin(PlayerEvent.PlayerLoggedInEvent event) { WebviewNetworking.sendTrustedOrigins(player, WebviewServerConfig.trustedCommandOriginsJoined()); + // Before any page URL: a page pointed at webgui:/ cannot resolve anything until + // the client knows what the server ships. + WebviewNetworking.sendAssetManifest(player); + // Pushed now, not at death: the client has to know whether to suppress the // vanilla death screen the instant it is opened. WebviewNetworking.sendDeathScreen(player, WebviewServerConfig.deathScreenUrl(), ""); diff --git a/src/main/java/land/webgui/WebviewNetworking.java b/src/main/java/land/webgui/WebviewNetworking.java index f12bdf2..b0d90f0 100644 --- a/src/main/java/land/webgui/WebviewNetworking.java +++ b/src/main/java/land/webgui/WebviewNetworking.java @@ -49,7 +49,10 @@ public static void registerPayloadTypes() { PayloadTypeRegistry.playS2C().register(WebviewPayloads.WebviewTrustedOriginsS2CPayload.ID, WebviewPayloads.WebviewTrustedOriginsS2CPayload.CODEC); PayloadTypeRegistry.playS2C().register(WebviewPayloads.WebviewDeathS2CPayload.ID, WebviewPayloads.WebviewDeathS2CPayload.CODEC); PayloadTypeRegistry.playS2C().register(WebviewPayloads.WebviewHelloS2CPayload.ID, WebviewPayloads.WebviewHelloS2CPayload.CODEC); + PayloadTypeRegistry.playS2C().register(WebviewPayloads.WebviewAssetManifestS2CPayload.ID, WebviewPayloads.WebviewAssetManifestS2CPayload.CODEC); + PayloadTypeRegistry.playS2C().register(WebviewPayloads.WebviewAssetChunkS2CPayload.ID, WebviewPayloads.WebviewAssetChunkS2CPayload.CODEC); PayloadTypeRegistry.playC2S().register(WebviewPayloads.WebviewPageEventC2SPayload.ID, WebviewPayloads.WebviewPageEventC2SPayload.CODEC); + PayloadTypeRegistry.playC2S().register(WebviewPayloads.WebviewAssetRequestC2SPayload.ID, WebviewPayloads.WebviewAssetRequestC2SPayload.CODEC); //? } } //? } else { @@ -68,13 +71,25 @@ public static void registerPayloadTypes() { net.minecraft.server.level.ServerPlayer sender = (net.minecraft.server.level.ServerPlayer) ctx.player(); // Checked here, off the server thread: enqueueing first would put the // flood on the tick loop, which is the thing being protected. - if (sender == null || !land.webgui.server.WebviewRateLimiter.allow(sender.getUUID(), sender.getName().getString())) { + if (sender == null || !land.webgui.server.WebviewRateLimiter.allowPageEvent(sender.getUUID(), sender.getName().getString())) { return; } ctx.enqueueWork(() -> WebviewServerEvents.firePageEvent(sender, payload.channel(), payload.jsonPayload())); }); + reg.playToServer(WebviewPayloads.WebviewAssetRequestC2SPayload.TYPE, + WebviewPayloads.WebviewAssetRequestC2SPayload.STREAM_CODEC, + (payload, ctx) -> { + net.minecraft.server.level.ServerPlayer sender = (net.minecraft.server.level.ServerPlayer) ctx.player(); + if (sender == null) { + return; + } + // Reading and slicing a file has no business on the tick loop, and + // the budget check inside is what keeps this from stalling it. + serveAsset(sender, payload.path()); + }); + // S2C types register on both sides (the server sends them), but their // handlers touch client-only classes — loading those on a dedicated // server trips the RuntimeDistCleaner. So: real handlers on the client, @@ -101,6 +116,10 @@ public static void registerPayloadTypes() { WebviewPayloads.WebviewDeathS2CPayload.STREAM_CODEC, (payload, ctx) -> {}); reg.playToClient(WebviewPayloads.WebviewHelloS2CPayload.TYPE, WebviewPayloads.WebviewHelloS2CPayload.STREAM_CODEC, (payload, ctx) -> {}); + reg.playToClient(WebviewPayloads.WebviewAssetManifestS2CPayload.TYPE, + WebviewPayloads.WebviewAssetManifestS2CPayload.STREAM_CODEC, (payload, ctx) -> {}); + reg.playToClient(WebviewPayloads.WebviewAssetChunkS2CPayload.TYPE, + WebviewPayloads.WebviewAssetChunkS2CPayload.STREAM_CODEC, (payload, ctx) -> {}); } }); }*/ @@ -115,22 +134,42 @@ public static void registerServerReceivers() { String json = payload.jsonPayload(); // Checked here, off the server thread: scheduling first would put the flood on // the tick loop, which is the thing being protected. - if (player == null || !land.webgui.server.WebviewRateLimiter.allow(player.getUuid(), player.getName().getString())) { + if (player == null || !land.webgui.server.WebviewRateLimiter.allowPageEvent(player.getUuid(), player.getName().getString())) { return; } context.server().execute(() -> WebviewServerEvents.PAGE_EVENT.invoker().onPageEvent(player, channel, json)); }); + + ServerPlayNetworking.registerGlobalReceiver(WebviewPayloads.WebviewAssetRequestC2SPayload.ID, (payload, context) -> { + ServerPlayerEntity player = context.player(); + if (player == null) { + return; + } + // Reading and slicing a file has no business on the tick loop, and the + // budget check inside is what keeps this from being a way to stall it. + serveAsset(player, payload.path()); + }); //? } else { /*ServerPlayNetworking.registerGlobalReceiver(WebviewPayloads.PAGE_EVENT_CHANNEL, (server, player, handler, buf, responseSender) -> { String channel = buf.readString(WebviewPayloads.MAX_EVENT_NAME_LENGTH); String json = buf.readString(WebviewPayloads.MAX_EVENT_DATA_LENGTH); // Checked here, off the server thread: scheduling first would put the flood on // the tick loop, which is the thing being protected. - if (player == null || !land.webgui.server.WebviewRateLimiter.allow(player.getUuid(), player.getName().getString())) { + if (player == null || !land.webgui.server.WebviewRateLimiter.allowPageEvent(player.getUuid(), player.getName().getString())) { return; } server.execute(() -> WebviewServerEvents.PAGE_EVENT.invoker().onPageEvent(player, channel, json)); + }); + + ServerPlayNetworking.registerGlobalReceiver(WebviewPayloads.ASSET_REQUEST_CHANNEL, (server, player, handler, buf, responseSender) -> { + String path = buf.readString(WebviewPayloads.MAX_ASSET_PATH_LENGTH); + if (player == null) { + return; + } + // Reading and slicing a file has no business on the tick loop, and the budget + // check inside is what keeps this from being a way to stall it. + serveAsset(player, path); });*/ //? } } @@ -276,6 +315,63 @@ public static ClientKind clientKind(ServerPlayerEntity player) { //? } } + /** Tells the client which pages this server ships. Skipped when it ships none. */ + public static void sendAssetManifest(ServerPlayerEntity player) { + //? if >=1.20.5 { + if (!land.webgui.server.WebviewAssets.enabled() + || !ServerPlayNetworking.canSend(player, WebviewPayloads.WebviewAssetManifestS2CPayload.ID)) { + return; + } + land.webgui.server.WebviewAssetStore store = land.webgui.server.WebviewAssets.store(); + ServerPlayNetworking.send(player, + new WebviewPayloads.WebviewAssetManifestS2CPayload(store.revision(), store.toManifest())); + //? } else { + /*if (!land.webgui.server.WebviewAssets.enabled() + || !ServerPlayNetworking.canSend(player, WebviewPayloads.ASSET_MANIFEST_CHANNEL)) { + return; + } + land.webgui.server.WebviewAssetStore store = land.webgui.server.WebviewAssets.store(); + PacketByteBuf buf = PacketByteBufs.create(); + buf.writeString(store.revision(), WebviewPayloads.MAX_VERSION_LENGTH); + buf.writeString(store.toManifest(), WebviewPayloads.MAX_MANIFEST_LENGTH); + ServerPlayNetworking.send(player, WebviewPayloads.ASSET_MANIFEST_CHANNEL, buf);*/ + //? } + } + + /** + * Answers one asset request, in slices. + * + * Always answers, even with nothing: the client has a browser request parked on this, + * and a page that hangs forever is far worse to diagnose than one that reports a + * missing file. + */ + public static void serveAsset(ServerPlayerEntity player, String requested) { + String path = land.webgui.server.WebviewAssetStore.normalizeRequest(requested); + java.util.List chunks = land.webgui.server.WebviewAssets.chunksFor( + player.getUuid(), player.getName().getString(), path); + + if (chunks.isEmpty()) { + sendAssetChunk(player, path, 0, 0, new byte[0]); + return; + } + for (int i = 0; i < chunks.size(); i++) { + sendAssetChunk(player, path, i, chunks.size(), chunks.get(i)); + } + } + + private static void sendAssetChunk(ServerPlayerEntity player, String path, int index, int count, byte[] bytes) { + //? if >=1.20.5 { + ServerPlayNetworking.send(player, new WebviewPayloads.WebviewAssetChunkS2CPayload(path, index, count, bytes)); + //? } else { + /*PacketByteBuf buf = PacketByteBufs.create(); + buf.writeString(path, WebviewPayloads.MAX_ASSET_PATH_LENGTH); + buf.writeVarInt(index); + buf.writeVarInt(count); + buf.writeByteArray(bytes); + ServerPlayNetworking.send(player, WebviewPayloads.ASSET_CHUNK_CHANNEL, buf);*/ + //? } + } + public static void sendTrustedOrigins(ServerPlayerEntity player, String origins) { String o = origins == null ? "" : origins; //? if >=1.20.5 { @@ -342,6 +438,36 @@ public static void sendTrustedOrigins(ServerPlayer player, String origins) { PacketDistributor.sendToPlayer(player, new WebviewPayloads.WebviewTrustedOriginsS2CPayload(origins == null ? "" : origins)); } + // Tells the client which pages this server ships. Skipped when it ships none. + public static void sendAssetManifest(ServerPlayer player) { + if (!land.webgui.server.WebviewAssets.enabled() + || !player.connection.hasChannel(WebviewPayloads.WebviewAssetManifestS2CPayload.TYPE)) { + return; + } + land.webgui.server.WebviewAssetStore store = land.webgui.server.WebviewAssets.store(); + PacketDistributor.sendToPlayer(player, + new WebviewPayloads.WebviewAssetManifestS2CPayload(store.revision(), store.toManifest())); + } + + // Answers one asset request, in slices. Always answers, even with nothing: the client + // has a browser request parked on this, and a page that hangs forever is far worse to + // diagnose than one that reports a missing file. + public static void serveAsset(ServerPlayer player, String requested) { + String path = land.webgui.server.WebviewAssetStore.normalizeRequest(requested); + java.util.List chunks = land.webgui.server.WebviewAssets.chunksFor( + player.getUUID(), player.getName().getString(), path); + + if (chunks.isEmpty()) { + PacketDistributor.sendToPlayer(player, + new WebviewPayloads.WebviewAssetChunkS2CPayload(path, 0, 0, new byte[0])); + return; + } + for (int i = 0; i < chunks.size(); i++) { + PacketDistributor.sendToPlayer(player, + new WebviewPayloads.WebviewAssetChunkS2CPayload(path, i, chunks.size(), chunks.get(i))); + } + } + // Tells the client which WebGUI the server runs. Silently skipped for a client that // has no such channel — that case is reported to the player as text instead. public static void sendHello(ServerPlayer player) { diff --git a/src/main/java/land/webgui/WebviewPageLoadHooks.java b/src/main/java/land/webgui/WebviewPageLoadHooks.java index 6f33033..399a068 100644 --- a/src/main/java/land/webgui/WebviewPageLoadHooks.java +++ b/src/main/java/land/webgui/WebviewPageLoadHooks.java @@ -23,6 +23,16 @@ public void onLoadStart(CefBrowser browser, CefFrame frame, org.cef.network.CefRequest.TransitionType transitionType) { RinkuBrowser active = WebSession.browser(); if (active == null || browser != active) return; + + // Before the document's own scripts, not after them. Injecting only at + // load end meant a plain + """); + WebGUIMod.LOGGER.info("webgui: created a starter page at {}", index); + } +} diff --git a/src/main/java/land/webgui/server/WebviewRateLimiter.java b/src/main/java/land/webgui/server/WebviewRateLimiter.java index f288364..6bf43f8 100644 --- a/src/main/java/land/webgui/server/WebviewRateLimiter.java +++ b/src/main/java/land/webgui/server/WebviewRateLimiter.java @@ -8,19 +8,38 @@ import java.util.concurrent.atomic.AtomicLong; /** - * Caps how fast one player's pages may talk to the server. + * Caps how much of the server one player's pages may consume per second. * - * The page-event channel is the only thing a client can push at a WebGUI server, and a - * page is web content: a loop in someone's JavaScript — or a hostile page a player was - * talked into opening — can fire it as fast as the socket allows, and every message is - * dispatched on the server thread. Without a ceiling that is a tick-rate denial of - * service that any player can trigger from a browser. + * Everything a client can push at a WebGUI server comes from web content: a loop in + * someone's JavaScript — or a hostile page a player was talked into opening — can fire + * as fast as the socket allows, and the work lands on the server thread. Without a + * ceiling that is a tick-rate denial of service any player can trigger from a browser. * - * A fixed window rather than a token bucket: it needs one long per player, and the - * failure it guards against is a flood, not a precisely-shaped burst. + * Two things are metered, in separate buckets, because they fail differently: page + * events cost server-thread time and are counted one by one, while asset requests cost + * bandwidth and are counted in bytes. + * + * A fixed window rather than a token bucket: it needs one long per player per bucket, + * and the failure it guards against is a flood, not a precisely-shaped burst. */ public final class WebviewRateLimiter { + /** What is being metered. */ + public enum Bucket { + /** {@code postToGame} messages, counted one each. */ + PAGE_EVENTS("page events", "pageEventsPerSecond"), + /** Bundled page bytes, counted by file size. */ + ASSET_BYTES("asset bytes", "assetBytesPerSecond"); + + final String what; + final String setting; + + Bucket(String what, String setting) { + this.what = what; + this.setting = setting; + } + } + /** Stops a churn of joining players from growing the map without bound. */ private static final int MAX_TRACKED = 1000; @@ -31,58 +50,86 @@ public final class WebviewRateLimiter { */ private static final long START_NANOS = System.nanoTime(); - private static final Map WINDOWS = new ConcurrentHashMap<>(); + /** 26 bits of count: enough for a bandwidth budget in bytes, not just a message tally. */ + private static final int COUNT_BITS = 26; + private static final long COUNT_MASK = (1L << COUNT_BITS) - 1; + + private static final Map WINDOWS = new ConcurrentHashMap<>(); private WebviewRateLimiter() {} private static final class Window { - final AtomicLong state = new AtomicLong(); // high 42 bits: second; low 22: count + /** High bits: the second this window covers. Low bits: what has been spent in it. */ + final AtomicLong state = new AtomicLong(); volatile boolean warned; } + /** Convenience for the page-event channel, whose limit lives in the config. */ + public static boolean allowPageEvent(UUID playerId, String playerName) { + return allow(playerId, playerName, Bucket.PAGE_EVENTS, 1, WebviewServerConfig.pageEventsPerSecond()); + } + /** - * Whether this event may be handled. Counts the call either way, so a page that keeps - * hammering stays blocked for the rest of its second. + * Whether this much may be spent. Charges the cost either way, so something that + * keeps hammering stays blocked for the rest of its second rather than slipping + * through whenever the budget briefly allows. + * + * @param cost what this call consumes — one message, or a file's bytes + * @param limitPerSecond the ceiling; zero or less disables the bucket */ - public static boolean allow(UUID playerId, String playerName) { - int limit = WebviewServerConfig.pageEventsPerSecond(); - if (limit <= 0 || playerId == null) { + public static boolean allow(UUID playerId, String playerName, Bucket bucket, long cost, long limitPerSecond) { + if (limitPerSecond <= 0 || playerId == null) { return true; } if (WINDOWS.size() > MAX_TRACKED) { WINDOWS.clear(); } - Window w = WINDOWS.computeIfAbsent(playerId, id -> new Window()); + Window[] buckets = WINDOWS.computeIfAbsent(playerId, id -> new Window[Bucket.values().length]); + Window w = buckets[bucket.ordinal()]; + if (w == null) { + // Racing here at worst creates a second window and loses a few counts of a + // single second, which is not worth a lock on every asset request. + w = new Window(); + buckets[bucket.ordinal()] = w; + } + long nowSecond = (System.nanoTime() - START_NANOS) / 1_000_000_000L; - long updated = w.state.updateAndGet(prev -> { - long second = prev >>> 22; - long count = prev & 0x3F_FFFF; + long charge = Math.max(1L, cost); + final Window window = w; + long updated = window.state.updateAndGet(prev -> { + long second = prev >>> COUNT_BITS; + long spent = prev & COUNT_MASK; if (second != nowSecond) { - return (nowSecond << 22) | 1L; + return (nowSecond << COUNT_BITS) | Math.min(charge, COUNT_MASK); } // Saturate rather than wrap into the second field. - return (second << 22) | Math.min(count + 1, 0x3F_FFFF); + return (second << COUNT_BITS) | Math.min(spent + charge, COUNT_MASK); }); - long count = updated & 0x3F_FFFF; - if (count <= limit) { - w.warned = false; + long spent = updated & COUNT_MASK; + if (spent <= limitPerSecond) { + window.warned = false; return true; } - if (!w.warned) { - w.warned = true; + if (!window.warned) { + window.warned = true; WebGUIMod.LOGGER.warn( - "webgui: dropping page events from {} — over {}/s. Raise or disable pageEventsPerSecond in config/webgui/server.json if this is legitimate.", - playerName, limit); + "webgui: dropping {} from {} — over {}/s. Raise or disable {} in config/webgui/server.json if this is legitimate.", + bucket.what, playerName, limitPerSecond, bucket.setting); } return false; } - /** Called when a player leaves, so their window does not outlive the session. */ + /** Called when a player leaves, so their windows do not outlive the session. */ public static void forget(UUID playerId) { if (playerId != null) { WINDOWS.remove(playerId); } } + + /** Test seam: drops every window so one test cannot inherit another's budget. */ + static void resetForTests() { + WINDOWS.clear(); + } } diff --git a/src/main/java/land/webgui/server/WebviewServerConfig.java b/src/main/java/land/webgui/server/WebviewServerConfig.java index d2992c7..8bb1cff 100644 --- a/src/main/java/land/webgui/server/WebviewServerConfig.java +++ b/src/main/java/land/webgui/server/WebviewServerConfig.java @@ -47,6 +47,11 @@ private static Path configPath() { // Page shown instead of the vanilla death screen. Empty = keep the vanilla screen. private String deathScreenUrl = ""; + // Serve pages from config/webgui/web/ over the players' own connection, so a server + // needs no web host to use any of this. Directory relative to config/webgui. + private Boolean serveBundledPages; + private String bundledPagesDir = "web"; + // Refuse players whose client has no compatible WebGUI, instead of letting them in // with a warning. Off by default: WebGUI enhances a client, it does not gate entry. private Boolean requireClientMod; @@ -54,6 +59,9 @@ private static Path configPath() { // Page events accepted from one player per second; the rest are dropped. 0 = no limit. private int pageEventsPerSecond = 20; + // Bundled page bytes one player may pull per second. 0 = no limit. + private int assetBytesPerSecond = 4 * 1024 * 1024; + // {"version":"1.2.3"} or GitHub Releases {"tag_name":"v1.2.3","html_url":"..."}; empty = disabled private String updateCheckUrl = ""; @@ -88,6 +96,9 @@ private static void writeExample() { o.addProperty("deathScreenUrl", ""); + o.addProperty("serveBundledPages", true); + o.addProperty("bundledPagesDir", "web"); + o.addProperty("requireClientMod", false); o.addProperty("pageEventsPerSecond", 20); @@ -223,6 +234,30 @@ public static String deathScreenUrl() { return u == null ? "" : u.trim(); } + /** + * Whether the server ships its own pages to clients over their game connection. + * + * On by default, and harmless when the directory is empty: it is the whole point of + * the feature that a server owner needs nothing but a folder to get started. + */ + public static boolean serveBundledPages() { + Boolean v = instance.serveBundledPages; + return v == null || v; + } + + /** Where those pages live, resolved under {@code config/webgui}. */ + public static Path bundledPagesRoot() { + String dir = instance.bundledPagesDir; + if (dir == null || dir.isBlank()) { + dir = "web"; + } + Path base = configPath().getParent(); + Path resolved = base.resolve(dir).normalize(); + // A path escaping config/webgui would publish something the operator did not + // mean to publish, and this value comes out of an editable file. + return resolved.startsWith(base.normalize()) ? resolved : base.resolve("web"); + } + /** * Whether a client without a compatible WebGUI is refused rather than warned. * @@ -240,6 +275,17 @@ public static int pageEventsPerSecond() { return Math.max(0, instance.pageEventsPerSecond); } + /** + * Bundled page bytes one player may pull per second; 0 means no limit. + * + * A page bundle is fetched once and then cached by hash, so this only has to be + * generous enough for a first load — it exists to stop one client asking for the + * same 8 MB file forty times a second. + */ + public static int assetBytesPerSecond() { + return Math.max(0, instance.assetBytesPerSecond); + } + public static String updateCheckUrl() { String u = instance.updateCheckUrl; return u == null ? "" : u.trim(); @@ -277,6 +323,7 @@ public static String reload() { save(); WebGUIMod.LOGGER.info("webgui: generated new token secret during reload"); } + WebviewAssets.reload(); WebGUIMod.LOGGER.info("webgui: config reloaded from {}", path); return "WebGUI config reloaded from " + path.getFileName(); } catch (IOException e) { diff --git a/src/test/java/land/webgui/server/WebviewAssetStoreTest.java b/src/test/java/land/webgui/server/WebviewAssetStoreTest.java new file mode 100644 index 0000000..e7716fc --- /dev/null +++ b/src/test/java/land/webgui/server/WebviewAssetStoreTest.java @@ -0,0 +1,179 @@ +package land.webgui.server; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * The asset store decides which bytes leave the server, so its path handling and its + * limits are the parts worth testing directly. Touches no Minecraft classes. + */ +class WebviewAssetStoreTest { + + private static void write(Path file, String content) throws IOException { + Files.createDirectories(file.getParent()); + Files.writeString(file, content, StandardCharsets.UTF_8); + } + + @Test + void scansFilesWithHashSizeAndType(@TempDir Path root) throws IOException { + write(root.resolve("index.html"), "

hi

"); + write(root.resolve("assets/app.js"), "console.log(1)"); + + WebviewAssetStore store = WebviewAssetStore.scan(root); + + assertEquals(2, store.fileCount()); + WebviewAssetStore.Asset page = store.find("index.html"); + assertNotNull(page); + assertEquals("text/html; charset=utf-8", page.contentType()); + assertEquals(11, page.size()); + assertEquals(WebviewAssetStore.sha256("

hi

".getBytes(StandardCharsets.UTF_8)), page.sha256()); + assertEquals("text/javascript; charset=utf-8", store.find("assets/app.js").contentType()); + } + + @Test + void nestedPathsUseForwardSlashesOnEveryPlatform(@TempDir Path root) throws IOException { + write(root.resolve("a").resolve("b").resolve("c.css"), "body{}"); + + WebviewAssetStore store = WebviewAssetStore.scan(root); + + assertNotNull(store.find("a/b/c.css")); + // The same file requested the way a Windows path would spell it. + assertNotNull(store.find("a\\b\\c.css")); + } + + @Test + void missingDirectoryIsAnEmptyStoreRatherThanAFailure(@TempDir Path root) { + WebviewAssetStore store = WebviewAssetStore.scan(root.resolve("nope")); + + assertTrue(store.isEmpty()); + assertEquals(0, store.totalBytes()); + } + + // --- the part that decides which bytes leave the machine ----------------- + + @Test + void traversalCannotReachOutsideTheRoot(@TempDir Path tmp) throws IOException { + Path root = tmp.resolve("web"); + write(root.resolve("index.html"), "public"); + write(tmp.resolve("secret.txt"), "private"); + + WebviewAssetStore store = WebviewAssetStore.scan(root); + + assertNull(store.find("../secret.txt")); + assertNull(store.find("..\\secret.txt")); + assertNull(store.find("a/../../secret.txt")); + assertNull(store.read("../secret.txt")); + } + + @Test + void normalizeCollapsesEquivalentSpellingsOfOnePath() { + assertEquals("a/b.js", WebviewAssetStore.normalizeRequest("/a/b.js")); + assertEquals("a/b.js", WebviewAssetStore.normalizeRequest("./a/./b.js")); + assertEquals("a/b.js", WebviewAssetStore.normalizeRequest("a//b.js")); + assertEquals("a/b.js", WebviewAssetStore.normalizeRequest("x/../a/b.js")); + assertEquals("a/b.js", WebviewAssetStore.normalizeRequest("a/b.js?v=2")); + assertEquals("a/b.js", WebviewAssetStore.normalizeRequest("a/b.js#top")); + // Climbing past the root leaves nothing rather than escaping. + assertEquals("secret", WebviewAssetStore.normalizeRequest("../../secret")); + } + + @Test + void oversizedFilesAreSkippedAndReported(@TempDir Path root) throws IOException { + Files.createDirectories(root); + Files.write(root.resolve("huge.bin"), new byte[(int) WebviewAssetStore.MAX_FILE_BYTES + 1]); + write(root.resolve("small.html"), "ok"); + + WebviewAssetStore store = WebviewAssetStore.scan(root); + + assertNull(store.find("huge.bin")); + assertNotNull(store.find("small.html")); + assertTrue(store.problems().stream().anyMatch(p -> p.contains("huge.bin")), + "the operator should be told what was skipped: " + store.problems()); + } + + // --- serving ------------------------------------------------------------- + + @Test + void readReturnsTheScannedBytes(@TempDir Path root) throws IOException { + write(root.resolve("index.html"), "

hi

"); + + WebviewAssetStore store = WebviewAssetStore.scan(root); + + assertEquals("

hi

", new String(store.read("index.html"), StandardCharsets.UTF_8)); + assertEquals("

hi

", new String(store.read("/index.html"), StandardCharsets.UTF_8)); + } + + @Test + void readRefusesAFileEditedSinceTheScan(@TempDir Path root) throws IOException { + Path page = root.resolve("index.html"); + write(page, "original"); + WebviewAssetStore store = WebviewAssetStore.scan(root); + + // The directory belongs to the operator and they may edit it while the server + // runs. Handing back bytes whose hash no longer matches would make the client's + // content-addressed cache hold a file under the wrong name, for good. + write(page, "edited in place"); + + assertNull(store.read("index.html")); + } + + @Test + void readOfAnUnknownPathIsNull(@TempDir Path root) throws IOException { + write(root.resolve("index.html"), "hi"); + + assertNull(WebviewAssetStore.scan(root).read("nope.html")); + } + + // --- manifest ------------------------------------------------------------ + + @Test + void manifestRoundTrips(@TempDir Path root) throws IOException { + write(root.resolve("index.html"), "

hi

"); + write(root.resolve("assets/app.js"), "console.log(1)"); + WebviewAssetStore store = WebviewAssetStore.scan(root); + + Map parsed = WebviewAssetStore.parseManifest(store.toManifest()); + + assertEquals(store.assets(), parsed); + } + + @Test + void revisionChangesWithContentAndNotWithARescan(@TempDir Path root) throws IOException { + write(root.resolve("index.html"), "one"); + String before = WebviewAssetStore.scan(root).revision(); + + assertEquals(before, WebviewAssetStore.scan(root).revision()); + + write(root.resolve("index.html"), "two"); + assertFalse(before.equals(WebviewAssetStore.scan(root).revision())); + } + + @Test + void emptyManifestParsesToNothing() { + assertTrue(WebviewAssetStore.parseManifest("").isEmpty()); + assertTrue(WebviewAssetStore.parseManifest(null).isEmpty()); + assertTrue(WebviewAssetStore.parseManifest("garbage without tabs\n").isEmpty()); + } + + @Test + void contentTypesCoverTheThingsAPageActuallyLoads() { + assertEquals("text/html; charset=utf-8", WebviewAssetStore.contentTypeFor("a.html")); + assertEquals("text/javascript; charset=utf-8", WebviewAssetStore.contentTypeFor("a.mjs")); + assertEquals("application/wasm", WebviewAssetStore.contentTypeFor("a.wasm")); + assertEquals("font/woff2", WebviewAssetStore.contentTypeFor("a.WOFF2")); + assertEquals("application/octet-stream", WebviewAssetStore.contentTypeFor("a.unknown")); + assertEquals("application/octet-stream", WebviewAssetStore.contentTypeFor("noextension")); + } +} diff --git a/src/test/java/land/webgui/server/WebviewRateLimiterTest.java b/src/test/java/land/webgui/server/WebviewRateLimiterTest.java new file mode 100644 index 0000000..b7d8d71 --- /dev/null +++ b/src/test/java/land/webgui/server/WebviewRateLimiterTest.java @@ -0,0 +1,115 @@ +package land.webgui.server; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * The limiter is the only thing standing between a page's JavaScript and the server + * thread, so its arithmetic is worth checking directly. Touches no Minecraft classes. + */ +class WebviewRateLimiterTest { + + private static final String NAME = "Steve"; + + private UUID alice; + private UUID bob; + + @BeforeEach + void freshWindows() { + WebviewRateLimiter.resetForTests(); + alice = UUID.randomUUID(); + bob = UUID.randomUUID(); + } + + private static boolean allow(UUID id, long cost, long limit) { + return WebviewRateLimiter.allow(id, NAME, WebviewRateLimiter.Bucket.PAGE_EVENTS, cost, limit); + } + + @Test + void allowsUpToTheLimitThenRefuses() { + for (int i = 1; i <= 5; i++) { + assertTrue(allow(alice, 1, 5), "call " + i + " should be inside the limit"); + } + assertFalse(allow(alice, 1, 5)); + assertFalse(allow(alice, 1, 5)); + } + + @Test + void chargesEvenWhenRefused() { + // Otherwise a page that keeps hammering slips a request through every time the + // window happens to have room, which is exactly the flood being guarded against. + for (int i = 0; i < 100; i++) { + allow(alice, 1, 3); + } + assertFalse(allow(alice, 1, 3)); + } + + @Test + void playersAreMeteredSeparately() { + for (int i = 0; i < 10; i++) { + allow(alice, 1, 2); + } + + assertTrue(allow(bob, 1, 2), "one player's flood must not block another"); + } + + @Test + void bucketsAreMeteredSeparately() { + for (int i = 0; i < 10; i++) { + WebviewRateLimiter.allow(alice, NAME, WebviewRateLimiter.Bucket.PAGE_EVENTS, 1, 2); + } + + assertTrue(WebviewRateLimiter.allow(alice, NAME, WebviewRateLimiter.Bucket.ASSET_BYTES, 1024, 1_000_000), + "spending the event budget must not spend the bandwidth budget"); + } + + @Test + void costIsChargedByWeightNotByCall() { + assertTrue(WebviewRateLimiter.allow(alice, NAME, WebviewRateLimiter.Bucket.ASSET_BYTES, 600_000, 1_000_000)); + + assertFalse(WebviewRateLimiter.allow(alice, NAME, WebviewRateLimiter.Bucket.ASSET_BYTES, 600_000, 1_000_000), + "two 600 KB files must not both fit in a 1 MB budget"); + } + + @Test + void aLimitOfZeroDisablesTheBucket() { + for (int i = 0; i < 10_000; i++) { + assertTrue(allow(alice, 1, 0)); + } + } + + @Test + void aNullPlayerIsNeverBlocked() { + // Console and internal senders have no UUID, and refusing them would be a bug + // that only shows up in production. + assertTrue(allow(null, 1, 1)); + assertTrue(allow(null, 1, 1)); + } + + @Test + void forgettingAPlayerClearsTheirBudget() { + for (int i = 0; i < 10; i++) { + allow(alice, 1, 2); + } + assertFalse(allow(alice, 1, 2)); + + WebviewRateLimiter.forget(alice); + + assertTrue(allow(alice, 1, 2)); + } + + @Test + void aHugeCostSaturatesInsteadOfCorruptingTheWindow() { + // The second and the amount spent share one long; a cost that overflows the + // count must not carry into the second, or the window silently resets and the + // limit stops working. + assertFalse(WebviewRateLimiter.allow(alice, NAME, WebviewRateLimiter.Bucket.ASSET_BYTES, Long.MAX_VALUE, 1000)); + assertFalse(WebviewRateLimiter.allow(alice, NAME, WebviewRateLimiter.Bucket.ASSET_BYTES, 1, 1000), + "the window must still be spent after a saturating charge"); + } +} From 196f5af769e55810b38883b25f9fd1376c83dbae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9A=D0=B8=D1=80=D0=B8=D0=BB=D0=BB=20=D0=93=D1=80=D0=B8?= =?UTF-8?q?=D0=BD=D0=B5=D0=B2?= Date: Wed, 9 Sep 2026 20:17:38 +0500 Subject: [PATCH 08/16] Let a page hand the player a file, and take one from them Both were dead controls. CEF asks the host where to put a download and, hearing nothing, throws it away, so an invoice or a CSV export was unclickable. A windowless browser cannot put up its own file chooser either, so did nothing at all and gave no hint why. Downloads land in webgui-downloads/ under the game directory and the player is told the name, because a file that arrives with no trace is one they will never find. The page does not choose where its bytes go: the suggested name is the one part of a download web content fully controls, so the path is stripped out of it, refused characters are replaced, and names Windows reserves are sidestepped - writing to CON succeeds and goes nowhere, which would look like the download working and leaving nothing behind. The chooser is LWJGL's, not Swing's: Rinku boots AWT headless so the game can own the window, and JFileChooser throws outright in that state. Both naming rules live in WebviewDownloadNames, apart from the handlers that use them. They are the only thing between attacker-controlled text and a filesystem call, and free of browser classes they can be tested on every version - the handlers themselves cannot even be loaded on some of them. Popups are deliberately not handled. CEF documents a callback for it, but JCEF never routes a popup to it on a windowless browser: with Chromium's popup blocker off and a page calling window.open outright, onBeforePopup did not fire once. Pages written for this environment can navigate with location.href instead. --- .../java/land/webgui/WebGUIAssetServer.java | 2 +- src/main/java/land/webgui/WebGUIClient.java | 4 + .../land/webgui/WebviewDownloadHandler.java | 126 +++++++++++++++++ .../land/webgui/WebviewDownloadNames.java | 108 ++++++++++++++ .../land/webgui/WebviewFileDialogHandler.java | 132 ++++++++++++++++++ .../land/webgui/WebviewPageLoadHooks.java | 2 +- .../java/land/webgui/WebviewScriptInject.java | 8 +- .../land/webgui/WebviewDownloadNamesTest.java | 128 +++++++++++++++++ 8 files changed, 506 insertions(+), 4 deletions(-) create mode 100644 src/main/java/land/webgui/WebviewDownloadHandler.java create mode 100644 src/main/java/land/webgui/WebviewDownloadNames.java create mode 100644 src/main/java/land/webgui/WebviewFileDialogHandler.java create mode 100644 src/test/java/land/webgui/WebviewDownloadNamesTest.java diff --git a/src/main/java/land/webgui/WebGUIAssetServer.java b/src/main/java/land/webgui/WebGUIAssetServer.java index ad26b1c..28231b7 100644 --- a/src/main/java/land/webgui/WebGUIAssetServer.java +++ b/src/main/java/land/webgui/WebGUIAssetServer.java @@ -227,7 +227,7 @@ private static void handle(HttpExchange exchange) { * precede anything in the body. */ private static byte[] withBridge(byte[] html) { - String script = ""; + String script = ""; String document = new String(html, StandardCharsets.UTF_8); int head = indexOfIgnoreCase(document, ""); diff --git a/src/main/java/land/webgui/WebGUIClient.java b/src/main/java/land/webgui/WebGUIClient.java index 96de44f..0d4e102 100644 --- a/src/main/java/land/webgui/WebGUIClient.java +++ b/src/main/java/land/webgui/WebGUIClient.java @@ -34,6 +34,8 @@ public void onInitializeClient() { Rinku.getClient().addDisplayHandler(new WebviewBrowserConsoleLogger()); WebviewPageToClientBridge.register(); WebviewPageLoadHooks.register(); + WebviewDownloadHandler.register(); + WebviewFileDialogHandler.register(); WebGUIMod.LOGGER.info("WebGUI bridge ready (console log, page↔game, client data)."); }); @@ -195,6 +197,8 @@ private static void handleOpenPayload(net.minecraft.client.MinecraftClient clien Rinku.getClient().addDisplayHandler(new WebviewBrowserConsoleLogger()); WebviewPageToClientBridge.register(); WebviewPageLoadHooks.register(); + WebviewDownloadHandler.register(); + WebviewFileDialogHandler.register(); WebGUIMod.LOGGER.info("WebGUI bridge ready (console log, page<->game, client data)."); }); diff --git a/src/main/java/land/webgui/WebviewDownloadHandler.java b/src/main/java/land/webgui/WebviewDownloadHandler.java new file mode 100644 index 0000000..a8ec368 --- /dev/null +++ b/src/main/java/land/webgui/WebviewDownloadHandler.java @@ -0,0 +1,126 @@ +package land.webgui; + +import de.keksuccino.rinku.Rinku; +import org.cef.browser.CefBrowser; +import org.cef.callback.CefBeforeDownloadCallback; +import org.cef.callback.CefDownloadItem; +import org.cef.callback.CefDownloadItemCallback; +import org.cef.handler.CefDownloadHandler; + +import java.nio.file.Files; +import java.nio.file.Path; + +/** + * Lets a page hand the player a file. + * + * With no handler CEF asks the host where to put a download and, hearing nothing, throws + * it away — so an invoice, a log, a schematic or a CSV export was simply unclickable. + * + * Everything lands in one folder under the game directory, named by the page's suggestion + * with the path stripped out of it. The page never chooses where its bytes go: it is web + * content, and a suggested name is the one part of a download an attacker fully controls. + */ +public final class WebviewDownloadHandler { + + private static final String FOLDER = "webgui-downloads"; + + private WebviewDownloadHandler() {} + + public static void register() { + // Rinku forwards this one itself, through a relay that lets several mods decide. + Rinku.getClient().addDownloadHandler(new CefDownloadHandler() { + + @Override + public boolean canDownload(CefBrowser browser, String url, String requestMethod) { + return true; + } + + @Override + public void onBeforeDownload(CefBrowser browser, CefDownloadItem item, + String suggestedName, CefBeforeDownloadCallback callback) { + start(item, suggestedName, callback); + } + + /** + * Rinku's own variant, which also asks whether this handler took the decision. + * Answering true stops it falling through to another mod's handler and saving + * the same file twice. + */ + @Override + public boolean onBeforeDownloadWithDecision(CefBrowser browser, CefDownloadItem item, + String suggestedName, CefBeforeDownloadCallback callback) { + start(item, suggestedName, callback); + return true; + } + + @Override + public void onDownloadUpdated(CefBrowser browser, CefDownloadItem item, + CefDownloadItemCallback callback) { + if (item != null && item.isComplete()) { + // Told, not silent. A file that arrives with no trace is a file the + // player will never find, and the folder is not one they know about. + WebGUIMod.LOGGER.info("webgui: downloaded {}", item.getFullPath()); + tell("Saved to " + FOLDER + "/" + fileName(item.getFullPath())); + } else if (item != null && item.isCanceled()) { + WebGUIMod.LOGGER.info("webgui: download cancelled ({})", item.getURL()); + } + } + }); + } + + private static void start(CefDownloadItem item, String suggestedName, CefBeforeDownloadCallback callback) { + if (callback == null) { + return; + } + try { + Path target = WebviewDownloadNames.free(directory(), WebviewDownloadNames.safeName(suggestedName)); + Files.createDirectories(target.getParent()); + // false: no "where do you want to save this" dialog. There is no window to + // put one in, and the answer is always the same folder. + callback.Continue(target.toAbsolutePath().toString(), false); + WebGUIMod.LOGGER.info("webgui: saving a download to {}", target); + } catch (Exception e) { + WebGUIMod.LOGGER.warn("webgui: could not start a download of {}: {}", + item == null ? "?" : item.getURL(), e.toString()); + callback.Continue("", false); + } + } + + private static Path directory() { + //? if fabric { + return net.fabricmc.loader.api.FabricLoader.getInstance().getGameDir().resolve(FOLDER); + //? } else { + /*return net.neoforged.fml.loading.FMLPaths.GAMEDIR.get().resolve(FOLDER);*/ + //? } + } + + private static String fileName(String fullPath) { + if (fullPath == null) { + return "?"; + } + int slash = Math.max(fullPath.lastIndexOf('/'), fullPath.lastIndexOf('\\')); + return slash >= 0 ? fullPath.substring(slash + 1) : fullPath; + } + + private static void tell(String message) { + //? if fabric { + net.minecraft.client.MinecraftClient client = net.minecraft.client.MinecraftClient.getInstance(); + client.execute(() -> { + if (client.player != null) { + client.player.sendMessage(net.minecraft.text.Text.literal("[WebGUI] " + message), false); + } + }); + //? } else { + /*net.minecraft.client.Minecraft client = net.minecraft.client.Minecraft.getInstance(); + client.execute(() -> { + if (client.player != null) { + //? if >=26 { + client.player.sendSystemMessage(net.minecraft.network.chat.Component.literal("[WebGUI] " + message)); + //? } else { + client.player.displayClientMessage(net.minecraft.network.chat.Component.literal("[WebGUI] " + message), false); + //? } + } + });*/ + //? } + } +} diff --git a/src/main/java/land/webgui/WebviewDownloadNames.java b/src/main/java/land/webgui/WebviewDownloadNames.java new file mode 100644 index 0000000..9313ae0 --- /dev/null +++ b/src/main/java/land/webgui/WebviewDownloadNames.java @@ -0,0 +1,108 @@ +package land.webgui; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Locale; +import java.util.Set; + +/** + * Turns a page's suggested download name into one that is safe to write. + * + * The suggestion arrives from web content — a Content-Disposition header, in practice — + * so it is attacker-controlled text on its way to a filesystem call. This is the only + * thing between the two, which is why it lives apart from the CEF handler that uses it: + * free of game and browser classes, it can be tested directly on every version. + */ +public final class WebviewDownloadNames { + + /** + * Windows refuses these as file names whatever the extension, and a write to one + * succeeds while going nowhere — so the download would look like it worked and leave + * nothing behind. + */ + private static final Set RESERVED = Set.of( + "CON", "PRN", "AUX", "NUL", + "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9", + "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9"); + + private static final int MAX_LENGTH = 120; + + private WebviewDownloadNames() {} + + /** + * A file name and nothing else. + * + * Separators and dot segments go, leaving something that cannot name a place other + * than the folder its caller chose. + */ + public static String safeName(String suggested) { + String name = suggested == null ? "" : suggested.trim(); + int slash = Math.max(name.lastIndexOf('/'), name.lastIndexOf('\\')); + if (slash >= 0) { + name = name.substring(slash + 1); + } + + StringBuilder cleaned = new StringBuilder(name.length()); + for (char c : name.toCharArray()) { + // Reserved on Windows, awkward everywhere, or a control character. + boolean bad = c < 0x20 || c == ':' || c == '*' || c == '?' || c == '"' + || c == '<' || c == '>' || c == '|' || c == '/' || c == '\\'; + cleaned.append(bad ? '_' : c); + } + name = cleaned.toString().trim(); + + while (name.startsWith(".")) { + name = name.substring(1); + } + if (name.isEmpty()) { + name = "download"; + } + + String stem = name.contains(".") ? name.substring(0, name.indexOf('.')) : name; + if (RESERVED.contains(stem.toUpperCase(Locale.ROOT))) { + name = "_" + name; + } + return name.length() > MAX_LENGTH ? name.substring(0, MAX_LENGTH) : name; + } + + /** The given name, or the same with a counter, so a second download never overwrites the first. */ + public static Path free(Path directory, String name) { + Path candidate = directory.resolve(name); + if (!Files.exists(candidate)) { + return candidate; + } + int dot = name.lastIndexOf('.'); + String stem = dot > 0 ? name.substring(0, dot) : name; + String extension = dot > 0 ? name.substring(dot) : ""; + for (int i = 1; i < 1000; i++) { + Path next = directory.resolve(stem + " (" + i + ")" + extension); + if (!Files.exists(next)) { + return next; + } + } + return directory.resolve(stem + "-" + System.currentTimeMillis() + extension); + } + + /** + * The page's accept list, as glob patterns a native file chooser understands. + * + * A page writes {@code accept=".png,image/png"}; the chooser wants {@code *.png}. + * MIME types have no glob to map to, so they are dropped rather than turned into a + * pattern that matches nothing — no patterns at all means "any file", which is the + * honest fallback when the page asked for something unexpressible. + */ + public static java.util.List globs(java.util.List acceptFilters) { + java.util.List globs = new java.util.ArrayList<>(); + if (acceptFilters == null) { + return globs; + } + for (String filter : acceptFilters) { + String f = filter == null ? "" : filter.trim(); + if (f.isEmpty() || f.contains("/")) { + continue; + } + globs.add(f.startsWith("*") ? f : (f.startsWith(".") ? "*" + f : "*." + f)); + } + return globs; + } +} diff --git a/src/main/java/land/webgui/WebviewFileDialogHandler.java b/src/main/java/land/webgui/WebviewFileDialogHandler.java new file mode 100644 index 0000000..3ac12b0 --- /dev/null +++ b/src/main/java/land/webgui/WebviewFileDialogHandler.java @@ -0,0 +1,132 @@ +package land.webgui; + +import de.keksuccino.rinku.Rinku; +import org.cef.browser.CefBrowser; +import org.cef.callback.CefFileDialogCallback; +import org.cef.handler.CefDialogHandler; + +import java.util.List; +import java.util.Vector; + +/** + * Makes {@code } do something. + * + * A windowless browser cannot put up its own file chooser — CEF hands the decision to the + * host, and with no handler the click does nothing whatsoever. So a ticket with a + * screenshot attached, a schematic upload, a skin picker: all of them were dead controls + * that gave the player no hint anything was wrong. + * + * The chooser is LWJGL's native one, not Swing's: Rinku boots AWT headless so the game + * can own the window, and {@code JFileChooser} throws outright in that state. LWJGL is + * already on the classpath because Minecraft itself runs on it. + */ +public final class WebviewFileDialogHandler { + + private WebviewFileDialogHandler() {} + + public static void register() { + Rinku.getClient().getHandle().addDialogHandler(new CefDialogHandler() { + @Override + public boolean onFileDialog(CefBrowser browser, FileDialogMode mode, String title, + String defaultFilePath, Vector acceptFilters, + CefFileDialogCallback callback) { + return open(mode, title, defaultFilePath, acceptFilters, callback); + } + + /** + * The wider signature some CEF builds call instead. Both are default methods + * on the interface, so implementing only one leaves the other silently doing + * nothing on the builds that use it. + */ + @Override + public boolean onFileDialog(CefBrowser browser, FileDialogMode mode, String title, + String defaultFilePath, Vector acceptFilters, + Vector acceptExtensions, Vector acceptDescriptions, + CefFileDialogCallback callback) { + Vector filters = acceptExtensions != null && !acceptExtensions.isEmpty() + ? acceptExtensions + : acceptFilters; + return open(mode, title, defaultFilePath, filters, callback); + } + }); + } + + /** + * @return true always: this handler owns the dialog. Returning false asks CEF to show + * its own, which in a windowless browser means none at all. + */ + private static boolean open(CefDialogHandler.FileDialogMode mode, String title, + String defaultPath, Vector acceptFilters, + CefFileDialogCallback callback) { + if (callback == null) { + return true; + } + final String heading = title == null || title.isBlank() ? "Select a file" : title; + final String start = defaultPath == null ? "" : defaultPath; + final List filters = acceptFilters == null ? List.of() : List.copyOf(acceptFilters); + + // On its own thread: CEF calls this on a thread it needs back, and a native modal + // dialog does not return until the player has finished with it. + Thread picker = new Thread(() -> { + try { + answer(mode, heading, start, filters, callback); + } catch (Throwable t) { + WebGUIMod.LOGGER.warn("webgui: the file chooser failed: {}", t.toString()); + callback.Cancel(); + } + }, "webgui-file-dialog"); + picker.setDaemon(true); + picker.start(); + return true; + } + + private static void answer(CefDialogHandler.FileDialogMode mode, String title, String start, + List acceptFilters, CefFileDialogCallback callback) { + String chosen; + boolean multiple = mode == CefDialogHandler.FileDialogMode.FILE_DIALOG_OPEN_MULTIPLE; + + if (mode == CefDialogHandler.FileDialogMode.FILE_DIALOG_OPEN_FOLDER) { + chosen = org.lwjgl.util.tinyfd.TinyFileDialogs.tinyfd_selectFolderDialog(title, start); + } else { + // Stack-allocated: these are native strings, and the alternative is a manual + // free on every path out of a call that can also throw. Each thread has its + // own stack, and this one is ours. + try (org.lwjgl.system.MemoryStack stack = org.lwjgl.system.MemoryStack.stackPush()) { + List globs = WebviewDownloadNames.globs(acceptFilters); + org.lwjgl.PointerBuffer filters = null; + if (!globs.isEmpty()) { + filters = stack.mallocPointer(globs.size()); + for (String glob : globs) { + filters.put(stack.UTF8(glob)); + } + filters.flip(); + } + String description = globs.isEmpty() ? null : String.join(", ", globs); + + chosen = mode == CefDialogHandler.FileDialogMode.FILE_DIALOG_SAVE + ? org.lwjgl.util.tinyfd.TinyFileDialogs.tinyfd_saveFileDialog(title, start, filters, description) + : org.lwjgl.util.tinyfd.TinyFileDialogs.tinyfd_openFileDialog(title, start, filters, description, multiple); + } + } + + if (chosen == null || chosen.isBlank()) { + callback.Cancel(); + return; + } + Vector selected = new Vector<>(); + // tinyfd returns multiple selections in one string, separated by pipes. + for (String part : multiple ? chosen.split("\\|") : new String[] { chosen }) { + String path = part.trim(); + if (!path.isEmpty()) { + selected.add(path); + } + } + if (selected.isEmpty()) { + callback.Cancel(); + return; + } + WebGUIMod.LOGGER.info("webgui: the page was given {} file(s)", selected.size()); + callback.Continue(selected); + } + +} diff --git a/src/main/java/land/webgui/WebviewPageLoadHooks.java b/src/main/java/land/webgui/WebviewPageLoadHooks.java index 399a068..4973e9e 100644 --- a/src/main/java/land/webgui/WebviewPageLoadHooks.java +++ b/src/main/java/land/webgui/WebviewPageLoadHooks.java @@ -98,7 +98,7 @@ public void onLoadError(CefBrowser browser, CefFrame frame, private static void injectBridgeScript(RinkuBrowser browser) { try { String url = browser.getURL(); - browser.executeJavaScript(WebviewScriptInject.bridgeSetup(WebGUIAssetServer.base()), url != null ? url : "", 0); + browser.executeJavaScript(WebviewScriptInject.bridgeSetup(WebGUIAssetServer.base(), WebSession.mode() == WebSession.Mode.HUD_OVERLAY), url != null ? url : "", 0); } catch (Throwable t) { WebGUIMod.LOGGER.debug("webgui bridge inject: {}", t.toString()); } diff --git a/src/main/java/land/webgui/WebviewScriptInject.java b/src/main/java/land/webgui/WebviewScriptInject.java index 82a3cb8..54572d4 100644 --- a/src/main/java/land/webgui/WebviewScriptInject.java +++ b/src/main/java/land/webgui/WebviewScriptInject.java @@ -16,7 +16,7 @@ private WebviewScriptInject() {} * @param assetsBase where the server's own pages are served from, or empty when the * server ships none */ - public static String bridgeSetup(String assetsBase) { + public static String bridgeSetup(String assetsBase, boolean hudMode) { // Encoded rather than interpolated: it is a URL built at runtime, and a quote in // it would turn this whole script into a syntax error at the worst moment. String base = GSON.toJson(assetsBase == null ? "" : assetsBase); @@ -25,7 +25,11 @@ public static String bridgeSetup(String assetsBase) { if (typeof window.webgui === 'undefined') window.webgui = {}; // Where this server's own files live, so a page can build a URL to one // without knowing which port the local page server ended up on. - window.webgui.assetsBase = %s;""".formatted(base) + """ + window.webgui.assetsBase = %s; + // Whether this page is the HUD overlay rather than a full screen. A HUD + // is sized for glanceable content, so a page worth writing for both + // needs to know which one it is in. + window.webgui.isHud = %s;""".formatted(base, hudMode) + """ if (typeof window.webgui.postToGame !== 'function') { window.webgui.postToGame = function (payload) { diff --git a/src/test/java/land/webgui/WebviewDownloadNamesTest.java b/src/test/java/land/webgui/WebviewDownloadNamesTest.java new file mode 100644 index 0000000..1181c0f --- /dev/null +++ b/src/test/java/land/webgui/WebviewDownloadNamesTest.java @@ -0,0 +1,128 @@ +package land.webgui; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * A download's file name comes from the page — from a Content-Disposition header, in + * practice — so it is attacker-controlled text on its way to a filesystem call. This + * covers the two functions standing between the two. + */ +class WebviewDownloadNamesTest { + + @Test + void keepsAnOrdinaryName() { + assertEquals("invoice.pdf", WebviewDownloadNames.safeName("invoice.pdf")); + assertEquals("my report (final).csv", WebviewDownloadNames.safeName(" my report (final).csv ")); + } + + @Test + void stripsAnyPathFromTheName() { + assertEquals("evil.exe", WebviewDownloadNames.safeName("../../evil.exe")); + assertEquals("evil.exe", WebviewDownloadNames.safeName("..\\..\\Windows\\System32\\evil.exe")); + assertEquals("passwd", WebviewDownloadNames.safeName("/etc/passwd")); + assertEquals("boot.ini", WebviewDownloadNames.safeName("C:\\boot.ini")); + } + + @Test + void replacesCharactersAFilesystemWouldRefuse() { + assertEquals("a_b_c_d_e_f", WebviewDownloadNames.safeName("a:b*c?d\"e Date: Wed, 9 Sep 2026 21:24:56 +0500 Subject: [PATCH 09/16] Show an icon and an author in the Mods list, and declare Rinku everywhere The Mods list is where a player looks to see what they installed and who wrote it, and WebGUI had no icon on either loader and no author on NeoForge at all. The icon is the project logo rasterised from the site's SVG, shipped at 128 and 256 because the two loaders ask for different sizes. More importantly, no CurseForge upload declared any dependency - including the NeoForge ones, which need Rinku installed separately. A NeoForge player who downloaded from CurseForge got no Rinku and the game died during mod loading with NoClassDefFoundError: exactly the failure the Modrinth declaration was added to prevent for 1.7.0, missed on the other platform. Fabric uploads now say rinku(embedded) rather than nothing. Rinku ships inside the Fabric jar, so asking a launcher to install it would fetch a second copy; embedded states the relationship without that. Drops the native file chooser. It took the client down with an access violation inside libcef's message loop - twice, and not from the thread it ran on: moving it to the render thread changed nothing, and the same crash then happened with no file dialog involved at all. Five clean single-client runs could not reproduce it, so the cause is still unknown, and a whole-client crash is not something to ship on the strength of "it did not happen to me". Downloads stay. They are verified repeatedly, by hand and unattended, and were never part of a crash. --- .github/workflows/release.yml | 10 ++ src/main/java/land/webgui/WebGUIClient.java | 2 - .../land/webgui/WebviewDownloadNames.java | 22 --- .../land/webgui/WebviewFileDialogHandler.java | 132 ------------------ .../resources/META-INF/neoforge.mods.toml | 5 + src/main/resources/fabric.mod.json | 7 +- src/main/resources/icon-128.png | Bin 0 -> 2783 bytes src/main/resources/icon-256.png | Bin 0 -> 5368 bytes .../land/webgui/WebGUIModMetadataTest.java | 37 +++++ .../land/webgui/WebviewDownloadNamesTest.java | 27 ---- 10 files changed, 58 insertions(+), 184 deletions(-) delete mode 100644 src/main/java/land/webgui/WebviewFileDialogHandler.java create mode 100644 src/main/resources/icon-128.png create mode 100644 src/main/resources/icon-256.png diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c82f3f5..04b01cb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -112,6 +112,7 @@ jobs: loaders: fabric game-versions: '1.20.1' changelog: ${{ steps.notes.outputs.body }} + dependencies: rinku(embedded) files: versions/1.20.1-fabric/build/libs/!(*-@(dev|sources|javadoc)).jar - name: Publish 1.21.1 Fabric to Modrinth @@ -127,6 +128,7 @@ jobs: loaders: fabric game-versions: '1.21.1' changelog: ${{ steps.notes.outputs.body }} + dependencies: rinku(embedded) files: versions/1.21.1-fabric/build/libs/!(*-@(dev|sources|javadoc)).jar - name: Publish 1.21.11 Fabric to Modrinth @@ -142,6 +144,7 @@ jobs: loaders: fabric game-versions: '1.21.11' changelog: ${{ steps.notes.outputs.body }} + dependencies: rinku(embedded) files: versions/1.21.11-fabric/build/libs/!(*-@(dev|sources|javadoc)).jar - name: Publish 1.21.1 NeoForge to Modrinth @@ -224,6 +227,7 @@ jobs: loaders: fabric game-versions: '1.20.1' changelog: ${{ steps.notes.outputs.body }} + dependencies: rinku(embedded) files: versions/1.20.1-fabric/build/libs/!(*-@(dev|sources|javadoc)).jar - name: Publish 1.21.1 Fabric to CurseForge @@ -239,6 +243,7 @@ jobs: loaders: fabric game-versions: '1.21.1' changelog: ${{ steps.notes.outputs.body }} + dependencies: rinku(embedded) files: versions/1.21.1-fabric/build/libs/!(*-@(dev|sources|javadoc)).jar - name: Publish 1.21.11 Fabric to CurseForge @@ -254,6 +259,7 @@ jobs: loaders: fabric game-versions: '1.21.11' changelog: ${{ steps.notes.outputs.body }} + dependencies: rinku(embedded) files: versions/1.21.11-fabric/build/libs/!(*-@(dev|sources|javadoc)).jar - name: Publish 1.21.1 NeoForge to CurseForge @@ -269,6 +275,7 @@ jobs: loaders: neoforge game-versions: '1.21.1' changelog: ${{ steps.notes.outputs.body }} + dependencies: rinku(required) files: versions/1.21.1-neoforge/build/libs/!(*-@(dev|sources|javadoc)).jar - name: Publish 1.21.11 NeoForge to CurseForge @@ -284,6 +291,7 @@ jobs: loaders: neoforge game-versions: '1.21.11' changelog: ${{ steps.notes.outputs.body }} + dependencies: rinku(required) files: versions/1.21.11-neoforge/build/libs/!(*-@(dev|sources|javadoc)).jar - name: Publish 26.2 NeoForge to CurseForge @@ -299,6 +307,7 @@ jobs: loaders: neoforge game-versions: '26.2' changelog: ${{ steps.notes.outputs.body }} + dependencies: rinku(required) files: versions/26.2-neoforge/build/libs/!(*-@(dev|sources|javadoc)).jar - name: Publish 26.1.2 NeoForge to CurseForge @@ -314,6 +323,7 @@ jobs: loaders: neoforge game-versions: '26.1.2' changelog: ${{ steps.notes.outputs.body }} + dependencies: rinku(required) files: versions/26.1.2-neoforge/build/libs/!(*-@(dev|sources|javadoc)).jar # mc-publish can report success without actually creating a version (e.g. diff --git a/src/main/java/land/webgui/WebGUIClient.java b/src/main/java/land/webgui/WebGUIClient.java index 0d4e102..fa13475 100644 --- a/src/main/java/land/webgui/WebGUIClient.java +++ b/src/main/java/land/webgui/WebGUIClient.java @@ -35,7 +35,6 @@ public void onInitializeClient() { WebviewPageToClientBridge.register(); WebviewPageLoadHooks.register(); WebviewDownloadHandler.register(); - WebviewFileDialogHandler.register(); WebGUIMod.LOGGER.info("WebGUI bridge ready (console log, page↔game, client data)."); }); @@ -198,7 +197,6 @@ private static void handleOpenPayload(net.minecraft.client.MinecraftClient clien WebviewPageToClientBridge.register(); WebviewPageLoadHooks.register(); WebviewDownloadHandler.register(); - WebviewFileDialogHandler.register(); WebGUIMod.LOGGER.info("WebGUI bridge ready (console log, page<->game, client data)."); }); diff --git a/src/main/java/land/webgui/WebviewDownloadNames.java b/src/main/java/land/webgui/WebviewDownloadNames.java index 9313ae0..c193d31 100644 --- a/src/main/java/land/webgui/WebviewDownloadNames.java +++ b/src/main/java/land/webgui/WebviewDownloadNames.java @@ -83,26 +83,4 @@ public static Path free(Path directory, String name) { return directory.resolve(stem + "-" + System.currentTimeMillis() + extension); } - /** - * The page's accept list, as glob patterns a native file chooser understands. - * - * A page writes {@code accept=".png,image/png"}; the chooser wants {@code *.png}. - * MIME types have no glob to map to, so they are dropped rather than turned into a - * pattern that matches nothing — no patterns at all means "any file", which is the - * honest fallback when the page asked for something unexpressible. - */ - public static java.util.List globs(java.util.List acceptFilters) { - java.util.List globs = new java.util.ArrayList<>(); - if (acceptFilters == null) { - return globs; - } - for (String filter : acceptFilters) { - String f = filter == null ? "" : filter.trim(); - if (f.isEmpty() || f.contains("/")) { - continue; - } - globs.add(f.startsWith("*") ? f : (f.startsWith(".") ? "*" + f : "*." + f)); - } - return globs; - } } diff --git a/src/main/java/land/webgui/WebviewFileDialogHandler.java b/src/main/java/land/webgui/WebviewFileDialogHandler.java deleted file mode 100644 index 3ac12b0..0000000 --- a/src/main/java/land/webgui/WebviewFileDialogHandler.java +++ /dev/null @@ -1,132 +0,0 @@ -package land.webgui; - -import de.keksuccino.rinku.Rinku; -import org.cef.browser.CefBrowser; -import org.cef.callback.CefFileDialogCallback; -import org.cef.handler.CefDialogHandler; - -import java.util.List; -import java.util.Vector; - -/** - * Makes {@code } do something. - * - * A windowless browser cannot put up its own file chooser — CEF hands the decision to the - * host, and with no handler the click does nothing whatsoever. So a ticket with a - * screenshot attached, a schematic upload, a skin picker: all of them were dead controls - * that gave the player no hint anything was wrong. - * - * The chooser is LWJGL's native one, not Swing's: Rinku boots AWT headless so the game - * can own the window, and {@code JFileChooser} throws outright in that state. LWJGL is - * already on the classpath because Minecraft itself runs on it. - */ -public final class WebviewFileDialogHandler { - - private WebviewFileDialogHandler() {} - - public static void register() { - Rinku.getClient().getHandle().addDialogHandler(new CefDialogHandler() { - @Override - public boolean onFileDialog(CefBrowser browser, FileDialogMode mode, String title, - String defaultFilePath, Vector acceptFilters, - CefFileDialogCallback callback) { - return open(mode, title, defaultFilePath, acceptFilters, callback); - } - - /** - * The wider signature some CEF builds call instead. Both are default methods - * on the interface, so implementing only one leaves the other silently doing - * nothing on the builds that use it. - */ - @Override - public boolean onFileDialog(CefBrowser browser, FileDialogMode mode, String title, - String defaultFilePath, Vector acceptFilters, - Vector acceptExtensions, Vector acceptDescriptions, - CefFileDialogCallback callback) { - Vector filters = acceptExtensions != null && !acceptExtensions.isEmpty() - ? acceptExtensions - : acceptFilters; - return open(mode, title, defaultFilePath, filters, callback); - } - }); - } - - /** - * @return true always: this handler owns the dialog. Returning false asks CEF to show - * its own, which in a windowless browser means none at all. - */ - private static boolean open(CefDialogHandler.FileDialogMode mode, String title, - String defaultPath, Vector acceptFilters, - CefFileDialogCallback callback) { - if (callback == null) { - return true; - } - final String heading = title == null || title.isBlank() ? "Select a file" : title; - final String start = defaultPath == null ? "" : defaultPath; - final List filters = acceptFilters == null ? List.of() : List.copyOf(acceptFilters); - - // On its own thread: CEF calls this on a thread it needs back, and a native modal - // dialog does not return until the player has finished with it. - Thread picker = new Thread(() -> { - try { - answer(mode, heading, start, filters, callback); - } catch (Throwable t) { - WebGUIMod.LOGGER.warn("webgui: the file chooser failed: {}", t.toString()); - callback.Cancel(); - } - }, "webgui-file-dialog"); - picker.setDaemon(true); - picker.start(); - return true; - } - - private static void answer(CefDialogHandler.FileDialogMode mode, String title, String start, - List acceptFilters, CefFileDialogCallback callback) { - String chosen; - boolean multiple = mode == CefDialogHandler.FileDialogMode.FILE_DIALOG_OPEN_MULTIPLE; - - if (mode == CefDialogHandler.FileDialogMode.FILE_DIALOG_OPEN_FOLDER) { - chosen = org.lwjgl.util.tinyfd.TinyFileDialogs.tinyfd_selectFolderDialog(title, start); - } else { - // Stack-allocated: these are native strings, and the alternative is a manual - // free on every path out of a call that can also throw. Each thread has its - // own stack, and this one is ours. - try (org.lwjgl.system.MemoryStack stack = org.lwjgl.system.MemoryStack.stackPush()) { - List globs = WebviewDownloadNames.globs(acceptFilters); - org.lwjgl.PointerBuffer filters = null; - if (!globs.isEmpty()) { - filters = stack.mallocPointer(globs.size()); - for (String glob : globs) { - filters.put(stack.UTF8(glob)); - } - filters.flip(); - } - String description = globs.isEmpty() ? null : String.join(", ", globs); - - chosen = mode == CefDialogHandler.FileDialogMode.FILE_DIALOG_SAVE - ? org.lwjgl.util.tinyfd.TinyFileDialogs.tinyfd_saveFileDialog(title, start, filters, description) - : org.lwjgl.util.tinyfd.TinyFileDialogs.tinyfd_openFileDialog(title, start, filters, description, multiple); - } - } - - if (chosen == null || chosen.isBlank()) { - callback.Cancel(); - return; - } - Vector selected = new Vector<>(); - // tinyfd returns multiple selections in one string, separated by pipes. - for (String part : multiple ? chosen.split("\\|") : new String[] { chosen }) { - String path = part.trim(); - if (!path.isEmpty()) { - selected.add(path); - } - } - if (selected.isEmpty()) { - callback.Cancel(); - return; - } - WebGUIMod.LOGGER.info("webgui: the page was given {} file(s)", selected.size()); - callback.Continue(selected); - } - -} diff --git a/src/main/resources/META-INF/neoforge.mods.toml b/src/main/resources/META-INF/neoforge.mods.toml index 08cddd8..6d1f6e9 100644 --- a/src/main/resources/META-INF/neoforge.mods.toml +++ b/src/main/resources/META-INF/neoforge.mods.toml @@ -1,11 +1,16 @@ modLoader = "javafml" loaderVersion = "[4,)" license = "MIT" +issueTrackerURL = "https://github.com/mc-webgui/webgui/issues" [[mods]] modId = "webgui" version = "${version}" displayName = "WebGUI" +authors = "KoSHeroff" +displayURL = "https://webgui.space" +logoFile = "icon-256.png" +credits = "Chromium embedding by Rinku (Keksuccino)" description = "Web-based GUI framework for Minecraft — open any web page as a full-screen GUI or HUD overlay." [[dependencies.webgui]] diff --git a/src/main/resources/fabric.mod.json b/src/main/resources/fabric.mod.json index 4e9dd41..fedd2dc 100644 --- a/src/main/resources/fabric.mod.json +++ b/src/main/resources/fabric.mod.json @@ -5,13 +5,18 @@ "name": "WebGUI", "description": "Embed any web page (React, Vue, plain HTML) as a full-screen GUI or transparent HUD overlay inside Minecraft. Powered by Rinku (Chromium).", "authors": [ - "kosher" + "KoSHeroff" ], "contact": { + "homepage": "https://webgui.space", "sources": "https://github.com/mc-webgui/webgui", "issues": "https://github.com/mc-webgui/webgui/issues" }, "license": "MIT", + "icon": { + "128": "icon-128.png", + "256": "icon-256.png" + }, "environment": "*", "depends": { "fabricloader": ">=0.18.0", diff --git a/src/main/resources/icon-128.png b/src/main/resources/icon-128.png new file mode 100644 index 0000000000000000000000000000000000000000..a6e7c769f71dcb5db178a9617d0cc9505552be4c GIT binary patch literal 2783 zcmZuzc{J2*8~)8SlNn=~F{7C*F;k*Y$Qm+?v4;>*LUyv1U6_%XEZN?#Maou2wj@#6 zGK|O)Dp80LUQ1zQ8Lu#(&U?=Lo%fvYk7qged7kH7_kFJCy02twOJhNPF@6951Wip0 zZFX16?*ZGpJF`x?bprr@v8kcHUF3s>T<6P}A<>S$uWIeY^G}_UUzFMm#`+xu_nQ04 z)9P1gWw`sNFiynJWLlYZqP!ZaMWoUrr8QO3tY}cQRkaWw^c@GcJHkem5-(OAF_S<0 zB;cVL+=f-34Bz>jyVY00jR_C;3UAkLxEl9i`9WG|^kP$U9k;jX`3nNRKM4RCLtvT& zA`=Ayr^ArJh7`(V9gtH@PA)lk&*H^V#=~0=MN#@N$97Z*q%f}?_B2GI<-IMYHLQO& zz=tD*DL@~8bjZ#?U`^A=5PQoeM8cVjzpzJ8iYy?#U_-xs?g%zO^@ppq{eJ~+BD&4^_LiRtHd@{k~bN@rR>cgKT zM$~Dgv9?LC>(vt-+6#L5SQEj*A5u)V4R5dy)gwozv1&UbT#?nEvjnkI$qotV!6#>H zyv?(PYBj7^{*ZvS^`k?}`TyKYU-E;hZoyA~+$^E|68>X#v<~VUiA}D+pKOYUE{uJk zIFN6WD1nRt%_}3CB7opHO*2DP`1QBCWoQuF?c17rnh|M_IY)Q8K(iqw`-|3PrQrhDx>eFO|1Fp?=E)SWF>9fl?U-1kg~AoXPTv+!@u$ zmAoC;?8FHUMvGv2;KfRq8nzFdRt2ftF_m~&0~sG#(oxH>C^@X7+^E%`sDOz6or#ho z5#RNP$)QaST{hDza-Qj)bJ6KDsw}nTnsoZQ-SqeF##lhk+Jo7&(+R z&k-6Qyy*QhPG8h@WfQ5}aO!4-&V^=;MJ*S*NyRDi4tqj3XCIxauF_%)x`N;-{CsEi z_@_bOmM)dsyYB(51l!YgJh9uklg3<&h5mwQ*(ngQ-*jvw8&dVc`SZKy(~UQrq&}Bk zH-jpj!-(Ag`q<}0Y(e}>z>hKB$dPNu2$ZW{?&Tac1Vkyo{bSHc6r};tf#Zs;+2V1Y3!YG ze%mGg(8W>2jO>o8CPM5E?Rki*;Y9nXO2fwVkp$T_3k&wLOJd7!slpXHRN^u)kw zW5orFYPU%Sx|3k8sIkJ~=M1WP+`CsX=xT+-s++;~tlaBHS7plTy&!Dk+m)*trY!BL z*87OimHam^X=t7$LDl2Uh8ZxHo!S2MX6{y9>iKqu2Gna5o3p<6WK%QBF(tXP==FWM zY*t42atG_9lxY5rwCT7e2X{~H+Ck#tCtk{qMB!S%RKau9NWFt{Xt7MyN5qB^Wps^m zd?{{fR=HaGL}*dg`lW9@9F?k1G9fPfRb?NC3G_@FHai++@fFUrs=WNm%MVS{^XB}V zzRdk|HKx|6u6m#r=!j77MXkfhl6&?BIV(-rN}x9C<$b;+A7s z@aVSgvj`za{>S6xYF)P(l~82YZkZ%d((GM1d|U}Z9wq%t6Xz{~okRU!y5B&Dk%I5n zgFNpVm%nR^7g61`+&P?v<-F!itPk8APpU^3@M@#xJ7V@PM|udp-rVziB~<+R6~2p< z=Z_GWj_5jUwsWl4%$&N>v?V$clykB%_QBg(Z2wnO~=+E6YKH0o3&mESea^HQ%#oXu_70^6Zc%!435D2fRA%_4tM zmm>s zXyIz0Io=%<0}@l=PBho7l`m9_`y{zQO7{H*>@{7f4O*2=sOoTmEkQTtlBNGY1RW7# zK4g&TK0%-ugR{?8IFc1K{=P5N2wdxy%b=at&Zx%_?2@;< zG4SEx@7u$#*f-yOCxtTNWvT^&^eO5p`jC=?jmQGg5Wdo0ReMI>N$bW`*^TS01-EEr z?={hY2)u+)L>9altkmhDl1@5dFf*c?F}GMmx8R6BmGU3paYDs<#-owfP0TNmDL5fX znB+a!=G|6pKs7Ga#xKP}P9CVwI@T6dbaJ;}&O$wK9Px$w2BCB3#y0fQmi5d$s|ZBM zGSZhWwc7hWEymO%!R6J5@g!4MoFjkD7jqmhgP_K!00ih%Wy0n$1LJ5=yj$=Zx_-wS zIcakG*&*PvA9++iXM%k%DtaxaDfeiL_?V=V>GwhUuIB4=$CKS172r(Qv%xr{1yUJH zYG6Ys8YHW2pAhq4tQoBR?j36w3p2ClyL(?yF(pt)72oadbmUV?(!rOBilFS) zLW&zaj;cjng>>(++T5YY=j#n+vjm12g39TmDp5Aydax%bG2naX$Zos> zS4{VEDt(-qgs-IqfN(ITh{c&+5oH#dHPfruPp;Dr&JFVy9sJ9IJuf_Y_LR@Yfi`KI zT^B&10tziS(N%~5cW)mF;;2w2F*eDQ@%n3Ps4b1iijZf}JMrmIS7{<7)$qC|H8#=_ zlFD4U-VOUgtQOMP6b@mmtRD+n#N1r-^|D?HND7%sSH9-m;Ckuv4YU@u=8%p~6;hy^ zY1Pc{xRLC;lPRa6KI559jEx`^ml#|?QS^h5SWq{eB%$xaQFc0vF@P!k%Lo2$9abRZ13}o2D_I%Et-- zU^l#^dldi}^a=yaOwf@L8{)vWC38eC~FnFmqS^QAu0iGN)Wkel_>d zyYw4w`q|ISr~JAENR@d`g-K_@z;wEVSwVvyqgal37*R9X)#_*%2k_l~+2LKJB!E7m zBoUw~c9D}?2@HHom+-llbZt5m?+L@3PWHT*5G<)?Ib?yW{YH4Ea8FZg>eR(-I7-qj zszbz*Ujmg}%?%PSavB%j;0WCvxSdcz0jSIm!gwGJC^t2xP0lIOC+yk-4H*& z2>~_LvRKW!dc+^)t}Txq*!y@jcRt0or;M+cvwbit;OVp3tf6#-Hoc6_JOPEz{sWCT#71xe=BqEyx&}~?4r?R4oVOe^Zs8#h)R=*@ZK(OJ~fW{8- zaT5mz#K7A)ZOjx*R$1Bql91oKR>xz3GJ>y{Z)oVZ8K|l^xejQo6FZvDve)RSt0R%f z!bKFL1Vb*zYn|3uszabr+YhBNkBlKUye{9Zu^Icfq0dAg8L53}#}GyD-)F=jX%8l+a_`qi9H2*uaIOjD z;bXN*@7R#SFIx>#*M?O;wIT~nHKs0%j~CB`<1$W<@gAN+-v4Fothn!Pc!!r|n`J{~ zf6yRRb;7`M_zf-$)!83*ffW~XvCHc4S3cn7y!&mpASy%~rPe*xeHbC}x+i@y=MPcK z#!7p7%&2r9^$I@^TuY-IIXI2;!*3ND7e0%cSQCcrTX%(0CjQm>Xl9l}$bgBEH~c&! zaa%ZF=YA$V=qTT|&>(+^E~!*-6dO4lGVnA*ARu@r)ot9>xxp*~u4eJs<|_=ZJZhm@ z>SpzP{?LrwIlRE)2jPfwY)*n;eSLk9nqJD*_|9XL_NfS_Qm!$0JhWd_RFuD_(*mDx za)p{M!2{x#>$42Crc!21S=|ujavuY@d-kx)=FbWA{zEce4jfaFfnsq~F=2H3L&~nn zaW>o~Kclswy3a*S#I51`mKq*E4KiaK;- zvjdYU2F*oTdpnC?Wj2rTHr>ca)bxM;Lc)`54cuxg)g*hzF@vNG4ee#M z^q&iOT=myMbFPy_Asi(>u)Z-Vdy~!rO$RUa#II4*`Qx^IFeAXF=#-UWlI<4#7RL<> z_i|=n@fJ%U?a_cQE#_iJF+7*P$vZ&xhxVyrQt-8T4KWw_6R4sMbXMap`g3W|#LeNPHrRSC)N^Nppk*oewUfjNvN?z;DWOU2j z1%p2JU&-vTjtRB?GA$E=zmAEdZa3koVX`aF8_j>Rn1IDuwM3kxe89N`x*ux!ZJih9 zsSWG#5&yym0<&Nri>F%-2RWds-_(ZM*9AuJz{3TBol7s8MGs&&{}?$EKK@k>4xJqJ zH$J`ZVlez3(g8~ z4C-;Y+`zE#XhR9JyD7NT97E@zKU*H0o!t4n5G9?%Z7*6F8d7&(iaXn#1$?Xfodvh} z&7c3HHrL2g5EsIq1qw3%pVKHOr{TiH@DOD^vIr+Akb>it;UHNLZ0G}gB{OaU3L6is zhaZOPI1TQK0`xTm$^!u;me%MwOD)n8^IP;cbdQgx|DK2f3o!uY#03akAh_P%eQI@F zw&91Y-p);uHP%75nK404t8q&DhTrOm6Fg(xbpN-XUj}q&z~YWA{h;h_M6AWqc#aFR zIb-G4XtUS^Mi?wE)TAz^VTkV7sTlGT$pH3iqkP>IZKZjByt{izPly>8t#PjoNvGTu zFvw969MxKCq9-nwY5ful9$|uLLK{D5KEfVb%?eAg!AW+^oIqqbb+SqJzP(m>NnYf| z9r*L^Zod`QNG>39L|yKyU_%s(7I#bfnyuF;#rEp?AOx5w1>X2DTFXmM@o1 z8a}#P(cjU%DAg~ZtT)O3ebYeZi#)3^P=A8G6&B>QloVrIb00FoE zkXdGJ)*=V?1{2GLR2yw07_fCaBOc2dn``S)iTAIG0`)6faabp#+AVvAE7Hbc9--H? z*od4!5Dr@Ofs_b98z6{B;lNiHNQqm6dp}YfR-bMTnF@#8yM9as%cBvhw$QB8)K|*} zD}Qkt=1KD?%^OzdYfL2%smbZY|KRZBd6yJ%W1KZ!v6r zpjfWPrkCwW2iVluw=w!J`uH{_>FYG znYedDt9WjvS8+S-FlA%fsnc^7CWBZJ97Q%$8`RQ7gUdl@X3oy%o<2&10 z44(uizUt128lA9b-v1`t-fVluOKbj)6TQ;$2vAUa zW~pkSuUh#4=eQd6&3fUuwg=A8aiI$W9`gW0C9(p)F6nQ&_Lnvs zbneY_^?>Nl2U8VOB+fcQ3n$1#GNKZ&jhALZy5>4)X)R~2ak%fhDE6d2%lmaD_N%Pn zpbkH5N(6L>uFl((E@!D4Cv5iUgMdYDpw9D#Z>H9t_rPXB?%$+{fTHv5+foe*D<%hU z)Opmc4?N$;eB)nqK$yYsX@KN{HW+^7fGdbur2_&X12G4_Fyq`r|EUPXI5Cg-r|HZb zM(~8O8#;6$y1CDTgxlq=N{@<+Dm9~|O@fXcEd6(2^G#tyhquKdzXe4=TL4OmGjzZB z_j}JjCGTB`Fsu|DIf$X)|CL_@;vhgOK=Hl%nk*E4QyALdZ|b4QTPJK@^Kl(XKY#t(AjmD!2{%wAKGa$7yNrNS4o9=^)U7lpEoC;xfJbk?A~N@KlZ_9b_E(zTdS_?XwURn%05;w} zAnGIh#}sCoYW*HcGQ~f$o!+XdT3_8Wn?JGjBXseHI|85`{2APbW98+x!pmC$<%Xgm&t*zcI+)-sj=^RuZ)!wV7$!G+Kvxx>O z*}$cN`wlxUe??rp?T_Xgd<8kqLcdIB24eEu;vWlugBC<3Vb37}hkV2Z42}cqYGWx& z(E0iE3hRUY@(TKMdmOYlPV3H1z!e4gZ>2Wwy<8}J+27?a&-|c8I;hrtX~MN7oMb0X*o+pNS|V>n9m&pLai z3E=B^hv;f6P|cn+Pamyh#bxF*oMZ%qR`QND__NYCh7%WLF$Y}&)YY^TsDj=wRBFU( zrAGswc}a0DAI+mz{#Y6#P9K;g&lc&=Bnfkt3>9kSYS2Fj&yld7oOpnKF3xqxNxsKZ zAJqRGtwuM#nTT~yWBN;_a)%)#XjJd~CLDL`mDg$(hfnkBq)T_R+(*wfj|Tb^miH$a z$y>!5wOOA+Ve8wJUnXy*Q70xH1pyyS!7{dZ{pIG@8O;FP6pyegBCuf3SLl^ zT17Ke8!#^2;Yc(B3+$u5FlU>Tb4>i{9OY~eH8lx%D^xIfljir zU>z>TuQpLf2#J>5rDY*$j~HsWMoLiET*H=t_Hh=^Fa!jA(m5qa?(7He~maq*>s>dz2IR+2|{&x zj;U6|j~d&(BkLOZf0C$ap~C@kflFt1_Zo>cP*=vh!cd1_fZVD(Z=ozCB_K_{wWGoL zfwDMs-ehceVjLP0R_1on2BLD@_ky5qTdlgJZsX2EA2#Q(>)h=tst_nkoP=t=sYlSB zIEgJ6y-%wWA35*%=EEECynOe&S(NQsle&#cH0ufAe%;k1*|xN8wiO3g<3il`-F3A> zhq2?7!cgW;DWc41!E+BD`0v)fJA_Kt#urxVkUmSGxCAWoCyV7*F6St~dR}t6xY#F# z9gDpJ3t!Xq+|E&;5jA|GWId5a07(RQQYLYT@9WMORd}Q^dVxe;Gc6Dvz zh0^zz1%Rfau}+j%vM+0xuiL3&WsA=G4qpkB^1Q`O3uB|vfoSB;$@#I9`IEQO-Jr|4 mWayf5PXyrjME}9FqZKKY{63)M94B<62@Lg2bxXA|5B>|*%gw<6 literal 0 HcmV?d00001 diff --git a/src/test/java/land/webgui/WebGUIModMetadataTest.java b/src/test/java/land/webgui/WebGUIModMetadataTest.java index 1808ad9..222aa76 100644 --- a/src/test/java/land/webgui/WebGUIModMetadataTest.java +++ b/src/test/java/land/webgui/WebGUIModMetadataTest.java @@ -79,6 +79,43 @@ void declaresModAndEntrypointsForActiveLoader() throws Exception { } } + /** + * The Mods list is where a player looks to see what they installed and who wrote it, + * and a declared icon that is not in the jar shows as a blank tile. Both loaders read + * these from different files, so it is easy to fix one and forget the other. + */ + @Test + void declaresAuthorshipAndAnIconThatIsActuallyShipped() throws Exception { + String fabric = findOurs("fabric.mod.json", "land.webgui.WebGUIMod"); + String neoforge = findOurs("META-INF/neoforge.mods.toml", "modId = \"" + MOD_ID + "\""); + Assumptions.assumeTrue(fabric != null || neoforge != null, + "our loader metadata is not exposed on the dev/test classpath — skipping"); + + if (fabric != null) { + assertContains(fabric, "KoSHeroff", "fabric.mod.json names the author"); + assertContains(fabric, "icon-128.png", "declares a 128px icon"); + assertContains(fabric, "https://webgui.space", "links to the homepage"); + } + if (neoforge != null) { + assertContains(neoforge, "authors = \"KoSHeroff\"", "neoforge.mods.toml names the author"); + assertContains(neoforge, "logoFile = \"icon-256.png\"", "declares a logo file"); + assertContains(neoforge, "displayURL", "links to the homepage"); + assertContains(neoforge, "issueTrackerURL", "links to the issue tracker"); + } + + for (String icon : new String[] { "/icon-128.png", "/icon-256.png" }) { + try (InputStream in = getClass().getResourceAsStream(icon)) { + assertNotNull(in, icon + " is declared in the metadata and must be in the jar"); + byte[] header = in.readNBytes(8); + // A declared file that is not a PNG renders as a blank tile just the same. + assertTrue(header.length == 8 + && (header[0] & 0xFF) == 0x89 && header[1] == 'P' + && header[2] == 'N' && header[3] == 'G', + icon + " must be a PNG"); + } + } + } + @Test void mixinConfigIsPresentAndPointsAtTheMixinPackage() throws Exception { // webgui.mixins.json is uniquely named to this mod, so the root lookup is unambiguous. diff --git a/src/test/java/land/webgui/WebviewDownloadNamesTest.java b/src/test/java/land/webgui/WebviewDownloadNamesTest.java index 1181c0f..f958a0d 100644 --- a/src/test/java/land/webgui/WebviewDownloadNamesTest.java +++ b/src/test/java/land/webgui/WebviewDownloadNamesTest.java @@ -98,31 +98,4 @@ void countsExtensionlessNamesToo(@TempDir Path dir) throws IOException { void usesTheNameAsGivenWhenNothingIsThere(@TempDir Path dir) { assertEquals("fresh.txt", WebviewDownloadNames.free(dir, "fresh.txt").getFileName().toString()); } - - // --- the other direction: what a file chooser is told to show ------------ - - @Test - void turnsAcceptEntriesIntoGlobs() { - assertEquals(java.util.List.of("*.png", "*.jpg"), - WebviewDownloadNames.globs(java.util.List.of(".png", ".jpg"))); - assertEquals(java.util.List.of("*.png"), WebviewDownloadNames.globs(java.util.List.of("png"))); - assertEquals(java.util.List.of("*.png"), WebviewDownloadNames.globs(java.util.List.of("*.png"))); - } - - @Test - void dropsMimeTypesRatherThanInventingAPatternForThem() { - // "image/png" has no glob; a made-up one would match nothing and hide every file - // the player has, which reads as a broken upload rather than a wrong filter. - assertEquals(java.util.List.of(), WebviewDownloadNames.globs(java.util.List.of("image/png"))); - assertEquals(java.util.List.of("*.png"), - WebviewDownloadNames.globs(java.util.List.of("image/png", ".png"))); - } - - @Test - void ignoresEmptyAndMissingAcceptEntries() { - assertEquals(java.util.List.of("*.txt"), - WebviewDownloadNames.globs(java.util.Arrays.asList("", " ", null, ".txt"))); - assertTrue(WebviewDownloadNames.globs(null).isEmpty()); - assertTrue(WebviewDownloadNames.globs(java.util.List.of()).isEmpty()); - } } From 8d12a412403190d0f4d4f0f221341db1d9b23dc9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9A=D0=B8=D1=80=D0=B8=D0=BB=D0=BB=20=D0=93=D1=80=D0=B8?= =?UTF-8?q?=D0=BD=D0=B5=D0=B2?= Date: Wed, 9 Sep 2026 21:59:05 +0500 Subject: [PATCH 10/16] Tell the page the field of view A page already knows where the player is and which way they are facing, and it can read its own viewport, but not the field of view - that lives in the player's settings and nowhere a page can reach. Without it there is no way to turn a point in the world into a point on the screen, so anything anchored to the world was out of reach. It is the setting, not the momentary value. The renderer stretches the field of view while sprinting or under a speed effect, and following that would mean reading the camera every frame; a marker placed from this is exact standing still and drifts a little on the move. Verified on all seven targets by writing a non-default fov into options.txt and requiring the matching degrees in the page - asserting a plausible number would have passed on a hardcoded default. --- .../java/land/webgui/WebviewClientBridge.java | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/src/main/java/land/webgui/WebviewClientBridge.java b/src/main/java/land/webgui/WebviewClientBridge.java index a457e2e..8ca0da0 100644 --- a/src/main/java/land/webgui/WebviewClientBridge.java +++ b/src/main/java/land/webgui/WebviewClientBridge.java @@ -135,12 +135,28 @@ private static JsonObject buildPayload(MinecraftClient client, ClientPlayerEntit look.addProperty("pitch", player.getPitch()); o.add("look", look); + // The one camera value a page cannot work out for itself. Position and heading are + // above, and the viewport is window.innerWidth/innerHeight, but the field of view + // lives only in the player's own settings — and without it there is no way to turn + // a point in the world into a point on the screen. + // + // This is the setting, not the momentary value: the renderer stretches it while + // sprinting or under a speed effect, and reading that would mean hooking the + // camera every frame. A marker drawn from this drifts a little during a sprint and + // is exact when standing still. + o.addProperty("fov", fov(client)); + JsonObject server = buildServerInfo(client); if (server != null) o.add("server", server); return o; } + /** Vertical field of view in degrees, as the player set it. */ + private static int fov(MinecraftClient client) { + return client.options.getFov().getValue(); + } + private static JsonObject buildServerInfo(MinecraftClient client) { var nh = client.getNetworkHandler(); if (nh == null) return null; @@ -188,12 +204,21 @@ private static JsonObject buildServerInfo(MinecraftClient client) { look.addProperty("pitch", player.getXRot()); o.add("look", look); + // The one camera value a page cannot work out for itself - see the note on the + // Fabric side of this method. + o.addProperty("fov", fov(client)); + JsonObject server = buildServerInfo(client); if (server != null) o.add("server", server); return o; } + // Vertical field of view in degrees, as the player set it. + private static int fov(Minecraft client) { + return client.options.fov().get(); + } + private static JsonObject buildServerInfo(Minecraft client) { var nh = client.getConnection(); if (nh == null) return null; From ca2caee34f6c37fda5946873cc2bcf0cad590028 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9A=D0=B8=D1=80=D0=B8=D0=BB=D0=BB=20=D0=93=D1=80=D0=B8?= =?UTF-8?q?=D0=BD=D0=B5=D0=B2?= Date: Wed, 9 Sep 2026 22:50:33 +0500 Subject: [PATCH 11/16] Fix client data never updating after the page opened on NeoForge 1.21.11 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A page there received exactly one payload — the one pushed when its document loaded — and then nothing, ever. Position, health, game mode: frozen at whatever they were when the page opened, with nothing in the log to say so. From the outside it looked like a page that had stopped re-rendering, which is the wrong place to go looking. The per-tick push asks whether the browser's texture is ready, and on NeoForge that test was getRenderer().getTextureID() != 0. Rinku 3 keeps the texture as a Blaze3D GpuTexture, which need not have a raw GL id at all; on this target the id stayed 0, so the test never passed. The document-load push skips the check entirely, which is why a page got one payload and no more. Both loaders now use isTextureReady(), Rinku's own accessor, which the Fabric side was already using. Caught by a check that changes the world and expects the payload to follow, where every earlier test was satisfied by a single payload and passed. --- .../java/land/webgui/WebviewClientBridge.java | 111 +++++++++++++++++- 1 file changed, 107 insertions(+), 4 deletions(-) diff --git a/src/main/java/land/webgui/WebviewClientBridge.java b/src/main/java/land/webgui/WebviewClientBridge.java index 8ca0da0..36baedb 100644 --- a/src/main/java/land/webgui/WebviewClientBridge.java +++ b/src/main/java/land/webgui/WebviewClientBridge.java @@ -59,11 +59,13 @@ private static void tryPush(MinecraftClient client, boolean requireTexture, bool RinkuBrowser main = WebSession.browser(); RinkuBrowser hud = WebSession.hudBrowser(); - //? if fabric { + // isTextureReady on both loaders, not getRenderer().getTextureID() != 0 on one of + // them. Rinku 3 keeps the browser's texture as a Blaze3D GpuTexture, which need + // not have a raw GL id at all — on NeoForge 1.21.11 that id stayed 0, so this test + // never passed and the page received exactly one payload, the one pushed when its + // document loaded. Position, health, everything: frozen at the value it had on + // open, with nothing in the log to say so. boolean hasMain = main != null && (!requireTexture || main.isTextureReady()); - //? } else { - /*boolean hasMain = main != null && (!requireTexture || main.getRenderer().getTextureID() != 0);*/ - //? } boolean hasHud = hud != null && hud != main; if (!hasMain && !hasHud) return; @@ -145,6 +147,7 @@ private static JsonObject buildPayload(MinecraftClient client, ClientPlayerEntit // camera every frame. A marker drawn from this drifts a little during a sprint and // is exact when standing still. o.addProperty("fov", fov(client)); + o.add("lookingAt", buildLookingAt(client, player)); JsonObject server = buildServerInfo(client); if (server != null) o.add("server", server); @@ -157,6 +160,59 @@ private static int fov(MinecraftClient client) { return client.options.getFov().getValue(); } + /** + * What the crosshair is on, if anything. + * + * The game already computes this every frame for its own use — the block outline, the + * name above a mob, what a click would hit — so this is that same answer rather than a + * second ray cast with its own idea of reach. + * + * Always present, with {@code type: "none"} when the player is looking at nothing. A + * field that disappears makes every page write the same optional-chaining dance. + */ + private static JsonObject buildLookingAt(MinecraftClient client, ClientPlayerEntity player) { + JsonObject o = new JsonObject(); + net.minecraft.util.hit.HitResult hit = client.crosshairTarget; + if (hit == null || hit.getType() == net.minecraft.util.hit.HitResult.Type.MISS) { + o.addProperty("type", "none"); + return o; + } + + o.addProperty("distance", Math.sqrt(player.getEyePos().squaredDistanceTo(hit.getPos()))); + + if (hit instanceof net.minecraft.util.hit.BlockHitResult block) { + o.addProperty("type", "block"); + net.minecraft.util.math.BlockPos at = block.getBlockPos(); + JsonObject pos = new JsonObject(); + pos.addProperty("x", at.getX()); + pos.addProperty("y", at.getY()); + pos.addProperty("z", at.getZ()); + o.add("pos", pos); + o.addProperty("face", block.getSide().asString()); + var world = player.getEntityWorld(); + o.addProperty("block", net.minecraft.registry.Registries.BLOCK + .getId(world.getBlockState(at).getBlock()).toString()); + return o; + } + + if (hit instanceof net.minecraft.util.hit.EntityHitResult entityHit) { + var entity = entityHit.getEntity(); + o.addProperty("type", "entity"); + o.addProperty("uuid", entity.getUuid().toString()); + o.addProperty("entityType", net.minecraft.entity.EntityType.getId(entity.getType()).toString()); + o.addProperty("name", entity.getName().getString()); + JsonObject pos = new JsonObject(); + pos.addProperty("x", entity.getX()); + pos.addProperty("y", entity.getY()); + pos.addProperty("z", entity.getZ()); + o.add("pos", pos); + return o; + } + + o.addProperty("type", "none"); + return o; + } + private static JsonObject buildServerInfo(MinecraftClient client) { var nh = client.getNetworkHandler(); if (nh == null) return null; @@ -207,6 +263,7 @@ private static JsonObject buildServerInfo(MinecraftClient client) { // The one camera value a page cannot work out for itself - see the note on the // Fabric side of this method. o.addProperty("fov", fov(client)); + o.add("lookingAt", buildLookingAt(client, player)); JsonObject server = buildServerInfo(client); if (server != null) o.add("server", server); @@ -219,6 +276,52 @@ private static int fov(Minecraft client) { return client.options.fov().get(); } + // What the crosshair is on, if anything. This is the game's own answer, the one it + // uses for the block outline and the name above a mob, rather than a second ray cast + // with its own idea of reach. Always present, with type "none" for nothing - a field + // that disappears makes every page write the same optional-chaining dance. + private static JsonObject buildLookingAt(Minecraft client, LocalPlayer player) { + JsonObject o = new JsonObject(); + net.minecraft.world.phys.HitResult hit = client.hitResult; + if (hit == null || hit.getType() == net.minecraft.world.phys.HitResult.Type.MISS) { + o.addProperty("type", "none"); + return o; + } + + o.addProperty("distance", Math.sqrt(player.getEyePosition().distanceToSqr(hit.getLocation()))); + + if (hit instanceof net.minecraft.world.phys.BlockHitResult block) { + o.addProperty("type", "block"); + net.minecraft.core.BlockPos at = block.getBlockPos(); + JsonObject pos = new JsonObject(); + pos.addProperty("x", at.getX()); + pos.addProperty("y", at.getY()); + pos.addProperty("z", at.getZ()); + o.add("pos", pos); + o.addProperty("face", block.getDirection().getName()); + o.addProperty("block", net.minecraft.core.registries.BuiltInRegistries.BLOCK + .getKey(player.level().getBlockState(at).getBlock()).toString()); + return o; + } + + if (hit instanceof net.minecraft.world.phys.EntityHitResult entityHit) { + var entity = entityHit.getEntity(); + o.addProperty("type", "entity"); + o.addProperty("uuid", entity.getStringUUID()); + o.addProperty("entityType", net.minecraft.world.entity.EntityType.getKey(entity.getType()).toString()); + o.addProperty("name", entity.getName().getString()); + JsonObject pos = new JsonObject(); + pos.addProperty("x", entity.getX()); + pos.addProperty("y", entity.getY()); + pos.addProperty("z", entity.getZ()); + o.add("pos", pos); + return o; + } + + o.addProperty("type", "none"); + return o; + } + private static JsonObject buildServerInfo(Minecraft client) { var nh = client.getConnection(); if (nh == null) return null; From aa0727dd8992c7863aba2223bc2930cb62d09dea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9A=D0=B8=D1=80=D0=B8=D0=BB=D0=BB=20=D0=93=D1=80=D0=B8?= =?UTF-8?q?=D0=BD=D0=B5=D0=B2?= Date: Wed, 9 Sep 2026 22:50:45 +0500 Subject: [PATCH 12/16] Tell the page what the crosshair is on MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Alongside where the player is and which way they face, a page can now ask what they are actually looking at: a block with its registry id, whole coordinates and the face being approached, or an entity with its type, name and uuid, each with the distance to the point of contact. This is the game's own answer — the one it uses to draw the block outline and the name above a mob — not a second ray cast with its own idea of reach, which would disagree with what the player sees. type is always present, "none" included. A field that disappears makes every page write the same optional-chaining dance before it can ask the only question that matters. Verified on all seven targets against a world the test builds itself: the player is teleported to a known spot facing a known direction, a gold block goes exactly three away, and the payload has to name those coordinates, that block and the south face. Then the block is replaced by a zombie, and finally the player looks at the sky. --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ee618b5..f7904af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,9 @@ - One thing to know: a bundled page is served from `http://127.0.0.1:25580`, not from your domain, so your API needs to allow that origin in CORS. - `window.webgui.assetsBase` — where the server's own files are served from, so a page can build a URL to one without knowing the port. - `serveBundledPages`, `bundledPagesDir` and `assetBytesPerSecond` in `config/webgui/server.json`. +- **`client.fov`** — the vertical field of view in degrees. A page already knew where the player was and which way they faced, and could read its own viewport, but not this; without it there was no way to turn a point in the world into a point on the screen. It is the setting, not the momentary value — the game stretches it while sprinting or under a speed effect. +- **`client.lookingAt`** — what the crosshair is on: `{type: "block", block, pos, face, distance}`, `{type: "entity", uuid, entityType, name, pos, distance}`, or `{type: "none"}`. This is the game's own answer, the one it uses for the block outline and the name above a mob, rather than a second ray cast with its own idea of reach. `type` is always there, so a page switches on it instead of testing for a field that comes and goes. +- **A file can be downloaded from a page.** Files land in `webgui-downloads/` under the game directory and the player is told the name. Previously CEF asked the host where to put a download, heard nothing, and threw it away — so an invoice or a CSV export was simply unclickable. ### Fixed - **Fixed `window.webgui` being undefined while a page was still loading.** The bridge was injected only after the document finished, so a plain inline script calling it threw `Cannot read properties of undefined`; only pages that waited for `DOMContentLoaded` or later ever worked. From e1c71ec07ba294b8e00d3a28e26bd8828a674d57 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9A=D0=B8=D1=80=D0=B8=D0=BB=D0=BB=20=D0=93=D1=80=D0=B8?= =?UTF-8?q?=D0=BD=D0=B5=D0=B2?= Date: Wed, 9 Sep 2026 23:24:25 +0500 Subject: [PATCH 13/16] Apply the whole config on /webgui reload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bundled page list was only ever sent when a player joined, so after a reload the server served new bytes while the client still checked them against the old hash. trustedCommandOrigins and mainMenuUrl were never re-sent at all: dropping an origin from the trusted list did nothing for anyone already online until they reconnected. Join and reload now go through one sendConfigSnapshot, so they cannot drift apart again. The client keeps its session token for the life of the session instead of minting a new one per manifest — rotating it moved the origin out from under every open page, which turned a reload into a 404. Leaving a world still clears it, so a page from the previous server cannot read the next one's files. An open bundled page is reloaded when the revision actually changed; pages from a web host are left alone. Also rescan the web folder when server.json is missing or empty, since pages are served by default and the folder can still have changed. --- CHANGELOG.md | 3 +- .../java/land/webgui/WebGUIAssetServer.java | 12 +++++- src/main/java/land/webgui/WebGUIClient.java | 8 ++++ src/main/java/land/webgui/WebSession.java | 36 ++++++++++++++++ .../java/land/webgui/WebviewCommands.java | 12 +++--- src/main/java/land/webgui/WebviewJoinHud.java | 30 +------------ .../java/land/webgui/WebviewNetworking.java | 43 +++++++++++++++++++ .../webgui/server/WebviewServerConfig.java | 5 +++ 8 files changed, 112 insertions(+), 37 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f7904af..7637141 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ ### Added - **The server can host its own pages.** Drop files in `config/webgui/web/` and point any URL setting at them with `webgui:/index.html` — no web host, no domain, no port to open. They travel down the connection the player is already on, so it works behind NAT like everything else a server sends. - Addressed by SHA-256: a client that already has a file never asks for it again, and a changed file is simply a different name, so a page can never be stale. - - The folder is created on first start with a working page in it, and `/webgui reload` picks up edits without a restart. + - The folder is created on first start with a working page in it, and `/webgui reload` picks up edits without a restart — everyone online is told about the new files, and a bundled page a player already has open is refreshed in place, so you can edit a file and watch the result. - Limits, so a mistake here cannot take a server down: 8 MiB per file, 64 MiB in total, 2000 files, and `assetBytesPerSecond` (default 4 MiB/s per player). - **External hosting is unchanged.** `http://` and `https://` URLs behave exactly as before, and the two mix freely. - One thing to know: a bundled page is served from `http://127.0.0.1:25580`, not from your domain, so your API needs to allow that origin in CORS. @@ -16,6 +16,7 @@ - **A file can be downloaded from a page.** Files land in `webgui-downloads/` under the game directory and the player is told the name. Previously CEF asked the host where to put a download, heard nothing, and threw it away — so an invoice or a CSV export was simply unclickable. ### Fixed +- **`/webgui reload` now applies every setting it claims to.** `trustedCommandOrigins` and `mainMenuUrl` are copies each connected client holds, and the reload never re-sent them: removing an origin from the trusted list, or changing the main menu page, quietly did nothing for anyone already online until they reconnected. Joining and reloading now push the same set through one path, so they cannot drift apart again. - **Fixed `window.webgui` being undefined while a page was still loading.** The bridge was injected only after the document finished, so a plain inline script calling it threw `Cannot read properties of undefined`; only pages that waited for `DOMContentLoaded` or later ever worked. ## 1.7.1 - 2026-09-09 diff --git a/src/main/java/land/webgui/WebGUIAssetServer.java b/src/main/java/land/webgui/WebGUIAssetServer.java index 28231b7..bf3dfb5 100644 --- a/src/main/java/land/webgui/WebGUIAssetServer.java +++ b/src/main/java/land/webgui/WebGUIAssetServer.java @@ -61,13 +61,21 @@ public static boolean running() { } /** - * Starts the server if it is not already up, and rotates the session token. + * Starts the server if it is not already up, and mints a session token if there is none. * * Called when a manifest arrives rather than at launch: a client that never joins a * server that ships pages should not be listening on anything at all. + * + * The token is minted once per session and kept, not rotated on every manifest. A + * server may send a second manifest while the player is in the world — {@code /webgui + * reload} does exactly that — and rotating here would move the origin out from under + * every page already open, so an operator editing a file watched their HUD turn into + * a 404. Leaving a server clears the token, which is what actually has to invalidate. */ public static synchronized void ensureStarted() { - rotateToken(); + if (sessionToken.isEmpty()) { + rotateToken(); + } if (server != null) { return; } diff --git a/src/main/java/land/webgui/WebGUIClient.java b/src/main/java/land/webgui/WebGUIClient.java index fa13475..ba52a1d 100644 --- a/src/main/java/land/webgui/WebGUIClient.java +++ b/src/main/java/land/webgui/WebGUIClient.java @@ -326,6 +326,11 @@ private static void handleOpenPayload(Minecraft client, int mode, String url) { */ private static void onAssetManifest(String revision, String manifest) { WebGUIAssetCache.useCacheDir(assetCacheDir()); + // Kept from before the manifest is replaced: a second manifest in one session + // means the server reloaded, and only a different revision means the files + // actually moved. Empty is the first manifest of a session, where there is + // nothing open yet to refresh. + String previous = WebGUIAssetCache.revision(); WebGUIAssetCache.setManifest(revision, manifest); if (WebGUIAssetCache.isEmpty()) { WebGUIAssetServer.invalidateSession(); @@ -336,6 +341,9 @@ private static void onAssetManifest(String revision, String manifest) { // channel would leave the one place a server fully controls as the one place it // cannot use — and unlike a web host, nobody else can put a file there. WebGUITrustedOrigins.allowAlso(WebGUIAssetServer.origin()); + if (!previous.isEmpty() && !previous.equals(WebGUIAssetCache.revision())) { + WebSession.reloadBundledPages(); + } } /** diff --git a/src/main/java/land/webgui/WebSession.java b/src/main/java/land/webgui/WebSession.java index 6c4b7eb..ccfcb2e 100644 --- a/src/main/java/land/webgui/WebSession.java +++ b/src/main/java/land/webgui/WebSession.java @@ -98,6 +98,42 @@ public static RinkuBrowser openForHud(String url) { return browser; } + /** + * Refreshes any open page that came from the server's own files. + * + * Nothing would make the browser ask again on its own: the page's address has not + * changed, only the bytes behind it, so an operator running {@code /webgui reload} + * kept looking at the old page until they reopened it by hand. + * + * A page loaded from a web host is left alone — its content is not ours to decide + * has changed. + */ + public static void reloadBundledPages() { + String origin = WebGUIAssetServer.origin(); + if (origin.isEmpty()) { + return; + } + boolean any = reloadIfBundled(browser, origin) | reloadIfBundled(suspendedHudBrowser, origin); + if (any) { + // The bridge only pushes what changed, and a reloaded page starts with + // nothing at all, so without this it would sit there empty until the player + // moved. + WebviewClientBridge.clearCache(); + } + } + + private static boolean reloadIfBundled(RinkuBrowser target, String origin) { + if (target == null) { + return false; + } + String url = target.getURL(); + if (url == null || !url.startsWith(origin)) { + return false; + } + target.reload(); + return true; + } + public static void closeHudOnly() { if (mode == Mode.HUD_OVERLAY) { closeActiveBrowser(); diff --git a/src/main/java/land/webgui/WebviewCommands.java b/src/main/java/land/webgui/WebviewCommands.java index e260941..f9ea6b3 100644 --- a/src/main/java/land/webgui/WebviewCommands.java +++ b/src/main/java/land/webgui/WebviewCommands.java @@ -131,10 +131,10 @@ public static void register() { .executes(ctx -> { String msg = WebviewServerConfig.reload(); EntityBindingStore.load(); - // The death page url lives on the client, so a reload - // has to re-push it or online players keep the old one. + // Every setting the client holds a copy of has to be + // pushed again, or online players keep the old one. for (ServerPlayerEntity p : ctx.getSource().getServer().getPlayerManager().getPlayerList()) { - WebviewNetworking.sendDeathScreen(p, WebviewServerConfig.deathScreenUrl(), ""); + WebviewNetworking.sendConfigSnapshot(p); } ctx.getSource().sendFeedback(() -> Text.literal(msg), true); return 1; @@ -204,10 +204,10 @@ private static void onRegisterCommands(RegisterCommandsEvent event) { .executes(ctx -> { String msg = WebviewServerConfig.reload(); EntityBindingStore.load(); - // The death page url lives on the client, so a reload has to - // re-push it or online players keep the old one. + // Every setting the client holds a copy of has to be pushed + // again, or online players keep the old one. for (ServerPlayer p : ctx.getSource().getServer().getPlayerList().getPlayers()) { - WebviewNetworking.sendDeathScreen(p, WebviewServerConfig.deathScreenUrl(), ""); + WebviewNetworking.sendConfigSnapshot(p); } ctx.getSource().sendSuccess(() -> Component.literal(msg), true); return 1; diff --git a/src/main/java/land/webgui/WebviewJoinHud.java b/src/main/java/land/webgui/WebviewJoinHud.java index df1ddd1..c2ec8f1 100644 --- a/src/main/java/land/webgui/WebviewJoinHud.java +++ b/src/main/java/land/webgui/WebviewJoinHud.java @@ -29,20 +29,7 @@ public static void register() { return; } - WebviewNetworking.sendTrustedOrigins(player, WebviewServerConfig.trustedCommandOriginsJoined()); - - // Before any page URL: a page pointed at webgui:/ cannot resolve - // anything until the client knows what the server ships. - WebviewNetworking.sendAssetManifest(player); - - // Pushed now, not at death: the client has to know whether to - // suppress the vanilla death screen the instant it is opened. - WebviewNetworking.sendDeathScreen(player, WebviewServerConfig.deathScreenUrl(), ""); - - String mainMenuUrl = WebviewServerConfig.mainMenuUrl(); - if (!mainMenuUrl.isEmpty()) { - WebviewNetworking.sendMainMenuUrl(player, mainMenuUrl); - } + WebviewNetworking.sendConfigSnapshot(player); if (!WebviewServerConfig.autoHudOnJoin()) { return; @@ -79,20 +66,7 @@ private static void onPlayerJoin(PlayerEvent.PlayerLoggedInEvent event) { return; } - WebviewNetworking.sendTrustedOrigins(player, WebviewServerConfig.trustedCommandOriginsJoined()); - - // Before any page URL: a page pointed at webgui:/ cannot resolve anything until - // the client knows what the server ships. - WebviewNetworking.sendAssetManifest(player); - - // Pushed now, not at death: the client has to know whether to suppress the - // vanilla death screen the instant it is opened. - WebviewNetworking.sendDeathScreen(player, WebviewServerConfig.deathScreenUrl(), ""); - - String mainMenuUrl = WebviewServerConfig.mainMenuUrl(); - if (!mainMenuUrl.isEmpty()) { - WebviewNetworking.sendMainMenuUrl(player, mainMenuUrl); - } + WebviewNetworking.sendConfigSnapshot(player); if (!WebviewServerConfig.autoHudOnJoin()) { return; diff --git a/src/main/java/land/webgui/WebviewNetworking.java b/src/main/java/land/webgui/WebviewNetworking.java index b0d90f0..895ec6c 100644 --- a/src/main/java/land/webgui/WebviewNetworking.java +++ b/src/main/java/land/webgui/WebviewNetworking.java @@ -372,6 +372,27 @@ private static void sendAssetChunk(ServerPlayerEntity player, String path, int i //? } } + + /** + * Sends a player every client-side copy of a server setting. + * + * One place, called both when a player joins and when {@code /webgui reload} runs. + * These values live on the client once pushed, and each one the reload path forgot to + * re-send was a setting that silently did not apply until the player reconnected — + * which is how an edited page kept showing its old self. + */ + public static void sendConfigSnapshot(ServerPlayerEntity player) { + sendTrustedOrigins(player, WebviewServerConfig.trustedCommandOriginsJoined()); + // Before any page URL: a page pointed at webgui:/ cannot resolve anything until + // the client knows what the server ships. + sendAssetManifest(player); + // Pushed ahead of the death itself: the client has to know whether to suppress + // the vanilla screen the instant it is opened. + sendDeathScreen(player, WebviewServerConfig.deathScreenUrl(), ""); + // Empty included, so clearing mainMenuUrl reaches a player who was given one. + sendMainMenuUrl(player, WebviewServerConfig.mainMenuUrl()); + } + public static void sendTrustedOrigins(ServerPlayerEntity player, String origins) { String o = origins == null ? "" : origins; //? if >=1.20.5 { @@ -434,6 +455,28 @@ public static void sendDeathScreen(ServerPlayer player, String url, String infoJ PacketDistributor.sendToPlayer(player, new WebviewPayloads.WebviewDeathS2CPayload(u, info)); } + + // Sends a player every client-side copy of a server setting. + // + // One place, called both when a player joins and when /webgui reload runs. These + // values live on the client once pushed, and each one the reload path forgot to + // re-send was a setting that silently did not apply until the player reconnected — + // which is how an edited page kept showing its old self. + // + // Line comments, not a javadoc block: this whole branch is one commented-out region, + // and a nested end-of-comment marker would close it early. + public static void sendConfigSnapshot(ServerPlayer player) { + sendTrustedOrigins(player, WebviewServerConfig.trustedCommandOriginsJoined()); + // Before any page URL: a page pointed at webgui:/ cannot resolve anything until + // the client knows what the server ships. + sendAssetManifest(player); + // Pushed ahead of the death itself: the client has to know whether to suppress + // the vanilla screen the instant it is opened. + sendDeathScreen(player, WebviewServerConfig.deathScreenUrl(), ""); + // Empty included, so clearing mainMenuUrl reaches a player who was given one. + sendMainMenuUrl(player, WebviewServerConfig.mainMenuUrl()); + } + public static void sendTrustedOrigins(ServerPlayer player, String origins) { PacketDistributor.sendToPlayer(player, new WebviewPayloads.WebviewTrustedOriginsS2CPayload(origins == null ? "" : origins)); } diff --git a/src/main/java/land/webgui/server/WebviewServerConfig.java b/src/main/java/land/webgui/server/WebviewServerConfig.java index 8bb1cff..77574dd 100644 --- a/src/main/java/land/webgui/server/WebviewServerConfig.java +++ b/src/main/java/land/webgui/server/WebviewServerConfig.java @@ -307,11 +307,16 @@ public static String reload() { Path path = configPath(); try { if (!Files.isRegularFile(path)) { + // The pages are rescanned even so: they are served by default, so a + // server with no config file at all still has a web folder whose + // contents a reload is expected to pick up. + WebviewAssets.reload(); return "server.json not found — using current settings (path: " + path + ")"; } String json = Files.readString(path, StandardCharsets.UTF_8); WebviewServerConfig read = GSON.fromJson(json, WebviewServerConfig.class); if (read == null) { + WebviewAssets.reload(); return "server.json is empty — using current settings"; } instance = read; From 464e4991c5851f4ff0ddd3c69822e93d6c4bd6a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9A=D0=B8=D1=80=D0=B8=D0=BB=D0=BB=20=D0=93=D1=80=D0=B8?= =?UTF-8?q?=D0=BD=D0=B5=D0=B2?= Date: Wed, 9 Sep 2026 23:39:33 +0500 Subject: [PATCH 14/16] Say in the log why a page asset could not be served A bundled page whose build emitted absolute paths came up blank with nothing anywhere to explain it: the 404 went to the browser, and the only trace on our side was a debug line nobody has enabled. Both misses now warn once per distinct path, name the document that asked, and say that a relative base is the fix. resolve() also stopped blaming the wrong thing. It reported "this server ships no pages" whether the server shipped none or no loopback port was free, which sent an operator to check a config file that was fine. --- .../java/land/webgui/WebGUIAssetCache.java | 5 +++ .../java/land/webgui/WebGUIAssetServer.java | 40 ++++++++++++++++++- 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/src/main/java/land/webgui/WebGUIAssetCache.java b/src/main/java/land/webgui/WebGUIAssetCache.java index e1d29a4..f2ab3e2 100644 --- a/src/main/java/land/webgui/WebGUIAssetCache.java +++ b/src/main/java/land/webgui/WebGUIAssetCache.java @@ -62,6 +62,11 @@ public static boolean isEmpty() { return MANIFEST.isEmpty(); } + /** How many files this server said it ships. */ + public static int size() { + return MANIFEST.size(); + } + public static String revision() { return revision; } diff --git a/src/main/java/land/webgui/WebGUIAssetServer.java b/src/main/java/land/webgui/WebGUIAssetServer.java index bf3dfb5..a6e90b3 100644 --- a/src/main/java/land/webgui/WebGUIAssetServer.java +++ b/src/main/java/land/webgui/WebGUIAssetServer.java @@ -43,6 +43,8 @@ public final class WebGUIAssetServer { private static HttpServer server; private static int port; private static String sessionToken = ""; + /** Paths already reported as missing, so one broken build logs each name once. */ + private static final java.util.Set WARNED = java.util.concurrent.ConcurrentHashMap.newKeySet(); private WebGUIAssetServer() {} @@ -112,6 +114,7 @@ public static synchronized void ensureStarted() { /** Called on disconnect: the previous server's token must stop working immediately. */ public static synchronized void invalidateSession() { sessionToken = ""; + WARNED.clear(); } private static void rotateToken() { @@ -148,7 +151,15 @@ public static String resolve(String url) { path = "index.html"; } if (!running()) { - WebGUIMod.LOGGER.warn("webgui: {} cannot be opened - this server ships no pages", url); + // Two very different causes, and blaming the wrong one sent an operator to + // check a config file that was fine. + if (WebGUIAssetCache.isEmpty()) { + WebGUIMod.LOGGER.warn("webgui: {} cannot be opened - this server ships no pages", url); + } else { + WebGUIMod.LOGGER.warn("webgui: {} cannot be opened - the server sent {} page file(s), but no local" + + " port in {}..{} was free to serve them from", url, WebGUIAssetCache.size(), + DEFAULT_PORT, DEFAULT_PORT + PORT_ATTEMPTS - 1); + } return trimmed; } return base() + "/" + path + query; @@ -172,6 +183,10 @@ private static void handle(HttpExchange exchange) { // Either nothing is connected, or this is a stale page from the last // server. Both are "not yours to read", and both are a 404 rather than a // 403 so nothing here confirms what does exist. + warnOnce(raw, "webgui: page asset {} was requested without this session's prefix, so it cannot be served." + + " A build that emits absolute paths like /assets/app.js does this - rebuild it with a" + + " relative base (Vite base: './', CRA homepage: '.'). Requested by: {}", + raw, referrerOf(exchange)); respond(exchange, 404, "text/plain; charset=utf-8", "Not found".getBytes(StandardCharsets.UTF_8), null, false); return; } @@ -183,6 +198,11 @@ private static void handle(HttpExchange exchange) { WebviewAssetStore.Asset asset = WebGUIAssetCache.lookup(path); if (asset == null) { + // Warned, not silent: before this, a page with one mistyped path came up + // blank with nothing anywhere to say which file was missing. + warnOnce(path, "webgui: {} is not in this server's pages - check the name and its case," + + " and the server log for files skipped during the scan. Requested by: {}", + path, referrerOf(exchange)); respond(exchange, 404, "text/plain; charset=utf-8", ("Not in this server's pages: " + path).getBytes(StandardCharsets.UTF_8), null, false); return; @@ -220,6 +240,24 @@ private static void handle(HttpExchange exchange) { } } + /** + * Logs a miss the first time it is seen, and not again. + * + * A page that gets one path wrong usually gets thirty wrong the same way, and a wall + * of identical warnings is how the one line that matters gets lost. + */ + private static void warnOnce(String key, String message, Object... args) { + // Bounded, because the keys come from whatever a page chose to request. + if (WARNED.size() < 200 && WARNED.add(key)) { + WebGUIMod.LOGGER.warn(message, args); + } + } + + private static String referrerOf(HttpExchange exchange) { + String referrer = exchange.getRequestHeaders().getFirst("Referer"); + return referrer == null || referrer.isBlank() ? "(no referrer)" : referrer; + } + /** * Weaves the game bridge into a page before its own scripts can run. * From dd7d3b5b9782b84c6940514bef5fda3e7c42441f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9A=D0=B8=D1=80=D0=B8=D0=BB=D0=BB=20=D0=93=D1=80=D0=B8?= =?UTF-8?q?=D0=BD=D0=B5=D0=B2?= Date: Thu, 10 Sep 2026 00:30:04 +0500 Subject: [PATCH 15/16] Add a settings screen with page developer tools The mod had no settings of its own and nowhere to put any: NeoForge's mod list shows a Config button when a mod registers a screen factory, and on Fabric Mod Menu does the same, so both now open one. The first setting on it turns Chromium's own view of the page into log lines - console calls with the file and line that made them, uncaught exceptions, failed requests with their url and status, and the browser warnings nobody could see before. A page in the game has no inspector, and a blank one left a developer with nothing at all to go on. It reads the DevTools protocol, which needs neither a window nor a debugging port: openDevTools() wants a desktop window a game does not have, and the real inspector needs --remote-debugging-port, which is fixed on the command line before any mod runs. That argument does work, so the screen reports whether someone passed it and prints the address to open. Capped at 20 messages a second, and off by default. Also stop advising a rebuild when Chromium asks the origin root for favicon.ico, which it does on every page and which is nobody's mistake. --- CHANGELOG.md | 2 + build.gradle | 3 + .../java/land/webgui/WebGUIAssetServer.java | 6 + src/main/java/land/webgui/WebGUIClient.java | 8 + .../java/land/webgui/WebGUIClientConfig.java | 85 ++++++ src/main/java/land/webgui/WebGUIDevTools.java | 261 ++++++++++++++++++ .../land/webgui/WebGUIRemoteDebugging.java | 80 ++++++ .../land/webgui/WebGUISettingsScreen.java | 198 +++++++++++++ .../land/webgui/WebviewPageLoadHooks.java | 5 + .../webgui/compat/ModMenuIntegration.java | 21 ++ .../resources/assets/webgui/lang/en_us.json | 8 +- .../resources/assets/webgui/lang/ru_ru.json | 8 +- src/main/resources/fabric.mod.json | 3 + versions/1.20.1-fabric/gradle.properties | 1 + versions/1.21.1-fabric/gradle.properties | 1 + versions/1.21.11-fabric/gradle.properties | 1 + 16 files changed, 689 insertions(+), 2 deletions(-) create mode 100644 src/main/java/land/webgui/WebGUIClientConfig.java create mode 100644 src/main/java/land/webgui/WebGUIDevTools.java create mode 100644 src/main/java/land/webgui/WebGUIRemoteDebugging.java create mode 100644 src/main/java/land/webgui/WebGUISettingsScreen.java create mode 100644 src/main/java/land/webgui/compat/ModMenuIntegration.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 7637141..c23612c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,8 @@ - `serveBundledPages`, `bundledPagesDir` and `assetBytesPerSecond` in `config/webgui/server.json`. - **`client.fov`** — the vertical field of view in degrees. A page already knew where the player was and which way they faced, and could read its own viewport, but not this; without it there was no way to turn a point in the world into a point on the screen. It is the setting, not the momentary value — the game stretches it while sprinting or under a speed effect. - **`client.lookingAt`** — what the crosshair is on: `{type: "block", block, pos, face, distance}`, `{type: "entity", uuid, entityType, name, pos, distance}`, or `{type: "none"}`. This is the game's own answer, the one it uses for the block outline and the name above a mob, rather than a second ray cast with its own idea of reach. `type` is always there, so a page switches on it instead of testing for a field that comes and goes. +- **The mod has settings of its own, in the game's mod list.** NeoForge shows a **Config** button on WebGUI's entry; on Fabric it is Mod Menu's config button. Choices are saved as you make them, to `config/webgui/client.json`. +- **Page developer tools**, the first setting on that screen. With it on, what Chromium knows about the page goes into the game log: `console.*` calls with the file and line that made them, uncaught exceptions with where they were thrown, failed requests with their URL and status, and browser warnings — blocked mixed content, CORS refusals — that were previously invisible. A page in the game has no inspector, and until now a blank page left nothing to go on. Off by default and capped at 20 messages a second. It is not the DevTools window: that needs a remote debugging port the browser library does not open. See [Debugging a page](https://webgui.space/guide/debugging). - **A file can be downloaded from a page.** Files land in `webgui-downloads/` under the game directory and the player is told the name. Previously CEF asked the host where to put a download, heard nothing, and threw it away — so an invoice or a CSV export was simply unclickable. ### Fixed diff --git a/build.gradle b/build.gradle index c610db5..5b08e02 100644 --- a/build.gradle +++ b/build.gradle @@ -61,6 +61,9 @@ if (sc_loader == "fabric") { modImplementation "net.fabricmc:fabric-loader:${loader_version}" modImplementation "net.fabricmc.fabric-api:fabric-api:${fabric_version}" include(modImplementation("maven.modrinth:rinku:${rinku_version}")) + // Compiled against, never shipped and never required: it is only needed to + // implement the entrypoint that puts a Config button in its list. + modCompileOnly("maven.modrinth:modmenu:${modmenu_version}") } } else { configurations { diff --git a/src/main/java/land/webgui/WebGUIAssetServer.java b/src/main/java/land/webgui/WebGUIAssetServer.java index a6e90b3..7dacb93 100644 --- a/src/main/java/land/webgui/WebGUIAssetServer.java +++ b/src/main/java/land/webgui/WebGUIAssetServer.java @@ -179,6 +179,12 @@ private static void handle(HttpExchange exchange) { WebGUIMod.LOGGER.debug("webgui: http request {}", raw); String token = sessionToken; String prefix = "/" + token + "/"; + // Chromium asks the origin root for this on every page, unprompted. It is + // not a mistake in anyone's build, so it must not be reported as one. + if (raw.equals("/favicon.ico")) { + respond(exchange, 404, "text/plain; charset=utf-8", "No favicon".getBytes(StandardCharsets.UTF_8), null, false); + return; + } if (token.isEmpty() || !raw.startsWith(prefix)) { // Either nothing is connected, or this is a stale page from the last // server. Both are "not yours to read", and both are a 404 rather than a diff --git a/src/main/java/land/webgui/WebGUIClient.java b/src/main/java/land/webgui/WebGUIClient.java index ba52a1d..b8ca4ae 100644 --- a/src/main/java/land/webgui/WebGUIClient.java +++ b/src/main/java/land/webgui/WebGUIClient.java @@ -135,6 +135,7 @@ public void onInitializeClient() { });*/ //? } + WebGUIClientConfig.load(); WebGUIKeys.register(); WebHudOverlay.register(); @@ -202,8 +203,15 @@ private static void handleOpenPayload(net.minecraft.client.MinecraftClient clien // Payload registration moved to common init (WebGUIMod) so the dedicated // server also registers the S2C channels — see issue #4. + WebGUIClientConfig.load(); WebGUIKeys.register(modBus); WebHudOverlay.register(); + // Puts a Config button on the mod's entry in NeoForge's own mod list. Looked up + // rather than taken from the constructor, so the mod's entry point keeps the + // signature it has. + net.neoforged.fml.ModList.get().getModContainerById(WebGUIMod.MOD_ID).ifPresent(container -> + container.registerExtensionPoint(net.neoforged.neoforge.client.gui.IConfigScreenFactory.class, + (c, parent) -> new WebGUISettingsScreen(parent))); NeoForge.EVENT_BUS.addListener(WebGUIClient::onClientTick); NeoForge.EVENT_BUS.addListener(WebGUIClient::onLoggingOut); NeoForge.EVENT_BUS.addListener(WebGUIClient::onScreenOpening); diff --git a/src/main/java/land/webgui/WebGUIClientConfig.java b/src/main/java/land/webgui/WebGUIClientConfig.java new file mode 100644 index 0000000..3335a1a --- /dev/null +++ b/src/main/java/land/webgui/WebGUIClientConfig.java @@ -0,0 +1,85 @@ +package land.webgui; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +//? if fabric { +import net.fabricmc.loader.api.FabricLoader; +//? } else { +/*import net.neoforged.fml.loading.FMLPaths;*/ +//? } + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +/** + * Settings that belong to the player, not the server. + * + * Separate from {@code server.json} because nothing here is a server's business: it is + * what one person wants their own client to do. Written on change rather than on exit, so + * a crash cannot lose the choice that may well have been made to diagnose that crash. + */ +public final class WebGUIClientConfig { + + private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create(); + + /** + * Forward what Chromium knows about the page into the game log. + * + * Off by default: it is a developer's tool, it makes the log noisier, and a player + * running someone else's pages has no use for it. + */ + private boolean devTools = false; + + private static WebGUIClientConfig instance = new WebGUIClientConfig(); + + private WebGUIClientConfig() {} + + private static Path configPath() { + //? if fabric { + return FabricLoader.getInstance().getConfigDir().resolve("webgui").resolve("client.json"); + //? } else { + /*return FMLPaths.CONFIGDIR.get().resolve("webgui").resolve("client.json");*/ + //? } + } + + public static void load() { + Path path = configPath(); + try { + if (Files.isRegularFile(path)) { + WebGUIClientConfig read = GSON.fromJson(Files.readString(path, StandardCharsets.UTF_8), + WebGUIClientConfig.class); + if (read != null) { + instance = read; + } + } + } catch (IOException | RuntimeException e) { + // A hand-edited file with a typo in it must not stop the game from starting; + // the defaults are all usable. + WebGUIMod.LOGGER.warn("webgui: could not read {} - using defaults ({})", path, e.toString()); + } + } + + private static void save() { + Path path = configPath(); + try { + Files.createDirectories(path.getParent()); + Files.writeString(path, GSON.toJson(instance), StandardCharsets.UTF_8); + } catch (IOException e) { + WebGUIMod.LOGGER.warn("webgui: could not write {}: {}", path, e.toString()); + } + } + + public static boolean devTools() { + return instance.devTools; + } + + public static void setDevTools(boolean value) { + if (instance.devTools == value) { + return; + } + instance.devTools = value; + save(); + } +} diff --git a/src/main/java/land/webgui/WebGUIDevTools.java b/src/main/java/land/webgui/WebGUIDevTools.java new file mode 100644 index 0000000..289f032 --- /dev/null +++ b/src/main/java/land/webgui/WebGUIDevTools.java @@ -0,0 +1,261 @@ +package land.webgui; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import de.keksuccino.rinku.RinkuBrowser; +import org.cef.browser.CefDevToolsClient; + +/** + * Chromium's own view of a page, in the game log. + * + * A page inside the game has no address bar and no inspector: the full DevTools window + * needs {@code --remote-debugging-port}, which the browser library does not set and gives + * no way to add, and {@code openDevTools()} needs a desktop window a game does not have. + * The DevTools *protocol* needs neither, and it is where the browser keeps everything a + * developer is missing — failed requests with their status, uncaught exceptions with a + * stack, console calls with the line that made them. + * + * Attached per browser and only while the player has asked for it, because it is a + * developer's tool and a chatty page would otherwise bury the log. + */ +public final class WebGUIDevTools { + + /** A page in a loop could produce thousands of these a second. */ + private static final int MAX_PER_SECOND = 20; + /** Browsers already being watched, so a navigation does not attach a second time. */ + private static final java.util.Map ATTACHED = new java.util.WeakHashMap<>(); + + private static long windowStartedAt; + private static int inWindow; + private static boolean announcedSuppression; + + private WebGUIDevTools() {} + + /** + * Turns the protocol on for one browser, once. + * + * Called on every page load, because that is the earliest point where the browser + * certainly exists natively — but a browser navigates many times, and asking twice + * would double every line in the log. + * + * Safe to call always: with the setting off it does nothing at all, so no page pays + * for a feature nobody asked for. + */ + public static void attachOnce(RinkuBrowser browser) { + if (browser == null || !WebGUIClientConfig.devTools()) { + return; + } + synchronized (ATTACHED) { + // Identity, and weak: a browser is closed and forgotten without telling us. + if (ATTACHED.containsKey(browser)) { + return; + } + ATTACHED.put(browser, Boolean.TRUE); + } + CefDevToolsClient client; + try { + client = browser.getDevToolsClient(); + } catch (Throwable t) { + WebGUIMod.LOGGER.warn("webgui devtools: no protocol client for this browser: {}", t.toString()); + return; + } + if (client == null) { + return; + } + client.addEventListener(WebGUIDevTools::onEvent); + // Each domain has to be asked for before it says anything. Network is the one + // that answers "why is my page blank"; the other two carry errors and console. + for (String domain : new String[] {"Log.enable", "Runtime.enable", "Network.enable"}) { + client.executeDevToolsMethod(domain); + } + WebGUIMod.LOGGER.info("webgui devtools: watching {}", browser.getURL()); + } + + private static void onEvent(String method, String paramsJson) { + if (!allow()) { + return; + } + try { + JsonObject params = paramsJson == null || paramsJson.isBlank() + ? new JsonObject() + : JsonParser.parseString(paramsJson).getAsJsonObject(); + switch (method) { + case "Runtime.consoleAPICalled" -> consoleCall(params); + case "Runtime.exceptionThrown" -> exception(params); + case "Log.entryAdded" -> logEntry(params); + case "Network.loadingFailed" -> loadingFailed(params); + case "Network.responseReceived" -> responseReceived(params); + default -> { /* The domains above are chatty enough. */ } + } + } catch (RuntimeException e) { + // The payload shape is Chromium's, not ours, and a surprise in it must not + // take down the callback that carries every other message. + WebGUIMod.LOGGER.debug("webgui devtools: could not read {}: {}", method, e.toString()); + } + } + + // --- the events worth a line --------------------------------------------- + + private static void consoleCall(JsonObject params) { + StringBuilder text = new StringBuilder(); + for (JsonElement arg : array(params, "args")) { + if (text.length() > 0) { + text.append(' '); + } + text.append(describe(arg)); + } + String where = firstFrame(params.getAsJsonObject("stackTrace")); + String type = string(params, "type", "log"); + String line = "webgui devtools: console." + type + ": " + text + where; + if (type.equals("error") || type.equals("assert")) { + WebGUIMod.LOGGER.error(line); + } else if (type.equals("warning")) { + WebGUIMod.LOGGER.warn(line); + } else { + WebGUIMod.LOGGER.info(line); + } + } + + private static void exception(JsonObject params) { + JsonObject details = params.getAsJsonObject("exceptionDetails"); + if (details == null) { + return; + } + String text = string(details, "text", "uncaught exception"); + JsonObject thrown = details.getAsJsonObject("exception"); + if (thrown != null) { + String described = string(thrown, "description", string(thrown, "value", "")); + if (!described.isEmpty()) { + text = described; + } + } + WebGUIMod.LOGGER.error("webgui devtools: uncaught {}{}", text, firstFrame(details.getAsJsonObject("stackTrace"))); + } + + private static void logEntry(JsonObject params) { + JsonObject entry = params.getAsJsonObject("entry"); + if (entry == null) { + return; + } + String level = string(entry, "level", "info"); + String source = string(entry, "source", ""); + String url = string(entry, "url", ""); + String text = string(entry, "text", ""); + String line = "webgui devtools: [" + source + "] " + text + (url.isEmpty() ? "" : " " + url); + if (level.equals("error")) { + WebGUIMod.LOGGER.error(line); + } else if (level.equals("warning")) { + WebGUIMod.LOGGER.warn(line); + } else { + WebGUIMod.LOGGER.info(line); + } + } + + private static void loadingFailed(JsonObject params) { + // Cancelled requests are ordinary — a page navigating away does it — so only a + // real error is worth a line. + if (params.has("canceled") && params.get("canceled").getAsBoolean()) { + return; + } + WebGUIMod.LOGGER.warn("webgui devtools: request failed ({}) {}", + string(params, "type", "?"), string(params, "errorText", "unknown error")); + } + + private static void responseReceived(JsonObject params) { + JsonObject response = params.getAsJsonObject("response"); + if (response == null || !response.has("status")) { + return; + } + int status = response.get("status").getAsInt(); + if (status < 400) { + return; + } + WebGUIMod.LOGGER.warn("webgui devtools: {} {} ({})", + status, string(response, "url", "?"), string(params, "type", "?")); + } + + // --- reading Chromium's json -------------------------------------------- + + /** A console argument as a person would want to read it. */ + private static String describe(JsonElement arg) { + if (arg == null || !arg.isJsonObject()) { + return String.valueOf(arg); + } + JsonObject o = arg.getAsJsonObject(); + if (o.has("value")) { + JsonElement value = o.get("value"); + return value.isJsonPrimitive() ? value.getAsString() : value.toString(); + } + // An object or a DOM node: Chromium sends a rendered form for exactly this. + String described = string(o, "description", ""); + if (!described.isEmpty()) { + return described; + } + return string(o, "className", string(o, "type", "?")); + } + + /** Where it happened, as {@code at file.js:12}, or nothing when unknown. */ + private static String firstFrame(JsonObject stackTrace) { + if (stackTrace == null) { + return ""; + } + for (JsonElement element : array(stackTrace, "callFrames")) { + if (!element.isJsonObject()) { + continue; + } + JsonObject frame = element.getAsJsonObject(); + String url = string(frame, "url", ""); + if (url.isEmpty()) { + continue; + } + String function = string(frame, "functionName", ""); + int line = frame.has("lineNumber") ? frame.get("lineNumber").getAsInt() + 1 : 0; + return " at " + (function.isEmpty() ? "" : function + " ") + shorten(url) + ":" + line; + } + return ""; + } + + /** + * Drops the origin and session token from a served URL. + * + * The token is 32 characters of noise in front of every path, and it turns the one + * useful part of the line — which file — into something you have to hunt for. + */ + private static String shorten(String url) { + String base = WebGUIAssetServer.base(); + if (!base.isEmpty() && url.startsWith(base + "/")) { + return url.substring(base.length() + 1); + } + return url; + } + + private static JsonArray array(JsonObject o, String name) { + JsonElement value = o == null ? null : o.get(name); + return value != null && value.isJsonArray() ? value.getAsJsonArray() : new JsonArray(); + } + + private static String string(JsonObject o, String name, String fallback) { + JsonElement value = o == null ? null : o.get(name); + return value != null && value.isJsonPrimitive() ? value.getAsString() : fallback; + } + + private static synchronized boolean allow() { + long second = System.nanoTime() / 1_000_000_000L; + if (second != windowStartedAt) { + windowStartedAt = second; + inWindow = 0; + announcedSuppression = false; + } + if (++inWindow <= MAX_PER_SECOND) { + return true; + } + if (!announcedSuppression) { + announcedSuppression = true; + WebGUIMod.LOGGER.warn("webgui devtools: more than {} messages in a second - dropping the rest of them", + MAX_PER_SECOND); + } + return false; + } +} diff --git a/src/main/java/land/webgui/WebGUIRemoteDebugging.java b/src/main/java/land/webgui/WebGUIRemoteDebugging.java new file mode 100644 index 0000000..6c47fb7 --- /dev/null +++ b/src/main/java/land/webgui/WebGUIRemoteDebugging.java @@ -0,0 +1,80 @@ +package land.webgui; + +/** + * Whether the real Chrome DevTools can reach this client. + * + * The full inspector needs Chromium's remote debugging port. Nothing in the browser + * library sets it and it cannot be turned on once the browser has started — but on + * Windows CEF builds its command line from the process command line, and the game passes + * arguments it does not recognise straight through. So a single launch argument, + * {@code --remote-debugging-port=9222}, is enough, and this class reports whether someone + * used it. + * + * Reported rather than set, because by the time any mod runs the command line is fixed. + */ +public final class WebGUIRemoteDebugging { + + private static final String SWITCH = "--remote-debugging-port="; + /** What the wiki tells people to use, and what the screen offers when it is off. */ + public static final int SUGGESTED_PORT = 9222; + + private static Integer cached; + + private WebGUIRemoteDebugging() {} + + /** The port the inspector is listening on, or 0 when the argument was not given. */ + public static synchronized int port() { + if (cached == null) { + cached = findPort(); + } + return cached; + } + + public static boolean enabled() { + return port() > 0; + } + + /** The address to open in a browser, or an empty string when it is off. */ + public static String address() { + int port = port(); + return port > 0 ? "http://127.0.0.1:" + port : ""; + } + + private static int findPort() { + // Two sources because neither is reliable alone: the launcher's arguments are in + // sun.java.command on every JVM that matters, while ProcessHandle can return + // nothing at all on Windows. + int fromCommand = parse(System.getProperty("sun.java.command", "")); + if (fromCommand > 0) { + return fromCommand; + } + try { + return ProcessHandle.current().info().arguments() + .map(args -> parse(String.join(" ", args))) + .orElse(0); + } catch (RuntimeException e) { + return 0; + } + } + + private static int parse(String commandLine) { + int at = commandLine.indexOf(SWITCH); + if (at < 0) { + return 0; + } + int from = at + SWITCH.length(); + int to = from; + while (to < commandLine.length() && Character.isDigit(commandLine.charAt(to))) { + to++; + } + if (to == from) { + return 0; + } + try { + int port = Integer.parseInt(commandLine.substring(from, to)); + return port > 0 && port <= 65535 ? port : 0; + } catch (NumberFormatException e) { + return 0; + } + } +} diff --git a/src/main/java/land/webgui/WebGUISettingsScreen.java b/src/main/java/land/webgui/WebGUISettingsScreen.java new file mode 100644 index 0000000..b679033 --- /dev/null +++ b/src/main/java/land/webgui/WebGUISettingsScreen.java @@ -0,0 +1,198 @@ +package land.webgui; + +//? if fabric { +import net.minecraft.client.gui.DrawContext; +import net.minecraft.client.gui.screen.Screen; +import net.minecraft.client.gui.widget.ButtonWidget; +import net.minecraft.text.Text; +//? } else { +/*//? if >=26 { +import net.minecraft.client.gui.GuiGraphicsExtractor; +//? } else { +import net.minecraft.client.gui.GuiGraphics; +//? } +import net.minecraft.client.gui.components.Button; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.network.chat.Component;*/ +//? } + +/** + * The mod's own settings, reached from the game's mod list. + * + * Deliberately not a page in a browser: this is where a player goes when the browser is + * the thing that is not working. + */ +//? if fabric { +public class WebGUISettingsScreen extends Screen { +//? } else { +/*public class WebGUISettingsScreen extends Screen {*/ +//? } + + private static final int ROW_HEIGHT = 24; + private static final int BUTTON_WIDTH = 200; + + private final Screen parent; + + //? if fabric { + private ButtonWidget devToolsButton; + + public WebGUISettingsScreen(Screen parent) { + super(Text.translatable("screen.webgui.settings.title")); + this.parent = parent; + } + //? } else { + /*private Button devToolsButton; + + public WebGUISettingsScreen(Screen parent) { + super(Component.translatable("screen.webgui.settings.title")); + this.parent = parent; + }*/ + //? } + + @Override + protected void init() { + super.init(); + int left = this.width / 2 - BUTTON_WIDTH / 2; + int top = this.height / 4 + 24; + + //? if fabric { + this.devToolsButton = ButtonWidget.builder(devToolsLabel(), b -> { + WebGUIClientConfig.setDevTools(!WebGUIClientConfig.devTools()); + this.devToolsButton.setMessage(devToolsLabel()); + }).dimensions(left, top, BUTTON_WIDTH, 20).build(); + this.addDrawableChild(this.devToolsButton); + + this.addDrawableChild(ButtonWidget.builder(Text.translatable("gui.done"), b -> this.close()) + .dimensions(left, this.height - 32, BUTTON_WIDTH, 20).build()); + //? } else { + /*this.devToolsButton = Button.builder(devToolsLabel(), b -> { + WebGUIClientConfig.setDevTools(!WebGUIClientConfig.devTools()); + this.devToolsButton.setMessage(devToolsLabel()); + }).bounds(left, top, BUTTON_WIDTH, 20).build(); + this.addRenderableWidget(this.devToolsButton); + + this.addRenderableWidget(Button.builder(Component.translatable("gui.done"), b -> this.onClose()) + .bounds(left, this.height - 32, BUTTON_WIDTH, 20).build());*/ + //? } + } + + //? if fabric { + private Text devToolsLabel() { + return Text.translatable("screen.webgui.settings.devtools") + .append(": ") + .append(Text.translatable(WebGUIClientConfig.devTools() ? "options.on" : "options.off")); + } + //? } else { + /*private Component devToolsLabel() { + return Component.translatable("screen.webgui.settings.devtools") + .append(": ") + .append(Component.translatable(WebGUIClientConfig.devTools() ? "options.on" : "options.off")); + }*/ + //? } + + //? if fabric { + @Override + public void render(DrawContext context, int mouseX, int mouseY, float delta) { + //? if <1.20.5 { + /*this.renderBackground(context);*/ + //? } + super.render(context, mouseX, mouseY, delta); + int centre = this.width / 2; + context.drawCenteredTextWithShadow(this.textRenderer, this.title, centre, this.height / 4 - 8, 0xFFFFFFFF); + int under = this.height / 4 + 24 + ROW_HEIGHT; + context.drawCenteredTextWithShadow(this.textRenderer, + Text.translatable("screen.webgui.settings.devtools.hint1"), centre, under, 0xFFA0A0A0); + context.drawCenteredTextWithShadow(this.textRenderer, + Text.translatable("screen.webgui.settings.devtools.hint2"), centre, under + 12, 0xFFA0A0A0); + + // The real inspector, which no setting here can turn on: it is a launch argument, + // fixed before any mod runs. Reporting it is the most this screen can do, and it + // is what turns "why can I not inspect this" into one line to copy. + int inspector = under + 34; + boolean on = WebGUIRemoteDebugging.enabled(); + context.drawCenteredTextWithShadow(this.textRenderer, + on ? Text.translatable("screen.webgui.settings.inspector.on", WebGUIRemoteDebugging.address()) + : Text.translatable("screen.webgui.settings.inspector.off"), + centre, inspector, on ? 0xFF80FF80 : 0xFFA0A0A0); + if (!on) { + context.drawCenteredTextWithShadow(this.textRenderer, + Text.literal("--remote-debugging-port=" + WebGUIRemoteDebugging.SUGGESTED_PORT), + centre, inspector + 12, 0xFFFFFF80); + } + } + + @Override + public void close() { + if (this.client != null) { + this.client.setScreen(this.parent); + } + } + //? } else { + /*//? if >=26 { + @Override + public void extractRenderState(GuiGraphicsExtractor context, int mouseX, int mouseY, float delta) { + super.extractRenderState(context, mouseX, mouseY, delta); + int centre = this.width / 2; + context.centeredText(this.font, this.title, centre, this.height / 4 - 8, 0xFFFFFFFF); + int under = this.height / 4 + 24 + ROW_HEIGHT; + context.centeredText(this.font, + Component.translatable("screen.webgui.settings.devtools.hint1"), centre, under, 0xFFA0A0A0); + context.centeredText(this.font, + Component.translatable("screen.webgui.settings.devtools.hint2"), centre, under + 12, 0xFFA0A0A0); + + // The real inspector, which no setting here can turn on: it is a launch argument, + // fixed before any mod runs. Reporting it is the most this screen can do, and it + // is what turns "why can I not inspect this" into one line to copy. + int inspector = under + 34; + boolean on = WebGUIRemoteDebugging.enabled(); + context.centeredText(this.font, + on ? Component.translatable("screen.webgui.settings.inspector.on", WebGUIRemoteDebugging.address()) + : Component.translatable("screen.webgui.settings.inspector.off"), + centre, inspector, on ? 0xFF80FF80 : 0xFFA0A0A0); + if (!on) { + context.centeredText(this.font, + Component.literal("--remote-debugging-port=" + WebGUIRemoteDebugging.SUGGESTED_PORT), + centre, inspector + 12, 0xFFFFFF80); + } + } + //? } else { + @Override + public void render(GuiGraphics context, int mouseX, int mouseY, float delta) { + super.render(context, mouseX, mouseY, delta); + int centre = this.width / 2; + context.drawCenteredString(this.font, this.title, centre, this.height / 4 - 8, 0xFFFFFFFF); + int under = this.height / 4 + 24 + ROW_HEIGHT; + context.drawCenteredString(this.font, + Component.translatable("screen.webgui.settings.devtools.hint1"), centre, under, 0xFFA0A0A0); + context.drawCenteredString(this.font, + Component.translatable("screen.webgui.settings.devtools.hint2"), centre, under + 12, 0xFFA0A0A0); + + // The real inspector, which no setting here can turn on: it is a launch argument, + // fixed before any mod runs. Reporting it is the most this screen can do, and it + // is what turns "why can I not inspect this" into one line to copy. + int inspector = under + 34; + boolean on = WebGUIRemoteDebugging.enabled(); + context.drawCenteredString(this.font, + on ? Component.translatable("screen.webgui.settings.inspector.on", WebGUIRemoteDebugging.address()) + : Component.translatable("screen.webgui.settings.inspector.off"), + centre, inspector, on ? 0xFF80FF80 : 0xFFA0A0A0); + if (!on) { + context.drawCenteredString(this.font, + Component.literal("--remote-debugging-port=" + WebGUIRemoteDebugging.SUGGESTED_PORT), + centre, inspector + 12, 0xFFFFFF80); + } + } + //? } + + @Override + public void onClose() { + if (this.minecraft != null) { + //? if >=26.2-neoforge { + this.minecraft.gui.setScreen(this.parent); + //? } else { + this.minecraft.setScreen(this.parent); + //? } + } + }*/ + //? } +} diff --git a/src/main/java/land/webgui/WebviewPageLoadHooks.java b/src/main/java/land/webgui/WebviewPageLoadHooks.java index 4973e9e..6b2b79d 100644 --- a/src/main/java/land/webgui/WebviewPageLoadHooks.java +++ b/src/main/java/land/webgui/WebviewPageLoadHooks.java @@ -24,6 +24,11 @@ public void onLoadStart(CefBrowser browser, CefFrame frame, RinkuBrowser active = WebSession.browser(); if (active == null || browser != active) return; + // Here rather than where the browser is created: the protocol needs a + // browser that exists natively, and the first load is the earliest point + // where that is certainly true. + WebGUIDevTools.attachOnce(active); + // Before the document's own scripts, not after them. Injecting only at // load end meant a plain