Browse Source

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 <igordmn@users.noreply.github.com>
pull/4628/head v1.6.10-dev1580
badmannersteam 7 months ago committed by GitHub
parent
commit
994f0c6ea0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 1
      gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/dsl/ProguardSettings.kt
  2. 59
      gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/internal/configureJvmApplication.kt
  3. 13
      gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/tasks/AbstractJPackageTask.kt
  4. 8
      gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/tasks/AbstractProguardTask.kt
  5. 4
      gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/tasks/AbstractComposeDesktopTask.kt
  6. 86
      gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/tasks/AbstractJarsFlattenTask.kt
  7. 117
      gradle-plugins/compose/src/test/kotlin/org/jetbrains/compose/test/tests/integration/DesktopApplicationTest.kt

1
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<Boolean> = objects.notNullProperty(false)
val obfuscate: Property<Boolean> = objects.notNullProperty(false)
val optimize: Property<Boolean> = objects.notNullProperty(true)
val joinOutputJars: Property<Boolean> = objects.notNullProperty(false)
}

59
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<AbstractJarsFlattenTask>(
taskNameAction = "flatten",
taskNameObject = "Jars"
) {
configureFlattenJars(this, runProguard)
}
val packageUberJarForCurrentOS = tasks.register<Jar>(
taskNameAction = "package",
taskNameObject = "uberJarForCurrentOS"
) {
configurePackageUberJarForCurrentOS(this)
configurePackageUberJarForCurrentOS(this, flattenJars)
}
val runDistributable = tasks.register<AbstractRunDistributableTask>(
@ -234,7 +240,7 @@ private fun JvmApplicationContext.configurePackagingTasks(
)
val run = tasks.register<JavaExec>(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<Sync>
prepareAppResources: TaskProvider<Sync>,
runProguard: Provider<AbstractProguardTask>?
) {
exec.dependsOn(prepareAppResources)
@ -431,34 +441,49 @@ private fun JvmApplicationContext.configureRunTask(
add("-D$APP_RESOURCES_DIR=${appResourcesDir.absolutePath}")
}
exec.args = app.args
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<AbstractProguardTask>?
) {
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<AbstractJarsFlattenTask>
) {
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)

13
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<Boolean> = objects.notNullProperty(true)
/**
* Indicates that task will get the uber JAR as input.
*/
@get:Input
val packageFromUberJar: Property<Boolean> = 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 {

8
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<Boolean?> = objects.nullableProperty()
@get:Optional
@get:Input
val joinOutputJars: Property<Boolean?> = 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()}'")
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)")

4
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<Directory> = project.layout.buildDirectory.dir("compose/logs/$name")

86
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<String>()
@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
}
}
}

117
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,10 +544,19 @@ class DesktopApplicationTest : GradlePluginTestBase() {
}
@Test
fun testUnpackSkiko() {
with(testProject(TestProjects.unpackSkiko)) {
gradle(":runDistributable").checks {
check.taskSuccessful(":runDistributable")
fun testUnpackSkiko() = with(testProject(TestProjects.unpackSkiko)) {
testUnpackSkiko(":runDistributable")
}
@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)
@ -491,7 +574,6 @@ class DesktopApplicationTest : GradlePluginTestBase() {
}
}
}
}
@Test
fun resources() = with(testProject(TestProjects.resources)) {
@ -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" }
}
}

Loading…
Cancel
Save