From 994f0c6ea0dc46e06576b6bf3bc9b4d1cdb07bc5 Mon Sep 17 00:00:00 2001 From: badmannersteam <49683084+badmannersteam@users.noreply.github.com> Date: Fri, 12 Apr 2024 04:07:37 +0200 Subject: [PATCH] Option to pack jars as uber JAR, support Proguard for uber JAR (#4136) ## Proposed changes 1. Added support to join JARs to the uber JAR with ProGuard, disabled by default: ``` compose.desktop { application { buildTypes.release.proguard { joinOutputJars.set(true) } } } ``` 2. All 'release' tasks now really depend on ProGuard, as stated in [tutorial](https://github.com/JetBrains/compose-multiplatform/tree/master/tutorials/Native_distributions_and_local_execution#minification--obfuscation). ## Testing - A new auto test - Manual: 1. Test on Windows/macOs/Linux 2. Test the new Gradle parameter `joinOutputJars`: ``` compose.desktop { application { buildTypes.release.proguard { joinOutputJars.set(true) } } } ``` `false` (by default) should generate multiple jars (except for `package*UberJarForCurrentOS`) `true` should generate a single jar in a result distribution 3. Test debug tasks: ``` run runDistributable createDistributable packageUberJarForCurrentOS ``` 4. Test release tasks: ``` runRelease runReleaseDistributable createReleaseDistributable packageReleaseUberJarForCurrentOS ``` The jars should be reduced in size (because Proguard is enabled in the release mode) This should be test by QA. ## Issues fixed Fixes https://github.com/JetBrains/compose-multiplatform/issues/4129 --------- Co-authored-by: Igor Demin --- .../application/dsl/ProguardSettings.kt | 1 + .../internal/configureJvmApplication.kt | 65 +++++--- .../application/tasks/AbstractJPackageTask.kt | 13 +- .../application/tasks/AbstractProguardTask.kt | 10 +- .../tasks/AbstractComposeDesktopTask.kt | 4 + .../desktop/tasks/AbstractJarsFlattenTask.kt | 86 +++++++++++ .../integration/DesktopApplicationTest.kt | 143 +++++++++++++++--- 7 files changed, 275 insertions(+), 47 deletions(-) create mode 100644 gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/tasks/AbstractJarsFlattenTask.kt diff --git a/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/dsl/ProguardSettings.kt b/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/dsl/ProguardSettings.kt index 3a6ef8d84f..a3459d40d5 100644 --- a/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/dsl/ProguardSettings.kt +++ b/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/dsl/ProguardSettings.kt @@ -23,4 +23,5 @@ abstract class ProguardSettings @Inject constructor( val isEnabled: Property = objects.notNullProperty(false) val obfuscate: Property = objects.notNullProperty(false) val optimize: Property = objects.notNullProperty(true) + val joinOutputJars: Property = objects.notNullProperty(false) } diff --git a/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/internal/configureJvmApplication.kt b/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/internal/configureJvmApplication.kt index e93462ea7e..d53ad3dd62 100644 --- a/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/internal/configureJvmApplication.kt +++ b/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/internal/configureJvmApplication.kt @@ -7,7 +7,6 @@ package org.jetbrains.compose.desktop.application.internal import org.gradle.api.DefaultTask import org.gradle.api.file.DuplicatesStrategy -import org.gradle.api.file.FileCollection import org.gradle.api.provider.Provider import org.gradle.api.tasks.JavaExec import org.gradle.api.tasks.Sync @@ -16,6 +15,7 @@ import org.gradle.jvm.tasks.Jar import org.jetbrains.compose.desktop.application.dsl.TargetFormat import org.jetbrains.compose.desktop.application.internal.validation.validatePackageVersions import org.jetbrains.compose.desktop.application.tasks.* +import org.jetbrains.compose.desktop.tasks.AbstractJarsFlattenTask import org.jetbrains.compose.desktop.tasks.AbstractUnpackDefaultComposeApplicationResourcesTask import org.jetbrains.compose.internal.utils.* import org.jetbrains.compose.internal.utils.OS @@ -26,7 +26,6 @@ import org.jetbrains.compose.internal.utils.ioFile import org.jetbrains.compose.internal.utils.ioFileOrNull import org.jetbrains.compose.internal.utils.javaExecutable import org.jetbrains.compose.internal.utils.provider -import java.io.File private val defaultJvmArgs = listOf("-D$CONFIGURE_SWING_GLOBALS=true") internal const val composeDesktopTaskGroup = "compose desktop" @@ -220,11 +219,18 @@ private fun JvmApplicationContext.configurePackagingTasks( } } + val flattenJars = tasks.register( + taskNameAction = "flatten", + taskNameObject = "Jars" + ) { + configureFlattenJars(this, runProguard) + } + val packageUberJarForCurrentOS = tasks.register( taskNameAction = "package", taskNameObject = "uberJarForCurrentOS" ) { - configurePackageUberJarForCurrentOS(this) + configurePackageUberJarForCurrentOS(this, flattenJars) } val runDistributable = tasks.register( @@ -234,7 +240,7 @@ private fun JvmApplicationContext.configurePackagingTasks( ) val run = tasks.register(taskNameAction = "run") { - configureRunTask(this, commonTasks.prepareAppResources) + configureRunTask(this, commonTasks.prepareAppResources, runProguard) } } @@ -260,6 +266,8 @@ private fun JvmApplicationContext.configureProguardTask( dontobfuscate.set(settings.obfuscate.map { !it }) dontoptimize.set(settings.optimize.map { !it }) + joinOutputJars.set(settings.joinOutputJars) + dependsOn(unpackDefaultResources) defaultComposeRulesFile.set(unpackDefaultResources.flatMap { it.resources.defaultComposeProguardRules }) @@ -326,6 +334,7 @@ private fun JvmApplicationContext.configurePackageTask( packageTask.files.from(project.fileTree(runProguard.flatMap { it.destinationDir })) packageTask.launcherMainJar.set(runProguard.flatMap { it.mainJarInDestinationDir }) packageTask.mangleJarFilesNames.set(false) + packageTask.packageFromUberJar.set(runProguard.flatMap { it.joinOutputJars }) } else { packageTask.useAppRuntimeFiles { (runtimeJars, mainJar) -> files.from(runtimeJars) @@ -412,7 +421,8 @@ internal fun JvmApplicationContext.configurePlatformSettings( private fun JvmApplicationContext.configureRunTask( exec: JavaExec, - prepareAppResources: TaskProvider + prepareAppResources: TaskProvider, + runProguard: Provider? ) { exec.dependsOn(prepareAppResources) @@ -431,34 +441,49 @@ private fun JvmApplicationContext.configureRunTask( add("-D$APP_RESOURCES_DIR=${appResourcesDir.absolutePath}") } exec.args = app.args - exec.useAppRuntimeFiles { (runtimeJars, _) -> - classpath = runtimeJars + + if (runProguard != null) { + exec.dependsOn(runProguard) + exec.classpath = project.fileTree(runProguard.flatMap { it.destinationDir }) + } else { + exec.useAppRuntimeFiles { (runtimeJars, _) -> + classpath = runtimeJars + } } } -private fun JvmApplicationContext.configurePackageUberJarForCurrentOS(jar: Jar) { - fun flattenJars(files: FileCollection): FileCollection = - jar.project.files({ - files.map { if (it.isZipOrJar()) jar.project.zipTree(it) else it } - }) +private fun JvmApplicationContext.configureFlattenJars( + flattenJars: AbstractJarsFlattenTask, + runProguard: Provider? +) { + if (runProguard != null) { + flattenJars.dependsOn(runProguard) + flattenJars.inputFiles.from(runProguard.flatMap { it.destinationDir }) + } else { + flattenJars.useAppRuntimeFiles { (runtimeJars, _) -> + inputFiles.from(runtimeJars) + } + } + flattenJars.flattenedJar.set(appTmpDir.file("flattenJars/flattened.jar")) +} - jar.useAppRuntimeFiles { (runtimeJars, _) -> - from(flattenJars(runtimeJars)) - } +private fun JvmApplicationContext.configurePackageUberJarForCurrentOS( + jar: Jar, + flattenJars: Provider +) { + jar.dependsOn(flattenJars) + jar.from(project.zipTree(flattenJars.flatMap { it.flattenedJar })) app.mainClass?.let { jar.manifest.attributes["Main-Class"] = it } jar.duplicatesStrategy = DuplicatesStrategy.EXCLUDE jar.archiveAppendix.set(currentTarget.id) jar.archiveBaseName.set(packageNameProvider) jar.archiveVersion.set(packageVersionFor(TargetFormat.AppImage)) + jar.archiveClassifier.set(buildType.classifier) jar.destinationDirectory.set(jar.project.layout.buildDirectory.dir("compose/jars")) jar.doLast { jar.logger.lifecycle("The jar is written to ${jar.archiveFile.ioFile.canonicalPath}") } -} - -private fun File.isZipOrJar() = - name.endsWith(".jar", ignoreCase = true) - || name.endsWith(".zip", ignoreCase = true) \ No newline at end of file +} \ No newline at end of file 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 8dbb0dae75..ad56727c05 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 @@ -65,6 +65,12 @@ abstract class AbstractJPackageTask @Inject constructor( @get:Input val mangleJarFilesNames: Property = objects.notNullProperty(true) + /** + * Indicates that task will get the uber JAR as input. + */ + @get:Input + val packageFromUberJar: Property = objects.notNullProperty(false) + @get:InputDirectory @get:Optional /** @see internal/wixToolset.kt */ @@ -323,7 +329,7 @@ abstract class AbstractJPackageTask @Inject constructor( javaOption("-D$APP_RESOURCES_DIR=${appDir(packagedResourcesDir.ioFile.name)}") - val mappedJar = libsMapping[launcherMainJar.ioFile]?.singleOrNull() + val mappedJar = libsMapping[launcherMainJar.ioFile]?.singleOrNull { it.isJarFile } ?: error("Main jar was not processed correctly: ${launcherMainJar.ioFile}") val mainJarPath = mappedJar.normalizedPath(base = libsDir.ioFile) cliArg("--main-jar", mainJarPath) @@ -468,11 +474,14 @@ abstract class AbstractJPackageTask @Inject constructor( return targetFile } + // skiko can be bundled to the main uber jar by proguard + fun File.isMainUberJar() = packageFromUberJar.get() && name == launcherMainJar.ioFile.name + val outdatedLibs = invalidateMappedLibs(inputChanges) for (sourceFile in outdatedLibs) { assert(sourceFile.exists()) { "Lib file does not exist: $sourceFile" } - libsMapping[sourceFile] = if (isSkikoForCurrentOS(sourceFile)) { + libsMapping[sourceFile] = if (isSkikoForCurrentOS(sourceFile) || sourceFile.isMainUberJar()) { val unpackedFiles = unpackSkikoForCurrentOS(sourceFile, skikoDir.ioFile, fileOperations) unpackedFiles.map { copyFileToLibsDir(it) } } else { diff --git a/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/tasks/AbstractProguardTask.kt b/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/tasks/AbstractProguardTask.kt index 201d5353b5..fa04502a91 100644 --- a/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/tasks/AbstractProguardTask.kt +++ b/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/tasks/AbstractProguardTask.kt @@ -42,6 +42,10 @@ abstract class AbstractProguardTask : AbstractComposeDesktopTask() { @get:Input val dontoptimize: Property = objects.nullableProperty() + @get:Optional + @get:Input + val joinOutputJars: Property = objects.nullableProperty() + // todo: DSL for excluding default rules // also consider pulling coroutines rules from coroutines artifact // https://github.com/Kotlin/kotlinx.coroutines/blob/master/kotlinx-coroutines-core/jvm/resources/META-INF/proguard/coroutines.pro @@ -98,10 +102,14 @@ abstract class AbstractProguardTask : AbstractComposeDesktopTask() { } jarsConfigurationFile.ioFile.bufferedWriter().use { writer -> + val toSingleOutputJar = joinOutputJars.orNull == true for ((input, output) in inputToOutputJars.entries) { writer.writeLn("-injars '${input.normalizedPath()}'") - writer.writeLn("-outjars '${output.normalizedPath()}'") + if (!toSingleOutputJar) + writer.writeLn("-outjars '${output.normalizedPath()}'") } + if (toSingleOutputJar) + writer.writeLn("-outjars '${mainJarInDestinationDir.ioFile.normalizedPath()}'") for (jmod in jmods) { writer.writeLn("-libraryjars '${jmod.normalizedPath()}'(!**.jar;!module-info.class)") diff --git a/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/tasks/AbstractComposeDesktopTask.kt b/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/tasks/AbstractComposeDesktopTask.kt index 63110a417d..f5c8e600a6 100644 --- a/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/tasks/AbstractComposeDesktopTask.kt +++ b/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/tasks/AbstractComposeDesktopTask.kt @@ -6,6 +6,7 @@ package org.jetbrains.compose.desktop.tasks import org.gradle.api.DefaultTask +import org.gradle.api.file.ArchiveOperations import org.gradle.api.file.Directory import org.gradle.api.file.FileSystemOperations import org.gradle.api.model.ObjectFactory @@ -33,6 +34,9 @@ abstract class AbstractComposeDesktopTask : DefaultTask() { @get:Inject protected abstract val fileOperations: FileSystemOperations + @get:Inject + protected abstract val archiveOperations: ArchiveOperations + @get:LocalState protected val logsDir: Provider = project.layout.buildDirectory.dir("compose/logs/$name") diff --git a/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/tasks/AbstractJarsFlattenTask.kt b/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/tasks/AbstractJarsFlattenTask.kt new file mode 100644 index 0000000000..161a9b106e --- /dev/null +++ b/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/tasks/AbstractJarsFlattenTask.kt @@ -0,0 +1,86 @@ +/* + * Copyright 2020-2024 JetBrains s.r.o. and respective authors and developers. + * Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file. + */ + +package org.jetbrains.compose.desktop.tasks + +import org.gradle.api.file.ConfigurableFileCollection +import org.gradle.api.file.RegularFileProperty +import org.gradle.api.tasks.InputFiles +import org.gradle.api.tasks.Internal +import org.gradle.api.tasks.OutputFile +import org.gradle.api.tasks.TaskAction +import org.jetbrains.compose.desktop.application.internal.files.copyZipEntry +import org.jetbrains.compose.desktop.application.internal.files.isJarFile +import org.jetbrains.compose.internal.utils.delete +import org.jetbrains.compose.internal.utils.ioFile +import java.io.File +import java.io.FileInputStream +import java.io.FileOutputStream +import java.io.InputStream +import java.util.zip.ZipEntry +import java.util.zip.ZipInputStream +import java.util.zip.ZipOutputStream + + +/** + * This task flattens all jars from the input directory into the single one, + * which is used later as a single source for uberjar. + * + * This task is necessary because the standard Jar/Zip task evaluates own `from()` args eagerly + * [in the configuration phase](https://discuss.gradle.org/t/why-is-the-closure-in-from-method-of-copy-task-evaluated-in-config-phase/23469/4) + * and snapshots an empty list of files in the Proguard destination directory, + * instead of a list of real jars after Proguard task execution. + * + * Also, we use output to the single jar instead of flattening to the directory in the filesystem because: + * - Windows filesystem is case-insensitive and not every jar can be unzipped without losing files + * - it's just faster + */ +abstract class AbstractJarsFlattenTask : AbstractComposeDesktopTask() { + + @get:InputFiles + val inputFiles: ConfigurableFileCollection = objects.fileCollection() + + @get:OutputFile + val flattenedJar: RegularFileProperty = objects.fileProperty() + + @get:Internal + val seenEntryNames = hashSetOf() + + @TaskAction + fun execute() { + seenEntryNames.clear() + fileOperations.delete(flattenedJar) + + ZipOutputStream(FileOutputStream(flattenedJar.ioFile).buffered()).use { outputStream -> + inputFiles.asFileTree.visit { + when { + !it.isDirectory && it.file.isJarFile -> outputStream.writeJarContent(it.file) + !it.isDirectory -> outputStream.writeFile(it.file) + } + } + } + } + + private fun ZipOutputStream.writeJarContent(jarFile: File) = + ZipInputStream(FileInputStream(jarFile)).use { inputStream -> + var inputEntry: ZipEntry? = inputStream.nextEntry + while (inputEntry != null) { + writeEntryIfNotSeen(inputEntry, inputStream) + inputEntry = inputStream.nextEntry + } + } + + private fun ZipOutputStream.writeFile(file: File) = + FileInputStream(file).use { inputStream -> + writeEntryIfNotSeen(ZipEntry(file.name), inputStream) + } + + private fun ZipOutputStream.writeEntryIfNotSeen(entry: ZipEntry, inputStream: InputStream) { + if (entry.name !in seenEntryNames) { + copyZipEntry(entry, inputStream, this) + seenEntryNames += entry.name + } + } +} \ No newline at end of file diff --git a/gradle-plugins/compose/src/test/kotlin/org/jetbrains/compose/test/tests/integration/DesktopApplicationTest.kt b/gradle-plugins/compose/src/test/kotlin/org/jetbrains/compose/test/tests/integration/DesktopApplicationTest.kt index 9dd9e572e8..0653807888 100644 --- a/gradle-plugins/compose/src/test/kotlin/org/jetbrains/compose/test/tests/integration/DesktopApplicationTest.kt +++ b/gradle-plugins/compose/src/test/kotlin/org/jetbrains/compose/test/tests/integration/DesktopApplicationTest.kt @@ -32,20 +32,33 @@ class DesktopApplicationTest : GradlePluginTestBase() { tasks.getByName("run").doFirst { throw new StopExecutionException("Skip run task") } + tasks.getByName("runRelease").doFirst { + throw new StopExecutionException("Skip runRelease task") + } tasks.getByName("runDistributable").doFirst { throw new StopExecutionException("Skip runDistributable task") } + tasks.getByName("runReleaseDistributable").doFirst { + throw new StopExecutionException("Skip runReleaseDistributable task") + } } """.trimIndent() } gradle("run").checks { check.taskSuccessful(":run") } + gradle("runRelease").checks { + check.taskSuccessful(":runRelease") + } gradle("runDistributable").checks { check.taskSuccessful(":createDistributable") check.taskSuccessful(":runDistributable") } + gradle("runReleaseDistributable").checks { + check.taskSuccessful(":createReleaseDistributable") + check.taskSuccessful(":runReleaseDistributable") + } } @Test @@ -55,11 +68,20 @@ class DesktopApplicationTest : GradlePluginTestBase() { check.taskSuccessful(":run") check.logContains(logLine) } + gradle("runRelease").checks { + check.taskSuccessful(":runRelease") + check.logContains(logLine) + } gradle("runDistributable").checks { check.taskSuccessful(":createDistributable") check.taskSuccessful(":runDistributable") check.logContains(logLine) } + gradle("runReleaseDistributable").checks { + check.taskSuccessful(":createReleaseDistributable") + check.taskSuccessful(":runReleaseDistributable") + check.logContains(logLine) + } } /** @@ -165,6 +187,38 @@ class DesktopApplicationTest : GradlePluginTestBase() { } } + @Test + fun joinOutputJarsJvm() = with(testProject(TestProjects.jvm)) { + joinOutputJars() + } + + @Test + fun joinOutputJarsMpp() = with(testProject(TestProjects.mpp)) { + joinOutputJars() + } + + private fun TestProject.joinOutputJars() { + enableJoinOutputJars() + gradle(":createReleaseDistributable").checks { + check.taskSuccessful(":createReleaseDistributable") + + val distributionPathPattern = "The distribution is written to (.*)".toRegex() + val m = distributionPathPattern.find(check.log) + val distributionDir = m?.groupValues?.get(1)?.let(::File) + if (distributionDir == null || !distributionDir.exists()) { + error("Invalid distribution path: $distributionDir") + } + val appDirSubPath = when (currentOS) { + OS.Linux -> "TestPackage/lib/app" + OS.Windows -> "TestPackage/app" + OS.MacOS -> "TestPackage.app/Contents/app" + } + val appDir = distributionDir.resolve(appDirSubPath) + val jarsCount = appDir.listFiles()?.count { it.name.endsWith(".jar", ignoreCase = true) } ?: 0 + assert(jarsCount == 1) + } + } + @Test fun gradleBuildCache() = with(testProject(TestProjects.jvm)) { modifyGradleProperties { @@ -265,19 +319,39 @@ class DesktopApplicationTest : GradlePluginTestBase() { @Test fun packageUberJarForCurrentOSJvm() = with(testProject(TestProjects.jvm)) { - testPackageUberJarForCurrentOS() + testPackageUberJarForCurrentOS(false) } @Test fun packageUberJarForCurrentOSMpp() = with(testProject(TestProjects.mpp)) { - testPackageUberJarForCurrentOS() + testPackageUberJarForCurrentOS(false) } - private fun TestProject.testPackageUberJarForCurrentOS() { - gradle(":packageUberJarForCurrentOS").checks { - check.taskSuccessful(":packageUberJarForCurrentOS") + @Test + fun packageReleaseUberJarForCurrentOSJvm() = with(testProject(TestProjects.jvm)) { + testPackageUberJarForCurrentOS(true) + } - val resultJarFile = file("build/compose/jars/TestPackage-${currentTarget.id}-1.0.0.jar") + @Test + fun packageReleaseUberJarForCurrentOSMpp() = with(testProject(TestProjects.mpp)) { + testPackageUberJarForCurrentOS(true) + } + + private fun TestProject.testPackageUberJarForCurrentOS(release: Boolean) { + val task = when { + release -> ":packageReleaseUberJarForCurrentOS" + else -> ":packageUberJarForCurrentOS" + } + + val jarFileName = when { + release -> "build/compose/jars/TestPackage-${currentTarget.id}-1.0.0-release.jar" + else -> "build/compose/jars/TestPackage-${currentTarget.id}-1.0.0.jar" + } + + gradle(task).checks { + check.taskSuccessful(task) + + val resultJarFile = file(jarFileName) resultJarFile.checkExists() JarFile(resultJarFile).use { jar -> @@ -470,25 +544,33 @@ class DesktopApplicationTest : GradlePluginTestBase() { } @Test - fun testUnpackSkiko() { - with(testProject(TestProjects.unpackSkiko)) { - gradle(":runDistributable").checks { - check.taskSuccessful(":runDistributable") + fun testUnpackSkiko() = with(testProject(TestProjects.unpackSkiko)) { + testUnpackSkiko(":runDistributable") + } - val libraryPathPattern = "Read skiko library path: '(.*)'".toRegex() - val m = libraryPathPattern.find(check.log) - val skikoDir = m?.groupValues?.get(1)?.let(::File) - if (skikoDir == null || !skikoDir.exists()) { - error("Invalid skiko path: $skikoDir") - } - val filesToFind = when (currentOS) { - OS.Linux -> listOf("libskiko-linux-${currentArch.id}.so") - OS.Windows -> listOf("skiko-windows-${currentArch.id}.dll", "icudtl.dat") - OS.MacOS -> listOf("libskiko-macos-${currentArch.id}.dylib") - } - for (fileName in filesToFind) { - skikoDir.resolve(fileName).checkExists() - } + @Test + fun testUnpackSkikoFromUberJar() = with(testProject(TestProjects.unpackSkiko)) { + enableJoinOutputJars() + testUnpackSkiko(":runReleaseDistributable") + } + + private fun TestProject.testUnpackSkiko(runDistributableTask: String) { + gradle(runDistributableTask).checks { + check.taskSuccessful(runDistributableTask) + + val libraryPathPattern = "Read skiko library path: '(.*)'".toRegex() + val m = libraryPathPattern.find(check.log) + val skikoDir = m?.groupValues?.get(1)?.let(::File) + if (skikoDir == null || !skikoDir.exists()) { + error("Invalid skiko path: $skikoDir") + } + val filesToFind = when (currentOS) { + OS.Linux -> listOf("libskiko-linux-${currentArch.id}.so") + OS.Windows -> listOf("skiko-windows-${currentArch.id}.dll", "icudtl.dat") + OS.MacOS -> listOf("libskiko-macos-${currentArch.id}.dylib") + } + for (fileName in filesToFind) { + skikoDir.resolve(fileName).checkExists() } } } @@ -518,4 +600,17 @@ class DesktopApplicationTest : GradlePluginTestBase() { } } } + + private fun TestProject.enableJoinOutputJars() { + val enableJoinOutputJars = """ + compose.desktop { + application { + buildTypes.release.proguard { + joinOutputJars.set(true) + } + } + } + """.trimIndent() + file("build.gradle").modify { "$it\n$enableJoinOutputJars" } + } }