Release LexianDEV v1.0.0
Build and publish release / Build and publish LexianDEV (push) Successful in 1m0s
Build and publish release / Build and publish LexianDEV (push) Successful in 1m0s
This commit is contained in:
@@ -0,0 +1,324 @@
|
||||
package dev.lexian.lexiandev;
|
||||
|
||||
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 LexianDEV 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("LexianDEV-Bedrock" + extension);
|
||||
Path temporary = Files.createTempFile(directory, "LexianDEV-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.lexiandev;
|
||||
|
||||
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.lexiandev;
|
||||
|
||||
import org.geysermc.geyser.api.event.EventRegistrar;
|
||||
|
||||
/** Separate registrar keeps Geyser entirely optional for Java-only servers. */
|
||||
public final class GeyserEventRegistrar implements EventRegistrar {
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package dev.lexian.lexiandev;
|
||||
|
||||
import org.bukkit.command.Command;
|
||||
import org.bukkit.command.CommandExecutor;
|
||||
import org.bukkit.command.CommandSender;
|
||||
import org.bukkit.command.PluginCommand;
|
||||
import org.bukkit.command.TabCompleter;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.player.PlayerJoinEvent;
|
||||
import org.bukkit.plugin.java.JavaPlugin;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/** Entry point for LexianDEV. Uses only stable Bukkit/Spigot APIs. */
|
||||
public final class LexianDEVPlugin extends JavaPlugin implements Listener, CommandExecutor, TabCompleter {
|
||||
private ResourcePackManager resourcePackManager;
|
||||
private BedrockPackManager bedrockPackManager;
|
||||
|
||||
@Override
|
||||
public void onEnable() {
|
||||
saveDefaultConfig();
|
||||
getConfig().addDefault("bedrockResourcePackUrl", "");
|
||||
getConfig().addDefault("bedrockResourcePack", "");
|
||||
getConfig().options().copyDefaults(true);
|
||||
saveConfig();
|
||||
resourcePackManager = new ResourcePackManager(this);
|
||||
getServer().getPluginManager().registerEvents(this, this);
|
||||
|
||||
PluginCommand command = getCommand("lexiandev");
|
||||
if (command != null) {
|
||||
command.setExecutor(this);
|
||||
command.setTabCompleter(this);
|
||||
}
|
||||
reloadPackConfiguration();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDisable() {
|
||||
if (resourcePackManager != null) {
|
||||
resourcePackManager.stop();
|
||||
}
|
||||
if (bedrockPackManager != null) {
|
||||
bedrockPackManager.stop();
|
||||
}
|
||||
}
|
||||
|
||||
@EventHandler
|
||||
public void onPlayerJoin(PlayerJoinEvent event) {
|
||||
// Give the initial asynchronous checksum refresh a chance to complete first.
|
||||
getServer().getScheduler().runTaskLater(this, () -> resourcePackManager.sendTo(event.getPlayer()), 1L);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onCommand(CommandSender sender, Command command, String label, String[] args) {
|
||||
if (args.length != 1 || !args[0].equalsIgnoreCase("reload")) {
|
||||
sender.sendMessage("§eUsage: /" + label + " reload");
|
||||
return true;
|
||||
}
|
||||
if (!sender.hasPermission("lexiandev.reload")) {
|
||||
sender.sendMessage("§cYou do not have permission to reload LexianDEV.");
|
||||
return true;
|
||||
}
|
||||
|
||||
reloadPackConfiguration();
|
||||
sender.sendMessage("§aLexianDEV configuration reloaded.");
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<String> onTabComplete(CommandSender sender, Command command, String alias, String[] args) {
|
||||
if (args.length == 1 && "reload".startsWith(args[0].toLowerCase())) {
|
||||
return List.of("reload");
|
||||
}
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
private void reloadPackConfiguration() {
|
||||
reloadConfig();
|
||||
try {
|
||||
Optional<PackSettings> settings = PackSettings.from(getConfig());
|
||||
resourcePackManager.configure(settings);
|
||||
} catch (IllegalArgumentException exception) {
|
||||
resourcePackManager.configure(Optional.empty());
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package dev.lexian.lexiandev;
|
||||
|
||||
import org.bukkit.configuration.file.FileConfiguration;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.URISyntaxException;
|
||||
import java.util.Optional;
|
||||
|
||||
/** Immutable, validated configuration for resource-pack delivery. */
|
||||
public record PackSettings(URI resourcePackUrl, URI sha1Url, long refreshIntervalTicks) {
|
||||
private static final long MINIMUM_REFRESH_SECONDS = 30L;
|
||||
|
||||
public static Optional<PackSettings> from(FileConfiguration config) throws IllegalArgumentException {
|
||||
String resourcePackUrl = config.getString("resourcePackUrl", "").trim();
|
||||
String sha1Url = config.getString("sha1Url", "").trim();
|
||||
|
||||
if (resourcePackUrl.isEmpty() && sha1Url.isEmpty()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
if (resourcePackUrl.isEmpty() || sha1Url.isEmpty()) {
|
||||
throw new IllegalArgumentException("resourcePackUrl and sha1Url must both be configured, or both left blank.");
|
||||
}
|
||||
|
||||
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 PackSettings(
|
||||
parseHttpUrl("resourcePackUrl", resourcePackUrl),
|
||||
parseHttpUrl("sha1Url", sha1Url),
|
||||
Math.multiplyExact(refreshSeconds, 20L)
|
||||
));
|
||||
}
|
||||
|
||||
private static URI parseHttpUrl(String key, String value) {
|
||||
try {
|
||||
URI uri = new URI(value);
|
||||
if (!uri.isAbsolute() || uri.getHost() == null
|
||||
|| !("http".equalsIgnoreCase(uri.getScheme()) || "https".equalsIgnoreCase(uri.getScheme()))) {
|
||||
throw new IllegalArgumentException(key + " must be an absolute HTTP or HTTPS URL.");
|
||||
}
|
||||
return uri;
|
||||
} catch (URISyntaxException exception) {
|
||||
throw new IllegalArgumentException(key + " is not a valid URL.", exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package dev.lexian.lexiandev;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.time.Duration;
|
||||
import java.util.HexFormat;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/** Downloads and validates a SHA-1 checksum without downloading the resource pack itself. */
|
||||
public final class RemoteSha1Fetcher {
|
||||
private static final Pattern SHA1_PATTERN = Pattern.compile("(?i)(?<![0-9a-f])[0-9a-f]{40}(?![0-9a-f])");
|
||||
private static final Duration REQUEST_TIMEOUT = Duration.ofSeconds(15);
|
||||
private static final int MAX_RESPONSE_BYTES = 64 * 1024;
|
||||
|
||||
private final HttpClient client = HttpClient.newBuilder()
|
||||
.connectTimeout(Duration.ofSeconds(10))
|
||||
.followRedirects(HttpClient.Redirect.NORMAL)
|
||||
.build();
|
||||
|
||||
public byte[] fetch(URI sha1Url) throws IOException, InterruptedException {
|
||||
HttpRequest request = HttpRequest.newBuilder(sha1Url)
|
||||
.GET()
|
||||
.timeout(REQUEST_TIMEOUT)
|
||||
.header("Accept", "text/plain, text/*;q=0.9, */*;q=0.1")
|
||||
.header("User-Agent", "LexianDEV/v1.0.0")
|
||||
.build();
|
||||
|
||||
HttpResponse<byte[]> response = client.send(request, HttpResponse.BodyHandlers.ofByteArray());
|
||||
if (response.statusCode() < 200 || response.statusCode() >= 300) {
|
||||
throw new IOException("SHA-1 endpoint returned HTTP " + response.statusCode());
|
||||
}
|
||||
if (response.body().length > MAX_RESPONSE_BYTES) {
|
||||
throw new IOException("SHA-1 response exceeds " + MAX_RESPONSE_BYTES + " bytes");
|
||||
}
|
||||
|
||||
String responseText = new String(response.body(), java.nio.charset.StandardCharsets.UTF_8);
|
||||
Matcher matcher = SHA1_PATTERN.matcher(responseText);
|
||||
if (!matcher.find()) {
|
||||
throw new IOException("SHA-1 response does not contain a 40-character hexadecimal SHA-1");
|
||||
}
|
||||
return HexFormat.of().parseHex(matcher.group());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package dev.lexian.lexiandev;
|
||||
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.plugin.java.JavaPlugin;
|
||||
import org.bukkit.scheduler.BukkitTask;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.logging.Level;
|
||||
|
||||
/** Fetches the remote checksum on an async scheduler and delivers it through the Bukkit API. */
|
||||
public final class ResourcePackManager {
|
||||
private final JavaPlugin plugin;
|
||||
private final RemoteSha1Fetcher fetcher;
|
||||
private final AtomicReference<byte[]> currentSha1 = new AtomicReference<>();
|
||||
|
||||
private volatile PackSettings settings;
|
||||
private BukkitTask refreshTask;
|
||||
|
||||
public ResourcePackManager(JavaPlugin plugin) {
|
||||
this.plugin = plugin;
|
||||
this.fetcher = new RemoteSha1Fetcher();
|
||||
}
|
||||
|
||||
public void configure(Optional<PackSettings> newSettings) {
|
||||
stop();
|
||||
currentSha1.set(null);
|
||||
settings = newSettings.orElse(null);
|
||||
|
||||
if (settings == null) {
|
||||
plugin.getLogger().info("Resource-pack delivery is disabled: resourcePackUrl and sha1Url are blank.");
|
||||
return;
|
||||
}
|
||||
|
||||
refreshTask = Bukkit.getScheduler().runTaskTimerAsynchronously(
|
||||
plugin, this::refreshChecksum, 0L, settings.refreshIntervalTicks());
|
||||
plugin.getLogger().info("Remote SHA-1 refresh enabled every " + (settings.refreshIntervalTicks() / 20L) + " seconds.");
|
||||
}
|
||||
|
||||
public void stop() {
|
||||
if (refreshTask != null) {
|
||||
refreshTask.cancel();
|
||||
refreshTask = null;
|
||||
}
|
||||
}
|
||||
|
||||
public void sendTo(Player player) {
|
||||
PackSettings activeSettings = settings;
|
||||
byte[] activeSha1 = currentSha1.get();
|
||||
if (activeSettings == null || activeSha1 == null) {
|
||||
return;
|
||||
}
|
||||
player.setResourcePack(activeSettings.resourcePackUrl().toString(), Arrays.copyOf(activeSha1, activeSha1.length));
|
||||
}
|
||||
|
||||
private void refreshChecksum() {
|
||||
PackSettings activeSettings = settings;
|
||||
if (activeSettings == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
byte[] downloadedSha1 = fetcher.fetch(activeSettings.sha1Url());
|
||||
// A reload may have replaced the settings while this HTTP request was in flight.
|
||||
if (activeSettings != settings) {
|
||||
return;
|
||||
}
|
||||
byte[] previousSha1 = currentSha1.getAndSet(downloadedSha1);
|
||||
if (Arrays.equals(previousSha1, downloadedSha1)) {
|
||||
return;
|
||||
}
|
||||
|
||||
Bukkit.getScheduler().runTask(plugin, () -> {
|
||||
if (activeSettings != settings) {
|
||||
return;
|
||||
}
|
||||
for (Player player : Bukkit.getOnlinePlayers()) {
|
||||
sendTo(player);
|
||||
}
|
||||
plugin.getLogger().info("Resource-pack SHA-1 refreshed; sent the pack to "
|
||||
+ Bukkit.getOnlinePlayers().size() + " online player(s).");
|
||||
});
|
||||
} catch (InterruptedException exception) {
|
||||
Thread.currentThread().interrupt();
|
||||
} catch (Exception exception) {
|
||||
plugin.getLogger().log(Level.WARNING, "Could not refresh remote resource-pack SHA-1: "
|
||||
+ exception.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user