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;
+ }
+}