diff --git a/examples/imageviewer/build.gradle.kts b/examples/imageviewer/build.gradle.kts index 84d2cd967e..1cb8bd7c32 100755 --- a/examples/imageviewer/build.gradle.kts +++ b/examples/imageviewer/build.gradle.kts @@ -1,5 +1,8 @@ buildscript { repositories { + mavenLocal().mavenContent { + includeModule("org.jetbrains.compose", "compose-desktop-application-gradle-plugin") + } google() jcenter() maven("https://maven.pkg.jetbrains.space/public/p/compose/dev") @@ -7,6 +10,7 @@ buildscript { dependencies { classpath("org.jetbrains.compose:compose-gradle-plugin:0.1.0-dev109") + classpath("org.jetbrains.compose:compose-desktop-application-gradle-plugin:0.1.0-SNAPSHOT") classpath("com.android.tools.build:gradle:4.0.1") classpath(kotlin("gradle-plugin", version = "1.4.0")) } diff --git a/examples/imageviewer/desktop/build.gradle.kts b/examples/imageviewer/desktop/build.gradle.kts index 1c0eb81892..a396c58596 100755 --- a/examples/imageviewer/desktop/build.gradle.kts +++ b/examples/imageviewer/desktop/build.gradle.kts @@ -1,10 +1,10 @@ import org.jetbrains.compose.compose +import org.jetbrains.compose.desktop.application.dsl.TargetFormat plugins { kotlin("multiplatform") // kotlin("jvm") doesn't work well in IDEA/AndroidStudio (https://github.com/JetBrains/compose-jb/issues/22) id("org.jetbrains.compose") - java - application + id("org.jetbrains.compose.desktop.application") } kotlin { @@ -21,6 +21,13 @@ kotlin { } } -application { - mainClassName = "example.imageviewer.MainKt" -} \ No newline at end of file +compose.desktop { + application { + mainClass = "example.imageviewer.MainKt" + + nativeExecutables { + targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb) + packageName = "ImageViewer" + } + } +} diff --git a/gradle-plugins/build.gradle.kts b/gradle-plugins/build.gradle.kts index 3b522c7a26..c5231d8e58 100644 --- a/gradle-plugins/build.gradle.kts +++ b/gradle-plugins/build.gradle.kts @@ -20,6 +20,9 @@ subprojects { configureIfExists { sourceCompatibility = JavaVersion.VERSION_1_8 targetCompatibility = JavaVersion.VERSION_1_8 + + withJavadocJar() + withSourcesJar() } } @@ -51,7 +54,9 @@ subprojects { fun Project.configureGradlePlugin(config: GradlePluginConfigExtension) { // maven publication for plugin configureIfExists { - publications.create("gradlePlugin") { + // pluginMaven is a default publication created by java-gradle-plugin + // https://github.com/gradle/gradle/issues/10384 + publications.create("pluginMaven") { artifactId = config.artifactId pom { name.set(config.displayName) diff --git a/gradle-plugins/compose-desktop-application/build.gradle.kts b/gradle-plugins/compose-desktop-application/build.gradle.kts new file mode 100644 index 0000000000..cb94359bb1 --- /dev/null +++ b/gradle-plugins/compose-desktop-application/build.gradle.kts @@ -0,0 +1,20 @@ +plugins { + kotlin("jvm") + id("com.gradle.plugin-publish") + id("java-gradle-plugin") + id("maven-publish") +} + +gradlePluginConfig { + pluginId = "org.jetbrains.compose.desktop.application" + artifactId = "compose-desktop-application-gradle-plugin" + displayName = "Jetpack Compose Desktop Application Plugin" + description = "Plugin for creating native distributions and run configurations" + implementationClass = "org.jetbrains.compose.desktop.application.ApplicationPlugin" +} + +dependencies { + compileOnly(gradleApi()) + compileOnly(kotlin("gradle-plugin-api")) + compileOnly(kotlin("gradle-plugin")) +} diff --git a/gradle-plugins/compose-desktop-application/src/main/kotlin/org/jetbrains/compose/ComposeBasePlugin.kt b/gradle-plugins/compose-desktop-application/src/main/kotlin/org/jetbrains/compose/ComposeBasePlugin.kt new file mode 100644 index 0000000000..2da769ba9b --- /dev/null +++ b/gradle-plugins/compose-desktop-application/src/main/kotlin/org/jetbrains/compose/ComposeBasePlugin.kt @@ -0,0 +1,10 @@ +package org.jetbrains.compose + +import org.gradle.api.Plugin +import org.gradle.api.Project + +open class ComposeBasePlugin : Plugin { + override fun apply(project: Project) { + project.extensions.create("compose", ComposeExtension::class.java) + } +} \ No newline at end of file diff --git a/gradle-plugins/compose-desktop-application/src/main/kotlin/org/jetbrains/compose/ComposeExtension.kt b/gradle-plugins/compose-desktop-application/src/main/kotlin/org/jetbrains/compose/ComposeExtension.kt new file mode 100644 index 0000000000..ff8438c7cb --- /dev/null +++ b/gradle-plugins/compose-desktop-application/src/main/kotlin/org/jetbrains/compose/ComposeExtension.kt @@ -0,0 +1,5 @@ +package org.jetbrains.compose + +import org.gradle.api.plugins.ExtensionAware + +abstract class ComposeExtension : ExtensionAware \ No newline at end of file diff --git a/gradle-plugins/compose-desktop-application/src/main/kotlin/org/jetbrains/compose/desktop/DesktopBasePlugin.kt b/gradle-plugins/compose-desktop-application/src/main/kotlin/org/jetbrains/compose/desktop/DesktopBasePlugin.kt new file mode 100644 index 0000000000..99b4550567 --- /dev/null +++ b/gradle-plugins/compose-desktop-application/src/main/kotlin/org/jetbrains/compose/desktop/DesktopBasePlugin.kt @@ -0,0 +1,14 @@ +package org.jetbrains.compose.desktop + +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.jetbrains.compose.ComposeBasePlugin +import org.jetbrains.compose.ComposeExtension + +open class DesktopBasePlugin : Plugin { + override fun apply(project: Project) { + project.plugins.apply(ComposeBasePlugin::class.java) + val composeExt = project.extensions.getByType(ComposeExtension::class.java) + composeExt.extensions.create("desktop", DesktopExtension::class.java) + } +} \ No newline at end of file diff --git a/gradle-plugins/compose-desktop-application/src/main/kotlin/org/jetbrains/compose/desktop/DesktopExtension.kt b/gradle-plugins/compose-desktop-application/src/main/kotlin/org/jetbrains/compose/desktop/DesktopExtension.kt new file mode 100644 index 0000000000..ac192ca6b7 --- /dev/null +++ b/gradle-plugins/compose-desktop-application/src/main/kotlin/org/jetbrains/compose/desktop/DesktopExtension.kt @@ -0,0 +1,5 @@ +package org.jetbrains.compose.desktop + +import org.gradle.api.plugins.ExtensionAware + +abstract class DesktopExtension : ExtensionAware \ No newline at end of file diff --git a/gradle-plugins/compose-desktop-application/src/main/kotlin/org/jetbrains/compose/desktop/application/ApplicationPlugin.kt b/gradle-plugins/compose-desktop-application/src/main/kotlin/org/jetbrains/compose/desktop/application/ApplicationPlugin.kt new file mode 100644 index 0000000000..e22449845b --- /dev/null +++ b/gradle-plugins/compose-desktop-application/src/main/kotlin/org/jetbrains/compose/desktop/application/ApplicationPlugin.kt @@ -0,0 +1,225 @@ +package org.jetbrains.compose.desktop.application + +import org.gradle.api.* +import org.gradle.api.plugins.JavaPluginConvention +import org.gradle.api.tasks.JavaExec +import org.gradle.api.tasks.TaskContainer +import org.gradle.api.tasks.TaskProvider +import org.gradle.jvm.tasks.Jar +import org.jetbrains.compose.ComposeExtension +import org.jetbrains.compose.desktop.DesktopBasePlugin +import org.jetbrains.compose.desktop.DesktopExtension +import org.jetbrains.compose.desktop.application.dsl.Application +import org.jetbrains.compose.desktop.application.dsl.ConfigurationSource +import org.jetbrains.compose.desktop.application.internal.OS +import org.jetbrains.compose.desktop.application.internal.currentOS +import org.jetbrains.compose.desktop.application.internal.provider +import org.jetbrains.compose.desktop.application.tasks.AbstractJPackageTask +import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension +import org.jetbrains.kotlin.gradle.plugin.KotlinPlatformType +import java.io.File +import java.util.* + +private const val PLUGIN_ID = "org.jetbrains.compose.desktop.application" + +// todo: fix windows +// todo: multiple launchers +// todo: file associations +// todo: icon +// todo: use workers +@Suppress("unused") // Gradle plugin entry point +open class ApplicationPlugin : Plugin { + override fun apply(project: Project) { + project.plugins.apply(DesktopBasePlugin::class.java) + val composeExt = project.extensions.getByType(ComposeExtension::class.java) + val desktopExt = composeExt.extensions.getByType(DesktopExtension::class.java) + val mainApplication = project.objects.newInstance(Application::class.java, "main") + desktopExt.extensions.add("application", mainApplication) + project.plugins.withId("org.jetbrains.kotlin.jvm") { + val mainSourceSet = project.convention.getPlugin(JavaPluginConvention::class.java).sourceSets.getByName("main") + mainApplication.from(mainSourceSet) + } + project.plugins.withId("org.jetbrains.kotlin.multiplatform") { + project.configureFromMppPlugin(mainApplication) + } + project.afterEvaluate { + project.configurePackagingTasks(listOf(mainApplication)) + } + } +} + +internal fun Project.configureFromMppPlugin(mainApplication: Application) { + val kotlinExt = extensions.getByType(KotlinMultiplatformExtension::class.java) + var isJvmTargetConfigured = false + kotlinExt.targets.all { target -> + if (target.platformType == KotlinPlatformType.jvm) { + if (!isJvmTargetConfigured) { + mainApplication.from(target) + isJvmTargetConfigured = true + } else { + logger.error("w: Default configuration for '$PLUGIN_ID' is disabled: " + + "multiple Kotlin JVM targets definitions are detected. " + + "Specify, which target to use by using `compose.desktop.application.from(kotlinMppTarget)`") + mainApplication.disableDefaultConfiguration() + } + } + } +} + +internal fun Project.configurePackagingTasks(apps: Collection) { + for (app in apps) { + configureRunTask(app) + configurePackagingTasks(app) + } +} + +internal fun Project.configurePackagingTasks(app: Application): TaskProvider { + val packageFormats = app.nativeExecutables.targetFormats.map { targetFormat -> + tasks.composeTask( + taskName("package", app, targetFormat.name), + args = listOf(targetFormat) + ) { + configurePackagingTask(app) + } + } + return tasks.composeTask(taskName("package", app)) { + dependsOn(packageFormats) + } +} + +internal fun AbstractJPackageTask.configurePackagingTask(app: Application) { + enabled = (currentOS == targetOS) + + val targetPlatformSettings = when (targetOS) { + OS.Linux -> { + app.nativeExecutables.linux.also { linux -> + linuxShortcut.set(provider { linux.shortcut }) + linuxAppCategory.set(provider { linux.appCategory }) + linuxAppRelease.set(provider { linux.appRelease }) + linuxDebMaintainer.set(provider { linux.debMaintainer }) + linuxMenuGroup.set(provider { linux.menuGroup }) + linuxPackageName.set(provider { linux.packageName }) + linuxRpmLicenseType.set(provider { linux.rpmLicenseType }) + } + } + OS.Windows -> { + app.nativeExecutables.windows.also { win -> + winConsole.set(provider { win.console }) + winDirChooser.set(provider { win.dirChooser }) + winPerUserInstall.set(provider { win.perUserInstall }) + winShortcut.set(provider { win.shortcut }) + winMenu.set(provider { win.menu }) + winMenuGroup.set(provider { win.menuGroup }) + winUpgradeUuid.set(provider { win.upgradeUuid }) + } + } + OS.MacOS -> { + app.nativeExecutables.macOS.also { mac -> + macPackageName.set(provider { mac.packageName }) + macPackageIdentifier.set(provider { mac.packageIdentifier }) + macSign.set(provider { mac.signing.sign }) + macSigningKeyUserName.set(provider { mac.signing.keyUserName }) + macSigningKeychain.set(project.layout.file(provider { mac.signing.keychain })) + macBundleSigningPrefix.set(provider { mac.signing.bundlePrefix }) + } + } + } + + app.nativeExecutables.let { executables -> + packageName.set(provider { executables.packageName ?: project.name }) + packageDescription.set(provider { executables.description }) + packageCopyright.set(provider { executables.copyright }) + packageVendor.set(provider { executables.vendor }) + packageVersion.set(provider { + targetPlatformSettings.version + ?: executables.version + ?: project.version.toString().takeIf { it != "unspecified" } + }) + } + + destinationDir.set(app.nativeExecutables.outputBaseDir.map { it.dir("${app.name}/${targetFormat.id}") }) + javaHome.set(provider { app.javaHomeOrDefault() }) + + launcherMainJar.set(app.mainJar.orNull) + app._fromFiles.forEach { files.from(it) } + dependsOn(*app._dependenciesTaskNames.toTypedArray()) + when (val configSource = app._configurationSource) { + is ConfigurationSource.None -> {} + is ConfigurationSource.GradleSourceSet -> { + val sourceSet = configSource.sourceSet + dependsOn(sourceSet.jarTaskName) + launcherMainJar.set(app.mainJar.orElse(jarFromJarTaskByName(sourceSet.jarTaskName))) + files.from(sourceSet.runtimeClasspath) + } + is ConfigurationSource.KotlinMppTarget -> { + val target = configSource.target + dependsOn(target.artifactsTaskName) + launcherMainJar.set(app.mainJar.orElse(jarFromJarTaskByName(target.artifactsTaskName))) + files.from(project.configurations.named(target.runtimeElementsConfigurationName)) + } + } + modules.set(provider { app.nativeExecutables.modules }) + launcherMainClass.set(provider { app.mainClass }) + launcherJvmArgs.set(provider { app.jvmArgs }) + launcherArgs.set(provider { app.args }) +} + +private fun AbstractJPackageTask.jarFromJarTaskByName(jarTaskName: String) = + project.tasks.named(jarTaskName).map { (it as Jar).archiveFile.get() } + +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 + + val cp = objects.fileCollection() + cp.from(app.mainJar.orNull) + cp.from(app._fromFiles) + dependsOn(*app._dependenciesTaskNames.toTypedArray()) + + when (val configSource = app._configurationSource) { + is ConfigurationSource.None -> {} + is ConfigurationSource.GradleSourceSet -> { + val sourceSet = configSource.sourceSet + dependsOn(sourceSet.jarTaskName) + cp.from(sourceSet.runtimeClasspath) + } + is ConfigurationSource.KotlinMppTarget -> { + val target = configSource.target + dependsOn(target.artifactsTaskName) + cp.from(configurations.named(target.runtimeElementsConfigurationName)) + } + } + + classpath = cp + } +} + +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(), + noinline configureFn: T.() -> Unit = {} +) = register(name, T::class.java, *args.toTypedArray()).apply { + configure { + it.group = "compose-desktop-application" + it.configureFn() + } +} + +@OptIn(ExperimentalStdlibApi::class) +private fun taskName(action: String, app: Application, suffix: String? = null): String = + listOf( + action, + app.name.takeIf { it != "main" }?.capitalize(Locale.ROOT), + suffix?.capitalize(Locale.ROOT) + ).filterNotNull().joinToString("") diff --git a/gradle-plugins/compose-desktop-application/src/main/kotlin/org/jetbrains/compose/desktop/application/dsl/Application.kt b/gradle-plugins/compose-desktop-application/src/main/kotlin/org/jetbrains/compose/desktop/application/dsl/Application.kt new file mode 100644 index 0000000000..9a5479f92b --- /dev/null +++ b/gradle-plugins/compose-desktop-application/src/main/kotlin/org/jetbrains/compose/desktop/application/dsl/Application.kt @@ -0,0 +1,63 @@ +package org.jetbrains.compose.desktop.application.dsl + +import org.gradle.api.Action +import org.gradle.api.Task +import org.gradle.api.file.RegularFileProperty +import org.gradle.api.model.ObjectFactory +import org.gradle.api.tasks.SourceSet +import org.jetbrains.kotlin.gradle.plugin.KotlinTarget +import org.jetbrains.kotlin.gradle.targets.jvm.KotlinJvmTarget +import java.util.* +import javax.inject.Inject + +open class Application @Inject constructor( + @Suppress("unused") + val name: String, + objects: ObjectFactory +) { + internal var _configurationSource: ConfigurationSource = ConfigurationSource.None + internal val _fromFiles = objects.fileCollection() + internal val _dependenciesTaskNames = ArrayList() + + fun from(from: SourceSet) { + _configurationSource = ConfigurationSource.GradleSourceSet(from) + } + fun from(from: KotlinTarget) { + check(from is KotlinJvmTarget) { "Non JVM Kotlin MPP targets are not supported: ${from.javaClass.canonicalName} " + + "is not subtype of ${KotlinJvmTarget::class.java.canonicalName}" } + _configurationSource = ConfigurationSource.KotlinMppTarget(from) + } + fun disableDefaultConfiguration() { + _configurationSource = ConfigurationSource.None + } + + fun fromFiles(vararg files: Any) { + _fromFiles.from(*files) + } + + fun dependsOn(vararg tasks: String) { + _dependenciesTaskNames.addAll(tasks) + } + fun dependsOn(vararg tasks: Task) { + tasks.mapTo(_dependenciesTaskNames) { it.path } + } + + var mainClass: String? = null + val mainJar: RegularFileProperty = objects.fileProperty() + var javaHome: String? = null + + val args: MutableList = ArrayList() + fun args(vararg args: String) { + this.args.addAll(args) + } + + val jvmArgs: MutableList = ArrayList() + fun jvmArgs(vararg jvmArgs: String) { + this.jvmArgs.addAll(jvmArgs) + } + + val nativeExecutables: NativeExecutables = objects.newInstance(NativeExecutables::class.java) + fun nativeExecutables(fn: Action) { + fn.execute(nativeExecutables) + } +} diff --git a/gradle-plugins/compose-desktop-application/src/main/kotlin/org/jetbrains/compose/desktop/application/dsl/ConfigurationSource.kt b/gradle-plugins/compose-desktop-application/src/main/kotlin/org/jetbrains/compose/desktop/application/dsl/ConfigurationSource.kt new file mode 100644 index 0000000000..45fe29c90b --- /dev/null +++ b/gradle-plugins/compose-desktop-application/src/main/kotlin/org/jetbrains/compose/desktop/application/dsl/ConfigurationSource.kt @@ -0,0 +1,10 @@ +package org.jetbrains.compose.desktop.application.dsl + +import org.gradle.api.tasks.SourceSet +import org.jetbrains.kotlin.gradle.targets.jvm.KotlinJvmTarget + +internal sealed class ConfigurationSource { + object None : ConfigurationSource() + class GradleSourceSet(val sourceSet: SourceSet) : ConfigurationSource() + class KotlinMppTarget(val target: KotlinJvmTarget) : ConfigurationSource() +} \ No newline at end of file diff --git a/gradle-plugins/compose-desktop-application/src/main/kotlin/org/jetbrains/compose/desktop/application/dsl/NativeExecutables.kt b/gradle-plugins/compose-desktop-application/src/main/kotlin/org/jetbrains/compose/desktop/application/dsl/NativeExecutables.kt new file mode 100644 index 0000000000..a54b7a76de --- /dev/null +++ b/gradle-plugins/compose-desktop-application/src/main/kotlin/org/jetbrains/compose/desktop/application/dsl/NativeExecutables.kt @@ -0,0 +1,48 @@ +package org.jetbrains.compose.desktop.application.dsl + +import org.gradle.api.Action +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.file.ProjectLayout +import org.gradle.api.model.ObjectFactory +import java.util.* +import javax.inject.Inject + +open class NativeExecutables @Inject constructor( + objects: ObjectFactory, + layout: ProjectLayout +) { + var packageName: String? = null + var description: String? = null + var copyright: String? = null + var vendor: String? = null + var version: String? = null + + val outputBaseDir: DirectoryProperty = objects.directoryProperty().apply { + set(layout.buildDirectory.dir("compose/binaries")) + } + + var modules = arrayListOf("java.desktop") + fun modules(vararg modules: String) { + this.modules.addAll(modules.toList()) + } + + var targetFormats: Set = EnumSet.noneOf(TargetFormat::class.java) + fun targetFormats(vararg formats: TargetFormat) { + targetFormats = EnumSet.copyOf(formats.toList()) + } + + val linux: LinuxPlatformSettings = objects.newInstance(LinuxPlatformSettings::class.java) + fun linux(fn: Action) { + fn.execute(linux) + } + + val macOS: MacOSPlatformSettings = objects.newInstance(MacOSPlatformSettings::class.java) + fun macOS(fn: Action) { + fn.execute(macOS) + } + + val windows: WindowsPlatformSettings = objects.newInstance(WindowsPlatformSettings::class.java) + fun windows(fn: Action) { + fn.execute(windows) + } +} \ No newline at end of file diff --git a/gradle-plugins/compose-desktop-application/src/main/kotlin/org/jetbrains/compose/desktop/application/dsl/PlatformSettings.kt b/gradle-plugins/compose-desktop-application/src/main/kotlin/org/jetbrains/compose/desktop/application/dsl/PlatformSettings.kt new file mode 100644 index 0000000000..e82b4df0eb --- /dev/null +++ b/gradle-plugins/compose-desktop-application/src/main/kotlin/org/jetbrains/compose/desktop/application/dsl/PlatformSettings.kt @@ -0,0 +1,52 @@ +package org.jetbrains.compose.desktop.application.dsl + +import org.gradle.api.Action +import java.io.File + +abstract class PlatformSettings { + var version: String? = null + var installDir: String? = null +} + +open class MacOSPlatformSettings : PlatformSettings() { + var packageIdentifier: String? = null + var packageName: String? = null + val signing: MacOSSigningSettings = MacOSSigningSettings() + + private var isSignInitialized = false + fun signing(fn: Action) { + // enable sign if it the corresponding block is present in DSL + if (!isSignInitialized) { + isSignInitialized = true + signing.sign = true + } + fn.execute(signing) + } +} + +open class MacOSSigningSettings { + var sign: Boolean = false + var keychain: File? = null + var bundlePrefix: String? = null + var keyUserName: String? = null +} + +open class LinuxPlatformSettings : PlatformSettings() { + var shortcut: Boolean = false + var packageName: String? = null + var appRelease: String? = null + var appCategory: String? = null + var debMaintainer: String? = null + var menuGroup: String? = null + var rpmLicenseType: String? = null +} + +open class WindowsPlatformSettings : PlatformSettings() { + var console: Boolean = false + var dirChooser: Boolean = false + var perUserInstall: Boolean = false + var shortcut: Boolean = false + var menu: Boolean = false + var menuGroup: String? = null + var upgradeUuid: String? = null +} \ No newline at end of file diff --git a/gradle-plugins/compose-desktop-application/src/main/kotlin/org/jetbrains/compose/desktop/application/dsl/TargetFormat.kt b/gradle-plugins/compose-desktop-application/src/main/kotlin/org/jetbrains/compose/desktop/application/dsl/TargetFormat.kt new file mode 100644 index 0000000000..43e6a5ea67 --- /dev/null +++ b/gradle-plugins/compose-desktop-application/src/main/kotlin/org/jetbrains/compose/desktop/application/dsl/TargetFormat.kt @@ -0,0 +1,16 @@ +package org.jetbrains.compose.desktop.application.dsl + +import org.jetbrains.compose.desktop.application.internal.OS + +enum class TargetFormat( + internal val id: String, + internal val os: OS +) { + Deb("deb", OS.Linux), + Rpm("rpm", OS.Linux), + App("app-image", OS.MacOS), + Dmg("dmg", OS.MacOS), + Pkg("pkg", OS.MacOS), + Exe("exe", OS.Windows), + Msi("msi", OS.Windows) +} \ No newline at end of file diff --git a/gradle-plugins/compose-desktop-application/src/main/kotlin/org/jetbrains/compose/desktop/application/internal/cliArgUtils.kt b/gradle-plugins/compose-desktop-application/src/main/kotlin/org/jetbrains/compose/desktop/application/internal/cliArgUtils.kt new file mode 100644 index 0000000000..29c6cd78a4 --- /dev/null +++ b/gradle-plugins/compose-desktop-application/src/main/kotlin/org/jetbrains/compose/desktop/application/internal/cliArgUtils.kt @@ -0,0 +1,27 @@ +package org.jetbrains.compose.desktop.application.internal + +import org.gradle.api.provider.Provider + +internal fun MutableCollection.cliArg( + name: String, + value: T?, + fn: (T) -> String = defaultToString() +) { + if (value is Boolean) { + if (value) add(name) + } else if (value != null) { + add(name) + add(fn(value)) + } +} + +internal fun MutableCollection.cliArg( + name: String, + value: Provider, + fn: (T) -> String = defaultToString() +) { + cliArg(name, value.orNull, fn) +} + +private fun defaultToString(): (T) -> String = + { "\"${it.toString()}\"" } \ No newline at end of file diff --git a/gradle-plugins/compose-desktop-application/src/main/kotlin/org/jetbrains/compose/desktop/application/internal/dslUtils.kt b/gradle-plugins/compose-desktop-application/src/main/kotlin/org/jetbrains/compose/desktop/application/internal/dslUtils.kt new file mode 100644 index 0000000000..b28787d57d --- /dev/null +++ b/gradle-plugins/compose-desktop-application/src/main/kotlin/org/jetbrains/compose/desktop/application/internal/dslUtils.kt @@ -0,0 +1,16 @@ +package org.jetbrains.compose.desktop.application.internal + +import org.gradle.api.Task +import org.gradle.api.model.ObjectFactory +import org.gradle.api.provider.Property +import org.gradle.api.provider.Provider + +@SuppressWarnings("UNCHECKED_CAST") +internal inline fun ObjectFactory.nullableProperty(): Property = + property(T::class.java) as Property + +internal inline fun ObjectFactory.notNullProperty(): Property = + property(T::class.java) + +internal inline fun Task.provider(noinline fn: () -> T): Provider = + project.provider(fn) \ No newline at end of file diff --git a/gradle-plugins/compose-desktop-application/src/main/kotlin/org/jetbrains/compose/desktop/application/internal/osUtils.kt b/gradle-plugins/compose-desktop-application/src/main/kotlin/org/jetbrains/compose/desktop/application/internal/osUtils.kt new file mode 100644 index 0000000000..3b98d7446d --- /dev/null +++ b/gradle-plugins/compose-desktop-application/src/main/kotlin/org/jetbrains/compose/desktop/application/internal/osUtils.kt @@ -0,0 +1,15 @@ +package org.jetbrains.compose.desktop.application.internal + +internal enum class OS { + Linux, Windows, MacOS +} + +internal val currentOS: OS by lazy { + val os = System.getProperty("os.name") + when { + os.equals("Mac OS X", ignoreCase = true) -> OS.MacOS + os.startsWith("Win", ignoreCase = true) -> OS.Windows + os.startsWith("Linux", ignoreCase = true) -> OS.Linux + else -> error("Unknown OS name: $os") + } +} \ No newline at end of file diff --git a/gradle-plugins/compose-desktop-application/src/main/kotlin/org/jetbrains/compose/desktop/application/tasks/AbstractJPackageTask.kt b/gradle-plugins/compose-desktop-application/src/main/kotlin/org/jetbrains/compose/desktop/application/tasks/AbstractJPackageTask.kt new file mode 100644 index 0000000000..d7cf923b13 --- /dev/null +++ b/gradle-plugins/compose-desktop-application/src/main/kotlin/org/jetbrains/compose/desktop/application/tasks/AbstractJPackageTask.kt @@ -0,0 +1,296 @@ +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.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 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() { + @get:Input + internal val targetOS: OS + get() = targetFormat.os + + @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:Input + @get:Optional + val installationPath: Property = objects.nullableProperty() + + @get:InputFile + @get:Optional + @get:PathSensitive(PathSensitivity.ABSOLUTE) + val licenseFile: RegularFileProperty = objects.fileProperty() + + @get:InputFile + @get:Optional + @get:PathSensitive(PathSensitivity.ABSOLUTE) + val iconFile: RegularFileProperty = objects.fileProperty() + + @get:Input + val launcherMainClass: Property = objects.notNullProperty() + + @get:InputFile + @get:PathSensitive(PathSensitivity.ABSOLUTE) + val launcherMainJar: RegularFileProperty = objects.fileProperty() + + @get:Input + @get:Optional + val launcherArgs: ListProperty = objects.listProperty(String::class.java) + + @get:Input + @get:Optional + val launcherJvmArgs: ListProperty = objects.listProperty(String::class.java) + + @get:Input + val packageName: Property = objects.notNullProperty() + + @get:Input + @get:Optional + val packageDescription: Property = objects.nullableProperty() + + @get:Input + @get:Optional + val packageCopyright: Property = objects.nullableProperty() + + @get:Input + @get:Optional + val packageVendor: Property = objects.nullableProperty() + + @get:Input + @get:Optional + val packageVersion: Property = objects.nullableProperty() + + @get:Input + @get:Optional + val linuxShortcut: Property = objects.nullableProperty() + + @get:Input + @get:Optional + val linuxPackageName: Property = objects.nullableProperty() + + @get:Input + @get:Optional + val linuxAppRelease: Property = objects.nullableProperty() + + @get:Input + @get:Optional + val linuxAppCategory: Property = objects.nullableProperty() + + @get:Input + @get:Optional + val linuxDebMaintainer: Property = objects.nullableProperty() + + @get:Input + @get:Optional + val linuxMenuGroup: Property = objects.nullableProperty() + + @get:Input + @get:Optional + val linuxRpmLicenseType: Property = objects.nullableProperty() + + @get:Input + @get:Optional + val macPackageIdentifier: Property = objects.nullableProperty() + + @get:Input + @get:Optional + val macPackageName: Property = objects.nullableProperty() + + @get:Input + @get:Optional + val macBundleSigningPrefix: Property = objects.nullableProperty() + + @get:Input + @get:Optional + val macSign: Property = objects.nullableProperty() + + @get:InputFile + @get:Optional + val macSigningKeychain: RegularFileProperty = objects.fileProperty() + + @get:Input + @get:Optional + val macSigningKeyUserName: Property = objects.nullableProperty() + + @get:Input + @get:Optional + val winConsole: Property = objects.nullableProperty() + + @get:Input + @get:Optional + val winDirChooser: Property = objects.nullableProperty() + + @get:Input + @get:Optional + val winPerUserInstall: Property = objects.nullableProperty() + + @get:Input + @get:Optional + val winShortcut: Property = objects.nullableProperty() + + @get:Input + @get:Optional + val winMenu: Property = objects.nullableProperty() + + @get:Input + @get:Optional + val winMenuGroup: Property = objects.nullableProperty() + + @get:Input + @get:Optional + val winUpgradeUuid: Property = objects.nullableProperty() + + @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.absolutePath) + } + + cliArg("--type", targetFormat.id) + cliArg("--dest", destinationDir.asFile.get().absolutePath) + cliArg("--verbose", verbose) + + cliArg("--install-dir", installationPath) + cliArg("--license-file", licenseFile.asFile.orNull?.absolutePath) + cliArg("--icon", iconFile.asFile.orNull?.absolutePath) + + cliArg("--name", packageName) + cliArg("--description", packageDescription) + cliArg("--copyright", packageCopyright) + cliArg("--app-version", packageVersion) + cliArg("--vendor", packageVendor) + + cliArg("--main-jar", launcherMainJar.asFile.get().name) + cliArg("--main-class", launcherMainClass) + launcherArgs.orNull?.forEach { + cliArg("--arguments", it) + } + launcherJvmArgs.orNull?.forEach { + cliArg("--java-options", it) + } + + when (currentOS) { + OS.Linux -> { + cliArg("--linux-shortcut", linuxShortcut) + cliArg("--linux-package-name", linuxPackageName) + cliArg("--linux-app-release", linuxAppRelease) + cliArg("--linux-app-category", linuxAppCategory) + cliArg("--linux-deb-maintainer", linuxDebMaintainer) + cliArg("--linux-menu-group", linuxMenuGroup) + cliArg("--linux-rpm-license-type", linuxRpmLicenseType) + } + OS.MacOS -> { + cliArg("--mac-package-identifier", macPackageIdentifier) + cliArg("--mac-package-name", macPackageName) + cliArg("--mac-bundle-signing-prefix", macBundleSigningPrefix) + cliArg("--mac-sign", macSign) + cliArg("--mac-signing-keychain", macSigningKeychain.asFile.orNull) + cliArg("--mac-signing-key-user-name", macSigningKeyUserName) + } + OS.Windows -> { + cliArg("--win-console", winConsole) + cliArg("--win-dir-chooser", winDirChooser) + cliArg("--win-per-user-install", winPerUserInstall) + cliArg("--win-shortcut", winShortcut) + cliArg("--win-menu", winMenu) + cliArg("--win-menu-group", winMenuGroup) + cliArg("--win-upgrade-uuid", winUpgradeUuid) + } + } + + 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" + } + + fileOperations.delete(destinationDir) + val tmpDir = Files.createTempDirectory("compose-package").toFile().apply { + deleteOnExit() + } + try { + val args = makeArgs(tmpDir) + + val sourceFile = launcherMainJar.get().asFile + 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 { + it.executable = jpackage.absolutePath + it.setArgs(listOf("@${argsFile.absolutePath}")) + }.assertNormalExitValue() + } finally { + tmpDir.deleteRecursively() + } + } +} \ No newline at end of file diff --git a/gradle-plugins/settings.gradle.kts b/gradle-plugins/settings.gradle.kts index 198979339b..0a5f35c9d2 100644 --- a/gradle-plugins/settings.gradle.kts +++ b/gradle-plugins/settings.gradle.kts @@ -5,4 +5,5 @@ pluginManagement { } } -include(":compose") \ No newline at end of file +include(":compose") +include(":compose-desktop-application") \ No newline at end of file