diff --git a/api/src/main/java/org/geysermc/geyser/api/event/bedrock/SessionLoadResourcePacksEvent.java b/api/src/main/java/org/geysermc/geyser/api/event/bedrock/SessionLoadResourcePacksEvent.java index 1894750bc..d5bcd1784 100644 --- a/api/src/main/java/org/geysermc/geyser/api/event/bedrock/SessionLoadResourcePacksEvent.java +++ b/api/src/main/java/org/geysermc/geyser/api/event/bedrock/SessionLoadResourcePacksEvent.java @@ -45,7 +45,7 @@ public abstract class SessionLoadResourcePacksEvent extends ConnectionEvent { } /** - * Gets an unmodifiable list of {@link ResourcePack}s that will be sent to the client. + * Gets an unmodifiable list of {@link ResourcePack}'s that will be sent to the client. * * @return an unmodifiable list of resource packs that will be sent to the client. */ diff --git a/api/src/main/java/org/geysermc/geyser/api/event/lifecycle/GeyserDefineResourcePacksEvent.java b/api/src/main/java/org/geysermc/geyser/api/event/lifecycle/GeyserDefineResourcePacksEvent.java new file mode 100644 index 000000000..2613f8c5b --- /dev/null +++ b/api/src/main/java/org/geysermc/geyser/api/event/lifecycle/GeyserDefineResourcePacksEvent.java @@ -0,0 +1,72 @@ +/* + * Copyright (c) 2019-2023 GeyserMC. http://geysermc.org + * + * 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. + * + * @author GeyserMC + * @link https://github.com/GeyserMC/Geyser + */ + +package org.geysermc.geyser.api.event.lifecycle; + +import org.checkerframework.checker.nullness.qual.NonNull; +import org.geysermc.event.Event; +import org.geysermc.geyser.api.pack.ResourcePack; + +import java.util.Collection; +import java.util.List; +import java.util.UUID; + +/** + * Called when {@link ResourcePack}'s are loaded within Geyser. + */ +public abstract class GeyserDefineResourcePacksEvent implements Event { + + /** + * Gets an unmodifiable list of {@link ResourcePack}'s that will be sent to clients. + * + * @return an unmodifiable list of resource packs that will be sent to clients. + */ + public abstract @NonNull List resourcePacks(); + + /** + * Registers a {@link ResourcePack} to be sent to clients. + * + * @param resourcePack a resource pack that will be sent to clients. + * @return true if the resource pack was added successfully, + * or false if already present + */ + public abstract boolean register(@NonNull ResourcePack resourcePack); + + /** + * Registers a collection of {@link ResourcePack}'s to be sent to clients. + * + * @param resourcePacks a collection of resource pack's that will be sent to clients. + */ + public abstract void registerAll(@NonNull Collection resourcePacks); + + + /** + * Unregisters a {@link ResourcePack} from being sent to clients. + * + * @param uuid the UUID of the resource pack to remove. + * @return true whether the resource pack was removed successfully. + */ + public abstract boolean unregister(@NonNull UUID uuid); +} diff --git a/api/src/main/java/org/geysermc/geyser/api/event/lifecycle/GeyserLoadResourcePacksEvent.java b/api/src/main/java/org/geysermc/geyser/api/event/lifecycle/GeyserLoadResourcePacksEvent.java index e9b283ecb..98b34126e 100644 --- a/api/src/main/java/org/geysermc/geyser/api/event/lifecycle/GeyserLoadResourcePacksEvent.java +++ b/api/src/main/java/org/geysermc/geyser/api/event/lifecycle/GeyserLoadResourcePacksEvent.java @@ -33,8 +33,11 @@ import java.util.List; /** * Called when resource packs are loaded within Geyser. + * @deprecated Use {@link GeyserDefineResourcePacksEvent} instead. * * @param resourcePacks a mutable list of the currently listed resource packs */ + +@Deprecated public record GeyserLoadResourcePacksEvent(@NonNull List resourcePacks) implements Event { } diff --git a/api/src/main/java/org/geysermc/geyser/api/pack/PackCodec.java b/api/src/main/java/org/geysermc/geyser/api/pack/PackCodec.java index 15d9220a9..b09b09f89 100644 --- a/api/src/main/java/org/geysermc/geyser/api/pack/PackCodec.java +++ b/api/src/main/java/org/geysermc/geyser/api/pack/PackCodec.java @@ -26,6 +26,7 @@ package org.geysermc.geyser.api.pack; import org.checkerframework.checker.nullness.qual.NonNull; +import org.checkerframework.checker.nullness.qual.Nullable; import org.geysermc.geyser.api.GeyserApi; import java.io.IOException; @@ -94,4 +95,27 @@ public abstract class PackCodec { public static PackCodec path(@NonNull Path path) { return GeyserApi.api().provider(PathPackCodec.class, path); } + + /** + * Creates a new pack provider from the given url with no content key. + * + * @param url the url to create the pack provider from + * @return the new pack provider + */ + @NonNull + public static PackCodec url(@NonNull String url) { + return url(url, null); + } + + /** + * Creates a new pack provider from the given url and content key. + * + * @param url the url to create the pack provider from + * @param contentKey the content key, leave empty or null if pack is not encrypted + * @return the new pack provider + */ + @NonNull + public static PackCodec url(@NonNull String url, @Nullable String contentKey) { + return GeyserApi.api().provider(UrlPackCodec.class, url, contentKey); + } } diff --git a/api/src/main/java/org/geysermc/geyser/api/pack/UrlPackCodec.java b/api/src/main/java/org/geysermc/geyser/api/pack/UrlPackCodec.java new file mode 100644 index 000000000..8f279ae0d --- /dev/null +++ b/api/src/main/java/org/geysermc/geyser/api/pack/UrlPackCodec.java @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2019-2023 GeyserMC. http://geysermc.org + * + * 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. + * + * @author GeyserMC + * @link https://github.com/GeyserMC/Geyser + */ + +package org.geysermc.geyser.api.pack; + +import org.checkerframework.checker.nullness.qual.NonNull; + +/** + * Represents a pack codec that creates a resource + * pack from a URL. + *

