From b69a0eb244c339f1a9d787d628f0cf7beb2209d6 Mon Sep 17 00:00:00 2001 From: root Date: Sun, 2 Aug 2026 07:48:46 +0200 Subject: [PATCH] Add automatic Geyser Bedrock pack support --- README.md | 25 +- pom.xml | 16 ++ .../remotepacksync/BedrockPackManager.java | 251 ++++++++++++++++++ .../remotepacksync/BedrockPackSettings.java | 35 +++ .../remotepacksync/GeyserEventRegistrar.java | 7 + .../remotepacksync/RemotePackSyncPlugin.java | 37 ++- src/main/resources/config.yml | 7 +- src/main/resources/plugin.yml | 1 + .../BedrockPackManagerTest.java | 46 ++++ 9 files changed, 419 insertions(+), 6 deletions(-) create mode 100644 src/main/java/io/github/lf1/remotepacksync/BedrockPackManager.java create mode 100644 src/main/java/io/github/lf1/remotepacksync/BedrockPackSettings.java create mode 100644 src/main/java/io/github/lf1/remotepacksync/GeyserEventRegistrar.java create mode 100644 src/test/java/io/github/lf1/remotepacksync/BedrockPackManagerTest.java diff --git a/README.md b/README.md index 285dcd1..d793a0c 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # RemotePackSync -RemotePackSync is a lightweight **Spigot 26.2** plugin that keeps a server resource pack synchronized with a remotely hosted SHA-1 checksum. It uses only the public Bukkit/Spigot API, so it runs unchanged on **Spigot, Paper, Purpur, and Bukkit-compatible servers**. +RemotePackSync is a lightweight **Spigot 26.2** plugin that keeps Java Edition resource packs synchronized with a remotely hosted SHA-1 checksum and optionally serves a local Bedrock Edition pack to players connecting through **GeyserMC**. It uses only public Bukkit/Spigot and Geyser APIs, so it runs on **Spigot, Paper, Purpur, Bukkit-compatible servers, and current GeyserMC**. -No NMS, packet libraries, or fork-specific APIs are used. +No NMS, packet libraries, or server-fork-specific APIs are used. ## Requirements @@ -37,8 +37,12 @@ resourcePackUrl: "https://cdn.example.com/server-pack.zip" # URL to a text file containing the pack's SHA-1 checksum. sha1Url: "https://cdn.example.com/server-pack.zip.sha1" -# Seconds between SHA-1 checks. Minimum: 30. +# Seconds between Java SHA-1 and Bedrock pack checks. Minimum: 30. refreshInterval: 300 + +# Optional local Bedrock Edition pack for GeyserMC players. Accepts .mcpack or .zip. +# Relative paths are resolved from plugins/RemotePackSync/. +bedrockResourcePack: "bedrock/server-pack.mcpack" ``` The SHA-1 response must contain a 40-character hexadecimal SHA-1. Both of these standard formats work: @@ -53,12 +57,25 @@ The SHA-1 response must contain a 40-character hexadecimal SHA-1. Both of these ## Behaviour +### Java Edition + - On startup and at every refresh interval, RemotePackSync fetches only the SHA-1 file asynchronously. - When the SHA-1 changes, it sends the configured resource-pack URL and new hash to every online player. - Players joining after a successful SHA-1 fetch receive the current resource pack. - A failed request leaves the last known-good SHA-1 active, so a brief CDN outage does not interrupt existing pack delivery. - If both URLs are blank, delivery is disabled without error. If only one is set, the configuration is rejected and delivery remains disabled. +### Bedrock Edition through GeyserMC + +- Set `bedrockResourcePack` to a local `.mcpack` or `.zip` file. `.mcpack` is fully supported. +- The source pack is checked asynchronously at `refreshInterval`. When its SHA-256 changes, RemotePackSync creates a managed copy under `plugins/RemotePackSync/bedrock-managed/`. +- The managed copy receives a deterministic new header UUID derived from the updated source SHA-256. This makes Bedrock clients treat a changed pack as a new pack, while Geyser calculates and supplies the required final pack hash and size. +- The original configured pack is never modified. +- Every new Geyser Bedrock connection receives the latest successfully processed pack through Geyser's public `SessionLoadResourcePacksEvent` API. +- The pack must be a valid Bedrock pack containing `manifest.json` at the archive root. Geyser does **not** convert Java packs into Bedrock packs. +- Bedrock's protocol only negotiates packs while a client connects. Players already connected when the file changes must reconnect to receive the new version; RemotePackSync cannot hot-push a replacement to an active Bedrock session. +- If Geyser is absent, Java Edition resource-pack operation continues normally and Bedrock delivery stays disabled. + ## Commands and permissions - `/remotepacksync reload` (alias: `/rps reload`) reloads `config.yml` and begins a fresh asynchronous SHA-1 refresh. @@ -66,7 +83,7 @@ The SHA-1 response must contain a 40-character hexadecimal SHA-1. Both of these ## Compatibility -RemotePackSync compiles against `org.spigotmc:spigot-api:26.2-R0.1-SNAPSHOT` and targets Java 25, which is required by Minecraft/Spigot 26.2. Resource packs are sent through Bukkit's stable `Player#setResourcePack(String, byte[])` API. +RemotePackSync compiles against `org.spigotmc:spigot-api:26.2-R0.1-SNAPSHOT` and Geyser API `2.11.0-SNAPSHOT`, targeting Java 25 as required by Minecraft/Spigot 26.2. Java packs are sent through Bukkit's stable `Player#setResourcePack(String, byte[])` API; Bedrock packs use Geyser's public pack/session API. Geyser is a soft dependency, so Java-only servers remain supported. ## License diff --git a/pom.xml b/pom.xml index 7200870..9edc894 100644 --- a/pom.xml +++ b/pom.xml @@ -21,6 +21,10 @@ spigotmc-repo https://hub.spigotmc.org/nexus/content/repositories/snapshots/ + + opencollab-snapshots + https://repo.opencollab.dev/main/ + @@ -30,6 +34,18 @@ 26.2-R0.1-SNAPSHOT provided + + org.geysermc.geyser + api + 2.11.0-SNAPSHOT + provided + + + org.junit.jupiter + junit-jupiter + 5.12.2 + test + diff --git a/src/main/java/io/github/lf1/remotepacksync/BedrockPackManager.java b/src/main/java/io/github/lf1/remotepacksync/BedrockPackManager.java new file mode 100644 index 0000000..bc87a20 --- /dev/null +++ b/src/main/java/io/github/lf1/remotepacksync/BedrockPackManager.java @@ -0,0 +1,251 @@ +package io.github.lf1.remotepacksync; + +import org.bukkit.Bukkit; +import org.bukkit.plugin.java.JavaPlugin; +import org.bukkit.scheduler.BukkitTask; +import org.geysermc.geyser.api.GeyserApi; +import org.geysermc.geyser.api.event.bedrock.SessionLoadResourcePacksEvent; +import org.geysermc.geyser.api.pack.PackCodec; +import org.geysermc.geyser.api.pack.ResourcePack; +import org.geysermc.geyser.api.pack.option.ResourcePackOption; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Arrays; +import java.util.HexFormat; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicReference; +import java.util.logging.Level; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.zip.ZipEntry; +import java.util.zip.ZipFile; +import java.util.zip.ZipOutputStream; + +/** Maintains a managed Bedrock pack and registers its current version for new Geyser sessions. */ +public final class BedrockPackManager { + private static final int MAX_MANIFEST_BYTES = 1024 * 1024; + private static final Pattern HEADER_PROPERTY = Pattern.compile("\\\"header\\\"\\s*:\\s*\\{"); + private static final Pattern UUID_PROPERTY = Pattern.compile("\\\"uuid\\\"\\s*:\\s*\\\"[^\\\"]*\\\""); + + private final JavaPlugin plugin; + private final AtomicReference currentPack = new AtomicReference<>(); + + private volatile BedrockPackSettings settings; + private BukkitTask refreshTask; + private boolean subscribed; + + public BedrockPackManager(JavaPlugin plugin) { + this.plugin = plugin; + } + + public void configure(BedrockPackSettings newSettings) { + stop(); + currentPack.set(null); + settings = newSettings; + if (newSettings == null) { + plugin.getLogger().info("Bedrock resource-pack delivery is disabled: bedrockResourcePack is blank."); + return; + } + + subscribeToGeyser(); + refreshTask = Bukkit.getScheduler().runTaskTimerAsynchronously( + plugin, this::refreshPack, 0L, newSettings.refreshIntervalTicks()); + plugin.getLogger().info("Bedrock resource-pack refresh enabled every " + + (newSettings.refreshIntervalTicks() / 20L) + " seconds."); + } + + public void stop() { + if (refreshTask != null) { + refreshTask.cancel(); + refreshTask = null; + } + } + + private void subscribeToGeyser() { + if (subscribed) { + return; + } + if (Bukkit.getPluginManager().getPlugin("Geyser-Spigot") == null) { + plugin.getLogger().warning("bedrockResourcePack is configured, but Geyser-Spigot is not installed; " + + "the Bedrock pack will not be sent until Geyser is available and RemotePackSync is reloaded."); + return; + } + + try { + GeyserEventRegistrar registrar = new GeyserEventRegistrar(); + GeyserApi.api().eventBus().subscribe(registrar, SessionLoadResourcePacksEvent.class, this::registerForSession); + subscribed = true; + plugin.getLogger().info("Registered Geyser Bedrock resource-pack session handler."); + } catch (RuntimeException | LinkageError exception) { + plugin.getLogger().log(Level.WARNING, "Could not initialize Geyser Bedrock resource-pack support: " + + exception.getMessage(), exception); + } + } + + private void registerForSession(SessionLoadResourcePacksEvent event) { + Snapshot snapshot = currentPack.get(); + if (snapshot == null) { + return; + } + try { + event.register(snapshot.resourcePack(), new ResourcePackOption[0]); + } catch (RuntimeException exception) { + plugin.getLogger().log(Level.WARNING, "Could not register the Bedrock resource pack for a connecting player: " + + exception.getMessage(), exception); + } + } + + private void refreshPack() { + BedrockPackSettings activeSettings = settings; + if (activeSettings == null) { + return; + } + + try { + Path source = activeSettings.sourceFile(); + if (!Files.isRegularFile(source)) { + throw new IOException("Configured Bedrock pack does not exist or is not a regular file: " + source); + } + byte[] sourceSha256 = sha256(source); + Snapshot previous = currentPack.get(); + if (previous != null && Arrays.equals(previous.sourceSha256(), sourceSha256)) { + return; + } + + Path managedPack = writeManagedPack(source, sourceSha256); + ResourcePack resourcePack = ResourcePack.create(PackCodec.path(managedPack)); + if (activeSettings != settings) { + return; + } + + currentPack.set(new Snapshot(sourceSha256, resourcePack)); + plugin.getLogger().info("Bedrock resource pack refreshed (SHA-256: " + + HexFormat.of().formatHex(resourcePack.codec().sha256()) + "). " + + "New Geyser connections will receive it."); + } catch (Exception exception) { + plugin.getLogger().log(Level.WARNING, "Could not refresh the Bedrock resource pack: " + + exception.getMessage(), exception); + } + } + + private Path writeManagedPack(Path source, byte[] sourceSha256) throws IOException { + String sourceName = source.getFileName().toString().toLowerCase(java.util.Locale.ROOT); + String extension = sourceName.endsWith(".mcpack") ? ".mcpack" : ".zip"; + Path directory = plugin.getDataFolder().toPath().resolve("bedrock-managed"); + Files.createDirectories(directory); + Path target = directory.resolve("RemotePackSync-Bedrock" + extension); + Path temporary = Files.createTempFile(directory, "RemotePackSync-Bedrock-", extension); + UUID cacheBustingUuid = UUID.nameUUIDFromBytes(sourceSha256); + + boolean foundManifest = false; + try (ZipFile input = new ZipFile(source.toFile()); + OutputStream fileOutput = Files.newOutputStream(temporary); + ZipOutputStream output = new ZipOutputStream(fileOutput)) { + var entries = input.entries(); + while (entries.hasMoreElements()) { + ZipEntry entry = entries.nextElement(); + ZipEntry copiedEntry = new ZipEntry(entry.getName()); + copiedEntry.setTime(entry.getTime()); + output.putNextEntry(copiedEntry); + if (!entry.isDirectory()) { + try (InputStream entryInput = input.getInputStream(entry)) { + if (entry.getName().equalsIgnoreCase("manifest.json")) { + byte[] manifest = entryInput.readNBytes(MAX_MANIFEST_BYTES + 1); + if (manifest.length > MAX_MANIFEST_BYTES) { + throw new IOException("manifest.json exceeds " + MAX_MANIFEST_BYTES + " bytes"); + } + output.write(replaceHeaderUuid(new String(manifest, StandardCharsets.UTF_8), cacheBustingUuid) + .getBytes(StandardCharsets.UTF_8)); + foundManifest = true; + } else { + entryInput.transferTo(output); + } + } + } + output.closeEntry(); + } + } catch (Exception exception) { + Files.deleteIfExists(temporary); + throw exception; + } + + if (!foundManifest) { + Files.deleteIfExists(temporary); + throw new IOException("The Bedrock pack must contain manifest.json at the archive root."); + } + try { + Files.move(temporary, target, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); + } catch (AtomicMoveNotSupportedException exception) { + Files.move(temporary, target, StandardCopyOption.REPLACE_EXISTING); + } + return target; + } + + static String replaceHeaderUuid(String manifest, UUID uuid) throws IOException { + Matcher headerMatcher = HEADER_PROPERTY.matcher(manifest); + if (!headerMatcher.find()) { + throw new IOException("manifest.json does not contain a header object."); + } + int headerStart = headerMatcher.end() - 1; + int headerEnd = matchingObjectEnd(manifest, headerStart); + String header = manifest.substring(headerStart, headerEnd + 1); + Matcher uuidMatcher = UUID_PROPERTY.matcher(header); + if (!uuidMatcher.find()) { + throw new IOException("manifest.json header does not contain a UUID."); + } + String updatedHeader = uuidMatcher.replaceFirst("\\\"uuid\\\": \\\"" + uuid + "\\\""); + return manifest.substring(0, headerStart) + updatedHeader + manifest.substring(headerEnd + 1); + } + + private static int matchingObjectEnd(String json, int start) throws IOException { + int depth = 0; + boolean inString = false; + boolean escaped = false; + for (int index = start; index < json.length(); index++) { + char character = json.charAt(index); + if (inString) { + if (escaped) { + escaped = false; + } else if (character == '\\') { + escaped = true; + } else if (character == '"') { + inString = false; + } + continue; + } + if (character == '"') { + inString = true; + } else if (character == '{') { + depth++; + } else if (character == '}' && --depth == 0) { + return index; + } + } + throw new IOException("manifest.json header object is not closed."); + } + + private static byte[] sha256(Path file) throws IOException { + try (InputStream input = Files.newInputStream(file)) { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] buffer = new byte[8192]; + for (int read; (read = input.read(buffer)) != -1;) { + digest.update(buffer, 0, read); + } + return digest.digest(); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException("SHA-256 is unavailable in this Java runtime.", exception); + } + } + + private record Snapshot(byte[] sourceSha256, ResourcePack resourcePack) { + } +} diff --git a/src/main/java/io/github/lf1/remotepacksync/BedrockPackSettings.java b/src/main/java/io/github/lf1/remotepacksync/BedrockPackSettings.java new file mode 100644 index 0000000..27506f1 --- /dev/null +++ b/src/main/java/io/github/lf1/remotepacksync/BedrockPackSettings.java @@ -0,0 +1,35 @@ +package io.github.lf1.remotepacksync; + +import org.bukkit.configuration.file.FileConfiguration; + +import java.nio.file.Path; +import java.util.Locale; +import java.util.Optional; + +/** Validated configuration for an optional local Bedrock resource pack. */ +public record BedrockPackSettings(Path sourceFile, long refreshIntervalTicks) { + private static final long MINIMUM_REFRESH_SECONDS = 30L; + + public static Optional from(FileConfiguration config, Path dataDirectory) { + String configuredPath = config.getString("bedrockResourcePack", "").trim(); + if (configuredPath.isEmpty()) { + return Optional.empty(); + } + + Path source = Path.of(configuredPath); + if (!source.isAbsolute()) { + source = dataDirectory.resolve(source); + } + source = source.normalize(); + + String fileName = source.getFileName().toString().toLowerCase(Locale.ROOT); + if (!fileName.endsWith(".mcpack") && !fileName.endsWith(".zip")) { + throw new IllegalArgumentException("bedrockResourcePack must reference a .mcpack or .zip file."); + } + long refreshSeconds = config.getLong("refreshInterval", 300L); + if (refreshSeconds < MINIMUM_REFRESH_SECONDS) { + throw new IllegalArgumentException("refreshInterval must be at least " + MINIMUM_REFRESH_SECONDS + " seconds."); + } + return Optional.of(new BedrockPackSettings(source, Math.multiplyExact(refreshSeconds, 20L))); + } +} diff --git a/src/main/java/io/github/lf1/remotepacksync/GeyserEventRegistrar.java b/src/main/java/io/github/lf1/remotepacksync/GeyserEventRegistrar.java new file mode 100644 index 0000000..d09d16d --- /dev/null +++ b/src/main/java/io/github/lf1/remotepacksync/GeyserEventRegistrar.java @@ -0,0 +1,7 @@ +package io.github.lf1.remotepacksync; + +import org.geysermc.geyser.api.event.EventRegistrar; + +/** Separate registrar keeps Geyser entirely optional for Java-only servers. */ +public final class GeyserEventRegistrar implements EventRegistrar { +} diff --git a/src/main/java/io/github/lf1/remotepacksync/RemotePackSyncPlugin.java b/src/main/java/io/github/lf1/remotepacksync/RemotePackSyncPlugin.java index c4fe68b..aa739d5 100644 --- a/src/main/java/io/github/lf1/remotepacksync/RemotePackSyncPlugin.java +++ b/src/main/java/io/github/lf1/remotepacksync/RemotePackSyncPlugin.java @@ -18,10 +18,14 @@ import java.util.Optional; /** Entry point for RemotePackSync. Uses only stable Bukkit/Spigot APIs. */ public final class RemotePackSyncPlugin extends JavaPlugin implements Listener, CommandExecutor, TabCompleter { private ResourcePackManager resourcePackManager; + private BedrockPackManager bedrockPackManager; @Override public void onEnable() { saveDefaultConfig(); + getConfig().addDefault("bedrockResourcePack", ""); + getConfig().options().copyDefaults(true); + saveConfig(); resourcePackManager = new ResourcePackManager(this); getServer().getPluginManager().registerEvents(this, this); @@ -38,6 +42,9 @@ public final class RemotePackSyncPlugin extends JavaPlugin implements Listener, if (resourcePackManager != null) { resourcePackManager.stop(); } + if (bedrockPackManager != null) { + bedrockPackManager.stop(); + } } @EventHandler @@ -77,7 +84,35 @@ public final class RemotePackSyncPlugin extends JavaPlugin implements Listener, resourcePackManager.configure(settings); } catch (IllegalArgumentException exception) { resourcePackManager.configure(Optional.empty()); - getLogger().severe("Invalid config.yml: " + exception.getMessage()); + getLogger().severe("Invalid Java resource-pack config: " + exception.getMessage()); + } + + try { + Optional bedrockSettings = BedrockPackSettings.from( + getConfig(), getDataFolder().toPath()); + configureBedrockPack(bedrockSettings); + } catch (IllegalArgumentException exception) { + if (bedrockPackManager != null) { + bedrockPackManager.configure(null); + } + getLogger().severe("Invalid Bedrock resource-pack config: " + exception.getMessage()); } } + + private void configureBedrockPack(Optional settings) { + if (settings.isEmpty()) { + if (bedrockPackManager != null) { + bedrockPackManager.configure(null); + } + return; + } + if (getServer().getPluginManager().getPlugin("Geyser-Spigot") == null) { + getLogger().warning("bedrockResourcePack is configured, but Geyser-Spigot is not installed; Bedrock delivery is disabled."); + return; + } + if (bedrockPackManager == null) { + bedrockPackManager = new BedrockPackManager(this); + } + bedrockPackManager.configure(settings.get()); + } } diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 979920d..9a66633 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -6,5 +6,10 @@ resourcePackUrl: "" # so standard ' filename.zip' checksum files are supported. sha1Url: "" -# Seconds between remote SHA-1 checks. Minimum: 30 seconds. +# Seconds between remote SHA-1 and local Bedrock pack checks. Minimum: 30 seconds. refreshInterval: 300 + +# Optional local Bedrock Edition resource pack for players joining through GeyserMC. +# The path may point to a .mcpack or .zip file. Relative paths are resolved from +# plugins/RemotePackSync/. Leave blank to disable Bedrock pack delivery. +bedrockResourcePack: "" diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index 117d866..069b44f 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -4,6 +4,7 @@ main: io.github.lf1.remotepacksync.RemotePackSyncPlugin api-version: '26.2' description: Synchronizes a server resource-pack SHA-1 from a remote URL. author: LF1 +softdepend: [Geyser-Spigot] commands: remotepacksync: description: Reload RemotePackSync configuration and SHA-1 data. diff --git a/src/test/java/io/github/lf1/remotepacksync/BedrockPackManagerTest.java b/src/test/java/io/github/lf1/remotepacksync/BedrockPackManagerTest.java new file mode 100644 index 0000000..5d2cd65 --- /dev/null +++ b/src/test/java/io/github/lf1/remotepacksync/BedrockPackManagerTest.java @@ -0,0 +1,46 @@ +package io.github.lf1.remotepacksync; + +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +final class BedrockPackManagerTest { + @Test + void replacesOnlyTheManifestHeaderUuid() throws IOException { + UUID uuid = UUID.fromString("11111111-2222-3333-4444-555555555555"); + String manifest = """ + { + "format_version": 2, + "header": { + "name": "Example pack", + "uuid": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + "version": [1, 0, 0] + }, + "modules": [{ + "uuid": "module-uuid-must-not-change", + "type": "resources", + "version": [1, 0, 0] + }] + } + """; + + String updated = BedrockPackManager.replaceHeaderUuid(manifest, uuid); + + assertEquals(1, occurrences(updated, uuid.toString())); + assertEquals(1, occurrences(updated, "module-uuid-must-not-change")); + } + + @Test + void rejectsManifestWithoutHeaderUuid() { + assertThrows(IOException.class, () -> BedrockPackManager.replaceHeaderUuid( + "{\"header\": {\"name\": \"No UUID\"}}", UUID.randomUUID())); + } + + private static int occurrences(String value, String needle) { + return value.split(java.util.regex.Pattern.quote(needle), -1).length - 1; + } +}