diff --git a/.github/scripts/verify-modrinth.py b/.github/scripts/verify-modrinth.py index f1143e3..6eae350 100644 --- a/.github/scripts/verify-modrinth.py +++ b/.github/scripts/verify-modrinth.py @@ -16,6 +16,9 @@ "mc1.21.11-neoforge": ("neoforge", "1.21.11"), "mc26.1.2-neoforge": ("neoforge", "26.1.2"), "mc26.2-neoforge": ("neoforge", "26.2"), + "mc26.2-fabric": ("fabric", "26.2"), + "mc26.3-fabric": ("fabric", "26.3"), + "mc26.3-neoforge": ("neoforge", "26.3"), } diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index fcd2c4c..1c80238 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -30,17 +30,21 @@ jobs: # broken on both 26 targets for months precisely because nothing # booted one in CI. - version: 26.2-neoforge - java: '25' smoke: true - version: 26.1.2-neoforge - java: '25' smoke: true + - version: 26.2-fabric + - version: 26.3-fabric + - version: 26.3-neoforge steps: - uses: actions/checkout@v4 + # Both: Loom needs 25 to configure any target, the pre-26 ones compile against 21. - uses: actions/setup-java@v4 with: - java-version: ${{ matrix.java || '21' }} + java-version: | + 21 + 25 distribution: temurin cache: gradle diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d8da7cf..e97267a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -103,7 +103,8 @@ jobs: chmod +x gradlew ./gradlew :1.20.1-fabric:build :1.21.1-fabric:build :1.21.11-fabric:build \ :1.21.1-neoforge:build :1.21.11-neoforge:build \ - :26.1.2-neoforge:build :26.2-neoforge:build --no-daemon + :26.1.2-neoforge:build :26.2-neoforge:build :26.2-fabric:build \ + :26.3-fabric:build :26.3-neoforge:build --no-daemon env: GRADLE_OPTS: -Xmx2g @@ -180,7 +181,6 @@ jobs: version: ${{ steps.meta.outputs.version }}+mc1.21.1-neoforge version-type: release loaders: neoforge - dependencies: rinku(required) game-versions: '1.21.1' changelog: ${{ steps.notes.outputs.body }} files: versions/1.21.1-neoforge/build/libs/!(*-@(dev|sources|javadoc)).jar @@ -196,7 +196,6 @@ jobs: version: ${{ steps.meta.outputs.version }}+mc1.21.11-neoforge version-type: release loaders: neoforge - dependencies: rinku(required) game-versions: '1.21.11' changelog: ${{ steps.notes.outputs.body }} files: versions/1.21.11-neoforge/build/libs/!(*-@(dev|sources|javadoc)).jar @@ -212,7 +211,6 @@ jobs: version: ${{ steps.meta.outputs.version }}+mc26.2-neoforge version-type: release loaders: neoforge - dependencies: rinku(required) game-versions: '26.2' changelog: ${{ steps.notes.outputs.body }} files: versions/26.2-neoforge/build/libs/!(*-@(dev|sources|javadoc)).jar @@ -228,11 +226,55 @@ jobs: version: ${{ steps.meta.outputs.version }}+mc26.1.2-neoforge version-type: release loaders: neoforge - dependencies: rinku(required) game-versions: '26.1.2' changelog: ${{ steps.notes.outputs.body }} files: versions/26.1.2-neoforge/build/libs/!(*-@(dev|sources|javadoc)).jar + - name: Publish 26.2 Fabric to Modrinth + if: steps.check.outputs.release == 'true' && steps.check.outputs.fabric == 'true' + uses: Kir-Antipov/mc-publish@v3.3 + with: + modrinth-id: tDw1xQja + modrinth-token: ${{ secrets.MODRINTH_TOKEN }} + modrinth-featured: false + name: WebGUI ${{ steps.meta.outputs.version }} (26.2 Fabric) + version: ${{ steps.meta.outputs.version }}+mc26.2-fabric + version-type: release + loaders: fabric + game-versions: '26.2' + changelog: ${{ steps.notes.outputs.body }} + files: versions/26.2-fabric/build/libs/!(*-@(dev|sources|javadoc)).jar + + - name: Publish 26.3 Fabric to Modrinth + if: steps.check.outputs.release == 'true' && steps.check.outputs.fabric == 'true' + uses: Kir-Antipov/mc-publish@v3.3 + with: + modrinth-id: tDw1xQja + modrinth-token: ${{ secrets.MODRINTH_TOKEN }} + modrinth-featured: false + name: WebGUI ${{ steps.meta.outputs.version }} (26.3 Fabric) + version: ${{ steps.meta.outputs.version }}+mc26.3-fabric + version-type: beta + loaders: fabric + game-versions: '26.3' + changelog: ${{ steps.notes.outputs.body }} + files: versions/26.3-fabric/build/libs/!(*-@(dev|sources|javadoc)).jar + + - name: Publish 26.3 NeoForge to Modrinth + if: steps.check.outputs.release == 'true' && steps.check.outputs.neoforge == 'true' + uses: Kir-Antipov/mc-publish@v3.3 + with: + modrinth-id: tDw1xQja + modrinth-token: ${{ secrets.MODRINTH_TOKEN }} + modrinth-featured: false + name: WebGUI ${{ steps.meta.outputs.version }} (26.3 NeoForge) + version: ${{ steps.meta.outputs.version }}+mc26.3-neoforge + version-type: beta + loaders: neoforge + game-versions: '26.3' + changelog: ${{ steps.notes.outputs.body }} + files: versions/26.3-neoforge/build/libs/!(*-@(dev|sources|javadoc)).jar + # CurseForge — mirrors the Modrinth uploads. Only runs when the CURSEFORGE_ID # repo variable is set, and never blocks the (gated) Modrinth release: # every step is continue-on-error. @@ -294,7 +336,6 @@ 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 @@ -310,7 +351,6 @@ 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 @@ -326,7 +366,6 @@ 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 @@ -342,9 +381,53 @@ 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 + - name: Publish 26.2 Fabric to CurseForge + if: steps.check.outputs.release == 'true' && steps.check.outputs.fabric == 'true' && vars.CURSEFORGE_ID != '' + continue-on-error: true + uses: Kir-Antipov/mc-publish@v3.3 + with: + curseforge-id: ${{ vars.CURSEFORGE_ID }} + curseforge-token: ${{ secrets.CURSEFORGE_TOKEN }} + name: WebGUI ${{ steps.meta.outputs.version }} (26.2 Fabric) + version: ${{ steps.meta.outputs.version }}+mc26.2-fabric + version-type: release + loaders: fabric + game-versions: '26.2' + changelog: ${{ steps.notes.outputs.body }} + files: versions/26.2-fabric/build/libs/!(*-@(dev|sources|javadoc)).jar + + - name: Publish 26.3 Fabric to CurseForge + if: steps.check.outputs.release == 'true' && steps.check.outputs.fabric == 'true' && vars.CURSEFORGE_ID != '' + continue-on-error: true + uses: Kir-Antipov/mc-publish@v3.3 + with: + curseforge-id: ${{ vars.CURSEFORGE_ID }} + curseforge-token: ${{ secrets.CURSEFORGE_TOKEN }} + name: WebGUI ${{ steps.meta.outputs.version }} (26.3 Fabric) + version: ${{ steps.meta.outputs.version }}+mc26.3-fabric + version-type: beta + loaders: fabric + game-versions: '26.3' + changelog: ${{ steps.notes.outputs.body }} + files: versions/26.3-fabric/build/libs/!(*-@(dev|sources|javadoc)).jar + + - name: Publish 26.3 NeoForge to CurseForge + if: steps.check.outputs.release == 'true' && steps.check.outputs.neoforge == 'true' && vars.CURSEFORGE_ID != '' + continue-on-error: true + uses: Kir-Antipov/mc-publish@v3.3 + with: + curseforge-id: ${{ vars.CURSEFORGE_ID }} + curseforge-token: ${{ secrets.CURSEFORGE_TOKEN }} + name: WebGUI ${{ steps.meta.outputs.version }} (26.3 NeoForge) + version: ${{ steps.meta.outputs.version }}+mc26.3-neoforge + version-type: beta + loaders: neoforge + game-versions: '26.3' + changelog: ${{ steps.notes.outputs.body }} + files: versions/26.3-neoforge/build/libs/!(*-@(dev|sources|javadoc)).jar + # mc-publish can report success without actually creating a version (e.g. # a token lacking the "Create versions" scope). Confirm every version is # really on Modrinth so a green run can't lie. diff --git a/CHANGELOG.md b/CHANGELOG.md index 90d8f98..6eb5124 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,40 @@ # Changelog +## 2.0.0 - 2026-10-13 + +A major version for one reason: WebGUI no longer depends on another mod. Nothing in the page +API changed and the network protocol is the same, so existing pages keep working and a 2.0.0 +client still talks to a 1.8.x server. + +### Added +- **Minecraft 26.2 on Fabric, and 26.3 on both loaders.** 26.3 is published as a beta: the game itself is still in snapshots, and it replaced GLFW with SDL and moved the GPU API out of `blaze3d`, so the parts of this mod that touch the window and the screen are new there. + +### Changed +- **The update check is built in and always runs.** The server asks Modrinth on start whether a newer WebGUI exists for its Minecraft version and loader, and falls back to GitHub releases if Modrinth cannot be reached. It used to do nothing at all unless an operator found `updateCheckUrl` and filled in an endpoint of their own, which almost nobody did. + - Operators are also told in chat when they join, so an update is not something only the start-up log knows. Set `updateNotifyInChat` to `false` to keep the console notice and drop the chat one. Ordinary players are never told. + - **`updateCheckUrl` is gone.** The address is fixed in the mod now. A config that still has the key is not an error; it is ignored. +- **A page URL must now name a scheme the mod will open:** `webgui://`, `https://`, `http://`, `wss://` or `ws://`. Anything else is refused with a line in the log instead of being handed to the browser — a URL arrives from a server, and `file://` would read the player's disk while `javascript:` would run inside whatever page is open. + - `webgui:name.html` and `webgui:/name.html` used to mean the same as `webgui://name.html` and no longer do. One spelling is easier to check than three. Settings that use the old form need the two slashes. +- **A server now starts with a HUD page as well as a full-screen one.** `config/webgui/web/hud.html` is created beside `index.html` on first start: a small corner card naming the keys — F6 for the main menu, ` to interact with the overlay, F12 for developer tools — and linking to webgui.space. It paints no background of its own, which is what an overlay drawn over the world has to do. + - It is shown on join out of the box: `autoHudUrl` points at it and `autoHudOnJoin` is now on unless the config says otherwise. A server that set either of them keeps what it set. +- **The mod's settings screen now explains itself.** Every option has a tooltip saying what it does and what it costs, in the spirit of the game's own video settings, and two more settings are there instead of only in the config file: + - **Ask where to save downloads.** Off, a file goes into `webgui-downloads/` and the name is said in chat; on, the system save dialog opens for each one. The engine could always do this and the mod never offered it. + - **Let pages play sound on their own.** Off by default, as it already was: video and audio wait for a click, the way a browser treats a tab you did not open. + - **Page frame rate** was in the config file and nowhere else; it is now a setting like the rest. + - The screen used to tell everyone to add `--remote-debugging-port=9222` to their launcher. That was true of the old browser library, whose command line was fixed before any mod ran; this one takes the port from the mod, so the advice was for a problem that no longer exists. The inspector's address is shown when it is actually listening, and only while the developer tools are on. +- **Scrolling a page starts at 25 notches instead of 7,** and the setting now goes to 40. Seven was picked to fix a wheel that barely moved, and it still read as slow on a long page. A client that already has the setting saved keeps its own value; it is in the mod's settings screen either way. +- **The main-menu key no longer opens `example.com`.** That placeholder was what a player saw after installing the mod and pressing the key on the title screen, where no server has sent any settings yet. It now opens webgui.space, which at least says what the key is for. +- **`mainMenuUrl` now points at the server's own page out of the box.** A fresh server writes `webgui://index.html` into its config, which is the starter page it also creates, so the key that opens the main menu shows something of the server's from the first launch instead of `example.com`. A config that already has the setting is left alone. +- **WebGUI no longer needs another mod to run.** The embedded browser is part of WebGUI now instead of being provided by a separate library mod, so installing WebGUI is one file and nothing else. Chromium itself is still downloaded once per machine on first start, outside the game directory, and shared by every instance. + +### Fixed +- **The custom death screen on Fabric 26.2 and later.** It hooked a method the game no longer has, which the mixin configuration treats as fatal — the game would not start at all on those versions. +- **The game can be closed again.** Quitting left two threads running — the page server and Chromium — so the process never exited: silently on 1.21.1 and earlier, and from 1.21.11 as a crash report written by the game's own shutdown watchdog. +- **Typing into a page on 26.3.** SDL only delivers typed characters while text input has been started, and the game starts it for its own text fields. Keys arrived and shortcuts such as Ctrl+A and Ctrl+V worked, but letters did not reach the page. +- **Cursor shapes on 26.3.** The cursor is now decided once per frame by the game, so setting it directly was overwritten before it could be seen. +- **Enter in a page.** It never produced a character event, on any version, so a `textarea` took every key but this one and a form could not be submitted with it. +- A NeoForge warning on every launch of 26.3 about deprecated mod metadata. + ## 1.8.1 - 2026-09-12 ### Fixed diff --git a/MODRINTH.md b/MODRINTH.md new file mode 100644 index 0000000..6f8fb05 --- /dev/null +++ b/MODRINTH.md @@ -0,0 +1,75 @@ +**Your web app, running inside Minecraft.** + +WebGUI opens a real web page in the game — as a full-screen interface or as a transparent overlay on top of gameplay. Not a reskinned inventory, not another UI framework to learn: actual HTML, CSS and JavaScript, rendered by actual Chromium. + +If you can build a website, you can build a Minecraft interface. + + + +## What people build with it + +* **Server shops** that look like an online store, with images, search and a cart +* **Quest journals and codexes** with real typography and layout +* **Live maps** — Leaflet or your own canvas, following the player as they walk +* **HUD overlays** — team scores, timers, health bars, a minimap, drawn over the world +* **Admin panels** for staff, with the same dashboard you already run in a browser +* **Custom death screens** that say who killed you and let you respawn +* **Launch menus** and lobby screens with your server's own branding +* **Entire in-game apps** — anything you would otherwise build as a web app + +## Why it feels different + +Everything you already use keeps working. React, Vue, Svelte or plain JavaScript. Tailwind. Your component library. Your animations, your API, your WebSocket. You develop in a browser with hot reload, then point the mod at the page — and it looks exactly the same in the game. + +The page is not a decoration, either. It knows who is looking at it and what is happening around them — the player's name, health, food, position, where they are looking, which block or mob is under the crosshair — updated as they move. And it can talk back: a click in the page becomes an event on the server, so a shop can actually sell, and a button can actually run a command. + +```js +// This is the whole integration +window.webgui.on('client', player => { + document.title = `${player.username} — ${player.health} HP`; +}); + +window.webgui.postToGame({ channel: 'shop:buy', item: 'diamond' }); +``` + +For React, Vue and Svelte there is one package with hooks and stores ready to go: + +```bash +npm i @webgui/client +``` + +## You don't even need a web host + +Put your files in a folder on the server and point the mod at them. They travel down the same connection the player is already on — no domain, no hosting bill, no port to open, works behind NAT. Edit a file, run one command, and the page a player has open refreshes in front of them. + +Prefer your own host? Nothing changes: an `https://` address works exactly as it always did, and the two mix freely. + +## For server owners + +Opening a page is one command: + +``` +/webgui gui @a https://your-site.example/shop +/webgui hud @a webgui://hud.html +``` + +Pages can also be bound to an entity, so right-clicking a villager opens your shop with that villager's context. Every URL the mod opens carries a signed token, so your backend knows which player is asking without a login screen. Players without the mod are told what they are missing instead of quietly seeing nothing. + +## Compatibility + +| Minecraft | Loader | +|-----------|--------| +| 26\.2 · 26\.1.2 | NeoForge | +| 1\.21.5 – 1.21.11 | Fabric · NeoForge | +| 1\.21 – 1.21.1 | Fabric · NeoForge | +| 1\.20.1 | Fabric | + +Minecraft 26 is NeoForge-only for now — Fabric is waiting on Yarn mappings for 26.x. + +**Installing:** on Fabric you need Fabric API, and that is it — the browser ships inside the jar. On NeoForge, add [Rinku](https://modrinth.com/mod/rinku) alongside it; launchers pick it up automatically. Chromium (~150 MB) downloads itself on first launch. Java 21, or Java 25 for Minecraft 26. + +## Links + +* **Documentation** — — guides, the full API, and examples you can paste +* **GitHub** — — issues and source +* **npm** — diff --git a/build.gradle b/build.gradle index 5b08e02..d52ff87 100644 --- a/build.gradle +++ b/build.gradle @@ -1,9 +1,19 @@ plugins { - id 'fabric-loom' version '1.16.+' apply false + // Two Loom plugins, one artifact and one version. Minecraft 26.1 ships unobfuscated, so + // there is nothing left to remap and no mapping file to ask for; the older versions still + // need both. Fabric splits that into two plugin ids rather than one plugin with a switch, + // and because they are the same artifact they sit on the build classpath together — + // which is what makes one Gradle build able to cover both eras at all. + id 'net.fabricmc.fabric-loom-remap' version '1.18.4' apply false + id 'net.fabricmc.fabric-loom' version '1.18.4' apply false id 'net.neoforged.moddev' version '2.0.+' apply false id 'maven-publish' } +// Whether this Minecraft ships unobfuscated. Set in the version's own gradle.properties, not +// worked out from the number: the line is where Mojang drew it, not where a comparison would. +def unobfuscated = project.findProperty('unobfuscated') == 'true' + // Version-specific properties are loaded automatically by Stonecutter // from versions/{active}/gradle.properties. version = "${mod_version}+mc${minecraft_version}" @@ -15,11 +25,9 @@ stonecutter.constants { put "fabric", (sc_loader == "fabric") } -// NeoForge uses Mojang mappings: currentScreen → screen -if (sc_loader != "fabric") { - stonecutter.swaps { - put "currentScreen", "screen" - } +// Both loaders are on Mojang mappings now, so this is no longer loader-specific. +stonecutter.swaps { + put "currentScreen", "screen" } base { @@ -31,7 +39,7 @@ base { // Apply the loader-specific plugin after the stonecutter constants are set if (sc_loader == "fabric") { - apply plugin: 'fabric-loom' + apply plugin: unobfuscated ? 'net.fabricmc.fabric-loom' : 'net.fabricmc.fabric-loom-remap' } else { apply plugin: 'net.neoforged.moddev' } @@ -57,37 +65,26 @@ repositories { if (sc_loader == "fabric") { dependencies { minecraft "com.mojang:minecraft:${minecraft_version}" - mappings "net.fabricmc:yarn:${yarn_mappings}:v2" - 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}") + if (unobfuscated) { + // No mappings at all, and nothing is remapped: from 26.1 the game carries its own + // names and its parameter names with it. A dependency needs no `mod` prefix for + // the same reason — there is nothing to translate on the way in. + implementation "net.fabricmc:fabric-loader:${loader_version}" + implementation "net.fabricmc.fabric-api:fabric-api:${fabric_version}" + compileOnly("maven.modrinth:modmenu:${modmenu_version}") + } else { + // Mojang's own, not Yarn. Yarn stopped at 1.21.11 and is no longer supported by + // Fabric, which tells mods to move before going near Minecraft 26 — so both + // loaders now speak the same names and this mod has one set, not two. + mappings loom.officialMojangMappings() + modImplementation "net.fabricmc:fabric-loader:${loader_version}" + modImplementation "net.fabricmc.fabric-api:fabric-api:${fabric_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 { - rinkuArtifact - } - - // Rinku is a client-side library, and NeoForge's dev dist cleaner strips - // the client classes it constructs against. A plain `runtimeOnly` feeds - // BOTH dev runs, so runServer used to die during mod loading with - // NoClassDefFoundError on Screen — on every Minecraft 26 build, and never - // caught because 26.x had no server smoke test. moddev's per-run classpath - // is gone on modern Minecraft ("no additional classpath anymore"), so scope - // it by installing the jar into the client run's mods folder instead. That - // also mirrors production, where a server operator simply never installs - // Rinku and WebGUI never declares it as a dependency. - def installRinkuForClientRun = tasks.register('installRinkuForClientRun', Copy) { - from configurations.rinkuArtifact - 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. @@ -106,72 +103,95 @@ if (sc_loader == "fabric") { runs { client { client() - // Rinku reaches the client the way a player installs it: as a - // jar in the mods folder, not on the runtime classpath. Keeping - // it off the classpath is what keeps runServer alive — see the - // installRinkuForClientRun task below. - taskBefore(installRinkuForClientRun) } 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) } } } +} +// --- The browser --- +// +// Chromium is ours now: there is no browser mod to install beside this one, and nothing +// here depends on one. What is needed at build time is the java-cef Java classes — a couple +// of hundred kilobytes of JNI declarations — and they are baked into the jar below rather +// than shipped as a library of their own. +// +// Nothing is relocated on the way in. The native side binds to these classes by name, so +// `org.cef.Foo` moved to `land.webgui.shadow.org.cef.Foo` links against nothing at the first +// JNI call. It also has to land in the same jar as our own `org.cef.browser.OffscreenBrowser`, +// which reaches package-private state upstream never exposed: split across two jars, NeoForge's +// module layer refuses the package outright. +// +// The version must stay equal to NativeBundles.JCEF_VERSION — the classes are the JNI +// declarations for the libraries in that exact bundle, and a mismatch is a missing symbol at +// the first call rather than an error here. WmcNativeBundlesTest asserts the two agree. +def jcefVersion = 'jcef-ca49ada+cef-135.0.20+ge7de5c3+chromium-135.0.7049.85' - // Rinku ships its NeoForge builds inconsistently: 26.2 has classes at the - // top level, but 1.21.1 and 26.1.2 are jarJar wrappers whose real mod sits - // in META-INF/jarjar/. NeoForge unpacks that at runtime, so the game is - // happy either way — javac is not, and sees an empty jar. Compile against - // whatever actually holds the classes; the published artifact stays on the - // runtime classpath untouched. - dependencies { - rinkuArtifact "maven.modrinth:rinku:${rinku_version}" - } +configurations { + jcef + // Compiled against, never handed to the loader as a library: the classes are unpacked + // into this mod's own output below, so they are part of it everywhere. + compileOnly.extendsFrom jcef +} - def rinkuClassesDir = layout.buildDirectory.dir('rinku-compile') - def unwrapRinku = tasks.register('unwrapRinku') { - def artifact = configurations.rinkuArtifact - inputs.files(artifact) - outputs.dir(rinkuClassesDir) - doLast { - def dir = rinkuClassesDir.get().asFile - dir.deleteDir() - dir.mkdirs() - def outer = artifact.singleFile - def target = new File(dir, 'rinku.jar') - new java.util.zip.ZipFile(outer).withCloseable { zip -> - def nested = zip.entries().toList().find { - it.name.startsWith('META-INF/jarjar/') && it.name.endsWith('.jar') - } - if (nested != null) { - target.withOutputStream { out -> out << zip.getInputStream(nested) } - logger.lifecycle("rinku: compiling against nested ${nested.name}") - } else { - target.bytes = outer.bytes - } - } - } +dependencies { + // Its OpenGL dependencies exist for the AWT-canvas browser upstream provides, which is + // the one thing a Minecraft integration can never use: ten megabytes for a class that is + // never loaded. + jcef("me.friwi:jcef-api:${jcefVersion}") { + exclude group: 'me.friwi', module: 'jogl-all' + exclude group: 'me.friwi', module: 'gluegen-rt' } +} - dependencies { - // Point at the jar itself, not the directory holding it — a directory - // on the compile classpath is searched for loose .class files only. - compileOnly files(rinkuClassesDir.map { it.file('rinku.jar') }) { - builtBy unwrapRinku - } +// Unpacked into this mod's own class output rather than kept as a library beside it. +// +// One mechanism covering three places at once: the jar gets the classes, a dev run gets them, +// and — the reason it has to be this way — NeoForge puts every mod in a JPMS module, where a +// package may live in only one. Our own org.cef.browser.OffscreenBrowser reaches +// package-private state upstream never exposed, so it must sit in that package; with +// jcef-api beside the mod as a module of its own, the game refuses to start: +// +// Module webgui contains package org.cef.browser, module jcef.api... exports package +// org.cef.browser to webgui +// +// Fabric's flat class loader does not care, which is why this only showed up on the other +// loader, three targets in. +def unpackJcef = tasks.register('unpackJcef', Copy) { + from({ configurations.jcef.collect { zipTree(it) } }) { + // Upstream's own metadata, which says nothing about this mod: an index that is now + // wrong, signatures that no longer match, and a module declaration that would put + // these classes straight back into a module of their own. + exclude 'META-INF/INDEX.LIST' + exclude 'META-INF/*.SF' + exclude 'META-INF/*.DSA' + exclude 'META-INF/*.RSA' + exclude 'META-INF/MANIFEST.MF' + exclude 'module-info.class' + // Kept, and renamed so it does not sit on top of ours: java-cef is BSD-3-Clause and + // its terms travel with the bytes. + rename(~/^LICENSE\.txt$/, 'META-INF/licenses/jcef-LICENSE.txt') + rename(~/^README\.txt$/, 'META-INF/licenses/jcef-README.txt') } + into layout.buildDirectory.dir('jcef-classes') } +sourceSets.main.output.dir(layout.buildDirectory.dir('jcef-classes'), builtBy: unpackJcef) + // --- Testing --- dependencies { testImplementation platform("org.junit:junit-bom:5.10.2") testImplementation "org.junit.jupiter:junit-jupiter" testRuntimeOnly "org.junit.platform:junit-platform-launcher" + // Test-only, and deliberately: the tar reader is checked against archives a real tar + // writer produced, not against ones written by the same code that reads them. Nothing + // of this reaches the jar. + // Pinned old and kept non-transitive on purpose. Only the tar writer is wanted, and + // newer releases drag in commons-io and commons-codec at versions Minecraft pins + // strictly to older ones — which resolves to nothing at all on NeoForge 1.21.1. + testImplementation("org.apache.commons:commons-compress:1.21") { transitive = false } if (sc_loader == "fabric") { // Bootstraps Minecraft + the Fabric loader inside JUnit so the launch // smoke test (@Tag("launch")) can assert the mod actually loads. @@ -181,6 +201,8 @@ dependencies { } test { + // So a test can insist the Java classes and the pinned bundle name the same build. + systemProperty 'wmc.jcefVersion', jcefVersion useJUnitPlatform { if (sc_loader == "fabric") { // Fabric runs the launch test in the separate `launchTest` task so @@ -210,9 +232,25 @@ tasks.register('launchTest', Test) { } } +// By number, so that "26.3" sorts after "1.21.11" rather than before it. +static boolean atLeast(String version, String floor) { + def have = version.tokenize('.').collect { it.toInteger() } + def want = floor.tokenize('.').collect { it.toInteger() } + for (int i = 0; i < Math.max(have.size(), want.size()); i++) { + int a = i < have.size() ? have[i] : 0 + int b = i < want.size() ? want[i] : 0 + if (a != b) { + return a > b + } + } + return true +} + processResources { inputs.property 'version', project.version inputs.property 'minecraft_range', project.minecraft_range + // Declared, or the rename below is invisible to the up-to-date check. + inputs.property 'iconKey', atLeast(project.minecraft_version, '26.3') if (sc_loader == "fabric") { // Don't ship NeoForge metadata in the Fabric jar, or loaders/Modrinth // mis-detect it as a NeoForge mod too. @@ -223,12 +261,16 @@ processResources { } else { // 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. + // instead, so applying the mixin there would swap the screen twice. exclude 'fabric.mod.json' exclude 'webgui.fabric.mixins.json' filesMatching('META-INF/neoforge.mods.toml') { expand version: project.version, minecraft_range: project.minecraft_range + // NeoForge renamed logoFile to iconFile in 26.3 and warns about the old name. + // Renamed here, since earlier versions still need the file as it is. + if (atLeast(project.minecraft_version, '26.3')) { + filter { line -> line.replaceFirst(/^logoFile(\s*)=/, 'iconFile$1=') } + } } filesMatching('pack.mcmeta') { expand version: project.version } } @@ -268,6 +310,7 @@ def javaVersion = (project.findProperty('java_version') ?: '21').toString().toIn tasks.withType(JavaCompile).configureEach { it.options.encoding = 'UTF-8' + it.options.compilerArgs << '-Xmaxerrs' << '3000' it.options.release = javaVersion } diff --git a/dev/testpages/README.md b/dev/testpages/README.md new file mode 100644 index 0000000..7049bca --- /dev/null +++ b/dev/testpages/README.md @@ -0,0 +1,32 @@ +# Test pages + +Dev-only. Never shipped: the released jar carries no pages of its own, and a server +operator's `config/webgui/web` is theirs. + +Copy them into the target you are testing: + + cp dev/testpages/* versions//run/config/webgui/web/ + +Then, in game: + + /webgui gui @s webgui:index.html + /webgui hud @s webgui:hud.html + +What each page is for: + +| page | checks | +|---|---| +| `index.html` | bridge present, `isHud`, `assetsBase`, and `webgui://` links navigating | +| `bridge.html` | page → game channels, live client data, bound entity, server events, death, respawn | +| `input.html` | typing, selection by double click, native dropdown, cursor shapes, scroll speed | +| `download.html` | files the page makes (including an awkward name), and one from the web | +| `hud.html` | the overlay: see-through, glanceable, interactive on `` ` `` | +| `death.html` | the vanilla death screen replaced, the death payload, respawn, Escape refused | + +The death page is the one that needs more than copying: the server only suppresses the +vanilla screen when it has been asked to, so set it in `config/webgui/server.json` and +reload. No reconnect — `reload` pushes the url to everyone already online. + + "deathScreenUrl": "webgui://death.html" + + /webgui reload diff --git a/dev/testpages/bridge.html b/dev/testpages/bridge.html new file mode 100644 index 0000000..f2aaf7d --- /dev/null +++ b/dev/testpages/bridge.html @@ -0,0 +1,136 @@ + + + + +Bridge & data — WebGUI test + + + +
+