+ * Due to Bedrock limitations, the URL must: + *

    + *
  • be a direct download link to a .zip or .mcpack resource pack
  • + *
  • use the application type `application/zip` and set a correct content length
  • + *
+ */ +public abstract class UrlPackCodec extends PackCodec { + + /** + * Gets the URL to the resource pack location. + * + * @return the URL of the resource pack + */ + @NonNull + public abstract String url(); + + /** + * If the remote pack has an encryption key, it must be specified here. + * This will return empty if none is specified. + * + * @return the encryption key of the resource pack + */ + @NonNull + public abstract String contentKey(); +} diff --git a/core/src/main/java/org/geysermc/geyser/GeyserImpl.java b/core/src/main/java/org/geysermc/geyser/GeyserImpl.java index 8febf4d21..1a84c2788 100644 --- a/core/src/main/java/org/geysermc/geyser/GeyserImpl.java +++ b/core/src/main/java/org/geysermc/geyser/GeyserImpl.java @@ -81,6 +81,7 @@ import org.geysermc.geyser.network.GameProtocol; import org.geysermc.geyser.network.netty.GeyserServer; import org.geysermc.geyser.registry.BlockRegistries; import org.geysermc.geyser.registry.Registries; +import org.geysermc.geyser.registry.loader.ResourcePackLoader; import org.geysermc.geyser.registry.provider.ProviderSupplier; import org.geysermc.geyser.scoreboard.ScoreboardUpdater; import org.geysermc.geyser.session.GeyserSession; @@ -719,7 +720,7 @@ public class GeyserImpl implements GeyserApi, EventRegistrar { runIfNonNull(newsHandler, NewsHandler::shutdown); runIfNonNull(erosionUnixListener, UnixSocketClientListener::close); - Registries.RESOURCE_PACKS.get().clear(); + ResourcePackLoader.clear(); this.setEnabled(false); } diff --git a/core/src/main/java/org/geysermc/geyser/configuration/GeyserConfiguration.java b/core/src/main/java/org/geysermc/geyser/configuration/GeyserConfiguration.java index 88bb98171..fb66a002a 100644 --- a/core/src/main/java/org/geysermc/geyser/configuration/GeyserConfiguration.java +++ b/core/src/main/java/org/geysermc/geyser/configuration/GeyserConfiguration.java @@ -96,6 +96,8 @@ public interface GeyserConfiguration { boolean isForceResourcePacks(); + List getResourcePackUrls(); + @SuppressWarnings("BooleanMethodIsAlwaysInverted") boolean isXboxAchievementsEnabled(); diff --git a/core/src/main/java/org/geysermc/geyser/configuration/GeyserJacksonConfiguration.java b/core/src/main/java/org/geysermc/geyser/configuration/GeyserJacksonConfiguration.java index 81ac824e4..e4e82165f 100644 --- a/core/src/main/java/org/geysermc/geyser/configuration/GeyserJacksonConfiguration.java +++ b/core/src/main/java/org/geysermc/geyser/configuration/GeyserJacksonConfiguration.java @@ -136,6 +136,9 @@ public abstract class GeyserJacksonConfiguration implements GeyserConfiguration @JsonProperty("force-resource-packs") private boolean forceResourcePacks = true; + @JsonProperty("resource-pack-urls") + private List resourcePackUrls = new ArrayList<>(); + @JsonProperty("xbox-achievements-enabled") private boolean xboxAchievementsEnabled = false; diff --git a/core/src/main/java/org/geysermc/geyser/event/type/GeyserDefineResourcePacksEventImpl.java b/core/src/main/java/org/geysermc/geyser/event/type/GeyserDefineResourcePacksEventImpl.java new file mode 100644 index 000000000..b97712ca9 --- /dev/null +++ b/core/src/main/java/org/geysermc/geyser/event/type/GeyserDefineResourcePacksEventImpl.java @@ -0,0 +1,71 @@ +/* + * Copyright (c) 2019-2023 GeyserMC. http://geysermc.org + * + * 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. + * + * @author GeyserMC + * @link https://github.com/GeyserMC/Geyser + */ + +package org.geysermc.geyser.event.type; + +import lombok.Getter; +import org.checkerframework.checker.nullness.qual.NonNull; +import org.geysermc.geyser.api.event.lifecycle.GeyserDefineResourcePacksEvent; +import org.geysermc.geyser.api.pack.ResourcePack; + +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +@Getter +public class GeyserDefineResourcePacksEventImpl extends GeyserDefineResourcePacksEvent { + + private final Map packs; + + public GeyserDefineResourcePacksEventImpl(Map packMap) { + this.packs = packMap; + } + + @Override + public @NonNull List resourcePacks() { + return List.copyOf(packs.values()); + } + + @Override + public boolean register(@NonNull ResourcePack resourcePack) { + String packID = resourcePack.manifest().header().uuid().toString(); + if (packs.containsValue(resourcePack) || packs.containsKey(packID)) { + return false; + } + packs.put(resourcePack.manifest().header().uuid().toString(), resourcePack); + return true; + } + + @Override + public void registerAll(@NonNull Collection resourcePacks) { + resourcePacks.forEach(this::register); + } + + @Override + public boolean unregister(@NonNull UUID uuid) { + return packs.remove(uuid.toString()) != null; + } +} diff --git a/core/src/main/java/org/geysermc/geyser/network/UpstreamPacketHandler.java b/core/src/main/java/org/geysermc/geyser/network/UpstreamPacketHandler.java index c998ee4c3..751981255 100644 --- a/core/src/main/java/org/geysermc/geyser/network/UpstreamPacketHandler.java +++ b/core/src/main/java/org/geysermc/geyser/network/UpstreamPacketHandler.java @@ -59,10 +59,12 @@ import org.geysermc.geyser.api.network.AuthType; import org.geysermc.geyser.api.pack.PackCodec; import org.geysermc.geyser.api.pack.ResourcePack; import org.geysermc.geyser.api.pack.ResourcePackManifest; +import org.geysermc.geyser.api.pack.UrlPackCodec; import org.geysermc.geyser.event.type.SessionLoadResourcePacksEventImpl; import org.geysermc.geyser.pack.GeyserResourcePack; import org.geysermc.geyser.registry.BlockRegistries; import org.geysermc.geyser.registry.Registries; +import org.geysermc.geyser.registry.loader.ResourcePackLoader; import org.geysermc.geyser.session.GeyserSession; import org.geysermc.geyser.session.PendingMicrosoftAuthentication; import org.geysermc.geyser.text.GeyserLocale; @@ -80,7 +82,7 @@ import java.util.OptionalInt; public class UpstreamPacketHandler extends LoggingPacketHandler { private boolean networkSettingsRequested = false; - private final Deque packsToSent = new ArrayDeque<>(); + private final Deque packsToSend = new ArrayDeque<>(); private final CompressionStrategy compressionStrategy; private SessionLoadResourcePacksEventImpl resourcePackLoadEvent; @@ -204,6 +206,14 @@ public class UpstreamPacketHandler extends LoggingPacketHandler { ResourcePacksInfoPacket resourcePacksInfo = new ResourcePacksInfoPacket(); resourcePacksInfo.getResourcePackInfos().addAll(this.resourcePackLoadEvent.infoPacketEntries()); + + // TODO add url pack entries + /* + if (pack.codec() instanceof UrlPackCodec urlPackCodec) { + resourcePacksInfo.getCDNEntries().add(new ResourcePacksInfoPacket.CDNEntry( + header.uuid() + "_" + header.version(), urlPackCodec.url())); + } + */ resourcePacksInfo.setForcedToAccept(GeyserImpl.getInstance().getConfig().isForceResourcePacks()); session.sendUpstreamPacket(resourcePacksInfo); @@ -214,7 +224,7 @@ public class UpstreamPacketHandler extends LoggingPacketHandler { @Override public PacketSignal handle(ResourcePackClientResponsePacket packet) { switch (packet.getStatus()) { - case COMPLETED: + case COMPLETED -> { if (geyser.getConfig().getRemote().authType() != AuthType.ONLINE) { session.authenticate(session.getAuthData().name()); } else if (!couldLoginUserByName(session.getAuthData().name())) { @@ -222,14 +232,12 @@ public class UpstreamPacketHandler extends LoggingPacketHandler { session.connect(); } geyser.getLogger().info(GeyserLocale.getLocaleStringLog("geyser.network.connect", session.getAuthData().name())); - break; - - case SEND_PACKS: - packsToSent.addAll(packet.getPackIds()); - sendPackDataInfo(packsToSent.pop()); - break; - - case HAVE_ALL_PACKS: + } + case SEND_PACKS -> { + packsToSend.addAll(packet.getPackIds()); + sendPackDataInfo(packsToSend.pop()); + } + case HAVE_ALL_PACKS -> { ResourcePackStackPacket stackPacket = new ResourcePackStackPacket(); stackPacket.setExperimentsPreviouslyToggled(false); stackPacket.setForcedToAccept(false); // Leaving this as false allows the player to choose to download or not @@ -245,11 +253,8 @@ public class UpstreamPacketHandler extends LoggingPacketHandler { stackPacket.getExperiments().add(new ExperimentData("updateAnnouncedLive2023", true)); session.sendUpstreamPacket(stackPacket); - break; - - default: - session.disconnect("disconnectionScreen.resourcePack"); - break; + } + default -> session.disconnect("disconnectionScreen.resourcePack"); } return PacketSignal.HANDLED; @@ -309,6 +314,11 @@ public class UpstreamPacketHandler extends LoggingPacketHandler { ResourcePackChunkDataPacket data = new ResourcePackChunkDataPacket(); PackCodec codec = pack.codec(); + // If a remote pack ends up here, that usually implies that a client was not able to download the pack + if (codec instanceof UrlPackCodec urlPackCodec) { + ResourcePackLoader.testRemotePack(session, urlPackCodec, packet.getPackId().toString(), packet.getPackVersion()); + } + data.setChunkIndex(packet.getChunkIndex()); data.setProgress((long) packet.getChunkIndex() * GeyserResourcePack.CHUNK_SIZE); data.setPackVersion(packet.getPackVersion()); @@ -330,8 +340,8 @@ public class UpstreamPacketHandler extends LoggingPacketHandler { session.sendUpstreamPacket(data); // Check if it is the last chunk and send next pack in queue when available. - if (remainingSize <= GeyserResourcePack.CHUNK_SIZE && !packsToSent.isEmpty()) { - sendPackDataInfo(packsToSent.pop()); + if (remainingSize <= GeyserResourcePack.CHUNK_SIZE && !packsToSend.isEmpty()) { + sendPackDataInfo(packsToSend.pop()); } return PacketSignal.HANDLED; diff --git a/core/src/main/java/org/geysermc/geyser/pack/GeyserResourcePack.java b/core/src/main/java/org/geysermc/geyser/pack/GeyserResourcePack.java index 6e7a35628..4de140305 100644 --- a/core/src/main/java/org/geysermc/geyser/pack/GeyserResourcePack.java +++ b/core/src/main/java/org/geysermc/geyser/pack/GeyserResourcePack.java @@ -36,10 +36,10 @@ import java.util.Collection; import java.util.Objects; public record GeyserResourcePack( - PackCodec codec, - ResourcePackManifest manifest, - String contentKey, - OptionHolder options + @NonNull PackCodec codec, + @NonNull ResourcePackManifest manifest, + @NonNull String contentKey, + @NonNull OptionHolder options ) implements ResourcePack { /** diff --git a/core/src/main/java/org/geysermc/geyser/pack/url/GeyserUrlPackCodec.java b/core/src/main/java/org/geysermc/geyser/pack/url/GeyserUrlPackCodec.java new file mode 100644 index 000000000..f8b5ba7fb --- /dev/null +++ b/core/src/main/java/org/geysermc/geyser/pack/url/GeyserUrlPackCodec.java @@ -0,0 +1,103 @@ +/* + * Copyright (c) 2019-2023 GeyserMC. http://geysermc.org + * + * 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. + * + * @author GeyserMC + * @link https://github.com/GeyserMC/Geyser + */ + +package org.geysermc.geyser.pack.url; + +import lombok.Getter; +import org.checkerframework.checker.nullness.qual.NonNull; +import org.checkerframework.checker.nullness.qual.Nullable; +import org.geysermc.geyser.api.pack.PathPackCodec; +import org.geysermc.geyser.api.pack.ResourcePack; +import org.geysermc.geyser.api.pack.UrlPackCodec; +import org.geysermc.geyser.registry.loader.ResourcePackLoader; + +import java.io.IOException; +import java.nio.channels.SeekableByteChannel; +import java.util.Objects; + +public class GeyserUrlPackCodec extends UrlPackCodec { + private final @NonNull String url; + private final @Nullable String contentKey; + @Getter + private PathPackCodec fallback; + + public GeyserUrlPackCodec(String url) throws IllegalArgumentException { + this(url, null); + } + + public GeyserUrlPackCodec(@NonNull String url, @Nullable String contentKey) throws IllegalArgumentException { + Objects.requireNonNull(url, "url cannot be null"); + this.url = url; + this.contentKey = contentKey; + } + + @Override + public byte @NonNull [] sha256() { + Objects.requireNonNull(fallback, "must call #create() before attempting to get the sha256!"); + return fallback.sha256(); + } + + @Override + public long size() { + Objects.requireNonNull(fallback, "must call #create() before attempting to get the size!"); + return fallback.size(); + } + + @Override + public @NonNull SeekableByteChannel serialize(@NonNull ResourcePack resourcePack) throws IOException { + Objects.requireNonNull(fallback, "must call #create() before attempting to serialize!!"); + return fallback.serialize(resourcePack); + } + + @Override + @NonNull + public ResourcePack create() { + if (this.fallback == null) { + try { + ResourcePackLoader.downloadPack(url, false).whenComplete((pack, throwable) -> { + if (throwable != null) { + throw new IllegalArgumentException(throwable); + } else if (pack != null) { + this.fallback = pack; + } + }).join(); // Needed to ensure that we don't attempt to read a pack before downloading/checking it + } catch (Exception e) { + throw new IllegalArgumentException("Failed to download pack from the url %s (reason: %s)!".formatted(url, e.getMessage())); + } + } + + return ResourcePackLoader.readPack(this); + } + + @Override + public @NonNull String url() { + return this.url; + } + + @Override + public @NonNull String contentKey() { + return this.contentKey != null ? contentKey : ""; + } +} diff --git a/core/src/main/java/org/geysermc/geyser/registry/loader/ProviderRegistryLoader.java b/core/src/main/java/org/geysermc/geyser/registry/loader/ProviderRegistryLoader.java index b80612667..1eec8e259 100644 --- a/core/src/main/java/org/geysermc/geyser/registry/loader/ProviderRegistryLoader.java +++ b/core/src/main/java/org/geysermc/geyser/registry/loader/ProviderRegistryLoader.java @@ -40,6 +40,7 @@ import org.geysermc.geyser.api.item.custom.CustomItemData; import org.geysermc.geyser.api.item.custom.CustomItemOptions; import org.geysermc.geyser.api.item.custom.NonVanillaCustomItemData; import org.geysermc.geyser.api.pack.PathPackCodec; +import org.geysermc.geyser.api.pack.UrlPackCodec; import org.geysermc.geyser.api.pack.option.PriorityOption; import org.geysermc.geyser.api.pack.option.SubpackOption; import org.geysermc.geyser.event.GeyserEventRegistrar; @@ -58,6 +59,7 @@ import org.geysermc.geyser.level.block.GeyserNonVanillaCustomBlockData; import org.geysermc.geyser.pack.option.GeyserPriorityOption; import org.geysermc.geyser.pack.option.GeyserSubpackOption; import org.geysermc.geyser.pack.path.GeyserPathPackCodec; +import org.geysermc.geyser.pack.url.GeyserUrlPackCodec; import org.geysermc.geyser.registry.provider.ProviderSupplier; import java.nio.file.Path; @@ -88,6 +90,7 @@ public class ProviderRegistryLoader implements RegistryLoader, Prov providers.put(PathPackCodec.class, args -> new GeyserPathPackCodec((Path) args[0])); providers.put(PriorityOption.class, args -> new GeyserPriorityOption((double) args[0])); providers.put(SubpackOption.class, args -> new GeyserSubpackOption((String) args[0])); + providers.put(UrlPackCodec.class, args -> new GeyserUrlPackCodec((String) args[0])); // items providers.put(CustomItemData.Builder.class, args -> new GeyserCustomItemData.Builder()); diff --git a/core/src/main/java/org/geysermc/geyser/registry/loader/ResourcePackLoader.java b/core/src/main/java/org/geysermc/geyser/registry/loader/ResourcePackLoader.java index 1c688c895..21f329709 100644 --- a/core/src/main/java/org/geysermc/geyser/registry/loader/ResourcePackLoader.java +++ b/core/src/main/java/org/geysermc/geyser/registry/loader/ResourcePackLoader.java @@ -25,27 +25,39 @@ package org.geysermc.geyser.registry.loader; +import com.google.common.cache.Cache; +import com.google.common.cache.CacheBuilder; +import it.unimi.dsi.fastutil.objects.Object2ObjectOpenHashMap; +import org.checkerframework.checker.nullness.qual.Nullable; import org.geysermc.geyser.GeyserImpl; import org.geysermc.geyser.api.event.lifecycle.GeyserLoadResourcePacksEvent; +import org.geysermc.geyser.api.pack.PathPackCodec; import org.geysermc.geyser.api.pack.ResourcePack; +import org.geysermc.geyser.api.pack.ResourcePackManifest; +import org.geysermc.geyser.api.pack.UrlPackCodec; +import org.geysermc.geyser.event.type.GeyserDefineResourcePacksEventImpl; import org.geysermc.geyser.pack.GeyserResourcePack; import org.geysermc.geyser.pack.GeyserResourcePackManifest; import org.geysermc.geyser.pack.SkullResourcePackManager; import org.geysermc.geyser.pack.path.GeyserPathPackCodec; +import org.geysermc.geyser.pack.url.GeyserUrlPackCodec; +import org.geysermc.geyser.registry.Registries; +import org.geysermc.geyser.session.GeyserSession; import org.geysermc.geyser.text.GeyserLocale; import org.geysermc.geyser.util.FileUtils; +import org.geysermc.geyser.util.WebUtils; +import java.io.File; import java.io.IOException; import java.nio.charset.StandardCharsets; import java.nio.file.FileSystems; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.PathMatcher; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.UUID; +import java.util.*; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicReference; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -53,10 +65,18 @@ import java.util.zip.ZipEntry; import java.util.zip.ZipFile; /** - * Loads {@link ResourcePack}s within a {@link Path} directory, firing the {@link GeyserLoadResourcePacksEvent}. + * Loads {@link ResourcePack}s within a {@link Path} directory, firing the {@link GeyserDefineResourcePacksEventImpl}. */ public class ResourcePackLoader implements RegistryLoader> { + /** + * Used to keep track of remote resource packs that the client rejected. + * If a client rejects such a pack, it falls back to the old method, and Geyser serves a cached variant. + */ + private static final Cache CACHED_FAILED_PACKS = CacheBuilder.newBuilder() + .expireAfterWrite(1, TimeUnit.HOURS) + .build(); + static final PathMatcher PACK_MATCHER = FileSystems.getDefault().getPathMatcher("glob:**.{zip,mcpack}"); private static final boolean SHOW_RESOURCE_PACK_LENGTH_WARNING = Boolean.parseBoolean(System.getProperty("Geyser.ShowResourcePackLengthWarning", "true")); @@ -66,7 +86,7 @@ public class ResourcePackLoader implements RegistryLoader load(Path directory) { - Map packMap = new HashMap<>(); + Map packMap = new Object2ObjectOpenHashMap<>(); if (!Files.exists(directory)) { try { @@ -95,6 +115,7 @@ public class ResourcePackLoader implements RegistryLoader manifestReference = new AtomicReference<>(); try (ZipFile zip = new ZipFile(path.toFile()); @@ -129,7 +185,7 @@ public class ResourcePackLoader implements RegistryLoader { String name = x.getName(); if (SHOW_RESOURCE_PACK_LENGTH_WARNING && name.length() >= 80) { - GeyserImpl.getInstance().getLogger().warning("The resource pack " + path.getFileName() + GeyserImpl.getInstance().getLogger().warning("The resource pack " + packLocation + " has a file in it that meets or exceeds 80 characters in its path (" + name + ", " + name.length() + " characters long). This will cause problems on some Bedrock platforms." + " Please rename it to be shorter, or reduce the amount of folders needed to get to the file."); @@ -148,17 +204,192 @@ public class ResourcePackLoader implements RegistryLoader loadRemotePacks() { + GeyserImpl instance = GeyserImpl.getInstance(); + // Unable to make this a static variable, as the test would fail + final Path cachedCdnPacksDirectory = instance.getBootstrap().getConfigFolder().resolve("cache").resolve("remote_packs"); + + if (!Files.exists(cachedCdnPacksDirectory)) { + try { + Files.createDirectories(cachedCdnPacksDirectory); + } catch (IOException e) { + instance.getLogger().error("Could not create remote pack cache directory", e); + return new Object2ObjectOpenHashMap<>(); + } + } + + List remotePackUrls = instance.getConfig().getResourcePackUrls(); + Map packMap = new Object2ObjectOpenHashMap<>(); + + for (String url : remotePackUrls) { + try { + GeyserUrlPackCodec codec = new GeyserUrlPackCodec(url); + ResourcePack pack = codec.create(); + packMap.put(pack.manifest().header().uuid().toString(), pack); + } catch (Throwable e) { + instance.getLogger().error(GeyserLocale.getLocaleStringLog("geyser.resource_pack.broken", url)); + instance.getLogger().error(e.getMessage()); + if (instance.getLogger().isDebug()) { + e.printStackTrace(); + } + } + } + + // After loading the new resource packs: let's clean up the old + cleanupRemotePacks(); + + return packMap; + } + + /** + * Used when a Bedrock client requests a Bedrock resource pack from the server when it should be downloading it + * from a remote provider. Since this would be called each time a Bedrock client requests a piece of the Bedrock pack, + * this uses a cache to ensure we aren't re-checking a dozen times. + * + * @param codec the codec of the resource pack that wasn't successfully downloaded by a Bedrock client. + */ + public static void testRemotePack(GeyserSession session, UrlPackCodec codec, String packId, String packVersion) { + if (CACHED_FAILED_PACKS.getIfPresent(codec.url()) == null) { + String url = codec.url(); + CACHED_FAILED_PACKS.put(url, codec); + GeyserImpl.getInstance().getLogger().warning( + "A Bedrock client (%s, playing on %s / %s) was not able to download the resource pack at %s. Checking for changes now:" + .formatted(session.bedrockUsername(), session.getClientData().getDeviceOs().name(), session.getClientData().getDeviceId(), codec.url()) + ); + + downloadPack(codec.url(), true).whenComplete((pathPackCodec, e) -> { + if (e != null) { + GeyserImpl.getInstance().getLogger().error(GeyserLocale.getLocaleStringLog("geyser.resource_pack.broken", url), e); + if (GeyserImpl.getInstance().getLogger().isDebug()) { + e.printStackTrace(); + if (pathPackCodec != null) { + deleteFile(pathPackCodec.path()); + } + return; + } + } + + if (pathPackCodec == null) { + return; // Already warned about + } + + ResourcePack newPack = ResourcePackLoader.readPack(pathPackCodec.path()); + UUID newUUID = newPack.manifest().header().uuid(); + if (newUUID.toString().equals(packId)) { + if (packVersion.equals(newPack.manifest().header().version().toString())) { + GeyserImpl.getInstance().getLogger().info("No version or pack change detected: Was the resource pack server down?"); + } else { + GeyserImpl.getInstance().getLogger().info("Detected a new resource pack version (%s, old version %s) for pack at %s!" + .formatted(packVersion, newPack.manifest().header().version().toString(), url)); + } + } else { + GeyserImpl.getInstance().getLogger().info("Detected a new resource pack at the url %s!".formatted(url)); + } + + // This should be safe to do as we're not directly using registries to read packs. + // Instead, they're cached per-session in the SessionLoadResourcePacks event + Registries.RESOURCE_PACKS.get().remove(packId); + Registries.RESOURCE_PACKS.get().put(newUUID.toString(), newPack); + + if (codec instanceof GeyserUrlPackCodec geyserUrlPackCodec && geyserUrlPackCodec.getFallback() != null) { + // Other implementations could, in theory, not have a fallback + Path path = geyserUrlPackCodec.getFallback().path(); + try { + GeyserImpl.getInstance().getScheduledThread().schedule(() -> { + CACHED_FAILED_PACKS.invalidate(packId); + deleteFile(path); + }, 5, TimeUnit.MINUTES); + } catch (RejectedExecutionException exception) { + // No scheduling here, probably because we're shutting down? + deleteFile(path); + } + } + }); + } + } + + private static void deleteFile(Path path) { + if (path.toFile().exists()) { + try { + Files.delete(path); + } catch (IOException e) { + GeyserImpl.getInstance().getLogger().error("Unable to delete old pack! " + e.getMessage()); + e.printStackTrace(); + } + } + } + + public static CompletableFuture<@Nullable PathPackCodec> downloadPack(String url, boolean testing) throws IllegalArgumentException { + return CompletableFuture.supplyAsync(() -> { + Path path = WebUtils.downloadRemotePack(url, testing); + + // Already warned about these above + if (path == null) { + return null; + } + + // Check if the pack is a .zip or .mcpack file + if (!PACK_MATCHER.matches(path)) { + throw new IllegalArgumentException("Invalid pack format from url %s! Not a .zip or .mcpack file.".formatted(url)); + } + + try { + try (ZipFile zip = new ZipFile(path.toFile())) { + if (zip.stream().noneMatch(x -> x.getName().contains("manifest.json"))) { + throw new IllegalArgumentException("The pack at the url " + url + " does not contain a manifest file!"); + } + + // Check if a "manifest.json" or "pack_manifest.json" file is located directly in the zip... does not work otherwise. + // (something like MyZip.zip/manifest.json) will not, but will if it's a subfolder (MyPack.zip/MyPack/manifest.json) + if (zip.getEntry("manifest.json") != null || zip.getEntry("pack_manifest.json") != null) { + if (GeyserImpl.getInstance().getLogger().isDebug()) { + GeyserImpl.getInstance().getLogger().info("The remote resource pack from " + url + " contains a manifest.json file at the root of the zip file. " + + "This may not work for remote packs, and could cause Bedrock clients to fall back to request the pack from the server. " + + "Please put the pack file in a subfolder, and provide that zip in the URL."); + } + } + } + } catch (IOException e) { + throw new IllegalArgumentException(GeyserLocale.getLocaleStringLog("geyser.resource_pack.broken", url), e); + } + + return new GeyserPathPackCodec(path); + }); + } + + public static void clear() { + Registries.RESOURCE_PACKS.get().clear(); + CACHED_FAILED_PACKS.invalidateAll(); + + } + + public static void cleanupRemotePacks() { + File cacheFolder = GeyserImpl.getInstance().getBootstrap().getConfigFolder().resolve("cache").resolve("remote_packs").toFile(); + if (!cacheFolder.exists()) { + return; + } + + int count = 0; + final long expireTime = (((long) 1000 * 60 * 60)); // one hour + for (File imageFile : Objects.requireNonNull(cacheFolder.listFiles())) { + if (imageFile.lastModified() < System.currentTimeMillis() - expireTime) { + //noinspection ResultOfMethodCallIgnored + imageFile.delete(); + count++; + } + } + + if (count > 0) { + GeyserImpl.getInstance().getLogger().debug(String.format("Removed %d cached resource pack files as they are no longer in use!", count)); } } } diff --git a/core/src/main/java/org/geysermc/geyser/scoreboard/Scoreboard.java b/core/src/main/java/org/geysermc/geyser/scoreboard/Scoreboard.java index acce86f4d..48a45fd27 100644 --- a/core/src/main/java/org/geysermc/geyser/scoreboard/Scoreboard.java +++ b/core/src/main/java/org/geysermc/geyser/scoreboard/Scoreboard.java @@ -51,7 +51,7 @@ import java.util.stream.Collectors; import static org.geysermc.geyser.scoreboard.UpdateType.*; public final class Scoreboard { - private static final boolean SHOW_SCOREBOARD_LOGS = Boolean.parseBoolean(System.getProperty("Geyser.ShowScoreboardLogs", "true")); + private static final boolean SHOW_SCOREBOARD_LOGS = Boolean.parseBoolean(System.getProperty("Geyser.ShowScoreboardLogs", "false")); private static final boolean ADD_TEAM_SUGGESTIONS = Boolean.parseBoolean(System.getProperty("Geyser.AddTeamSuggestions", "true")); private final GeyserSession session; diff --git a/core/src/main/java/org/geysermc/geyser/scoreboard/ScoreboardUpdater.java b/core/src/main/java/org/geysermc/geyser/scoreboard/ScoreboardUpdater.java index 395eb9576..c28985784 100644 --- a/core/src/main/java/org/geysermc/geyser/scoreboard/ScoreboardUpdater.java +++ b/core/src/main/java/org/geysermc/geyser/scoreboard/ScoreboardUpdater.java @@ -48,7 +48,7 @@ public final class ScoreboardUpdater extends Thread { static { GeyserConfiguration config = GeyserImpl.getInstance().getConfig(); FIRST_SCORE_PACKETS_PER_SECOND_THRESHOLD = Math.min(config.getScoreboardPacketThreshold(), SECOND_SCORE_PACKETS_PER_SECOND_THRESHOLD); - DEBUG_ENABLED = config.isDebugMode(); + DEBUG_ENABLED = Boolean.parseBoolean(System.getProperty("Geyser.ShowScoreboardLogs", "false")) && config.isDebugMode(); } private final GeyserImpl geyser = GeyserImpl.getInstance(); diff --git a/core/src/main/java/org/geysermc/geyser/skin/SkinProvider.java b/core/src/main/java/org/geysermc/geyser/skin/SkinProvider.java index aec1fa4de..f3ad0be2f 100644 --- a/core/src/main/java/org/geysermc/geyser/skin/SkinProvider.java +++ b/core/src/main/java/org/geysermc/geyser/skin/SkinProvider.java @@ -167,7 +167,7 @@ public class SkinProvider { if (count > 0) { GeyserImpl.getInstance().getLogger().debug(String.format("Removed %d cached image files as they have expired", count)); } - }, 10, 1440, TimeUnit.MINUTES); + }, 10, 1, TimeUnit.DAYS); } } diff --git a/core/src/main/java/org/geysermc/geyser/util/WebUtils.java b/core/src/main/java/org/geysermc/geyser/util/WebUtils.java index 1b7f2d9d9..50ff0ede2 100644 --- a/core/src/main/java/org/geysermc/geyser/util/WebUtils.java +++ b/core/src/main/java/org/geysermc/geyser/util/WebUtils.java @@ -28,22 +28,26 @@ package org.geysermc.geyser.util; import com.fasterxml.jackson.databind.JsonNode; import org.checkerframework.checker.nullness.qual.Nullable; import org.geysermc.geyser.GeyserImpl; +import org.geysermc.geyser.GeyserLogger; import javax.naming.directory.Attribute; import javax.naming.directory.InitialDirContext; import java.io.*; -import java.net.HttpURLConnection; -import java.net.URL; -import java.net.URLEncoder; +import java.net.*; import java.nio.charset.StandardCharsets; import java.nio.file.Files; +import java.nio.file.Path; import java.nio.file.Paths; import java.nio.file.StandardCopyOption; +import java.util.Arrays; +import java.util.List; import java.util.Map; import java.util.stream.Stream; public class WebUtils { + private static final Path REMOTE_PACK_CACHE = GeyserImpl.getInstance().getBootstrap().getConfigFolder().resolve("cache").resolve("remote_packs"); + /** * Makes a web request to the given URL and returns the body as a string * @@ -96,6 +100,114 @@ public class WebUtils { } } + /** + * Checks a remote pack URL to see if it is valid + * If it is, it will download the pack file and return a path to it + * + * @param url The URL to check + * @param force If true, the pack will be downloaded even if it is cached to a separate location. + * @return Path to the downloaded pack file, or null if it was unable to be loaded + */ + @SuppressWarnings("ResultOfMethodCallIgnored") + public static @Nullable Path downloadRemotePack(String url, boolean force) { + GeyserLogger logger = GeyserImpl.getInstance().getLogger(); + try { + HttpURLConnection con = (HttpURLConnection) new URL(url).openConnection(); + + con.setConnectTimeout(10000); + con.setReadTimeout(10000); + con.setRequestProperty("User-Agent", "Geyser-" + GeyserImpl.getInstance().getPlatformType().platformName() + "/" + GeyserImpl.VERSION); + con.setInstanceFollowRedirects(true); + + int responseCode = con.getResponseCode(); + if (responseCode >= 400) { + throw new IllegalStateException(String.format("Invalid response code from remote pack at URL: %s (code: %d)", url, responseCode)); + } + + int size = con.getContentLength(); + String type = con.getContentType(); + + if (size <= 0) { + throw new IllegalArgumentException(String.format("Invalid content length received from remote pack at URL: %s (size: %d)", url, size)); + } + + // This doesn't seem to be a requirement (anymore?). Logging to debug as it might be interesting though. + if (type == null || !type.equals("application/zip")) { + logger.warning(String.format("Application type received from remote pack at URL %s uses the content type: %s! This may result in packs not loading " + + "for Bedrock players.", url, type)); + } + + Path packMetadata = REMOTE_PACK_CACHE.resolve(url.hashCode() + ".metadata"); + Path downloadLocation; + + // If we downloaded this pack before, reuse it if the ETag matches. + if (Files.exists(packMetadata) && !force) { + try { + List metadata = Files.readAllLines(packMetadata, StandardCharsets.UTF_8); + int cachedSize = Integer.parseInt(metadata.get(0)); + String cachedEtag = metadata.get(1); + long cachedLastModified = Long.parseLong(metadata.get(2)); + downloadLocation = REMOTE_PACK_CACHE.resolve(metadata.get(3)); + + if (cachedSize == size && cachedEtag.equals(con.getHeaderField("ETag")) && + cachedLastModified == con.getLastModified() && downloadLocation.toFile().exists()) { + logger.debug("Using cached pack (%s) for %s.".formatted(downloadLocation.getFileName(), url)); + downloadLocation.toFile().setLastModified(System.currentTimeMillis()); + packMetadata.toFile().setLastModified(System.currentTimeMillis()); + return downloadLocation; + } + } catch (IOException e) { + GeyserImpl.getInstance().getLogger().error("Failed to read cached pack metadata! " + e); + try { + Files.delete(packMetadata); + } catch (Exception exception) { + GeyserImpl.getInstance().getLogger().error("Failed to delete pack metadata!", exception); + } + } + } + + downloadLocation = REMOTE_PACK_CACHE.resolve(url.hashCode() + "_" + System.currentTimeMillis() + ".zip"); + Files.copy(con.getInputStream(), downloadLocation, StandardCopyOption.REPLACE_EXISTING); + + // This needs to match as the client fails to download the pack otherwise + long downloadSize = Files.size(downloadLocation); + if (downloadSize != size) { + Files.delete(downloadLocation); + throw new IllegalStateException("Size mismatch with resource pack at url: %s. Downloaded pack has %s bytes, expected %s bytes!" + .formatted(url, downloadSize, size)); + } + + try { + Files.write( + packMetadata, + Arrays.asList( + String.valueOf(size), + con.getHeaderField("ETag"), + String.valueOf(con.getLastModified()), + downloadLocation.getFileName().toString() + )); + packMetadata.toFile().setLastModified(System.currentTimeMillis()); + } catch (IOException e) { + GeyserImpl.getInstance().getLogger().error("Failed to write cached pack metadata: " + e.getMessage()); + Files.delete(packMetadata); + Files.delete(downloadLocation); + return null; + } + + downloadLocation.toFile().setLastModified(System.currentTimeMillis()); + return downloadLocation; + } catch (MalformedURLException e) { + throw new IllegalArgumentException("Unable to download resource pack from malformed URL %s! ".formatted(url)); + } catch (SocketTimeoutException | ConnectException e) { + logger.error("Unable to download pack from url %s due to network error! ( %s )".formatted(url, e.getMessage())); + logger.debug(e); + } catch (IOException e) { + throw new IllegalStateException("Unable to download and save remote resource pack from: %s ( %s )!".formatted(url, e.getMessage())); + } + return null; + } + + /** * Post a string to the given URL * diff --git a/core/src/main/resources/config.yml b/core/src/main/resources/config.yml index a5fe2072b..e1adc2ca1 100644 --- a/core/src/main/resources/config.yml +++ b/core/src/main/resources/config.yml @@ -167,6 +167,14 @@ above-bedrock-nether-building: false # want to download the resource packs. force-resource-packs: true +# A list of links to send to the client to download resource packs from. +# These must be direct links to the resource pack, not a link to a page containing the resource pack. +# If you enter a link here, Geyser will download the resource pack once to check if it's in a valid format. +# See https://wiki.geysermc.org/geyser/packs for more info. +resource-pack-urls: + # GeyserOptionalPack + - "https://download.geysermc.org/v2/projects/geyseroptionalpack/versions/latest/builds/latest/downloads/geyseroptionalpack" + # Allows Xbox achievements to be unlocked. xbox-achievements-enabled: false