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/CHANGELOG.md b/CHANGELOG.md index 19d2932..ce5c965 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,46 @@ # Changelog +## 1.8.0 - 2026-09-10 + +### 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}`, 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. +- **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. +- **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 — 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. +- `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. +- **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. + +### 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. +- **`/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. + +### Known limitations +- **A second game client on the same machine gets no browser.** The browser library keeps one Chromium cache for the whole machine and locks it, so the second client to start finds it taken. Reported upstream; nothing WebGUI can do from its side. +- **`window.open` does nothing.** Pop-up windows are not supported; open pages through the mod instead. +- **On Fabric, the settings screen needs [Mod Menu](https://modrinth.com/mod/modmenu)**, since Fabric has no mod list of its own. Without it, `config/webgui/client.json` can be edited by hand. + ## 1.7.0 - 2026-09-08 ### Changed diff --git a/build.gradle b/build.gradle index 6cba9bf..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 { @@ -81,6 +84,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 +114,10 @@ if (sc_loader == "fabric") { } server { server() + // 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) } } } @@ -210,8 +221,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/gradle.properties b/gradle.properties index 5a52918..b80ac93 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.8.0 maven_group=land.webgui archives_base_name=webgui diff --git a/src/main/java/land/webgui/WebGUIAssetCache.java b/src/main/java/land/webgui/WebGUIAssetCache.java new file mode 100644 index 0000000..f2ab3e2 --- /dev/null +++ b/src/main/java/land/webgui/WebGUIAssetCache.java @@ -0,0 +1,261 @@ +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(); + } + + /** How many files this server said it ships. */ + public static int size() { + return MANIFEST.size(); + } + + 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..b75a01f --- /dev/null +++ b/src/main/java/land/webgui/WebGUIAssetServer.java @@ -0,0 +1,313 @@ +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. + * + * A plain HTTP origin is the only form every web API treats as ordinary, so the files are + * served rather than handed over some scheme of our own. + * + * Bound to loopback only, 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 rather than random because a backend accepting calls from these pages + * has to name {@code http://127.0.0.1:25580} in CORS, which a moving port would make + * impossible. + */ +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 = ""; + /** 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() {} + + public static String origin() { + return server == null ? "" : "http://" + LOOPBACK + ":" + port; + } + + 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 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() { + if (sessionToken.isEmpty()) { + rotateToken(); + } + if (server != null) { + return; + } + for (int candidate = DEFAULT_PORT; candidate < DEFAULT_PORT + PORT_ATTEMPTS; candidate++) { + try { + // Explicitly IPv4, not getLoopbackAddress(): with IPv6 present that + // returns ::1, and the socket then listens on [::1] while every URL says + // 127.0.0.1 - listening, and refusing every connection. + 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 = ""; + WARNED.clear(); + } + + 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()) { + // Two different causes; naming the wrong one sends an operator to a config + // file that is 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; + } + + // --- 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 + "/"; + // Chromium asks for this unprompted on every page; it is nobody's mistake. + 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 + // 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; + } + + String path = WebviewAssetStore.normalizeRequest(raw.substring(prefix.length())); + if (path.isEmpty()) { + path = "index.html"; + } + + WebviewAssetStore.Asset asset = WebGUIAssetCache.lookup(path); + if (asset == null) { + // Warned, not silent: one mistyped path otherwise means a blank page + // with nothing to explain it. + 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; + } + + // 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: the symptom on the other side of this catch is a blank + // screen, and nothing else would say why. + WebGUIMod.LOGGER.warn("webgui: page request failed for {}: {}", + exchange.getRequestURI(), e.toString()); + } + } + + /** + * Logs a miss the first time it is seen, and not again: a build that gets one path + * wrong gets thirty wrong the same way. + */ + private static void warnOnce(String key, String message, Object... args) { + 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. + * + * 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 6b41065..b8ca4ae 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;*/ //? } @@ -32,6 +34,7 @@ public void onInitializeClient() { Rinku.getClient().addDisplayHandler(new WebviewBrowserConsoleLogger()); WebviewPageToClientBridge.register(); WebviewPageLoadHooks.register(); + WebviewDownloadHandler.register(); WebGUIMod.LOGGER.info("WebGUI bridge ready (console log, page↔game, client data)."); }); @@ -58,6 +61,24 @@ 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())); + }); + + 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(); @@ -83,9 +104,38 @@ 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)); + }); + + 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)); + }); + + 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); });*/ //? } + WebGUIClientConfig.load(); WebGUIKeys.register(); WebHudOverlay.register(); @@ -105,6 +155,22 @@ private static void onLeaveWorld() { WebSession.dispose(); WebGUIMainMenuUrl.setUrl(""); 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 + * 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) { @@ -131,15 +197,24 @@ private static void handleOpenPayload(net.minecraft.client.MinecraftClient clien Rinku.getClient().addDisplayHandler(new WebviewBrowserConsoleLogger()); WebviewPageToClientBridge.register(); WebviewPageLoadHooks.register(); + WebviewDownloadHandler.register(); WebGUIMod.LOGGER.info("WebGUI bridge ready (console log, page<->game, client data)."); }); // 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); } private static void onLoggingOut(ClientPlayerNetworkEvent.LoggingOut event) { @@ -153,12 +228,29 @@ private static void onLeaveWorld() { WebSession.dispose(); WebGUIMainMenuUrl.setUrl(""); 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 + // 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 // 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; @@ -172,6 +264,33 @@ 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()))); + 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 + // 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) { @@ -206,4 +325,82 @@ 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()); + // 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(); + 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()); + if (!previous.isEmpty() && !previous.equals(WebGUIAssetCache.revision())) { + WebSession.reloadBundledPages(); + } + } + + /** + * 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/WebGUIClientConfig.java b/src/main/java/land/webgui/WebGUIClientConfig.java new file mode 100644 index 0000000..14c3c31 --- /dev/null +++ b/src/main/java/land/webgui/WebGUIClientConfig.java @@ -0,0 +1,78 @@ +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. + * + * Written on change rather than on exit: the choice here is often made to diagnose a + * crash, and a crash must not lose it. + */ +public final class WebGUIClientConfig { + + private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create(); + + /** Off by default: a developer's tool, and it makes the log noisier. */ + 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 typo in a hand-edited file must not stop the game starting. + 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/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/WebGUIDevTools.java b/src/main/java/land/webgui/WebGUIDevTools.java new file mode 100644 index 0000000..0514bb2 --- /dev/null +++ b/src/main/java/land/webgui/WebGUIDevTools.java @@ -0,0 +1,245 @@ +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: failed requests with their status, + * uncaught exceptions with a stack, console calls with the line that made them. + * + * Over the DevTools protocol, which needs neither a window nor a debugging port - + * {@code openDevTools()} wants a desktop window a game does not have, and the inspector + * needs a port set on the command line before any mod runs. + * + * Attached per browser, and only while the player has asked for it. + */ +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 — the earliest point where the browser certainly exists + * natively — but attaching twice would double every line in the log. Does nothing + * with the setting off. + */ + public static void attachOnce(RinkuBrowser browser) { + if (browser == null || !WebGUIClientConfig.devTools()) { + return; + } + synchronized (ATTACHED) { + 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 says nothing until it is asked for. + 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 -> { } + } + } catch (RuntimeException e) { + // The payload shape is Chromium's; a surprise in one must not kill the + // callback that carries the rest. + 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) { + // A page navigating away cancels requests; that is not a failure. + 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 -------------------------------------------- + + 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(); + } + 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, leaving the part that says which file. */ + 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/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/WebGUIMod.java b/src/main/java/land/webgui/WebGUIMod.java index e77910b..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 -> { @@ -43,6 +44,7 @@ public void onInitialize() { }); WebviewCommands.register(); WebviewJoinHud.register(); + land.webgui.server.WebviewDeathListener.register(); LOGGER.info("WebGUI common init (S2C payloads, commands)."); } //? } else { @@ -53,6 +55,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 { @@ -69,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/WebGUIRemoteDebugging.java b/src/main/java/land/webgui/WebGUIRemoteDebugging.java new file mode 100644 index 0000000..447547f --- /dev/null +++ b/src/main/java/land/webgui/WebGUIRemoteDebugging.java @@ -0,0 +1,75 @@ +package land.webgui; + +/** + * Whether the real Chrome DevTools can reach this client. + * + * CEF builds its command line from the process command line, and the game passes + * arguments it does not recognise straight through, so {@code --remote-debugging-port} + * on the game's own command line is enough to get the full inspector. + * + * Reported rather than set: by the time any mod runs, that 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() { + // Both sources: ProcessHandle returns nothing at all on some platforms. + 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..c896cdb --- /dev/null +++ b/src/main/java/land/webgui/WebGUISettingsScreen.java @@ -0,0 +1,195 @@ +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. + * + * Not a page in a browser: this is where a player goes when the browser is what 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 is a launch argument, fixed before any mod runs; reporting + // it is the most this screen can do. + 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 is a launch argument, fixed before any mod runs; reporting + // it is the most this screen can do. + 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 is a launch argument, fixed before any mod runs; reporting + // it is the most this screen can do. + 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/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/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/WebSession.java b/src/main/java/land/webgui/WebSession.java index e0dd0e4..c36776f 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,17 +87,47 @@ 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; } + /** + * Refreshes any open page that came from the server's own files. + * + * The address does not change across a reload, only the bytes behind it, so nothing + * would make the browser ask again on its own. A page from a web host is left alone. + */ + 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 has nothing. + 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/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/WebviewClientBridge.java b/src/main/java/land/webgui/WebviewClientBridge.java index a457e2e..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; @@ -135,12 +137,82 @@ 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)); + o.add("lookingAt", buildLookingAt(client, player)); + 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(); + } + + /** + * 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; @@ -188,12 +260,68 @@ 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)); + o.add("lookingAt", buildLookingAt(client, player)); + 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(); + } + + // 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; 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/WebviewCommands.java b/src/main/java/land/webgui/WebviewCommands.java index 902b31e..f9ea6b3 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(); + // 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.sendConfigSnapshot(p); + } 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(); + // 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.sendConfigSnapshot(p); + } ctx.getSource().sendSuccess(() -> Component.literal(msg), true); return 1; })) 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..c193d31 --- /dev/null +++ b/src/main/java/land/webgui/WebviewDownloadNames.java @@ -0,0 +1,86 @@ +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); + } + +} diff --git a/src/main/java/land/webgui/WebviewJoinHud.java b/src/main/java/land/webgui/WebviewJoinHud.java index d414ca9..c2ec8f1 100644 --- a/src/main/java/land/webgui/WebviewJoinHud.java +++ b/src/main/java/land/webgui/WebviewJoinHud.java @@ -23,13 +23,14 @@ public static void register() { return; } - WebviewNetworking.sendTrustedOrigins(player, WebviewServerConfig.trustedCommandOriginsJoined()); - - String mainMenuUrl = WebviewServerConfig.mainMenuUrl(); - if (!mainMenuUrl.isEmpty()) { - WebviewNetworking.sendMainMenuUrl(player, mainMenuUrl); + // 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.sendConfigSnapshot(player); + if (!WebviewServerConfig.autoHudOnJoin()) { return; } @@ -42,22 +43,31 @@ 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(); - WebviewNetworking.sendTrustedOrigins(player, WebviewServerConfig.trustedCommandOriginsJoined()); - - String mainMenuUrl = WebviewServerConfig.mainMenuUrl(); - if (!mainMenuUrl.isEmpty()) { - WebviewNetworking.sendMainMenuUrl(player, mainMenuUrl); + // 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.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 9ad8840..895ec6c 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 { @@ -37,17 +47,48 @@ 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.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 { /*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.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 @@ -71,6 +112,14 @@ 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) -> {}); + 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) -> {}); } }); }*/ @@ -83,14 +132,44 @@ 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.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.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); });*/ //? } } @@ -175,6 +254,145 @@ 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);*/ + //? } + } + + /** + * 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;*/ + //? } + } + + /** 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);*/ + //? } + } + + + /** + * 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 { @@ -231,10 +449,88 @@ 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)); + } + + + // 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)); } + // 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) { + 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/WebviewPageLoadHooks.java b/src/main/java/land/webgui/WebviewPageLoadHooks.java index 6130257..6b2b79d 100644 --- a/src/main/java/land/webgui/WebviewPageLoadHooks.java +++ b/src/main/java/land/webgui/WebviewPageLoadHooks.java @@ -23,6 +23,21 @@ public void onLoadStart(CefBrowser browser, CefFrame frame, org.cef.network.CefRequest.TransitionType transitionType) { 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 + """); + WebGUIMod.LOGGER.info("webgui: created a starter page at {}", index); + } +} 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/WebviewDeathListener.java b/src/main/java/land/webgui/server/WebviewDeathListener.java new file mode 100644 index 0000000..064e20e --- /dev/null +++ b/src/main/java/land/webgui/server/WebviewDeathListener.java @@ -0,0 +1,137 @@ +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.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; +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) { + // 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; + } + //? } 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) { + // 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; + }*/ + //? } +} 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..6bf43f8 --- /dev/null +++ b/src/main/java/land/webgui/server/WebviewRateLimiter.java @@ -0,0 +1,135 @@ +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 much of the server one player's pages may consume per second. + * + * 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. + * + * 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; + + /** + * 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(); + + /** 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 { + /** 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 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, Bucket bucket, long cost, long limitPerSecond) { + if (limitPerSecond <= 0 || playerId == null) { + return true; + } + if (WINDOWS.size() > MAX_TRACKED) { + WINDOWS.clear(); + } + + 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 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 << COUNT_BITS) | Math.min(charge, COUNT_MASK); + } + // Saturate rather than wrap into the second field. + return (second << COUNT_BITS) | Math.min(spent + charge, COUNT_MASK); + }); + + long spent = updated & COUNT_MASK; + if (spent <= limitPerSecond) { + window.warned = false; + return true; + } + if (!window.warned) { + window.warned = true; + WebGUIMod.LOGGER.warn( + "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 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 6dc40cb..77574dd 100644 --- a/src/main/java/land/webgui/server/WebviewServerConfig.java +++ b/src/main/java/land/webgui/server/WebviewServerConfig.java @@ -44,6 +44,24 @@ private static Path configPath() { private String mainMenuUrl = ""; + // 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; + + // 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 = ""; @@ -76,6 +94,14 @@ private static void writeExample() { o.addProperty("mainMenuUrl", "http://your-site.example/menu"); + o.addProperty("deathScreenUrl", ""); + + o.addProperty("serveBundledPages", true); + o.addProperty("bundledPagesDir", "web"); + + o.addProperty("requireClientMod", false); + o.addProperty("pageEventsPerSecond", 20); + o.addProperty("updateCheckUrl", ""); JsonArray origins = new JsonArray(); @@ -197,6 +223,69 @@ 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(); + } + + /** + * 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. + * + * 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); + } + + /** + * 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(); @@ -218,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; @@ -234,6 +328,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/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/assets/webgui/lang/en_us.json b/src/main/resources/assets/webgui/lang/en_us.json index c81196b..f869498 100644 --- a/src/main/resources/assets/webgui/lang/en_us.json +++ b/src/main/resources/assets/webgui/lang/en_us.json @@ -6,5 +6,11 @@ "screen.webgui.url": "URL", "message.webgui.mcef_not_ready": "WebGUI is not ready yet. Wait for Chromium to finish loading or check the log.", "message.webgui.hud_interactive_on": "Web HUD: interactive mode (pseudo-GUI). Press the key again to exit.", - "message.webgui.hud_interactive_off": "Web HUD: view only. Press the interactive mode key to enable input." + "message.webgui.hud_interactive_off": "Web HUD: view only. Press the interactive mode key to enable input.", + "screen.webgui.settings.title": "WebGUI Settings", + "screen.webgui.settings.devtools": "Page developer tools", + "screen.webgui.settings.devtools.hint1": "Writes browser errors, failed requests and console output", + "screen.webgui.settings.devtools.hint2": "to the game log. For developing pages, not for playing.", + "screen.webgui.settings.inspector.on": "Chrome DevTools: open %s in a browser", + "screen.webgui.settings.inspector.off": "For the full inspector, add this launcher game argument:" } diff --git a/src/main/resources/assets/webgui/lang/ru_ru.json b/src/main/resources/assets/webgui/lang/ru_ru.json index 2475262..9cf3954 100644 --- a/src/main/resources/assets/webgui/lang/ru_ru.json +++ b/src/main/resources/assets/webgui/lang/ru_ru.json @@ -6,5 +6,11 @@ "screen.webgui.url": "URL", "message.webgui.mcef_not_ready": "WebGUI ещё не готов. Дождитесь загрузки Chromium или проверьте лог.", "message.webgui.hud_interactive_on": "Веб-HUD: интерактивный режим (псевдо-GUI). Нажмите снова для выхода.", - "message.webgui.hud_interactive_off": "Веб-HUD: только просмотр. Нажмите клавишу интерактивного режима для ввода." + "message.webgui.hud_interactive_off": "Веб-HUD: только просмотр. Нажмите клавишу интерактивного режима для ввода.", + "screen.webgui.settings.title": "Настройки WebGUI", + "screen.webgui.settings.devtools": "Инструменты разработчика страниц", + "screen.webgui.settings.devtools.hint1": "Пишет ошибки браузера, упавшие запросы и вывод консоли", + "screen.webgui.settings.devtools.hint2": "в лог игры. Нужно при разработке страниц, не для игры.", + "screen.webgui.settings.inspector.on": "Chrome DevTools: откройте %s в браузере", + "screen.webgui.settings.inspector.off": "Для полного инспектора добавьте аргумент запуска игры:" } diff --git a/src/main/resources/fabric.mod.json b/src/main/resources/fabric.mod.json index 2b85e08..19a9af2 100644 --- a/src/main/resources/fabric.mod.json +++ b/src/main/resources/fabric.mod.json @@ -3,15 +3,20 @@ "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" + "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", @@ -25,9 +30,13 @@ ], "client": [ "land.webgui.WebGUIClient" + ], + "modmenu": [ + "land.webgui.compat.ModMenuIntegration" ] }, "mixins": [ - "webgui.mixins.json" + "webgui.mixins.json", + "webgui.fabric.mixins.json" ] } diff --git a/src/main/resources/icon-128.png b/src/main/resources/icon-128.png new file mode 100644 index 0000000..a6e7c76 Binary files /dev/null and b/src/main/resources/icon-128.png differ diff --git a/src/main/resources/icon-256.png b/src/main/resources/icon-256.png new file mode 100644 index 0000000..8bd17aa Binary files /dev/null and b/src/main/resources/icon-256.png differ 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 + } +} 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 new file mode 100644 index 0000000..f958a0d --- /dev/null +++ b/src/test/java/land/webgui/WebviewDownloadNamesTest.java @@ -0,0 +1,101 @@ +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\"ehi"); + 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"); + } +} diff --git a/versions/1.20.1-fabric/gradle.properties b/versions/1.20.1-fabric/gradle.properties index 34d7a6c..b53c61a 100644 --- a/versions/1.20.1-fabric/gradle.properties +++ b/versions/1.20.1-fabric/gradle.properties @@ -5,3 +5,4 @@ fabric_version=0.92.12+1.20.1 rinku_version=3.0.4-1.20.1-fabric minecraft_range=>=1.20.1 <=1.20.1 loader_type=fabric +modmenu_version=7.2.2 diff --git a/versions/1.21.1-fabric/gradle.properties b/versions/1.21.1-fabric/gradle.properties index e3f31cf..8e85f5d 100644 --- a/versions/1.21.1-fabric/gradle.properties +++ b/versions/1.21.1-fabric/gradle.properties @@ -5,3 +5,4 @@ fabric_version=0.116.17+1.21.1 rinku_version=3.0.4-1.21.1-fabric minecraft_range=>=1.21 <=1.21.1 loader_type=fabric +modmenu_version=11.0.4 diff --git a/versions/1.21.11-fabric/gradle.properties b/versions/1.21.11-fabric/gradle.properties index 79f34df..eb81029 100644 --- a/versions/1.21.11-fabric/gradle.properties +++ b/versions/1.21.11-fabric/gradle.properties @@ -5,3 +5,4 @@ fabric_version=0.141.6+1.21.11 rinku_version=3.0.4-1.21.11-fabric minecraft_range=>=1.21.5 <=1.21.11 loader_type=fabric +modmenu_version=17.0.0