diff --git a/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/dsl/MacOSNotarizationSettings.kt b/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/dsl/MacOSNotarizationSettings.kt new file mode 100644 index 0000000000..7a54e642e3 --- /dev/null +++ b/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/dsl/MacOSNotarizationSettings.kt @@ -0,0 +1,30 @@ +package org.jetbrains.compose.desktop.application.dsl + +import org.gradle.api.model.ObjectFactory +import org.gradle.api.provider.Property +import org.gradle.api.provider.ProviderFactory +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.Optional +import org.jetbrains.compose.desktop.application.internal.ComposeProperties +import org.jetbrains.compose.desktop.application.internal.nullableProperty +import javax.inject.Inject + +abstract class MacOSNotarizationSettings { + @get:Inject + protected abstract val objects: ObjectFactory + + @get:Inject + protected abstract val providers: ProviderFactory + + @get:Input + @get:Optional + val appleID: Property = objects.nullableProperty().apply { + set(ComposeProperties.macNotarizationAppleID(providers)) + } + + @get:Input + @get:Optional + val password: Property = objects.nullableProperty().apply { + set(ComposeProperties.macNotarizationPassword(providers)) + } +} \ No newline at end of file diff --git a/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/dsl/MacOSSigningSettings.kt b/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/dsl/MacOSSigningSettings.kt new file mode 100644 index 0000000000..3f7485687e --- /dev/null +++ b/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/dsl/MacOSSigningSettings.kt @@ -0,0 +1,41 @@ +package org.jetbrains.compose.desktop.application.dsl + +import org.gradle.api.model.ObjectFactory +import org.gradle.api.provider.Property +import org.gradle.api.provider.ProviderFactory +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.Optional +import org.jetbrains.compose.desktop.application.internal.ComposeProperties +import org.jetbrains.compose.desktop.application.internal.notNullProperty +import org.jetbrains.compose.desktop.application.internal.nullableProperty +import javax.inject.Inject + +abstract class MacOSSigningSettings { + @get:Inject + protected abstract val objects: ObjectFactory + @get:Inject + protected abstract val providers: ProviderFactory + + @get:Input + val sign: Property = objects.notNullProperty().apply { + set( + ComposeProperties.macSign(providers) + .orElse(false) + ) + } + @get:Input + @get:Optional + val identity: Property = objects.nullableProperty().apply { + set(ComposeProperties.macSignIdentity(providers)) + } + @get:Input + @get:Optional + val keychain: Property = objects.nullableProperty().apply { + set(ComposeProperties.macSignKeychain(providers)) + } + @get:Input + @get:Optional + val prefix: Property = objects.nullableProperty().apply { + set(ComposeProperties.macSignPrefix(providers)) + } +} \ No newline at end of file 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 400e9d97ee..681a600c01 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 @@ -3,7 +3,6 @@ package org.jetbrains.compose.desktop.application.dsl import org.gradle.api.Action import org.gradle.api.file.RegularFileProperty import org.gradle.api.model.ObjectFactory -import java.io.File import javax.inject.Inject abstract class PlatformSettings (objects: ObjectFactory) { @@ -11,26 +10,26 @@ abstract class PlatformSettings (objects: ObjectFactory) { } open class MacOSPlatformSettings @Inject constructor(objects: ObjectFactory): PlatformSettings(objects) { - var packageIdentifier: String? = null var packageName: String? = null - val signing: MacOSSigningSettings = MacOSSigningSettings() - private var isSignInitialized = false + /** + * An application's unique identifier across Apple's ecosystem. + * + * May only contain alphanumeric characters (A-Z,a-z,0-9), hyphen (-) and period (.) characters + * + * Use of a reverse DNS notation (e.g. com.mycompany.myapp) is recommended. + */ + var bundleID: String? = null + + val signing: MacOSSigningSettings = objects.newInstance(MacOSSigningSettings::class.java) 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 + val notarization: MacOSNotarizationSettings = objects.newInstance(MacOSNotarizationSettings::class.java) + fun notarization(fn: Action) { + fn.execute(notarization) + } } open class LinuxPlatformSettings @Inject constructor(objects: ObjectFactory): PlatformSettings(objects) { diff --git a/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/internal/ComposeProjectProperties.kt b/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/internal/ComposeProjectProperties.kt index f841283aa4..492aa2957b 100644 --- a/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/internal/ComposeProjectProperties.kt +++ b/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/internal/ComposeProjectProperties.kt @@ -4,13 +4,52 @@ import org.gradle.api.provider.Provider import org.gradle.api.provider.ProviderFactory internal object ComposeProperties { - fun isVerbose(providers: ProviderFactory): Provider = providers - .gradleProperty("compose.desktop.verbose") - .orElse("false") - .map { "true".equals(it, ignoreCase = true) } - - fun preserveWorkingDir(providers: ProviderFactory): Provider = providers - .gradleProperty("compose.preserve.working.dir") - .orElse("false") - .map { "true".equals(it, ignoreCase = true) } + internal const val VERBOSE = "compose.desktop.verbose" + internal const val PRESERVE_WD = "compose.preserve.working.dir" + internal const val MAC_SIGN = "compose.desktop.mac.sign" + internal const val MAC_SIGN_ID = "compose.desktop.mac.signing.identity" + internal const val MAC_SIGN_KEYCHAIN = "compose.desktop.mac.signing.keychain" + internal const val MAC_SIGN_PREFIX = "compose.desktop.mac.signing.prefix" + internal const val MAC_NOTARIZATION_APPLE_ID = "compose.desktop.mac.notarization.appleID" + internal const val MAC_NOTARIZATION_PASSWORD = "compose.desktop.mac.notarization.password" + + fun isVerbose(providers: ProviderFactory): Provider = + providers.findProperty(VERBOSE).toBoolean() + + fun preserveWorkingDir(providers: ProviderFactory): Provider = + providers.findProperty(PRESERVE_WD).toBoolean() + + fun macSign(providers: ProviderFactory): Provider = + providers.findProperty(MAC_SIGN).toBoolean() + + fun macSignIdentity(providers: ProviderFactory): Provider = + providers.findProperty(MAC_SIGN_ID) + + fun macSignKeychain(providers: ProviderFactory): Provider = + providers.findProperty(MAC_SIGN_KEYCHAIN) + + fun macSignPrefix(providers: ProviderFactory): Provider = + providers.findProperty(MAC_SIGN_PREFIX) + + fun macNotarizationAppleID(providers: ProviderFactory): Provider = + providers.findProperty(MAC_NOTARIZATION_APPLE_ID) + + fun macNotarizationPassword(providers: ProviderFactory): Provider = + providers.findProperty(MAC_NOTARIZATION_PASSWORD) + + private fun ProviderFactory.findProperty(prop: String): Provider = + provider { + gradleProperty(prop).forUseAtConfigurationTimeSafe().orNull + } + + private fun Provider.forUseAtConfigurationTimeSafe(): Provider = + try { + forUseAtConfigurationTime() + } catch (e: NoSuchMethodError) { + // todo: remove once we drop support for Gradle 6.4 + this + } + + private fun Provider.toBoolean(): Provider = + orElse("false").map { "true" == it } } \ No newline at end of file diff --git a/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/internal/FileCopyingProcessor.kt b/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/internal/FileCopyingProcessor.kt new file mode 100644 index 0000000000..18f6d9f858 --- /dev/null +++ b/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/internal/FileCopyingProcessor.kt @@ -0,0 +1,7 @@ +package org.jetbrains.compose.desktop.application.internal + +import java.io.File + +internal interface FileCopyingProcessor { + fun copy(source: File, target: File) +} diff --git a/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/internal/MacJarSignFileCopyingProcessor.kt b/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/internal/MacJarSignFileCopyingProcessor.kt new file mode 100644 index 0000000000..f5926e2c2b --- /dev/null +++ b/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/internal/MacJarSignFileCopyingProcessor.kt @@ -0,0 +1,116 @@ +package org.jetbrains.compose.desktop.application.internal + +import org.gradle.process.ExecOperations +import org.jetbrains.compose.desktop.application.internal.validation.ValidatedMacOSSigningSettings +import java.io.* +import java.util.regex.Pattern +import java.util.zip.ZipEntry +import java.util.zip.ZipInputStream +import java.util.zip.ZipOutputStream + +internal class MacJarSignFileCopyingProcessor( + private val tempDir: File, + private val execOperations: ExecOperations, + private val signing: ValidatedMacOSSigningSettings +) : FileCopyingProcessor { + private val signKey: String + + init { + val certificates = ByteArrayOutputStream().use { baos -> + PrintStream(baos).use { ps -> + execOperations.exec { exec -> + exec.executable = MacUtils.security.absolutePath + val args = arrayListOf("find-certificate", "-a", "-c", signing.fullDeveloperID) + signing.keychain?.let { args.add(it.absolutePath) } + exec.args(*args.toTypedArray()) + exec.standardOutput = ps + } + } + baos.toString() + } + val regex = Pattern.compile("\"alis\"=\"([^\"]+)\"") + val m = regex.matcher(certificates) + if (!m.find()) { + val keychainPath = signing.keychain?.absolutePath + error( + "Could not find certificate for '${signing.identity}'" + + " in keychain [${keychainPath.orEmpty()}]" + ) + } + + signKey = m.group(1) + if (m.find()) error("Multiple matching certificates are found for '${signing.fullDeveloperID}'. " + + "Please specify keychain containing unique matching certificate.") + } + + override fun copy(source: File, target: File) { + if (!source.isJarFile) { + SimpleFileCopyingProcessor.copy(source, target) + return + } + + if (target.exists()) target.delete() + + ZipInputStream(FileInputStream(source).buffered()).use { zin -> + ZipOutputStream(FileOutputStream(target).buffered()).use { zout -> + copyAndSignNativeLibs(zin, zout) + } + } + } + + private fun copyAndSignNativeLibs(zin: ZipInputStream, zout: ZipOutputStream) { + for (sourceEntry in generateSequence { zin.nextEntry }) { + if (!sourceEntry.name.endsWith(".dylib")) { + zout.putNextEntry(ZipEntry(sourceEntry)) + zin.copyTo(zout) + } else { + + val unpackedDylibFile = tempDir.resolve(sourceEntry.name.substringAfterLast("/")) + try { + unpackedDylibFile.outputStream().buffered().use { + zin.copyTo(it) + } + + signDylib(unpackedDylibFile) + val targetEntry = ZipEntry(sourceEntry.name).apply { + comment = sourceEntry.comment + extra = sourceEntry.extra + method = sourceEntry.method + size = unpackedDylibFile.length() + } + zout.putNextEntry(targetEntry) + + unpackedDylibFile.inputStream().buffered().use { + it.copyTo(zout) + } + } finally { + unpackedDylibFile.delete() + } + } + zout.closeEntry() + } + } + + private fun signDylib(dylibFile: File) { + val args = arrayListOf( + "-vvvv", + "--timestamp", + "--options", "runtime", + "--force", + "--prefix", signing.prefix, + "--sign", signKey + ) + + signing.keychain?.let { + args.add("--keychain") + args.add(it.absolutePath) + } + + args.add(dylibFile.absolutePath) + + execOperations.exec { exec -> + exec.executable = MacUtils.codesign.absolutePath + exec.args(*args.toTypedArray()) + }.assertNormalExitValue() + } +} \ No newline at end of file diff --git a/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/internal/SimpleFileCopyingProcessor.kt b/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/internal/SimpleFileCopyingProcessor.kt new file mode 100644 index 0000000000..b70a192fdc --- /dev/null +++ b/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/internal/SimpleFileCopyingProcessor.kt @@ -0,0 +1,9 @@ +package org.jetbrains.compose.desktop.application.internal + +import java.io.File + +object SimpleFileCopyingProcessor : FileCopyingProcessor { + override fun copy(source: File, target: File) { + source.copyTo(target, overwrite = true) + } +} \ No newline at end of file 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 2dc3c7d18e..aaeae42977 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 @@ -9,9 +9,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.tasks.AbstractJLinkTask -import org.jetbrains.compose.desktop.application.tasks.AbstractJPackageTask -import org.jetbrains.compose.desktop.application.tasks.AbstractRunDistributableTask +import org.jetbrains.compose.desktop.application.tasks.* import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension import org.jetbrains.kotlin.gradle.plugin.KotlinPlatformType import java.io.File @@ -53,10 +51,6 @@ internal fun Project.configureFromMppPlugin(mainApplication: Application) { internal fun Project.configurePackagingTasks(apps: Collection) { for (app in apps) { - val run = project.tasks.composeTask(taskName("run", app)) { - configureRunTask(app) - } - val createRuntimeImage = tasks.composeTask( taskName("createRuntimeImage", app) ) { @@ -65,13 +59,41 @@ internal fun Project.configurePackagingTasks(apps: Collection) { destinationDir.set(project.layout.buildDirectory.dir("compose/tmp/${app.name}/runtime")) } + val createDistributable = tasks.composeTask( + taskName("createDistributable", app), + args = listOf(TargetFormat.AppImage) + ) { + configurePackagingTask(app, createRuntimeImage = createRuntimeImage) + } + val packageFormats = app.nativeDistributions.targetFormats.map { targetFormat -> - tasks.composeTask( + val packageFormat = tasks.composeTask( taskName("package", app, targetFormat.name), args = listOf(targetFormat) ) { - configurePackagingTask(app, createRuntimeImage) + configurePackagingTask(app, createAppImage = createDistributable) } + + if (targetFormat.isCompatibleWith(OS.MacOS)) { + check(targetFormat == TargetFormat.Dmg || targetFormat == TargetFormat.Pkg) { + "Unexpected target format for MacOS: $targetFormat" + } + + val upload = tasks.composeTask( + taskName("notarize", app, targetFormat.name), + args = listOf(targetFormat) + ) { + configureUploadForNotarizationTask(app, packageFormat, targetFormat) + } + + tasks.composeTask( + taskName("checkNotarizationStatus", app, targetFormat.name) + ) { + configureCheckNotarizationStatusTask(app, upload) + } + } + + packageFormat } val packageAll = tasks.composeTask(taskName("package", app)) { @@ -82,29 +104,33 @@ internal fun Project.configurePackagingTasks(apps: Collection) { configurePackageUberJarForCurrentOS(app) } - val createDistributable = tasks.composeTask( - taskName("createDistributable", app), - args = listOf(TargetFormat.AppImage) - ) { - configurePackagingTask(app, createRuntimeImage) - } - val runDistributable = project.tasks.composeTask( taskName("runDistributable", app), args = listOf(createDistributable) ) + + val run = project.tasks.composeTask(taskName("run", app)) { + configureRunTask(app) + } } } internal fun AbstractJPackageTask.configurePackagingTask( app: Application, - createRuntimeImage: TaskProvider + createAppImage: TaskProvider? = null, + createRuntimeImage: TaskProvider? = null ) { enabled = targetFormat.isCompatibleWithCurrentOS - val runtimeImageDir = createRuntimeImage.flatMap { it.destinationDir } - dependsOn(createRuntimeImage) - runtimeImage.set(runtimeImageDir) + createAppImage?.let { createAppImage -> + dependsOn(createAppImage) + appImage.set(createAppImage.flatMap { it.destinationDir }) + } + + createRuntimeImage?.let { createRuntimeImage -> + dependsOn(createRuntimeImage) + runtimeImage.set(createRuntimeImage.flatMap { it.destinationDir }) + } configurePlatformSettings(app) @@ -134,6 +160,32 @@ internal fun AbstractJPackageTask.configurePackagingTask( launcherArgs.set(provider { app.args }) } +internal fun AbstractUploadAppForNotarizationTask.configureUploadForNotarizationTask( + app: Application, + packageFormat: TaskProvider, + targetFormat: TargetFormat +) { + dependsOn(packageFormat) + inputDir.set(packageFormat.flatMap { it.destinationDir }) + requestIDFile.set(project.layout.buildDirectory.file("compose/notarization/${app.name}-${targetFormat.id}-request-id.txt")) + configureCommonNotarizationSettings(app) +} + +internal fun AbstractCheckNotarizationStatusTask.configureCheckNotarizationStatusTask( + app: Application, + uploadTask: Provider +) { + requestIDFile.set(uploadTask.flatMap { it.requestIDFile }) + configureCommonNotarizationSettings(app) +} + +internal fun AbstractNotarizationTask.configureCommonNotarizationSettings( + app: Application +) { + nonValidatedBundleID.set(app.nativeDistributions.macOS.bundleID) + nonValidatedNotarizationSettings = app.nativeDistributions.macOS.notarization +} + internal fun AbstractJPackageTask.configurePlatformSettings(app: Application) { when (currentOS) { OS.Linux -> { @@ -163,11 +215,8 @@ internal fun AbstractJPackageTask.configurePlatformSettings(app: Application) { OS.MacOS -> { app.nativeDistributions.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 }) + nonValidatedMacBundleID.set(provider { mac.bundleID }) + nonValidatedMacSigningSettings = app.nativeDistributions.macOS.signing iconFile.set(mac.iconFile) } } diff --git a/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/internal/osUtils.kt b/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/internal/osUtils.kt index e6a1b35893..3abc79bba3 100644 --- a/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/internal/osUtils.kt +++ b/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/internal/osUtils.kt @@ -1,5 +1,7 @@ package org.jetbrains.compose.desktop.application.internal +import org.gradle.api.tasks.Internal +import org.jetbrains.compose.desktop.application.dsl.TargetFormat import java.io.File internal enum class OS(val id: String) { @@ -46,3 +48,32 @@ internal fun executableName(nameWithoutExtension: String): String = internal fun javaExecutable(javaHome: String): String = File(javaHome).resolve("bin/${executableName("java")}").absolutePath + +internal object MacUtils { + val codesign: File by lazy { + File("/usr/bin/codesign").checkExistingFile() + } + + val security: File by lazy { + File("/usr/bin/security").checkExistingFile() + } + + val xcrun: File by lazy { + File("/usr/bin/xcrun").checkExistingFile() + } +} + +@Internal +internal fun findOutputFileOrDir(dir: File, targetFormat: TargetFormat): File = + when (targetFormat) { + TargetFormat.AppImage -> dir + else -> dir.walk().first { it.isFile && it.name.endsWith(targetFormat.fileExt) } + } + +internal fun File.checkExistingFile(): File = + apply { + check(isFile) { "'$absolutePath' does not exist" } + } + +internal val File.isJarFile: Boolean + get() = name.endsWith(".jar", ignoreCase = true) && isFile diff --git a/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/internal/validation/ValidatedMacOSNotarizationSettings.kt b/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/internal/validation/ValidatedMacOSNotarizationSettings.kt new file mode 100644 index 0000000000..6cc35fb173 --- /dev/null +++ b/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/internal/validation/ValidatedMacOSNotarizationSettings.kt @@ -0,0 +1,40 @@ +package org.jetbrains.compose.desktop.application.internal.validation + +import org.gradle.api.provider.Provider +import org.jetbrains.compose.desktop.application.dsl.MacOSNotarizationSettings +import org.jetbrains.compose.desktop.application.internal.ComposeProperties + +internal data class ValidatedMacOSNotarizationSettings( + val bundleID: String, + val appleID: String, + val password: String +) + +internal fun MacOSNotarizationSettings.validate( + bundleIDProvider: Provider +): ValidatedMacOSNotarizationSettings { + val bundleID = validateBundleID(bundleIDProvider) + check(!appleID.orNull.isNullOrEmpty()) { + ERR_APPLE_ID_IS_EMPTY + } + check(!password.orNull.isNullOrEmpty()) { + ERR_PASSWORD_IS_EMPTY + } + return ValidatedMacOSNotarizationSettings( + bundleID = bundleID, + appleID = appleID.orNull!!, + password = password.orNull!! + ) +} + +private const val ERR_PREFIX = "Notarization settings error:" +private val ERR_APPLE_ID_IS_EMPTY = + """|$ERR_PREFIX appleID is null or empty. To specify: + | * Use '${ComposeProperties.MAC_NOTARIZATION_APPLE_ID}' Gradle property; + | * Or use 'nativeDistributions.macOS.notarization.appleID' DSL property; + """.trimMargin() +private val ERR_PASSWORD_IS_EMPTY = + """|$ERR_PREFIX password is null or empty. To specify: + | * Use '${ComposeProperties.MAC_NOTARIZATION_PASSWORD}' Gradle property; + | * Or use 'nativeDistributions.macOS.notarization.password' DSL property; + """.trimMargin() \ No newline at end of file diff --git a/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/internal/validation/ValidatedMacOSSigningSettings.kt b/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/internal/validation/ValidatedMacOSSigningSettings.kt new file mode 100644 index 0000000000..8613955d85 --- /dev/null +++ b/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/internal/validation/ValidatedMacOSSigningSettings.kt @@ -0,0 +1,67 @@ +package org.jetbrains.compose.desktop.application.internal.validation + +import org.gradle.api.provider.Provider +import org.jetbrains.compose.desktop.application.dsl.MacOSSigningSettings +import org.jetbrains.compose.desktop.application.internal.ComposeProperties +import org.jetbrains.compose.desktop.application.internal.OS +import org.jetbrains.compose.desktop.application.internal.currentOS +import java.io.File + +internal data class ValidatedMacOSSigningSettings( + val bundleID: String, + val identity: String, + val keychain: File?, + val prefix: String, +) { + val fullDeveloperID: String + get() { + val developerIdPrefix = "Developer ID Application: " + val thirdPartyMacDeveloperPrefix = "3rd Party Mac Developer Application: " + return when { + identity.startsWith(developerIdPrefix) -> identity + identity.startsWith(thirdPartyMacDeveloperPrefix) -> identity + else -> developerIdPrefix + identity + } + } +} + +internal fun MacOSSigningSettings.validate( + bundleIDProvider: Provider +): ValidatedMacOSSigningSettings { + check(currentOS == OS.MacOS) { ERR_WRONG_OS } + + val bundleID = validateBundleID(bundleIDProvider) + val signPrefix = this.prefix.orNull + ?: (bundleID.substringBeforeLast(".") + ".").takeIf { bundleID.contains('.') } + ?: error(ERR_UNKNOWN_PREFIX) + val signIdentity = this.identity.orNull + ?: error(ERR_UNKNOWN_SIGN_ID) + val keychainFile = this.keychain.orNull?.let { File(it) } + if (keychainFile != null) { + check(keychainFile.exists()) { + "$ERR_PREFIX keychain is not an existing file: ${keychainFile.absolutePath}" + } + } + + return ValidatedMacOSSigningSettings( + bundleID = bundleID, + identity = signIdentity, + keychain = keychainFile, + prefix = signPrefix + ) +} + +private const val ERR_PREFIX = "Signing settings error:" +private val ERR_WRONG_OS = + "$ERR_PREFIX macOS was expected, actual OS is $currentOS" +private val ERR_UNKNOWN_PREFIX = + """|$ERR_PREFIX Could not infer signing prefix. To specify: + | * Set bundleID to reverse DNS notation (e.g. "com.mycompany.myapp"); + | * Use '${ComposeProperties.MAC_SIGN_PREFIX}' Gradle property; + | * Use 'nativeExecutables.macOS.signing.prefix' DSL property; + """.trimMargin() +private val ERR_UNKNOWN_SIGN_ID = + """|$ERR_PREFIX signing identity is null or empty. To specify: + | * Use '${ComposeProperties.MAC_SIGN_ID}' Gradle property; + | * Use 'nativeExecutables.macOS.signing.identity' DSL property; + """.trimMargin() diff --git a/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/internal/validation/validateBundleID.kt b/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/internal/validation/validateBundleID.kt new file mode 100644 index 0000000000..dd2f0bb823 --- /dev/null +++ b/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/internal/validation/validateBundleID.kt @@ -0,0 +1,22 @@ +package org.jetbrains.compose.desktop.application.internal.validation + +import org.gradle.api.provider.Provider + +internal fun validateBundleID(bundleIDProvider: Provider): String { + val bundleID = bundleIDProvider.orNull + check(!bundleID.isNullOrEmpty()) { ERR_BUNDLE_ID_IS_EMPTY } + check(bundleID.matches("[A-Za-z0-9\\-\\.]+".toRegex())) { ERR_BUNDLE_ID_WRONG_FORMAT } + return bundleID +} + +private const val ERR_PREFIX = "macOS settings error:" +private const val BUNDLE_ID_FORMAT = + "bundleID may only contain alphanumeric characters (A-Z, a-z, 0-9), hyphen (-) and period (.) characters" +private val ERR_BUNDLE_ID_IS_EMPTY = + """|$ERR_PREFIX bundleID is empty or null. To specify: + | * Use 'nativeExecutables.macOS.bundleID' DSL property; + | * $BUNDLE_ID_FORMAT; + | * Use reverse DNS notation (e.g. "com.mycompany.myapp"); + |""".trimMargin() +private val ERR_BUNDLE_ID_WRONG_FORMAT = + "$ERR_PREFIX $BUNDLE_ID_FORMAT" \ No newline at end of file diff --git a/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/tasks/AbstractCheckNotarizationStatusTask.kt b/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/tasks/AbstractCheckNotarizationStatusTask.kt new file mode 100644 index 0000000000..677119aea0 --- /dev/null +++ b/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/tasks/AbstractCheckNotarizationStatusTask.kt @@ -0,0 +1,27 @@ +package org.jetbrains.compose.desktop.application.tasks + +import org.gradle.api.file.RegularFileProperty +import org.gradle.api.tasks.* +import org.jetbrains.compose.desktop.application.internal.MacUtils +import org.jetbrains.compose.desktop.application.internal.ioFile + +abstract class AbstractCheckNotarizationStatusTask : AbstractNotarizationTask() { + @get:InputFile + val requestIDFile: RegularFileProperty = objects.fileProperty() + + @TaskAction + fun run() { + val notarization = validateNotarization() + + val requestId = requestIDFile.ioFile.readText() + execOperations.exec { exec -> + exec.executable = MacUtils.xcrun.absolutePath + exec.args( + "altool", + "--notarization-info", requestId, + "--username", notarization.appleID, + "--password", notarization.password + ) + } + } +} \ No newline at end of file diff --git a/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/tasks/AbstractJPackageTask.kt b/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/tasks/AbstractJPackageTask.kt index c4cb3426c4..55cc1438c7 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 @@ -1,17 +1,21 @@ package org.jetbrains.compose.desktop.application.tasks import org.gradle.api.file.ConfigurableFileCollection +import org.gradle.api.file.Directory import org.gradle.api.file.DirectoryProperty import org.gradle.api.file.RegularFileProperty 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.gradle.process.ExecResult import org.gradle.process.ExecSpec import org.gradle.work.ChangeType import org.gradle.work.InputChanges +import org.jetbrains.compose.desktop.application.dsl.MacOSSigningSettings import org.jetbrains.compose.desktop.application.dsl.TargetFormat import org.jetbrains.compose.desktop.application.internal.* +import org.jetbrains.compose.desktop.application.internal.validation.validate import java.io.File import javax.inject.Inject @@ -103,30 +107,10 @@ abstract class AbstractJPackageTask @Inject constructor( @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() @@ -159,17 +143,64 @@ abstract class AbstractJPackageTask @Inject constructor( @get:Optional val runtimeImage: DirectoryProperty = objects.directoryProperty() + @get:InputDirectory + @get:Optional + val appImage: DirectoryProperty = objects.directoryProperty() + + @get:Input + @get:Optional + internal val nonValidatedMacBundleID: Property = objects.nullableProperty() + + @get:Nested + internal lateinit var nonValidatedMacSigningSettings: MacOSSigningSettings + + private fun validateSigning() = + nonValidatedMacSigningSettings.validate(nonValidatedMacBundleID) + + @get:LocalState + protected val signDir: Provider = project.layout.buildDirectory.dir("compose/tmp/sign") + override fun makeArgs(tmpDir: File): MutableList = super.makeArgs(tmpDir).apply { - cliArg("--input", tmpDir) + if (targetFormat == TargetFormat.AppImage) { + cliArg("--input", tmpDir) + check(runtimeImage.isPresent) { "runtimeImage must be set for ${TargetFormat.AppImage}" } + check(!appImage.isPresent) { "appImage must not be set for ${TargetFormat.AppImage}" } + cliArg("--runtime-image", runtimeImage) + cliArg("--main-jar", launcherMainJar.ioFile.name) + cliArg("--main-class", launcherMainClass) + } else { + check(!runtimeImage.isPresent) { "runtimeImage must not be set for $targetFormat" } + check(appImage.isPresent) { "appImage must be set for $targetFormat" } + cliArg("--app-image", appImage) + cliArg("--install-dir", installationPath) + cliArg("--license-file", licenseFile) + + 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.Windows -> { + 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) + } + } + } + cliArg("--type", targetFormat.id) cliArg("--dest", destinationDir) cliArg("--verbose", verbose) - if (targetFormat != TargetFormat.AppImage) { - cliArg("--install-dir", installationPath) - cliArg("--license-file", licenseFile) - } cliArg("--icon", iconFile) cliArg("--name", packageName) @@ -178,8 +209,6 @@ abstract class AbstractJPackageTask @Inject constructor( cliArg("--app-version", packageVersion) cliArg("--vendor", packageVendor) - cliArg("--main-jar", launcherMainJar.ioFile.name) - cliArg("--main-class", launcherMainClass) launcherArgs.orNull?.forEach { cliArg("--arguments", it) } @@ -188,44 +217,42 @@ abstract class AbstractJPackageTask @Inject constructor( } when (currentOS) { - OS.Linux -> { - if (targetFormat != TargetFormat.AppImage) { - 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) - cliArg("--mac-signing-key-user-name", macSigningKeyUserName) + cliArg("--mac-package-identifier", nonValidatedMacBundleID) + + if (nonValidatedMacSigningSettings.sign.get()) { + val signing = validateSigning() + cliArg("--mac-sign", true) + cliArg("--mac-signing-key-user-name", signing.identity) + cliArg("--mac-signing-keychain", signing.keychain) + cliArg("--mac-package-signing-prefix", signing.prefix) + + } } OS.Windows -> { cliArg("--win-console", winConsole) - if (targetFormat != TargetFormat.AppImage) { - 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) - } } } - - cliArg("--runtime-image", runtimeImage) } override fun prepareWorkingDir(inputChanges: InputChanges) { val workingDir = workingDir.ioFile + // todo: parallel processing + val fileProcessor = + if (currentOS == OS.MacOS && nonValidatedMacSigningSettings.sign.get()) { + val tmpDirForSign = signDir.ioFile + fileOperations.delete(tmpDirForSign) + tmpDirForSign.mkdirs() + + MacJarSignFileCopyingProcessor( + tempDir = tmpDirForSign, + execOperations = execOperations, + signing = validateSigning() + ) + } else SimpleFileCopyingProcessor + if (inputChanges.isIncremental) { logger.debug("Updating working dir incrementally: $workingDir") val allChanges = inputChanges.getFileChanges(files).asSequence() + @@ -238,7 +265,7 @@ abstract class AbstractJPackageTask @Inject constructor( fileOperations.delete(targetFile) logger.debug("Deleted: $targetFile") } else { - sourceFile.copyTo(targetFile, overwrite = true) + fileProcessor.copy(sourceFile, targetFile) logger.debug("Updated: $targetFile") } } @@ -246,10 +273,14 @@ abstract class AbstractJPackageTask @Inject constructor( logger.debug("Updating working dir non-incrementally: $workingDir") fileOperations.delete(workingDir) fileOperations.mkdir(workingDir) - fileOperations.copy { - it.from(files) - it.from(launcherMainJar) - it.into(workingDir) + + files.forEach { sourceFile -> + val targetFile = workingDir.resolve(sourceFile.name) + if (targetFile.exists()) { + // todo: handle possible clashes + logger.warn("w: File already exists: $targetFile") + } + fileProcessor.copy(sourceFile, targetFile) } } } @@ -270,13 +301,7 @@ abstract class AbstractJPackageTask @Inject constructor( override fun checkResult(result: ExecResult) { super.checkResult(result) - - val finalLocation = destinationDir.ioFile.let { destinationDir -> - when (targetFormat) { - TargetFormat.AppImage -> destinationDir - else -> destinationDir.walk().first { it.isFile && it.name.endsWith(targetFormat.fileExt) } - } - } - logger.lifecycle("The distribution is written to ${finalLocation.canonicalPath}") + val outputFile = findOutputFileOrDir(destinationDir.ioFile, targetFormat) + logger.lifecycle("The distribution is written to ${outputFile.canonicalPath}") } } \ No newline at end of file diff --git a/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/tasks/AbstractNotarizationTask.kt b/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/tasks/AbstractNotarizationTask.kt new file mode 100644 index 0000000000..13340d593f --- /dev/null +++ b/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/tasks/AbstractNotarizationTask.kt @@ -0,0 +1,31 @@ +package org.jetbrains.compose.desktop.application.tasks + +import org.gradle.api.DefaultTask +import org.gradle.api.model.ObjectFactory +import org.gradle.api.provider.Property +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.Nested +import org.gradle.api.tasks.Optional +import org.gradle.process.ExecOperations +import org.jetbrains.compose.desktop.application.dsl.MacOSNotarizationSettings +import org.jetbrains.compose.desktop.application.internal.nullableProperty +import org.jetbrains.compose.desktop.application.internal.validation.validate +import javax.inject.Inject + +abstract class AbstractNotarizationTask( +) : DefaultTask() { + @get:Inject + protected abstract val objects: ObjectFactory + @get:Inject + protected abstract val execOperations: ExecOperations + + @get:Input + @get:Optional + internal val nonValidatedBundleID: Property = objects.nullableProperty() + + @get:Nested + internal lateinit var nonValidatedNotarizationSettings: MacOSNotarizationSettings + + internal fun validateNotarization() = + nonValidatedNotarizationSettings.validate(nonValidatedBundleID) +} \ No newline at end of file diff --git a/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/tasks/AbstractUploadAppForNotarizationTask.kt b/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/tasks/AbstractUploadAppForNotarizationTask.kt new file mode 100644 index 0000000000..f962b5899b --- /dev/null +++ b/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/tasks/AbstractUploadAppForNotarizationTask.kt @@ -0,0 +1,68 @@ +package org.jetbrains.compose.desktop.application.tasks + +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.file.RegularFileProperty +import org.gradle.api.tasks.* +import org.jetbrains.compose.desktop.application.dsl.TargetFormat +import org.jetbrains.compose.desktop.application.internal.* +import java.io.ByteArrayOutputStream +import java.io.PrintStream +import javax.inject.Inject + +abstract class AbstractUploadAppForNotarizationTask @Inject constructor( + @get:Input + val targetFormat: TargetFormat, +) : AbstractNotarizationTask() { + @get:InputDirectory + val inputDir: DirectoryProperty = objects.directoryProperty() + + @get:OutputFile + val requestIDFile: RegularFileProperty = objects.fileProperty() + + init { + check(targetFormat != TargetFormat.AppImage) { "${TargetFormat.AppImage} cannot be notarized!" } + } + + @TaskAction + fun run() { + val notarization = validateNotarization() + + val inputFile = findOutputFileOrDir(inputDir.ioFile, targetFormat) + val file = inputFile.checkExistingFile() + + logger.quiet("Uploading '${file.name}' for notarization (package id: '${notarization.bundleID}')") + val (res, output) = ByteArrayOutputStream().use { baos -> + PrintStream(baos).use { ps -> + val res = execOperations.exec { exec -> + exec.executable = MacUtils.xcrun.absolutePath + exec.args( + "altool", + "--notarize-app", + "--primary-bundle-id", notarization.bundleID, + "--username", notarization.appleID, + "--password", notarization.password, + "--file", file + ) + exec.standardOutput = ps + } + + res to baos.toString() + } + } + if (res.exitValue != 0) { + logger.error("Uploading failed. Stdout: $output") + res.assertNormalExitValue() + } + val m = "RequestUUID = ([A-Za-z0-9\\-]+)".toRegex().find(output) + ?: error("Could not determine RequestUUID from output: $output") + + val requestId = m.groupValues[1] + requestIDFile.ioFile.apply { + parentFile.mkdirs() + writeText(requestId) + } + + logger.quiet("Request UUID: $requestId") + logger.quiet("Request UUID is saved to ${requestIDFile.ioFile.absolutePath}") + } +} diff --git a/tutorials/Native_distributions_and_local_execution/README.md b/tutorials/Native_distributions_and_local_execution/README.md index 8fba076881..ea000b9a8e 100755 --- a/tutorials/Native_distributions_and_local_execution/README.md +++ b/tutorials/Native_distributions_and_local_execution/README.md @@ -71,21 +71,15 @@ The following formats available for the supported operating systems: * Windows — `.exe` (`TargetFormat.Exe`), `.msi` (`TargetFormat.Msi`) * Linux — `.deb` (`TargetFormat.Deb`), `.rpm` (`TargetFormat.Rpm`) -## Distributing Artifacts +## Signing & notarization on macOS By default, Apple does not allow users to execute unsigned applications downloaded from the internet. Users attempting to run such applications will be faced with an error like this: ![](attrs-error.png) -To temporarily work around this issue, users can try a couple of things (after downloading to target machine, try commands in this order, do not attempt out of order): -* `xattr -cr MyFancyProgram.app` -* `sudo spctl --master-disable` -* Try right-clicking on the app, and select "Open", then when the dialog pops up, select "Open" again. - -A more correct fix is to manually sign & notarize the application: -* [Apple's Guide on Signing & Notarizing Applications](https://developer.apple.com/documentation/xcode/notarizing_macos_software_before_distribution) -* [JPackage Documentation on Signing MacOS Applications](https://docs.oracle.com/en/java/javase/15/jpackage/support-application-features.html#GUID-8D9F0607-91F4-4070-8823-02FCAB12238D) +See [our tutorial](tutorials/Signing_and_notarization_on_macOS/README.md) +on how to sign and notarize your application. ## Customizing JDK version diff --git a/tutorials/Signing_and_notarization_on_macOS/README.md b/tutorials/Signing_and_notarization_on_macOS/README.md new file mode 100644 index 0000000000..6f1e4688be --- /dev/null +++ b/tutorials/Signing_and_notarization_on_macOS/README.md @@ -0,0 +1,226 @@ +# Signing and notarizing distributions for macOS + +Apple [requires](https://developer.apple.com/documentation/xcode/notarizing_macos_software_before_distribution) +all 3rd apps to be signed and notarized (checked by Apple) +for running on recent versions of macOS. + +## What is covered + +In this tutorial, we'll show you how to sign and notarize +native distributions of Compose apps (in `dmg` or `pkg` formats) +for distribution on macOS. + +## Prerequisites + +* [Xcode](https://developer.apple.com/xcode/). The tutorial was checked with Xcode 12.3. +* JDK 15+ (JDK 14 is not guaranteed to work). The tutorial was checked with OpenJDK 15.0.1. + +## Preparing a Developer ID certificate + +You will need a Developer ID certificate for signing your app. + +#### Checking existing Developer ID certificates + +Open https://developer.apple.com/account/resources/certificates + +#### Creating a new Developer ID certificate +1. [Create a certificate signing request](https://help.apple.com/developer-account/#/devbfa00fef7): + * Open `Keychain Access`. + * Open the menu dialog + ``` + Keychain Access > Certificate Assistant > Request a Certificate from a Certificate Authority + ``` + * Enter your Developer ID email and common name. + * Check `Save to disk` option. +2. Create and install a new certificate using your [Apple Developer account](https://developer.apple.com/account/): + * Open https://developer.apple.com/account/resources/certificates/add + * Choose the `Developer ID Application` certificate type. + * Upload your Certificate Signing Request from the previous step. + * Download and install the certificate (drag & drop the certificate into the `Keychain Access` application). + +#### Viewing installed certificates + +You can find all installed certificates and their keychains by running the following command: +``` +/usr/bin/security find-certificate -c "Developer ID Application" +``` + +If you have multiple `Developer ID Application` certificates installed, +you will need to specify the path to the keychain, containing +the certificate intended for signing. + +## Preparing an App ID + +An App ID represents one or more applications in Apple's ecosystem. + +#### Viewing existing App IDs + +Open [the page](https://developer.apple.com/account/resources/identifiers/list) on Apple's developer portal. + +#### Creating a new App ID + + +1. Open [the page](https://developer.apple.com/account/resources/identifiers/add/bundleId) on Apple's developer portal. +2. Choose `App ID` option. +3. Choose `App` type. +4. Fill the `Bundle ID` field. + * A [bundle ID](https://developer.apple.com/documentation/bundleresources/information_property_list/cfbundleidentifier) + uniquely identifies an application in Apple's ecosystem. + * You can use an explicit bundle ID a wildcard, matching multiple bundle IDs. + * It is recommended to use the reverse DNS notation (e.g.`com.yoursitename.yourappname`). + +## Creating an app-specific password + +To be able to upload an app for notarization, +you will need an app-specific password associated with your Apple ID. + +Follow these steps to generate a new password: +1. Sign in to your [Apple ID](https://appleid.apple.com/account/home) account page. +2. In the Security section, click Generate Password below App-Specific Passwords. + +See [this Apple support page](https://support.apple.com/en-us/HT204397) for more information +on the app-specific passwords. + +## Adding an app-specific password to a keychain + +To avoid remembering your one-time password, or writing it in scripts, +you can add it to the keychain by running: +``` +# Any name can be used instead of NOTARIZATION_PASSWORD + +xcrun altool --store-password-in-keychain-item "NOTARIZATION_PASSWORD" + --username + --password +``` + +Then you'll be able to refer to the password like `@keychain:NOTARIZATION_PASSWORD` +without the need to write the password itself. + +## Configuring Gradle + +### Gradle DSL + +DSL properties should be specified in `macOS` DSL block of Compose Desktop DSL: +``` kotlin +import org.jetbrains.compose.compose +import org.jetbrains.compose.desktop.application.dsl.TargetFormat + +plugins { + kotlin("jvm") + id("org.jetbrains.compose") +} + +dependencies { + implementation(compose.desktop.currentOS) +} + +compose.desktop { + application { + mainClass = "example.MainKt" + + nativeDistributions { + targetFormats(TargetFormat.Dmg) + + macOS { + // macOS DSL settings + } + } + } +} +``` + +### Gradle properties + +Some properties can also be specified using +[Gradle properties](https://docs.gradle.org/current/userguide/build_environment.html#sec:gradle_configuration_properties). + +* Default Gradle properties (`compose.desktop.mac.*`) have lower priority, than DSL properties. +* Gradle properties can be specified (the items are listed in order of ascending priority): + * In `gradle.properties` file in Gradle home; + * In `gradle.properties` file in project's root; + * In command-line + ``` + ./gradlew packageDmg -Pcompose.desktop.mac.sign=true + ``` +* Note, that `local.properties` is not a standard Gradle file, so it is not supported by default. +You can load custom properties from it manually in a script, if you want. + +### Configuring bundle ID + +``` kotlin +macOS { + bundleID = "com.example-company.example-app" +} +``` + +A [bundle ID](https://developer.apple.com/documentation/bundleresources/information_property_list/cfbundleidentifier) +uniquely identifies an application in Apple's ecosystem. +* A bundle ID must be specified using the `bundleID` DSL property. +* Use only alphanumeric characters (`A-Z`, `a-z`, and `0-9`), hyphen (`-`) and period (`.`) characters. +* Use the reverse DNS notation of your domain (e.g. + `com.yoursitename.yourappname`). +* The specified bundle ID must match one of your App IDs. + +### Configuring signing settings + +``` kotlin +macOS { + signing { + sign.set(true) + identity.set("John Doe") + // keychain.set("/path/to/keychain") + } +} +``` + +* Set the `sign` DSL property or to `true`. + * Alternatively, the `compose.desktop.mac.sign` Gradle property can be used. +* Set the `identity` DSL property to the certificate's name, e.g. `"John Doe"`. + * Alternatively, the `compose.desktop.mac.signing.identity` Gradle property can be used. +* Optionally, set the `keychain` DSL property to the path to the specific keychain, containing your certificate. + * Alternatively, the `compose.desktop.mac.signing.keychain` Gradle property can be used. + * This step is only necessary, if multiple `Developer ID Application` certificates are installed. + +The following Gradle properties can be used instead of DSL properties: +* `compose.desktop.mac.sign` enables or disables signing. + Possible values: `true` or `false`. +* `compose.desktop.mac.signing.identity` overrides the `identity` DSL property. +* `compose.desktop.mac.signing.keychain` overrides the `keychain` DSL property. + +### Configuring notarization settings + +``` kotlin +macOS { + notarization { + appleID.set("john.doe@example.com") + password.set("@keychain:NOTARIZATION_PASSWORD") + } +} +``` + +* Set `appleID` to your Apple ID. + * Alternatively, the `compose.desktop.mac.notarization.appleID` can be used. +* Set `password` to the app-specific password created previously. + * Alternatively, the `compose.desktop.mac.notarization.password` can be used. + * Don't write raw password directly into a build script. + * If the password was added to the keychain, as described previously, it can be referenced as + ``` + @keychain:NOTARIZATION_PASSWORD + ``` + +## Using Gradle + +The following tasks are available: +* Use `createDistributable` or `package` to get a signed application + (no separate step is required). +* Use `notarize` to upload an application for notarization. + Once the upload finishes, a `RequestUUID` will be printed. + The notarization process takes some time. + Once the notarization process finishes, an email will be sent to you. +* Use `checkNotarizationStatus` to check a status of + the last notarization request. You can also use a command-line command directly: +``` +xcrun altool --notarization-info + --username + --password "@keychain:NOTARIZATION_PASSWORD" +```