Gradle - How to bundle a jvm in a Gradle application targeting Windows, Linux or Macos

About

The gradle application plugin permits to create a desktop/console application but does not give any possibility to embed a jvm (ie a jre or jdk). This page shows how to do with a minimal knowledge of Gradle code.

This example was done with the kotlin DSL

The steps by steps will embed a whole jre/jdk and will result in a archive that as a significant size. A better way in Java would be to use Jlink to create a smaller image (With gradle, see the badass-runtime-plugin)

Steps

Create a configuration

We create a configuration that will get the JVM as dependencies because they will then not be added to the runtime or compile class path.

Therefore, there is no chance that they interfere with other tasks plugin (such as getting in the libs)

val jdk: Configuration by configurations.creating

Define a web repository

To download dependencies, a repository is mandatory.

Because we will just download the JRE/JDK from the internet (ie in our case Amazon correto), we are creating an ivy repository.

repositories {

  // To download the JVM
  var corretto = ivy {

    url = uri("https://corretto.aws/downloads/latest")

    patternLayout {
      artifact("/[organisation]-[module]-[revision]-[classifier].[ext]")
    }

    // This is required in Gradle 6.0+ as metadata file (ivy.xml) is mandatory.
    // https://docs.gradle.org/6.2/userguide/declaring_repositories.html#sec:supported_metadata_sources
    metadataSources { artifact() }

  }
  
  // Use correto only for amazon dependencies
  // https://docs.gradle.org/current/userguide/declaring_repositories.html#declaring_content_exclusively_found_in_one_repository
  exclusiveContent {
    forRepositories(corretto)
    filter { includeGroup("amazon") }
  }
}

Defining the JVM that we want as dependencies

We are taking the jdk but you could as well defining the JRE.

dependencies {
  // For the corretto ivy repo, the dependency maps to the pattern:
//   [organisation]:[module]:[revision]:[classifier]@[ext]
// In maven term: [group]:[artifact]:[version]:[classifier]@[ext]
  jdk("amazon:corretto:8:[email protected]")
  jdk("amazon:corretto:8:[email protected]")
  jdk("amazon:corretto:8:[email protected]")
  jdk("amazon:corretto:8:[email protected]")

}

Defining the Targeted OS

The Target Os for the needed distributions as variable

val x64Windows = "x64-windows"
val x86Windows = "x86-windows"
val x64Linux = "x64-linux"
val x64Macos = "x64-macos"

Creating the task that will extract the content of JRE/JDK

For each targeted os, we will just extract the content of the JDK/JRE and copy them at the distribution location. To not repeat ourzelf, we create a custom task with arguments

abstract class JvmTask @Inject constructor(
  private val jdk: Configuration,
  private val osTarget: String
) : DefaultTask() {

  @TaskAction
  fun copyJvm() {

    project.copy {

// Listing
// project.configurations.compileClasspath.get().files.map { key -> println(key.name) };
      val corretto = Regex("corretto.*-${osTarget}.*");

      val jvmZipFile = project.configurations[jdk.name].resolve().find { file -> file.name.matches(corretto) };
      println("$osTarget Jvm file to unzip: $jvmZipFile")
// Unarchive doc https://docs.gradle.org/current/userguide/working_with_files.html#sec:unpacking_archives_example
      if (jvmZipFile == null) {
        throw RuntimeException("Unable to find a Jvm file dependency for the OS (${osTarget})")
      }
      val fileTree: FileTree = when (jvmZipFile.extension) {
        "zip" -> project.zipTree(jvmZipFile)
        "gz" -> project.tarTree(jvmZipFile)
        else -> null
      } ?: throw RuntimeException("The extension (${jvmZipFile.extension}) is unknown for the Jvm file")
      from(fileTree) {
        eachFile {
// delete the first directory as explained in the example 12
// https://docs.gradle.org/current/userguide/working_with_files.html#sec:unpacking_archives_example
          var segments = relativePath.segments.drop(1);
// macos has more than a root directory:
//   * the home is at amazon-correto-8/Contents/Home
//   * and there is third directory such as Contents/MacOS
          if (osTarget == "macos") {
            if (segments.size >= 2 && segments[0] == "Contents" && segments[1] == "Home") {
              segments = segments.drop(2);
            } else {
              this.exclude();
            }
          }
          relativePath = RelativePath(true, *segments.toTypedArray())
        }
      }
      into(project.layout.buildDirectory.dir("jdk/${osTarget}"))
      println("${osTarget} Jvm file Unzipped")
    }
  }

}

