diff --git a/CHANGELOG.md b/CHANGELOG.md index 96ca5aa..2bc1eec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Java Module Packaging Gradle Plugin - Changelog +## Version 1.1 +- Configuration option for `--jlink-options` +- Configuration option for `--verbose` +- Configuration option to build packages in one step (interesting for MacOS signing) +- More options to add custom resources to a package +- Option to explicitly build the 'app-image' folder only (or in addition) +- By convention, set default target to current operating system and architecture + ## Version 1.0.1 * Do not bind platform-specific assemble tasks to a lifecycle diff --git a/README.md b/README.md index 28f79fe..9929e82 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,7 @@ Add this to the build file of your convention plugin's build ``` dependencies { - implementation("org.gradlex:java-module-packaging:1.0.1") + implementation("org.gradlex:java-module-packaging:1.1") } ``` @@ -80,32 +80,56 @@ javaModulePackaging { operatingSystem = OperatingSystemFamily.WINDOWS architecture = MachineArchitecture.X86_64 } - - primaryTarget(target("macos-14")) } ``` You can now run _target-specific_ builds: -``` -./gradlew assembleWindows +```shell +./gradlew jpackageWindows ``` -``` +```shell ./gradlew runWindows ``` -There are some additional configuration options that can be used: +Or, for convenience, let the plugin pick the target fitting the machine you run on: +```shell +./gradlew jpackage ``` + +```shell +./gradlew run +``` + +There are some additional configuration options that can be used if needed. +All options have a default. Only configure what you need in addition. + +```kotlin javaModulePackaging { - applicationName(); - applicationVersion(); - applicationDescription = "" - vendor = "My Company" - copyright = "(c) My Company" - jpackageResources.setFrom(...); - resources.from(...) + // global options + applicationName = "app" // defaults to project name + applicationVersion = "1.0" // defaults to project version + applicationDescription = "Awesome App" + vendor = "My Company" + copyright = "(c) My Company" + jlinkOptions.addAll("--no-header-files", "--no-man-pages", "--bind-services") + addModules.addAll("additional.module.to.include") + jpackageResources = layout.projectDirectory.dir("res") // defaults to 'src/main/resourcesPackage' + resources.from(layout.projectDirectory.dir("extra-res")) + verbose = false + + // target specific options + targetsWithOs("windows") { + options.addAll("--win-dir-chooser", "--win-shortcut", "--win-menu") + appImageOptions.addAll("--win-console") + targetResources.from("windows-res") + } + targetsWithOs("macos") { + options.addAll("--mac-sign", "--mac-signing-key-user-name", "gradlex") + singleStepPackaging = true + } } ``` diff --git a/build.gradle.kts b/build.gradle.kts index 71479dd..418bcc5 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -3,7 +3,7 @@ plugins { } group = "org.gradlex" -version = "1.0.1" +version = "1.1" java { toolchain.languageVersion = JavaLanguageVersion.of(17) diff --git a/src/main/java/org/gradlex/javamodule/packaging/JavaModulePackagingExtension.java b/src/main/java/org/gradlex/javamodule/packaging/JavaModulePackagingExtension.java index 40ee21f..84c6845 100644 --- a/src/main/java/org/gradlex/javamodule/packaging/JavaModulePackagingExtension.java +++ b/src/main/java/org/gradlex/javamodule/packaging/JavaModulePackagingExtension.java @@ -18,6 +18,7 @@ import org.gradle.api.Action; import org.gradle.api.NamedDomainObjectContainer; +import org.gradle.api.NamedDomainObjectSet; import org.gradle.api.Project; import org.gradle.api.Task; import org.gradle.api.artifacts.Configuration; @@ -35,6 +36,7 @@ import org.gradle.api.plugins.JavaApplication; import org.gradle.api.plugins.JavaPluginExtension; import org.gradle.api.plugins.jvm.JvmTestSuite; +import org.gradle.api.provider.ListProperty; import org.gradle.api.provider.Property; import org.gradle.api.tasks.JavaExec; import org.gradle.api.tasks.SourceSet; @@ -45,6 +47,7 @@ import org.gradle.nativeplatform.MachineArchitecture; import org.gradle.nativeplatform.OperatingSystemFamily; import org.gradle.testing.base.TestSuite; +import org.gradlex.javamodule.packaging.internal.HostIdentification; import org.gradlex.javamodule.packaging.model.Target; import org.gradlex.javamodule.packaging.tasks.Jpackage; import org.gradlex.javamodule.packaging.tasks.ValidateHostSystemAction; @@ -63,14 +66,18 @@ abstract public class JavaModulePackagingExtension { private static final Attribute JAVA_MODULE_ATTRIBUTE = Attribute.of("javaModule", Boolean.class); private static final String INTERNAL = "internal"; + private static final String JPACKAGE = "jpackage"; abstract public Property getApplicationName(); abstract public Property getApplicationVersion(); abstract public Property getApplicationDescription(); abstract public Property getVendor(); abstract public Property getCopyright(); + abstract public ListProperty getJlinkOptions(); + abstract public ListProperty getAddModules(); abstract public DirectoryProperty getJpackageResources(); abstract public ConfigurableFileCollection getResources(); + abstract public Property getVerbose(); private final NamedDomainObjectContainer targets = getObjects().domainObjectContainer(Target.class); @@ -109,6 +116,16 @@ public Target target(String label, Action action) { return target; } + /** + * Configure all targets for the given OS. + */ + @SuppressWarnings("unused") + public void targetsWithOs(String operatingSystem, Action action) { + NamedDomainObjectSet matches = targets.matching(t -> + t.getOperatingSystem().isPresent() && t.getOperatingSystem().get().equals(operatingSystem)); + matches.all(action); + } + /** * Set a 'primary target'. Standard Gradle tasks that are not bound to a specific target – like 'assemble' – use * this 'primary target'. @@ -132,6 +149,7 @@ public Target primaryTarget(Target target) { /** * Set a test suite to be 'multi-target'. This registers an additional 'test' task for each target. */ + @SuppressWarnings({"unused", "UnstableApiUsage"}) public TestSuite multiTargetTestSuite(TestSuite testSuite) { if (!(testSuite instanceof JvmTestSuite)) { return testSuite; @@ -220,7 +238,7 @@ private void registerTargetSpecificTasks(Target target, String applicationJarTas JavaPluginExtension java = getProject().getExtensions().getByType(JavaPluginExtension.class); JavaApplication application = getProject().getExtensions().getByType(JavaApplication.class); - TaskProvider jpackage = tasks.register("jpackage" + capitalize(target.getName()), Jpackage.class, t -> { + TaskProvider jpackage = tasks.register(JPACKAGE + capitalize(target.getName()), Jpackage.class, t -> { t.getJavaInstallation().convention(getJavaToolchains().compilerFor(java.getToolchain()).get().getMetadata()); t.getOperatingSystem().convention(target.getOperatingSystem()); t.getArchitecture().convention(target.getArchitecture()); @@ -230,14 +248,20 @@ private void registerTargetSpecificTasks(Target target, String applicationJarTas t.getModulePath().from(runtimeClasspath); t.getApplicationName().convention(getApplicationName()); - t.getJpackageResources().convention(getJpackageResources().dir(target.getOperatingSystem())); + t.getJpackageResources().from(getJpackageResources().dir(target.getOperatingSystem())); t.getApplicationDescription().convention(getApplicationDescription()); t.getVendor().convention(getVendor()); t.getCopyright().convention(getCopyright()); t.getJavaOptions().convention(application.getApplicationDefaultJvmArgs()); + t.getJlinkOptions().convention(getJlinkOptions()); + t.getAddModules().convention(getAddModules()); t.getOptions().convention(target.getOptions()); + t.getAppImageOptions().convention(target.getAppImageOptions()); t.getPackageTypes().convention(target.getPackageTypes()); + t.getSingleStepPackaging().convention(target.getSingleStepPackaging()); t.getResources().from(getResources()); + t.getTargetResources().from(target.getTargetResources()); + t.getVerbose().convention(getVerbose()); t.getDestination().convention(getProject().getLayout().getBuildDirectory().dir("packages/" + target.getName())); t.getTempDirectory().convention(getProject().getLayout().getBuildDirectory().dir("tmp/jpackage/" + target.getName())); @@ -252,15 +276,25 @@ private void registerTargetSpecificTasks(Target target, String applicationJarTas t.setJvmArgs(application.getApplicationDefaultJvmArgs()); t.classpath(tasks.named("jar"), runtimeClasspath); }); + maybeAddJpackageLifecycleTask(tasks, target, jpackage); + } - String targetAssembleLifecycle = "assemble" + capitalize(target.getName()); - if (!tasks.getNames().contains(targetAssembleLifecycle)) { - TaskProvider lifecycleTask = tasks.register(targetAssembleLifecycle, t -> { + private void maybeAddJpackageLifecycleTask(TaskContainer tasks, Target target, TaskProvider targetJpackage) { + // if a task already exists, do nothing to avoid conflciting with other plugins + TaskProvider jpackage; + if (tasks.getNames().contains(JPACKAGE)) { + jpackage = tasks.named(JPACKAGE); + } else { + jpackage = tasks.register(JPACKAGE, t -> { t.setGroup(BUILD_GROUP); - t.setDescription("Builds this project for " + target.getName()); + t.setDescription("Build the package for the current host system"); }); } - tasks.named(targetAssembleLifecycle, t -> t.dependsOn(jpackage)); + jpackage.configure(t -> { + if (HostIdentification.isHostTarget(target)) { + t.dependsOn(targetJpackage); + } + }); } private Configuration maybeCreateInternalConfiguration() { diff --git a/src/main/java/org/gradlex/javamodule/packaging/JavaModulePackagingPlugin.java b/src/main/java/org/gradlex/javamodule/packaging/JavaModulePackagingPlugin.java index d6268de..407c6ff 100644 --- a/src/main/java/org/gradlex/javamodule/packaging/JavaModulePackagingPlugin.java +++ b/src/main/java/org/gradlex/javamodule/packaging/JavaModulePackagingPlugin.java @@ -23,6 +23,7 @@ import org.gradle.api.tasks.SourceSetContainer; import org.gradle.jvm.toolchain.JavaToolchainService; import org.gradle.util.GradleVersion; +import org.gradlex.javamodule.packaging.internal.HostIdentification; import javax.inject.Inject; @@ -47,5 +48,7 @@ public void apply(Project project) { javaModulePackaging.getApplicationVersion().convention(project.provider(() -> (String) project.getVersion())); javaModulePackaging.getJpackageResources().convention(project.provider(() -> project.getLayout().getProjectDirectory().dir(mainResources.getSrcDirs().iterator().next().getParent() + "/resourcesPackage"))); + javaModulePackaging.getVerbose().convention(false); + javaModulePackaging.primaryTarget(HostIdentification.hostTarget(project.getObjects())); } } diff --git a/src/main/java/org/gradlex/javamodule/packaging/internal/HostIdentification.java b/src/main/java/org/gradlex/javamodule/packaging/internal/HostIdentification.java new file mode 100644 index 0000000..3fdf20e --- /dev/null +++ b/src/main/java/org/gradlex/javamodule/packaging/internal/HostIdentification.java @@ -0,0 +1,87 @@ +/* + * Copyright the GradleX team. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.gradlex.javamodule.packaging.internal; + +import org.gradle.api.model.ObjectFactory; +import org.gradle.nativeplatform.MachineArchitecture; +import org.gradle.nativeplatform.OperatingSystemFamily; +import org.gradlex.javamodule.packaging.model.Target; + +import java.util.Locale; + +public class HostIdentification { + + public static void validateHostSystem(String arch, String os) { + String hostOs = hostOs(); + String hostArch = hostArch(); + + if (!normalizeOs(hostOs).equals(normalizeOs(os))) { + wrongHostSystemError(hostOs, os); + } + if (!normalizeArch(hostArch).equals(normalizeArch(arch))) { + wrongHostSystemError(hostArch, arch); + } + } + + public static Target hostTarget(ObjectFactory objects) { + Target host = objects.newInstance(Target.class, "host"); + host.getOperatingSystem().convention(hostOs()); + host.getArchitecture().convention(normalizeArch(System.getProperty("os.arch"))); + return host; + } + + public static boolean isHostTarget(Target target) { + return target.getOperatingSystem().isPresent() + && target.getArchitecture().isPresent() + && target.getOperatingSystem().get().equals(hostOs()) + && target.getArchitecture().get().equals(normalizeArch(hostArch())); + } + + private static String hostOs() { + return normalizeOs(System.getProperty("os.name")); + } + + private static String hostArch() { + return System.getProperty("os.arch"); + } + + private static String normalizeOs(String name) { + String os = name.toLowerCase(Locale.ROOT).replace(" ", ""); + if (os.contains("windows")) { + return OperatingSystemFamily.WINDOWS; + } + if (os.contains("macos") || os.contains("darwin") || os.contains("osx")) { + return OperatingSystemFamily.MACOS; + } + return OperatingSystemFamily.LINUX; + } + + private static String normalizeArch(String name) { + String arch = name.toLowerCase(Locale.ROOT); + if (arch.contains("aarch")) { + return MachineArchitecture.ARM64; + } + if (arch.contains("64")) { + return MachineArchitecture.X86_64; + } + return MachineArchitecture.X86; + } + + private static void wrongHostSystemError(String hostOs, String os) { + throw new RuntimeException("Running on " + hostOs + "; cannot build for " + os); + } +} diff --git a/src/main/java/org/gradlex/javamodule/packaging/internal/Validator.java b/src/main/java/org/gradlex/javamodule/packaging/internal/Validator.java deleted file mode 100644 index c34dc84..0000000 --- a/src/main/java/org/gradlex/javamodule/packaging/internal/Validator.java +++ /dev/null @@ -1,52 +0,0 @@ -/* - * Copyright the GradleX team. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.gradlex.javamodule.packaging.internal; - -public class Validator { - public static void validateHostSystem(String arch, String os) { - String hostOs = System.getProperty("os.name").replace(" ", "").toLowerCase(); - String hostArch = System.getProperty("os.arch"); - - if (os.contains("macos")) { - if (!hostOs.contains(os)) { - wrongHostSystemError(hostOs, os); - } - } else if (os.contains("windows")) { - if (!hostOs.contains(os)) { - wrongHostSystemError(hostOs, os); - } - } else { - if (hostOs.contains("windows") || hostOs.contains("macos")) { - wrongHostSystemError(hostOs, os); - } - } - - if (arch.contains("64") && !hostArch.contains("64")) { - wrongHostSystemError(hostArch, arch); - } - if (arch.contains("aarch") && !hostArch.contains("aarch")) { - wrongHostSystemError(hostArch, arch); - } - if (!arch.contains("aarch") && hostArch.contains("aarch")) { - wrongHostSystemError(hostArch, arch); - } - } - - public static void wrongHostSystemError(String hostOs, String os) { - throw new RuntimeException("Running on " + hostOs + "; cannot build for " + os); - } -} diff --git a/src/main/java/org/gradlex/javamodule/packaging/model/Target.java b/src/main/java/org/gradlex/javamodule/packaging/model/Target.java index 64f0913..e491766 100644 --- a/src/main/java/org/gradlex/javamodule/packaging/model/Target.java +++ b/src/main/java/org/gradlex/javamodule/packaging/model/Target.java @@ -16,6 +16,7 @@ package org.gradlex.javamodule.packaging.model; +import org.gradle.api.file.ConfigurableFileCollection; import org.gradle.api.provider.ListProperty; import org.gradle.api.provider.Property; @@ -30,10 +31,16 @@ abstract public class Target { abstract public ListProperty getPackageTypes(); abstract public ListProperty getOptions(); + abstract public ListProperty getAppImageOptions(); + + abstract public ConfigurableFileCollection getTargetResources(); + + abstract public Property getSingleStepPackaging(); @Inject public Target(String name) { this.name = name; + getSingleStepPackaging().convention(false); } public String getName() { diff --git a/src/main/java/org/gradlex/javamodule/packaging/tasks/Jpackage.java b/src/main/java/org/gradlex/javamodule/packaging/tasks/Jpackage.java index a620ddf..1ba13a6 100644 --- a/src/main/java/org/gradlex/javamodule/packaging/tasks/Jpackage.java +++ b/src/main/java/org/gradlex/javamodule/packaging/tasks/Jpackage.java @@ -26,7 +26,6 @@ import org.gradle.api.tasks.CacheableTask; import org.gradle.api.tasks.Classpath; import org.gradle.api.tasks.Input; -import org.gradle.api.tasks.InputDirectory; import org.gradle.api.tasks.InputFiles; import org.gradle.api.tasks.Internal; import org.gradle.api.tasks.Nested; @@ -37,6 +36,7 @@ import org.gradle.api.tasks.TaskAction; import org.gradle.jvm.toolchain.JavaInstallationMetadata; import org.gradle.process.ExecOperations; +import org.gradle.process.ExecSpec; import javax.inject.Inject; import java.io.File; @@ -50,7 +50,7 @@ import static java.util.Objects.requireNonNull; import static org.gradle.nativeplatform.OperatingSystemFamily.WINDOWS; -import static org.gradlex.javamodule.packaging.internal.Validator.validateHostSystem; +import static org.gradlex.javamodule.packaging.internal.HostIdentification.validateHostSystem; @CacheableTask abstract public class Jpackage extends DefaultTask { @@ -80,14 +80,18 @@ abstract public class Jpackage extends DefaultTask { @Optional abstract public Property getApplicationDescription(); - @InputDirectory + @InputFiles @PathSensitive(PathSensitivity.RELATIVE) - abstract public DirectoryProperty getJpackageResources(); + abstract public ConfigurableFileCollection getJpackageResources(); @InputFiles @PathSensitive(PathSensitivity.RELATIVE) abstract public ConfigurableFileCollection getResources(); + @InputFiles + @PathSensitive(PathSensitivity.RELATIVE) + abstract public ConfigurableFileCollection getTargetResources(); + @Input @Optional abstract public Property getVendor(); @@ -99,15 +103,34 @@ abstract public class Jpackage extends DefaultTask { @Input abstract public ListProperty getJavaOptions(); + @Input + abstract public ListProperty getJlinkOptions(); + + @Input + abstract public ListProperty getAddModules(); + @Input abstract public ListProperty getOptions(); + @Input + abstract public ListProperty getAppImageOptions(); + @Input abstract public ListProperty getPackageTypes(); + @Input + abstract public Property getSingleStepPackaging(); + + @Input + abstract public Property getVerbose(); + @OutputDirectory abstract public DirectoryProperty getDestination(); + /** + * To copy resources before adding them. This allows ressource filtering via Gradle + * FileCollection and FileTree APIs. + */ @Internal abstract public DirectoryProperty getTempDirectory(); @@ -128,7 +151,6 @@ public void runJpackage() throws Exception { validateHostSystem(arch, os); Directory resourcesDir = getTempDirectory().get().dir("jpackage-resources"); - Directory appImageParent = getTempDirectory().get().dir("app-image"); //noinspection ResultOfMethodCallIgnored resourcesDir.getAsFile().mkdirs(); @@ -141,67 +163,50 @@ public void runJpackage() throws Exception { String executableName = WINDOWS.equals(os) ? "jpackage.exe" : "jpackage"; String jpackage = getJavaInstallation().get().getInstallationPath().file("bin/" + executableName).getAsFile().getAbsolutePath(); - // create app image folder - getExec().exec(e -> { - e.commandLine( - jpackage, - "--type", - "app-image", - "--module", - getMainModule().get(), - "--resource-dir", - resourcesDir.getAsFile().getPath(), - "--app-version", - getVersion().get(), - "--module-path", - getModulePath().getAsPath(), - "--name", - getApplicationName().get(), - "--dest", - appImageParent.getAsFile().getPath() - ); - if (getApplicationDescription().isPresent()) { - e.args("--description", getApplicationDescription().get()); - } - if (getVendor().isPresent()) { - e.args("--vendor", getVendor().get()); + File appContentTmpFolder = getTempDirectory().get().dir("app-content").getAsFile(); + + // build 'app-image' target if required (either needed for the next step or explicitly requested) + if (!getSingleStepPackaging().get() || getPackageTypes().get().contains("app-image")) { + performAppImageStep(jpackage, resourcesDir); + File appImageFolder = appImageFolder(); + File appRootFolder; + if (os.contains("macos")) { + appRootFolder = new File(appImageFolder, "Contents"); + } else if (os.contains("windows")) { + appRootFolder = appImageFolder; + } else { + appRootFolder = new File(appImageFolder, "lib"); } - if (getCopyright().isPresent()) { - e.args("--copyright", getCopyright().get()); - } - for (String javaOption : getJavaOptions().get()) { - e.args("--java-options", javaOption); - } - }); - - File appImageFolder = requireNonNull(appImageParent.getAsFile().listFiles())[0]; - File appResourcesFolder; - if (os.contains("macos")) { - appResourcesFolder = new File(appImageFolder, "Contents/app"); - } else if (os.contains("windows")) { - appResourcesFolder = new File(appImageFolder, "app"); - } else { - appResourcesFolder = new File(appImageFolder, "lib/app"); + copyAdditionalRessourcesToImageFolder(appRootFolder); } - // copy additional resource into app-image folder - getFiles().copy(c -> { - c.from(getResources()); - c.into(appResourcesFolder); - }); + if (getSingleStepPackaging().get()) { + // an isolated folder which is later inserted via '--app-content' parameter + copyAdditionalRessourcesToImageFolder(appContentTmpFolder); + } // package with additional resources - getPackageTypes().get().forEach(packageType -> + getPackageTypes().get().stream().filter(t -> !"app-image".equals(t)).forEach(packageType -> getExec().exec(e -> { e.commandLine( jpackage, "--type", packageType, - "--app-image", - appImageFolder.getPath(), + "--app-version", + getVersion().get(), "--dest", getDestination().get().getAsFile().getPath() ); + if (getSingleStepPackaging().get()) { + configureJPackageArguments(e, resourcesDir); + if (appContentTmpFolder.exists()) { + for (File appContent : requireNonNull(appContentTmpFolder.listFiles())) { + e.args("--app-content", appContent.getPath()); + } + } + } else { + e.args("--app-image", appImageFolder().getPath()); + } for (String option : getOptions().get()) { e.args(option); } @@ -211,6 +216,72 @@ public void runJpackage() throws Exception { generateChecksums(); } + private File appImageFolder() { + return Arrays.stream(requireNonNull(getDestination().get().getAsFile().listFiles())) + .filter(File::isDirectory).findFirst().get(); + } + + private void copyAdditionalRessourcesToImageFolder(File appRootFolder) { + // copy additional resource into the app-image folder + getFiles().copy(c -> { + c.into(appRootFolder); + c.from(getTargetResources()); + c.from(getResources(), to -> to.into("app")); // 'app' is the folder Java loads resources from at runtime + }); + } + + private void performAppImageStep(String jpackage, Directory resourcesDir) { + getExec().exec(e -> { + e.commandLine( + jpackage, + "--type", + "app-image", + "--dest", + getDestination().get().getAsFile().getPath() + ); + configureJPackageArguments(e, resourcesDir); + for (String option : getAppImageOptions().get()) { + e.args(option); + } + }); + } + + private void configureJPackageArguments(ExecSpec e, Directory resourcesDir) { + e.args( + "--module", + getMainModule().get(), + "--resource-dir", + resourcesDir.getAsFile().getPath(), + "--app-version", + getVersion().get(), + "--module-path", + getModulePath().getAsPath(), + "--name", + getApplicationName().get() + ); + if (getApplicationDescription().isPresent()) { + e.args("--description", getApplicationDescription().get()); + } + if (getVendor().isPresent()) { + e.args("--vendor", getVendor().get()); + } + if (getCopyright().isPresent()) { + e.args("--copyright", getCopyright().get()); + } + for (String javaOption : getJavaOptions().get()) { + e.args("--java-options", javaOption); + } + for (String javaOption : getJlinkOptions().get()) { + e.args("--jlink-options", javaOption); + } + if (!getAddModules().get().isEmpty()) { + e.args("--add-modules", String.join(",", getAddModules().get())); + } + if (getVerbose().get()) { + e.args("--verbose"); + } + } + private void generateChecksums() throws NoSuchAlgorithmException, IOException { File destination = getDestination().get().getAsFile(); List allFiles = Arrays.stream(requireNonNull(destination.listFiles())).filter(File::isFile).collect(Collectors.toList()); diff --git a/src/main/java/org/gradlex/javamodule/packaging/tasks/ValidateHostSystemAction.java b/src/main/java/org/gradlex/javamodule/packaging/tasks/ValidateHostSystemAction.java index 3b6207d..e333de9 100644 --- a/src/main/java/org/gradlex/javamodule/packaging/tasks/ValidateHostSystemAction.java +++ b/src/main/java/org/gradlex/javamodule/packaging/tasks/ValidateHostSystemAction.java @@ -21,7 +21,7 @@ import java.util.Map; -import static org.gradlex.javamodule.packaging.internal.Validator.validateHostSystem; +import static org.gradlex.javamodule.packaging.internal.HostIdentification.validateHostSystem; public class ValidateHostSystemAction implements Action { @Override diff --git a/src/test/java/org/gradlex/javamodule/packaging/test/JavaModulePackagingOptionsTest.java b/src/test/java/org/gradlex/javamodule/packaging/test/JavaModulePackagingOptionsTest.java new file mode 100644 index 0000000..c615105 --- /dev/null +++ b/src/test/java/org/gradlex/javamodule/packaging/test/JavaModulePackagingOptionsTest.java @@ -0,0 +1,206 @@ +/* + * Copyright the GradleX team. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.gradlex.javamodule.packaging.test; + +import org.gradlex.javamodule.packaging.test.fixture.GradleBuild; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for setting various options for jpackage or the underlying jlink. + * The tests are OS-dependent and should run on each operating system once. + */ +class JavaModulePackagingOptionsTest { + + GradleBuild build = new GradleBuild(); + + @BeforeEach + void setup() { + var macosArch = System.getProperty("os.arch").contains("aarch") ? "aarch64" : "x86-64"; + build.appBuildFile.appendText(""" + version = "1.0" + javaModulePackaging { + target("macos") { + operatingSystem.set("macos") + architecture.set("%s") + packageTypes.set(listOf("dmg")) + } + target("ubuntu") { + operatingSystem.set("linux") + architecture.set("x86-64") + packageTypes.set(listOf("deb")) + } + target("windows") { + operatingSystem.set("windows") + architecture.set("x86-64") + packageTypes.set(listOf("exe")) + } + } + """.formatted(macosArch)); + build.appModuleInfoFile.writeText(""" + module org.example.app { + } + """); + + } + + @Test + void can_configure_jlink_options() { + build.appBuildFile.appendText(""" + javaModulePackaging { + jlinkOptions.addAll( + "--ignore-signing-information", + "--compress", "zip-6", + "--no-header-files", + "--no-man-pages", + "--bind-services", + "--unsupported-option" + ) + } + """); + + var result = build.fail(":app:jpackage"); + + // The error shows that all options before '--unsupported-option' are passed through to jlink + assertThat(result.getOutput()).contains("jlink failed with: Error: unknown option: --unsupported-option"); + } + + @Test + void can_configure_java_options() { + build.appBuildFile.appendText(""" + application { + applicationDefaultJvmArgs = listOf( + "-XX:+UnlockExperimentalVMOptions", + "-XX:+UseCompactObjectHeaders", + "-Xmx1g", + "-Dsome.prop=some.val" + ) + } + """); + + build.build(":app:jpackage"); + + assertThat(build.appContentsFolder().file("app/app.cfg").getAsPath()).hasContent(""" + [Application] + app.mainmodule=org.example.app/org.example.app.Main + + [JavaOptions] + java-options=-Djpackage.app-version=1.0 + java-options=-XX:+UnlockExperimentalVMOptions + java-options=-XX:+UseCompactObjectHeaders + java-options=-Xmx1g + java-options=-Dsome.prop=some.val + """); + } + + @Test + void can_configure_add_modules() { + build.appBuildFile.appendText(""" + javaModulePackaging { + addModules.addAll("com.acme.boo") + } + """); + + var result = build.fail(":app:jpackage"); + + // The error shows that the option is passed on to jlink + assertThat(result.getOutput()).contains("jlink failed with: Error: Module com.acme.boo not found"); + } + + @Test + void can_set_verbose_option() { + build.appBuildFile.appendText(""" + javaModulePackaging { + verbose.set(true) + } + """); + + var result = build.build(":app:jpackage"); + + assertThat(result.getOutput()).contains("Creating app package: "); + } + + @Test + void can_set_target_specific_option() { + build.appBuildFile.appendText(""" + javaModulePackaging { + targetsWithOs("windows") { + singleStepPackaging.set(true) + options.addAll("--dummy") + appImageOptions.addAll("--dummyimg") // no effect due to single-step + } + targetsWithOs("linux") { + singleStepPackaging.set(true) + options.addAll("--dummy") + appImageOptions.addAll("--dummyimg") // no effect due to single-step + } + targetsWithOs("macos") { + singleStepPackaging.set(true) + options.addAll("--dummy") + appImageOptions.addAll("--dummyimg") // no effect due to single-step + } + } + """); + + var result = build.fail(":app:jpackage"); + + assertThat(result.getOutput()).contains("Error: Invalid Option: [--dummy]"); + } + + @Test + void can_set_target_specific_option_for_app_image() { + build.appBuildFile.appendText(""" + javaModulePackaging { + targetsWithOs("windows") { + options.addAll("--dummy") // no effect as app-image fails first + appImageOptions.addAll("--dummyimg") + } + targetsWithOs("linux") { + options.addAll("--dummy") // no effect as app-image fails first + appImageOptions.addAll("--dummyimg") + } + targetsWithOs("macos") { + options.addAll("--dummy") // no effect as app-image fails first + appImageOptions.addAll("--dummyimg") + } + } + """); + + var result = build.fail(":app:jpackage"); + + assertThat(result.getOutput()).contains("Error: Invalid Option: [--dummyimg]"); + } + + @Test + void can_build_package_in_one_step() { + build.appBuildFile.appendText(""" + javaModulePackaging { + targetsWithOs("windows") { singleStepPackaging.set(true) } + targetsWithOs("linux") { singleStepPackaging.set(true) } + targetsWithOs("macos") { singleStepPackaging.set(true) } + } + """); + + build.build(":app:jpackage"); + + assertThat(build.appImageFolder().getAsPath()).isDirectoryContaining(f -> + f.getFileName().toString().contains("app") && f.getFileName().toString().contains("1.0")); + assertThat(build.appImageFolder().getAsPath()).isDirectoryNotContaining(f -> f.toFile().isDirectory()); + } +} diff --git a/src/test/java/org/gradlex/javamodule/packaging/test/JavaModulePackagingResourcesTest.java b/src/test/java/org/gradlex/javamodule/packaging/test/JavaModulePackagingResourcesTest.java new file mode 100644 index 0000000..b582509 --- /dev/null +++ b/src/test/java/org/gradlex/javamodule/packaging/test/JavaModulePackagingResourcesTest.java @@ -0,0 +1,168 @@ +/* + * Copyright the GradleX team. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.gradlex.javamodule.packaging.test; + +import org.gradlex.javamodule.packaging.test.fixture.GradleBuild; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.gradlex.javamodule.packaging.test.fixture.GradleBuild.currentTarget; +import static org.gradlex.javamodule.packaging.test.fixture.GradleBuild.runsOnLinux; +import static org.gradlex.javamodule.packaging.test.fixture.GradleBuild.runsOnMacos; +import static org.gradlex.javamodule.packaging.test.fixture.GradleBuild.runsOnWindows; + +/** + * Tests for adding custom resources to the image/package. + * The tests are OS-dependent and should run on each operating system once. + */ +class JavaModulePackagingResourcesTest { + + GradleBuild build = new GradleBuild(); + + @BeforeEach + void setup() { + var macosArch = System.getProperty("os.arch").contains("aarch") ? "aarch64" : "x86-64"; + build.appBuildFile.appendText(""" + version = "1.0" + javaModulePackaging { + target("macos") { + operatingSystem.set("macos") + architecture.set("%s") + } + target("ubuntu") { + operatingSystem.set("linux") + architecture.set("x86-64") + } + target("windows") { + operatingSystem.set("windows") + architecture.set("x86-64") + } + } + """.formatted(macosArch)); + build.appModuleInfoFile.writeText(""" + module org.example.app { + } + """); + + } + + @Test + void can_configure_jlink_options() { + build.appBuildFile.appendText(""" + javaModulePackaging { + jlinkOptions.addAll( + "--ignore-signing-information", + "--compress", "zip-6", + "--no-header-files", + "--no-man-pages", + "--bind-services", + "--unsupported-option" + ) + } + """); + + // The error shows that all options before '--unsupported-option' are passed through to jlink + var result = build.fail(":app:jpackage"); + assertThat(result.getOutput()).contains( + "jlink failed with: Error: unknown option: --unsupported-option"); + } + + @Test + void can_configure_add_modules() { + build.appBuildFile.appendText(""" + javaModulePackaging { + addModules.addAll("com.acme.boo") + } + """); + + // The error shows that the option is passed on to jlink + var result = build.fail(":app:jpackage"); + assertThat(result.getOutput()).contains( + "jlink failed with: Error: Module com.acme.boo not found"); + } + + @Test + void can_add_resources_for_jpackage() { + // Use 'src/main/resourcesPackage', which is the convention + + // resources that are not known - will be ignored + build.projectDir.file("app/src/main/resourcesPackage/linux/dummy.txt").writeText(""); + build.projectDir.file("app/src/main/resourcesPackage/macos/dummy.txt").writeText(""); + build.projectDir.file("app/src/main/resourcesPackage/windows/dummy.txt").writeText(""); + + // icons will be used + build.projectDir.file("app/src/main/resourcesPackage/linux/icon.png").create(); + build.projectDir.file("app/src/main/resourcesPackage/macos/icon.icns").create(); + build.projectDir.file("app/src/main/resourcesPackage/windows/icon.ico").create(); + + build.build(":app:jpackage"); + + String icon = "app.icns"; + if (runsOnLinux()) icon = "app.png"; + if (runsOnWindows()) icon = "app.ico"; + + // Intermediate location to collect files + assertThat(build.file("app/build/tmp/jpackage/%s/jpackage-resources/dummy.txt".formatted(currentTarget())) + .getAsPath()).exists(); + assertThat(build.file("app/build/tmp/jpackage/%s/jpackage-resources/%s".formatted(currentTarget(), icon)) + .getAsPath()).exists(); + + // icons end up in Resources + String resourcesFolder = ""; + if (runsOnMacos()) resourcesFolder = "Resources/"; + assertThat(build.appContentsFolder().file(resourcesFolder + icon).getAsPath()).hasSize(0); + } + + @Test + void can_add_resources_for_app_folder() { + build.appBuildFile.appendText(""" + javaModulePackaging { + // resource is added to the os-specific 'app' folder inside the image + resources.from("res") + } + """); + + // resources that are not known - will be ignored + build.projectDir.file("app/res/dummy.txt").writeText(""); + + build.build(":app:jpackage"); + + assertThat(build.appContentsFolder().file("app/dummy.txt").getAsPath()).exists(); + } + + @Test + void can_add_resources_to_image_root() { + // Resource is added to the root of the image. + // This is a target-specific setting as it usually needs to be placed in a place that + // makes sense in the corresponding package structure. + build.appBuildFile.appendText(""" + javaModulePackaging { + targetsWithOs("windows") { targetResources.from("res") } + targetsWithOs("linux") { targetResources.from("res") } + targetsWithOs("macos") { targetResources.from("res") } + } + """); + + // resources that are not known - will be ignored + build.projectDir.file("app/res/customFolder/dummy.txt").writeText(""); + + build.build(":app:jpackage"); + + assertThat(build.appContentsFolder().file("customFolder/dummy.txt").getAsPath()).exists(); + } +} diff --git a/src/test/java/org/gradlex/javamodule/packaging/test/JavaModulePackagingTest.java b/src/test/java/org/gradlex/javamodule/packaging/test/JavaModulePackagingSmokeTest.java similarity index 94% rename from src/test/java/org/gradlex/javamodule/packaging/test/JavaModulePackagingTest.java rename to src/test/java/org/gradlex/javamodule/packaging/test/JavaModulePackagingSmokeTest.java index dd2c147..a129540 100644 --- a/src/test/java/org/gradlex/javamodule/packaging/test/JavaModulePackagingTest.java +++ b/src/test/java/org/gradlex/javamodule/packaging/test/JavaModulePackagingSmokeTest.java @@ -32,7 +32,10 @@ import static org.gradlex.javamodule.packaging.test.fixture.GradleBuild.runsOnMacos; import static org.gradlex.javamodule.packaging.test.fixture.GradleBuild.runsOnWindows; -class JavaModulePackagingTest { +/** + * Tests that run on all operating systems and assert success or failure depending on the system they run on. + */ +class JavaModulePackagingSmokeTest { GradleBuild build = new GradleBuild(); @@ -46,7 +49,7 @@ private static Stream testTargets() { @ParameterizedTest @MethodSource("testTargets") void can_use_plugin(String label, String os, boolean success) { - var taskToRun = ":app:assemble" + capitalize(label); + var taskToRun = ":app:jpackage" + capitalize(label); var taskToCheck = ":app:jpackage" + capitalize(label); var macosArch = System.getProperty("os.arch").contains("aarch") ? "aarch64" : "x86-64"; build.appBuildFile.appendText(""" diff --git a/src/test/java/org/gradlex/javamodule/packaging/test/fixture/GradleBuild.java b/src/test/java/org/gradlex/javamodule/packaging/test/fixture/GradleBuild.java index a2f60a2..681d497 100644 --- a/src/test/java/org/gradlex/javamodule/packaging/test/fixture/GradleBuild.java +++ b/src/test/java/org/gradlex/javamodule/packaging/test/fixture/GradleBuild.java @@ -53,11 +53,6 @@ public GradleBuild(Path dir) { this.libBuildFile = file("lib/build.gradle.kts"); this.libModuleInfoFile = file("lib/src/main/java/module-info.java"); - // TODO remove, should work with empty dir - projectDir.dir("app/src/main/resourcesPackage/windows"); - projectDir.dir("app/src/main/resourcesPackage/macos"); - projectDir.dir("app/src/main/resourcesPackage/linux"); - settingsFile.writeText(""" dependencyResolutionManagement { repositories.mavenCentral() } includeBuild(".") @@ -113,19 +108,22 @@ public WritableFile file(String path) { return new WritableFile(projectDir, path); } - public BuildResult build(String task) { - return runner(task).build(); + public Directory appImageFolder() { + if (runsOnMacos()) return projectDir.dir("app/build/packages/macos"); + if (runsOnLinux()) return projectDir.dir("app/build/packages/ubuntu"); + if (runsOnWindows()) return projectDir.dir("app/build/packages/windows"); + throw new IllegalStateException("unknown os"); } - public BuildResult run() { - return runner("run").build(); + public Directory appContentsFolder() { + if (runsOnMacos()) return projectDir.dir("app/build/packages/macos/app.app/Contents"); + if (runsOnLinux()) return projectDir.dir("app/build/packages/ubuntu/app/lib"); + if (runsOnWindows()) return projectDir.dir("app/build/packages/windows/app"); + throw new IllegalStateException("unknown os"); } - public BuildResult printRuntimeJars() { - return runner(":app:printRuntimeJars", "-q").build(); - } - public BuildResult printCompileJars() { - return runner(":app:printCompileJars", "-q").build(); + public BuildResult build(String task) { + return runner(task).build(); } public BuildResult fail(String task) { @@ -167,6 +165,13 @@ private static Path createBuildTmpDir() { } } + public static String currentTarget() { + if (runsOnMacos()) return "macos"; + if (runsOnLinux()) return "ubuntu"; + if (runsOnWindows()) return "windows"; + throw new IllegalStateException("unknown os"); + } + public static boolean runsOnWindows() { return hostOs().contains("win"); } @@ -180,7 +185,9 @@ public static boolean runsOnLinux() { } public static String hostOs() { - return System.getProperty("os.name").replace(" ", "").toLowerCase(); + String hostOs = System.getProperty("os.name").toLowerCase().replace(" ", ""); + if (hostOs.startsWith("mac")) return "macos"; + if (hostOs.startsWith("win")) return "windows"; + return "linux"; } - } diff --git a/src/test/java/org/gradlex/javamodule/packaging/test/fixture/WritableFile.java b/src/test/java/org/gradlex/javamodule/packaging/test/fixture/WritableFile.java index a11a708..3bb20de 100644 --- a/src/test/java/org/gradlex/javamodule/packaging/test/fixture/WritableFile.java +++ b/src/test/java/org/gradlex/javamodule/packaging/test/fixture/WritableFile.java @@ -43,6 +43,10 @@ public WritableFile appendText(String text) { return this; } + public void create() { + Io.unchecked(() -> Files.createFile(file)); + } + public WritableFile delete() { Io.unchecked(file, Files::delete); return this;