5 Commits
Author SHA1 Message Date
root b24522acb5 Restore RemotePackSync plugin identity
Build and publish release / Build and publish RemotePackSync (push) Successful in 51s
2026-08-04 19:56:32 +02:00
root 7f869855c9 Release LexianDEV v1.0.0
Build and publish release / Build and publish LexianDEV (push) Successful in 1m0s
2026-08-04 19:54:23 +02:00
root 3dc2915604 Validate Bedrock URL pack responses
Build and publish release / Build and publish RemotePackSync (push) Successful in 1m1s
2026-08-02 09:43:05 +02:00
root 97c282a389 Support remote Geyser Bedrock pack URLs
Build and publish release / Build and publish RemotePackSync (push) Successful in 59s
2026-08-02 07:56:04 +02:00
root b69a0eb244 Add automatic Geyser Bedrock pack support 2026-08-02 07:48:46 +02:00
13 changed files with 555 additions and 17 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
MIT License MIT License
Copyright (c) 2026 LF1 Copyright (c) 2026 LexianDEV
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal
+29 -4
View File
@@ -1,8 +1,10 @@
# RemotePackSync # 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**. Created by **LexianDEV** · Official website: <https://lexian.dev>
No NMS, packet libraries, or fork-specific APIs are used. 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 server-fork-specific APIs are used.
## Requirements ## Requirements
@@ -37,8 +39,17 @@ resourcePackUrl: "https://cdn.example.com/server-pack.zip"
# URL to a text file containing the pack's SHA-1 checksum. # URL to a text file containing the pack's SHA-1 checksum.
sha1Url: "https://cdn.example.com/server-pack.zip.sha1" 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 refreshInterval: 300
# Preferred: remote Bedrock pack URL for GeyserMC players.
# Must be a direct .mcpack/.zip download with Content-Type: application/zip
# and an exact Content-Length.
bedrockResourcePackUrl: "https://example.com/packs/server-pack.mcpack"
# Optional local alternative; use only when bedrockResourcePackUrl is blank.
# 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: The SHA-1 response must contain a 40-character hexadecimal SHA-1. Both of these standard formats work:
@@ -53,12 +64,26 @@ The SHA-1 response must contain a 40-character hexadecimal SHA-1. Both of these
## Behaviour ## Behaviour
### Java Edition
- On startup and at every refresh interval, RemotePackSync fetches only the SHA-1 file asynchronously. - 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. - 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. - 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. - 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. - 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
- Preferred remote mode: set `bedrockResourcePackUrl` to a direct `.mcpack` or `.zip` download URL. RemotePackSync creates a Geyser `UrlPackCodec` and registers it through the public `SessionLoadResourcePacksEvent` API, so Geyser supplies the URL to connecting Bedrock clients.
- The remote server must return `Content-Type: application/zip` and an exact `Content-Length`. RemotePackSync verifies those headers (following redirects) before registering the URL; a provider that returns `application/octet-stream` is not compatible with Bedrock URL packs. Redirects are allowed only when they ultimately resolve to a valid direct download. The URL should be stable and should expose a changed ETag or URL when the pack changes.
- Geyser validates/downloads remote URL packs during its lifecycle. Use `/remotepacksync reload` after changing the URL or after replacing a pack at the same URL; players already connected must reconnect.
- Local alternative: set `bedrockResourcePack` to a `.mcpack` or `.zip` file. The source is checked asynchronously at `refreshInterval`; changes receive a deterministic manifest UUID and are managed under `plugins/RemotePackSync/bedrock-managed/`.
- The original configured local pack is never modified.
- Every new Geyser Bedrock connection receives the latest successfully registered pack.
- 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 ## Commands and permissions
- `/remotepacksync reload` (alias: `/rps reload`) reloads `config.yml` and begins a fresh asynchronous SHA-1 refresh. - `/remotepacksync reload` (alias: `/rps reload`) reloads `config.yml` and begins a fresh asynchronous SHA-1 refresh.
@@ -66,7 +91,7 @@ The SHA-1 response must contain a 40-character hexadecimal SHA-1. Both of these
## Compatibility ## 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 `UrlPackCodec`/pack-session API. Geyser is a soft dependency, so Java-only servers remain supported.
## License ## License
+19 -2
View File
@@ -4,11 +4,12 @@
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"> xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion> <modelVersion>4.0.0</modelVersion>
<groupId>io.github.lf1</groupId> <groupId>dev.lexian</groupId>
<artifactId>remotepacksync</artifactId> <artifactId>remotepacksync</artifactId>
<version>1.0.0</version> <version>v1.0.0</version>
<name>RemotePackSync</name> <name>RemotePackSync</name>
<description>Synchronizes a server resource-pack SHA-1 from a remote URL.</description> <description>Synchronizes a server resource-pack SHA-1 from a remote URL.</description>
<url>https://lexian.dev</url>
<properties> <properties>
<!-- Minecraft/Spigot 26.2 requires Java 25 at runtime. --> <!-- Minecraft/Spigot 26.2 requires Java 25 at runtime. -->
@@ -21,6 +22,10 @@
<id>spigotmc-repo</id> <id>spigotmc-repo</id>
<url>https://hub.spigotmc.org/nexus/content/repositories/snapshots/</url> <url>https://hub.spigotmc.org/nexus/content/repositories/snapshots/</url>
</repository> </repository>
<repository>
<id>opencollab-snapshots</id>
<url>https://repo.opencollab.dev/main/</url>
</repository>
</repositories> </repositories>
<dependencies> <dependencies>
@@ -30,6 +35,18 @@
<version>26.2-R0.1-SNAPSHOT</version> <version>26.2-R0.1-SNAPSHOT</version>
<scope>provided</scope> <scope>provided</scope>
</dependency> </dependency>
<dependency>
<groupId>org.geysermc.geyser</groupId>
<artifactId>api</artifactId>
<version>2.11.0-SNAPSHOT</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.12.2</version>
<scope>test</scope>
</dependency>
</dependencies> </dependencies>
<build> <build>
@@ -0,0 +1,324 @@
package dev.lexian.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.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
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 HttpClient HTTP_CLIENT = HttpClient.newBuilder()
.followRedirects(HttpClient.Redirect.NORMAL)
.build();
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<Snapshot> currentPack = new AtomicReference<>();
private volatile BedrockPackSettings settings;
private BukkitTask refreshTask;
private GeyserEventRegistrar geyserEventRegistrar;
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();
if (newSettings.isRemote()) {
// Geyser's URL codec validates the remote download and obtains its metadata.
// Geyser itself downloads remote packs once during its lifecycle, so do not
// repeatedly recreate the URL codec on a timer.
refreshTask = Bukkit.getScheduler().runTaskAsynchronously(plugin, this::refreshPack);
plugin.getLogger().info("Bedrock remote resource-pack URL configured. Geyser will fetch it for new connections.");
} else {
refreshTask = Bukkit.getScheduler().runTaskTimerAsynchronously(
plugin, this::refreshPack, 0L, newSettings.refreshIntervalTicks());
plugin.getLogger().info("Bedrock local resource-pack refresh enabled every "
+ (newSettings.refreshIntervalTicks() / 20L) + " seconds.");
}
}
public void stop() {
if (refreshTask != null) {
refreshTask.cancel();
refreshTask = null;
}
if (geyserEventRegistrar != null) {
try {
GeyserApi.api().eventBus().unregisterAll(geyserEventRegistrar);
} catch (RuntimeException | LinkageError exception) {
plugin.getLogger().log(Level.FINE, "Could not unregister the Geyser Bedrock resource-pack handler.", exception);
} finally {
geyserEventRegistrar = null;
subscribed = false;
}
}
}
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 = new GeyserEventRegistrar();
GeyserApi.api().eventBus().subscribe(
geyserEventRegistrar, SessionLoadResourcePacksEvent.class, this::registerForSession);
subscribed = true;
plugin.getLogger().info("Registered Geyser Bedrock resource-pack session handler.");
} catch (RuntimeException | LinkageError exception) {
geyserEventRegistrar = null;
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 {
if (activeSettings.isRemote()) {
validateRemotePackUrl(activeSettings.remoteUrl());
ResourcePack resourcePack = ResourcePack.create(PackCodec.url(activeSettings.remoteUrl()));
if (activeSettings != settings) {
return;
}
currentPack.set(new Snapshot(null, resourcePack));
plugin.getLogger().info("Bedrock remote resource-pack URL validated and registered: "
+ activeSettings.remoteUrl());
return;
}
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);
}
}
/**
* Geyser gives URL packs to Bedrock clients as URLs. Unlike Geyser's server-side
* download, the Bedrock client requires a direct response with application/zip
* and a real Content-Length, so catch bad hosting before registering the pack.
*/
private static void validateRemotePackUrl(String url) throws IOException, InterruptedException {
HttpRequest request = HttpRequest.newBuilder(URI.create(url))
.method("HEAD", HttpRequest.BodyPublishers.noBody())
.header("Accept", "application/zip")
.build();
HttpResponse<Void> response = HTTP_CLIENT.send(request, HttpResponse.BodyHandlers.discarding());
if (response.statusCode() < 200 || response.statusCode() >= 300) {
throw new IOException("Remote Bedrock pack returned HTTP " + response.statusCode() + ".");
}
validateRemotePackHeaders(
response.headers().firstValue("Content-Type").orElse(null),
response.headers().firstValue("Content-Length").orElse(null));
}
static void validateRemotePackHeaders(String contentType, String contentLength) throws IOException {
if (contentType == null || !contentType.toLowerCase(java.util.Locale.ROOT).startsWith("application/zip")) {
throw new IOException("Remote Bedrock pack must return Content-Type: application/zip, but returned "
+ (contentType == null ? "no Content-Type header" : contentType) + ".");
}
try {
if (contentLength == null || Long.parseLong(contentLength) <= 0) {
throw new NumberFormatException();
}
} catch (NumberFormatException exception) {
throw new IOException("Remote Bedrock pack must return a positive, exact Content-Length header.");
}
}
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) {
}
}
@@ -0,0 +1,51 @@
package dev.lexian.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 or remote Bedrock resource pack. */
public record BedrockPackSettings(Path sourceFile, String remoteUrl, long refreshIntervalTicks) {
private static final long MINIMUM_REFRESH_SECONDS = 30L;
public static Optional<BedrockPackSettings> from(FileConfiguration config, Path dataDirectory) {
String configuredUrl = config.getString("bedrockResourcePackUrl", "").trim();
String configuredPath = config.getString("bedrockResourcePack", "").trim();
if (!configuredUrl.isEmpty() && !configuredPath.isEmpty()) {
throw new IllegalArgumentException("Configure only one of bedrockResourcePackUrl or bedrockResourcePack.");
}
if (configuredUrl.isEmpty() && configuredPath.isEmpty()) {
return Optional.empty();
}
Path source = null;
if (!configuredPath.isEmpty()) {
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.");
}
} else {
if (!configuredUrl.startsWith("https://") && !configuredUrl.startsWith("http://")) {
throw new IllegalArgumentException("bedrockResourcePackUrl must be an HTTP(S) direct download URL.");
}
}
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, configuredUrl.isEmpty() ? null : configuredUrl,
Math.multiplyExact(refreshSeconds, 20L)));
}
public boolean isRemote() {
return remoteUrl != null;
}
}
@@ -0,0 +1,7 @@
package dev.lexian.remotepacksync;
import org.geysermc.geyser.api.event.EventRegistrar;
/** Separate registrar keeps Geyser entirely optional for Java-only servers. */
public final class GeyserEventRegistrar implements EventRegistrar {
}
@@ -1,4 +1,4 @@
package io.github.lf1.remotepacksync; package dev.lexian.remotepacksync;
import org.bukkit.configuration.file.FileConfiguration; import org.bukkit.configuration.file.FileConfiguration;
@@ -1,4 +1,4 @@
package io.github.lf1.remotepacksync; package dev.lexian.remotepacksync;
import org.bukkit.command.Command; import org.bukkit.command.Command;
import org.bukkit.command.CommandExecutor; import org.bukkit.command.CommandExecutor;
@@ -18,10 +18,15 @@ import java.util.Optional;
/** Entry point for RemotePackSync. Uses only stable Bukkit/Spigot APIs. */ /** Entry point for RemotePackSync. Uses only stable Bukkit/Spigot APIs. */
public final class RemotePackSyncPlugin extends JavaPlugin implements Listener, CommandExecutor, TabCompleter { public final class RemotePackSyncPlugin extends JavaPlugin implements Listener, CommandExecutor, TabCompleter {
private ResourcePackManager resourcePackManager; private ResourcePackManager resourcePackManager;
private BedrockPackManager bedrockPackManager;
@Override @Override
public void onEnable() { public void onEnable() {
saveDefaultConfig(); saveDefaultConfig();
getConfig().addDefault("bedrockResourcePackUrl", "");
getConfig().addDefault("bedrockResourcePack", "");
getConfig().options().copyDefaults(true);
saveConfig();
resourcePackManager = new ResourcePackManager(this); resourcePackManager = new ResourcePackManager(this);
getServer().getPluginManager().registerEvents(this, this); getServer().getPluginManager().registerEvents(this, this);
@@ -38,6 +43,9 @@ public final class RemotePackSyncPlugin extends JavaPlugin implements Listener,
if (resourcePackManager != null) { if (resourcePackManager != null) {
resourcePackManager.stop(); resourcePackManager.stop();
} }
if (bedrockPackManager != null) {
bedrockPackManager.stop();
}
} }
@EventHandler @EventHandler
@@ -77,7 +85,38 @@ public final class RemotePackSyncPlugin extends JavaPlugin implements Listener,
resourcePackManager.configure(settings); resourcePackManager.configure(settings);
} catch (IllegalArgumentException exception) { } catch (IllegalArgumentException exception) {
resourcePackManager.configure(Optional.empty()); resourcePackManager.configure(Optional.empty());
getLogger().severe("Invalid config.yml: " + exception.getMessage()); getLogger().severe("Invalid Java resource-pack config: " + exception.getMessage());
}
try {
Optional<BedrockPackSettings> 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<BedrockPackSettings> settings) {
if (settings.isEmpty()) {
if (bedrockPackManager != null) {
bedrockPackManager.configure(null);
}
return;
}
if (getServer().getPluginManager().getPlugin("Geyser-Spigot") == null) {
if (bedrockPackManager != null) {
bedrockPackManager.configure(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());
}
} }
@@ -1,4 +1,4 @@
package io.github.lf1.remotepacksync; package dev.lexian.remotepacksync;
import java.io.IOException; import java.io.IOException;
import java.net.URI; import java.net.URI;
@@ -26,7 +26,7 @@ public final class RemoteSha1Fetcher {
.GET() .GET()
.timeout(REQUEST_TIMEOUT) .timeout(REQUEST_TIMEOUT)
.header("Accept", "text/plain, text/*;q=0.9, */*;q=0.1") .header("Accept", "text/plain, text/*;q=0.9, */*;q=0.1")
.header("User-Agent", "RemotePackSync/1.0") .header("User-Agent", "RemotePackSync/v1.0.0")
.build(); .build();
HttpResponse<byte[]> response = client.send(request, HttpResponse.BodyHandlers.ofByteArray()); HttpResponse<byte[]> response = client.send(request, HttpResponse.BodyHandlers.ofByteArray());
@@ -1,4 +1,4 @@
package io.github.lf1.remotepacksync; package dev.lexian.remotepacksync;
import org.bukkit.Bukkit; import org.bukkit.Bukkit;
import org.bukkit.entity.Player; import org.bukkit.entity.Player;
+11 -1
View File
@@ -6,5 +6,15 @@ resourcePackUrl: ""
# so standard '<hash> filename.zip' checksum files are supported. # so standard '<hash> filename.zip' checksum files are supported.
sha1Url: "" 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 refreshInterval: 300
# Optional remote Bedrock Edition resource-pack URL for players connecting through GeyserMC.
# This must be a direct HTTP(S) download URL to a .mcpack or .zip pack. The server
# must return Content-Type: application/zip and an exact Content-Length.
# Geyser passes this URL to Bedrock clients using its URL pack codec.
bedrockResourcePackUrl: ""
# Optional local alternative. Configure this only when bedrockResourcePackUrl is blank.
# Relative paths are resolved from plugins/RemotePackSync/.
bedrockResourcePack: ""
+5 -3
View File
@@ -1,9 +1,11 @@
name: RemotePackSync name: RemotePackSync
version: '1.0.0' version: 'v1.0.0'
main: io.github.lf1.remotepacksync.RemotePackSyncPlugin main: dev.lexian.remotepacksync.RemotePackSyncPlugin
api-version: '26.2' api-version: '26.2'
description: Synchronizes a server resource-pack SHA-1 from a remote URL. description: Synchronizes a server resource-pack SHA-1 from a remote URL.
author: LF1 author: LexianDEV
website: https://lexian.dev
softdepend: [Geyser-Spigot]
commands: commands:
remotepacksync: remotepacksync:
description: Reload RemotePackSync configuration and SHA-1 data. description: Reload RemotePackSync configuration and SHA-1 data.
@@ -0,0 +1,63 @@
package dev.lexian.remotepacksync;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
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 acceptsBedrockClientCompatibleRemoteHeaders() {
assertDoesNotThrow(() -> BedrockPackManager.validateRemotePackHeaders("application/zip", "10406764"));
assertDoesNotThrow(() -> BedrockPackManager.validateRemotePackHeaders("application/zip; charset=binary", "1"));
}
@Test
void rejectsRemoteHeadersThatBedrockClientsCannotUse() {
IOException contentType = assertThrows(IOException.class,
() -> BedrockPackManager.validateRemotePackHeaders("application/octet-stream", "10406764"));
assertEquals("Remote Bedrock pack must return Content-Type: application/zip, but returned application/octet-stream.",
contentType.getMessage());
assertThrows(IOException.class, () -> BedrockPackManager.validateRemotePackHeaders("application/zip", null));
assertThrows(IOException.class, () -> BedrockPackManager.validateRemotePackHeaders("application/zip", "0"));
}
@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;
}
}