Bridge & data

+

← back

+ +
+

Page → game

+

Each button sends one message. The first four land in the game log; + watch it, not this page.

+
+ + + + +
+
+ + only from a trusted origin — otherwise the game refuses it +
+
+ + any channel the game does not handle itself is forwarded to the server +
+
+ +
+

Game → page

+

Client data is pushed as the game plays. Move, look around or take damage + and these should change.

+
waiting…
+
+ +
+

Bound entity

+

Bind one with + /webgui bind entity @e[type=pig,limit=1] "webgui:bridge.html", + then right-click the pig.

+
none
+
+ +
+

Events

+

Everything the game dispatches: webgui:client, + webgui:entity, webgui:death and anything a server emits.

+
+
+ + only meaningful on the death screen +
+
+
+ + + + diff --git a/dev/testpages/death.html b/dev/testpages/death.html new file mode 100644 index 0000000..3559605 --- /dev/null +++ b/dev/testpages/death.html @@ -0,0 +1,82 @@ + + + + +WebGUI death screen + + + + +
+

You died.

+

waiting for the death payload…

+ +
+

What the server told the page

+
+
cause
—
+
killer.type
—
+
killer.name
—
+
killer.entityType
—
+
hardcore
—
+
canRespawn
—
+
+
+ +
+ + the only way out — Escape must not close this screen +
+ +

+ Three things are under test here: that this page replaces the vanilla screen at all, + that the death payload arrives even though it is sent while the page is still loading, + and that Escape is refused while it is up. +

+
+ + + + diff --git a/dev/testpages/download.html b/dev/testpages/download.html new file mode 100644 index 0000000..2267113 --- /dev/null +++ b/dev/testpages/download.html @@ -0,0 +1,102 @@ + + + + +Downloads — WebGUI test + + + +
+

Downloads

+

← back

+ +
+

What should happen

+

The engine saves the file itself, into webgui-downloads beside the game. + The mod only says so: a line in the game log and a message in chat.

+

The name a page suggests is stripped to its last part and cleaned before it + is used — it arrives from outside and is about to become a path.

+
+ +
+

A file this page makes

+

No network involved, so this works on a server that ships pages and nothing + else.

+
+ + + + +
+

The last one suggests + ../../escape me.txt — it must land in the downloads folder under a cleaned + name, not anywhere else.

+
+ +
+

A file from the web

+

Needs the game to have internet.

+
+ 1Mb.dat + an ordinary link to a binary file +
+
+ +
+

Log

+
+
+
+ + + + diff --git a/dev/testpages/hud.html b/dev/testpages/hud.html new file mode 100644 index 0000000..7b75ed6 --- /dev/null +++ b/dev/testpages/hud.html @@ -0,0 +1,79 @@ + + + + +HUD — WebGUI test + + + +
+
+

WebGUI HUD

+
+
mode
—
+
health
—
+
food
—
+
pos
—
+
look
—
+
dimension
—
+
fps-ish
—
+
+

