Skip to content

Commit 5017446

Browse files
shartteTechnici4nMatyrobbrt
authored
Support local file dependencies in Jar-in-Jar (#94)
Co-authored-by: Technici4n <[email protected]> Co-authored-by: Matyrobbrt <[email protected]>
1 parent c32dc0b commit 5017446

File tree

11 files changed

+453
-49
lines changed

11 files changed

+453
-49
lines changed

README.md

Lines changed: 62 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -227,24 +227,6 @@ neoForge {
227227

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

230-
#### Subprojects
231-
232-
For example, if you have a coremod in a subproject and want to embed its jar file, you can use the following syntax.
233-
234-
```groovy
235-
dependencies {
236-
jarJar project(":coremod")
237-
}
238-
```
239-
240-
When starting the game, FML will use the group and artifact id of an embedded Jar-file to determine if the same file
241-
has been embedded in other mods.
242-
For subprojects, the group id is the root project name, while the artifact id is the name of the subproject.
243-
Besides the group and artifact id, the Java module name of an embedded Jar also has to be unique across all loaded
244-
Jar files.
245-
To decrease the likelihood of conflicts if no explicit module name is set,
246-
we prefix the filename of embedded subprojects with the group id.
247-
248230
#### External Dependencies
249231

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

282-
#### External Dependencies: Runs
264+
#### Local Files
265+
266+
You can also include files built by other tasks in your project, for example, jar tasks of other source sets.
267+
268+
When wanting to build a secondary jar for a coremod or plugin, you could define a separate source set `plugin`,
269+
add a jar task to package it and then include the output of that jar like this:
270+
271+
```groovy
272+
sourceSets {
273+
plugin
274+
}
275+
276+
277+
neoForge {
278+
// ...
279+
mods {
280+
// ...
281+
// To make the plugin load in dev
282+
'plugin' {
283+
sourceSet sourceSets.plugin
284+
}
285+
}
286+
}
287+
288+
def pluginJar = tasks.register("pluginJar", Jar) {
289+
from(sourceSets.plugin.output)
290+
archiveClassifier = "plugin"
291+
manifest {
292+
attributes(
293+
'FMLModType': "LIBRARY",
294+
"Automatic-Module-Name": project.name + "-plugin"
295+
)
296+
}
297+
}
298+
299+
dependencies {
300+
jarJar files(pluginJar)
301+
}
302+
```
303+
304+
When you include a jar file like this, we use its filename as the artifact-id and its MD5 hash as the version.
305+
It will never be swapped out with embedded libraries of the same name, unless their content matches.
306+
307+
#### Subprojects
308+
309+
For example, if you have a coremod in a subproject and want to embed its jar file, you can use the following syntax.
310+
311+
```groovy
312+
dependencies {
313+
jarJar project(":coremod")
314+
}
315+
```
316+
317+
When starting the game, FML will use the group and artifact id of an embedded Jar-file to determine if the same file
318+
has been embedded in other mods.
319+
For subprojects, the group id is the root project name, while the artifact id is the name of the subproject.
320+
Besides the group and artifact id, the Java module name of an embedded Jar also has to be unique across all loaded
321+
Jar files.
322+
To decrease the likelihood of conflicts if no explicit module name is set,
323+
we prefix the filename of embedded subprojects with the group id.
324+
325+
### External Dependencies: Runs
283326
External dependencies will only be loaded in your runs if they are mods (with a `META-INF/neoforge.mods.toml` file),
284327
or if they have the `FMLModType` entry set in their `META-INF/MANIFEST.MF` file.
285328
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: 57 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@
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.Project;
911
import org.gradle.api.artifacts.Configuration;
1012
import org.gradle.api.attributes.Bundling;
@@ -18,6 +20,7 @@
1820
import org.gradle.api.model.ObjectFactory;
1921
import org.gradle.api.plugins.JavaPluginExtension;
2022
import org.gradle.api.tasks.InputFiles;
23+
import org.gradle.api.tasks.Internal;
2124
import org.gradle.api.tasks.Nested;
2225
import org.gradle.api.tasks.OutputDirectory;
2326
import org.gradle.api.tasks.SkipWhenEmpty;
@@ -31,6 +34,7 @@
3134
import java.nio.file.Files;
3235
import java.nio.file.Path;
3336
import java.nio.file.StandardOpenOption;
37+
import java.util.ArrayList;
3438
import java.util.Collection;
3539
import java.util.List;
3640
import java.util.regex.Matcher;
@@ -57,12 +61,16 @@ public abstract class JarJar extends DefaultTask {
5761
@OutputDirectory
5862
public abstract DirectoryProperty getOutputDirectory();
5963

64+
@Internal
65+
public abstract DirectoryProperty getBuildDirectory();
66+
6067
private final FileSystemOperations fileSystemOperations;
6168

6269
@Inject
6370
public JarJar(FileSystemOperations fileSystemOperations) {
6471
this.fileSystemOperations = fileSystemOperations;
6572
this.getOutputDirectory().convention(getProject().getLayout().getBuildDirectory().dir("generated/" + getName()));
73+
this.getBuildDirectory().convention(getProject().getLayout().getBuildDirectory());
6674
setGroup(DEFAULT_GROUP);
6775
}
6876

@@ -100,16 +108,60 @@ public static TaskProvider<JarJar> registerWithConfiguration(Project project, St
100108
}
101109

102110
@TaskAction
103-
protected void run() {
104-
List<ResolvedJarJarArtifact> includedJars = getJarJarArtifacts().getResolvedArtifacts().get();
111+
protected void run() throws IOException {
112+
List<ResolvedJarJarArtifact> includedJars = new ArrayList<>(getJarJarArtifacts().getResolvedArtifacts().get());
105113
fileSystemOperations.delete(spec -> spec.delete(getOutputDirectory()));
106114

115+
var artifactFiles = new ArrayList<>(includedJars.stream().map(ResolvedJarJarArtifact::getFile).toList());
116+
// Now we have to handle pure file collection dependencies that do not have artifact ids
117+
for (var file : getInputFiles()) {
118+
if (!artifactFiles.contains(file)) {
119+
// Determine the module-name of the file, which is also what Java will use as the unique key
120+
// when it tries to load the file. No two files can have the same module name, so it seems
121+
// like a fitting key for conflict resolution by JiJ.
122+
var moduleName = FileUtils.getExplicitJavaModuleName(file);
123+
if (moduleName.isEmpty()) {
124+
throw new GradleException("Cannot embed local file dependency " + file + " because it has no explicit Java module name.\n" +
125+
"Please set either 'Automatic-Module-Name' in the Jar manifest, or make it an explicit Java module.\n" +
126+
"This ensures that your file does not conflict with another mods library that has the same or a similar filename.");
127+
}
128+
129+
// Create a hashcode to use as a version
130+
var hashCode = FileUtils.hashFile(file, "MD5");
131+
includedJars.add(new ResolvedJarJarArtifact(
132+
file,
133+
file.getName(),
134+
hashCode,
135+
"[" + hashCode + "]",
136+
"",
137+
moduleName.get()
138+
));
139+
artifactFiles.add(file);
140+
}
141+
}
142+
107143
// Only copy metadata if not empty, always delete
108144
if (!includedJars.isEmpty()) {
109145
fileSystemOperations.copy(spec -> {
110146
spec.into(getOutputDirectory().dir("META-INF/jarjar"));
111-
spec.from(includedJars.stream().map(ResolvedJarJarArtifact::getFile).toArray());
147+
spec.from(artifactFiles.toArray());
112148
for (var includedJar : includedJars) {
149+
// Warn if any included jar is using the cursemaven group.
150+
// We know that cursemaven versions are not comparable, and the same artifact might also be
151+
// available under a "normal" group and artifact from another Maven repository.
152+
// JIJ will not correctly detect the conflicting file at runtime if another mod uses the normal Maven dependency.
153+
// For a description of Curse Maven, see https://www.cursemaven.com/
154+
if ("curse.maven".equals(includedJar.getGroup())) {
155+
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.",
156+
includedJar.getGroup(), includedJar.getArtifact(), includedJar.getVersion());
157+
}
158+
// Same with the Modrinth official maven (see https://support.modrinth.com/en/articles/8801191-modrinth-maven)
159+
// While actual versions can be used, version IDs (which are random strings) can also be used
160+
else if ("maven.modrinth".equals(includedJar.getGroup())) {
161+
getLogger().warn("Embedding dependency {}:{}:{} from Modrinth Maven using JiJ is likely to cause conflicts at runtime when other mods include the same library from a normal Maven repository.",
162+
includedJar.getGroup(), includedJar.getArtifact(), includedJar.getVersion());
163+
}
164+
113165
var originalName = includedJar.getFile().getName();
114166
var embeddedName = includedJar.getEmbeddedFilename();
115167
if (!originalName.equals(embeddedName)) {
@@ -123,8 +175,8 @@ protected void run() {
123175

124176
@SuppressWarnings("ResultOfMethodCallIgnored")
125177
private Path writeMetadata(List<ResolvedJarJarArtifact> includedJars) {
126-
final Path metadataPath = getJarJarMetadataPath();
127-
final Metadata metadata = createMetadata(includedJars);
178+
var metadataPath = getJarJarMetadataPath();
179+
var metadata = createMetadata(includedJars);
128180

129181
try {
130182
metadataPath.toFile().getParentFile().mkdirs();

src/test/java/net/neoforged/moddevgradle/functional/AbstractFunctionalTest.java

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,17 +12,17 @@
1212
import java.util.regex.Matcher;
1313
import java.util.regex.Pattern;
1414

15-
abstract class AbstractFunctionalTest {
15+
public abstract class AbstractFunctionalTest {
1616
static final String DEFAULT_NEOFORGE_VERSION = "21.0.133-beta";
1717

1818
static final Map<String, String> DEFAULT_PLACEHOLDERS = Map.of(
1919
"DEFAULT_NEOFORGE_VERSION", DEFAULT_NEOFORGE_VERSION
2020
);
2121

2222
@TempDir
23-
File testProjectDir;
24-
File settingsFile;
25-
File buildFile;
23+
protected File testProjectDir;
24+
protected File settingsFile;
25+
protected File buildFile;
2626

2727
@BeforeEach
2828
final void setBaseFiles() {

0 commit comments

Comments
 (0)