diff --git a/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/dsl/NativeDistributions.kt b/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/dsl/NativeDistributions.kt index 022b6ce488..c6c5db6114 100644 --- a/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/dsl/NativeDistributions.kt +++ b/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/dsl/NativeDistributions.kt @@ -15,7 +15,16 @@ open class NativeDistributions @Inject constructor( var description: String? = null var copyright: String? = null var vendor: String? = null - var version: String? = null + @Deprecated( + "version is deprecated, use packageVersion instead", + replaceWith = ReplaceWith("packageVersion") + ) + var version: String? + get() = packageVersion + set(value) { + packageVersion = value + } + var packageVersion: String? = null val outputBaseDir: DirectoryProperty = objects.directoryProperty().apply { set(layout.buildDirectory.dir("compose/binaries")) diff --git a/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/dsl/PlatformSettings.kt b/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/dsl/PlatformSettings.kt index 681a600c01..171e32734e 100644 --- a/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/dsl/PlatformSettings.kt +++ b/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/dsl/PlatformSettings.kt @@ -7,10 +7,13 @@ import javax.inject.Inject abstract class PlatformSettings (objects: ObjectFactory) { val iconFile: RegularFileProperty = objects.fileProperty() + var packageVersion: String? = null } open class MacOSPlatformSettings @Inject constructor(objects: ObjectFactory): PlatformSettings(objects) { var packageName: String? = null + var dmgPackageVersion: String? = null + var pkgPackageVersion: String? = null /** * An application's unique identifier across Apple's ecosystem. @@ -40,6 +43,8 @@ open class LinuxPlatformSettings @Inject constructor(objects: ObjectFactory): Pl var debMaintainer: String? = null var menuGroup: String? = null var rpmLicenseType: String? = null + var debPackageVersion: String? = null + var rpmPackageVersion: String? = null } open class WindowsPlatformSettings @Inject constructor(objects: ObjectFactory): PlatformSettings(objects) { @@ -51,4 +56,6 @@ open class WindowsPlatformSettings @Inject constructor(objects: ObjectFactory): get() = field || menuGroup != null var menuGroup: String? = null var upgradeUuid: String? = null + var msiPackageVersion: String? = null + var exePackageVersion: String? = null } \ No newline at end of file diff --git a/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/dsl/TargetFormat.kt b/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/dsl/TargetFormat.kt index ae57949117..ae453207ae 100644 --- a/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/dsl/TargetFormat.kt +++ b/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/dsl/TargetFormat.kt @@ -5,9 +5,9 @@ import org.jetbrains.compose.desktop.application.internal.currentOS enum class TargetFormat( internal val id: String, - private vararg val compatibleOSs: OS + internal val targetOS: OS ) { - AppImage("app-image", *OS.values()), + AppImage("app-image", currentOS), Deb("deb", OS.Linux), Rpm("rpm", OS.Linux), Dmg("dmg", OS.MacOS), @@ -17,7 +17,7 @@ enum class TargetFormat( val isCompatibleWithCurrentOS: Boolean by lazy { isCompatibleWith(currentOS) } - internal fun isCompatibleWith(targetOS: OS): Boolean = targetOS in compatibleOSs + internal fun isCompatibleWith(os: OS): Boolean = os == targetOS val outputDirName: String get() = if (this == AppImage) "app" else id diff --git a/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/internal/configureApplication.kt b/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/internal/configureApplication.kt index 26a8579d4e..e071632d38 100644 --- a/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/internal/configureApplication.kt +++ b/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/internal/configureApplication.kt @@ -10,6 +10,7 @@ import org.gradle.api.tasks.* import org.gradle.jvm.tasks.Jar import org.jetbrains.compose.desktop.application.dsl.Application import org.jetbrains.compose.desktop.application.dsl.TargetFormat +import org.jetbrains.compose.desktop.application.internal.validation.validatePackageVersions import org.jetbrains.compose.desktop.application.tasks.* import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension import org.jetbrains.kotlin.gradle.plugin.KotlinPlatformType @@ -28,6 +29,7 @@ fun configureApplicationImpl(project: Project, app: Application) { app.from(mainSourceSet) } } + project.validatePackageVersions(app) project.configurePackagingTasks(listOf(app)) project.configureWix() } @@ -141,7 +143,7 @@ internal fun AbstractJPackageTask.configurePackagingTask( packageDescription.set(provider { executables.description }) packageCopyright.set(provider { executables.copyright }) packageVendor.set(provider { executables.vendor }) - packageVersion.set(app._packageVersionInternal(project)) + packageVersion.set(packageVersionFor(project, app, targetFormat)) } destinationDir.set(app.nativeDistributions.outputBaseDir.map { it.dir("${app.name}/${targetFormat.outputDirName}") }) @@ -265,7 +267,7 @@ private fun Jar.configurePackageUberJarForCurrentOS(app: Application) { duplicatesStrategy = DuplicatesStrategy.EXCLUDE archiveAppendix.set(currentTarget.id) archiveBaseName.set(app._packageNameProvider(project)) - archiveVersion.set(app._packageVersionInternal(project)) + archiveVersion.set(packageVersionFor(project, app, TargetFormat.AppImage)) destinationDirectory.set(project.layout.buildDirectory.dir("compose/jars")) doLast { @@ -294,12 +296,6 @@ private inline fun TaskContainer.composeTask( internal fun Application._packageNameProvider(project: Project): Provider = project.provider { nativeDistributions.packageName ?: project.name } -internal fun Application._packageVersionInternal(project: Project): Provider = - project.provider { - nativeDistributions.version - ?: project.version.toString().takeIf { it != "unspecified" } - } - @OptIn(ExperimentalStdlibApi::class) private fun taskName(action: String, app: Application, suffix: String? = null): String = listOf( diff --git a/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/internal/packageVersions.kt b/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/internal/packageVersions.kt new file mode 100644 index 0000000000..98c12e7061 --- /dev/null +++ b/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/internal/packageVersions.kt @@ -0,0 +1,39 @@ +package org.jetbrains.compose.desktop.application.internal + +import org.gradle.api.Project +import org.gradle.api.provider.Provider +import org.jetbrains.compose.desktop.application.dsl.Application +import org.jetbrains.compose.desktop.application.dsl.NativeDistributions +import org.jetbrains.compose.desktop.application.dsl.TargetFormat + +internal fun packageVersionFor( + project: Project, + app: Application, + targetFormat: TargetFormat +): Provider = + project.provider { + app.nativeDistributions.packageVersionFor(targetFormat) + ?: project.version.toString().takeIf { it != "unspecified" } + } + +private fun NativeDistributions.packageVersionFor( + targetFormat: TargetFormat +): String? { + val formatSpecificVersion: String? = when (targetFormat) { + TargetFormat.AppImage -> null + TargetFormat.Deb -> linux.debPackageVersion + TargetFormat.Rpm -> linux.rpmPackageVersion + TargetFormat.Dmg -> macOS.dmgPackageVersion + TargetFormat.Pkg -> macOS.pkgPackageVersion + TargetFormat.Exe -> windows.exePackageVersion + TargetFormat.Msi -> windows.msiPackageVersion + } + val osSpecificVersion: String? = when (targetFormat.targetOS) { + OS.Linux -> linux.packageVersion + OS.MacOS -> macOS.packageVersion + OS.Windows -> windows.packageVersion + } + return formatSpecificVersion + ?: osSpecificVersion + ?: packageVersion +} diff --git a/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/internal/validation/validatePackageVersions.kt b/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/internal/validation/validatePackageVersions.kt new file mode 100644 index 0000000000..588fbd3610 --- /dev/null +++ b/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/internal/validation/validatePackageVersions.kt @@ -0,0 +1,164 @@ +package org.jetbrains.compose.desktop.application.internal.validation + +import org.gradle.api.GradleException +import org.gradle.api.Project +import org.jetbrains.compose.desktop.application.dsl.Application +import org.jetbrains.compose.desktop.application.dsl.TargetFormat +import org.jetbrains.compose.desktop.application.internal.OS +import org.jetbrains.compose.desktop.application.internal.packageVersionFor + +internal fun Project.validatePackageVersions(app: Application) { + val errors = ErrorsCollector() + + for (targetFormat in app.nativeDistributions.targetFormats) { + val packageVersion = packageVersionFor(project, app, targetFormat).orNull + if (packageVersion == null) { + errors.addError(targetFormat, "no version was specified") + continue + } + + val versionChecker: VersionChecker? = when (targetFormat) { + TargetFormat.AppImage -> null + TargetFormat.Deb -> DebVersionChecker + TargetFormat.Rpm -> RpmVersionChecker + TargetFormat.Msi, TargetFormat.Exe -> WindowsVersionChecker + TargetFormat.Dmg, TargetFormat.Pkg -> MacVersionChecker + } + + versionChecker?.apply { + if (!isValid(packageVersion)) { + errors.addError( + targetFormat, + "'$packageVersion' is not a valid version", + correctFormat = correctFormat + ) + } + } + } + + if (errors.errors.isNotEmpty()) { + throw GradleException(errors.errors.joinToString("\n")) + } +} + +private class ErrorsCollector { + private val myErrors = arrayListOf() + + val errors: List + get() = myErrors + + fun addError( + targetFormat: TargetFormat, + error: String, + correctFormat: String? = null + ) { + val msg = buildString { + appendln("* Illegal version for '$targetFormat': $error.") + if (correctFormat != null) { + appendln(" * Correct format: $correctFormat") + } + appendln(" * You can specify the correct version using DSL properties: " + + dslPropertiesFor(targetFormat).joinToString(", ") + ) + } + myErrors.add(msg) + } +} + +private fun dslPropertiesFor( + targetFormat: TargetFormat +): List { + val nativeDistributions = "nativeDistributions" + val linux = "$nativeDistributions.linux" + val macOS = "$nativeDistributions.macOS" + val windows = "$nativeDistributions.windows" + val packageVersion = "packageVersion" + + val formatSpecificProperty: String? = when (targetFormat) { + TargetFormat.AppImage -> null + TargetFormat.Deb -> "$linux.debPackageVersion" + TargetFormat.Rpm -> "$linux.rpmPackageVersion" + TargetFormat.Dmg -> "$macOS.dmgPackageVersion" + TargetFormat.Pkg -> "$macOS.pkgPackageVersion" + TargetFormat.Exe -> "$windows.exePackageVersion" + TargetFormat.Msi -> "$windows.msiPackageVersion" + } + val osSettingsProperty: String = when (targetFormat.targetOS) { + OS.Linux -> "$linux.$packageVersion" + OS.MacOS -> "$macOS.$packageVersion" + OS.Windows -> "$windows.$packageVersion" + } + val appSpecificProperty = "$nativeDistributions.$packageVersion" + return listOfNotNull( + formatSpecificProperty, + osSettingsProperty, + appSpecificProperty, + ) +} + +private interface VersionChecker { + val correctFormat: String + fun isValid(version: String): Boolean +} + +private object DebVersionChecker : VersionChecker { + override val correctFormat = """|'[EPOCH:]UPSTREAM_VERSION[-DEBIAN_REVISION]', where: + | * EPOCH is an optional non-negative integer; + | * UPSTREAM_VERSION may contain only alphanumerics and the characters '.', '+', '-', '~' and must start with a digit; + | * DEBIAN_REVISION is optional and may contain only alphanumerics and the characters '.', '+', '~'; + | * see https://www.debian.org/doc/debian-policy/ch-controlfields.html#version for details; + """.trimMargin() + + override fun isValid(version: String): Boolean = + version.matches(debRegex) + + private val debRegex = ( + /* EPOCH */"([0-9]+:)?" + + /* UPSTREAM_VERSION */ "[0-9][0-9a-zA-Z.+\\-~]*" + + /* DEBIAN_REVISION */ "(-[0-9a-zA-Z.+~]+)?").toRegex() +} + +private object RpmVersionChecker : VersionChecker { + override val correctFormat = "rpm package version must not contain a dash '-'" + + override fun isValid(version: String): Boolean = + !version.contains("-") +} + +private object WindowsVersionChecker : VersionChecker { + override val correctFormat = """|'MAJOR.MINOR.BUILD', where: + | * MAJOR is a non-negative integer with a maximum value of 255; + | * MINOR is a non-negative integer with a maximum value of 255; + | * BUILD is a non-negative integer with a maximum value of 65535; + """.trimMargin() + + override fun isValid(version: String): Boolean { + val parts = version.split(".").map { it.toIntOrNull() } + if (parts.size != 3) return false + + return parts[0].isIntInRange(0, 255) + && parts[1].isIntInRange(0, 255) + && parts[2].isIntInRange(0, 65535) + } + + private fun Int?.isIntInRange(min: Int, max: Int) = + this != null && this >= min && this <= max +} + + +private object MacVersionChecker : VersionChecker { + override val correctFormat = """|'MAJOR[.MINOR][.PATCH]', where: + | * MAJOR is an integer > 0; + | * MINOR is an optional non-negative integer; + | * PATCH is an optional non-negative integer; + """.trimMargin() + + override fun isValid(version: String): Boolean { + val parts = version.split(".").map { it.toIntOrNull() } + + return parts.isNotEmpty() + && parts.size <= 3 + && parts.all { it != null && it >= 0 } + && (parts.first() ?: 0) > 0 + } +} diff --git a/gradle-plugins/compose/src/test/kotlin/org/jetbrains/compose/DesktopApplicationTest.kt b/gradle-plugins/compose/src/test/kotlin/org/jetbrains/compose/DesktopApplicationTest.kt index f7b937fb6e..efbaefbd77 100644 --- a/gradle-plugins/compose/src/test/kotlin/org/jetbrains/compose/DesktopApplicationTest.kt +++ b/gradle-plugins/compose/src/test/kotlin/org/jetbrains/compose/DesktopApplicationTest.kt @@ -65,12 +65,12 @@ class DesktopApplicationTest : GradlePluginTestBase() { val packageFile = packageDirFiles.single() if (currentOS == OS.Linux) { - val expectedName = "test-package_1.0-1_amd64.$ext" + val expectedName = "test-package_1.0.0-1_amd64.$ext" check(packageFile.name.equals(expectedName, ignoreCase = true)) { "Expected '$expectedName' package in $packageDir, got '${packageFile.name}'" } } else { - Assert.assertEquals(packageFile.name, "TestPackage-1.0.$ext", "Unexpected package name") + Assert.assertEquals(packageFile.name, "TestPackage-1.0.0.$ext", "Unexpected package name") } assertEquals(TaskOutcome.SUCCESS, result.task(":package${ext.capitalize()}")?.outcome) assertEquals(TaskOutcome.SUCCESS, result.task(":package")?.outcome) @@ -90,7 +90,7 @@ class DesktopApplicationTest : GradlePluginTestBase() { gradle(":packageUberJarForCurrentOS").build().let { result -> assertEquals(TaskOutcome.SUCCESS, result.task(":packageUberJarForCurrentOS")?.outcome) - val resultJarFile = file("build/compose/jars/TestPackage-${currentTarget.id}-1.0.jar") + val resultJarFile = file("build/compose/jars/TestPackage-${currentTarget.id}-1.0.0.jar") resultJarFile.checkExists() JarFile(resultJarFile).use { jar -> diff --git a/gradle-plugins/compose/src/test/test-projects/application/jvm/build.gradle b/gradle-plugins/compose/src/test/test-projects/application/jvm/build.gradle index babdbe728b..ec71735ccb 100644 --- a/gradle-plugins/compose/src/test/test-projects/application/jvm/build.gradle +++ b/gradle-plugins/compose/src/test/test-projects/application/jvm/build.gradle @@ -25,7 +25,7 @@ compose.desktop { nativeDistributions { targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb) - version = "1.0" + packageVersion = "1.0.0" packageName = "TestPackage" description = "Test description" copyright = "Test Copyright Holder" diff --git a/gradle-plugins/compose/src/test/test-projects/application/jvmKotlinDsl/build.gradle.kts b/gradle-plugins/compose/src/test/test-projects/application/jvmKotlinDsl/build.gradle.kts index d5cdb6fb73..bbdff3b140 100644 --- a/gradle-plugins/compose/src/test/test-projects/application/jvmKotlinDsl/build.gradle.kts +++ b/gradle-plugins/compose/src/test/test-projects/application/jvmKotlinDsl/build.gradle.kts @@ -23,6 +23,8 @@ compose.desktop { mainClass = "MainKt" nativeDistributions { targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb) + + packageVersion = "1.0.0" } } } diff --git a/gradle-plugins/compose/src/test/test-projects/application/moduleClashCli/app/build.gradle b/gradle-plugins/compose/src/test/test-projects/application/moduleClashCli/app/build.gradle index e0e128c688..6791935473 100644 --- a/gradle-plugins/compose/src/test/test-projects/application/moduleClashCli/app/build.gradle +++ b/gradle-plugins/compose/src/test/test-projects/application/moduleClashCli/app/build.gradle @@ -16,8 +16,5 @@ dependencies { compose.desktop { application { mainClass = "MainKt" - nativeDistributions { - targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb) - } } } \ No newline at end of file diff --git a/gradle-plugins/compose/src/test/test-projects/application/mpp/build.gradle b/gradle-plugins/compose/src/test/test-projects/application/mpp/build.gradle index fc7aeb0383..102c54677a 100644 --- a/gradle-plugins/compose/src/test/test-projects/application/mpp/build.gradle +++ b/gradle-plugins/compose/src/test/test-projects/application/mpp/build.gradle @@ -35,7 +35,7 @@ compose.desktop { nativeDistributions { targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb) - version = "1.0" + packageVersion = "1.0.0" packageName = "TestPackage" description = "Test description" copyright = "Test Copyright Holder" diff --git a/tutorials/Native_distributions_and_local_execution/README.md b/tutorials/Native_distributions_and_local_execution/README.md index c70c4540a6..83eb7e37a8 100755 --- a/tutorials/Native_distributions_and_local_execution/README.md +++ b/tutorials/Native_distributions_and_local_execution/README.md @@ -81,6 +81,75 @@ to run such applications will be faced with an error like this: See [our tutorial](/tutorials/Signing_and_notarization_on_macOS/README.md) on how to sign and notarize your application. +## Specifying package version + +You must specify a package version for native distribution packages. + +You can use the following DSL properties (in order of descending priority): +* `nativeDistributions..PackageVersion` specifies a version for a single package format; +* `nativeDistributions..packageVersion` specifies a version for a single target OS; +* `nativeDistributions.packageVersion` specifies a version for all packages; + +``` kotlin +compose.desktop { + application { + nativeDistributions { + // a version for all distributables + packageVersion = "..." + + linux { + // a version for all Linux distributables + packageVersion = "..." + // a version only for the deb package + debVersion = "..." + // a version only for the rpm package + rpmVersion = "..." + } + macOS { + // a version for all macOS distributables + packageVersion = "..." + // a version only for the dmg package + dmgVersion = "..." + // a version only for the pkg package + pkgVersion = "..." + } + windows { + // a version for all Windows distributables + packageVersion = "..." + // a version only for the msi package + msiVersion = "..." + // a version only for the exe package + exeVersion = "..." + } + } + } +} +``` + +Versions must follow the rules: + * For `dmg` and `pkg`: + * The format is `MAJOR[.MINOR][.PATCH]`, where: + * `MAJOR` is an integer > 0; + * `MINOR` is an optional non-negative integer; + * `PATCH` is an optional non-negative integer; + * For `msi` and `exe`: + * The format is `MAJOR.MINOR.BUILD`, where: + * `MAJOR` is a non-negative integer with a maximum value of 255; + * `MINOR` is a non-negative integer with a maximum value of 255; + * `BUILD` is a non-negative integer with a maximum value of 65535; + * For `deb`: + * The format is `[EPOCH:]UPSTREAM_VERSION[-DEBIAN_REVISION]`, where: + * `EPOCH` is an optional non-negative integer; + * `UPSTREAM_VERSION` + * may contain only alphanumerics and the characters `.`, `+`, `-`, `~`; + * must start with a digit; + * `DEBIAN_REVISION` + * is optional; + * may contain only alphanumerics and the characters `.`, `+`, `~`; + * See [Debian documentation](https://www.debian.org/doc/debian-policy/ch-controlfields.html#version) for more details; + * For `rpm`: + * A version must not contain the `-` (dash) character. + ## Customizing JDK version The plugin uses `jpackage`, which is available since [JDK 14](https://openjdk.java.net/projects/jdk/14/).