Require extensions to be namespaced #14

Merged
owlsys merged 2 commits from TheKodeToad/extension-namespace into main 2024-09-02 12:08:59 -04:00
18 changed files with 247 additions and 155 deletions

View file

@ -10,7 +10,7 @@ credits = [
{ name = "You", roles = ["author", "other_role"] }
]
[frog.extensions]
[frog.extensions.frogloader]
prelaunch = "dev.frogmc.frogloader.example.ExamplePreLaunchExtension"
mixin = "example_mod.mixins.json"
accesswidener = "example_mod.accesswidener"

View file

@ -10,6 +10,6 @@ credits = [
{ name = "You", roles = ["author", "other_role"] }
]
[frog.extensions]
[frog.extensions.frogloader]
prelaunch="dev.frogmc.example.ExampleTestMod"

View file

@ -0,0 +1,17 @@
package dev.frogmc.frogloader.api.exception;
public class ModExtensionResolutionException extends Exception {
public ModExtensionResolutionException(String message) {
super(message);
}
public ModExtensionResolutionException(Throwable cause) {
super(cause);
}
public ModExtensionResolutionException(String message, Throwable cause) {
super(message, cause);
}
}

View file

@ -1,7 +1,7 @@
package dev.frogmc.frogloader.api.extensions;
import dev.frogmc.frogloader.api.mod.ModExtensions;
import dev.frogmc.frogloader.impl.mod.BuiltinExtensions;
import dev.frogmc.frogloader.impl.Constants;
/**
* The Pre-Launch Extension.
@ -13,7 +13,7 @@ public interface PreLaunchExtension {
/**
* This extension's id. This is the key to use in your frog.mod.toml.
*/
String ID = BuiltinExtensions.PRE_LAUNCH;
String ID = Constants.EXTENSION_PRE_LAUNCH;
/**
* The initializer. This method will be invoked when this extension is run.

View file

@ -1,150 +1,74 @@
package dev.frogmc.frogloader.api.mod;
import java.lang.invoke.MethodHandle;
import java.lang.invoke.MethodHandleProxies;
import java.lang.invoke.MethodHandles;
import java.lang.invoke.MethodType;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.lang.reflect.Modifier;
import java.util.Collection;
import java.util.Map;
import java.util.function.Consumer;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import dev.frogmc.frogloader.api.exception.ModExtensionResolutionException;
/**
* This class stores a mod's extensions.
* <p>An extension is a simple key-value mapping of a string name to any value.</p>
* <p>This class further provides utility methods to easily work with extension values of various types,
* especially for extensions providing some form of class or method reference.</p>
* <p>
* An extension is a simple key-value mapping of a string name to any value.
* </p>
* <p>
* This class further provides utility methods to easily work with extension
* values of various types, especially for extensions providing some form of
* class or method reference.
* </p>
*
* @see ModProperties
*/
public final class ModExtensions {
private static final Logger LOGGER = LoggerFactory.getLogger(ModExtensions.class);
private final Map<String, Object> extensions;
private ModExtensions(Map<String, Object> entries) {
extensions = entries;
}
/**
* Retrieve a new instance of this class.
* <p><strong>Internal use only.</strong></p>
*
* @param entries the entries read from the mod's properties file
* @return an instance of this class
*/
public static ModExtensions of(Map<String, Object> entries) {
return new ModExtensions(entries);
}
public interface ModExtensions {
/**
* Get the value of the provided extension name.
*
* @param modId The mod ID this extension is stored under
* @param key The extension name to query
* @param <T> The type of the value of this extension
*
* @return The value of the extension, or null
*/
@SuppressWarnings("unchecked")
public <T> T get(String key) {
return (T) extensions.get(key);
}
<T> T get(String modId, String key);
/**
* Get the value of the provided extension name.
*
* @param key The extension name to query
* @param defaultValue a default value
* @param <T> The type of the value of this extension
* @param modId The mod ID this extension is stored under
* @param key The extension name to query
* @param defaultValue A default value
* @param <T> The type of the value of this extension
*
* @return The value of the extension, or the default value if it isn't present
*/
@SuppressWarnings("unchecked")
public <T> T getOrDefault(String key, T defaultValue) {
return (T) extensions.getOrDefault(key, defaultValue);
}
<T> T getOrDefault(String modId, String key, T defaultValue);
/**
* Run the given action on this extension if it is present.
*
* @param key the extension name to query
* @param action the action to run on the value of the extension if it is present
* @param <T> The type of the value of this extension
* @param modId The mod ID this extension is stored under
* @param key The extension name to query
* @param action The action to run on the value of the extension if it is present
* @param <T> The type of the value of this extension
*/
@SuppressWarnings({"rawtypes", "unchecked"})
public <T> void runIfPresent(String key, Consumer<T> action) {
Object value = get(key);
if (value == null) {
return;
}
if (value instanceof Collection c) {
((Collection<T>) c).forEach(action);
} else {
action.accept((T) value);
}
}
<T> void runIfPresent(String modId, String key, Consumer<T> action);
/**
* Run the given action on this extension if it is present.
* <p>This method simplifies handling references to classes or methods.</p>
* It will first query the string value of the extension and, if it is found, create a new instance of the referenced class
* or invoke the referenced method to retrieve an instance of the provided class. Then the provided action is run on this
* object.
* <p>
* This method simplifies handling references to classes or methods.
* </p>
* It will first query the string value of the extension and, if it is found,
* create a new instance of the referenced class or invoke the referenced method
* to retrieve an instance of the provided class. Then the provided action is
* run on this object.
*
* @param key The name of the extension
* @param type The class type of the extension (The class the extension class is extending/implementing)
* @param modId The mod ID this extension is stored under
* @param key The name of the extension
* @param type The class type of the extension (The class the extension class is extending/implementing)
* @param action The action to run on the newly retrieved instance of the provided class
* @param <T> The type of the class
* @param <T> The type of the class
*
* @throws ModExtensionResolutionException If the reference could not be resolved
*/
@SuppressWarnings({"unchecked"})
public <T> void runIfPresent(String key, Class<T> type, Consumer<T> action) {
runIfPresent(key, (Consumer<String>) s -> {
try {
T object;
if (s.contains("::")) {
String[] parts = s.split("::");
Class<?> extension = Class.forName(parts[0]);
object = (T) handleReference(extension, type, parts[1]);
} else {
object = (T) MethodHandles.lookup().findConstructor(Class.forName(s), MethodType.methodType(void.class)).invoke();
}
if (object != null) {
action.accept(object);
}
} catch (Throwable e) {
LOGGER.warn("Failed to instantiate Extension: ", e);
}
});
}
private Object handleReference(Class<?> clazz, Class<?> type, String ref) throws Throwable {
try {
Field found = clazz.getDeclaredField(ref);
found.setAccessible(true);
return found.get(null);
} catch (Exception ignored) {
}
Method found = null;
for (Method method : clazz.getDeclaredMethods()) {
if (method.getName().equals(ref)) {
if (found != null) {
throw new IllegalArgumentException("Ambiguous method reference: "+ref+" in "+clazz.getName());
}
found = method;
}
}
if (found != null) {
MethodHandle method = MethodHandles.lookup().unreflect(found);
if (!Modifier.isStatic(found.getModifiers())) {
method = method.bindTo(MethodHandles.lookup().findConstructor(clazz, MethodType.methodType(void.class)).invoke());
}
return MethodHandleProxies.asInterfaceInstance(type, method);
}
throw new IllegalArgumentException("Could not find either a static field or a method named '" + ref + "' in class " + clazz.getName() + "!");
}
<T> void runIfPresent(String modId, String key, Class<T> type, Consumer<T> action) throws ModExtensionResolutionException;
}

View file

@ -65,8 +65,9 @@ public interface ModProvider {
/**
* This method will be invoked just before the game is launched. It may be used for a pre-launch entrypoint for mods.
* @param mods The mods loaded by this provider
* @throws Exception If an exception occurs during pre-launch. It will be handled by the loader.
*/
default void preLaunch(Collection<ModProperties> mods) {
default void preLaunch(Collection<ModProperties> mods) throws Exception {
}
/**

View file

@ -0,0 +1,14 @@
package dev.frogmc.frogloader.impl;
import lombok.experimental.UtilityClass;
@UtilityClass
public class Constants {
public final String MOD_ID = "frogloader";
public final String EXTENSION_MIXIN_CONFIG = "mixin";
public final String EXTENSION_INCLUDED_JARS = "included_jars";
public final String EXTENSION_PRE_LAUNCH = "prelaunch";
public final String EXTENSION_ACCESSWIDENER = "accesswidener";
public final String EXTENSION_LOADING_TYPE = "loading_type";
public final String EXTENSION_MOD_PROVIDER = "modprovider";
}

View file

@ -78,7 +78,8 @@ public class FrogLoaderImpl implements FrogLoader {
modIds = collectModIds();
LOGGER.info(ModUtil.getModList(getMods()));
LOGGER.info("Launching...");
modProviders.forEach(plugin -> plugin.preLaunch(mods.get(plugin.id()).values()));
for (ModProvider plugin : modProviders)
plugin.preLaunch(mods.get(plugin.id()).values());
gamePlugin.run();
} catch (Throwable t) {
LoaderGui.execReport(CrashReportGenerator.writeReport(t, getMods()), false);

View file

@ -11,7 +11,6 @@ import dev.frogmc.frogloader.api.plugin.GamePlugin;
import dev.frogmc.frogloader.api.plugin.ModProvider;
import dev.frogmc.frogloader.impl.gui.LoaderGui;
import dev.frogmc.frogloader.impl.mixin.AWProcessor;
import dev.frogmc.frogloader.impl.mod.BuiltinExtensions;
import dev.frogmc.frogloader.impl.mod.ModDependencyResolver;
import dev.frogmc.frogloader.impl.util.CrashReportGenerator;
import org.slf4j.Logger;
@ -47,10 +46,12 @@ public class PluginLoader {
properties.put(plugin.id(), loadedMods);
LOGGER.debug("Loaded {} mod(s) from provider: {}", loadedMods.size(), plugin.id());
loadedMods.forEach(p -> p.extensions().runIfPresent(BuiltinExtensions.MOD_PROVIDER, ModProvider.class, provider -> {
providers.add(provider);
size[0]++;
}));
for (ModProperties p : loadedMods) {
p.extensions().runIfPresent(Constants.MOD_ID, Constants.EXTENSION_MOD_PROVIDER, ModProvider.class, provider -> {
providers.add(provider);
size[0]++;
owlsys marked this conversation as resolved
Review

this single-element array could be replaced with a normal int since it doesn't need to be used anymore (since the lambda was replaced with a loop)

this single-element array could be replaced with a normal int since it doesn't need to be used anymore (since the lambda was replaced with a loop)
Review

I think it is still needed as it is a callback to runIfPresent

I think it is still needed as it is a callback to runIfPresent
Review

I tried to look for other usages already but it's fairly possible I missed something in the diff view.

I tried to look for other usages already but it's fairly possible I missed something in the diff view.
});
}
modProviders.add(plugin);
} catch (Throwable e) {
LOGGER.error("Error during plugin initialisation: ", e);
@ -78,7 +79,7 @@ public class PluginLoader {
private static void initializeModMixins(Collection<ModProperties> loadedMods) {
Map<String, ModProperties> configs = new HashMap<>();
loadedMods.forEach(props -> {
Object o = props.extensions().get(BuiltinExtensions.MIXIN_CONFIG);
Object o = props.extensions().get(Constants.MOD_ID, Constants.EXTENSION_MIXIN_CONFIG);
if (o instanceof String name) {
configs.put(name, props);
Mixins.addConfiguration(name);

View file

@ -14,12 +14,11 @@ import java.util.regex.Pattern;
import java.util.stream.Stream;
import dev.frogmc.frogloader.api.mod.ModProperties;
import dev.frogmc.frogloader.impl.Constants;
import dev.frogmc.frogloader.impl.launch.transformer.AccessWidener;
import dev.frogmc.frogloader.impl.mod.BuiltinExtensions;
public class AWProcessor {
private static final String AW_EXTENSION_NAME = BuiltinExtensions.ACCESSWIDENER;
private static final Predicate<String> HEADER = Pattern.compile("accessWidener\\s+v[12]\\s+.*").asMatchPredicate();
private static final String SEPARATOR = "[\\t ]+";
@ -30,7 +29,7 @@ public class AWProcessor {
Map<String, Map<String, AccessWidener.Entry>> mutations = new ConcurrentHashMap<>();
Set<String> classNames = new ConcurrentSkipListSet<>();
mods.stream().map(ModProperties::extensions).map(e -> e.get(AW_EXTENSION_NAME)).flatMap(o -> {
mods.stream().map(ModProperties::extensions).map(e -> e.get(Constants.MOD_ID, Constants.EXTENSION_ACCESSWIDENER)).flatMap(o -> {
if (o instanceof Collection<?> c) {
return c.stream().map(Object::toString);
}

View file

@ -1,13 +0,0 @@
package dev.frogmc.frogloader.impl.mod;
import lombok.experimental.UtilityClass;
@UtilityClass
public class BuiltinExtensions {
public final String MIXIN_CONFIG = "mixin";
public final String INCLUDED_JARS = "included_jars";
public final String PRE_LAUNCH = "prelaunch";
public final String ACCESSWIDENER = "accesswidener";
public final String LOADING_TYPE = "loading_type";
public final String MOD_PROVIDER = "modprovider";
}

View file

@ -25,7 +25,7 @@ public class JavaModProperties {
"",
Map.of(System.getProperty("java.vm.vendor"), Collections.singleton("Vendor")),
new ModDependencies(Collections.emptySet(), Collections.emptySet(), Collections.emptySet(), Collections.emptySet()),
ModExtensions.of(Collections.emptyMap()), Collections.emptySet());
new ModExtensionsImpl(Collections.emptyMap()), Collections.emptySet());
}
return INSTANCE;
}

View file

@ -11,6 +11,7 @@ import com.google.common.collect.MultimapBuilder;
import dev.frogmc.frogloader.api.mod.ModDependencies;
import dev.frogmc.frogloader.api.mod.ModProperties;
import dev.frogmc.frogloader.api.mod.SemVer;
import dev.frogmc.frogloader.impl.Constants;
import dev.frogmc.frogloader.impl.SemVerParseException;
import lombok.AllArgsConstructor;
import lombok.Getter;
@ -121,7 +122,7 @@ public class ModDependencyResolver {
DependencyEntry value = entry.getValue();
if (!(presentMods.containsKey(s) && value.range.versionMatches(presentOrProvided.get(s)))) { // The dependency isn't fulfilled by any present mods
if (!(provides.containsKey(s) && provides.get(s).stream().map(ProvidedMod::version).allMatch(value.range::versionMatches))) { // The dependency also isn't fulfilled by any provided mods
if (value.origin.extensions().getOrDefault(BuiltinExtensions.LOADING_TYPE, "required").equals("required")) {
if (value.origin.extensions().getOrDefault(Constants.MOD_ID, Constants.EXTENSION_LOADING_TYPE, "required").equals("required")) {
unfulfilled.add(new UnfulfilledDependencyException.Entry(value.origin, s, value.range, presentOrProvided.get(s), value.link(), value.name()));
} else {
LOGGER.debug("Skipping optional mod: {} ({})", value.origin().id(), value.origin().name());

View file

@ -0,0 +1,136 @@
package dev.frogmc.frogloader.impl.mod;
import java.lang.invoke.MethodHandle;
import java.lang.invoke.MethodHandleProxies;
import java.lang.invoke.MethodHandles;
import java.lang.invoke.MethodType;
import java.lang.invoke.WrongMethodTypeException;
import java.lang.reflect.AccessibleObject;
import java.lang.reflect.Field;
import java.lang.reflect.Member;
import java.lang.reflect.Method;
import java.lang.reflect.Modifier;
import java.util.Arrays;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.function.Consumer;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import dev.frogmc.frogloader.api.exception.ModExtensionResolutionException;
import dev.frogmc.frogloader.api.mod.ModExtensions;
public class ModExtensionsImpl implements ModExtensions {
private final Map<Key, Object> map;
public ModExtensionsImpl(Map<Key, Object> map) {
this.map = map;
}
@Override
@SuppressWarnings("unchecked")
public <T> T get(String modId, String key) {
return (T) map.get(new Key(modId, key));
}
@Override
@SuppressWarnings("unchecked")
public <T> T getOrDefault(String modId, String key, T defaultValue) {
return (T) map.getOrDefault(new Key(modId, key), defaultValue);
}
@Override
@SuppressWarnings("unchecked")
public <T> void runIfPresent(String modId, String key, Consumer<T> action) {
Object value = get(modId, key);
if (value == null)
return;
if (value instanceof Collection)
((Collection<T>) value).forEach(action);
else
action.accept((T) value);
}
@Override
public <T> void runIfPresent(String modId, String key, Class<T> type, Consumer<T> action)
throws ModExtensionResolutionException {
String s = get(modId, key);
if (s == null)
return;
T object;
if (s.contains("::")) {
String[] parts = s.split("::", 2);
try {
Class<?> extension = Class.forName(parts[0]);
object = (T) handleReference(extension, type, parts[1]);
} catch (ModExtensionResolutionException e) {
throw e;
} catch (Throwable e) {
throw new ModExtensionResolutionException(e);
}
} else {
try {
Class<?> extension = Class.forName(s);
if (!type.isAssignableFrom(extension))
throw new ModExtensionResolutionException(extension + " does not inherit from " + type);
object = (T) MethodHandles.lookup().findConstructor(extension, MethodType.methodType(void.class))
.invoke();
} catch (ModExtensionResolutionException e) {
throw e;
} catch (Throwable e) {
throw new ModExtensionResolutionException(e);
}
}
if (object != null) {
action.accept(object);
}
}
private Object handleReference(Class<?> owner, Class<?> type, String name)
throws ModExtensionResolutionException, IllegalAccessException {
try {
Field found = owner.getDeclaredField(name);
if (Modifier.isStatic(found.getModifiers())) {
found.setAccessible(true);
return found.get(null);
}
} catch (NoSuchFieldException ignored) {
}
Method found = null;
for (Method method : owner.getDeclaredMethods()) {
if (!Modifier.isStatic(method.getModifiers()))
continue;
if (!method.getName().equals(name))
continue;
if (found != null)
throw new ModExtensionResolutionException("Ambiguous method reference: " + name + " in " + owner.getName());
found = method;
}
if (found != null) {
found.setAccessible(true);
MethodHandle method = MethodHandles.lookup().unreflect(found);
return MethodHandleProxies.asInterfaceInstance(type, method);
}
throw new ModExtensionResolutionException("Could not find a static field or method named '" + name + "' in class " + owner.getName() + "!");
}
public record Key(String modId, String key) {
}
}

View file

@ -153,16 +153,23 @@ public class ModPropertiesReader {
var suggests = parseDependencies.apply("suggests");
var provides = parseDependencies.apply("provides");
UnmodifiableConfig extensionsConfig = config.get("frog.extensions");
Map<ModExtensionsImpl.Key, Object> extensions = new HashMap<>();
if (extensionsConfig != null) {
for (var modEntry : extensionsConfig.entrySet()) {
UnmodifiableConfig modExtensionsConig = modEntry.getValue();
for (var entry : modExtensionsConig.entrySet())
extensions.put(new ModExtensionsImpl.Key(modEntry.getKey(), entry.getKey()), entry.getValue());
}
}
if (!badProperties.isEmpty())
throw new InvalidModPropertiesException(id, sources, badProperties);
UnmodifiableConfig extensionsConfig = config.get("frog.extensions");
Map<String, Object> extensions = new HashMap<>();
if (extensionsConfig != null)
extensionsConfig.entrySet().forEach(entry -> extensions.put(entry.getKey(), entry.getValue()));
return new ModPropertiesImpl(id, name, icon, semVer, license, Collections.unmodifiableMap(credits),
new ModDependencies(depends, breaks, suggests, provides), ModExtensions.of(extensions), sources);
new ModDependencies(depends, breaks, suggests, provides), new ModExtensionsImpl(extensions), sources);
});
private static final Map<String, Parser> versions = Arrays.stream(values()).collect(Collectors.toMap(v -> v.version, v -> v.parser));

View file

@ -6,6 +6,7 @@ import java.util.stream.Collectors;
import com.electronwill.nightconfig.core.UnmodifiableConfig;
import dev.frogmc.frogloader.api.mod.ModProperties;
import dev.frogmc.frogloader.impl.Constants;
public class ModUtil {
@ -66,7 +67,7 @@ public class ModUtil {
private static Map<ModProperties, Collection<String>> getParentMods(Collection<ModProperties> mods) {
Map<ModProperties, Collection<String>> children = new HashMap<>();
for (ModProperties mod : mods) {
List<UnmodifiableConfig> entries = mod.extensions().get(BuiltinExtensions.INCLUDED_JARS);
List<UnmodifiableConfig> entries = mod.extensions().get(Constants.MOD_ID, Constants.EXTENSION_INCLUDED_JARS);
if (entries != null) {
for (var jar : entries) {
String id = jar.get("id");

View file

@ -21,6 +21,7 @@ import dev.frogmc.frogloader.api.mod.ModExtensions;
import dev.frogmc.frogloader.api.mod.ModProperties;
import dev.frogmc.frogloader.api.plugin.GamePlugin;
import dev.frogmc.frogloader.impl.FrogLoaderImpl;
import dev.frogmc.frogloader.impl.mod.ModExtensionsImpl;
import dev.frogmc.frogloader.impl.mod.ModPropertiesImpl;
import dev.frogmc.frogloader.impl.util.SystemProperties;
import dev.frogmc.thyroxine.HttpHelper;
@ -203,6 +204,6 @@ public class MinecraftGamePlugin implements GamePlugin {
MinecraftSemVerImpl.get(version), "MC-EULA",
Map.of("Mojang AB", Collections.singleton("Author")),
new ModDependencies(Collections.emptySet(), Collections.emptySet(), Collections.emptySet(), Collections.emptySet()),
ModExtensions.of(Collections.emptyMap()), Collections.emptySet());
new ModExtensionsImpl(Collections.emptyMap()), Collections.emptySet());
}
}

View file

@ -10,10 +10,11 @@ import java.util.function.Consumer;
import com.electronwill.nightconfig.core.UnmodifiableConfig;
import dev.frogmc.frogloader.api.FrogLoader;
import dev.frogmc.frogloader.api.exception.ModExtensionResolutionException;
import dev.frogmc.frogloader.api.extensions.PreLaunchExtension;
import dev.frogmc.frogloader.api.mod.ModProperties;
import dev.frogmc.frogloader.api.plugin.ModProvider;
import dev.frogmc.frogloader.impl.mod.BuiltinExtensions;
import dev.frogmc.frogloader.impl.Constants;
import dev.frogmc.frogloader.impl.mod.ModPropertiesReader;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@ -115,7 +116,7 @@ public class FrogModProvider implements ModProvider {
if (opt.isPresent()) {
ModProperties p = opt.get();
modPaths.put(mod, p);
List<UnmodifiableConfig> entries = p.extensions().getOrDefault(BuiltinExtensions.INCLUDED_JARS, Collections.emptyList());
List<UnmodifiableConfig> entries = p.extensions().getOrDefault(Constants.MOD_ID, Constants.EXTENSION_INCLUDED_JARS, Collections.emptyList());
if (entries.isEmpty()) {
return;
}
@ -135,7 +136,8 @@ public class FrogModProvider implements ModProvider {
}
@Override
public void preLaunch(Collection<ModProperties> mods) {
mods.forEach(mod -> mod.extensions().runIfPresent(PreLaunchExtension.ID, PreLaunchExtension.class, PreLaunchExtension::onPreLaunch));
public void preLaunch(Collection<ModProperties> mods) throws ModExtensionResolutionException {
owlsys marked this conversation as resolved
Review

the exception should probably be handled inside the loop to just skip the faulty mod instead of breaking and propagating the error upwards

the exception should probably be handled inside the loop to just skip the faulty mod instead of breaking and propagating the error upwards
Review

If a mod wants to handle an error without crashing, shouldn't it have a try catch block in its prelaunch handler?

If a mod wants to handle an error without crashing, shouldn't it have a try catch block in its prelaunch handler?
Review

it may still be okay like this considering mod devs should definitely know when their stuff is malformed or throwing errors

it may still be okay like this considering mod devs should definitely know when their stuff is malformed or throwing errors
for (ModProperties mod : mods)
mod.extensions().runIfPresent(Constants.MOD_ID, Constants.EXTENSION_PRE_LAUNCH, PreLaunchExtension.class, PreLaunchExtension::onPreLaunch);
}
}