+ Open with /webgui hud @s webgui:hud.html. + Press ` to make it interactive — the button below only works then. +

+
+ +
+
+
+ + + + diff --git a/dev/testpages/index.html b/dev/testpages/index.html new file mode 100644 index 0000000..9e8ca68 --- /dev/null +++ b/dev/testpages/index.html @@ -0,0 +1,83 @@ + + + + +WebGUI test pages + + + +
+

WebGUI test pages

+

Each page checks one part of the mod. Open with + /webgui gui @s webgui:index.html.

+ +
+

This page

+
+
bridge
checking…
+
mode
—
+
assetsBase
—
+
location
—
+
+

+ The links below are written as webgui://…. They are the mod's own scheme, + which the browser knows nothing about — the bridge rewrites them to the local page + server. If they navigate, that rewriting works. +

+
+ + + +
+

Close

+
+ + should shut the screen, same as Escape +
+
+
+ + + + diff --git a/dev/testpages/input.html b/dev/testpages/input.html new file mode 100644 index 0000000..c9e9f24 --- /dev/null +++ b/dev/testpages/input.html @@ -0,0 +1,157 @@ + + + + +Input & cursors — WebGUI test + + + +
+

Input & cursors

+

← back

+ +
+

Typing

+

Letters, digits, backspace, arrows, Home/End, Ctrl+A, Ctrl+C/V. + Escape should close the screen rather than reach the field.

+
+ + +
+

+
+
last key
—
+
value
—
+
+

+ + — keydown, keypress, beforeinput, input, keyup; one line each +

+
+ +
+

Form

+

Enter in a single-line field should submit the form rather than type + anything — the browser's own behaviour, and a different path from the newline a + textarea gets.

+
+
+ + +
+
+
+
submits
0
+
last value
—
+
+
+ +
+

Selection

+

Double click selects a word, triple click a line — the game reports each + press on its own, so the count is worked out by the mod.

+

The quick brown fox jumps over the lazy dog, twice before breakfast.

+
+
selected
—
+
+
+ +
+

Dropdown

+

A native select. Chromium paints this into a surface of its own, so it + checks that popups are drawn over the page at all.

+ + +
+ +
+

Cursors

+

Hover each box. The name is decided by the engine, not by this page: + java-cef turns Chromium's cursor into one of AWT's fourteen before the mod sees it, + so anything AWT has no name for arrives as a plain arrow and cannot be recovered. + The boxes marked n/a are expected to stay arrows.

+
+
pointer
+
text
+
crosshair
+
ew-resize
+
ns-resize
+
n-resize
+
s-resize
+
move
+
not-allowed n/a
+
grab n/a
+
+
+ +
+

Scrolling

+

Spin the wheel. Speed is a setting — mod list → WebGUI → Config.

+
+
+
+
+
+ + + + diff --git a/dev/testpages/style.css b/dev/testpages/style.css new file mode 100644 index 0000000..840a729 --- /dev/null +++ b/dev/testpages/style.css @@ -0,0 +1,118 @@ +:root { + --bg: #11141a; + --panel: #1a1f29; + --line: #2c3444; + --text: #e6e9ef; + --dim: #99a1b3; + --ok: #68d391; + --warn: #f6c177; + --bad: #f07178; + --link: #7aa2f7; +} + +* { box-sizing: border-box; } + +html, body { + margin: 0; + min-height: 100%; + background: var(--bg); + color: var(--text); + font: 14px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif; +} + +/* The HUD is drawn over the world, so it must not paint a background of its own. + Both elements, not just body: a background on html paints the canvas all the same, + and a page that sets one covers the game. This is the usual first mistake. */ +html.hud, body.hud { background: transparent; } +body.hud .wrap { max-width: none; padding: 10px; } +body.hud .panel { background: rgba(17, 20, 26, .78); } + +.wrap { max-width: 860px; margin: 0 auto; padding: 20px; } + +h1 { font-size: 20px; margin: 0 0 4px; } +h2 { font-size: 15px; margin: 0 0 10px; color: var(--dim); font-weight: 600; } +p { margin: 0 0 10px; } + +.sub { color: var(--dim); margin-bottom: 18px; } + +.panel { + background: var(--panel); + border: 1px solid var(--line); + border-radius: 8px; + padding: 14px 16px; + margin-bottom: 14px; +} + +.row { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; } + +button { + font: inherit; + color: var(--text); + background: #273041; + border: 1px solid var(--line); + border-radius: 6px; + padding: 7px 12px; + cursor: pointer; +} +button:hover { background: #313c51; } +button:active { background: #202836; } + +input[type="text"], input[type="password"], textarea, select { + font: inherit; + color: var(--text); + background: #0e1218; + border: 1px solid var(--line); + border-radius: 6px; + padding: 7px 10px; +} +textarea { width: 100%; min-height: 70px; resize: vertical; } + +a { color: var(--link); } + +.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); gap: 10px; } + +.card { + display: block; + background: var(--panel); + border: 1px solid var(--line); + border-radius: 8px; + padding: 14px 16px; + text-decoration: none; + color: var(--text); +} +.card:hover { border-color: var(--link); } +.card b { display: block; margin-bottom: 4px; } +.card span { color: var(--dim); font-size: 13px; } + +.kv { display: grid; grid-template-columns: 150px 1fr; gap: 4px 12px; font-family: ui-monospace, Consolas, monospace; font-size: 13px; } +.kv div:nth-child(odd) { color: var(--dim); } + +.log { + font-family: ui-monospace, Consolas, monospace; + font-size: 12px; + background: #0e1218; + border: 1px solid var(--line); + border-radius: 6px; + padding: 10px; + height: 150px; + overflow: auto; + white-space: pre-wrap; +} + +.ok { color: var(--ok); } +.bad { color: var(--bad); } +.dim { color: var(--dim); } + +.tall { height: 1400px; background: linear-gradient(#1a1f29, #11141a 40%, #1a1f29); border-radius: 6px; padding: 12px; } +.ruler { color: var(--dim); font-family: ui-monospace, Consolas, monospace; font-size: 12px; } + +.cursors { display: grid; grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); gap: 8px; } +.cursors div { + border: 1px dashed var(--line); + border-radius: 6px; + padding: 14px 8px; + text-align: center; + font-family: ui-monospace, Consolas, monospace; + font-size: 12px; + color: var(--dim); +} diff --git a/gradle.properties b/gradle.properties index a74b415..42aa994 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,8 +1,12 @@ org.gradle.jvmargs=-Xmx4G org.gradle.parallel=true dev.kikugie.stonecutter.hard_mode=true +# Loom 1.18.4 needs a Java 25 JVM to configure, while the pre-26 targets still compile +# against 21, so both JDKs have to be visible. On CI they come from setup-java; locally +# these variables do not exist and Gradle falls back to its own detection. +org.gradle.java.installations.fromEnv=JAVA_HOME_21_X64,JAVA_HOME_25_X64 -mod_version=1.8.1 +mod_version=2.0.0 maven_group=land.webgui archives_base_name=webgui diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 5dd3c01..1e922f4 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.0-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/settings.gradle b/settings.gradle index 0f04619..ae68c7d 100644 --- a/settings.gradle +++ b/settings.gradle @@ -16,10 +16,15 @@ stonecutter { centralScript = 'build.gradle' create(rootProject) { - // MC 26 (NeoForge only — Yarn mappings for 26.x are not published). - // Both lines are stable: 26.2 is Mojang's current release and NeoForge - // dropped the -beta suffix on the 26.2 line at build 26.2.0.57. + // MC 26, both loaders. The game is unobfuscated from 26.1, so Fabric here builds + // with a different Loom plugin than the versions below — see the plugins block in + // build.gradle. One Gradle build still covers both eras. + // 26.3 is Mojang's newest stable. NeoForge has only a beta on that line, so the + // release workflow publishes both 26.3 targets as beta rather than release. + version '26.3-neoforge', '26.3-neoforge' + version '26.3-fabric', '26.3-fabric' version '26.2-neoforge', '26.2-neoforge' + version '26.2-fabric', '26.2-fabric' version '26.1.2-neoforge', '26.1.2-neoforge' version '1.21.11-fabric', '1.21.11-fabric' version '1.21.11-neoforge', '1.21.11-neoforge' diff --git a/src/main/java/land/webgui/EntityInteractionListener.java b/src/main/java/land/webgui/EntityInteractionListener.java index f0877b1..e70eb09 100644 --- a/src/main/java/land/webgui/EntityInteractionListener.java +++ b/src/main/java/land/webgui/EntityInteractionListener.java @@ -3,16 +3,13 @@ import land.webgui.server.EntityBinding; import land.webgui.server.WebviewEntityContext; import land.webgui.server.WebviewPlaceholders; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.InteractionResult; //? if fabric { import net.fabricmc.fabric.api.event.player.UseEntityCallback; -import net.minecraft.server.network.ServerPlayerEntity; -import net.minecraft.util.ActionResult; -import net.minecraft.util.Hand; //? } else { -/*import net.minecraft.server.level.ServerPlayer; -import net.minecraft.world.InteractionHand; -import net.minecraft.world.InteractionResult; -import net.neoforged.neoforge.common.NeoForge; +/*import net.neoforged.neoforge.common.NeoForge; import net.neoforged.neoforge.event.entity.player.PlayerInteractEvent;*/ //? } @@ -22,19 +19,19 @@ private EntityInteractionListener() {} //? if fabric { public static void register() { UseEntityCallback.EVENT.register((player, world, hand, entity, hitResult) -> { - if (hand != Hand.MAIN_HAND) return ActionResult.PASS; - if (world.isClient()) return ActionResult.PASS; + if (hand != InteractionHand.MAIN_HAND) return InteractionResult.PASS; + if (world.isClientSide()) return InteractionResult.PASS; - var opt = EntityBindingStore.get(entity.getUuid()); - if (opt.isEmpty()) return ActionResult.PASS; + var opt = EntityBindingStore.get(entity.getUUID()); + if (opt.isEmpty()) return InteractionResult.PASS; - ServerPlayerEntity sp = (ServerPlayerEntity) player; + ServerPlayer sp = (ServerPlayer) player; EntityBinding b = opt.get(); String url = WebviewPlaceholders.resolve(b.urlTemplate(), sp, entity); String entityJson = WebviewEntityContext.buildJson(entity); WebviewNetworking.openGuiForEntity(sp, url, entityJson); - return b.cancelInteraction() ? ActionResult.SUCCESS : ActionResult.PASS; + return b.cancelInteraction() ? InteractionResult.SUCCESS : InteractionResult.PASS; }); } //? } else { diff --git a/src/main/java/land/webgui/StartUrls.java b/src/main/java/land/webgui/StartUrls.java index 73511d0..8e052a2 100644 --- a/src/main/java/land/webgui/StartUrls.java +++ b/src/main/java/land/webgui/StartUrls.java @@ -1,7 +1,16 @@ package land.webgui; +/** + * Where a page opens when nobody said where. + * + *

Reached whenever a URL is missing rather than wrong: the main-menu key pressed on the + * title screen, where no server has sent its settings yet, or a server that opened a page + * without naming one. The mod's own site explains what to set; it used to be + * {@code example.com}, which explained nothing and looked like a bug. + */ public final class StartUrls { - public static final String HTTPS_FALLBACK = "https://example.com"; + + public static final String HTTPS_FALLBACK = "https://webgui.space"; private StartUrls() {} diff --git a/src/main/java/land/webgui/WebGUIAssetServer.java b/src/main/java/land/webgui/WebGUIAssetServer.java index 20b6f3f..bb3d028 100644 --- a/src/main/java/land/webgui/WebGUIAssetServer.java +++ b/src/main/java/land/webgui/WebGUIAssetServer.java @@ -11,6 +11,7 @@ import java.nio.charset.StandardCharsets; import java.security.SecureRandom; import java.util.HexFormat; +import java.util.List; import java.util.concurrent.Executors; /** @@ -79,6 +80,20 @@ public static synchronized void ensureStarted() { if (server != null) { return; } + // HttpServer.start() spawns its dispatcher thread internally, inheriting the daemon + // flag of the caller. Bound from the render thread it would keep the JVM alive after + // the player quits. Joined at once, so callers still get a started server. + Thread binder = new Thread(WebGUIAssetServer::bind, "webgui-assets-bind"); + binder.setDaemon(true); + binder.start(); + try { + binder.join(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + + private static void bind() { for (int candidate = DEFAULT_PORT; candidate < DEFAULT_PORT + PORT_ATTEMPTS; candidate++) { try { // Explicitly IPv4, not getLoopbackAddress(): with IPv6 present that @@ -119,23 +134,34 @@ private static void rotateToken() { sessionToken = HexFormat.of().formatHex(raw); } + /** The schemes a page may be opened from. Everything else is refused. */ + private static final List REMOTE_SCHEMES = List.of("https://", "http://", "wss://", "ws://"); + /** - * Turns {@code webgui:/path} into a URL the browser can load. + * Turns {@code webgui://path} into a URL the browser can load, and refuses anything that + * is not a page. * - * 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. + * A remote URL passes through untouched — that is a page hosted the ordinary way. Any + * other scheme is refused rather than loaded: a page URL arrives from a server, and + * {@code file://} would read the player's disk while {@code javascript:} would run in + * whatever page is open. */ 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; + for (String scheme : REMOTE_SCHEMES) { + if (trimmed.regionMatches(true, 0, scheme, 0, scheme.length())) { + return trimmed; + } + } + if (!trimmed.regionMatches(true, 0, "webgui://", 0, 9)) { + WebGUIMod.LOGGER.warn("webgui: refusing to open {} - a page must be webgui://," + + " https://, http://, wss:// or ws://", trimmed); + return "about:blank"; } - 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 rest = trimmed.substring(9); String query = ""; int mark = rest.indexOf('?'); if (mark >= 0) { diff --git a/src/main/java/land/webgui/WebGUIBrowserTuning.java b/src/main/java/land/webgui/WebGUIBrowserTuning.java new file mode 100644 index 0000000..0e56857 --- /dev/null +++ b/src/main/java/land/webgui/WebGUIBrowserTuning.java @@ -0,0 +1,46 @@ +package land.webgui; + +import land.webgui.browser.WebBrowser; + +/** + * How often the browser is allowed to paint. + * + * A windowless browser has a frame rate of its own, and the browser library never sets it, + * so the page repaints at whatever CEF defaults to. Sending data more often cannot help + * past that ceiling: the page would hold fresh numbers and still be drawn at the old rate, + * which reads as the overlay trailing the world. + */ +public final class WebGUIBrowserTuning { + + private static final java.util.Map TUNED = new java.util.WeakHashMap<>(); + + private WebGUIBrowserTuning() {} + + public static void applyOnce(WebBrowser browser) { + if (browser == null) { + return; + } + synchronized (TUNED) { + if (TUNED.containsKey(browser)) { + return; + } + TUNED.put(browser, Boolean.TRUE); + } + + try { + int target = WebGUIClientConfig.browserFrameRate(); + if (target > 0) { + browser.setWindowlessFrameRate(target); + WebGUIMod.LOGGER.debug("webgui: browser paint rate asked for {} fps", target); + } + } catch (Throwable t) { + WebGUIMod.LOGGER.warn("webgui: could not set the browser paint rate: {}", t.toString()); + } + } + + static void forget() { + synchronized (TUNED) { + TUNED.clear(); + } + } +} diff --git a/src/main/java/land/webgui/WebGUIClient.java b/src/main/java/land/webgui/WebGUIClient.java index b8ca4ae..c7474da 100644 --- a/src/main/java/land/webgui/WebGUIClient.java +++ b/src/main/java/land/webgui/WebGUIClient.java @@ -1,18 +1,22 @@ package land.webgui; -import de.keksuccino.rinku.Rinku; +import land.webgui.compat.Mc; +import land.webgui.browser.BrowserBoot; +import land.webgui.browser.WebBrowser; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.screens.DeathScreen; //? if fabric { import net.fabricmc.api.ClientModInitializer; +import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientLifecycleEvents; import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents; import net.fabricmc.fabric.api.client.networking.v1.ClientPlayConnectionEvents; import net.fabricmc.fabric.api.client.networking.v1.ClientPlayNetworking; //? } else { -/*import net.minecraft.client.Minecraft; -import net.neoforged.bus.api.IEventBus; +/*import net.neoforged.bus.api.IEventBus; +import net.neoforged.neoforge.event.GameShuttingDownEvent; 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;*/ //? } @@ -26,55 +30,57 @@ public final class WebGUIClient //? if fabric { @Override public void onInitializeClient() { - Rinku.scheduleForInit(success -> { - if (!success) { - WebGUIMod.LOGGER.error("Rinku (Chromium) failed to initialize — web GUI will not work."); - return; - } - Rinku.getClient().addDisplayHandler(new WebviewBrowserConsoleLogger()); + BrowserBoot.start(); + + // Attached before the engine is up: they are per-browser, and the first browser + // cannot exist before this returns. + WebBrowser.addListener(new WebviewBrowserConsoleLogger()); + WebviewPageLoadHooks.register(); + WebviewDownloadHandler.register(); + + // The message router needs a CEF client, which exists only once the engine is up. + WebBrowser.whenReady(() -> { WebviewPageToClientBridge.register(); - WebviewPageLoadHooks.register(); - WebviewDownloadHandler.register(); WebGUIMod.LOGGER.info("WebGUI bridge ready (console log, page↔game, client data)."); }); //? if >=1.20.5 { - ClientPlayNetworking.registerGlobalReceiver(WebviewPayloads.OpenWebS2CPayload.ID, (payload, context) -> { + ClientPlayNetworking.registerGlobalReceiver(WebviewPayloads.OpenWebS2CPayload.TYPE, (payload, context) -> { if (payload.protocolVersion() != WebviewNetworking.PROTOCOL_VERSION) { return; } context.client().execute(() -> handleOpenPayload(context.client(), payload.displayMode(), payload.url())); }); - ClientPlayNetworking.registerGlobalReceiver(WebviewPayloads.WebUIMainMenuPayload.ID, (payload, context) -> { + ClientPlayNetworking.registerGlobalReceiver(WebviewPayloads.WebUIMainMenuPayload.TYPE, (payload, context) -> { context.client().execute(() -> WebGUIMainMenuUrl.setUrl(payload.url())); }); - ClientPlayNetworking.registerGlobalReceiver(WebviewPayloads.WebviewEmitS2CPayload.ID, (payload, context) -> { + ClientPlayNetworking.registerGlobalReceiver(WebviewPayloads.WebviewEmitS2CPayload.TYPE, (payload, context) -> { context.client().execute(() -> WebviewClientEmit.dispatch(payload.eventName(), payload.jsonPayload())); }); - ClientPlayNetworking.registerGlobalReceiver(WebviewPayloads.WebviewEntityContextS2CPayload.ID, (payload, context) -> { + ClientPlayNetworking.registerGlobalReceiver(WebviewPayloads.WebviewEntityContextS2CPayload.TYPE, (payload, context) -> { context.client().execute(() -> WebviewClientBridge.setEntityContext(payload.entityJson())); }); - ClientPlayNetworking.registerGlobalReceiver(WebviewPayloads.WebviewTrustedOriginsS2CPayload.ID, (payload, context) -> { + ClientPlayNetworking.registerGlobalReceiver(WebviewPayloads.WebviewTrustedOriginsS2CPayload.TYPE, (payload, context) -> { context.client().execute(() -> WebGUITrustedOrigins.set(payload.origins())); }); - ClientPlayNetworking.registerGlobalReceiver(WebviewPayloads.WebviewDeathS2CPayload.ID, (payload, context) -> { + ClientPlayNetworking.registerGlobalReceiver(WebviewPayloads.WebviewDeathS2CPayload.TYPE, (payload, context) -> { context.client().execute(() -> onDeathPayload(payload.url(), payload.infoJson())); }); - ClientPlayNetworking.registerGlobalReceiver(WebviewPayloads.WebviewHelloS2CPayload.ID, (payload, context) -> { + ClientPlayNetworking.registerGlobalReceiver(WebviewPayloads.WebviewHelloS2CPayload.TYPE, (payload, context) -> { context.client().execute(() -> WebGUIHandshake.onHello(payload.protocolVersion(), payload.modVersion())); }); - ClientPlayNetworking.registerGlobalReceiver(WebviewPayloads.WebviewAssetManifestS2CPayload.ID, (payload, context) -> { + ClientPlayNetworking.registerGlobalReceiver(WebviewPayloads.WebviewAssetManifestS2CPayload.TYPE, (payload, context) -> { context.client().execute(() -> onAssetManifest(payload.revision(), payload.manifest())); }); - ClientPlayNetworking.registerGlobalReceiver(WebviewPayloads.WebviewAssetChunkS2CPayload.ID, (payload, context) -> { + ClientPlayNetworking.registerGlobalReceiver(WebviewPayloads.WebviewAssetChunkS2CPayload.TYPE, (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()); @@ -83,7 +89,7 @@ public void onInitializeClient() { /*ClientPlayNetworking.registerGlobalReceiver(WebviewPayloads.OPEN_WEB_CHANNEL, (client, handler, buf, responseSender) -> { int protocolVersion = buf.readVarInt(); int displayMode = buf.readVarInt(); - String url = buf.readString(WebviewNetworking.MAX_URL_LENGTH); + String url = buf.readUtf(WebviewNetworking.MAX_URL_LENGTH); if (protocolVersion != WebviewNetworking.PROTOCOL_VERSION) { return; } @@ -91,41 +97,41 @@ public void onInitializeClient() { }); ClientPlayNetworking.registerGlobalReceiver(WebviewPayloads.MAIN_MENU_CHANNEL, (client, handler, buf, responseSender) -> { - String url = buf.readString(WebviewNetworking.MAX_URL_LENGTH); + String url = buf.readUtf(WebviewNetworking.MAX_URL_LENGTH); client.execute(() -> WebGUIMainMenuUrl.setUrl(url)); }); ClientPlayNetworking.registerGlobalReceiver(WebviewPayloads.EMIT_TO_PAGE_CHANNEL, (client, handler, buf, responseSender) -> { - String eventName = buf.readString(WebviewPayloads.MAX_EVENT_NAME_LENGTH); - String jsonPayload = buf.readString(WebviewPayloads.MAX_EVENT_DATA_LENGTH); + String eventName = buf.readUtf(WebviewPayloads.MAX_EVENT_NAME_LENGTH); + String jsonPayload = buf.readUtf(WebviewPayloads.MAX_EVENT_DATA_LENGTH); client.execute(() -> WebviewClientEmit.dispatch(eventName, jsonPayload)); }); ClientPlayNetworking.registerGlobalReceiver(WebviewPayloads.TRUSTED_ORIGINS_CHANNEL, (client, handler, buf, responseSender) -> { - String origins = buf.readString(WebviewPayloads.MAX_EVENT_DATA_LENGTH); + String origins = buf.readUtf(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); + String url = buf.readUtf(WebviewNetworking.MAX_URL_LENGTH); + String info = buf.readUtf(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); + String version = buf.readUtf(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); + String rev = buf.readUtf(WebviewPayloads.MAX_VERSION_LENGTH); + String manifest = buf.readUtf(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); + String path = buf.readUtf(WebviewPayloads.MAX_ASSET_PATH_LENGTH); int index = buf.readVarInt(); int count = buf.readVarInt(); byte[] data = buf.readByteArray(WebviewPayloads.ASSET_CHUNK_BYTES); @@ -145,6 +151,8 @@ public void onInitializeClient() { WebviewClientBridge.tick(client); }); + ClientLifecycleEvents.CLIENT_STOPPING.register(client -> WebBrowser.shutdown()); + // Leaving a world (disconnect / exit to title) tears down any server-opened HUD or GUI // browser so it doesn't keep rendering in the background on the main menu. ClientPlayConnectionEvents.DISCONNECT.register((handler, client) -> client.execute(WebGUIClient::onLeaveWorld)); @@ -173,31 +181,37 @@ static void onDeathPayload(String url, String infoJson) { } } - private static void handleOpenPayload(net.minecraft.client.MinecraftClient client, int mode, String url) { - if (!Rinku.isInitialized()) { + private static void handleOpenPayload(net.minecraft.client.Minecraft client, int mode, String url) { + if (!WebBrowser.ready()) { if (client.player != null) { - client.player.sendMessage(net.minecraft.text.Text.translatable("message.webgui.mcef_not_ready"), false); + //? if >=26 { + /*client.player.sendSystemMessage(net.minecraft.network.chat.Component.translatable("message.webgui.mcef_not_ready")); + *///? } else { + client.player.displayClientMessage(net.minecraft.network.chat.Component.translatable("message.webgui.mcef_not_ready"), false); + //? } } return; } String u = url == null || url.isBlank() ? StartUrls.primary() : url; if (mode == WebviewNetworking.MODE_GUI) { - client.setScreen(new WebViewScreen(u)); + Mc.openScreen(client, new WebViewScreen(u)); } else if (mode == WebviewNetworking.MODE_HUD) { WebHudOverlay.applyServerOpen(client, u); } } //? } else { /*public static void initClient(IEventBus modBus) { - Rinku.scheduleForInit(success -> { - if (!success) { - WebGUIMod.LOGGER.error("Rinku (Chromium) failed to initialize - web GUI will not work."); - return; - } - Rinku.getClient().addDisplayHandler(new WebviewBrowserConsoleLogger()); + BrowserBoot.start(); + + // Attached before the engine is up: they are per-browser, and the first browser + // cannot exist before this returns. + WebBrowser.addListener(new WebviewBrowserConsoleLogger()); + WebviewPageLoadHooks.register(); + WebviewDownloadHandler.register(); + + // The message router needs a CEF client, which exists only once the engine is up. + WebBrowser.whenReady(() -> { WebviewPageToClientBridge.register(); - WebviewPageLoadHooks.register(); - WebviewDownloadHandler.register(); WebGUIMod.LOGGER.info("WebGUI bridge ready (console log, page<->game, client data)."); }); @@ -215,6 +229,14 @@ private static void handleOpenPayload(net.minecraft.client.MinecraftClient clien NeoForge.EVENT_BUS.addListener(WebGUIClient::onClientTick); NeoForge.EVENT_BUS.addListener(WebGUIClient::onLoggingOut); NeoForge.EVENT_BUS.addListener(WebGUIClient::onScreenOpening); + // GameShuttingDownEvent, not ClientStoppingEvent: it has sat in the same package on + // every NeoForge this mod builds for, while the client one does not exist at all on + // the oldest of them. + NeoForge.EVENT_BUS.addListener(WebGUIClient::onGameShuttingDown); + } + + private static void onGameShuttingDown(GameShuttingDownEvent event) { + WebBrowser.shutdown(); } private static void onLoggingOut(ClientPlayerNetworkEvent.LoggingOut event) { @@ -286,7 +308,7 @@ private static void onScreenOpening(ScreenEvent.Opening event) { // 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()) { + if (!WebBrowser.ready()) { return; } WebGUIDeathScreen.setActive(true); @@ -301,7 +323,7 @@ private static void onClientTick(ClientTickEvent.Post event) { } private static void handleOpenPayload(Minecraft client, int mode, String url) { - if (!Rinku.isInitialized()) { + if (!WebBrowser.ready()) { if (client.player != null) { //? if >=26 { client.player.sendSystemMessage( @@ -315,7 +337,7 @@ private static void handleOpenPayload(Minecraft client, int mode, String url) { } String u = url == null || url.isBlank() ? StartUrls.primary() : url; if (mode == WebviewNetworking.MODE_GUI) { - //? if >=26.2-neoforge { + //? if >26.1.2 { client.gui.setScreen(new WebViewScreen(u)); //? } else { client.setScreen(new WebViewScreen(u)); @@ -377,22 +399,18 @@ private static java.nio.file.Path assetCacheDir() { * 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));*/ - //? } + 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)) { + if (ClientPlayNetworking.canSend(WebviewPayloads.WebviewAssetRequestC2SPayload.TYPE)) { 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); + /*net.minecraft.network.FriendlyByteBuf buf = net.fabricmc.fabric.api.networking.v1.PacketByteBufs.create(); + buf.writeUtf(path, WebviewPayloads.MAX_ASSET_PATH_LENGTH); ClientPlayNetworking.send(WebviewPayloads.ASSET_REQUEST_CHANNEL, buf);*/ //? } //? } else { diff --git a/src/main/java/land/webgui/WebGUIClientConfig.java b/src/main/java/land/webgui/WebGUIClientConfig.java index 14c3c31..b9583aa 100644 --- a/src/main/java/land/webgui/WebGUIClientConfig.java +++ b/src/main/java/land/webgui/WebGUIClientConfig.java @@ -23,9 +23,29 @@ public final class WebGUIClientConfig { private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create(); + /** As fast as this setting goes. Past it a notch throws a page across its own length. */ + public static final int SCROLL_MAX = 40; + /** Off by default: a developer's tool, and it makes the log noisier. */ private boolean devTools = false; + /** How often the browser may paint. 0 leaves whatever CEF defaults to. */ + private int browserFrameRate = 144; + + /** + * How many wheel notches a page is told about for each one the game reports. + * + * One for one is what a browser does, and in a page it reads as barely moving — the game + * counts a notch where a desktop browser counts several lines. + */ + private int scrollNotches = 25; + + /** Whether Chromium opens the system save dialog instead of filing downloads itself. */ + private boolean askWhereToSaveDownloads = false; + + /** Whether a page may start audio or video without the player touching it first. */ + private boolean allowPageAutoplay = false; + private static WebGUIClientConfig instance = new WebGUIClientConfig(); private WebGUIClientConfig() {} @@ -68,6 +88,24 @@ public static boolean devTools() { return instance.devTools; } + public static int browserFrameRate() { + return Math.max(0, instance.browserFrameRate); + } + + /** Clamped, because a hand-edited zero would stop a page scrolling at all. */ + public static int scrollNotches() { + return Math.min(SCROLL_MAX, Math.max(1, instance.scrollNotches)); + } + + public static void setScrollNotches(int value) { + int wanted = Math.min(SCROLL_MAX, Math.max(1, value)); + if (instance.scrollNotches == wanted) { + return; + } + instance.scrollNotches = wanted; + save(); + } + public static void setDevTools(boolean value) { if (instance.devTools == value) { return; @@ -75,4 +113,37 @@ public static void setDevTools(boolean value) { instance.devTools = value; save(); } + + public static boolean askWhereToSaveDownloads() { + return instance.askWhereToSaveDownloads; + } + + public static void setAskWhereToSaveDownloads(boolean value) { + if (instance.askWhereToSaveDownloads == value) { + return; + } + instance.askWhereToSaveDownloads = value; + save(); + } + + public static boolean allowPageAutoplay() { + return instance.allowPageAutoplay; + } + + public static void setAllowPageAutoplay(boolean value) { + if (instance.allowPageAutoplay == value) { + return; + } + instance.allowPageAutoplay = value; + save(); + } + + public static void setBrowserFrameRate(int value) { + int wanted = Math.max(0, value); + if (instance.browserFrameRate == wanted) { + return; + } + instance.browserFrameRate = wanted; + save(); + } } diff --git a/src/main/java/land/webgui/WebGUIDevTools.java b/src/main/java/land/webgui/WebGUIDevTools.java index 0514bb2..b2205e5 100644 --- a/src/main/java/land/webgui/WebGUIDevTools.java +++ b/src/main/java/land/webgui/WebGUIDevTools.java @@ -1,11 +1,10 @@ package land.webgui; +import land.webgui.browser.WebBrowser; 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, @@ -22,7 +21,7 @@ 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 final java.util.Map ATTACHED = new java.util.WeakHashMap<>(); private static long windowStartedAt; private static int inWindow; @@ -37,7 +36,7 @@ private WebGUIDevTools() {} * natively — but attaching twice would double every line in the log. Does nothing * with the setting off. */ - public static void attachOnce(RinkuBrowser browser) { + public static void attachOnce(WebBrowser browser) { if (browser == null || !WebGUIClientConfig.devTools()) { return; } @@ -47,20 +46,13 @@ public static void attachOnce(RinkuBrowser browser) { } 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); + // Through the runtime's own protocol connection rather than a CEF client of our + // own: it waits for the browser to exist and for its first load, which is the race + // the old code lost whenever a page was asked about too early. + browser.onDevToolsEvent(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); + browser.callDevTools(domain); } WebGUIMod.LOGGER.info("webgui devtools: watching {}", browser.getURL()); } diff --git a/src/main/java/land/webgui/WebGUIDevToolsWindow.java b/src/main/java/land/webgui/WebGUIDevToolsWindow.java new file mode 100644 index 0000000..3b988d8 --- /dev/null +++ b/src/main/java/land/webgui/WebGUIDevToolsWindow.java @@ -0,0 +1,34 @@ +package land.webgui; + +import land.webgui.browser.WebBrowser; + +/** + * The inspector, in a window of its own, the way a browser opens it. + * + * Chromium opens that window itself: the request goes to its own ShowDevTools, which is a + * Chromium pop-up and not something the game or AWT has to host, and it paints from the + * message loop the client already drives. So there is nothing here but the asking. + * + * Different from {@link WebGUIDevTools}, which puts the page's own complaints in the game log + * and needs no window at all. + */ +public final class WebGUIDevToolsWindow { + + private WebGUIDevToolsWindow() {} + + /** Whether there was a page to ask about. The browser opens a moment later. */ + public static boolean open(WebBrowser browser) { + if (browser == null) { + return false; + } + browser.openDevTools(why -> { + WebGUIMod.LOGGER.warn("webgui devtools: no inspector to open ({})", why); + // Said to the player, not only to the log. A key that answers nothing at all is + // indistinguishable from a key that is broken, and this one is off by default. + land.webgui.compat.Mc.tell("Inspector is off. Turn on \"Page developer tools\" in the mod settings" + + " and restart the game."); + }); + return true; + } + +} diff --git a/src/main/java/land/webgui/WebGUIHandshake.java b/src/main/java/land/webgui/WebGUIHandshake.java index e4324d7..76b49e9 100644 --- a/src/main/java/land/webgui/WebGUIHandshake.java +++ b/src/main/java/land/webgui/WebGUIHandshake.java @@ -1,14 +1,8 @@ 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.ChatFormatting; import net.minecraft.client.Minecraft; -import net.minecraft.network.chat.Component;*/ -//? } +import net.minecraft.network.chat.Component; /** * Client half of the join handshake: what the server said it is running. @@ -46,23 +40,7 @@ private static String mismatchText(int protocol, String version) { + " 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) { @@ -75,13 +53,12 @@ public static void onHello(int protocol, String version) { // 26 dropped Player.displayClientMessage and gave LocalPlayer its own // sendSystemMessage instead. //? if >=26 { - client.player.sendSystemMessage( + /*client.player.sendSystemMessage( Component.literal(mismatchText(protocol, serverVersion)).withStyle(ChatFormatting.RED)); - //? } else { + *///? } else { client.player.displayClientMessage( Component.literal(mismatchText(protocol, serverVersion)).withStyle(ChatFormatting.RED), false); //? } } - }*/ - //? } + } } diff --git a/src/main/java/land/webgui/WebGUIKeys.java b/src/main/java/land/webgui/WebGUIKeys.java index 3ca0fa8..50a3159 100644 --- a/src/main/java/land/webgui/WebGUIKeys.java +++ b/src/main/java/land/webgui/WebGUIKeys.java @@ -1,66 +1,103 @@ package land.webgui; -import de.keksuccino.rinku.Rinku; +import land.webgui.compat.Mc; +import land.webgui.browser.WebBrowser; +import net.minecraft.client.Minecraft; //? if fabric { +//? if >=26 { +/*import net.fabricmc.fabric.api.client.keymapping.v1.KeyMappingHelper; +*///? } else { import net.fabricmc.fabric.api.client.keybinding.v1.KeyBindingHelper; -import net.minecraft.client.MinecraftClient; +//? } //? } else { -/*import net.minecraft.client.Minecraft; -import net.neoforged.bus.api.IEventBus; +/*import net.neoforged.bus.api.IEventBus; import net.neoforged.neoforge.client.event.RegisterKeyMappingsEvent;*/ //? } -//? if fabric { -import net.minecraft.client.option.KeyBinding; -import net.minecraft.client.util.InputUtil; -import net.minecraft.text.Text; -//? if >=1.21.5 { -import net.minecraft.util.Identifier; -//? } -//? } else { -/*import com.mojang.blaze3d.platform.InputConstants; +import com.mojang.blaze3d.platform.InputConstants; import net.minecraft.client.KeyMapping; import net.minecraft.network.chat.Component; //? if >=1.21.5 { import net.minecraft.resources.Identifier; -//? }*/ //? } +//? if <=26.2 { import org.lwjgl.glfw.GLFW; +//? } public final class WebGUIKeys { - //? if fabric { - private static KeyBinding mainMenu; - private static KeyBinding hudInteractive; - //? } else { - /*private static KeyMapping mainMenu; - private static KeyMapping hudInteractive;*/ + + // The keys this mod binds, in whatever numbering the game is speaking. Up to 26.2 that + // is GLFW's; from 26.3 the window is SDL's and the numbers are its scan codes, so these + // are taken from Minecraft rather than written down — the game knows which it is on. + //? if >26.2 { + /*static final InputConstants.Type KEY_TYPE = InputConstants.Type.KEYBOARD; + static final int KEY_F6 = InputConstants.KEY_F6; + static final int KEY_F12 = InputConstants.KEY_F12; + static final int KEY_GRAVE = InputConstants.KEY_GRAVE; + *///? } else { + static final InputConstants.Type KEY_TYPE = InputConstants.Type.KEYSYM; + static final int KEY_F6 = GLFW.GLFW_KEY_F6; + static final int KEY_F12 = GLFW.GLFW_KEY_F12; + static final int KEY_GRAVE = GLFW.GLFW_KEY_GRAVE_ACCENT; //? } + private static KeyMapping mainMenu; + private static KeyMapping hudInteractive; + private static KeyMapping devtools; private WebGUIKeys() {} + //? if fabric { + /** + * Registers a key with the loader. + * + *

A method of its own because the helper was renamed when Fabric API dropped "key + * binding" for "key mapping" in 26 — and because a Stonecutter marker inside an + * expression is not a thing the preprocessor can make sense of. + */ + //? if >=26 { + /*private static KeyMapping bind(KeyMapping mapping) { + return KeyMappingHelper.registerKeyMapping(mapping); + } + *///? } else { + private static KeyMapping bind(KeyMapping mapping) { + return KeyBindingHelper.registerKeyBinding(mapping); + } + //? } + //? } + //? if fabric { public static void register() { //? if >=1.21.5 { - KeyBinding.Category cat = KeyBinding.Category.create(Identifier.of(WebGUIMod.MOD_ID, "web")); - mainMenu = KeyBindingHelper.registerKeyBinding(new KeyBinding( + KeyMapping.Category cat = KeyMapping.Category.register(Identifier.fromNamespaceAndPath(WebGUIMod.MOD_ID, "web")); + mainMenu = bind(new KeyMapping( "key.webgui.main_menu", - InputUtil.Type.KEYSYM, - GLFW.GLFW_KEY_F6, + KEY_TYPE, + WebGUIKeys.KEY_F6, cat)); - hudInteractive = KeyBindingHelper.registerKeyBinding(new KeyBinding( + hudInteractive = bind(new KeyMapping( "key.webgui.hud_interactive", - InputUtil.Type.KEYSYM, - GLFW.GLFW_KEY_GRAVE_ACCENT, + KEY_TYPE, + WebGUIKeys.KEY_GRAVE, + cat)); + devtools = bind(new KeyMapping( + "key.webgui.devtools", + KEY_TYPE, + WebGUIKeys.KEY_F12, cat)); //? } else { - /*mainMenu = KeyBindingHelper.registerKeyBinding(new KeyBinding( + /*mainMenu = bind(new KeyMapping( "key.webgui.main_menu", - InputUtil.Type.KEYSYM, - GLFW.GLFW_KEY_F6, + KEY_TYPE, + WebGUIKeys.KEY_F6, WebGUIMod.MOD_ID + ".web")); - hudInteractive = KeyBindingHelper.registerKeyBinding(new KeyBinding( + hudInteractive = bind(new KeyMapping( "key.webgui.hud_interactive", - InputUtil.Type.KEYSYM, - GLFW.GLFW_KEY_GRAVE_ACCENT, + KEY_TYPE, + WebGUIKeys.KEY_GRAVE, + WebGUIMod.MOD_ID + ".web")); + devtools = bind(new KeyMapping( + "key.webgui.devtools", + KEY_TYPE, + WebGUIKeys.KEY_F12, WebGUIMod.MOD_ID + ".web"));*/ //? } } @@ -72,83 +109,51 @@ public static void register() { private static void onRegisterKeyMappings(RegisterKeyMappingsEvent event) { //? if >=1.21.5 { KeyMapping.Category cat = KeyMapping.Category.register(Identifier.fromNamespaceAndPath(WebGUIMod.MOD_ID, "web")); - mainMenu = new KeyMapping("key.webgui.main_menu", InputConstants.Type.KEYSYM, GLFW.GLFW_KEY_F6, cat); - hudInteractive = new KeyMapping("key.webgui.hud_interactive", InputConstants.Type.KEYSYM, GLFW.GLFW_KEY_GRAVE_ACCENT, cat); + mainMenu = new KeyMapping("key.webgui.main_menu", KEY_TYPE, WebGUIKeys.KEY_F6, cat); + hudInteractive = new KeyMapping("key.webgui.hud_interactive", KEY_TYPE, WebGUIKeys.KEY_GRAVE, cat); + devtools = new KeyMapping("key.webgui.devtools", KEY_TYPE, WebGUIKeys.KEY_F12, cat); //? } else { - mainMenu = new KeyMapping("key.webgui.main_menu", InputConstants.Type.KEYSYM, GLFW.GLFW_KEY_F6, WebGUIMod.MOD_ID + ".web"); - hudInteractive = new KeyMapping("key.webgui.hud_interactive", InputConstants.Type.KEYSYM, GLFW.GLFW_KEY_GRAVE_ACCENT, WebGUIMod.MOD_ID + ".web"); + mainMenu = new KeyMapping("key.webgui.main_menu", KEY_TYPE, WebGUIKeys.KEY_F6, WebGUIMod.MOD_ID + ".web"); + hudInteractive = new KeyMapping("key.webgui.hud_interactive", KEY_TYPE, WebGUIKeys.KEY_GRAVE, WebGUIMod.MOD_ID + ".web"); + devtools = new KeyMapping("key.webgui.devtools", KEY_TYPE, WebGUIKeys.KEY_F12, WebGUIMod.MOD_ID + ".web"); //? } event.register(mainMenu); event.register(hudInteractive); + event.register(devtools); }*/ //? } - //? if fabric { - public static void tick(MinecraftClient client) { - //? } else { - /*public static void tick(Minecraft client) {*/ - //? } - //? if fabric { - while (mainMenu.wasPressed()) { - //? } else { - /*while (mainMenu.consumeClick()) {*/ - //? } + public static void tick(Minecraft client) { + while (mainMenu.consumeClick()) { tryOpenMainMenu(client); } - //? if fabric { - while (hudInteractive.wasPressed()) { - if (!WebHudOverlay.isHudVisible() || client.currentScreen != null) { - //? } else { - /*while (hudInteractive.consumeClick()) { - //? if >=26.2-neoforge { - if (!WebHudOverlay.isHudVisible() || client.gui.screen() != null) { - //? } else { - if (!WebHudOverlay.isHudVisible() || client.screen != null) { - //? }*/ - //? } + while (hudInteractive.consumeClick()) { + if (!WebHudOverlay.isHudVisible() || Mc.screen(client) != null) { continue; } WebHudOverlay.toggleInteractive(client); } - } - - //? if fabric { - private static void tryOpenMainMenu(MinecraftClient client) { - if (client.currentScreen instanceof WebViewScreen) { - return; + while (devtools.consumeClick()) { + // The screen has its own F12: a page in a screen gets the key before any key + // binding does, so this is only ever the HUD. + WebGUIDevToolsWindow.open(WebSession.hudBrowser()); } - if (!Rinku.isInitialized()) { - if (client.player != null) { - client.player.sendMessage(Text.translatable("message.webgui.mcef_not_ready"), false); - } - return; - } - client.setScreen(new WebViewScreen(WebGUIMainMenuUrl.getUrl())); } - //? } else { - /*private static void tryOpenMainMenu(Minecraft client) { - //? if >=26.2-neoforge { - if (client.gui.screen() instanceof WebViewScreen) { - //? } else { - if (client.screen instanceof WebViewScreen) { - //? } + + private static void tryOpenMainMenu(Minecraft client) { + if (Mc.screen(client) instanceof WebViewScreen) { return; } - if (!Rinku.isInitialized()) { + if (!WebBrowser.ready()) { if (client.player != null) { //? if >=26 { - client.player.sendSystemMessage(Component.translatable("message.webgui.mcef_not_ready")); - //? } else { + /*client.player.sendSystemMessage(Component.translatable("message.webgui.mcef_not_ready")); + *///? } else { client.player.displayClientMessage(Component.translatable("message.webgui.mcef_not_ready"), false); //? } } return; } - //? if >=26.2-neoforge { - client.gui.setScreen(new WebViewScreen(WebGUIMainMenuUrl.getUrl())); - //? } else { - client.setScreen(new WebViewScreen(WebGUIMainMenuUrl.getUrl())); - //? } - }*/ - //? } + Mc.openScreen(client, new WebViewScreen(WebGUIMainMenuUrl.getUrl())); + } } diff --git a/src/main/java/land/webgui/WebGUIRemoteDebugging.java b/src/main/java/land/webgui/WebGUIRemoteDebugging.java index 447547f..01c2442 100644 --- a/src/main/java/land/webgui/WebGUIRemoteDebugging.java +++ b/src/main/java/land/webgui/WebGUIRemoteDebugging.java @@ -7,7 +7,8 @@ * 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. + * The engine is ours, so the mod can also choose the port itself when the settings screen + * asks for the tools; {@link #inUse()} is the one Chromium actually got, whichever way. */ public final class WebGUIRemoteDebugging { @@ -17,8 +18,25 @@ public final class WebGUIRemoteDebugging { private static Integer cached; + /** The port Chromium was started with, which is not known until it starts. */ + private static volatile int inUse; + private WebGUIRemoteDebugging() {} + /** Told by the boot once Chromium has been configured. */ + public static void started(int port) { + inUse = Math.max(0, port); + } + + /** The port the inspector is actually on, or 0 while there is none. */ + public static int inUse() { + return inUse; + } + + public static boolean reachable() { + return inUse > 0; + } + /** The port the inspector is listening on, or 0 when the argument was not given. */ public static synchronized int port() { if (cached == null) { @@ -33,8 +51,7 @@ public static boolean enabled() { /** 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 : ""; + return inUse > 0 ? "http://127.0.0.1:" + inUse : ""; } private static int findPort() { diff --git a/src/main/java/land/webgui/WebGUISettingsScreen.java b/src/main/java/land/webgui/WebGUISettingsScreen.java index c896cdb..3637acb 100644 --- a/src/main/java/land/webgui/WebGUISettingsScreen.java +++ b/src/main/java/land/webgui/WebGUISettingsScreen.java @@ -1,195 +1,189 @@ 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 land.webgui.compat.Mc; + +//? 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.components.Tooltip; import net.minecraft.client.gui.screens.Screen; -import net.minecraft.network.chat.Component;*/ -//? } +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. + * + * Plain cycling buttons rather than sliders or a scrolling list: a slider's constructor + * differs on every other Minecraft version and this screen has to build on ten of them. + * What each option costs is said in its tooltip instead of on the screen, so the screen + * stays the list of settings it is. */ -//? 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 static final int BUTTON_WIDTH = 220; + private static final int ROWS = 5; - private final Screen parent; + private static final int[] SCROLL_STEPS = {1, 3, 5, 10, 15, 20, 25, 30, 40}; - //? if fabric { - private ButtonWidget devToolsButton; + /** Zero last, and it means whatever Chromium would do unasked. */ + private static final int[] FRAME_RATE_STEPS = {30, 60, 90, 120, 144, 240, 0}; - public WebGUISettingsScreen(Screen parent) { - super(Text.translatable("screen.webgui.settings.title")); - this.parent = parent; - } - //? } else { - /*private Button devToolsButton; + private final Screen parent; + + private Button scrollButton; + private Button frameRateButton; + private Button downloadsButton; + private Button autoplayButton; + 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; + int top = this.height / 2 - (ROWS * ROW_HEIGHT) / 2 - 16; - //? 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.scrollButton = row(left, top, scrollLabel(), "scroll", b -> { + WebGUIClientConfig.setScrollNotches(next(SCROLL_STEPS, WebGUIClientConfig.scrollNotches())); + this.scrollButton.setMessage(scrollLabel()); + }); - 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 -> { + this.frameRateButton = row(left, top + ROW_HEIGHT, frameRateLabel(), "framerate", b -> { + WebGUIClientConfig.setBrowserFrameRate(nextFrameRate()); + this.frameRateButton.setMessage(frameRateLabel()); + }); + + this.downloadsButton = row(left, top + ROW_HEIGHT * 2, downloadsLabel(), "downloads", b -> { + WebGUIClientConfig.setAskWhereToSaveDownloads(!WebGUIClientConfig.askWhereToSaveDownloads()); + this.downloadsButton.setMessage(downloadsLabel()); + }); + + this.autoplayButton = row(left, top + ROW_HEIGHT * 3, autoplayLabel(), "autoplay", b -> { + WebGUIClientConfig.setAllowPageAutoplay(!WebGUIClientConfig.allowPageAutoplay()); + this.autoplayButton.setMessage(autoplayLabel()); + }); + + this.devToolsButton = row(left, top + ROW_HEIGHT * 4, devToolsLabel(), "devtools", 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")); - }*/ - //? } + .bounds(left, this.height - 32, BUTTON_WIDTH, 20).build()); + } - //? 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); + private Button row(int left, int top, Component label, String key, Button.OnPress action) { + Button button = Button.builder(label, action) + .tooltip(Tooltip.create(Component.translatable("screen.webgui.settings." + key + ".tip"))) + .bounds(left, top, BUTTON_WIDTH, 20) + .build(); + this.addRenderableWidget(button); + return button; + } + + private static int next(int[] steps, int current) { + for (int step : steps) { + if (step > current) { + return step; + } } + return steps[0]; } - @Override - public void close() { - if (this.client != null) { - this.client.setScreen(this.parent); + // Its own, because zero sorts first but belongs last: it is "no limit", not "slowest". + private static int nextFrameRate() { + int current = WebGUIClientConfig.browserFrameRate(); + for (int i = 0; i < FRAME_RATE_STEPS.length; i++) { + if (FRAME_RATE_STEPS[i] == current) { + return FRAME_RATE_STEPS[(i + 1) % FRAME_RATE_STEPS.length]; + } } + return FRAME_RATE_STEPS[0]; } - //? } else { - /*//? if >=26 { - @Override + + private static Component value(String key, Component shown) { + return Component.translatable("screen.webgui.settings." + key).append(": ").append(shown); + } + + private static Component onOff(boolean on) { + return Component.translatable(on ? "options.on" : "options.off"); + } + + private Component scrollLabel() { + return value("scroll", Component.literal(String.valueOf(WebGUIClientConfig.scrollNotches()))); + } + + private Component frameRateLabel() { + int rate = WebGUIClientConfig.browserFrameRate(); + return value("framerate", rate <= 0 + ? Component.translatable("screen.webgui.settings.framerate.unlimited") + : Component.translatable("screen.webgui.settings.framerate.value", rate)); + } + + private Component downloadsLabel() { + return value("downloads", onOff(WebGUIClientConfig.askWhereToSaveDownloads())); + } + + private Component autoplayLabel() { + return value("autoplay", onOff(WebGUIClientConfig.allowPageAutoplay())); + } + + private Component devToolsLabel() { + return value("devtools", onOff(WebGUIClientConfig.devTools())); + } + + // Only while the tools are on. The inspector is a developer's door, and a player who + // never opens it should not be reading about launch arguments. + private int inspectorTop() { + return this.height / 2 - (ROWS * ROW_HEIGHT) / 2 - 16 + ROWS * ROW_HEIGHT + 6; + } + + //? 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, this.title, centre, + this.height / 2 - (ROWS * ROW_HEIGHT) / 2 - 36, 0xFFFFFFFF); + if (!WebGUIClientConfig.devTools()) { + return; + } + boolean on = WebGUIRemoteDebugging.reachable(); 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); - } + centre, inspectorTop(), on ? 0xFF80FF80 : 0xFFA0A0A0); } - //? } else { + *///? } 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, this.title, centre, + this.height / 2 - (ROWS * ROW_HEIGHT) / 2 - 36, 0xFFFFFFFF); + if (!WebGUIClientConfig.devTools()) { + return; + } + boolean on = WebGUIRemoteDebugging.reachable(); 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); - } + centre, inspectorTop(), on ? 0xFF80FF80 : 0xFFA0A0A0); } //? } @Override public void onClose() { - if (this.minecraft != null) { - //? if >=26.2-neoforge { - this.minecraft.gui.setScreen(this.parent); - //? } else { - this.minecraft.setScreen(this.parent); - //? } - } - }*/ - //? } + Mc.openScreen(this.minecraft, this.parent); + } } diff --git a/src/main/java/land/webgui/WebHudOverlay.java b/src/main/java/land/webgui/WebHudOverlay.java index 23aa2cb..9e9263e 100644 --- a/src/main/java/land/webgui/WebHudOverlay.java +++ b/src/main/java/land/webgui/WebHudOverlay.java @@ -1,21 +1,32 @@ package land.webgui; -import de.keksuccino.rinku.Rinku; -import de.keksuccino.rinku.RinkuBrowser; +import land.webgui.compat.Mc; +import land.webgui.browser.WebBrowser; //? if fabric { +//? if >=26 { +/*import net.fabricmc.fabric.api.client.rendering.v1.hud.HudElementRegistry; +*///? } else { import net.fabricmc.fabric.api.client.rendering.v1.HudRenderCallback; -import net.minecraft.client.MinecraftClient; +//? } +import net.minecraft.client.Minecraft; //? if >=1.21.5 { -import net.minecraft.client.gl.RenderPipelines; +import net.minecraft.client.renderer.RenderPipelines; +//? } +//? if >=26 { +/*// GuiGraphics is gone from the HUD path in 26: the game extracts render state first. +*///? } else { +import net.minecraft.client.gui.GuiGraphics; //? } -import net.minecraft.client.gui.DrawContext; //? if >=1.20.5 { -import net.minecraft.client.render.RenderTickCounter; +import net.minecraft.client.DeltaTracker; //? } //? if >=1.21.5 { -import net.minecraft.util.Identifier; +import net.minecraft.resources.Identifier; //? } -import net.minecraft.text.Text; +//? if >=26 { +/*import net.minecraft.client.gui.GuiGraphicsExtractor; +*///? } +import net.minecraft.network.chat.Component; //? } else { /*import net.minecraft.client.Minecraft; //? if >=1.21.5 { @@ -55,23 +66,11 @@ public static void setHudInteractive(boolean value) { hudInteractive = value; } - //? if fabric { - public static boolean shouldDeliverHudBrowserInput(MinecraftClient client) { - //? } else { - /*public static boolean shouldDeliverHudBrowserInput(Minecraft client) {*/ - //? } - if (client == null || !Rinku.isInitialized()) { + public static boolean shouldDeliverHudBrowserInput(Minecraft client) { + if (client == null || !WebBrowser.ready()) { return false; } - //? if fabric { - boolean hasGui1 = client.currentScreen != null; - //? } else { - /*//? if >=26.2-neoforge { - boolean hasGui1 = client.gui.screen() != null; - //? } else { - boolean hasGui1 = client.screen != null; - //? }*/ - //? } + boolean hasGui1 = Mc.screen(client) != null; if (!hudVisible || !hudInteractive || hasGui1) { return false; } @@ -81,23 +80,11 @@ public static boolean shouldDeliverHudBrowserInput(MinecraftClient client) { return WebSession.browser() != null; } - //? if fabric { - public static boolean shouldForwardHudArrowKeys(MinecraftClient client) { - //? } else { - /*public static boolean shouldForwardHudArrowKeys(Minecraft client) {*/ - //? } - if (client == null || !Rinku.isInitialized()) { + public static boolean shouldForwardHudArrowKeys(Minecraft client) { + if (client == null || !WebBrowser.ready()) { return false; } - //? if fabric { - boolean hasGui2 = client.currentScreen != null; - //? } else { - /*//? if >=26.2-neoforge { - boolean hasGui2 = client.gui.screen() != null; - //? } else { - boolean hasGui2 = client.screen != null; - //? }*/ - //? } + boolean hasGui2 = Mc.screen(client) != null; if (!hudVisible || hasGui2) { return false; } @@ -110,98 +97,42 @@ public static boolean shouldForwardHudArrowKeys(MinecraftClient client) { return WebSession.browser() != null; } - //? if fabric { - public static boolean shouldBlockVanillaWorldInteractions(MinecraftClient client) { - //? } else { - /*public static boolean shouldBlockVanillaWorldInteractions(Minecraft client) {*/ - //? } - //? if fabric { - if (client == null || client.currentScreen != null) { - //? } else { - /*//? if >=26.2-neoforge { - if (client == null || client.gui.screen() != null) { - //? } else { - if (client == null || client.screen != null) { - //? }*/ - //? } + public static boolean shouldBlockVanillaWorldInteractions(Minecraft client) { + if (client == null || Mc.screen(client) != null) { return false; } if (!hudVisible || !hudInteractive) { return false; } - //? if fabric { - return client.world != null && client.world.isClient(); - //? } else { - /*return client.level != null;*/ - //? } + return client.level != null; } - //? if fabric { - public static void tickCursor(MinecraftClient client) { - //? } else { - /*public static void tickCursor(Minecraft client) {*/ - //? } - //? if fabric { - if (client.currentScreen != null) { - //? } else { - /*//? if >=26.2-neoforge { - if (client.gui.screen() != null) { - //? } else { - if (client.screen != null) { - //? }*/ - //? } + public static void tickCursor(Minecraft client) { + if (Mc.screen(client) != null) { return; } if (!hudVisible) { if (cursorUnlockedForWebHud) { - //? if fabric { - client.mouse.lockCursor(); - //? } else { - /*client.mouseHandler.grabMouse();*/ - //? } + client.mouseHandler.grabMouse(); cursorUnlockedForWebHud = false; } return; } if (hudInteractive) { - //? if fabric { - client.mouse.unlockCursor(); - //? } else { - /*client.mouseHandler.releaseMouse();*/ - //? } + client.mouseHandler.releaseMouse(); cursorUnlockedForWebHud = true; return; } if (cursorUnlockedForWebHud) { - //? if fabric { - client.mouse.lockCursor(); - //? } else { - /*client.mouseHandler.grabMouse();*/ - //? } + client.mouseHandler.grabMouse(); cursorUnlockedForWebHud = false; } } - //? if fabric { - public static void applyServerOpen(MinecraftClient client, String url) { - //? } else { - /*public static void applyServerOpen(Minecraft client, String url) {*/ - //? } + public static void applyServerOpen(Minecraft client, String url) { String u = url == null || url.isBlank() ? StartUrls.primary() : url; - //? if fabric { - if (client.currentScreen != null) { - //? } else { - /*//? if >=26.2-neoforge { - if (client.gui.screen() != null) { - //? } else { - if (client.screen != null) { - //? }*/ - //? } - //? if >=26.2-neoforge { - /*client.gui.setScreen(null);*/ - //? } else { - client.setScreen(null); - //? } + if (Mc.screen(client) != null) { + Mc.openScreen(client, null); } restoreHudAfterGuiClose = false; hudVisible = true; @@ -213,23 +144,11 @@ public static void applyServerOpen(MinecraftClient client, String url) { resizeBrowser(client); } - //? if fabric { - public static void toggleHud(MinecraftClient client) { - //? } else { - /*public static void toggleHud(Minecraft client) {*/ - //? } - //? if fabric { - if (client.currentScreen instanceof WebViewScreen) { - //? } else { - /*//? if >=26.2-neoforge { - if (client.gui.screen() instanceof WebViewScreen) { - //? } else { - if (client.screen instanceof WebViewScreen) { - //? }*/ - //? } + public static void toggleHud(Minecraft client) { + if (Mc.screen(client) instanceof WebViewScreen) { return; } - if (!Rinku.isInitialized()) { + if (!WebBrowser.ready()) { notifyMcefMissing(client); return; } @@ -241,11 +160,7 @@ public static void toggleHud(MinecraftClient client) { lastPixelW = -1; lastPixelH = -1; if (cursorUnlockedForWebHud) { - //? if fabric { - client.mouse.lockCursor(); - //? } else { - /*client.mouseHandler.grabMouse();*/ - //? } + client.mouseHandler.grabMouse(); cursorUnlockedForWebHud = false; } } else { @@ -257,40 +172,20 @@ public static void toggleHud(MinecraftClient client) { } } - //? if fabric { - public static void toggleInteractive(MinecraftClient client) { - //? } else { - /*public static void toggleInteractive(Minecraft client) {*/ - //? } - if (!Rinku.isInitialized()) { + public static void toggleInteractive(Minecraft client) { + if (!WebBrowser.ready()) { return; } - //? if fabric { - boolean hasGui7 = client.currentScreen != null; - //? } else { - /*//? if >=26.2-neoforge { - boolean hasGui7 = client.gui.screen() != null; - //? } else { - boolean hasGui7 = client.screen != null; - //? }*/ - //? } + boolean hasGui7 = Mc.screen(client) != null; if (!hudVisible || hasGui7) { return; } hudInteractive = !hudInteractive; if (hudInteractive) { - //? if fabric { - client.mouse.unlockCursor(); - //? } else { - /*client.mouseHandler.releaseMouse();*/ - //? } + client.mouseHandler.releaseMouse(); cursorUnlockedForWebHud = true; } else if (cursorUnlockedForWebHud) { - //? if fabric { - client.mouse.lockCursor(); - //? } else { - /*client.mouseHandler.grabMouse();*/ - //? } + client.mouseHandler.grabMouse(); cursorUnlockedForWebHud = false; } } @@ -314,11 +209,7 @@ public static void reset() { lastPixelH = -1; } - //? if fabric { public static void onGuiOpened() { - //? } else { - /*public static void onGuiOpened() {*/ - //? } // Only decided when a gui is opening over the hud. A gui replacing another gui // runs this too, and by then the mode is already GUI_SCREEN, so recomputing it // here answered "there is no hud to put back" and threw the parked one away. @@ -328,26 +219,14 @@ public static void onGuiOpened() { hudInteractive = false; lastPixelW = -1; lastPixelH = -1; - //? if fabric { - MinecraftClient c = MinecraftClient.getInstance(); - //? } else { - /*Minecraft c = Minecraft.getInstance();*/ - //? } + Minecraft c = Minecraft.getInstance(); if (cursorUnlockedForWebHud && c != null) { - //? if fabric { - c.mouse.lockCursor(); - //? } else { - /*c.mouseHandler.grabMouse();*/ - //? } + c.mouseHandler.grabMouse(); cursorUnlockedForWebHud = false; } } - //? if fabric { - public static void onGuiClosed(MinecraftClient client) { - //? } else { - /*public static void onGuiClosed(Minecraft client) {*/ - //? } + public static void onGuiClosed(Minecraft client) { if (!restoreHudAfterGuiClose) { return; } @@ -361,7 +240,7 @@ public static void onGuiClosed(MinecraftClient client) { } } - static void onHudBrowserLoadStart(RinkuBrowser browser) { + static void onHudBrowserLoadStart(WebBrowser browser) { if (browser == null) { return; } @@ -370,7 +249,7 @@ static void onHudBrowserLoadStart(RinkuBrowser browser) { } } - static void onHudBrowserLoadFinished(RinkuBrowser browser) { + static void onHudBrowserLoadFinished(WebBrowser browser) { if (browser == null) { return; } @@ -381,9 +260,19 @@ static void onHudBrowserLoadFinished(RinkuBrowser browser) { } //? if fabric { + //? if >=26 { + /*public static void register() { + // Not an event any more: 26 keeps an ordered list of HUD elements, and a mod adds + // one rather than hooking the whole HUD being drawn. Last, so the page sits over + // the game's own bars rather than under them. + HudElementRegistry.addLast(Identifier.fromNamespaceAndPath(WebGUIMod.MOD_ID, "page"), + WebHudOverlay::onHudRender); + } + *///? } else { public static void register() { HudRenderCallback.EVENT.register(WebHudOverlay::onHudRender); } + //? } //? } else { /*public static void register() { NeoForge.EVENT_BUS.addListener(WebHudOverlay::onHudRender); @@ -391,10 +280,14 @@ public static void register() { //? } //? if fabric { + //? if >=26 { + /*private static void onHudRender(GuiGraphicsExtractor context, DeltaTracker tickCounter) { + *///? } else { //? if >=1.20.5 { - private static void onHudRender(DrawContext context, RenderTickCounter tickCounter) { + private static void onHudRender(GuiGraphics context, DeltaTracker tickCounter) { //? } else { - /*private static void onHudRender(DrawContext context, float tickDelta) {*/ + /*private static void onHudRender(GuiGraphics context, float tickDelta) {*/ + //? } //? } //? } else { /*//? if >=1.20.5 { @@ -412,102 +305,60 @@ private static void onHudRender(RenderGuiEvent.Post event) { if (!hudVisible) { return; } - RinkuBrowser browser = WebSession.hudBrowser(); + WebBrowser browser = WebSession.hudBrowser(); if (browser == null) { return; } - //? if fabric { - MinecraftClient client = MinecraftClient.getInstance(); - //? } else { - /*Minecraft client = Minecraft.getInstance();*/ - //? } + Minecraft client = Minecraft.getInstance(); resizeBrowser(client); if (!hudPageReady) { return; } - //? if fabric { - if (!browser.isTextureReady()) { - return; - } - //? if >=1.21.5 { - net.minecraft.util.Identifier tex = browser.getTextureIdentifier(); - //? } else { - /*net.minecraft.util.Identifier tex = browser.getTextureIdentifier();*/ - //? } - if (tex == null) { - return; - } - int sw = client.getWindow().getScaledWidth(); - int sh = client.getWindow().getScaledHeight(); - //? if >=1.21.5 { - context.drawTexture(RenderPipelines.GUI_TEXTURED, tex, 0, 0, 0f, 0f, sw, sh, sw, sh); - //? } else { - /*context.drawTexture(tex, 0, 0, 0, 0f, 0f, sw, sh, sw, sh);*/ - //? } - //? } else { - /*if (!browser.isTextureReady()) return; + if (!browser.isTextureReady()) return; //? if >=1.21.5 { net.minecraft.resources.Identifier tex = browser.getTextureIdentifier(); //? } else { - net.minecraft.resources.ResourceLocation tex = browser.getTextureIdentifier(); - //? } + /*net.minecraft.resources.ResourceLocation tex = browser.getTextureIdentifier(); + *///? } if (tex == null) return; int sw = client.getWindow().getGuiScaledWidth(); int sh = client.getWindow().getGuiScaledHeight(); //? if >=1.21.5 { context.blit(RenderPipelines.GUI_TEXTURED, tex, 0, 0, 0f, 0f, sw, sh, sw, sh); //? } else { - context.blit(tex, 0, 0, 0f, 0f, sw, sh, sw, sh); - //? }*/ - //? } + /*context.blit(tex, 0, 0, 0f, 0f, sw, sh, sw, sh); + *///? } + // Only while the HUD holds the mouse, or a player looking around gets the page's cursor. + //? if >26.2 { + /*if (hudInteractive) { + browser.requestCursor(context); + } + *///? } } - //? if fabric { - public static boolean containsMouse(double mouseX, double mouseY, MinecraftClient client) { - //? } else { - /*public static boolean containsMouse(double mouseX, double mouseY, Minecraft client) {*/ - //? } + public static boolean containsMouse(double mouseX, double mouseY, Minecraft client) { if (!hudVisible) { return false; } - //? if fabric { - int sw = client.getWindow().getScaledWidth(); - int sh = client.getWindow().getScaledHeight(); - //? } else { - /*int sw = client.getWindow().getGuiScaledWidth(); - int sh = client.getWindow().getGuiScaledHeight();*/ - //? } + int sw = client.getWindow().getGuiScaledWidth(); + int sh = client.getWindow().getGuiScaledHeight(); return mouseX >= 0 && mouseY >= 0 && mouseX < sw && mouseY < sh; } - //? if fabric { - public static int toBrowserLocalX(double mouseX, MinecraftClient client) { - int sw = client.getWindow().getScaledWidth(); - //? } else { - /*public static int toBrowserLocalX(double mouseX, Minecraft client) { - int sw = client.getWindow().getGuiScaledWidth();*/ - //? } + public static int toBrowserLocalX(double mouseX, Minecraft client) { + int sw = client.getWindow().getGuiScaledWidth(); return (int) Math.round(mouseX * browserViewSize(client)[0] / (double) Math.max(1, sw)); } - //? if fabric { - public static int toBrowserLocalY(double mouseY, MinecraftClient client) { - int sh = client.getWindow().getScaledHeight(); - //? } else { - /*public static int toBrowserLocalY(double mouseY, Minecraft client) { - int sh = client.getWindow().getGuiScaledHeight();*/ - //? } + public static int toBrowserLocalY(double mouseY, Minecraft client) { + int sh = client.getWindow().getGuiScaledHeight(); return (int) Math.round(mouseY * browserViewSize(client)[1] / (double) Math.max(1, sh)); } - //? if fabric { - static void resizeBrowser(MinecraftClient client) { - //? } else { - /*static void resizeBrowser(Minecraft client) {*/ - //? } - RinkuBrowser browser = WebSession.hudBrowser(); + static void resizeBrowser(Minecraft client) { + WebBrowser browser = WebSession.hudBrowser(); if (browser == null) { return; } @@ -523,32 +374,20 @@ static void resizeBrowser(MinecraftClient client) { } // Browser view size = physical framebuffer px; page zoom keeps the CSS viewport logical. - //? if fabric { - private static int[] browserViewSize(MinecraftClient client) { - //? } else { - /*private static int[] browserViewSize(Minecraft client) {*/ - //? } + private static int[] browserViewSize(Minecraft client) { var window = client.getWindow(); return WebViewLayout.browserSize(window.getWidth(), window.getHeight()); } - //? if fabric { - private static void notifyMcefMissing(MinecraftClient client) { - if (client.player != null) { - client.player.sendMessage(Text.translatable("message.webgui.mcef_not_ready"), false); - } - } - //? } else { - /*private static void notifyMcefMissing(Minecraft client) { + private static void notifyMcefMissing(Minecraft client) { if (client.player != null) { //? if >=26 { - client.player.sendSystemMessage( + /*client.player.sendSystemMessage( Component.translatable("message.webgui.mcef_not_ready")); - //? } else { + *///? } else { client.player.displayClientMessage( Component.translatable("message.webgui.mcef_not_ready"), false); //? } } - }*/ - //? } + } } diff --git a/src/main/java/land/webgui/WebSession.java b/src/main/java/land/webgui/WebSession.java index 810b167..3dc50af 100644 --- a/src/main/java/land/webgui/WebSession.java +++ b/src/main/java/land/webgui/WebSession.java @@ -1,11 +1,10 @@ package land.webgui; -import de.keksuccino.rinku.Rinku; -import de.keksuccino.rinku.RinkuBrowser; +import land.webgui.browser.WebBrowser; public final class WebSession { - private static RinkuBrowser browser; - private static RinkuBrowser suspendedHudBrowser; + private static WebBrowser browser; + private static WebBrowser suspendedHudBrowser; private static Mode mode = Mode.NONE; public enum Mode { @@ -16,11 +15,11 @@ public enum Mode { private WebSession() {} - public static RinkuBrowser browser() { + public static WebBrowser browser() { return browser; } - public static RinkuBrowser hudBrowser() { + public static WebBrowser hudBrowser() { if (mode == Mode.HUD_OVERLAY) { return browser; } @@ -70,7 +69,7 @@ public static void closeGuiAndRestoreHud() { WebviewClientBridge.clearCache(); } - public static RinkuBrowser openForGui(String url) { + public static WebBrowser openForGui(String url) { if (mode == Mode.HUD_OVERLAY && browser != null) { // Coming from the hud: park it. Nothing should already be parked in this mode, // but closing first keeps that from becoming a leak if it ever is. @@ -84,13 +83,13 @@ public static RinkuBrowser openForGui(String url) { // hud at all once they escaped out. closeActiveBrowser(); } - browser = Rinku.createBrowser(WebGUIAssetServer.resolve(url), true); + browser = WebBrowser.create(WebGUIAssetServer.resolve(url), true); mode = Mode.GUI_SCREEN; WebviewClientBridge.clearCache(); return browser; } - public static RinkuBrowser openForHud(String url) { + public static WebBrowser openForHud(String url) { closeSuspendedHudBrowser(); if (mode == Mode.HUD_OVERLAY && browser != null) { browser.loadURL(WebGUIAssetServer.resolve(url)); @@ -98,7 +97,7 @@ public static RinkuBrowser openForHud(String url) { return browser; } closeActiveBrowser(); - browser = Rinku.createBrowser(WebGUIAssetServer.resolve(url), true); + browser = WebBrowser.create(WebGUIAssetServer.resolve(url), true); mode = Mode.HUD_OVERLAY; WebviewClientBridge.clearCache(); return browser; @@ -122,7 +121,7 @@ public static void reloadBundledPages() { } } - private static boolean reloadIfBundled(RinkuBrowser target, String origin) { + private static boolean reloadIfBundled(WebBrowser target, String origin) { if (target == null) { return false; } diff --git a/src/main/java/land/webgui/WebViewLayout.java b/src/main/java/land/webgui/WebViewLayout.java index 1003406..02628c7 100644 --- a/src/main/java/land/webgui/WebViewLayout.java +++ b/src/main/java/land/webgui/WebViewLayout.java @@ -1,19 +1,30 @@ package land.webgui; -import de.keksuccino.rinku.RinkuBrowser; +import land.webgui.browser.WebBrowser; +//? if <=26.2 { import org.lwjgl.glfw.GLFW; +//? } /** * Sizing/scale helpers for the embedded browser. The browser view is the physical framebuffer size * (so its texture draws 1:1 → crisp on HiDPI), and CEF page zoom scales the content up by the OS * content scale so the page's CSS viewport stays logical and independent of Minecraft's GUI Scale. - * Uses only the public {@link RinkuBrowser#setZoomLevel} API — no device_scale_factor, no reflection. + * Uses only the public {@link WebBrowser#setZoomLevel} API — no device_scale_factor, no reflection. */ final class WebViewLayout { private WebViewLayout() {} /** OS content scale of the current window (e.g. 2 on a Retina display), or 1 if unknown. */ static float contentScale() { + //? if >26.2 { + /*// The game answers this itself from 26.3, where there is no GLFW left to ask. + var client = net.minecraft.client.Minecraft.getInstance(); + if (client == null || client.getWindow() == null) { + return 1f; + } + float density = client.getWindow().getPixelDensity(); + return density > 0f ? density : 1f; + *///? } else { long handle = GLFW.glfwGetCurrentContext(); if (handle == 0L) { return 1f; @@ -22,6 +33,7 @@ static float contentScale() { float[] sy = new float[1]; GLFW.glfwGetWindowContentScale(handle, sx, sy); return sx[0] > 0f ? sx[0] : 1f; + //? } } /** Browser view size = physical framebuffer pixels, so the OSR texture is drawn 1:1 (crisp). */ @@ -40,7 +52,7 @@ static double zoomLevel() { } /** Applies the content-scale zoom. CEF may reset zoom on navigation, so call after load too. */ - static void applyZoom(RinkuBrowser browser) { + static void applyZoom(WebBrowser browser) { if (browser == null) { return; } diff --git a/src/main/java/land/webgui/WebViewScreen.java b/src/main/java/land/webgui/WebViewScreen.java index 1a8a12d..f5f8b87 100644 --- a/src/main/java/land/webgui/WebViewScreen.java +++ b/src/main/java/land/webgui/WebViewScreen.java @@ -1,29 +1,15 @@ package land.webgui; -import de.keksuccino.rinku.Rinku; -import de.keksuccino.rinku.RinkuBrowser; +import land.webgui.browser.WebBrowser; //? if >=1.21.5 { -//? if fabric { -import net.minecraft.client.gl.RenderPipelines; -import net.minecraft.client.gui.Click; -import net.minecraft.client.input.CharInput; -import net.minecraft.client.input.KeyInput; -//? } else { -/*import net.minecraft.client.renderer.RenderPipelines; +import net.minecraft.client.renderer.RenderPipelines; import net.minecraft.client.input.CharacterEvent; import net.minecraft.client.input.KeyEvent; -import net.minecraft.client.input.MouseButtonEvent;*/ -//? } +import net.minecraft.client.input.MouseButtonEvent; //? } -//? if fabric { -import net.minecraft.client.gui.DrawContext; -import net.minecraft.client.gui.screen.Screen; -import net.minecraft.text.Text; -import net.minecraft.util.Identifier; -//? } else { -/*//? if >=26 { -import net.minecraft.client.gui.GuiGraphicsExtractor; -//? } else { +//? if >=26 { +/*import net.minecraft.client.gui.GuiGraphicsExtractor; +*///? } else { import net.minecraft.client.gui.GuiGraphics; //? } import net.minecraft.client.gui.screens.Screen; @@ -31,54 +17,32 @@ //? if >=1.21.5 { import net.minecraft.resources.Identifier; //? } else { -import net.minecraft.resources.ResourceLocation; -//? }*/ -//? } +/*import net.minecraft.resources.ResourceLocation; +*///? } -//? if fabric { public class WebViewScreen extends Screen { -//? } else { -/*public class WebViewScreen extends Screen {*/ -//? } private static boolean guiPageReady; private final String initialUrl; - private RinkuBrowser browser; + private WebBrowser browser; - //? if fabric { public WebViewScreen(String startUrl) { - super(Text.translatable("screen.webgui.title")); - this.initialUrl = startUrl == null || startUrl.isBlank() ? StartUrls.primary() : startUrl; - } - //? } else { - /*public WebViewScreen(String startUrl) { super(Component.translatable("screen.webgui.title")); this.initialUrl = startUrl == null || startUrl.isBlank() ? StartUrls.primary() : startUrl; - }*/ - //? } + } @Override protected void init() { super.init(); - if (!Rinku.isInitialized()) { - //? if fabric { - if (this.client != null && this.client.player != null) { - this.client.player.sendMessage(Text.translatable("message.webgui.mcef_not_ready"), false); - } - //? } else { - /*if (this.minecraft != null && this.minecraft.player != null) { + if (!WebBrowser.ready()) { + if (this.minecraft != null && this.minecraft.player != null) { //? if >=26 { - this.minecraft.player.sendSystemMessage(Component.translatable("message.webgui.mcef_not_ready")); - //? } else { + /*this.minecraft.player.sendSystemMessage(Component.translatable("message.webgui.mcef_not_ready")); + *///? } else { this.minecraft.player.displayClientMessage(Component.translatable("message.webgui.mcef_not_ready"), false); //? } - }*/ - //? } - //? if fabric { - this.close(); - //? } else { - /*this.onClose();*/ - //? } + } + this.onClose(); return; } @@ -91,6 +55,13 @@ protected void init() { guiPageReady = false; browser = WebSession.openForGui(initialUrl); resizeBrowser(); + land.webgui.compat.TextInput.start(this); + } + + @Override + public void removed() { + land.webgui.compat.TextInput.stop(this); + super.removed(); } private int getBrowserWidth() { @@ -107,11 +78,7 @@ private boolean isInBrowserBounds(double x, double y) { // Browser view size in logical window points, independent of the GUI Scale setting. private int[] browserViewSize() { - //? if fabric { - var window = this.client.getWindow(); - //? } else { - /*var window = this.minecraft.getWindow();*/ - //? } + var window = this.minecraft.getWindow(); return WebViewLayout.browserSize(window.getWidth(), window.getHeight()); } @@ -139,41 +106,15 @@ public void resize(int width, int height) { } //? } else { /*@Override - //? if fabric { - public void resize(net.minecraft.client.MinecraftClient client, int width, int height) { - //? } else { public void resize(net.minecraft.client.Minecraft minecraft, int width, int height) { - //? } super.resize( - //? if fabric { - client, - //? } else { minecraft, - //? } width, height); resizeBrowser(); }*/ //? } - //? if fabric { @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) { - WebHudOverlay.onGuiClosed(this.client); - } - } - //? } else { - /*@Override public void onClose() { guiPageReady = false; // Escaping a death page that never loaded has to respawn, or the player @@ -188,10 +129,9 @@ public void onClose() { if (this.minecraft != null) { WebHudOverlay.onGuiClosed(this.minecraft); } - }*/ - //? } + } - static void onGuiBrowserLoadStart(RinkuBrowser browser) { + static void onGuiBrowserLoadStart(WebBrowser browser) { if (browser == null) { return; } @@ -200,7 +140,7 @@ static void onGuiBrowserLoadStart(RinkuBrowser browser) { } } - static void onGuiBrowserLoadFinished(RinkuBrowser browser) { + static void onGuiBrowserLoadFinished(WebBrowser browser) { if (browser == null) { return; } @@ -210,72 +150,49 @@ static void onGuiBrowserLoadFinished(RinkuBrowser browser) { } } - //? if fabric { @Override - public void render(DrawContext context, int mouseX, int mouseY, float delta) { - if (browser != null && guiPageReady && browser.isTextureReady()) { - //? if >=1.21.5 { - Identifier textureLocation = browser.getTextureIdentifier(); - //? } else { - /*Identifier textureLocation = browser.getTextureIdentifier();*/ - //? } - if (textureLocation != null) { - int fw = getBrowserWidth(); - int fh = getBrowserHeight(); - //? if >=1.21.5 { - context.drawTexture(RenderPipelines.GUI_TEXTURED, textureLocation, 0, 0, 0f, 0f, fw, fh, fw, fh); - //? } else { - /*context.drawTexture(textureLocation, 0, 0, 0, 0f, 0f, fw, fh, fw, fh);*/ - //? } - } - } - } - //? } else { - /*@Override //? if >=26 { - public void extractRenderState(GuiGraphicsExtractor context, int mouseX, int mouseY, float delta) { - //? } else { + /*public void extractRenderState(GuiGraphicsExtractor context, int mouseX, int mouseY, float delta) { + *///? } else { public void render(GuiGraphics context, int mouseX, int mouseY, float delta) { //? } if (browser != null && guiPageReady && browser.isTextureReady()) { //? if >=1.21.5 { net.minecraft.resources.Identifier textureLocation = browser.getTextureIdentifier(); //? } else { - net.minecraft.resources.ResourceLocation textureLocation = browser.getTextureIdentifier(); - //? } + /*net.minecraft.resources.ResourceLocation textureLocation = browser.getTextureIdentifier(); + *///? } if (textureLocation != null) { int fw = getBrowserWidth(); int fh = getBrowserHeight(); //? if >=1.21.5 { context.blit(RenderPipelines.GUI_TEXTURED, textureLocation, 0, 0, 0f, 0f, fw, fh, fw, fh); //? } else { - context.blit(textureLocation, 0, 0, 0f, 0f, fw, fh, fw, fh); - //? } + /*context.blit(textureLocation, 0, 0, 0f, 0f, fw, fh, fw, fh); + *///? } } } - }*/ - //? } + // Every frame: the game takes the last request made during one and ignores the rest. + //? if >26.2 { + /*if (browser != null) { + browser.requestCursor(context); + } + *///? } + } //? if >=1.20.5 { - //? if fabric { @Override - public void renderBackground(DrawContext context, int mouseX, int mouseY, float deltaTicks) { - // no darkening — web fills the entire screen - } - //? } else { - /*@Override //? if >=26 { - public void extractBackground(GuiGraphicsExtractor context, int mouseX, int mouseY, float deltaTicks) { - //? } else { + /*public void extractBackground(GuiGraphicsExtractor context, int mouseX, int mouseY, float deltaTicks) { + *///? } else { public void renderBackground(GuiGraphics context, int mouseX, int mouseY, float deltaTicks) { //? } // no darkening - web fills the entire screen - }*/ - //? } + } //? } else { /*//? if fabric { @Override - public void renderBackground(DrawContext context) { + public void renderBackground(GuiGraphics context) { // no darkening - web fills the entire screen } //? } else { @@ -287,43 +204,20 @@ public void renderBackground(GuiGraphics context) { //? } //? if >=1.21.5 { - //? if fabric { @Override - public boolean mouseClicked(Click click, boolean doubled) { - if (browser == null || !isInBrowserBounds(click.x(), click.y())) { - return false; - } - browser.sendMousePress(browserLocalMouseX(click.x()), browserLocalMouseY(click.y()), click.button()); - browser.setFocus(true); - return true; - } - - @Override - public boolean mouseReleased(Click click) { - if (browser == null) { - return false; - } - browser.sendMouseRelease(browserLocalMouseX(click.x()), browserLocalMouseY(click.y()), click.button()); - browser.setFocus(true); - return true; - } - //? } else { - /*@Override public boolean mouseClicked(MouseButtonEvent click, boolean doubled) { if (browser == null || !isInBrowserBounds(click.x(), click.y())) return false; browser.sendMousePress(browserLocalMouseX(click.x()), browserLocalMouseY(click.y()), click.button()); browser.setFocus(true); return true; } - @Override public boolean mouseReleased(MouseButtonEvent click) { if (browser == null) return false; browser.sendMouseRelease(browserLocalMouseX(click.x()), browserLocalMouseY(click.y()), click.button()); browser.setFocus(true); return true; - }*/ - //? } + } //? } else { /*@Override public boolean mouseClicked(double mouseX, double mouseY, int button) { @@ -375,54 +269,34 @@ public boolean mouseScrolled(double mouseX, double mouseY, double amount) { //? } //? if >=1.21.5 { - //? if fabric { @Override - public boolean keyPressed(KeyInput input) { - if (super.keyPressed(input)) return true; - if (browser == null) return false; - browser.sendKeyPress(input.key(), input.scancode(), input.modifiers()); - browser.setFocus(true); - return true; - } - - @Override - public boolean keyReleased(KeyInput input) { - if (super.keyReleased(input)) return true; - if (browser == null) return false; - browser.sendKeyRelease(input.key(), input.scancode(), input.modifiers()); - browser.setFocus(true); - return true; - } - - @Override - public boolean charTyped(CharInput input) { - if (super.charTyped(input)) return true; - if (browser == null || !input.isValidChar()) return false; - int cp = input.codepoint(); - if (cp <= 0 || cp > 0xFFFF) return false; - browser.sendKeyTyped((char) cp, input.modifiers()); - browser.setFocus(true); - return true; - } - //? } else { - /*@Override public boolean keyPressed(KeyEvent input) { if (super.keyPressed(input)) return true; if (browser == null) return false; + if (input.key() == WebGUIKeys.KEY_F12) { + WebGUIDevToolsWindow.open(browser); + return true; + } + //? if >26.2 { + /*browser.sendKeyPress(input.key(), input.key(), input.modifiers()); + *///? } else { browser.sendKeyPress(input.key(), input.scancode(), input.modifiers()); + //? } browser.setFocus(true); return true; } - @Override public boolean keyReleased(KeyEvent input) { if (super.keyReleased(input)) return true; if (browser == null) return false; + //? if >26.2 { + /*browser.sendKeyRelease(input.key(), input.key(), input.modifiers()); + *///? } else { browser.sendKeyRelease(input.key(), input.scancode(), input.modifiers()); + //? } browser.setFocus(true); return true; } - @Override public boolean charTyped(CharacterEvent input) { if (super.charTyped(input)) return true; @@ -430,14 +304,13 @@ public boolean charTyped(CharacterEvent input) { int cp = input.codepoint(); if (cp <= 0 || cp > 0xFFFF) return false; //? if >=26 { - browser.sendKeyTyped((char) cp, 0); - //? } else { + /*browser.sendKeyTyped((char) cp, 0); + *///? } else { browser.sendKeyTyped((char) cp, input.modifiers()); //? } browser.setFocus(true); return true; - }*/ - //? } + } //? } else { /*@Override public boolean keyPressed(int keyCode, int scanCode, int modifiers) { @@ -447,6 +320,10 @@ public boolean keyPressed(int keyCode, int scanCode, int modifiers) { if (browser == null) { return false; } + if (keyCode == WebGUIKeys.KEY_F12) { + WebGUIDevToolsWindow.open(browser); + return true; + } browser.sendKeyPress(keyCode, scanCode, modifiers); browser.setFocus(true); return true; @@ -481,7 +358,7 @@ public boolean charTyped(char chr, int modifiers) { //? if fabric { @Override - public boolean shouldPause() { + public boolean isPauseScreen() { return false; } //? } diff --git a/src/main/java/land/webgui/WebviewBrowserConsoleLogger.java b/src/main/java/land/webgui/WebviewBrowserConsoleLogger.java index 26050ca..96342ee 100644 --- a/src/main/java/land/webgui/WebviewBrowserConsoleLogger.java +++ b/src/main/java/land/webgui/WebviewBrowserConsoleLogger.java @@ -1,45 +1,27 @@ package land.webgui; -import org.cef.CefSettings; -import org.cef.browser.CefBrowser; -import org.cef.handler.CefDisplayHandlerAdapter; +import land.webgui.browser.WebBrowser; import org.slf4j.Logger; +import space.webgui.wmc.api.ConsoleLevel; -public final class WebviewBrowserConsoleLogger extends CefDisplayHandlerAdapter { +/** Puts what a page writes to its console into the game's log. */ +public final class WebviewBrowserConsoleLogger implements WebBrowser.Listener { private static final Logger LOG = WebGUIMod.LOGGER; @Override - public boolean onConsoleMessage( - CefBrowser browser, - CefSettings.LogSeverity level, - String message, - String source, - int line) { + public void onConsole(WebBrowser browser, ConsoleLevel level, String message, + String source, int line) { String text = formatMessage(message, source, line); if (level == null) { - level = CefSettings.LogSeverity.LOGSEVERITY_DEFAULT; + level = ConsoleLevel.INFO; } switch (level) { - case LOGSEVERITY_ERROR: - case LOGSEVERITY_FATAL: - LOG.error("[Web GUI] {}", text); - break; - case LOGSEVERITY_WARNING: - LOG.warn("[Web GUI] {}", text); - break; - case LOGSEVERITY_VERBOSE: - LOG.debug("[Web GUI] {}", text); - break; - case LOGSEVERITY_DISABLE: - break; - case LOGSEVERITY_DEFAULT: - case LOGSEVERITY_INFO: - default: - LOG.info("[Web GUI] {}", text); - break; + case ERROR -> LOG.error("[Web GUI] {}", text); + case WARNING -> LOG.warn("[Web GUI] {}", text); + case DEBUG -> LOG.debug("[Web GUI] {}", text); + default -> LOG.info("[Web GUI] {}", text); } - return false; } private static String formatMessage(String message, String source, int line) { diff --git a/src/main/java/land/webgui/WebviewClientBridge.java b/src/main/java/land/webgui/WebviewClientBridge.java index 36baedb..4e3e422 100644 --- a/src/main/java/land/webgui/WebviewClientBridge.java +++ b/src/main/java/land/webgui/WebviewClientBridge.java @@ -1,16 +1,10 @@ package land.webgui; -import de.keksuccino.rinku.Rinku; -import de.keksuccino.rinku.RinkuBrowser; +import land.webgui.browser.WebBrowser; import com.google.gson.Gson; import com.google.gson.JsonObject; -//? if fabric { -import net.minecraft.client.MinecraftClient; -import net.minecraft.client.network.ClientPlayerEntity; -//? } else { -/*import net.minecraft.client.Minecraft; -import net.minecraft.client.player.LocalPlayer;*/ -//? } +import net.minecraft.client.Minecraft; +import net.minecraft.client.player.LocalPlayer; public final class WebviewClientBridge { private static final Gson GSON = new Gson(); @@ -30,35 +24,21 @@ static void clearCache() { pendingEntityContextJson = null; } - //? if fabric { - public static void tick(MinecraftClient client) { - //? } else { - /*public static void tick(Minecraft client) {*/ - //? } + public static void tick(Minecraft client) { tryPush(client, true, false); } - //? if fabric { - /** Always pushes, regardless of dedup — page must get data on first load. */ - public static void pushAfterDocumentLoad(MinecraftClient client) { - //? } else { - /*public static void pushAfterDocumentLoad(Minecraft client) {*/ - //? } + public static void pushAfterDocumentLoad(Minecraft client) { tryPush(client, false, true); } - //? if fabric { - private static void tryPush(MinecraftClient client, boolean requireTexture, boolean ignoreDedup) { - ClientPlayerEntity player = client.player; - //? } else { - /*private static void tryPush(Minecraft client, boolean requireTexture, boolean ignoreDedup) { - LocalPlayer player = client.player;*/ - //? } - if (!Rinku.isInitialized()) return; + private static void tryPush(Minecraft client, boolean requireTexture, boolean ignoreDedup) { + LocalPlayer player = client.player; + if (!WebBrowser.ready()) return; if (player == null) return; - RinkuBrowser main = WebSession.browser(); - RinkuBrowser hud = WebSession.hudBrowser(); + WebBrowser main = WebSession.browser(); + WebBrowser hud = WebSession.hudBrowser(); // 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 @@ -92,7 +72,7 @@ private static void tryPush(MinecraftClient client, boolean requireTexture, bool if (hasHud) executeJs(hud, js); } - private static void executeJs(RinkuBrowser browser, String js) { + private static void executeJs(WebBrowser browser, String js) { try { String url = browser.getURL(); browser.executeJavaScript(js, url != null ? url : "", 0); @@ -110,172 +90,43 @@ private static String stripLeadingSlash(String address) { return address.startsWith("/") ? address.substring(1) : address; } - //? if fabric { - private static JsonObject buildPayload(MinecraftClient client, ClientPlayerEntity player) { + private static JsonObject buildPayload(Minecraft client, LocalPlayer player) { JsonObject o = new JsonObject(); - - o.addProperty("playerUuid", player.getUuid().toString()); - o.addProperty("username", player.getName().getString()); - o.addProperty("webviewMode", WebSession.mode().name()); - o.addProperty("dimension", player.getEntityWorld().getRegistryKey().getValue().toString()); - - o.addProperty("health", player.getHealth()); - o.addProperty("maxHealth", player.getMaxHealth()); - o.addProperty("food", player.getHungerManager().getFoodLevel()); - o.addProperty("xpLevel", player.experienceLevel); - var gameMode = client.interactionManager != null ? client.interactionManager.getCurrentGameMode() : null; - if (gameMode != null) o.addProperty("gamemode", gameMode.asString()); - - JsonObject pos = new JsonObject(); - pos.addProperty("x", player.getX()); - pos.addProperty("y", player.getY()); - pos.addProperty("z", player.getZ()); - o.add("pos", pos); - - JsonObject look = new JsonObject(); - look.addProperty("yaw", player.getYaw()); - 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; - JsonObject s = new JsonObject(); - var entry = client.getCurrentServerEntry(); - if (entry != null) { - s.addProperty("address", entry.address); - if (entry.ping >= 0) s.addProperty("ping", entry.ping); - } else { - var conn = nh.getConnection(); - if (conn != null && conn.getAddress() != null) { - s.addProperty("address", stripLeadingSlash(conn.getAddress().toString())); - } - } - return s; - } - //? } else { - /*private static JsonObject buildPayload(Minecraft client, LocalPlayer player) { - JsonObject o = new JsonObject(); - o.addProperty("playerUuid", player.getUUID().toString()); o.addProperty("username", player.getName().getString()); o.addProperty("webviewMode", WebSession.mode().name()); //? if >=1.21.5 { o.addProperty("dimension", player.level().dimension().identifier().toString()); //? } else { - o.addProperty("dimension", player.level().dimension().location().toString()); - //? } - + /*o.addProperty("dimension", player.level().dimension().location().toString()); + *///? } o.addProperty("health", player.getHealth()); o.addProperty("maxHealth", player.getMaxHealth()); o.addProperty("food", player.getFoodData().getFoodLevel()); o.addProperty("xpLevel", player.experienceLevel); var gameMode = client.gameMode != null ? client.gameMode.getPlayerMode() : null; if (gameMode != null) o.addProperty("gamemode", gameMode.getName()); - JsonObject pos = new JsonObject(); pos.addProperty("x", player.getX()); pos.addProperty("y", player.getY()); pos.addProperty("z", player.getZ()); o.add("pos", pos); - JsonObject look = new JsonObject(); look.addProperty("yaw", player.getYRot()); 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 @@ -287,9 +138,7 @@ private static JsonObject buildLookingAt(Minecraft client, LocalPlayer player) { 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(); @@ -303,7 +152,6 @@ private static JsonObject buildLookingAt(Minecraft client, LocalPlayer player) { .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"); @@ -317,11 +165,9 @@ private static JsonObject buildLookingAt(Minecraft client, LocalPlayer player) { 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; @@ -337,6 +183,5 @@ private static JsonObject buildServerInfo(Minecraft client) { } } return s; - }*/ - //? } + } } diff --git a/src/main/java/land/webgui/WebviewClientEmit.java b/src/main/java/land/webgui/WebviewClientEmit.java index 60323e1..042dffd 100644 --- a/src/main/java/land/webgui/WebviewClientEmit.java +++ b/src/main/java/land/webgui/WebviewClientEmit.java @@ -1,6 +1,6 @@ package land.webgui; -import de.keksuccino.rinku.RinkuBrowser; +import land.webgui.browser.WebBrowser; import com.google.gson.Gson; public final class WebviewClientEmit { @@ -10,8 +10,8 @@ private WebviewClientEmit() {} /** Called on the Minecraft client thread after receiving a WebviewEmitS2CPayload. */ public static void dispatch(String eventName, String jsonPayload) { - RinkuBrowser main = WebSession.browser(); - RinkuBrowser hud = WebSession.hudBrowser(); + WebBrowser main = WebSession.browser(); + WebBrowser hud = WebSession.hudBrowser(); if (main == null && hud == null) return; // GSON.toJson produces a properly escaped JS string literal, e.g. "\"my\\\"event\"" @@ -38,7 +38,7 @@ public static void dispatch(String eventName, String jsonPayload) { * something to read, exactly as {@code window.webgui.client} and {@code .entity} do. */ public static void dispatchDeath(String jsonPayload) { - RinkuBrowser main = WebSession.browser(); + WebBrowser main = WebSession.browser(); if (main == null) return; String data = (jsonPayload == null || jsonPayload.isBlank()) ? "null" : jsonPayload; String js = "(function(){" @@ -52,7 +52,7 @@ public static void dispatchDeath(String jsonPayload) { executeJs(main, js); } - private static void executeJs(RinkuBrowser browser, String js) { + private static void executeJs(WebBrowser browser, String js) { try { String url = browser.getURL(); browser.executeJavaScript(js, url != null ? url : "", 0); diff --git a/src/main/java/land/webgui/WebviewCommands.java b/src/main/java/land/webgui/WebviewCommands.java index f9ea6b3..61c6dfb 100644 --- a/src/main/java/land/webgui/WebviewCommands.java +++ b/src/main/java/land/webgui/WebviewCommands.java @@ -6,15 +6,12 @@ import land.webgui.server.WebviewServerConfig; //? if fabric { import net.fabricmc.fabric.api.command.v2.CommandRegistrationCallback; -import net.minecraft.command.argument.EntityArgumentType; -//? if >=1.21.5 { -import net.minecraft.command.DefaultPermissions; -//? } -import net.minecraft.entity.Entity; -import net.minecraft.server.command.CommandManager; -import net.minecraft.server.command.ServerCommandSource; -import net.minecraft.server.network.ServerPlayerEntity; -import net.minecraft.text.Text; +import net.minecraft.commands.arguments.EntityArgument; +import net.minecraft.world.entity.Entity; +import net.minecraft.commands.Commands; +import net.minecraft.commands.CommandSourceStack; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.network.chat.Component; //? } else { /*import net.minecraft.commands.Commands; import net.minecraft.commands.CommandSourceStack; @@ -32,23 +29,7 @@ public final class WebviewCommands { private WebviewCommands() {} - //? if fabric { - private static int bindEntities(com.mojang.brigadier.context.CommandContext ctx, boolean cancel) - throws com.mojang.brigadier.exceptions.CommandSyntaxException { - Collection entities = EntityArgumentType.getEntities(ctx, "selector"); - String url = StringArgumentType.getString(ctx, "url"); - EntityBinding binding = new EntityBinding(url, cancel); - for (Entity e : entities) { - EntityBindingStore.bind(e.getUuid(), binding); - } - final int count = entities.size(); - ctx.getSource().sendFeedback( - () -> Text.literal("WebGUI: bound " + count + " entity/entities → " + url), - true); - return count; - } - //? } else { - /*private static int bindEntities(com.mojang.brigadier.context.CommandContext ctx, boolean cancel) + private static int bindEntities(com.mojang.brigadier.context.CommandContext ctx, boolean cancel) throws com.mojang.brigadier.exceptions.CommandSyntaxException { Collection entities = EntityArgument.getEntities(ctx, "selector"); String url = StringArgumentType.getString(ctx, "url"); @@ -61,95 +42,82 @@ private static int bindEntities(com.mojang.brigadier.context.CommandContext Component.literal("WebGUI: bound " + count + " entity/entities -> " + url), true); return count; - }*/ - //? } - - //? if fabric { - //? if >=1.21.5 { - private static boolean hasOp2(ServerCommandSource s) { - return s.getPermissions().hasPermission(net.minecraft.command.DefaultPermissions.GAMEMASTERS); } - //? } else { - /*private static boolean hasOp2(ServerCommandSource s) { - return s.hasPermissionLevel(2); - }*/ - //? } - //? } else { - /*private static boolean hasOp2(CommandSourceStack s) { + + private static boolean hasOp2(CommandSourceStack s) { //? if >=1.21.5 { return net.minecraft.commands.Commands.LEVEL_GAMEMASTERS.check(s.permissions()); //? } else { - return s.hasPermission(2); - //? } - }*/ - //? } + /*return s.hasPermission(2); + *///? } + } //? if fabric { public static void register() { CommandRegistrationCallback.EVENT.register((dispatcher, registryAccess, environment) -> dispatcher.register( - CommandManager.literal("webgui") + Commands.literal("webgui") .requires(WebviewCommands::hasOp2) - .then(CommandManager.literal("gui") - .then(CommandManager.argument("targets", EntityArgumentType.players()) - .then(CommandManager.argument("url", StringArgumentType.greedyString()) + .then(Commands.literal("gui") + .then(Commands.argument("targets", EntityArgument.players()) + .then(Commands.argument("url", StringArgumentType.greedyString()) .executes(ctx -> { - Collection players = EntityArgumentType.getPlayers(ctx, "targets"); + Collection players = EntityArgument.getPlayers(ctx, "targets"); String url = StringArgumentType.getString(ctx, "url"); - for (ServerPlayerEntity p : players) { + for (ServerPlayer p : players) { WebviewNetworking.openGui(p, url); } - ctx.getSource().sendFeedback( - () -> Text.literal("Web GUI → " + players.size() + " player(s)"), + ctx.getSource().sendSuccess( + () -> Component.literal("Web GUI → " + players.size() + " player(s)"), true); return players.size(); })))) - .then(CommandManager.literal("bind") - .then(CommandManager.literal("entity") - .then(CommandManager.argument("selector", EntityArgumentType.entities()) - .then(CommandManager.argument("url", StringArgumentType.string()) + .then(Commands.literal("bind") + .then(Commands.literal("entity") + .then(Commands.argument("selector", EntityArgument.entities()) + .then(Commands.argument("url", StringArgumentType.string()) .executes(ctx -> bindEntities(ctx, false)) - .then(CommandManager.argument("cancel_interaction", BoolArgumentType.bool()) + .then(Commands.argument("cancel_interaction", BoolArgumentType.bool()) .executes(ctx -> bindEntities(ctx, BoolArgumentType.getBool(ctx, "cancel_interaction")))))))) - .then(CommandManager.literal("unbind") - .then(CommandManager.literal("entity") - .then(CommandManager.argument("selector", EntityArgumentType.entities()) + .then(Commands.literal("unbind") + .then(Commands.literal("entity") + .then(Commands.argument("selector", EntityArgument.entities()) .executes(ctx -> { - Collection entities = EntityArgumentType.getEntities(ctx, "selector"); + Collection entities = EntityArgument.getEntities(ctx, "selector"); int count = 0; for (Entity e : entities) { - if (EntityBindingStore.unbind(e.getUuid())) count++; + if (EntityBindingStore.unbind(e.getUUID())) count++; } final int removed = count; - ctx.getSource().sendFeedback( - () -> Text.literal("WebGUI: unbound " + removed + " entity/entities"), + ctx.getSource().sendSuccess( + () -> Component.literal("WebGUI: unbound " + removed + " entity/entities"), true); return removed; })))) - .then(CommandManager.literal("reload") + .then(Commands.literal("reload") .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()) { + for (ServerPlayer p : ctx.getSource().getServer().getPlayerList().getPlayers()) { WebviewNetworking.sendConfigSnapshot(p); } - ctx.getSource().sendFeedback(() -> Text.literal(msg), true); + ctx.getSource().sendSuccess(() -> Component.literal(msg), true); return 1; })) - .then(CommandManager.literal("hud") - .then(CommandManager.argument("targets", EntityArgumentType.players()) - .then(CommandManager.argument("url", StringArgumentType.greedyString()) + .then(Commands.literal("hud") + .then(Commands.argument("targets", EntityArgument.players()) + .then(Commands.argument("url", StringArgumentType.greedyString()) .executes(ctx -> { - Collection players = EntityArgumentType.getPlayers(ctx, "targets"); + Collection players = EntityArgument.getPlayers(ctx, "targets"); String url = StringArgumentType.getString(ctx, "url"); - for (ServerPlayerEntity p : players) { + for (ServerPlayer p : players) { WebviewNetworking.openHud(p, url); } - ctx.getSource().sendFeedback( - () -> Text.literal("Web HUD → " + players.size() + " player(s)"), + ctx.getSource().sendSuccess( + () -> Component.literal("Web HUD → " + players.size() + " player(s)"), true); return players.size(); })))))); diff --git a/src/main/java/land/webgui/WebviewDownloadHandler.java b/src/main/java/land/webgui/WebviewDownloadHandler.java index a8ec368..4672b59 100644 --- a/src/main/java/land/webgui/WebviewDownloadHandler.java +++ b/src/main/java/land/webgui/WebviewDownloadHandler.java @@ -1,24 +1,19 @@ 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; +import land.webgui.browser.WebBrowser; +import space.webgui.wmc.api.Download; /** - * Lets a page hand the player a file. + * Tells the player about a file a page handed them. * - * 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. + *

Saving it is the engine's job now, and so is choosing a safe name for it — a suggested + * name is the one part of a download that web content fully controls, and it is about to + * become a path. What is left here is the part that was always this mod's: saying so. A file + * that arrives with no trace is a file the player will never find, and the folder is not one + * they know about. * - * 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. + *

Where it lands is set once, in {@link land.webgui.browser.BrowserBoot}, rather than + * decided per download by a handler racing another mod's for the right to answer. */ public final class WebviewDownloadHandler { @@ -27,73 +22,19 @@ public final class WebviewDownloadHandler { 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; - } - + WebBrowser.addListener(new WebBrowser.Listener() { @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()); + public void onDownload(WebBrowser browser, Download download) { + if (download.done()) { + WebGUIMod.LOGGER.info("webgui: downloaded {}", download.path()); + land.webgui.compat.Mc.tell("Saved to " + FOLDER + "/" + fileName(String.valueOf(download.path()))); + } else if (download.canceled()) { + WebGUIMod.LOGGER.info("webgui: download cancelled ({})", download.url()); } } }); } - 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 "?"; @@ -102,25 +43,4 @@ private static String fileName(String fullPath) { 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/WebviewJoinHud.java b/src/main/java/land/webgui/WebviewJoinHud.java index c2ec8f1..3238e2a 100644 --- a/src/main/java/land/webgui/WebviewJoinHud.java +++ b/src/main/java/land/webgui/WebviewJoinHud.java @@ -1,12 +1,11 @@ package land.webgui; import land.webgui.server.WebviewServerConfig; +import net.minecraft.server.level.ServerPlayer; //? if fabric { import net.fabricmc.fabric.api.networking.v1.ServerPlayConnectionEvents; -import net.minecraft.server.network.ServerPlayerEntity; //? } else { -/*import net.minecraft.server.level.ServerPlayer; -import net.neoforged.neoforge.common.NeoForge; +/*import net.neoforged.neoforge.common.NeoForge; import net.neoforged.neoforge.event.entity.player.PlayerEvent;*/ //? } @@ -18,11 +17,15 @@ private WebviewJoinHud() {} public static void register() { ServerPlayConnectionEvents.JOIN.register( (handler, sender, server) -> { - ServerPlayerEntity player = handler.player; + ServerPlayer player = handler.player; if (player == null) { return; } + // Before the client check: this one is about the server's own version and + // is worth saying to an operator whose client has no WebGUI at all. + land.webgui.server.WebGUIUpdateChecker.notifyOnJoin(player); + // 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)) { @@ -46,7 +49,7 @@ public static void register() { ServerPlayConnectionEvents.DISCONNECT.register((handler, server) -> { if (handler.player != null) { - land.webgui.server.WebviewRateLimiter.forget(handler.player.getUuid()); + land.webgui.server.WebviewRateLimiter.forget(handler.player.getUUID()); } }); } @@ -60,6 +63,10 @@ public static void register() { private static void onPlayerJoin(PlayerEvent.PlayerLoggedInEvent event) { ServerPlayer player = (ServerPlayer) event.getEntity(); + // Before the client check: this one is about the server's own version and is worth + // saying to an operator whose client has no WebGUI at all. + land.webgui.server.WebGUIUpdateChecker.notifyOnJoin(player); + // 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)) { diff --git a/src/main/java/land/webgui/WebviewNetworking.java b/src/main/java/land/webgui/WebviewNetworking.java index 7388531..7d6efab 100644 --- a/src/main/java/land/webgui/WebviewNetworking.java +++ b/src/main/java/land/webgui/WebviewNetworking.java @@ -11,9 +11,9 @@ //? } else { /*import net.fabricmc.fabric.api.networking.v1.PacketByteBufs; import net.fabricmc.fabric.api.networking.v1.ServerPlayNetworking; -import net.minecraft.network.PacketByteBuf;*/ +import net.minecraft.network.FriendlyByteBuf;*/ //? } -import net.minecraft.server.network.ServerPlayerEntity; +import net.minecraft.server.level.ServerPlayer; //? } else { /*import net.minecraft.server.level.ServerPlayer; import net.neoforged.bus.api.IEventBus; @@ -42,17 +42,24 @@ private WebviewNetworking() {} //? if fabric { public static void registerPayloadTypes() { //? if >=1.20.5 { - PayloadTypeRegistry.playS2C().register(WebviewPayloads.OpenWebS2CPayload.ID, WebviewPayloads.OpenWebS2CPayload.CODEC); - PayloadTypeRegistry.playS2C().register(WebviewPayloads.WebUIMainMenuPayload.ID, WebviewPayloads.WebUIMainMenuPayload.CODEC); - 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); + //? if >=26 { + /*var toClient = PayloadTypeRegistry.clientboundPlay(); + var toServer = PayloadTypeRegistry.serverboundPlay(); + *///? } else { + var toClient = PayloadTypeRegistry.playS2C(); + var toServer = PayloadTypeRegistry.playC2S(); + //? } + toClient.register(WebviewPayloads.OpenWebS2CPayload.TYPE, WebviewPayloads.OpenWebS2CPayload.STREAM_CODEC); + toClient.register(WebviewPayloads.WebUIMainMenuPayload.TYPE, WebviewPayloads.WebUIMainMenuPayload.STREAM_CODEC); + toClient.register(WebviewPayloads.WebviewEmitS2CPayload.TYPE, WebviewPayloads.WebviewEmitS2CPayload.STREAM_CODEC); + toClient.register(WebviewPayloads.WebviewEntityContextS2CPayload.TYPE, WebviewPayloads.WebviewEntityContextS2CPayload.STREAM_CODEC); + toClient.register(WebviewPayloads.WebviewTrustedOriginsS2CPayload.TYPE, WebviewPayloads.WebviewTrustedOriginsS2CPayload.STREAM_CODEC); + toClient.register(WebviewPayloads.WebviewDeathS2CPayload.TYPE, WebviewPayloads.WebviewDeathS2CPayload.STREAM_CODEC); + toClient.register(WebviewPayloads.WebviewHelloS2CPayload.TYPE, WebviewPayloads.WebviewHelloS2CPayload.STREAM_CODEC); + toClient.register(WebviewPayloads.WebviewAssetManifestS2CPayload.TYPE, WebviewPayloads.WebviewAssetManifestS2CPayload.STREAM_CODEC); + toClient.register(WebviewPayloads.WebviewAssetChunkS2CPayload.TYPE, WebviewPayloads.WebviewAssetChunkS2CPayload.STREAM_CODEC); + toServer.register(WebviewPayloads.WebviewPageEventC2SPayload.TYPE, WebviewPayloads.WebviewPageEventC2SPayload.STREAM_CODEC); + toServer.register(WebviewPayloads.WebviewAssetRequestC2SPayload.TYPE, WebviewPayloads.WebviewAssetRequestC2SPayload.STREAM_CODEC); //? } } //? } else { @@ -128,21 +135,21 @@ public static void registerPayloadTypes() { //? if fabric { public static void registerServerReceivers() { //? if >=1.20.5 { - ServerPlayNetworking.registerGlobalReceiver(WebviewPayloads.WebviewPageEventC2SPayload.ID, (payload, context) -> { - ServerPlayerEntity player = context.player(); + ServerPlayNetworking.registerGlobalReceiver(WebviewPayloads.WebviewPageEventC2SPayload.TYPE, (payload, context) -> { + ServerPlayer 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())) { + 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(); + ServerPlayNetworking.registerGlobalReceiver(WebviewPayloads.WebviewAssetRequestC2SPayload.TYPE, (payload, context) -> { + ServerPlayer player = context.player(); if (player == null) { return; } @@ -152,18 +159,18 @@ public static void registerServerReceivers() { }); //? } 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); + String channel = buf.readUtf(WebviewPayloads.MAX_EVENT_NAME_LENGTH); + String json = buf.readUtf(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())) { + 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); + String path = buf.readUtf(WebviewPayloads.MAX_ASSET_PATH_LENGTH); if (player == null) { return; } @@ -178,40 +185,40 @@ public static void registerServerReceivers() { //? } //? if fabric { - public static void openGui(ServerPlayerEntity player, String url) { + public static void openGui(ServerPlayer player, String url) { clearEntityContext(player); //? if >=1.20.5 { ServerPlayNetworking.send(player, new WebviewPayloads.OpenWebS2CPayload(PROTOCOL_VERSION, MODE_GUI, withPlayerToken(player, url))); //? } else { - /*PacketByteBuf buf = PacketByteBufs.create(); + /*FriendlyByteBuf buf = PacketByteBufs.create(); buf.writeVarInt(PROTOCOL_VERSION); buf.writeVarInt(MODE_GUI); - buf.writeString(withPlayerToken(player, url), MAX_URL_LENGTH); + buf.writeUtf(withPlayerToken(player, url), MAX_URL_LENGTH); ServerPlayNetworking.send(player, WebviewPayloads.OPEN_WEB_CHANNEL, buf);*/ //? } } - public static void openHud(ServerPlayerEntity player, String url) { + public static void openHud(ServerPlayer player, String url) { clearEntityContext(player); //? if >=1.20.5 { ServerPlayNetworking.send(player, new WebviewPayloads.OpenWebS2CPayload(PROTOCOL_VERSION, MODE_HUD, withPlayerToken(player, url))); //? } else { - /*PacketByteBuf buf = PacketByteBufs.create(); + /*FriendlyByteBuf buf = PacketByteBufs.create(); buf.writeVarInt(PROTOCOL_VERSION); buf.writeVarInt(MODE_HUD); - buf.writeString(withPlayerToken(player, url), MAX_URL_LENGTH); + buf.writeUtf(withPlayerToken(player, url), MAX_URL_LENGTH); ServerPlayNetworking.send(player, WebviewPayloads.OPEN_WEB_CHANNEL, buf);*/ //? } } - public static void openGuiForEntity(ServerPlayerEntity player, String url, String entityJson) { + public static void openGuiForEntity(ServerPlayer player, String url, String entityJson) { //? if >=1.20.5 { ServerPlayNetworking.send(player, new WebviewPayloads.OpenWebS2CPayload(PROTOCOL_VERSION, MODE_GUI, withPlayerToken(player, url))); //? } else { - /*PacketByteBuf buf = PacketByteBufs.create(); + /*FriendlyByteBuf buf = PacketByteBufs.create(); buf.writeVarInt(PROTOCOL_VERSION); buf.writeVarInt(MODE_GUI); - buf.writeString(withPlayerToken(player, url), MAX_URL_LENGTH); + buf.writeUtf(withPlayerToken(player, url), MAX_URL_LENGTH); ServerPlayNetworking.send(player, WebviewPayloads.OPEN_WEB_CHANNEL, buf);*/ //? } // The context goes out after the open, not before it. Opening a gui builds a new @@ -221,39 +228,39 @@ public static void openGuiForEntity(ServerPlayerEntity player, String url, Strin sendEntityContext(player, entityJson); } - public static void sendEntityContext(ServerPlayerEntity player, String entityJson) { + public static void sendEntityContext(ServerPlayer player, String entityJson) { //? if >=1.20.5 { ServerPlayNetworking.send(player, new WebviewPayloads.WebviewEntityContextS2CPayload(entityJson)); //? } else { - /*PacketByteBuf buf = PacketByteBufs.create(); - buf.writeString(entityJson, WebviewPayloads.MAX_EVENT_DATA_LENGTH); + /*FriendlyByteBuf buf = PacketByteBufs.create(); + buf.writeUtf(entityJson, WebviewPayloads.MAX_EVENT_DATA_LENGTH); ServerPlayNetworking.send(player, WebviewPayloads.ENTITY_CONTEXT_CHANNEL, buf);*/ //? } } - public static void clearEntityContext(ServerPlayerEntity player) { + public static void clearEntityContext(ServerPlayer player) { sendEntityContext(player, "null"); } - public static void emitToPage(ServerPlayerEntity player, String eventName, String jsonPayload) { + public static void emitToPage(ServerPlayer player, String eventName, String jsonPayload) { String name = sanitizeStr(eventName, WebviewPayloads.MAX_EVENT_NAME_LENGTH); String data = (jsonPayload == null || jsonPayload.isBlank()) ? "null" : sanitizeStr(jsonPayload, WebviewPayloads.MAX_EVENT_DATA_LENGTH); //? if >=1.20.5 { ServerPlayNetworking.send(player, new WebviewPayloads.WebviewEmitS2CPayload(name, data)); //? } else { - /*PacketByteBuf buf = PacketByteBufs.create(); - buf.writeString(name, WebviewPayloads.MAX_EVENT_NAME_LENGTH); - buf.writeString(data, WebviewPayloads.MAX_EVENT_DATA_LENGTH); + /*FriendlyByteBuf buf = PacketByteBufs.create(); + buf.writeUtf(name, WebviewPayloads.MAX_EVENT_NAME_LENGTH); + buf.writeUtf(data, WebviewPayloads.MAX_EVENT_DATA_LENGTH); ServerPlayNetworking.send(player, WebviewPayloads.EMIT_TO_PAGE_CHANNEL, buf);*/ //? } } - public static void sendMainMenuUrl(ServerPlayerEntity player, String url) { + public static void sendMainMenuUrl(ServerPlayer player, String url) { //? if >=1.20.5 { ServerPlayNetworking.send(player, new WebviewPayloads.WebUIMainMenuPayload(sanitizeUrl(url))); //? } else { - /*PacketByteBuf buf = PacketByteBufs.create(); - buf.writeString(sanitizeUrl(url), MAX_URL_LENGTH); + /*FriendlyByteBuf buf = PacketByteBufs.create(); + buf.writeUtf(sanitizeUrl(url), MAX_URL_LENGTH); ServerPlayNetworking.send(player, WebviewPayloads.MAIN_MENU_CHANNEL, buf);*/ //? } } @@ -265,15 +272,15 @@ public static void sendMainMenuUrl(ServerPlayerEntity player, String url) { * 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) { + 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); //? 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); + /*FriendlyByteBuf buf = PacketByteBufs.create(); + buf.writeUtf(u, MAX_URL_LENGTH); + buf.writeUtf(info, WebviewPayloads.MAX_EVENT_DATA_LENGTH); ServerPlayNetworking.send(player, WebviewPayloads.DEATH_SCREEN_CHANNEL, buf);*/ //? } } @@ -282,9 +289,9 @@ public static void sendDeathScreen(ServerPlayerEntity player, String url, String * Tells the client which WebGUI the server runs. Silently skipped for a client that * has no such channel — that case is reported to the player as text instead. */ - public static void sendHello(ServerPlayerEntity player) { + public static void sendHello(ServerPlayer player) { //? if >=1.20.5 { - if (!ServerPlayNetworking.canSend(player, WebviewPayloads.WebviewHelloS2CPayload.ID)) { + if (!ServerPlayNetworking.canSend(player, WebviewPayloads.WebviewHelloS2CPayload.TYPE)) { return; } ServerPlayNetworking.send(player, new WebviewPayloads.WebviewHelloS2CPayload(PROTOCOL_VERSION, WebGUIVersion.current())); @@ -292,21 +299,21 @@ public static void sendHello(ServerPlayerEntity player) { /*if (!ServerPlayNetworking.canSend(player, WebviewPayloads.HELLO_CHANNEL)) { return; } - PacketByteBuf buf = PacketByteBufs.create(); + FriendlyByteBuf buf = PacketByteBufs.create(); buf.writeVarInt(PROTOCOL_VERSION); - buf.writeString(WebGUIVersion.current(), WebviewPayloads.MAX_VERSION_LENGTH); + buf.writeUtf(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) { + public static ClientKind clientKind(ServerPlayer player) { //? if >=1.20.5 { - if (ServerPlayNetworking.canSend(player, WebviewPayloads.WebviewHelloS2CPayload.ID)) { + if (ServerPlayNetworking.canSend(player, WebviewPayloads.WebviewHelloS2CPayload.TYPE)) { return ClientKind.CURRENT; } // No handshake channel but the original one is there: WebGUI, just an older build. - return ServerPlayNetworking.canSend(player, WebviewPayloads.OpenWebS2CPayload.ID) + return ServerPlayNetworking.canSend(player, WebviewPayloads.OpenWebS2CPayload.TYPE) ? ClientKind.OUTDATED : ClientKind.ABSENT; //? } else { @@ -320,10 +327,10 @@ public static ClientKind clientKind(ServerPlayerEntity player) { } /** Tells the client which pages this server ships. Skipped when it ships none. */ - public static void sendAssetManifest(ServerPlayerEntity player) { + public static void sendAssetManifest(ServerPlayer player) { //? if >=1.20.5 { if (!land.webgui.server.WebviewAssets.enabled() - || !ServerPlayNetworking.canSend(player, WebviewPayloads.WebviewAssetManifestS2CPayload.ID)) { + || !ServerPlayNetworking.canSend(player, WebviewPayloads.WebviewAssetManifestS2CPayload.TYPE)) { return; } land.webgui.server.WebviewAssetStore store = land.webgui.server.WebviewAssets.store(); @@ -335,9 +342,9 @@ public static void sendAssetManifest(ServerPlayerEntity player) { 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); + FriendlyByteBuf buf = PacketByteBufs.create(); + buf.writeUtf(store.revision(), WebviewPayloads.MAX_VERSION_LENGTH); + buf.writeUtf(store.toManifest(), WebviewPayloads.MAX_MANIFEST_LENGTH); ServerPlayNetworking.send(player, WebviewPayloads.ASSET_MANIFEST_CHANNEL, buf);*/ //? } } @@ -349,10 +356,10 @@ public static void sendAssetManifest(ServerPlayerEntity player) { * 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) { + 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); + player.getUUID(), player.getName().getString(), path); if (chunks.isEmpty()) { sendAssetChunk(player, path, 0, 0, new byte[0]); @@ -363,12 +370,12 @@ public static void serveAsset(ServerPlayerEntity player, String requested) { } } - private static void sendAssetChunk(ServerPlayerEntity player, String path, int index, int count, byte[] bytes) { + private static void sendAssetChunk(ServerPlayer 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); + /*FriendlyByteBuf buf = PacketByteBufs.create(); + buf.writeUtf(path, WebviewPayloads.MAX_ASSET_PATH_LENGTH); buf.writeVarInt(index); buf.writeVarInt(count); buf.writeByteArray(bytes); @@ -385,7 +392,7 @@ private static void sendAssetChunk(ServerPlayerEntity player, String path, int i * 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) { + 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. @@ -397,18 +404,18 @@ public static void sendConfigSnapshot(ServerPlayerEntity player) { sendMainMenuUrl(player, WebviewServerConfig.mainMenuUrl()); } - public static void sendTrustedOrigins(ServerPlayerEntity player, String origins) { + public static void sendTrustedOrigins(ServerPlayer player, String origins) { String o = origins == null ? "" : origins; //? if >=1.20.5 { ServerPlayNetworking.send(player, new WebviewPayloads.WebviewTrustedOriginsS2CPayload(o)); //? } else { - /*PacketByteBuf buf = PacketByteBufs.create(); - buf.writeString(o, WebviewPayloads.MAX_EVENT_DATA_LENGTH); + /*FriendlyByteBuf buf = PacketByteBufs.create(); + buf.writeUtf(o, WebviewPayloads.MAX_EVENT_DATA_LENGTH); ServerPlayNetworking.send(player, WebviewPayloads.TRUSTED_ORIGINS_CHANNEL, buf);*/ //? } } - private static String withPlayerToken(ServerPlayerEntity player, String url) { + 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 6b2b79d..14b2fb9 100644 --- a/src/main/java/land/webgui/WebviewPageLoadHooks.java +++ b/src/main/java/land/webgui/WebviewPageLoadHooks.java @@ -1,33 +1,26 @@ package land.webgui; -import de.keksuccino.rinku.Rinku; -import de.keksuccino.rinku.RinkuBrowser; -//? if fabric { -import net.minecraft.client.MinecraftClient; -//? } else { -/*import net.minecraft.client.Minecraft;*/ -//? } -import org.cef.browser.CefBrowser; -import org.cef.browser.CefFrame; -import org.cef.handler.CefLoadHandler; -import org.cef.handler.CefLoadHandlerAdapter; +import land.webgui.browser.WebBrowser; +import net.minecraft.client.Minecraft; public final class WebviewPageLoadHooks { private WebviewPageLoadHooks() {} public static void register() { - Rinku.getClient().addLoadHandler(new CefLoadHandlerAdapter() { + // Attached to every browser as it opens, rather than to the engine once: java-cef + // keeps a single load handler per client and the engine's own is in that slot. + WebBrowser.addListener(new WebBrowser.Listener() { @Override - public void onLoadStart(CefBrowser browser, CefFrame frame, - org.cef.network.CefRequest.TransitionType transitionType) { - RinkuBrowser active = WebSession.browser(); + public void onLoadStart(WebBrowser browser, String url) { + WebBrowser 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); + WebGUIBrowserTuning.applyOnce(active); // Before the document's own scripts, not after them. Injecting only at // load end meant a plain ", + "ftp://example.com/x", + "chrome://settings", + "example.com/no-scheme-at-all" }) { + assertEquals(REFUSED, WebGUIAssetServer.resolve(url), url + " must not be opened"); + } + } + + @Test + void refusesTheFormsThatUsedToBeAccepted() { + // webgui:x and webgui:/x were taken as the same thing as webgui://x. One spelling is + // easier to check than three, and these now say so rather than working by accident. + assertEquals(REFUSED, WebGUIAssetServer.resolve("webgui:index.html")); + assertEquals(REFUSED, WebGUIAssetServer.resolve("webgui:/index.html")); + } + + @Test + void nothingIsLeftAloneRatherThanRefused() { + assertNull(WebGUIAssetServer.resolve(null)); + assertEquals(" ", WebGUIAssetServer.resolve(" ")); + } +} diff --git a/src/test/java/land/webgui/WebGUIModMetadataTest.java b/src/test/java/land/webgui/WebGUIModMetadataTest.java index 222aa76..9d4774e 100644 --- a/src/test/java/land/webgui/WebGUIModMetadataTest.java +++ b/src/test/java/land/webgui/WebGUIModMetadataTest.java @@ -98,7 +98,11 @@ void declaresAuthorshipAndAnIconThatIsActuallyShipped() throws Exception { } if (neoforge != null) { assertContains(neoforge, "authors = \"KoSHeroff\"", "neoforge.mods.toml names the author"); - assertContains(neoforge, "logoFile = \"icon-256.png\"", "declares a logo file"); + // Either spelling: the build renames logoFile to iconFile from 26.3. What matters + // is that an icon is declared and that the file it names is shipped. + assertTrue(neoforge.contains("logoFile = \"icon-256.png\"") + || neoforge.contains("iconFile = \"icon-256.png\""), + "declares an icon file, under whichever key this version uses"); assertContains(neoforge, "displayURL", "links to the homepage"); assertContains(neoforge, "issueTrackerURL", "links to the issue tracker"); } diff --git a/src/test/java/space/webgui/wmc/api/ChannelPolicyTest.java b/src/test/java/space/webgui/wmc/api/ChannelPolicyTest.java new file mode 100644 index 0000000..e486586 --- /dev/null +++ b/src/test/java/space/webgui/wmc/api/ChannelPolicyTest.java @@ -0,0 +1,31 @@ +package space.webgui.wmc.api; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +class ChannelPolicyTest { + + @Test + void aPolicyThatNamesNobodyAllowsNobody() { + ChannelPolicy policy = ChannelPolicy.builder().build(); + assertFalse(policy.allows("https://example.com")); + assertFalse(policy.allows(null)); + } + + @Test + void onlyTheNamedOriginsGetThrough() { + ChannelPolicy policy = ChannelPolicy.builder().allowOrigin("app://shop").build(); + assertTrue(policy.allows("app://shop")); + assertFalse(policy.allows("app://shop.evil")); + assertFalse(policy.allows("https://shop")); + } + + @Test + void limitsMustBePositive() { + assertThrows(IllegalArgumentException.class, () -> ChannelPolicy.builder().perSecond(0)); + assertThrows(IllegalArgumentException.class, () -> ChannelPolicy.builder().maxPayload(-1)); + } +} diff --git a/src/test/java/space/webgui/wmc/api/ImeEventTest.java b/src/test/java/space/webgui/wmc/api/ImeEventTest.java new file mode 100644 index 0000000..b1539d3 --- /dev/null +++ b/src/test/java/space/webgui/wmc/api/ImeEventTest.java @@ -0,0 +1,28 @@ +package space.webgui.wmc.api; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +class ImeEventTest { + + @Test + void compositionIsNotACommit() { + assertFalse(ImeEvent.composing("\u306B\u307B", 2).committed()); + assertTrue(ImeEvent.commit("\u65E5\u672C").committed()); + } + + @Test + void commitPutsTheCaretAtTheEnd() { + assertEquals(2, ImeEvent.commit("ab").caret()); + } + + @Test + void aCaretOutsideTheTextIsRejected() { + assertThrows(IllegalArgumentException.class, () -> new ImeEvent("ab", 3, false)); + assertThrows(IllegalArgumentException.class, () -> new ImeEvent("ab", -1, false)); + } +} diff --git a/src/test/java/space/webgui/wmc/api/WebViewSpecTest.java b/src/test/java/space/webgui/wmc/api/WebViewSpecTest.java new file mode 100644 index 0000000..4b14ae3 --- /dev/null +++ b/src/test/java/space/webgui/wmc/api/WebViewSpecTest.java @@ -0,0 +1,32 @@ +package space.webgui.wmc.api; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +class WebViewSpecTest { + + @Test + void defaultsAreTransparentBecauseAViewIsDrawnOverTheGame() { + assertTrue(WebViewSpec.of("app://x/").isTransparent()); + } + + @Test + void settersDoNotMutateSoASpecCanBeATemplate() { + WebViewSpec template = WebViewSpec.of("app://x/"); + WebViewSpec derived = template.size(800, 600); + assertNotSame(template, derived); + assertEquals(1280, template.width()); + assertEquals(800, derived.width()); + } + + @Test + void refusesNonsenseRatherThanPassingItToChromium() { + assertThrows(IllegalArgumentException.class, () -> WebViewSpec.of("app://x/").size(0, 100)); + assertThrows(IllegalArgumentException.class, () -> WebViewSpec.of("app://x/").frameRate(0)); + assertThrows(IllegalArgumentException.class, () -> WebViewSpec.of("app://x/").frameRate(1000)); + } +} diff --git a/src/test/java/space/webgui/wmc/core/ArchivesTest.java b/src/test/java/space/webgui/wmc/core/ArchivesTest.java new file mode 100644 index 0000000..ca1686c --- /dev/null +++ b/src/test/java/space/webgui/wmc/core/ArchivesTest.java @@ -0,0 +1,162 @@ +package space.webgui.wmc.core; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.PosixFilePermission; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class ArchivesTest { + + @Test + void extractsFilesAndDirectories(@TempDir Path target) throws IOException { + byte[] archive = new TarGz().file("bin/run", "x").file("data/a.txt", "hello").build(); + + int written = Archives.extractTarGz(new ByteArrayInputStream(archive), target); + + assertEquals(2, written); + assertEquals("hello", Files.readString(target.resolve("data/a.txt"))); + } + + @Test + void refusesAnEntryThatWouldEscapeTheTarget(@TempDir Path target) throws IOException { + // The classic archive attack: without this check the entry lands outside the + // directory being extracted into, anywhere the process can write. + byte[] archive = new TarGz().file("../escaped.txt", "owned").build(); + + assertThrows(InstallException.class, + () -> Archives.extractTarGz(new ByteArrayInputStream(archive), target)); + assertFalse(Files.exists(target.getParent().resolve("escaped.txt"))); + } + + @Test + void keepsTheExecuteBit(@TempDir Path target) throws IOException { + assumeTrue(Files.getFileStore(target).supportsFileAttributeView("posix"), + "posix permissions are not a thing on this filesystem"); + byte[] archive = new TarGz().file("helper", "#!/bin/sh\n", 0755).build(); + + Archives.extractTarGz(new ByteArrayInputStream(archive), target); + + // A Chromium bundle ships helper executables. Lose this and the install looks + // complete and refuses to start, which is a long afternoon. + assertTrue(Files.getPosixFilePermissions(target.resolve("helper")) + .contains(PosixFilePermission.OWNER_EXECUTE)); + } + + @Test + void doesNotMakeEverythingExecutable(@TempDir Path target) throws IOException { + assumeTrue(Files.getFileStore(target).supportsFileAttributeView("posix")); + byte[] archive = new TarGz().file("data.pak", "x", 0644).build(); + + Archives.extractTarGz(new ByteArrayInputStream(archive), target); + + assertFalse(Files.getPosixFilePermissions(target.resolve("data.pak")) + .contains(PosixFilePermission.OWNER_EXECUTE)); + } + + @Test + void deletesRecursivelyWithoutComplaining(@TempDir Path target) throws IOException { + Files.createDirectories(target.resolve("a/b")); + Files.writeString(target.resolve("a/b/c.txt"), "x"); + + Archives.deleteRecursively(target.resolve("a")); + Archives.deleteRecursively(target.resolve("never-existed")); + + assertFalse(Files.exists(target.resolve("a"))); + } + + @Test + void unwrapsABundlePublishedInsideAJar(@TempDir Path target) throws IOException { + // How the artifacts on Maven Central are shaped: a jar, because that is what a Maven + // repository will host, with the real bundle as a single entry inside it. + byte[] bundle = new TarGz().file("libcef.so", "pretend").build(); + Path jar = target.resolve("natives.jar"); + Files.write(jar, Jars.holding("jcef-natives-linux-amd64.tar.gz", bundle)); + Path into = Files.createDirectories(target.resolve("into")); + + assertEquals(1, Archives.extractBundle(jar, into)); + assertEquals("pretend", Files.readString(into.resolve("libcef.so"))); + } + + @Test + void extractsAPlainBundleUnwrapped(@TempDir Path target) throws IOException { + Path archive = target.resolve("bundle.tar.gz"); + Files.write(archive, new TarGz().file("libcef.so", "pretend").build()); + Path into = Files.createDirectories(target.resolve("into")); + + assertEquals(1, Archives.extractBundle(archive, into)); + assertTrue(Files.isRegularFile(into.resolve("libcef.so"))); + } + + @Test + void refusesToGuessWhenAJarHoldsTwoBundles(@TempDir Path target) throws IOException { + byte[] bundle = new TarGz().file("libcef.so", "pretend").build(); + Path jar = target.resolve("ambiguous.jar"); + Files.write(jar, Jars.holding("one.tar.gz", bundle, "two.tar.gz", bundle)); + + assertThrows(InstallException.class, + () -> Archives.extractBundle(jar, Files.createDirectories(target.resolve("into")))); + } + + @Test + void refusesSomethingThatIsNeither(@TempDir Path target) throws IOException { + Path nonsense = target.resolve("nonsense.bin"); + Files.writeString(nonsense, "this is an html error page, not a bundle"); + + // What a proxy or a login wall serves instead of the file. Extracting it would fail + // somewhere deep in a decompressor with a message about neither. + assertThrows(InstallException.class, + () -> Archives.extractBundle(nonsense, Files.createDirectories(target.resolve("into")))); + } + + @Test + void readsANameTooLongForTheHeader() throws IOException { + // Over a hundred characters, so the writer moves the name into a PAX record and the + // header carries a stand-in. A reader that trusts the header alone extracts this + // under the wrong name, which is how half a Chromium bundle ends up missing. + String deep = "a-directory-with-a-long-name/".repeat(6) + "chrome-sandbox"; + assertTrue(deep.length() > 100); + Path target = Files.createTempDirectory("long-name"); + byte[] archive = new TarGz().file(deep, "x", 0755).build(); + + Archives.extractTarGz(new ByteArrayInputStream(archive), target); + + assertTrue(Files.isRegularFile(target.resolve(deep)), "the long name should survive"); + Archives.deleteRecursively(target); + } + + @Test + void keepsASymbolicLink(@TempDir Path target) throws IOException { + // A macOS framework is mostly these. Extracted as empty regular files, the bundle + // installs and Chromium will not start. + assumeTrue(!System.getProperty("os.name", "").toLowerCase(java.util.Locale.ROOT).contains("win"), + "Windows without developer mode refuses to create these"); + byte[] archive = new TarGz() + .file("Versions/A/Helper", "helper", 0755) + .symlink("Current", "Versions/A") + .build(); + + Archives.extractTarGz(new ByteArrayInputStream(archive), target); + + Path link = target.resolve("Current"); + assertTrue(Files.isSymbolicLink(link), "the entry should be extracted as a link"); + assertEquals("Versions/A", + Files.readSymbolicLink(link).toString().replace(java.io.File.separatorChar, '/')); + } + + @Test + void refusesALinkThatWouldPointOutOfTheTarget(@TempDir Path target) throws IOException { + byte[] archive = new TarGz().symlink("escape", "../../etc/passwd").build(); + + assertThrows(InstallException.class, + () -> Archives.extractTarGz(new ByteArrayInputStream(archive), target)); + } +} diff --git a/src/test/java/space/webgui/wmc/core/BundleLayoutTest.java b/src/test/java/space/webgui/wmc/core/BundleLayoutTest.java new file mode 100644 index 0000000..91db31b --- /dev/null +++ b/src/test/java/space/webgui/wmc/core/BundleLayoutTest.java @@ -0,0 +1,116 @@ +package space.webgui.wmc.core; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** + * Layout discovery against the three shapes a bundle actually comes in. + * + *

Built as directories of empty files rather than by unpacking a real bundle: what is being + * tested is whether the landmarks are recognised, and an empty file is as recognisable as a + * two hundred megabyte one. It also means the macOS shape is covered from any machine. + */ +class BundleLayoutTest { + + private static Path tree(Path root, String... entries) throws IOException { + for (String entry : entries) { + Path path = root.resolve(entry); + Files.createDirectories(path.getParent()); + Files.writeString(path, ""); + } + return root; + } + + private static Path windows(Path root) throws IOException { + return tree(root, "libcef.dll", "jcef.dll", "chrome_elf.dll", "jcef_helper.exe", + "icudtl.dat", "resources.pak", "locales/en-US.pak"); + } + + private static Path linux(Path root) throws IOException { + return tree(root, "libcef.so", "libjcef.so", "jcef_helper", + "icudtl.dat", "resources.pak", "locales/en-US.pak"); + } + + private static Path macos(Path root) throws IOException { + return tree(root, + "Chromium Embedded Framework.framework/Chromium Embedded Framework", + "Chromium Embedded Framework.framework/Resources/icudtl.dat", + "Chromium Embedded Framework.framework/Resources/en.lproj/locale.pak", + "libjcef.dylib", + "jcef Helper.app/Contents/MacOS/jcef Helper", + "jcef Helper (GPU).app/Contents/MacOS/jcef Helper (GPU)"); + } + + @Test + void findsEverythingInAWindowsBundle(@TempDir Path root) throws IOException { + BundleLayout layout = BundleLayout.discover(windows(root)); + + assertEquals(root.resolve("libcef.dll"), layout.library()); + assertEquals(root.resolve("jcef_helper.exe"), layout.helper()); + assertEquals(root, layout.resourcesDirectory()); + assertEquals(root.resolve("locales"), layout.localesDirectory().orElseThrow()); + assertEquals(root.resolve("jcef.dll"), layout.nativeLibrary("jcef").orElseThrow()); + } + + @Test + void findsEverythingInALinuxBundle(@TempDir Path root) throws IOException { + BundleLayout layout = BundleLayout.discover(linux(root)); + + assertEquals(root.resolve("libcef.so"), layout.library()); + assertEquals(root.resolve("jcef_helper"), layout.helper()); + assertEquals(root, layout.resourcesDirectory()); + assertTrue(layout.localesDirectory().isPresent()); + } + + @Test + void findsEverythingInAMacosBundle(@TempDir Path root) throws IOException { + BundleLayout layout = BundleLayout.discover(macos(root)); + + assertEquals(root.resolve("Chromium Embedded Framework.framework") + .resolve("Chromium Embedded Framework"), layout.library()); + // Resources live inside the framework there, not beside the library, which is exactly + // the difference a hard-coded per-platform table gets wrong. + assertEquals(root.resolve("Chromium Embedded Framework.framework").resolve("Resources"), + layout.resourcesDirectory()); + assertTrue(layout.localesDirectory().isEmpty(), + "macOS keeps locales inside the framework resources and CEF finds them itself"); + } + + @Test + void picksTheMainHelperRatherThanOneOfItsVariants(@TempDir Path root) throws IOException { + // A macOS bundle ships four helpers. Naming the GPU one as the subprocess path starts + // every renderer as a GPU process, which fails in a way that points nowhere useful. + BundleLayout layout = BundleLayout.discover(macos(root)); + + assertEquals("jcef Helper", layout.helper().getFileName().toString()); + } + + @Test + void saysWhatIsMissingRatherThanFailingLater(@TempDir Path root) throws IOException { + tree(root, "libcef.dll", "icudtl.dat"); + + InstallException e = assertThrows(InstallException.class, () -> BundleLayout.discover(root)); + assertTrue(e.getMessage().contains("helper"), e.getMessage()); + assertTrue(e.getMessage().contains(root.toString()), e.getMessage()); + } + + @Test + void refusesADirectoryThatIsNotThere(@TempDir Path root) { + assertThrows(InstallException.class, () -> BundleLayout.discover(root.resolve("nothing"))); + } + + @Test + void findsTheFrameworkOnMacosAndNowhereElse(@TempDir Path root) throws IOException { + assertEquals("Chromium Embedded Framework.framework", + BundleLayout.discover(macos(root)).frameworkDirectory().orElseThrow() + .getFileName().toString()); + assertTrue(BundleLayout.discover(windows(root.resolve("win"))).frameworkDirectory().isEmpty()); + } +} diff --git a/src/test/java/space/webgui/wmc/core/CefCursorsTest.java b/src/test/java/space/webgui/wmc/core/CefCursorsTest.java new file mode 100644 index 0000000..ec20e29 --- /dev/null +++ b/src/test/java/space/webgui/wmc/core/CefCursorsTest.java @@ -0,0 +1,54 @@ +package space.webgui.wmc.core; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.awt.Cursor; +import org.junit.jupiter.api.Test; + +/** + * Which cursor the page asked for. + * + *

Worth a test for a reason that is not obvious from the code: the number java-cef reports + * is an AWT cursor, and it was read as Chromium's own list first. Both lists start with the + * ordinary arrow at zero and disagree about nearly everything after it, so the mistake is + * invisible in a screenshot — the cursor changes as the mouse moves, and is the wrong shape + * everywhere. What gave it away was that {@code text} never once appeared. + */ +class CefCursorsTest { + + @Test + void theHandIsWhatCssCallsPointer() { + // The one that matters most: it is what says a thing can be clicked. Read as + // Chromium's list this number is a resize arrow. + assertEquals("pointer", CefCursors.cssName(Cursor.HAND_CURSOR)); + } + + @Test + void theBeamOverTextIsText() { + // And read as Chromium's list, this one is the hand - the other half of the swap. + assertEquals("text", CefCursors.cssName(Cursor.TEXT_CURSOR)); + } + + @Test + void theArrowIsTheDefault() { + assertEquals("default", CefCursors.cssName(Cursor.DEFAULT_CURSOR)); + } + + @Test + void theResizeArrowsKeepTheirDirections() { + assertEquals("e-resize", CefCursors.cssName(Cursor.E_RESIZE_CURSOR)); + assertEquals("w-resize", CefCursors.cssName(Cursor.W_RESIZE_CURSOR)); + assertEquals("n-resize", CefCursors.cssName(Cursor.N_RESIZE_CURSOR)); + assertEquals("nw-resize", CefCursors.cssName(Cursor.NW_RESIZE_CURSOR)); + assertEquals("move", CefCursors.cssName(Cursor.MOVE_CURSOR)); + } + + @Test + void anythingElseIsAnArrowRatherThanAGuess() { + // AWT has fourteen shapes and CSS has thirty. A number outside the list is either a + // cursor AWT has no name for or a library that has changed underneath; either way the + // arrow is right and inventing a name is not. + assertEquals("default", CefCursors.cssName(99)); + assertEquals("default", CefCursors.cssName(-1)); + } +} diff --git a/src/test/java/space/webgui/wmc/core/GlfwKeysTest.java b/src/test/java/space/webgui/wmc/core/GlfwKeysTest.java new file mode 100644 index 0000000..fc88de8 --- /dev/null +++ b/src/test/java/space/webgui/wmc/core/GlfwKeysTest.java @@ -0,0 +1,91 @@ +package space.webgui.wmc.core; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; + +import java.awt.event.InputEvent; +import java.awt.event.KeyEvent; +import org.junit.jupiter.api.Test; + +/** + * The key translation, which has no other way of being checked. + * + *

A wrong entry here is not a crash. It is a key that does nothing in a text field, or a + * shortcut that fires the wrong thing, found by a player rather than by a build — and there is + * no test that can be written in a game for "backspace deletes a character" that is cheaper + * than this one. + */ +class GlfwKeysTest { + + @Test + void lettersAndDigitsPassStraightThrough() { + // GLFW numbers these by the character on the key and so does AWT, which is why most of + // a keyboard needs no table at all. + assertEquals(KeyEvent.VK_A, GlfwKeys.toAwtKey('A')); + assertEquals(KeyEvent.VK_Z, GlfwKeys.toAwtKey('Z')); + assertEquals(KeyEvent.VK_0, GlfwKeys.toAwtKey('0')); + assertEquals(KeyEvent.VK_9, GlfwKeys.toAwtKey('9')); + } + + @Test + void theKeysATextFieldActuallyNeeds() { + assertEquals(KeyEvent.VK_BACK_SPACE, GlfwKeys.toAwtKey(259)); + assertEquals(KeyEvent.VK_DELETE, GlfwKeys.toAwtKey(261)); + assertEquals(KeyEvent.VK_ENTER, GlfwKeys.toAwtKey(257)); + assertEquals(KeyEvent.VK_TAB, GlfwKeys.toAwtKey(258)); + assertEquals(KeyEvent.VK_LEFT, GlfwKeys.toAwtKey(263)); + assertEquals(KeyEvent.VK_RIGHT, GlfwKeys.toAwtKey(262)); + assertEquals(KeyEvent.VK_HOME, GlfwKeys.toAwtKey(268)); + assertEquals(KeyEvent.VK_END, GlfwKeys.toAwtKey(269)); + assertEquals(KeyEvent.VK_ESCAPE, GlfwKeys.toAwtKey(256)); + } + + @Test + void bothSidesOfTheKeyboardAreTheSameKey() { + // Left and right shift are different GLFW codes and one AWT key. A table that only + // covers the left one leaves half a keyboard not holding shift. + assertEquals(KeyEvent.VK_SHIFT, GlfwKeys.toAwtKey(340)); + assertEquals(KeyEvent.VK_SHIFT, GlfwKeys.toAwtKey(344)); + assertEquals(KeyEvent.VK_CONTROL, GlfwKeys.toAwtKey(341)); + assertEquals(KeyEvent.VK_CONTROL, GlfwKeys.toAwtKey(345)); + assertEquals(KeyEvent.VK_ALT, GlfwKeys.toAwtKey(342)); + assertEquals(KeyEvent.VK_ALT, GlfwKeys.toAwtKey(346)); + } + + @Test + void theNumberPadIsContiguous() { + for (int digit = 0; digit <= 9; digit++) { + assertEquals(KeyEvent.VK_NUMPAD0 + digit, GlfwKeys.toAwtKey(320 + digit), + "numpad " + digit); + } + assertEquals(KeyEvent.VK_ENTER, GlfwKeys.toAwtKey(335), "the numpad enter is still enter"); + } + + @Test + void functionKeysCoverF1ToF12() { + for (int index = 0; index < 12; index++) { + assertNotEquals(KeyEvent.VK_UNDEFINED, GlfwKeys.toAwtKey(290 + index), "F" + (index + 1)); + } + assertEquals(KeyEvent.VK_F1, GlfwKeys.toAwtKey(290)); + assertEquals(KeyEvent.VK_F12, GlfwKeys.toAwtKey(301)); + } + + @Test + void anythingUnknownIsUndefinedRatherThanWrong() { + // Better than a plausible-looking guess: an undefined key still delivers its character + // through typing, which is what a text field reads. A wrong key code does not. + assertEquals(KeyEvent.VK_UNDEFINED, GlfwKeys.toAwtKey(9999)); + assertEquals(KeyEvent.VK_UNDEFINED, GlfwKeys.toAwtKey(-1)); + } + + @Test + void modifiersBecomeTheMasksJavaCefReads() { + assertEquals(0, GlfwKeys.toAwtModifiers(0)); + assertEquals(InputEvent.SHIFT_DOWN_MASK, GlfwKeys.toAwtModifiers(0x1)); + assertEquals(InputEvent.CTRL_DOWN_MASK, GlfwKeys.toAwtModifiers(0x2)); + assertEquals(InputEvent.ALT_DOWN_MASK, GlfwKeys.toAwtModifiers(0x4)); + assertEquals(InputEvent.META_DOWN_MASK, GlfwKeys.toAwtModifiers(0x8)); + assertEquals(InputEvent.SHIFT_DOWN_MASK | InputEvent.CTRL_DOWN_MASK, + GlfwKeys.toAwtModifiers(0x3)); + } +} diff --git a/src/test/java/space/webgui/wmc/core/Jars.java b/src/test/java/space/webgui/wmc/core/Jars.java new file mode 100644 index 0000000..7418d2c --- /dev/null +++ b/src/test/java/space/webgui/wmc/core/Jars.java @@ -0,0 +1,37 @@ +package space.webgui.wmc.core; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +/** Wraps bundles in a jar the way the published artifacts do. */ +final class Jars { + + private Jars() { + } + + /** A jar holding the given entries, each a name and its bytes. */ + static byte[] holding(String name, byte[] content) throws IOException { + return holding(name, content, null, null); + } + + static byte[] holding(String first, byte[] firstContent, String second, byte[] secondContent) + throws IOException { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + try (ZipOutputStream zip = new ZipOutputStream(bytes)) { + zip.putNextEntry(new ZipEntry("META-INF/MANIFEST.MF")); + zip.write("Manifest-Version: 1.0\n".getBytes(java.nio.charset.StandardCharsets.UTF_8)); + zip.closeEntry(); + zip.putNextEntry(new ZipEntry(first)); + zip.write(firstContent); + zip.closeEntry(); + if (second != null) { + zip.putNextEntry(new ZipEntry(second)); + zip.write(secondContent); + zip.closeEntry(); + } + } + return bytes.toByteArray(); + } +} diff --git a/src/test/java/space/webgui/wmc/core/NativeBundlesTest.java b/src/test/java/space/webgui/wmc/core/NativeBundlesTest.java new file mode 100644 index 0000000..833b93a --- /dev/null +++ b/src/test/java/space/webgui/wmc/core/NativeBundlesTest.java @@ -0,0 +1,67 @@ +package space.webgui.wmc.core; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Map; +import org.junit.jupiter.api.Test; +import space.webgui.wmc.api.Platform; + +class NativeBundlesTest { + + @Test + void everyBundleIsPinnedToADigestAndASize() { + assertTrue(NativeBundles.all().size() >= 6); + for (Map.Entry entry : NativeBundles.all().entrySet()) { + NativeArtifact artifact = entry.getValue(); + assertEquals(entry.getKey(), artifact.platform()); + assertEquals(NativeBundles.CEF_VERSION, artifact.cefVersion()); + assertTrue(artifact.sha256().matches("[0-9a-f]{64}"), + entry.getKey() + " has no usable digest: " + artifact.sha256()); + assertTrue(artifact.sizeBytes() > 50_000_000L, + entry.getKey() + " is implausibly small for a Chromium bundle"); + } + } + + @Test + void theVersionsAgreeWithEachOther() { + // Two places name the build, and an upgrade that changes one and not the other would + // install a bundle into a directory named after a different Chromium. + assertTrue(NativeBundles.JCEF_VERSION.contains("cef-" + NativeBundles.CEF_VERSION), + NativeBundles.JCEF_VERSION + " does not contain cef-" + NativeBundles.CEF_VERSION); + } + + @Test + void thisMachineIsOneOfThem() { + // Runs on Windows, Linux and macOS in CI, so this is three assertions in one: the + // platform string the JVM produces has to be a key in the table on each of them. + assertNotNull(NativeBundles.forThisMachine()); + assertEquals(Platform.current(), NativeBundles.forThisMachine().platform()); + } + + @Test + void anUnsupportedMachineIsAnEmptyAnswerRatherThanAWrongBundle() { + assertTrue(NativeBundles.forPlatform("solaris-sparc").isEmpty()); + assertTrue(NativeBundles.forPlatform("linux-i386").isEmpty(), + "32-bit Linux is not published, and quietly handing back the 64-bit bundle" + + " would fail much later with nothing pointing here"); + } + + @Test + void theTableCannotBeChangedByItsCallers() { + assertThrows(UnsupportedOperationException.class, + () -> NativeBundles.all().remove("linux-amd64")); + } + + @Test + void theClassesAndTheBundlesAreTheSameBuild() { + // The jar of Java classes is the JNI declaration of the libraries in the bundle. + // Upgrading one without the other links at build time and fails at the first native + // call, with a message about a missing symbol rather than about a version. + String fromBuild = System.getProperty("wmc.jcefVersion"); + assertNotNull(fromBuild, "the build should pass the version it resolves jcef-api at"); + assertEquals(fromBuild, NativeBundles.JCEF_VERSION); + } +} diff --git a/src/test/java/space/webgui/wmc/core/RuntimePathsTest.java b/src/test/java/space/webgui/wmc/core/RuntimePathsTest.java new file mode 100644 index 0000000..be7115d --- /dev/null +++ b/src/test/java/space/webgui/wmc/core/RuntimePathsTest.java @@ -0,0 +1,54 @@ +package space.webgui.wmc.core; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.file.Path; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class RuntimePathsTest { + + private static RuntimePaths.Environment of(Map values) { + return values::get; + } + + @Test + void theEnvironmentOverridesEverythingForOfflineMachines() { + Path root = RuntimePaths.defaultRuntimeRoot( + of(Map.of(RuntimePaths.RUNTIME_DIR_ENV, "/opt/wmc")), + of(Map.of("user.home", "/home/someone", "os.name", "Linux"))); + + assertEquals(Path.of("/opt/wmc"), root); + } + + @Test + void fallsBackToTheHomeDirectory() { + Path root = RuntimePaths.defaultRuntimeRoot( + of(Map.of()), + of(Map.of("user.home", "/home/someone", "os.name", "Linux"))); + + assertEquals(Path.of("/home/someone/.wmc/runtime"), root); + } + + @Test + void windowsUsesLocalAppDataWhereSuchThingsBelong() { + Path root = RuntimePaths.defaultRuntimeRoot( + of(Map.of("LOCALAPPDATA", "C:\\Users\\someone\\AppData\\Local")), + of(Map.of("user.home", "C:\\Users\\someone", "os.name", "Windows 11"))); + + assertTrue(root.toString().contains("AppData"), root.toString()); + assertTrue(root.endsWith(Path.of("wmc", "runtime")), root.toString()); + } + + @Test + void theCacheLivesWithTheGameAndTheRuntimeDoesNot(@TempDir Path game) { + RuntimePaths paths = RuntimePaths.standard(game); + + // Chromium locks its cache, so two instances cannot share one. The runtime is + // hundreds of megabytes and identical everywhere, so they must. + assertTrue(paths.cacheRoot().startsWith(game.toAbsolutePath())); + assertTrue(!paths.runtimeRoot().startsWith(game.toAbsolutePath())); + } +} diff --git a/src/test/java/space/webgui/wmc/core/TarGz.java b/src/test/java/space/webgui/wmc/core/TarGz.java new file mode 100644 index 0000000..19ae065 --- /dev/null +++ b/src/test/java/space/webgui/wmc/core/TarGz.java @@ -0,0 +1,58 @@ +package space.webgui.wmc.core; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import org.apache.commons.compress.archivers.tar.TarArchiveEntry; +import org.apache.commons.compress.archivers.tar.TarArchiveOutputStream; +import org.apache.commons.compress.compressors.gzip.GzipCompressorOutputStream; + +/** Builds small archives in memory, so the installer can be tested without downloading one. */ +final class TarGz { + + private final ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + private final TarArchiveOutputStream tar; + + TarGz() throws IOException { + this.tar = new TarArchiveOutputStream(new GzipCompressorOutputStream(bytes)); + tar.setLongFileMode(TarArchiveOutputStream.LONGFILE_POSIX); + } + + TarGz file(String name, String content, int mode) throws IOException { + byte[] data = content.getBytes(StandardCharsets.UTF_8); + TarArchiveEntry entry = new TarArchiveEntry(name); + entry.setSize(data.length); + entry.setMode(mode); + tar.putArchiveEntry(entry); + tar.write(data); + tar.closeArchiveEntry(); + return this; + } + + TarGz file(String name, String content) throws IOException { + return file(name, content, 0644); + } + + TarGz directory(String name) throws IOException { + TarArchiveEntry entry = new TarArchiveEntry(name.endsWith("/") ? name : name + "/"); + entry.setMode(0755); + tar.putArchiveEntry(entry); + tar.closeArchiveEntry(); + return this; + } + + TarGz symlink(String name, String target) throws IOException { + TarArchiveEntry entry = new TarArchiveEntry(name, TarArchiveEntry.LF_SYMLINK); + entry.setLinkName(target); + entry.setMode(0777); + tar.putArchiveEntry(entry); + tar.closeArchiveEntry(); + return this; + } + + byte[] build() throws IOException { + tar.finish(); + tar.close(); + return bytes.toByteArray(); + } +} diff --git a/src/test/java/space/webgui/wmc/core/TarTest.java b/src/test/java/space/webgui/wmc/core/TarTest.java new file mode 100644 index 0000000..b658b2e --- /dev/null +++ b/src/test/java/space/webgui/wmc/core/TarTest.java @@ -0,0 +1,147 @@ +package space.webgui.wmc.core; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.zip.GZIPInputStream; +import org.junit.jupiter.api.Test; + +/** + * The reader on its own, against archives a real tar writer produced. + * + *

Separate from {@link ArchivesTest} because what that one checks — the execute bit, a + * symbolic link — cannot be checked on a filesystem that has neither, so it is skipped on + * Windows, which is where this is most likely to be run. What a header says is readable + * anywhere, and the header is the part written here; applying it to a file is not. + */ +class TarTest { + + private static Tar open(byte[] archive) throws IOException { + return new Tar(new GZIPInputStream(new ByteArrayInputStream(archive))); + } + + @Test + void readsNamesSizesAndContentInOrder() throws IOException { + byte[] archive = new TarGz() + .file("first.txt", "one") + .file("nested/second.txt", "twotwo") + .build(); + + try (Tar tar = open(archive)) { + Tar.Entry first = tar.next(); + assertEquals("first.txt", first.name()); + assertEquals(3, first.size()); + assertEquals("one", new String(tar.readAllBytes(), StandardCharsets.UTF_8)); + + Tar.Entry second = tar.next(); + assertEquals("nested/second.txt", second.name()); + assertEquals("twotwo", new String(tar.readAllBytes(), StandardCharsets.UTF_8)); + + assertNull(tar.next(), "the archive should end after its entries"); + } + } + + @Test + void stopsReadingAtTheEndOfAnEntry() throws IOException { + // The whole point of bounding the stream: handed to Files.copy, an entry must not + // run on into the padding and the next header. + byte[] archive = new TarGz().file("a", "aaa").file("b", "bbb").build(); + + try (Tar tar = open(archive)) { + tar.next(); + assertEquals("aaa", new String(tar.readAllBytes(), StandardCharsets.UTF_8)); + assertEquals(-1, tar.read(), "the entry should be exhausted"); + assertEquals("b", tar.next().name()); + } + } + + @Test + void skipsAnEntryNobodyRead() throws IOException { + // A directory or a link is never read from, and the bytes of a file may be skipped + // too; the next header still has to be found. + byte[] archive = new TarGz().file("big", "x".repeat(1500)).file("after", "y").build(); + + try (Tar tar = open(archive)) { + assertEquals("big", tar.next().name()); + assertEquals("after", tar.next().name(), "an unread entry should be stepped over"); + } + } + + @Test + void keepsTheMode() throws IOException { + byte[] archive = new TarGz() + .file("chrome-sandbox", "#!", 0755) + .file("icudtl.dat", "data", 0644) + .build(); + + try (Tar tar = open(archive)) { + assertEquals(0755, tar.next().mode(), "an executable's mode should survive"); + assertEquals(0644, tar.next().mode()); + } + } + + @Test + void readsASymbolicLinkAndWhereItPoints() throws IOException { + byte[] archive = new TarGz().symlink("Current", "Versions/A").build(); + + try (Tar tar = open(archive)) { + Tar.Entry link = tar.next(); + assertTrue(link.isSymbolicLink(), "the type flag should say it is a link"); + assertFalse(link.isFile()); + assertEquals("Versions/A", link.linkName()); + } + } + + @Test + void readsADirectory() throws IOException { + byte[] archive = new TarGz().directory("locales").file("locales/en-US.pak", "p").build(); + + try (Tar tar = open(archive)) { + Tar.Entry directory = tar.next(); + assertTrue(directory.isDirectory()); + assertFalse(directory.isFile()); + assertEquals("locales/en-US.pak", tar.next().name()); + } + } + + @Test + void readsANameThatDoesNotFitTheHeader() throws IOException { + // Past a hundred characters the writer moves the name out of the header into a PAX + // record, and the header carries a stand-in name instead. Reading the header alone + // gives the wrong path for a good part of a Chromium bundle. + String deep = "a-directory-with-a-long-name/".repeat(6) + "chrome-sandbox"; + assertTrue(deep.length() > 100); + byte[] archive = new TarGz().file(deep, "x", 0755).build(); + + try (Tar tar = open(archive)) { + Tar.Entry entry = tar.next(); + assertEquals(deep, entry.name()); + assertEquals(0755, entry.mode(), "the mode belongs to the entry, not to the record"); + } + } + + @Test + void readsALinkTargetThatDoesNotFitTheHeader() throws IOException { + String deep = "Versions/" + "a-long-framework-directory/".repeat(5) + "Helper"; + assertTrue(deep.length() > 100); + byte[] archive = new TarGz().symlink("Current", deep).build(); + + try (Tar tar = open(archive)) { + Tar.Entry link = tar.next(); + assertTrue(link.isSymbolicLink()); + assertEquals(deep, link.linkName()); + } + } + + @Test + void endsOnAnArchiveWithNothingInIt() throws IOException { + try (Tar tar = open(new TarGz().build())) { + assertNull(tar.next()); + } + } +} diff --git a/versions/1.20.1-fabric/gradle.properties b/versions/1.20.1-fabric/gradle.properties index b53c61a..c0946c9 100644 --- a/versions/1.20.1-fabric/gradle.properties +++ b/versions/1.20.1-fabric/gradle.properties @@ -1,8 +1,6 @@ minecraft_version=1.20.1 -yarn_mappings=1.20.1+build.10 loader_version=0.19.5 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.20.1-neoforge/gradle.properties b/versions/1.20.1-neoforge/gradle.properties index 5ce3888..4b0a8bd 100644 --- a/versions/1.20.1-neoforge/gradle.properties +++ b/versions/1.20.1-neoforge/gradle.properties @@ -1,5 +1,4 @@ minecraft_version=1.20.1 loader_type=neoforge neoforge_version=47.3.0 -rinku_version=3.0.4-1.20.1-forge minecraft_range=[1.20.1] diff --git a/versions/1.21.1-fabric/gradle.properties b/versions/1.21.1-fabric/gradle.properties index 8e85f5d..a0c1c44 100644 --- a/versions/1.21.1-fabric/gradle.properties +++ b/versions/1.21.1-fabric/gradle.properties @@ -1,8 +1,6 @@ minecraft_version=1.21.1 -yarn_mappings=1.21.1+build.3 loader_version=0.19.5 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.1-neoforge/gradle.properties b/versions/1.21.1-neoforge/gradle.properties index f6e27a8..1c2f08d 100644 --- a/versions/1.21.1-neoforge/gradle.properties +++ b/versions/1.21.1-neoforge/gradle.properties @@ -1,5 +1,4 @@ minecraft_version=1.21.1 loader_type=neoforge -neoforge_version=21.1.250 -rinku_version=3.0.4-1.21.1-neoforge +neoforge_version=21.1.259 minecraft_range=[1.21,1.21.1] diff --git a/versions/1.21.11-fabric/gradle.properties b/versions/1.21.11-fabric/gradle.properties index eb81029..87208b7 100644 --- a/versions/1.21.11-fabric/gradle.properties +++ b/versions/1.21.11-fabric/gradle.properties @@ -1,8 +1,6 @@ minecraft_version=1.21.11 -yarn_mappings=1.21.11+build.6 loader_version=0.19.5 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 +modmenu_version=17.0.1 diff --git a/versions/1.21.11-neoforge/gradle.properties b/versions/1.21.11-neoforge/gradle.properties index 9fb04a6..12c23f3 100644 --- a/versions/1.21.11-neoforge/gradle.properties +++ b/versions/1.21.11-neoforge/gradle.properties @@ -1,5 +1,4 @@ minecraft_version=1.21.11 loader_type=neoforge neoforge_version=21.11.45 -rinku_version=3.0.4-1.21.11-neoforge minecraft_range=[1.21.5,1.21.11] diff --git a/versions/26.1.2-neoforge/gradle.properties b/versions/26.1.2-neoforge/gradle.properties index 1ef1c8e..6094e67 100644 --- a/versions/26.1.2-neoforge/gradle.properties +++ b/versions/26.1.2-neoforge/gradle.properties @@ -1,6 +1,5 @@ minecraft_version=26.1.2 loader_type=neoforge -neoforge_version=26.1.2.107 -rinku_version=3.0.4-26.1.2-neoforge +neoforge_version=26.1.2.116 minecraft_range=[26.1.2,26.1.2] java_version=25 diff --git a/versions/26.2-fabric/gradle.properties b/versions/26.2-fabric/gradle.properties new file mode 100644 index 0000000..b0542f4 --- /dev/null +++ b/versions/26.2-fabric/gradle.properties @@ -0,0 +1,9 @@ +minecraft_version=26.2 +loader_type=fabric +loader_version=0.19.5 +fabric_version=0.161.0+26.2 +modmenu_version=20.0.3 +minecraft_range=>=26.2 <=26.2 +java_version=25 +# From 26.1 the game ships unobfuscated: no mappings, no remapping. +unobfuscated=true diff --git a/versions/26.2-neoforge/gradle.properties b/versions/26.2-neoforge/gradle.properties index 1636ad0..3f4d9d1 100644 --- a/versions/26.2-neoforge/gradle.properties +++ b/versions/26.2-neoforge/gradle.properties @@ -1,6 +1,5 @@ minecraft_version=26.2 loader_type=neoforge -neoforge_version=26.2.0.82 -rinku_version=3.0.4-26.2-neoforge +neoforge_version=26.2.0.90 minecraft_range=[26.2,26.2] java_version=25 diff --git a/versions/26.3-fabric/gradle.properties b/versions/26.3-fabric/gradle.properties new file mode 100644 index 0000000..e0cca34 --- /dev/null +++ b/versions/26.3-fabric/gradle.properties @@ -0,0 +1,9 @@ +minecraft_version=26.3 +loader_type=fabric +loader_version=0.19.5 +fabric_version=0.162.0+26.3 +modmenu_version=21.0.0 +minecraft_range=>=26.3 <=26.3 +java_version=25 +# From 26.1 the game ships unobfuscated: no mappings, no remapping. +unobfuscated=true diff --git a/versions/26.3-neoforge/gradle.properties b/versions/26.3-neoforge/gradle.properties new file mode 100644 index 0000000..4f892ab --- /dev/null +++ b/versions/26.3-neoforge/gradle.properties @@ -0,0 +1,6 @@ +minecraft_version=26.3 +loader_type=neoforge +# The 26.3 line has no stable NeoForge yet, so this target is published as a beta. +neoforge_version=26.3.0.73-beta +minecraft_range=[26.3,26.3] +java_version=25