Skip to content

Commit ce7e09d

Browse files
committed
Support local file dependencies in JarInJar
1 parent c316565 commit ce7e09d

File tree

9 files changed

+316
-35
lines changed

9 files changed

+316
-35
lines changed

README.md

Lines changed: 63 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -186,24 +186,6 @@ neoForge {
186186

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

189-
#### Subprojects
190-
191-
For example, if you have a coremod in a subproject and want to embed its jar file, you can use the following syntax.
192-
193-
```groovy
194-
dependencies {
195-
jarJar project(":coremod")
196-
}
197-
```
198-
199-
When starting the game, FML will use the group and artifact id of an embedded Jar-file to determine if the same file
200-
has been embedded in other mods.
201-
For subprojects, the group id is the root project name, while the artifact id is the name of the subproject.
202-
Besides the group and artifact id, the Java module name of an embedded Jar also has to be unique across all loaded
203-
Jar files.
204-
To decrease the likelihood of conflicts if no explicit module name is set,
205-
we prefix the filename of embedded subprojects with the group id.
206-
207189
#### External Dependencies
208190

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

241-
#### External Dependencies: Runs
223+
#### Local Files
224+
225+
You can also include files built by other tasks in your project, for example, jar tasks of other source sets.
226+
227+
When wanting to build a secondary jar for a coremod or service, you could define a separate source set `service`,
228+
add a jar task to package it and then include the output of that jar like this:
229+
230+
```groovy
231+
sourceSets {
232+
service
233+
}
234+
235+
236+
neoForge {
237+
// ...
238+
mods {
239+
// ...
240+
// To make the service load in dev
241+
'service' {
242+
sourceSet sourceSets.service
243+
}
244+
}
245+
}
246+
247+
def serviceJar = tasks.register("serviceJar", Jar) {
248+
from(sourceSets.service.output)
249+
manifest.attributes["FMLModType"] = "LIBRARY"
250+
archiveClassifier = "service"
251+
manifest {
252+
attributes(
253+
'FMLModType': "LIBRARY",
254+
"Automatic-Module-Name": project.name + "-service"
255+
)
256+
}
257+
}
258+
259+
dependencies {
260+
jarJar files(serviceJar)
261+
}
262+
```
263+
264+
When you include a jar file like this, we use its filename as the artifact-id and its MD5 hash as the version.
265+
It will never be swapped out with embedded libraries of the same name, unless their content matches.
266+
267+
#### Subprojects
268+
269+
For example, if you have a coremod in a subproject and want to embed its jar file, you can use the following syntax.
270+
271+
```groovy
272+
dependencies {
273+
jarJar project(":coremod")
274+
}
275+
```
276+
277+
When starting the game, FML will use the group and artifact id of an embedded Jar-file to determine if the same file
278+
has been embedded in other mods.
279+
For subprojects, the group id is the root project name, while the artifact id is the name of the subproject.
280+
Besides the group and artifact id, the Java module name of an embedded Jar also has to be unique across all loaded
281+
Jar files.
282+
To decrease the likelihood of conflicts if no explicit module name is set,
283+
we prefix the filename of embedded subprojects with the group id.
284+
285+
### External Dependencies: Runs
242286
External dependencies will only be loaded in your runs if they are mods (with a `META-INF/neoforge.mods.toml` file),
243287
or if they have the `FMLModType` entry set in their `META-INF/MANIFEST.MF` file.
244288
Usually, Java libraries do not fit either of these requirements,

src/main/java/net/neoforged/moddevgradle/internal/utils/FileUtils.java

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,27 @@
11
package net.neoforged.moddevgradle.internal.utils;
22

3+
import org.gradle.api.GradleException;
34
import org.jetbrains.annotations.ApiStatus;
45

6+
import java.io.File;
7+
import java.io.FileInputStream;
58
import java.io.FilterOutputStream;
69
import java.io.IOException;
710
import java.io.OutputStream;
11+
import java.lang.module.ModuleDescriptor;
812
import java.nio.charset.Charset;
913
import java.nio.file.AccessDeniedException;
1014
import java.nio.file.AtomicMoveNotSupportedException;
1115
import java.nio.file.Files;
1216
import java.nio.file.Path;
1317
import java.nio.file.StandardCopyOption;
18+
import java.security.DigestInputStream;
19+
import java.security.MessageDigest;
20+
import java.util.HexFormat;
1421
import java.util.List;
22+
import java.util.Optional;
23+
import java.util.jar.JarFile;
24+
import java.util.zip.ZipFile;
1525

1626
@ApiStatus.Internal
1727
public final class FileUtils {
@@ -23,6 +33,47 @@ public final class FileUtils {
2333
private FileUtils() {
2434
}
2535

36+
/**
37+
* Finds an explicitly defined Java module name in the given Jar file.
38+
*/
39+
public static Optional<String> getExplicitJavaModuleName(File file) throws IOException {
40+
try (var jf = new JarFile(file, false, ZipFile.OPEN_READ, JarFile.runtimeVersion())) {
41+
var moduleInfoEntry = jf.getJarEntry("module-info.class");
42+
if (moduleInfoEntry != null) {
43+
try (var in = jf.getInputStream(moduleInfoEntry)) {
44+
return Optional.of(ModuleDescriptor.read(in).name());
45+
}
46+
}
47+
48+
var manifest = jf.getManifest();
49+
if (manifest == null) {
50+
return Optional.empty();
51+
}
52+
53+
var automaticModuleName = manifest.getMainAttributes().getValue("Automatic-Module-Name");
54+
if (automaticModuleName == null) {
55+
return Optional.empty();
56+
}
57+
58+
return Optional.of(automaticModuleName);
59+
} catch (Exception e) {
60+
throw new IOException("Failed to determine the Java module name of " + file + ": " + e, e);
61+
}
62+
63+
}
64+
65+
public static String hashFile(File file, String algorithm) {
66+
try {
67+
MessageDigest digest = MessageDigest.getInstance(algorithm);
68+
try (var input = new DigestInputStream(new FileInputStream(file), digest)) {
69+
input.transferTo(OutputStream.nullOutputStream());
70+
}
71+
return HexFormat.of().formatHex(digest.digest());
72+
} catch (Exception e) {
73+
throw new GradleException("Failed to hash file " + file, e);
74+
}
75+
}
76+
2677
public static void writeStringSafe(Path destination, String content, Charset charset) throws IOException {
2778
if (!charset.newEncoder().canEncode(content)) {
2879
throw new IllegalArgumentException("The given character set " + charset

src/main/java/net/neoforged/moddevgradle/tasks/JarJar.java

Lines changed: 51 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,16 @@
44
import net.neoforged.jarjar.metadata.MetadataIOHandler;
55
import net.neoforged.moddevgradle.internal.jarjar.JarJarArtifacts;
66
import net.neoforged.moddevgradle.internal.jarjar.ResolvedJarJarArtifact;
7+
import net.neoforged.moddevgradle.internal.utils.FileUtils;
78
import org.gradle.api.DefaultTask;
9+
import org.gradle.api.GradleException;
810
import org.gradle.api.artifacts.Configuration;
911
import org.gradle.api.file.ConfigurableFileCollection;
1012
import org.gradle.api.file.DirectoryProperty;
1113
import org.gradle.api.file.FileSystemOperations;
1214
import org.gradle.api.model.ObjectFactory;
1315
import org.gradle.api.tasks.InputFiles;
16+
import org.gradle.api.tasks.Internal;
1417
import org.gradle.api.tasks.Nested;
1518
import org.gradle.api.tasks.OutputDirectory;
1619
import org.gradle.api.tasks.SkipWhenEmpty;
@@ -22,6 +25,7 @@
2225
import java.nio.file.Files;
2326
import java.nio.file.Path;
2427
import java.nio.file.StandardOpenOption;
28+
import java.util.ArrayList;
2529
import java.util.Collection;
2630
import java.util.List;
2731
import java.util.regex.Matcher;
@@ -47,25 +51,67 @@ public abstract class JarJar extends DefaultTask {
4751
@OutputDirectory
4852
public abstract DirectoryProperty getOutputDirectory();
4953

54+
@Internal
55+
public abstract DirectoryProperty getBuildDirectory();
56+
5057
private final FileSystemOperations fileSystemOperations;
5158

5259
@Inject
5360
public JarJar(FileSystemOperations fileSystemOperations) {
5461
this.fileSystemOperations = fileSystemOperations;
5562
this.getOutputDirectory().convention(getProject().getLayout().getBuildDirectory().dir("generated/" + getName()));
63+
this.getBuildDirectory().convention(getProject().getLayout().getBuildDirectory());
5664
}
5765

5866
@TaskAction
59-
protected void run() {
60-
List<ResolvedJarJarArtifact> includedJars = getJarJarArtifacts().getResolvedArtifacts().get();
67+
protected void run() throws IOException {
68+
List<ResolvedJarJarArtifact> includedJars = new ArrayList<>(getJarJarArtifacts().getResolvedArtifacts().get());
6169
fileSystemOperations.delete(spec -> spec.delete(getOutputDirectory()));
6270

71+
var artifactFiles = new ArrayList<>(includedJars.stream().map(ResolvedJarJarArtifact::getFile).toList());
72+
// Now we have to handle pure file collection dependencies that do not have artifact ids
73+
for (var file : getInputFiles()) {
74+
if (!artifactFiles.contains(file)) {
75+
// Determine the module-name of the file, which is also what Java will use as the unique key
76+
// when it tries to load the file. No two files can have the same module name, so it seems
77+
// like a fitting key for conflict resolution by JiJ.
78+
var moduleName = FileUtils.getExplicitJavaModuleName(file);
79+
if (moduleName.isEmpty()) {
80+
throw new GradleException("Cannot embed local file dependency " + file + " because it has no explicit Java module name.\n" +
81+
"Please set either 'Automatic-Module-Name' in the Jar manifest, or make it an explicit Java module.\n" +
82+
"This ensures that your file does not conflict with another mods library that has the same or a similar filename.");
83+
}
84+
85+
// Create a hashcode to use as a version
86+
var hashCode = FileUtils.hashFile(file, "MD5");
87+
includedJars.add(new ResolvedJarJarArtifact(
88+
file,
89+
file.getName(),
90+
hashCode,
91+
"[" + hashCode + "]",
92+
"",
93+
moduleName.get()
94+
));
95+
artifactFiles.add(file);
96+
}
97+
}
98+
6399
// Only copy metadata if not empty, always delete
64100
if (!includedJars.isEmpty()) {
65101
fileSystemOperations.copy(spec -> {
66102
spec.into(getOutputDirectory().dir("META-INF/jarjar"));
67-
spec.from(includedJars.stream().map(ResolvedJarJarArtifact::getFile).toArray());
103+
spec.from(artifactFiles.toArray());
68104
for (var includedJar : includedJars) {
105+
// Warn if any included jar is using the cursemaven group.
106+
// We know that cursemaven versions are not comparable, and the same artifact might also be
107+
// available under a "normal" group and artifact from another Maven repository.
108+
// JIJ will not correctly detect the conflicting file at runtime if another mod uses the normal Maven dependency.
109+
// For a description of Curse Maven, see https://www.cursemaven.com/
110+
if ("curse.maven".equals(includedJar.getGroup())) {
111+
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.",
112+
includedJar.getGroup(), includedJar.getArtifact(), includedJar.getVersion());
113+
}
114+
69115
var originalName = includedJar.getFile().getName();
70116
var embeddedName = includedJar.getEmbeddedFilename();
71117
if (!originalName.equals(embeddedName)) {
@@ -79,8 +125,8 @@ protected void run() {
79125

80126
@SuppressWarnings("ResultOfMethodCallIgnored")
81127
private Path writeMetadata(List<ResolvedJarJarArtifact> includedJars) {
82-
final Path metadataPath = getJarJarMetadataPath();
83-
final Metadata metadata = createMetadata(includedJars);
128+
var metadataPath = getJarJarMetadataPath();
129+
var metadata = createMetadata(includedJars);
84130

85131
try {
86132
metadataPath.toFile().getParentFile().mkdirs();

0 commit comments

Comments
 (0)