Create the tasks that will extract the JRE/JDK for each targeted OS

tasks.register<JvmTask>("${x86Windows}Jvm", jdk, x86Windows);
tasks.register<JvmTask>("${x64Windows}Jvm", jdk, x64Windows);
tasks.register<JvmTask>("${x64Linux}Jvm", jdk, x64Linux);
tasks.register<JvmTask>("${x64Macos}Jvm", jdk, x64Macos);

Distributions

In the distribution plugin, the plugin that creates Zip and Tar files, add a configuration by targeted os

distributions {
  main {
    contents {

      into("/bin") {
        from("${buildDir}/scripts") {
          fileMode = Integer.parseInt("755", 8) // or 493 which is 755 base 8 in base 10
        }
      }
      // Copy the jar of the cli (ie the actual project artifact generated)
      into("lib") {
        from(project.layout.buildDirectory.dir("libs").get().file(jar.archiveFileName))
      }
      // Copy the runtime dependencies
      into("lib") { //
        from(configurations.runtimeClasspath)
      }
  }

  create(x64Windows) {

    contents {
      into("/jdk") {
        from("${buildDir}/jdk/$x64Windows")
      }
      with(distributions.main.get().contents)
    }

  }

  create(x86Windows) {

    contents {
      into("/jdk") {
        from("${buildDir}/jdk/$x86Windows")
      }
      with(distributions.main.get().contents)
    }

  }

  create(x64Linux) {

    contents {
      into("/jdk") {
        from("${buildDir}/jdk/$x64Linux")
      }
      with(distributions.main.get().contents)
    }

  }

  create(x64Macos) {

    contents {
      into("/jdk") {
        from("${buildDir}/jdk/$x64Macos")
      }
      with(distributions.main.get().contents)
    }
  }

}

The fileMode of the script is expected to be converted from base 8 to base 10

fileMode = Integer.parseInt("755", 8)

Add the tasks dependency to unzip the JRE/JDK before the distribution task

tasks.getByName("${x86Windows}DistZip")
  .dependsOn("${x86Windows}Jvm")

tasks.getByName("${x64Windows}DistZip")
  .dependsOn("${x64Windows}Jvm")

tasks.getByName("${x64Linux}DistZip")
  .dependsOn("${x64Linux}Jvm")

tasks.getByName("${x64Macos}DistZip")
  .dependsOn("${x64Macos}Jvm")

Create a task to create all distribution at once

val allDistZip = tasks.register("allDistZip")
  .get()
  .dependsOn(tasks.getByName("distZip"))
  .dependsOn("${x86Windows}DistZip")
  .dependsOn("${x64Windows}DistZip")
  .dependsOn("${x64Macos}DistZip")
  .dependsOn("${x64Linux}DistZip")

Support

The constructor for type xxx.xxx should be annotated with @Inject

If you get this error:

The constructor for type xxx.xxx should be annotated with @Inject.

be sure that when creating a custom task, you have:

  • not use any global variable
  • or written an non-qualified qualified function. For instance don't use zipTree but project.zipTree

Unable to determine constructor argument #1 - value configuration is not assignable to type …, or no service of type Configuration

Same as the problem above be sure to qualify all function in your custom task.

Documentation / Reference


Powered by ComboStrap