From 3aff8f47e3296f43abba18fd9d03e785b70e7d37 Mon Sep 17 00:00:00 2001 From: Alexey Tsvetkov Date: Mon, 30 Nov 2020 15:02:14 +0300 Subject: [PATCH] Add ability to create/run distributable app --- .../desktop/application/dsl/TargetFormat.kt | 3 + .../internal/configureApplication.kt | 98 ++++++++------- .../desktop/application/internal/osUtils.kt | 10 +- .../application/tasks/AbstractJPackageTask.kt | 117 +++++------------- .../tasks/AbstractJvmToolOperationTask.kt | 91 ++++++++++++++ .../tasks/AbstractRunDistributableTask.kt | 53 ++++++++ .../compose/DesktopApplicationTest.kt | 13 +- .../README.md | 5 + 8 files changed, 254 insertions(+), 136 deletions(-) create mode 100644 gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/tasks/AbstractJvmToolOperationTask.kt create mode 100644 gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/tasks/AbstractRunDistributableTask.kt diff --git a/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/dsl/TargetFormat.kt b/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/dsl/TargetFormat.kt index 2f28ee1d4b..ae57949117 100644 --- a/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/dsl/TargetFormat.kt +++ b/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" } diff --git a/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/internal/configureApplication.kt b/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/internal/configureApplication.kt index cc950d5847..920b59db06 100644 --- a/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/internal/configureApplication.kt +++ b/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) { for (app in apps) { - configureRunTask(app) - configurePackagingTasks(app) - configurePackageUberJarForCurrentOS(app) - } -} + val run = project.tasks.composeTask(taskName("run", app)) { + configureRunTask(app) + } -internal fun Project.configurePackagingTasks(app: Application): TaskProvider { - val packageFormats = app.nativeDistributions.targetFormats.map { targetFormat -> - tasks.composeTask( - taskName("package", app, targetFormat.name), - args = listOf(targetFormat) + val packageFormats = app.nativeDistributions.targetFormats.map { targetFormat -> + tasks.composeTask( + taskName("package", app, targetFormat.name), + args = listOf(targetFormat) + ) { + configurePackagingTask(app) + } + } + + val packageAll = tasks.composeTask(taskName("package", app)) { + dependsOn(packageFormats) + } + + val packageUberJarForCurrentOS = project.tasks.composeTask(taskName("package", app, "uberJarForCurrentOS")) { + configurePackageUberJarForCurrentOS(app) + } + + val createDistributable = tasks.composeTask( + taskName("createDistributable", app), + args = listOf(TargetFormat.AppImage) ) { configurePackagingTask(app) } - } - return tasks.composeTask(taskName("package", app)) { - dependsOn(packageFormats) + + val runDistributable = project.tasks.composeTask( + 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(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(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 TaskContainer.composeTask( name: String, args: List = emptyList(), diff --git a/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/internal/osUtils.kt b/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/internal/osUtils.kt index ddd1063cf3..e6a1b35893 100644 --- a/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/internal/osUtils.kt +++ b/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") } -} \ No newline at end of file +} + +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 diff --git a/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/tasks/AbstractJPackageTask.kt b/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/tasks/AbstractJPackageTask.kt index dcb651820b..b2a1a0ded2 100644 --- a/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/tasks/AbstractJPackageTask.kt +++ b/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 = objects.notNullProperty().apply { - set(providers.systemProperty("java.home")) - } - - @get:Internal - val verbose: Property = objects.notNullProperty().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 = objects.listProperty(String::class.java) - @get:Input - @get:Optional - val freeArgs: ListProperty = objects.listProperty(String::class.java) - - private fun makeArgs(vararg inputDirs: File) = arrayListOf().apply { - for (dir in inputDirs) { - cliArg("--input", dir) - } - + override fun makeArgs(tmpDir: File): MutableList = 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}") + } } \ No newline at end of file diff --git a/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/tasks/AbstractJvmToolOperationTask.kt b/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/tasks/AbstractJvmToolOperationTask.kt new file mode 100644 index 0000000000..dbe7658728 --- /dev/null +++ b/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 = objects.listProperty(String::class.java) + + @get:OutputDirectory + val destinationDir: DirectoryProperty = objects.directoryProperty() + + @get:Internal + val javaHome: Property = objects.notNullProperty().apply { + set(providers.systemProperty("java.home")) + } + + @get:Internal + val verbose: Property = objects.notNullProperty().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 = arrayListOf().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() + } + } +} \ No newline at end of file diff --git a/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/tasks/AbstractRunDistributableTask.kt b/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/tasks/AbstractRunDistributableTask.kt new file mode 100644 index 0000000000..76d992c6a8 --- /dev/null +++ b/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, + private val execOperations: ExecOperations +) : DefaultTask() { + @get:InputDirectory + internal val appImageRootDir: Provider = createApplicationImage.flatMap { it.destinationDir } + + @get:Input + internal val packageName: Provider = 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() + } +} \ No newline at end of file diff --git a/gradle-plugins/compose/src/test/kotlin/org/jetbrains/compose/DesktopApplicationTest.kt b/gradle-plugins/compose/src/test/kotlin/org/jetbrains/compose/DesktopApplicationTest.kt index 4ab0594092..fa9c3badff 100644 --- a/gradle-plugins/compose/src/test/kotlin/org/jetbrains/compose/DesktopApplicationTest.kt +++ b/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 diff --git a/tutorials/Native_distributions_and_local_execution/README.md b/tutorials/Native_distributions_and_local_execution/README.md index b726d299bb..bd580341f2 100755 --- a/tutorials/Native_distributions_and_local_execution/README.md +++ b/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.