Browse Source

Add ability to create/run distributable app

pull/224/head
Alexey Tsvetkov 4 years ago committed by Alexey Tsvetkov
parent
commit
3aff8f47e3
  1. 3
      gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/dsl/TargetFormat.kt
  2. 98
      gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/internal/configureApplication.kt
  3. 10
      gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/internal/osUtils.kt
  4. 117
      gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/tasks/AbstractJPackageTask.kt
  5. 91
      gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/tasks/AbstractJvmToolOperationTask.kt
  6. 53
      gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/tasks/AbstractRunDistributableTask.kt
  7. 13
      gradle-plugins/compose/src/test/kotlin/org/jetbrains/compose/DesktopApplicationTest.kt
  8. 5
      tutorials/Native_distributions_and_local_execution/README.md

3
gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/dsl/TargetFormat.kt

@ -19,6 +19,9 @@ enum class TargetFormat(
internal fun isCompatibleWith(targetOS: OS): Boolean = targetOS in compatibleOSs
val outputDirName: String
get() = if (this == AppImage) "app" else id
val fileExt: String
get() {
check(this != AppImage) { "$this cannot have a file extension" }

98
gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/internal/configureApplication.kt

@ -5,13 +5,12 @@ import org.gradle.api.file.DuplicatesStrategy
import org.gradle.api.file.FileCollection
import org.gradle.api.plugins.JavaPluginConvention
import org.gradle.api.provider.Provider
import org.gradle.api.tasks.JavaExec
import org.gradle.api.tasks.TaskContainer
import org.gradle.api.tasks.TaskProvider
import org.gradle.api.tasks.*
import org.gradle.jvm.tasks.Jar
import org.jetbrains.compose.desktop.application.dsl.Application
import org.jetbrains.compose.desktop.application.dsl.TargetFormat
import org.jetbrains.compose.desktop.application.tasks.AbstractJPackageTask
import org.jetbrains.compose.desktop.application.tasks.AbstractRunDistributableTask
import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension
import org.jetbrains.kotlin.gradle.plugin.KotlinPlatformType
import java.io.File
@ -53,32 +52,47 @@ internal fun Project.configureFromMppPlugin(mainApplication: Application) {
internal fun Project.configurePackagingTasks(apps: Collection<Application>) {
for (app in apps) {
configureRunTask(app)
configurePackagingTasks(app)
configurePackageUberJarForCurrentOS(app)
}
}
val run = project.tasks.composeTask<JavaExec>(taskName("run", app)) {
configureRunTask(app)
}
internal fun Project.configurePackagingTasks(app: Application): TaskProvider<DefaultTask> {
val packageFormats = app.nativeDistributions.targetFormats.map { targetFormat ->
tasks.composeTask<AbstractJPackageTask>(
taskName("package", app, targetFormat.name),
args = listOf(targetFormat)
val packageFormats = app.nativeDistributions.targetFormats.map { targetFormat ->
tasks.composeTask<AbstractJPackageTask>(
taskName("package", app, targetFormat.name),
args = listOf(targetFormat)
) {
configurePackagingTask(app)
}
}
val packageAll = tasks.composeTask<DefaultTask>(taskName("package", app)) {
dependsOn(packageFormats)
}
val packageUberJarForCurrentOS = project.tasks.composeTask<Jar>(taskName("package", app, "uberJarForCurrentOS")) {
configurePackageUberJarForCurrentOS(app)
}
val createDistributable = tasks.composeTask<AbstractJPackageTask>(
taskName("createDistributable", app),
args = listOf(TargetFormat.AppImage)
) {
configurePackagingTask(app)
}
}
return tasks.composeTask<DefaultTask>(taskName("package", app)) {
dependsOn(packageFormats)
val runDistributable = project.tasks.composeTask<AbstractRunDistributableTask>(
taskName("runDistributable", app),
args = listOf(createDistributable)
)
}
}
internal fun AbstractJPackageTask.configurePackagingTask(app: Application) {
internal fun AbstractJPackageTask.configurePackagingTask(
app: Application
) {
enabled = targetFormat.isCompatibleWithCurrentOS
if (targetFormat != TargetFormat.AppImage) {
configurePlatformSettings(app)
}
configurePlatformSettings(app)
app.nativeDistributions.let { executables ->
packageName.set(app._packageNameProvider(project))
@ -88,7 +102,7 @@ internal fun AbstractJPackageTask.configurePackagingTask(app: Application) {
packageVersion.set(app._packageVersionInternal(project))
}
destinationDir.set(app.nativeDistributions.outputBaseDir.map { it.dir("${app.name}/${targetFormat.id}") })
destinationDir.set(app.nativeDistributions.outputBaseDir.map { it.dir("${app.name}/${targetFormat.outputDirName}") })
javaHome.set(provider { app.javaHomeOrDefault() })
launcherMainJar.set(app.mainJar.orNull)
@ -147,33 +161,30 @@ internal fun AbstractJPackageTask.configurePlatformSettings(app: Application) {
}
}
private fun Project.configureRunTask(app: Application) {
project.tasks.composeTask<JavaExec>(taskName("run", app)) {
mainClass.set(provider { app.mainClass })
executable(javaExecutable(app.javaHomeOrDefault()))
jvmArgs = app.jvmArgs
args = app.args
private fun JavaExec.configureRunTask(app: Application) {
mainClass.set(provider { app.mainClass })
executable(javaExecutable(app.javaHomeOrDefault()))
jvmArgs = app.jvmArgs
args = app.args
val cp = objects.fileCollection()
// adding a null value will cause future invocations of `from` to throw an NPE
app.mainJar.orNull?.let { cp.from(it) }
cp.from(app._fromFiles)
dependsOn(*app._dependenciesTaskNames.toTypedArray())
app._configurationSource?.let { configSource ->
dependsOn(configSource.jarTaskName)
cp.from(configSource.runtimeClasspath)
}
val cp = project.objects.fileCollection()
// adding a null value will cause future invocations of `from` to throw an NPE
app.mainJar.orNull?.let { cp.from(it) }
cp.from(app._fromFiles)
dependsOn(*app._dependenciesTaskNames.toTypedArray())
classpath = cp
app._configurationSource?.let { configSource ->
dependsOn(configSource.jarTaskName)
cp.from(configSource.runtimeClasspath)
}
classpath = cp
}
private fun Project.configurePackageUberJarForCurrentOS(app: Application) =
project.tasks.composeTask<Jar>(taskName("package", app, "uberJarForCurrentOS")) {
private fun Jar.configurePackageUberJarForCurrentOS(app: Application) {
fun flattenJars(files: FileCollection): FileCollection =
project.files({
files.map { if (it.isZipOrJar()) zipTree(it) else it }
files.map { if (it.isZipOrJar()) project.zipTree(it) else it }
})
// adding a null value will cause future invocations of `from` to throw an NPE
@ -205,11 +216,6 @@ private fun File.isZipOrJar() =
private fun Application.javaHomeOrDefault(): String =
javaHome ?: System.getProperty("java.home")
private fun javaExecutable(javaHome: String): String {
val executableName = if (currentOS == OS.Windows) "java.exe" else "java"
return File(javaHome).resolve("bin/$executableName").absolutePath
}
private inline fun <reified T : Task> TaskContainer.composeTask(
name: String,
args: List<Any> = emptyList(),

10
gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/internal/osUtils.kt

@ -1,5 +1,7 @@
package org.jetbrains.compose.desktop.application.internal
import java.io.File
internal enum class OS(val id: String) {
Linux("linux"),
Windows("windows"),
@ -37,4 +39,10 @@ internal val currentOS: OS by lazy {
os.startsWith("Linux", ignoreCase = true) -> OS.Linux
else -> error("Unknown OS name: $os")
}
}
}
internal fun executableName(nameWithoutExtension: String): String =
if (currentOS == OS.Windows) "$nameWithoutExtension.exe" else nameWithoutExtension
internal fun javaExecutable(javaHome: String): String =
File(javaHome).resolve("bin/${executableName("java")}").absolutePath

117
gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/tasks/AbstractJPackageTask.kt

@ -1,56 +1,25 @@
package org.jetbrains.compose.desktop.application.tasks
import org.gradle.api.DefaultTask
import org.gradle.api.file.ConfigurableFileCollection
import org.gradle.api.file.DirectoryProperty
import org.gradle.api.file.RegularFileProperty
import org.gradle.api.internal.file.FileOperations
import org.gradle.api.model.ObjectFactory
import org.gradle.api.provider.ListProperty
import org.gradle.api.provider.Property
import org.gradle.api.provider.ProviderFactory
import org.gradle.api.tasks.*
import org.gradle.api.tasks.Optional
import org.gradle.process.ExecOperations
import org.gradle.process.ExecResult
import org.gradle.process.ExecSpec
import org.jetbrains.compose.desktop.application.dsl.TargetFormat
import org.jetbrains.compose.desktop.application.internal.OS
import org.jetbrains.compose.desktop.application.internal.cliArg
import org.jetbrains.compose.desktop.application.internal.currentOS
import org.jetbrains.compose.desktop.application.internal.notNullProperty
import org.jetbrains.compose.desktop.application.internal.nullableProperty
import org.jetbrains.compose.desktop.application.internal.*
import java.io.File
import java.nio.file.Files
import javax.inject.Inject
abstract class AbstractJPackageTask @Inject constructor(
@get:Input
val targetFormat: TargetFormat,
private val execOperations: ExecOperations,
private val fileOperations: FileOperations,
objects: ObjectFactory,
providers: ProviderFactory
) : DefaultTask() {
) : AbstractJvmToolOperationTask("jpackage") {
@get:InputFiles
val files: ConfigurableFileCollection = objects.fileCollection()
@get:OutputDirectory
val destinationDir: DirectoryProperty = objects.directoryProperty()
@get:Internal
val javaHome: Property<String> = objects.notNullProperty<String>().apply {
set(providers.systemProperty("java.home"))
}
@get:Internal
val verbose: Property<Boolean> = objects.notNullProperty<Boolean>().apply {
val composeVerbose = providers
.gradleProperty("compose.desktop.verbose")
.map { "true".equals(it, ignoreCase = true) }
set(providers.provider { logger.isDebugEnabled }.orElse(composeVerbose))
}
@get:InputDirectory
@get:Optional
/** @see internal/wixToolset.kt */
@ -187,16 +156,10 @@ abstract class AbstractJPackageTask @Inject constructor(
@get:Input
val modules: ListProperty<String> = objects.listProperty(String::class.java)
@get:Input
@get:Optional
val freeArgs: ListProperty<String> = objects.listProperty(String::class.java)
private fun makeArgs(vararg inputDirs: File) = arrayListOf<String>().apply {
for (dir in inputDirs) {
cliArg("--input", dir)
}
override fun makeArgs(tmpDir: File): MutableList<String> = super.makeArgs(tmpDir).apply {
cliArg("--input", tmpDir)
cliArg("--type", targetFormat.id)
cliArg("--dest", destinationDir.asFile.get())
cliArg("--verbose", verbose)
@ -251,59 +214,28 @@ abstract class AbstractJPackageTask @Inject constructor(
modules.get().forEach { m ->
cliArg("--add-modules", m)
}
freeArgs.orNull?.forEach { add(it) }
}
@TaskAction
fun run() {
val javaHomePath = javaHome.get()
val executableName = if (currentOS == OS.Windows) "jpackage.exe" else "jpackage"
val jpackage = File(javaHomePath).resolve("bin/$executableName")
check(jpackage.isFile) {
"Invalid JDK: $jpackage is not a file! \n" +
"Ensure JAVA_HOME or buildSettings.javaHome for '${packageName.get()}' app package is set to JDK 14 or newer"
}
override fun prepareWorkingDir(tmpDir: File) {
super.prepareWorkingDir(tmpDir)
fileOperations.delete(destinationDir)
val tmpDir = Files.createTempDirectory("compose-package").toFile().apply {
deleteOnExit()
}
try {
val args = makeArgs(tmpDir)
val sourceFile = launcherMainJar.get().asFile
launcherMainJar.asFile.orNull?.let { sourceFile ->
val targetFile = tmpDir.resolve(sourceFile.name)
sourceFile.copyTo(targetFile)
}
val myFiles = files
fileOperations.copy {
it.from(myFiles)
it.into(tmpDir)
}
val composeBuildDir = project.buildDir.resolve("compose").apply { mkdirs() }
val argsFile = composeBuildDir.resolve("${name}.args.txt")
argsFile.writeText(args.joinToString("\n"))
execOperations.exec { exec ->
configureWixPathIfNeeded(exec)
exec.executable = jpackage.absolutePath
exec.setArgs(listOf("@${argsFile.absolutePath}"))
}.assertNormalExitValue()
val destinationDirFile = destinationDir.asFile.get()
val finalLocation = when (targetFormat) {
TargetFormat.AppImage -> destinationDirFile
else -> destinationDirFile.walk().first { it.isFile && it.name.endsWith(targetFormat.fileExt) }
}
logger.lifecycle("The distribution is written to ${finalLocation.canonicalPath}")
} finally {
tmpDir.deleteRecursively()
val myFiles = files
fileOperations.copy {
it.from(myFiles)
it.into(tmpDir)
}
}
override fun configureExec(exec: ExecSpec) {
super.configureExec(exec)
configureWixPathIfNeeded(exec)
}
private fun configureWixPathIfNeeded(exec: ExecSpec) {
if (currentOS == OS.Windows) {
val wixDir = wixToolsetDir.asFile.orNull ?: return
@ -312,4 +244,15 @@ abstract class AbstractJPackageTask @Inject constructor(
exec.environment("PATH", "$wixPath;$path")
}
}
override fun checkResult(result: ExecResult) {
super.checkResult(result)
val destinationDirFile = destinationDir.asFile.get()
val finalLocation = when (targetFormat) {
TargetFormat.AppImage -> destinationDirFile
else -> destinationDirFile.walk().first { it.isFile && it.name.endsWith(targetFormat.fileExt) }
}
logger.lifecycle("The distribution is written to ${finalLocation.canonicalPath}")
}
}

91
gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/tasks/AbstractJvmToolOperationTask.kt

@ -0,0 +1,91 @@
package org.jetbrains.compose.desktop.application.tasks
import org.gradle.api.DefaultTask
import org.gradle.api.file.DirectoryProperty
import org.gradle.api.internal.file.FileOperations
import org.gradle.api.model.ObjectFactory
import org.gradle.api.provider.ListProperty
import org.gradle.api.provider.Property
import org.gradle.api.provider.ProviderFactory
import org.gradle.api.tasks.*
import org.gradle.process.ExecOperations
import org.gradle.process.ExecResult
import org.gradle.process.ExecSpec
import org.jetbrains.compose.desktop.application.internal.OS
import org.jetbrains.compose.desktop.application.internal.cliArg
import org.jetbrains.compose.desktop.application.internal.currentOS
import org.jetbrains.compose.desktop.application.internal.notNullProperty
import java.io.File
import java.nio.file.Files
import javax.inject.Inject
abstract class AbstractJvmToolOperationTask(private val toolName: String) : DefaultTask() {
@get:Inject
protected abstract val objects: ObjectFactory
@get:Inject
protected abstract val providers: ProviderFactory
@get:Inject
protected abstract val execOperations: ExecOperations
@get:Inject
protected abstract val fileOperations: FileOperations
@get:Input
@get:Optional
val freeArgs: ListProperty<String> = objects.listProperty(String::class.java)
@get:OutputDirectory
val destinationDir: DirectoryProperty = objects.directoryProperty()
@get:Internal
val javaHome: Property<String> = objects.notNullProperty<String>().apply {
set(providers.systemProperty("java.home"))
}
@get:Internal
val verbose: Property<Boolean> = objects.notNullProperty<Boolean>().apply {
val composeVerbose = providers
.gradleProperty("compose.desktop.verbose")
.map { "true".equals(it, ignoreCase = true) }
set(providers.provider { logger.isDebugEnabled }.orElse(composeVerbose))
}
protected open fun makeArgs(tmpDir: File): MutableList<String> = arrayListOf<String>().apply {
freeArgs.orNull?.forEach { add(it) }
}
protected open fun prepareWorkingDir(tmpDir: File) {}
protected open fun configureExec(exec: ExecSpec) {}
protected open fun checkResult(result: ExecResult) {
result.assertNormalExitValue()
}
@TaskAction
fun run() {
val javaHomePath = javaHome.get()
val jtool = File(javaHomePath).resolve("bin/${executableName(toolName)}")
check(jtool.isFile) {
"Invalid JDK: $jtool is not a file! \n" +
"Ensure JAVA_HOME or buildSettings.javaHome is set to JDK 14 or newer"
}
fileOperations.delete(destinationDir)
val tmpDir = Files.createTempDirectory("compose-${toolName}").toFile().apply {
deleteOnExit()
}
try {
val args = makeArgs(tmpDir)
prepareWorkingDir(tmpDir)
val composeBuildDir = project.buildDir.resolve("compose").apply { mkdirs() }
val argsFile = composeBuildDir.resolve("${name}.args.txt")
argsFile.writeText(args.joinToString("\n"))
execOperations.exec { exec ->
configureExec(exec)
exec.executable = jtool.absolutePath
exec.setArgs(listOf("@${argsFile.absolutePath}"))
}.also { checkResult(it) }
} finally {
tmpDir.deleteRecursively()
}
}
}

53
gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/tasks/AbstractRunDistributableTask.kt

@ -0,0 +1,53 @@
package org.jetbrains.compose.desktop.application.tasks
import org.gradle.api.DefaultTask
import org.gradle.api.file.Directory
import org.gradle.api.provider.Provider
import org.gradle.api.tasks.Input
import org.gradle.api.tasks.InputDirectory
import org.gradle.api.tasks.TaskAction
import org.gradle.api.tasks.TaskProvider
import org.gradle.process.ExecOperations
import org.jetbrains.compose.desktop.application.internal.OS
import org.jetbrains.compose.desktop.application.internal.currentOS
import org.jetbrains.compose.desktop.application.internal.executableName
import org.jetbrains.compose.desktop.application.internal.ioFile
import javax.inject.Inject
// Custom task is used instead of Exec, because Exec does not support
// lazy configuration yet. Lazy configuration is needed to
// calculate appImageDir after the evaluation of createApplicationImage
abstract class AbstractRunDistributableTask @Inject constructor(
createApplicationImage: TaskProvider<AbstractJPackageTask>,
private val execOperations: ExecOperations
) : DefaultTask() {
@get:InputDirectory
internal val appImageRootDir: Provider<Directory> = createApplicationImage.flatMap { it.destinationDir }
@get:Input
internal val packageName: Provider<String> = createApplicationImage.flatMap { it.packageName }
@TaskAction
fun run() {
val appDir = appImageRootDir.get().let { appImageRoot ->
val files = appImageRoot.asFile.listFiles()
if (files == null || files.isEmpty()) {
error("Could not find application image: $appImageRoot is empty!")
} else if (files.size > 1) {
error("Could not find application image: $appImageRoot contains multiple children [${files.joinToString(", ")}]")
} else files.single()
}
val appExecutableName = executableName(packageName.get())
val (workingDir, executable) = when (currentOS) {
OS.Linux -> appDir to "bin/$appExecutableName"
OS.Windows -> appDir to appExecutableName
OS.MacOS -> appDir.resolve("Contents") to "MacOS/$appExecutableName"
}
execOperations.exec { spec ->
spec.workingDir(workingDir)
spec.executable(executable)
}.assertNormalExitValue()
}
}

13
gradle-plugins/compose/src/test/kotlin/org/jetbrains/compose/DesktopApplicationTest.kt

@ -19,11 +19,20 @@ class DesktopApplicationTest : GradlePluginTestBase() {
tasks.getByName("run").doFirst {
throw new StopExecutionException("Skip run task")
}
tasks.getByName("runDistributable").doFirst {
throw new StopExecutionException("Skip runDistributable task")
}
}
""".trimIndent()
}
val result = gradle("run").build()
assertEquals(TaskOutcome.SUCCESS, result.task(":run")?.outcome)
gradle("run").build().let { result ->
assertEquals(TaskOutcome.SUCCESS, result.task(":run")?.outcome)
}
gradle("runDistributable").build().let { result ->
assertEquals(TaskOutcome.SUCCESS, result.task(":createDistributable")!!.outcome)
assertEquals(TaskOutcome.SUCCESS, result.task(":runDistributable")?.outcome)
}
}
@Test

5
tutorials/Native_distributions_and_local_execution/README.md

@ -54,6 +54,11 @@ The task is available starting from the M2 release.
The task expects `compose.desktop.currentOS` to be used as a `compile`/`implementation`/`runtime` dependency.
* `run` is used to run an app locally. You need to define a `mainClass` — an fq-name of a class,
containing the `main` function.
Note, that `run` starts a non-packaged JVM application with full runtime.
This is faster and easier to debug, than creating a compact binary image with minified runtime.
To run a final binary image, use `runDistributable` instead.
* `createDistributable` is used to create a prepackaged application image a final application image without creating an installer.
* `runDistributable` is used to run a prepackaged application image.
Note, that the tasks are created only if the `application` block/property is used in a script.

Loading…
Cancel
Save