diff --git a/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/dsl/AppCdsSettings.kt b/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/dsl/AppCdsSettings.kt new file mode 100644 index 00000000000..4878d8f8be6 --- /dev/null +++ b/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/dsl/AppCdsSettings.kt @@ -0,0 +1,218 @@ +package org.jetbrains.compose.desktop.application.dsl + +import org.gradle.api.file.Directory +import org.gradle.api.file.RegularFile +import org.jetbrains.compose.internal.utils.packagedAppJarFilesDir +import java.io.Serializable + +/** + * The configuration of AppCDS for the native distribution. + * + * AppCDS is a JVM mechanism that allows to significantly speed up application + * startup by creating an archive of the classes it uses that can be loaded + * and used much faster than class files. + */ +abstract class AppCdsConfiguration { + /** + * The AppCDS mode to use. + */ + var mode: AppCdsMode = AppCdsMode.None + + /** + * Whether to print AppCDS-related messages at application runtime. + */ + var logging: Boolean = false + + /** + * Whether to fail running the app if unable to load the AppCDS archive. + */ + var exitAppOnCdsFailure: Boolean = false +} + +/** + * Returns the AppCDS-related arguments to pass the JVM when running the app. + */ +internal fun AppCdsConfiguration.runtimeJvmArgs() = buildList { + addAll(mode.runtimeJvmArgs()) + if (exitAppOnCdsFailure && (mode != AppCdsMode.None)) { + add("-Xshare:on") + } + if (logging) { + add("-Xlog:cds") + } +} + +/** + * The mode of use of AppCDS. + */ +abstract class AppCdsMode(val name: String) : Serializable { + + /** + * The minimum JDK version for which this mode is supported. + */ + internal open val minJdkVersion: Int? = null + + /** + * Whether to generate a classes.jsa archive for the JRE classes. + */ + internal abstract val generateJreClassesArchive: Boolean + + /** + * Returns whether this mode creates an archive of app classes at build time. + */ + internal open val generateAppClassesArchive: Boolean get() = false + + /** + * The arguments to pass to the JVM when running the app to create + * the archive for the app's class files. + * + * This will only be called if [generateAppClassesArchive] is `true`. + */ + internal open fun appClassesArchiveCreationJvmArgs(): List = + error("AppCdsMode '$this' does not create an archive") + + /** + * Returns the app's classes archive file, given the root directory of + * the packaged app. + */ + internal open fun appClassesArchiveFile(packagedAppRootDir: Directory): RegularFile = + error("AppCdsMode '$this' does not create an archive") + + /** + * The arguments to pass to the JVM when running the final app. + */ + internal abstract fun runtimeJvmArgs(): List + + /** + * Checks whether this mode is compatible with the given JDK major version. + * Throws an exception if not. + */ + internal open fun checkJdkCompatibility(jdkMajorVersion: Int, jdkVendor: String) { + val minMajorJdkVersion = minJdkVersion ?: return + if (jdkMajorVersion < minMajorJdkVersion) { + error( + "AppCdsMode '$this' is not supported on JDK earlier than" + + " $minMajorJdkVersion; current is $jdkMajorVersion" + ) + } + } + + override fun toString() = name + + companion object { + + /** + * The name of the AppCDS archive file. + */ + private const val ARCHIVE_NAME = "app.jsa" + + /** + * The AppCDS archive file. + */ + internal const val ARCHIVE_FILE_ARGUMENT = "\$APPDIR/$ARCHIVE_NAME" + + /** + * AppCDS is not used. + */ + val None = object : AppCdsMode("None") { + override val generateJreClassesArchive: Boolean get() = false + override fun runtimeJvmArgs() = emptyList() + } + + /** + * AppCDS is used via a dynamic shared archive created automatically + * when the app is run (using `-XX:+AutoCreateSharedArchive`). + * + * Advantages: + * - Simplest - no additional step is needed to build the archive. + * - Creates a smaller distributable. + * + * Drawbacks: + * - Requires JDK 19 or later. + * - The archive is not available at the first execution of the app, + * so it is slower (and possibly even slower than regular execution), + * The archive is created when at shutdown time of the first execution, + * which also takes a little longer. + * - Some OSes may block writing the archive file to the application's + * directory at runtime. Due to this, `Auto` mode is mostly recommended + * only to experiment and see how fast your app starts up with AppCDS. + * In production, we recommend [Prebuild] mode. + * + */ + @Suppress("unused") + val Auto = object : AppCdsMode("Auto") { + override val minJdkVersion = 19 + override val generateJreClassesArchive: Boolean get() = true + override fun runtimeJvmArgs() = + listOf( + "-XX:SharedArchiveFile=$ARCHIVE_FILE_ARGUMENT", + "-XX:+AutoCreateSharedArchive" + ) + } + + /** + * AppCDS is used via a dynamic shared archive created by executing + * the app before packaging (using `-XX:ArchiveClassesAtExit`). + * + * When using this mode, the app will be run during the creation of + * the distributable. In this run, a system property + * `compose.appcds.create-archive` will be set to the value `true`. + * The app should "exercise" itself and cause the loading of all + * the classes that should be written into the AppCDS archive. It + * should then shut down in order to let the build process continue. + * + * For example: + * ``` + * application { + * ... + * if (System.getProperty("compose.appcds.create-archive") == "true") { + * LaunchedEffect(Unit) { + * delay(10.seconds) // Or a custom event indicating startup finished + * exitApplication() + * } + * } + * } + * ``` + * + * Advantages: + * - The first run of the distributed app is fast too. + * + * Drawbacks: + * - Requires JDK 21 or later. + * - Requires an additional step of running the app when building the + * distributable. + * - The distributable is larger because it includes the archive of + * the app's classes. + */ + @Suppress("unused") + val Prebuild = object : AppCdsMode("Prebuild") { + override val minJdkVersion = 21 + override fun checkJdkCompatibility(jdkMajorVersion: Int, jdkVendor: String) { + super.checkJdkCompatibility(jdkMajorVersion, jdkVendor) +// if (jdkVendor != "JetBrains s.r.o.") { +// error( +// "Prebuild AppCDS mode is only supported on JetBrains JDK; " + +// "current vendor is '$jdkVendor'" +// ) +// } + } + override val generateJreClassesArchive: Boolean get() = true + override val generateAppClassesArchive: Boolean get() = true + override fun appClassesArchiveCreationJvmArgs() = + listOf( + "-XX:ArchiveClassesAtExit=$ARCHIVE_FILE_ARGUMENT", + "-XX:+NoClasspathInArchive", + "-Dcompose.appcds.create-archive=true", + ) + override fun appClassesArchiveFile(packagedAppRootDir: Directory): RegularFile { + val appDir = packagedAppJarFilesDir(packagedAppRootDir) + return appDir.file(ARCHIVE_NAME) + } + override fun runtimeJvmArgs() = + listOf( + "-XX:SharedArchiveFile=$ARCHIVE_FILE_ARGUMENT", + "-XX:+NoClasspathInArchive", + ) + } + } +} \ No newline at end of file diff --git a/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/dsl/JvmApplicationBuildTypes.kt b/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/dsl/JvmApplicationBuildTypes.kt index d542b3fae57..de86cd083ff 100644 --- a/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/dsl/JvmApplicationBuildTypes.kt +++ b/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/dsl/JvmApplicationBuildTypes.kt @@ -8,11 +8,12 @@ package org.jetbrains.compose.desktop.application.dsl import org.gradle.api.Action import org.gradle.api.model.ObjectFactory import org.jetbrains.compose.internal.utils.new +import java.io.Serializable import javax.inject.Inject abstract class JvmApplicationBuildTypes @Inject constructor( objects: ObjectFactory -) { +): Serializable { /** * The default build type does not have a classifier * to preserve compatibility with tasks, existing before @@ -29,6 +30,8 @@ abstract class JvmApplicationBuildTypes @Inject constructor( fun release(fn: Action) { fn.execute(release) } + + internal val all: List = listOf(default, release) } abstract class JvmApplicationBuildType @Inject constructor( @@ -43,4 +46,9 @@ abstract class JvmApplicationBuildType @Inject constructor( fun proguard(fn: Action) { fn.execute(proguard) } + + val appCds: AppCdsConfiguration = objects.new() + fun appCds(fn: Action) { + fn.execute(appCds) + } } diff --git a/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/internal/JvmApplicationContext.kt b/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/internal/JvmApplicationContext.kt index dac795b4e4c..7245c6549ce 100644 --- a/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/internal/JvmApplicationContext.kt +++ b/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/internal/JvmApplicationContext.kt @@ -10,6 +10,7 @@ import org.gradle.api.Task import org.gradle.api.file.Directory import org.gradle.api.provider.Provider import org.jetbrains.compose.desktop.application.dsl.JvmApplicationBuildType +import org.jetbrains.compose.desktop.application.dsl.JvmApplicationBuildTypes import org.jetbrains.compose.internal.KOTLIN_JVM_PLUGIN_ID import org.jetbrains.compose.internal.KOTLIN_MPP_PLUGIN_ID import org.jetbrains.compose.internal.javaSourceSets @@ -34,6 +35,9 @@ internal data class JvmApplicationContext( "compose/tmp/$appDirName" ) + val buildTypes: JvmApplicationBuildTypes + get() = appInternal.buildTypes + fun T.useAppRuntimeFiles(fn: T.(JvmApplicationRuntimeFiles) -> Unit) { val runtimeFiles = app.jvmApplicationRuntimeFilesProvider?.jvmApplicationRuntimeFiles(project) ?: JvmApplicationRuntimeFiles( diff --git a/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/internal/JvmTasks.kt b/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/internal/JvmTasks.kt index 429f9c40c38..c2aac248d01 100644 --- a/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/internal/JvmTasks.kt +++ b/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/internal/JvmTasks.kt @@ -29,23 +29,31 @@ internal class JvmTasks( taskNameAction: String, taskNameObject: String = "", args: List = emptyList(), + isHidden: Boolean = false, noinline configureFn: T.() -> Unit = {} ): TaskProvider { val buildTypeClassifier = buildType.classifier.uppercaseFirstChar() val objectClassifier = taskNameObject.uppercaseFirstChar() val taskName = "$taskNameAction$buildTypeClassifier$objectClassifier" - return register(taskName, klass = T::class.java, args = args, configureFn = configureFn) + return register( + name = taskName, + klass = T::class.java, + args = args, + isHidden = isHidden, + configureFn = configureFn + ) } fun register( name: String, klass: Class, args: List, + isHidden: Boolean = false, configureFn: T.() -> Unit ): TaskProvider = project.tasks.register(name, klass, *args.toTypedArray()).apply { configure { task -> - task.group = taskGroup + task.group = if (isHidden) null else taskGroup task.configureFn() } } 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 35dec15a2fb..7b20fc6bf1b 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 @@ -6,6 +6,7 @@ package org.jetbrains.compose.desktop.application.internal import org.gradle.api.DefaultTask +import org.gradle.api.Task import org.gradle.api.file.DuplicatesStrategy import org.gradle.api.provider.Provider import org.gradle.api.tasks.JavaExec @@ -13,18 +14,12 @@ import org.gradle.api.tasks.Sync import org.gradle.api.tasks.TaskProvider import org.gradle.jvm.tasks.Jar import org.jetbrains.compose.desktop.application.dsl.TargetFormat +import org.jetbrains.compose.desktop.application.dsl.runtimeJvmArgs 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 -import org.jetbrains.compose.internal.utils.currentOS -import org.jetbrains.compose.internal.utils.currentTarget -import org.jetbrains.compose.internal.utils.dir -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 private val defaultJvmArgs = listOf("-D$CONFIGURE_SWING_GLOBALS=true") @@ -52,7 +47,6 @@ internal class CommonJvmDesktopTasks( val checkRuntime: TaskProvider, val suggestRuntimeModules: TaskProvider, val prepareAppResources: TaskProvider, - val createRuntimeImage: TaskProvider ) private fun JvmApplicationContext.configureCommonJvmDesktopTasks(): CommonJvmDesktopTasks { @@ -66,6 +60,7 @@ private fun JvmApplicationContext.configureCommonJvmDesktopTasks(): CommonJvmDes taskNameObject = "runtime" ) { jdkHome.set(app.javaHomeProvider) + appCdsModes.set(this@configureCommonJvmDesktopTasks.buildTypes.all.map { it.appCds.mode }) checkJdkVendor.set(ComposeProperties.checkJdkVendor(project.providers)) jdkVersionProbeJar.from( project.detachedComposeGradleDependency( @@ -101,30 +96,30 @@ private fun JvmApplicationContext.configureCommonJvmDesktopTasks(): CommonJvmDes into(jvmTmpDirForTask()) } + return CommonJvmDesktopTasks( + unpackDefaultResources = unpackDefaultResources, + checkRuntime = checkRuntime, + suggestRuntimeModules = suggestRuntimeModules, + prepareAppResources = prepareAppResources, + ) +} + +private fun JvmApplicationContext.configurePackagingTasks( + commonTasks: CommonJvmDesktopTasks +) { val createRuntimeImage = tasks.register( taskNameAction = "create", taskNameObject = "runtimeImage" ) { - dependsOn(checkRuntime) + dependsOn(commonTasks.checkRuntime) javaHome.set(app.javaHomeProvider) modules.set(provider { app.nativeDistributions.modules }) includeAllModules.set(provider { app.nativeDistributions.includeAllModules }) - javaRuntimePropertiesFile.set(checkRuntime.flatMap { it.javaRuntimePropertiesFile }) + javaRuntimePropertiesFile.set(commonTasks.checkRuntime.flatMap { it.javaRuntimePropertiesFile }) destinationDir.set(appTmpDir.dir("runtime")) + generateCdsArchive.set(buildType.appCds.mode.generateJreClassesArchive) } - return CommonJvmDesktopTasks( - unpackDefaultResources, - checkRuntime, - suggestRuntimeModules, - prepareAppResources, - createRuntimeImage - ) -} - -private fun JvmApplicationContext.configurePackagingTasks( - commonTasks: CommonJvmDesktopTasks -) { val runProguard = if (buildType.proguard.isEnabled.orNull == true) { tasks.register( taskNameAction = "proguard", @@ -134,14 +129,15 @@ private fun JvmApplicationContext.configurePackagingTasks( } } else null - val createDistributable = tasks.register( + val createDistributableImpl = tasks.register( taskNameAction = "create", - taskNameObject = "distributable", - args = listOf(TargetFormat.AppImage) + taskNameObject = "distributableImpl", + args = listOf(TargetFormat.AppImage), + isHidden = true, ) { configurePackageTask( this, - createRuntimeImage = commonTasks.createRuntimeImage, + createRuntimeImage = createRuntimeImage, prepareAppResources = commonTasks.prepareAppResources, checkRuntime = commonTasks.checkRuntime, unpackDefaultResources = commonTasks.unpackDefaultResources, @@ -149,35 +145,42 @@ private fun JvmApplicationContext.configurePackagingTasks( ) } + val appCdsMode = buildType.appCds.mode + val createAppCdsArchive = if (appCdsMode.generateAppClassesArchive) { + tasks.register( + taskNameAction = "create", + taskNameObject = "appCdsArchive", + args = listOf(createDistributableImpl), + isHidden = true, + ) { + dependsOn(createDistributableImpl) + this.appCdsMode.set(appCdsMode) + } + } else null + + val createDistributable = tasks.register( + taskNameAction = "create", + taskNameObject = "distributable", + ) { + dependsOn(createDistributableImpl) + if (createAppCdsArchive != null) { + dependsOn(createAppCdsArchive) + } + } + val packageFormats = app.nativeDistributions.targetFormats.map { targetFormat -> val packageFormat = tasks.register( taskNameAction = "package", taskNameObject = targetFormat.name, args = listOf(targetFormat) ) { - // On Mac we want to patch bundled Info.plist file, - // so we create an app image, change its Info.plist, - // then create an installer based on the app image. - // We could create an installer the same way on other platforms, but - // in some cases there are failures with JDK 15. - // See [AbstractJPackageTask.patchInfoPlistIfNeeded] - if (currentOS != OS.MacOS) { - configurePackageTask( - this, - createRuntimeImage = commonTasks.createRuntimeImage, - prepareAppResources = commonTasks.prepareAppResources, - checkRuntime = commonTasks.checkRuntime, - unpackDefaultResources = commonTasks.unpackDefaultResources, - runProguard = runProguard - ) - } else { - configurePackageTask( - this, - createAppImage = createDistributable, - checkRuntime = commonTasks.checkRuntime, - unpackDefaultResources = commonTasks.unpackDefaultResources - ) - } + configurePackageTask( + this, + createAppImage = createDistributableImpl, + checkRuntime = commonTasks.checkRuntime, + unpackDefaultResources = commonTasks.unpackDefaultResources, + createAppCdsArchive = createAppCdsArchive + ) } if (targetFormat.isCompatibleWith(OS.MacOS)) { @@ -232,8 +235,10 @@ private fun JvmApplicationContext.configurePackagingTasks( val runDistributable = tasks.register( taskNameAction = "run", taskNameObject = "distributable", - args = listOf(createDistributable) - ) + args = listOf(createDistributableImpl) + ) { + dependsOn(createDistributable) + } val run = tasks.register(taskNameAction = "run") { configureRunTask(this, commonTasks.prepareAppResources, runProguard) @@ -284,7 +289,8 @@ private fun JvmApplicationContext.configurePackageTask( prepareAppResources: TaskProvider? = null, checkRuntime: TaskProvider? = null, unpackDefaultResources: TaskProvider, - runProguard: Provider? = null + runProguard: Provider? = null, + createAppCdsArchive: TaskProvider? = null ) { packageTask.enabled = packageTask.targetFormat.isCompatibleWithCurrentOS @@ -338,8 +344,15 @@ private fun JvmApplicationContext.configurePackageTask( } } + if (createAppCdsArchive != null) { + packageTask.dependsOn(createAppCdsArchive) + packageTask.files.from(project.file(createAppCdsArchive.flatMap { it.appCdsArchiveFile })) + } + packageTask.launcherMainClass.set(provider { app.mainClass }) - packageTask.launcherJvmArgs.set(provider { defaultJvmArgs + app.jvmArgs }) + packageTask.launcherJvmArgs.set( + provider { defaultJvmArgs + buildType.appCds.runtimeJvmArgs() + app.jvmArgs } + ) packageTask.launcherArgs.set(provider { app.args }) } diff --git a/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/internal/files/fileUtils.kt b/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/internal/files/fileUtils.kt index 64a314ad220..d11fe2773ce 100644 --- a/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/internal/files/fileUtils.kt +++ b/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/internal/files/fileUtils.kt @@ -48,7 +48,7 @@ internal fun File.contentHash(): String { private fun MessageDigest.digestContent(file: File) { file.inputStream().buffered().use { fis -> DigestInputStream(fis, this).use { ds -> - while (ds.read() != -1) {} + ds.readAllBytes() } } } @@ -75,6 +75,11 @@ internal fun copyZipEntry( val newEntry = ZipEntry(entry.name).apply { comment = entry.comment extra = entry.extra + // Need to preserve timestamp so that identical inputs result in identical outputs + // (the jar files are hashed) + entry.time.let { + time = if (it == -1L) 0L else it + } } to.withNewEntry(newEntry) { from.copyTo(to) diff --git a/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/tasks/AbstractCheckNativeDistributionRuntime.kt b/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/tasks/AbstractCheckNativeDistributionRuntime.kt index e4e8010c894..196801a3f27 100644 --- a/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/tasks/AbstractCheckNativeDistributionRuntime.kt +++ b/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/tasks/AbstractCheckNativeDistributionRuntime.kt @@ -7,19 +7,17 @@ package org.jetbrains.compose.desktop.application.tasks import org.gradle.api.file.ConfigurableFileCollection import org.gradle.api.file.RegularFile +import org.gradle.api.provider.ListProperty import org.gradle.api.provider.Property import org.gradle.api.provider.Provider import org.gradle.api.tasks.* +import org.jetbrains.compose.desktop.application.dsl.AppCdsMode import org.jetbrains.compose.desktop.application.internal.ComposeProperties -import org.jetbrains.compose.desktop.application.internal.JvmRuntimeProperties import org.jetbrains.compose.desktop.application.internal.ExternalToolRunner import org.jetbrains.compose.desktop.application.internal.JdkVersionProbe +import org.jetbrains.compose.desktop.application.internal.JvmRuntimeProperties import org.jetbrains.compose.desktop.tasks.AbstractComposeDesktopTask -import org.jetbrains.compose.internal.utils.OS -import org.jetbrains.compose.internal.utils.currentOS -import org.jetbrains.compose.internal.utils.executableName -import org.jetbrains.compose.internal.utils.ioFile -import org.jetbrains.compose.internal.utils.notNullProperty +import org.jetbrains.compose.internal.utils.* import java.io.ByteArrayInputStream import java.io.File import java.util.* @@ -39,6 +37,9 @@ abstract class AbstractCheckNativeDistributionRuntime : AbstractComposeDesktopTa @get:Input abstract val checkJdkVendor: Property + @get:Input + val appCdsModes: ListProperty = objects.listProperty(AppCdsMode::class.java) + private val taskDir = project.layout.buildDirectory.dir("compose/tmp/$name") @get:OutputFile @@ -75,8 +76,8 @@ abstract class AbstractCheckNativeDistributionRuntime : AbstractComposeDesktopTa val jdkHome = jdkHomeFile val javaExecutable = jdkHome.getJdkTool("java") val jlinkExecutable = jdkHome.getJdkTool("jlink") - val jpackageExecutabke = jdkHome.getJdkTool("jpackage") - ensureToolsExist(javaExecutable, jlinkExecutable, jpackageExecutabke) + val jpackageExecutable = jdkHome.getJdkTool("jpackage") + ensureToolsExist(javaExecutable, jlinkExecutable, jpackageExecutable) val jdkRuntimeProperties = getJDKRuntimeProperties(javaExecutable) @@ -91,8 +92,8 @@ abstract class AbstractCheckNativeDistributionRuntime : AbstractComposeDesktopTa ) } + val vendor = jdkRuntimeProperties.getProperty(JdkVersionProbe.JDK_VENDOR_KEY) if (checkJdkVendor.get()) { - val vendor = jdkRuntimeProperties.getProperty(JdkVersionProbe.JDK_VENDOR_KEY) if (vendor == null) { logger.warn("JDK vendor probe failed: $jdkHome") } else { @@ -109,6 +110,10 @@ abstract class AbstractCheckNativeDistributionRuntime : AbstractComposeDesktopTa } } + for (appCdsMode in appCdsModes.get()) { + appCdsMode.checkJdkCompatibility(jdkMajorVersion, vendor) + } + val modules = arrayListOf() runExternalTool( tool = javaExecutable, diff --git a/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/tasks/AbstractCreateAppCdsArchiveTask.kt b/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/tasks/AbstractCreateAppCdsArchiveTask.kt new file mode 100644 index 00000000000..62323091177 --- /dev/null +++ b/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/tasks/AbstractCreateAppCdsArchiveTask.kt @@ -0,0 +1,98 @@ +package org.jetbrains.compose.desktop.application.tasks + +import org.gradle.api.file.Directory +import org.gradle.api.file.FileTree +import org.gradle.api.file.RegularFile +import org.gradle.api.provider.Property +import org.gradle.api.provider.Provider +import org.gradle.api.tasks.* +import org.jetbrains.compose.desktop.application.dsl.AppCdsMode +import org.jetbrains.compose.desktop.tasks.AbstractComposeDesktopTask +import org.jetbrains.compose.internal.utils.* +import java.io.File +import java.nio.file.Files +import java.nio.file.StandardCopyOption +import javax.inject.Inject + +abstract class AbstractCreateAppCdsArchiveTask @Inject constructor( + createDistributable: TaskProvider +) : AbstractComposeDesktopTask() { + @get:Internal + internal val appImageRootDir: Provider = createDistributable.flatMap { it.destinationDir } + + @get:Input + internal val packageName: Provider = createDistributable.flatMap { it.packageName } + + @get:Input + internal abstract val appCdsMode: Property + + @get:OutputFile + val appCdsArchiveFile: Provider = provider { + val appImageRootDir = appImageRootDir.get() + val packageName = packageName.get() + val appCdsMode = appCdsMode.get() + val packagedAppRootDir = packagedAppRootDir(appImageRootDir, packageName) + appCdsMode.appClassesArchiveFile(packagedAppRootDir) + } + + // This is needed to correctly describe the dependencies to Gradle. + // Can't just use appImageRootDir because the AppCDS archive needs to be excluded + @Suppress("unused") + @get:InputFiles + internal val dependencyFiles: FileTree get() { + // If the app image root directory doesn't exist, return an empty file tree + appImageRootDir.get().let { + if (!it.asFile.isDirectory) { + return it.asFileTree + } + } + + val appCdsArchiveFile = appCdsArchiveFile.ioFile.relativeTo(appImageRootDir.get().asFile).path + return appImageRootDir.get().asFileTree.matching { it.exclude(appCdsArchiveFile) } + } + + @TaskAction + fun run() { + // Before running the app, replace the 'java-options' corresponding to + // AppCdsMode.runtimeJvmArgs with AppCdsMode.appClassesArchiveCreationJvmArgs + // This must be done because, for example, -XX:SharedArchiveFile and + // -XX:ArchiveClassesAtExit can't be used at the same time + val packagedRootDir = packagedAppRootDir(appImageRootDir.get(), packageName = packageName.get()) + val appDir = packagedAppJarFilesDir(packagedRootDir) + val cfgFile = appDir.asFile.resolve("${packageName.get()}.cfg") + val cfgFileTempCopy = File(cfgFile.parentFile, "${cfgFile.name}.tmp") + + // Save the cfg file before making changes for the AppCDS archive-creating run + Files.copy(cfgFile.toPath(), cfgFileTempCopy.toPath(), StandardCopyOption.REPLACE_EXISTING) + try { + // Edit the cfg file + cfgFile.outputStream().bufferedWriter().use { output -> + // Copy lines, filtering the AppCdsMode's runtime options + val runtimeOptionCfgLines = appCdsMode.get().runtimeJvmArgs() + .mapTo(mutableSetOf()) { "java-options=$it" } + cfgFileTempCopy.useLines { lines -> + lines.forEach { line -> + if (line !in runtimeOptionCfgLines) { + output.appendLine(line) + } + } + } + + // Add the AppCdsMode's archive creation options + val archiveCreationOptions = appCdsMode.get().appClassesArchiveCreationJvmArgs().toSet() + for (arg in archiveCreationOptions) { + output.appendLine("java-options=$arg") + } + } + + // Run the app to create the AppCDS archive + execOperations.executePackagedApp( + appImageRootDir = appImageRootDir.get(), + packageName = packageName.get() + ) + } finally { + // Restore the cfg file + Files.move(cfgFileTempCopy.toPath(), cfgFile.toPath(), StandardCopyOption.REPLACE_EXISTING) + } + } +} \ No newline at end of file diff --git a/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/tasks/AbstractJLinkTask.kt b/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/tasks/AbstractJLinkTask.kt index b44237ef7b7..be42323461d 100644 --- a/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/tasks/AbstractJLinkTask.kt +++ b/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/tasks/AbstractJLinkTask.kt @@ -47,6 +47,9 @@ abstract class AbstractJLinkTask : AbstractJvmToolOperationTask("jlink") { @get:Optional internal val compressionLevel: Property = objects.nullableProperty() + @get:Input + internal val generateCdsArchive: Property = objects.notNullProperty(false) + override fun makeArgs(tmpDir: File): MutableList = super.makeArgs(tmpDir).apply { val modulesToInclude = if (includeAllModules.get()) { @@ -59,7 +62,12 @@ abstract class AbstractJLinkTask : AbstractJvmToolOperationTask("jlink") { cliArg("--strip-debug", stripDebug) cliArg("--no-header-files", noHeaderFiles) cliArg("--no-man-pages", noManPages) - cliArg("--strip-native-commands", stripNativeCommands) + if (generateCdsArchive.get()) { + cliArg("--generate-cds-archive", true) + } else { + // jlink (a native command) is needed to generate the CDS archive + cliArg("--strip-native-commands", stripNativeCommands) + } cliArg("--compress", compressionLevel.orNull?.id) cliArg("--output", destinationDir) 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 d1935673dbd..bea4f758d87 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 @@ -424,12 +424,12 @@ abstract class AbstractJPackageTask @Inject constructor( if (targetFormat != TargetFormat.AppImage) { // Args, that can only be used, when creating an installer - if (currentOS == OS.MacOS && jvmRuntimeInfo.majorVersion >= 18) { - // This is needed to prevent a directory does not exist error. - cliArg("--app-image", appImage.dir("${packageName.get()}.app")) - } else { - cliArg("--app-image", appImage) + val appImageDir = when { + jvmRuntimeInfo.majorVersion < 18 -> appImage + currentOS == OS.MacOS -> appImage.dir("${packageName.get()}.app") + else -> appImage.dir(packageName.get()) } + cliArg("--app-image", appImageDir) cliArg("--install-dir", installationPath) cliArg("--license-file", licenseFile) cliArg("--resource-dir", jpackageResources) 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 index 8d33b55c083..1964dcb84a1 100644 --- 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 @@ -11,11 +11,8 @@ 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.jetbrains.compose.internal.utils.OS -import org.jetbrains.compose.internal.utils.currentOS -import org.jetbrains.compose.internal.utils.executableName -import org.jetbrains.compose.internal.utils.ioFile import org.jetbrains.compose.desktop.tasks.AbstractComposeDesktopTask +import org.jetbrains.compose.internal.utils.executePackagedApp import javax.inject.Inject // Custom task is used instead of Exec, because Exec does not support @@ -32,26 +29,9 @@ abstract class AbstractRunDistributableTask @Inject constructor( @TaskAction fun run() { - val appDir = appImageRootDir.ioFile.let { appImageRoot -> - val files = appImageRoot.listFiles() - // Sometimes ".DS_Store" files are created on macOS, so ignore them. - ?.filterNot { it.name == ".DS_Store" } - 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(workingDir.resolve(executable).absolutePath) - }.assertNormalExitValue() + execOperations.executePackagedApp( + appImageRootDir = appImageRootDir.get(), + packageName = packageName.get() + ) } } \ No newline at end of file diff --git a/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/internal/utils/osUtils.kt b/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/internal/utils/osUtils.kt index 4537489e8f4..1c8490b4b7b 100644 --- a/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/internal/utils/osUtils.kt +++ b/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/internal/utils/osUtils.kt @@ -5,7 +5,9 @@ package org.jetbrains.compose.internal.utils +import org.gradle.api.file.Directory import org.gradle.api.provider.Provider +import org.gradle.process.ExecOperations import org.jetbrains.compose.desktop.application.internal.files.checkExistingFile import org.jetbrains.compose.desktop.application.tasks.MIN_JAVA_RUNTIME_VERSION import java.io.File @@ -52,6 +54,46 @@ internal val currentOS: OS by lazy { internal fun executableName(nameWithoutExtension: String): String = if (currentOS == OS.Windows) "$nameWithoutExtension.exe" else nameWithoutExtension +internal fun packagedAppRootDir(appImageRootDir: Directory, packageName: String): Directory { + val dirName = when (currentOS) { + OS.MacOS -> "$packageName.app" + else -> packageName + } + return appImageRootDir.dir(dirName) +} + +internal fun packagedAppExecutableName(packageName: String): String { + val appExecutableName = executableName(packageName) + return when (currentOS) { + OS.Linux -> "bin/$appExecutableName" + OS.Windows -> appExecutableName + OS.MacOS -> "Contents/MacOS/$appExecutableName" + } +} + +internal fun packagedAppJarFilesDir(packagedRootDir: Directory): Directory { + return packagedRootDir.dir( + when (currentOS) { + OS.Linux -> "lib/app/" + OS.Windows -> "app/" + OS.MacOS -> "Contents/app/" + } + ) +} + +internal fun ExecOperations.executePackagedApp( + appImageRootDir: Directory, + packageName: String +) { + val workingDir = packagedAppRootDir(appImageRootDir = appImageRootDir, packageName = packageName) + val executableName = packagedAppExecutableName(packageName = packageName) + + exec { spec -> + spec.workingDir(workingDir) + spec.executable(workingDir.asFile.resolve(executableName).absolutePath) + }.assertNormalExitValue() +} + internal fun javaExecutable(javaHome: String): String = File(javaHome).resolve("bin/${executableName("java")}").absolutePath 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 497881597a1..38747f3674e 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 @@ -6,6 +6,8 @@ package org.jetbrains.compose.test.tests.integration import org.gradle.internal.impldep.org.testng.Assert +import org.gradle.internal.jvm.inspection.JvmVendor +import org.jetbrains.compose.desktop.application.dsl.AppCdsMode import org.jetbrains.compose.internal.utils.MacUtils import org.jetbrains.compose.internal.utils.OS import org.jetbrains.compose.internal.utils.currentArch @@ -571,6 +573,118 @@ class DesktopApplicationTest : GradlePluginTestBase() { } } + @Suppress("SameParameterValue") + private fun appCdsProject( + appCdsMode: AppCdsMode, + javaVersion: Int, + javaVendor: JvmVendor.KnownJvmVendor = JvmVendor.KnownJvmVendor.AMAZON + ) : TestProject { + return testProject("application/appCds").apply { + modifyText("build.gradle.kts") { + it + .replace("%APP_CDS_MODE%", "AppCdsMode.$appCdsMode") + .replace("%JAVA_VERSION%", "$javaVersion") + .replace("%JVM_VENDOR%", javaVendor.name) + } + } + } + + @Test + fun testAppCdsAutoFailsOnJdk17() = with(appCdsProject(AppCdsMode.Auto, javaVersion = 17)) { + fun testRunTask(runTask: String) { + gradleFailure(runTask).checks { + check.logContains("AppCdsMode 'Auto' is not supported on JDK earlier than 19; current is 17") + } + } + + testRunTask(":runReleaseDistributable") + } + + private val loggedArchivePathRegex = + Regex("\\[cds] Opened archive " + + listOf( + ".*", + "build", + "compose", + "binaries", + ".*", + "app", + ".*", + AppCdsMode.ARCHIVE_FILE_ARGUMENT + .replace("\$APPDIR", "app") + .replace(".", "\\.") + ).joinToString(File.separator.replace("\\", "\\\\")) + ) + + @Test + fun testAppCdsAuto() = with(appCdsProject(AppCdsMode.Auto, javaVersion = 21)) { + fun testRunTask(taskName: String) { + gradle(taskName).checks { + check.taskSuccessful(taskName) + check.logContains("[cds] Dumping shared data to file") + } + gradle(taskName).checks { + check.taskSuccessful(taskName) + check.logContainsMatch(loggedArchivePathRegex) + check.logDoesntContain("[cds] Specified shared archive not found") + check.logDoesntContain("[cds] Dumping shared data to file") + check.logDoesntContain("[cds] Initialize dynamic archive failed") + check.logDoesntContain("[cds] An error has occurred while processing the shared archive file") + check.logDoesntContain("[cds] Failed to initialize dynamic archive") + } + gradle(":clean") + } + + testRunTask(":runReleaseDistributable") + } + + @Test + fun testAppCdsPrebuild() = with(appCdsProject(AppCdsMode.Prebuild, javaVersion = 17)) { + fun testPackageAndRun(release: Boolean) { + val releaseTag = if (release) "Release" else "" + val packageTaskName = ":package${releaseTag}DistributionForCurrentOS" + val createAppCdsTaskName = ":create${releaseTag}AppCdsArchive" + val runDistributableTaskName = ":run${releaseTag}Distributable" + gradle(packageTaskName).checks { + check.taskSuccessful(packageTaskName) + check.taskSuccessful(createAppCdsTaskName) + check.logContains("[cds] Dumping shared data to file") + check.logContains("Running app to create archive: true") + } + + gradle(runDistributableTaskName).checks { + check.taskSuccessful(runDistributableTaskName) + check.taskUpToDate(createAppCdsTaskName) + check.logContainsMatch(loggedArchivePathRegex) + check.logContains("Running app to create archive: false") + check.logDoesntContain("[cds] Specified shared archive not found") + check.logDoesntContain("[cds] Dumping shared data to file") + check.logDoesntContain("[cds] Initialize dynamic archive failed") + check.logDoesntContain("[cds] An error has occurred while processing the shared archive file") + check.logDoesntContain("[cds] Failed to initialize dynamic archive") + } + } + + testPackageAndRun(release = true) + } + + @Test + fun testAppCdsCreateDistributable() = with(appCdsProject(AppCdsMode.Prebuild, javaVersion = 17)) { + fun testPackageAndRun(release: Boolean) { + val releaseTag = if (release) "Release" else "" + val createDistributableTaskName = ":create${releaseTag}Distributable" + val createAppCdsTaskName = ":create${releaseTag}AppCdsArchive" + gradle(createDistributableTaskName).checks { + check.taskSuccessful(createDistributableTaskName) + check.taskSuccessful(createAppCdsTaskName) + check.logContains("[cds] Dumping shared data to file") + check.logContains("Running app to create archive: true") + } + } + + testPackageAndRun(release = true) + } + private fun TestProject.enableJoinOutputJars() { val enableJoinOutputJars = """ compose.desktop { diff --git a/gradle-plugins/compose/src/test/kotlin/org/jetbrains/compose/test/utils/assertUtils.kt b/gradle-plugins/compose/src/test/kotlin/org/jetbrains/compose/test/utils/assertUtils.kt index 16a5c1fecf9..1fea09f29d2 100644 --- a/gradle-plugins/compose/src/test/kotlin/org/jetbrains/compose/test/utils/assertUtils.kt +++ b/gradle-plugins/compose/src/test/kotlin/org/jetbrains/compose/test/utils/assertUtils.kt @@ -43,6 +43,12 @@ internal class BuildResultChecks(private val result: BuildResult) { } } + fun logContainsMatch(regex: Regex) { + if (!regex.containsMatchIn(result.output)) { + throw AssertionError("Test output does not match expected regular expression: $regex") + } + } + fun logDoesntContain(substring: String) { if (result.output.contains(substring)) { throw AssertionError("Test output contains the unexpected string: '$substring'") diff --git a/gradle-plugins/compose/src/test/test-projects/application/appCds/build.gradle.kts b/gradle-plugins/compose/src/test/test-projects/application/appCds/build.gradle.kts new file mode 100644 index 00000000000..fc9fbae135f --- /dev/null +++ b/gradle-plugins/compose/src/test/test-projects/application/appCds/build.gradle.kts @@ -0,0 +1,38 @@ +import org.jetbrains.compose.* +import org.jetbrains.compose.desktop.application.dsl.TargetFormat +import org.jetbrains.compose.desktop.application.dsl.AppCdsMode +import org.gradle.internal.jvm.inspection.JvmVendor +import org.gradle.jvm.toolchain.internal.DefaultJvmVendorSpec + +plugins { + id("org.jetbrains.kotlin.jvm") + id("org.jetbrains.kotlin.plugin.compose") + id("org.jetbrains.compose") +} + +dependencies { + implementation(kotlin("stdlib")) + implementation(compose.desktop.currentOs) +} + +compose.desktop { + application { + javaHome = javaToolchains.launcherFor { + languageVersion.set(JavaLanguageVersion.of(%JAVA_VERSION%)) + vendor.set(DefaultJvmVendorSpec.of(JvmVendor.KnownJvmVendor.%JVM_VENDOR%)) + }.get().metadata.installationPath.asFile.absolutePath + + mainClass = "MainKt" + nativeDistributions { + targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb) + packageVersion = "1.0.0" + } + buildTypes.release { + appCds { + mode = %APP_CDS_MODE% + logging = true + exitAppOnCdsFailure = true + } + } + } +} diff --git a/gradle-plugins/compose/src/test/test-projects/application/appCds/settings.gradle b/gradle-plugins/compose/src/test/test-projects/application/appCds/settings.gradle new file mode 100644 index 00000000000..d65cfd9c22d --- /dev/null +++ b/gradle-plugins/compose/src/test/test-projects/application/appCds/settings.gradle @@ -0,0 +1,31 @@ +pluginManagement { + plugins { + id 'org.jetbrains.kotlin.jvm' version 'KOTLIN_VERSION_PLACEHOLDER' + id 'org.jetbrains.kotlin.plugin.compose' version 'KOTLIN_VERSION_PLACEHOLDER' + id 'org.jetbrains.compose' version 'COMPOSE_GRADLE_PLUGIN_VERSION_PLACEHOLDER' + id("org.gradle.toolchains.foojay-resolver-convention").version("1.0.0") + } + repositories { + mavenLocal() + gradlePluginPortal() + mavenCentral() + google() + maven { + url 'https://maven.pkg.jetbrains.space/public/p/compose/dev' + } + } +} +plugins { + id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0" +} +dependencyResolutionManagement { + repositories { + mavenCentral() + google() + maven { + url 'https://maven.pkg.jetbrains.space/public/p/compose/dev' + } + mavenLocal() + } +} +rootProject.name = "simple" \ No newline at end of file diff --git a/gradle-plugins/compose/src/test/test-projects/application/appCds/src/main/kotlin/main.kt b/gradle-plugins/compose/src/test/test-projects/application/appCds/src/main/kotlin/main.kt new file mode 100644 index 00000000000..ab1d1c80a4f --- /dev/null +++ b/gradle-plugins/compose/src/test/test-projects/application/appCds/src/main/kotlin/main.kt @@ -0,0 +1,5 @@ + +fun main() { + val creatingAppCdsArchive = System.getProperty("compose.appcds.create-archive") != null + println("Running app to create archive: $creatingAppCdsArchive") +}