Initial RemotePackSync plugin
This commit is contained in:
@@ -0,0 +1,7 @@
|
|||||||
|
target/
|
||||||
|
.tooling/
|
||||||
|
.idea/
|
||||||
|
*.iml
|
||||||
|
.classpath
|
||||||
|
.project
|
||||||
|
.settings/
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 LF1
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
# 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**.
|
||||||
|
|
||||||
|
No NMS, packet libraries, or fork-specific APIs are used.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- Java 25 (Minecraft/Spigot 26.2 requires Java 25)
|
||||||
|
- A Minecraft/Spigot 26.2-compatible server
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mvn clean package
|
||||||
|
```
|
||||||
|
|
||||||
|
The ready-to-install plugin is written to:
|
||||||
|
|
||||||
|
```text
|
||||||
|
target/RemotePackSync.jar
|
||||||
|
```
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
1. Copy `RemotePackSync.jar` into the server's `plugins/` directory.
|
||||||
|
2. Start the server once. RemotePackSync writes `plugins/RemotePackSync/config.yml` automatically.
|
||||||
|
3. Configure the resource-pack and SHA-1 URLs.
|
||||||
|
4. Restart the server, or run `/remotepacksync reload` as an operator.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# Direct URL to the .zip resource pack. Leave blank to disable pack delivery.
|
||||||
|
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.
|
||||||
|
refreshInterval: 300
|
||||||
|
```
|
||||||
|
|
||||||
|
The SHA-1 response must contain a 40-character hexadecimal SHA-1. Both of these standard formats work:
|
||||||
|
|
||||||
|
```text
|
||||||
|
0123456789abcdef0123456789abcdef01234567
|
||||||
|
```
|
||||||
|
|
||||||
|
```text
|
||||||
|
0123456789abcdef0123456789abcdef01234567 server-pack.zip
|
||||||
|
```
|
||||||
|
|
||||||
|
## Behaviour
|
||||||
|
|
||||||
|
- 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.
|
||||||
|
|
||||||
|
## Commands and permissions
|
||||||
|
|
||||||
|
- `/remotepacksync reload` (alias: `/rps reload`) reloads `config.yml` and begins a fresh asynchronous SHA-1 refresh.
|
||||||
|
- Permission: `remotepacksync.reload` (default: `op`).
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
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>
|
||||||
|
|
||||||
|
<groupId>io.github.lf1</groupId>
|
||||||
|
<artifactId>remotepacksync</artifactId>
|
||||||
|
<version>1.0.0</version>
|
||||||
|
<name>RemotePackSync</name>
|
||||||
|
<description>Synchronizes a server resource-pack SHA-1 from a remote URL.</description>
|
||||||
|
|
||||||
|
<properties>
|
||||||
|
<!-- Minecraft/Spigot 26.2 requires Java 25 at runtime. -->
|
||||||
|
<maven.compiler.release>25</maven.compiler.release>
|
||||||
|
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||||
|
</properties>
|
||||||
|
|
||||||
|
<repositories>
|
||||||
|
<repository>
|
||||||
|
<id>spigotmc-repo</id>
|
||||||
|
<url>https://hub.spigotmc.org/nexus/content/repositories/snapshots/</url>
|
||||||
|
</repository>
|
||||||
|
</repositories>
|
||||||
|
|
||||||
|
<dependencies>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.spigotmc</groupId>
|
||||||
|
<artifactId>spigot-api</artifactId>
|
||||||
|
<version>26.2-R0.1-SNAPSHOT</version>
|
||||||
|
<scope>provided</scope>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
|
||||||
|
<build>
|
||||||
|
<finalName>RemotePackSync</finalName>
|
||||||
|
<plugins>
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.apache.maven.plugins</groupId>
|
||||||
|
<artifactId>maven-compiler-plugin</artifactId>
|
||||||
|
<version>3.14.1</version>
|
||||||
|
<configuration>
|
||||||
|
<release>${maven.compiler.release}</release>
|
||||||
|
</configuration>
|
||||||
|
</plugin>
|
||||||
|
</plugins>
|
||||||
|
</build>
|
||||||
|
</project>
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package io.github.lf1.remotepacksync;
|
||||||
|
|
||||||
|
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,83 @@
|
|||||||
|
package io.github.lf1.remotepacksync;
|
||||||
|
|
||||||
|
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 RemotePackSync. Uses only stable Bukkit/Spigot APIs. */
|
||||||
|
public final class RemotePackSyncPlugin extends JavaPlugin implements Listener, CommandExecutor, TabCompleter {
|
||||||
|
private ResourcePackManager resourcePackManager;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onEnable() {
|
||||||
|
saveDefaultConfig();
|
||||||
|
resourcePackManager = new ResourcePackManager(this);
|
||||||
|
getServer().getPluginManager().registerEvents(this, this);
|
||||||
|
|
||||||
|
PluginCommand command = getCommand("remotepacksync");
|
||||||
|
if (command != null) {
|
||||||
|
command.setExecutor(this);
|
||||||
|
command.setTabCompleter(this);
|
||||||
|
}
|
||||||
|
reloadPackConfiguration();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onDisable() {
|
||||||
|
if (resourcePackManager != null) {
|
||||||
|
resourcePackManager.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("remotepacksync.reload")) {
|
||||||
|
sender.sendMessage("§cYou do not have permission to reload RemotePackSync.");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
reloadPackConfiguration();
|
||||||
|
sender.sendMessage("§aRemotePackSync 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 config.yml: " + exception.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package io.github.lf1.remotepacksync;
|
||||||
|
|
||||||
|
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", "RemotePackSync/1.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 io.github.lf1.remotepacksync;
|
||||||
|
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
# Direct URL to the .zip resource pack. Leave blank to disable pack delivery.
|
||||||
|
resourcePackUrl: ""
|
||||||
|
|
||||||
|
# Direct URL to a text file containing the SHA-1 of the resource pack.
|
||||||
|
# RemotePackSync accepts a 40-character hexadecimal SHA-1 anywhere in the response,
|
||||||
|
# so standard '<hash> filename.zip' checksum files are supported.
|
||||||
|
sha1Url: ""
|
||||||
|
|
||||||
|
# Seconds between remote SHA-1 checks. Minimum: 30 seconds.
|
||||||
|
refreshInterval: 300
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
name: RemotePackSync
|
||||||
|
version: '1.0.0'
|
||||||
|
main: io.github.lf1.remotepacksync.RemotePackSyncPlugin
|
||||||
|
api-version: '26.2'
|
||||||
|
description: Synchronizes a server resource-pack SHA-1 from a remote URL.
|
||||||
|
author: LF1
|
||||||
|
commands:
|
||||||
|
remotepacksync:
|
||||||
|
description: Reload RemotePackSync configuration and SHA-1 data.
|
||||||
|
usage: /remotepacksync reload
|
||||||
|
aliases: [rps]
|
||||||
|
permissions:
|
||||||
|
remotepacksync.reload:
|
||||||
|
description: Allows reloading RemotePackSync.
|
||||||
|
default: op
|
||||||
Reference in New Issue
Block a user