Skip to content

Commit

Permalink
Support local file dependencies in JarInJar
Browse files Browse the repository at this point in the history
  • Loading branch information
shartte committed Aug 11, 2024
1 parent 3128c00 commit 948c588
Show file tree
Hide file tree
Showing 9 changed files with 317 additions and 35 deletions.
82 changes: 63 additions & 19 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -217,24 +217,6 @@ neoForge {

To embed external Jar-files into your mod file, you can use the `jarJar` configuration added by the plugin.

#### Subprojects

For example, if you have a coremod in a subproject and want to embed its jar file, you can use the following syntax.

```groovy
dependencies {
jarJar project(":coremod")
}
```

When starting the game, FML will use the group and artifact id of an embedded Jar-file to determine if the same file
has been embedded in other mods.
For subprojects, the group id is the root project name, while the artifact id is the name of the subproject.
Besides the group and artifact id, the Java module name of an embedded Jar also has to be unique across all loaded
Jar files.
To decrease the likelihood of conflicts if no explicit module name is set,
we prefix the filename of embedded subprojects with the group id.

#### External Dependencies

When you want to bundle external dependencies, Jar-in-Jar has to be able to select a single copy of that dependency
Expand Down Expand Up @@ -269,7 +251,69 @@ the [Maven version range format](https://cwiki.apache.org/confluence/display/MAV
| (,1.0],[1.2,) | x <= 1.0 or x >= 1.2. Multiple sets are comma-separated |
| (,1.1),(1.1,) | This excludes 1.1 if it is known not to work in combination with this library |

#### External Dependencies: Runs
#### Local Files

You can also include files built by other tasks in your project, for example, jar tasks of other source sets.

When wanting to build a secondary jar for a coremod or service, you could define a separate source set `service`,
add a jar task to package it and then include the output of that jar like this:

```groovy
sourceSets {
service
}
neoForge {
// ...
mods {
// ...
// To make the service load in dev
'service' {
sourceSet sourceSets.service
}
}
}
def serviceJar = tasks.register("serviceJar", Jar) {
from(sourceSets.service.output)
manifest.attributes["FMLModType"] = "LIBRARY"
archiveClassifier = "service"
manifest {
attributes(
'FMLModType': "LIBRARY",
"Automatic-Module-Name": project.name + "-service"
)
}
}
dependencies {
jarJar files(serviceJar)
}
```

When you include a jar file like this, we use its filename as the artifact-id and its MD5 hash as the version.
It will never be swapped out with embedded libraries of the same name, unless their content matches.

#### Subprojects

For example, if you have a coremod in a subproject and want to embed its jar file, you can use the following syntax.

```groovy
dependencies {
jarJar project(":coremod")
}
```

When starting the game, FML will use the group and artifact id of an embedded Jar-file to determine if the same file
has been embedded in other mods.
For subprojects, the group id is the root project name, while the artifact id is the name of the subproject.
Besides the group and artifact id, the Java module name of an embedded Jar also has to be unique across all loaded
Jar files.
To decrease the likelihood of conflicts if no explicit module name is set,
we prefix the filename of embedded subprojects with the group id.

### External Dependencies: Runs
External dependencies will only be loaded in your runs if they are mods (with a `META-INF/neoforge.mods.toml` file),
or if they have the `FMLModType` entry set in their `META-INF/MANIFEST.MF` file.
Usually, Java libraries do not fit either of these requirements,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,17 +1,27 @@
package net.neoforged.moddevgradle.internal.utils;

import org.gradle.api.GradleException;
import org.jetbrains.annotations.ApiStatus;

import java.io.File;
import java.io.FileInputStream;
import java.io.FilterOutputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.lang.module.ModuleDescriptor;
import java.nio.charset.Charset;
import java.nio.file.AccessDeniedException;
import java.nio.file.AtomicMoveNotSupportedException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.security.DigestInputStream;
import java.security.MessageDigest;
import java.util.HexFormat;
import java.util.List;
import java.util.Optional;
import java.util.jar.JarFile;
import java.util.zip.ZipFile;

@ApiStatus.Internal
public final class FileUtils {
Expand All @@ -23,6 +33,47 @@ public final class FileUtils {
private FileUtils() {
}

/**
* Finds an explicitly defined Java module name in the given Jar file.
*/
public static Optional<String> getExplicitJavaModuleName(File file) throws IOException {
try (var jf = new JarFile(file, false, ZipFile.OPEN_READ, JarFile.runtimeVersion())) {
var moduleInfoEntry = jf.getJarEntry("module-info.class");
if (moduleInfoEntry != null) {
try (var in = jf.getInputStream(moduleInfoEntry)) {
return Optional.of(ModuleDescriptor.read(in).name());
}
}

var manifest = jf.getManifest();
if (manifest == null) {
return Optional.empty();
}

var automaticModuleName = manifest.getMainAttributes().getValue("Automatic-Module-Name");
if (automaticModuleName == null) {
return Optional.empty();
}

return Optional.of(automaticModuleName);
} catch (Exception e) {
throw new IOException("Failed to determine the Java module name of " + file + ": " + e, e);
}

}

public static String hashFile(File file, String algorithm) {
try {
MessageDigest digest = MessageDigest.getInstance(algorithm);
try (var input = new DigestInputStream(new FileInputStream(file), digest)) {
input.transferTo(OutputStream.nullOutputStream());
}
return HexFormat.of().formatHex(digest.digest());
} catch (Exception e) {
throw new GradleException("Failed to hash file " + file, e);
}
}

public static void writeStringSafe(Path destination, String content, Charset charset) throws IOException {
if (!charset.newEncoder().canEncode(content)) {
throw new IllegalArgumentException("The given character set " + charset
Expand Down
56 changes: 51 additions & 5 deletions src/main/java/net/neoforged/moddevgradle/tasks/JarJar.java
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,16 @@
import net.neoforged.jarjar.metadata.MetadataIOHandler;
import net.neoforged.moddevgradle.internal.jarjar.JarJarArtifacts;
import net.neoforged.moddevgradle.internal.jarjar.ResolvedJarJarArtifact;
import net.neoforged.moddevgradle.internal.utils.FileUtils;
import org.gradle.api.DefaultTask;
import org.gradle.api.GradleException;
import org.gradle.api.artifacts.Configuration;
import org.gradle.api.file.ConfigurableFileCollection;
import org.gradle.api.file.DirectoryProperty;
import org.gradle.api.file.FileSystemOperations;
import org.gradle.api.model.ObjectFactory;
import org.gradle.api.tasks.InputFiles;
import org.gradle.api.tasks.Internal;
import org.gradle.api.tasks.Nested;
import org.gradle.api.tasks.OutputDirectory;
import org.gradle.api.tasks.SkipWhenEmpty;
Expand All @@ -22,6 +25,7 @@
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.regex.Matcher;
Expand All @@ -47,25 +51,67 @@ public abstract class JarJar extends DefaultTask {
@OutputDirectory
public abstract DirectoryProperty getOutputDirectory();

@Internal
public abstract DirectoryProperty getBuildDirectory();

private final FileSystemOperations fileSystemOperations;

@Inject
public JarJar(FileSystemOperations fileSystemOperations) {
this.fileSystemOperations = fileSystemOperations;
this.getOutputDirectory().convention(getProject().getLayout().getBuildDirectory().dir("generated/" + getName()));
this.getBuildDirectory().convention(getProject().getLayout().getBuildDirectory());
}

@TaskAction
protected void run() {
List<ResolvedJarJarArtifact> includedJars = getJarJarArtifacts().getResolvedArtifacts().get();
protected void run() throws IOException {
List<ResolvedJarJarArtifact> includedJars = new ArrayList<>(getJarJarArtifacts().getResolvedArtifacts().get());
fileSystemOperations.delete(spec -> spec.delete(getOutputDirectory()));

var artifactFiles = new ArrayList<>(includedJars.stream().map(ResolvedJarJarArtifact::getFile).toList());
// Now we have to handle pure file collection dependencies that do not have artifact ids
for (var file : getInputFiles()) {
if (!artifactFiles.contains(file)) {
// Determine the module-name of the file, which is also what Java will use as the unique key
// when it tries to load the file. No two files can have the same module name, so it seems
// like a fitting key for conflict resolution by JiJ.
var moduleName = FileUtils.getExplicitJavaModuleName(file);
if (moduleName.isEmpty()) {
throw new GradleException("Cannot embed local file dependency " + file + " because it has no explicit Java module name.\n" +
"Please set either 'Automatic-Module-Name' in the Jar manifest, or make it an explicit Java module.\n" +
"This ensures that your file does not conflict with another mods library that has the same or a similar filename.");
}

// Create a hashcode to use as a version
var hashCode = FileUtils.hashFile(file, "MD5");
includedJars.add(new ResolvedJarJarArtifact(
file,
file.getName(),
hashCode,
"[" + hashCode + "]",
"",
moduleName.get()
));
artifactFiles.add(file);
}
}

// Only copy metadata if not empty, always delete
if (!includedJars.isEmpty()) {
fileSystemOperations.copy(spec -> {
spec.into(getOutputDirectory().dir("META-INF/jarjar"));
spec.from(includedJars.stream().map(ResolvedJarJarArtifact::getFile).toArray());
spec.from(artifactFiles.toArray());
for (var includedJar : includedJars) {
// Warn if any included jar is using the cursemaven group.
// We know that cursemaven versions are not comparable, and the same artifact might also be
// available under a "normal" group and artifact from another Maven repository.
// JIJ will not correctly detect the conflicting file at runtime if another mod uses the normal Maven dependency.
// For a description of Curse Maven, see https://www.cursemaven.com/
if ("curse.maven".equals(includedJar.getGroup())) {
getLogger().warn("Embedding dependency {}:{}:{} from cursemaven using JiJ is likely to cause conflicts at runtime when other mods include the same library from a normal Maven repository.",
includedJar.getGroup(), includedJar.getArtifact(), includedJar.getVersion());
}

var originalName = includedJar.getFile().getName();
var embeddedName = includedJar.getEmbeddedFilename();
if (!originalName.equals(embeddedName)) {
Expand All @@ -79,8 +125,8 @@ protected void run() {

@SuppressWarnings("ResultOfMethodCallIgnored")
private Path writeMetadata(List<ResolvedJarJarArtifact> includedJars) {
final Path metadataPath = getJarJarMetadataPath();
final Metadata metadata = createMetadata(includedJars);
var metadataPath = getJarJarMetadataPath();
var metadata = createMetadata(includedJars);

try {
metadataPath.toFile().getParentFile().mkdirs();
Expand Down
Loading

0 comments on commit 948c588

Please sign in to comment.