Browse Source

Support simplified sign and notarization for macOS distribution in Gradle plugin (#332)

pull/342/head 0.3.0-build148
Alexey Tsvetkov 4 years ago committed by GitHub
parent
commit
c243639042
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 30
      gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/dsl/MacOSNotarizationSettings.kt
  2. 41
      gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/dsl/MacOSSigningSettings.kt
  3. 29
      gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/dsl/PlatformSettings.kt
  4. 57
      gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/internal/ComposeProjectProperties.kt
  5. 7
      gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/internal/FileCopyingProcessor.kt
  6. 116
      gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/internal/MacJarSignFileCopyingProcessor.kt
  7. 9
      gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/internal/SimpleFileCopyingProcessor.kt
  8. 99
      gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/internal/configureApplication.kt
  9. 31
      gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/internal/osUtils.kt
  10. 40
      gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/internal/validation/ValidatedMacOSNotarizationSettings.kt
  11. 67
      gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/internal/validation/ValidatedMacOSSigningSettings.kt
  12. 22
      gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/internal/validation/validateBundleID.kt
  13. 27
      gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/tasks/AbstractCheckNotarizationStatusTask.kt
  14. 157
      gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/tasks/AbstractJPackageTask.kt
  15. 31
      gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/tasks/AbstractNotarizationTask.kt
  16. 68
      gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/tasks/AbstractUploadAppForNotarizationTask.kt
  17. 12
      tutorials/Native_distributions_and_local_execution/README.md
  18. 226
      tutorials/Signing_and_notarization_on_macOS/README.md

30
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<String?> = objects.nullableProperty<String>().apply {
set(ComposeProperties.macNotarizationAppleID(providers))
}
@get:Input
@get:Optional
val password: Property<String?> = objects.nullableProperty<String>().apply {
set(ComposeProperties.macNotarizationPassword(providers))
}
}

41
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<Boolean> = objects.notNullProperty<Boolean>().apply {
set(
ComposeProperties.macSign(providers)
.orElse(false)
)
}
@get:Input
@get:Optional
val identity: Property<String?> = objects.nullableProperty<String>().apply {
set(ComposeProperties.macSignIdentity(providers))
}
@get:Input
@get:Optional
val keychain: Property<String?> = objects.nullableProperty<String>().apply {
set(ComposeProperties.macSignKeychain(providers))
}
@get:Input
@get:Optional
val prefix: Property<String?> = objects.nullableProperty<String>().apply {
set(ComposeProperties.macSignPrefix(providers))
}
}

29
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.Action
import org.gradle.api.file.RegularFileProperty import org.gradle.api.file.RegularFileProperty
import org.gradle.api.model.ObjectFactory import org.gradle.api.model.ObjectFactory
import java.io.File
import javax.inject.Inject import javax.inject.Inject
abstract class PlatformSettings (objects: ObjectFactory) { abstract class PlatformSettings (objects: ObjectFactory) {
@ -11,26 +10,26 @@ abstract class PlatformSettings (objects: ObjectFactory) {
} }
open class MacOSPlatformSettings @Inject constructor(objects: ObjectFactory): PlatformSettings(objects) { open class MacOSPlatformSettings @Inject constructor(objects: ObjectFactory): PlatformSettings(objects) {
var packageIdentifier: String? = null
var packageName: 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<MacOSSigningSettings>) { fun signing(fn: Action<MacOSSigningSettings>) {
// enable sign if it the corresponding block is present in DSL
if (!isSignInitialized) {
isSignInitialized = true
signing.sign = true
}
fn.execute(signing) fn.execute(signing)
} }
}
open class MacOSSigningSettings { val notarization: MacOSNotarizationSettings = objects.newInstance(MacOSNotarizationSettings::class.java)
var sign: Boolean = false fun notarization(fn: Action<MacOSNotarizationSettings>) {
var keychain: File? = null fn.execute(notarization)
var bundlePrefix: String? = null }
var keyUserName: String? = null
} }
open class LinuxPlatformSettings @Inject constructor(objects: ObjectFactory): PlatformSettings(objects) { open class LinuxPlatformSettings @Inject constructor(objects: ObjectFactory): PlatformSettings(objects) {

57
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 import org.gradle.api.provider.ProviderFactory
internal object ComposeProperties { internal object ComposeProperties {
fun isVerbose(providers: ProviderFactory): Provider<Boolean> = providers internal const val VERBOSE = "compose.desktop.verbose"
.gradleProperty("compose.desktop.verbose") internal const val PRESERVE_WD = "compose.preserve.working.dir"
.orElse("false") internal const val MAC_SIGN = "compose.desktop.mac.sign"
.map { "true".equals(it, ignoreCase = true) } internal const val MAC_SIGN_ID = "compose.desktop.mac.signing.identity"
internal const val MAC_SIGN_KEYCHAIN = "compose.desktop.mac.signing.keychain"
fun preserveWorkingDir(providers: ProviderFactory): Provider<Boolean> = providers internal const val MAC_SIGN_PREFIX = "compose.desktop.mac.signing.prefix"
.gradleProperty("compose.preserve.working.dir") internal const val MAC_NOTARIZATION_APPLE_ID = "compose.desktop.mac.notarization.appleID"
.orElse("false") internal const val MAC_NOTARIZATION_PASSWORD = "compose.desktop.mac.notarization.password"
.map { "true".equals(it, ignoreCase = true) }
fun isVerbose(providers: ProviderFactory): Provider<Boolean> =
providers.findProperty(VERBOSE).toBoolean()
fun preserveWorkingDir(providers: ProviderFactory): Provider<Boolean> =
providers.findProperty(PRESERVE_WD).toBoolean()
fun macSign(providers: ProviderFactory): Provider<Boolean> =
providers.findProperty(MAC_SIGN).toBoolean()
fun macSignIdentity(providers: ProviderFactory): Provider<String?> =
providers.findProperty(MAC_SIGN_ID)
fun macSignKeychain(providers: ProviderFactory): Provider<String?> =
providers.findProperty(MAC_SIGN_KEYCHAIN)
fun macSignPrefix(providers: ProviderFactory): Provider<String?> =
providers.findProperty(MAC_SIGN_PREFIX)
fun macNotarizationAppleID(providers: ProviderFactory): Provider<String?> =
providers.findProperty(MAC_NOTARIZATION_APPLE_ID)
fun macNotarizationPassword(providers: ProviderFactory): Provider<String?> =
providers.findProperty(MAC_NOTARIZATION_PASSWORD)
private fun ProviderFactory.findProperty(prop: String): Provider<String?> =
provider {
gradleProperty(prop).forUseAtConfigurationTimeSafe().orNull
}
private fun Provider<String?>.forUseAtConfigurationTimeSafe(): Provider<String?> =
try {
forUseAtConfigurationTime()
} catch (e: NoSuchMethodError) {
// todo: remove once we drop support for Gradle 6.4
this
}
private fun Provider<String?>.toBoolean(): Provider<Boolean> =
orElse("false").map { "true" == it }
} }

7
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)
}

116
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\"<blob>=\"([^\"]+)\"")
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()
}
}

9
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)
}
}

99
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.gradle.jvm.tasks.Jar
import org.jetbrains.compose.desktop.application.dsl.Application import org.jetbrains.compose.desktop.application.dsl.Application
import org.jetbrains.compose.desktop.application.dsl.TargetFormat import org.jetbrains.compose.desktop.application.dsl.TargetFormat
import org.jetbrains.compose.desktop.application.tasks.AbstractJLinkTask import org.jetbrains.compose.desktop.application.tasks.*
import org.jetbrains.compose.desktop.application.tasks.AbstractJPackageTask
import org.jetbrains.compose.desktop.application.tasks.AbstractRunDistributableTask
import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension
import org.jetbrains.kotlin.gradle.plugin.KotlinPlatformType import org.jetbrains.kotlin.gradle.plugin.KotlinPlatformType
import java.io.File import java.io.File
@ -53,10 +51,6 @@ internal fun Project.configureFromMppPlugin(mainApplication: Application) {
internal fun Project.configurePackagingTasks(apps: Collection<Application>) { internal fun Project.configurePackagingTasks(apps: Collection<Application>) {
for (app in apps) { for (app in apps) {
val run = project.tasks.composeTask<JavaExec>(taskName("run", app)) {
configureRunTask(app)
}
val createRuntimeImage = tasks.composeTask<AbstractJLinkTask>( val createRuntimeImage = tasks.composeTask<AbstractJLinkTask>(
taskName("createRuntimeImage", app) taskName("createRuntimeImage", app)
) { ) {
@ -65,13 +59,41 @@ internal fun Project.configurePackagingTasks(apps: Collection<Application>) {
destinationDir.set(project.layout.buildDirectory.dir("compose/tmp/${app.name}/runtime")) destinationDir.set(project.layout.buildDirectory.dir("compose/tmp/${app.name}/runtime"))
} }
val createDistributable = tasks.composeTask<AbstractJPackageTask>(
taskName("createDistributable", app),
args = listOf(TargetFormat.AppImage)
) {
configurePackagingTask(app, createRuntimeImage = createRuntimeImage)
}
val packageFormats = app.nativeDistributions.targetFormats.map { targetFormat -> val packageFormats = app.nativeDistributions.targetFormats.map { targetFormat ->
tasks.composeTask<AbstractJPackageTask>( val packageFormat = tasks.composeTask<AbstractJPackageTask>(
taskName("package", app, targetFormat.name), taskName("package", app, targetFormat.name),
args = listOf(targetFormat) 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<AbstractUploadAppForNotarizationTask>(
taskName("notarize", app, targetFormat.name),
args = listOf(targetFormat)
) {
configureUploadForNotarizationTask(app, packageFormat, targetFormat)
}
tasks.composeTask<AbstractCheckNotarizationStatusTask>(
taskName("checkNotarizationStatus", app, targetFormat.name)
) {
configureCheckNotarizationStatusTask(app, upload)
}
}
packageFormat
} }
val packageAll = tasks.composeTask<DefaultTask>(taskName("package", app)) { val packageAll = tasks.composeTask<DefaultTask>(taskName("package", app)) {
@ -82,29 +104,33 @@ internal fun Project.configurePackagingTasks(apps: Collection<Application>) {
configurePackageUberJarForCurrentOS(app) configurePackageUberJarForCurrentOS(app)
} }
val createDistributable = tasks.composeTask<AbstractJPackageTask>(
taskName("createDistributable", app),
args = listOf(TargetFormat.AppImage)
) {
configurePackagingTask(app, createRuntimeImage)
}
val runDistributable = project.tasks.composeTask<AbstractRunDistributableTask>( val runDistributable = project.tasks.composeTask<AbstractRunDistributableTask>(
taskName("runDistributable", app), taskName("runDistributable", app),
args = listOf(createDistributable) args = listOf(createDistributable)
) )
val run = project.tasks.composeTask<JavaExec>(taskName("run", app)) {
configureRunTask(app)
}
} }
} }
internal fun AbstractJPackageTask.configurePackagingTask( internal fun AbstractJPackageTask.configurePackagingTask(
app: Application, app: Application,
createRuntimeImage: TaskProvider<AbstractJLinkTask> createAppImage: TaskProvider<AbstractJPackageTask>? = null,
createRuntimeImage: TaskProvider<AbstractJLinkTask>? = null
) { ) {
enabled = targetFormat.isCompatibleWithCurrentOS enabled = targetFormat.isCompatibleWithCurrentOS
val runtimeImageDir = createRuntimeImage.flatMap { it.destinationDir } createAppImage?.let { createAppImage ->
dependsOn(createRuntimeImage) dependsOn(createAppImage)
runtimeImage.set(runtimeImageDir) appImage.set(createAppImage.flatMap { it.destinationDir })
}
createRuntimeImage?.let { createRuntimeImage ->
dependsOn(createRuntimeImage)
runtimeImage.set(createRuntimeImage.flatMap { it.destinationDir })
}
configurePlatformSettings(app) configurePlatformSettings(app)
@ -134,6 +160,32 @@ internal fun AbstractJPackageTask.configurePackagingTask(
launcherArgs.set(provider { app.args }) launcherArgs.set(provider { app.args })
} }
internal fun AbstractUploadAppForNotarizationTask.configureUploadForNotarizationTask(
app: Application,
packageFormat: TaskProvider<AbstractJPackageTask>,
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<AbstractUploadAppForNotarizationTask>
) {
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) { internal fun AbstractJPackageTask.configurePlatformSettings(app: Application) {
when (currentOS) { when (currentOS) {
OS.Linux -> { OS.Linux -> {
@ -163,11 +215,8 @@ internal fun AbstractJPackageTask.configurePlatformSettings(app: Application) {
OS.MacOS -> { OS.MacOS -> {
app.nativeDistributions.macOS.also { mac -> app.nativeDistributions.macOS.also { mac ->
macPackageName.set(provider { mac.packageName }) macPackageName.set(provider { mac.packageName })
macPackageIdentifier.set(provider { mac.packageIdentifier }) nonValidatedMacBundleID.set(provider { mac.bundleID })
macSign.set(provider { mac.signing.sign }) nonValidatedMacSigningSettings = app.nativeDistributions.macOS.signing
macSigningKeyUserName.set(provider { mac.signing.keyUserName })
macSigningKeychain.set(project.layout.file(provider { mac.signing.keychain }))
macBundleSigningPrefix.set(provider { mac.signing.bundlePrefix })
iconFile.set(mac.iconFile) iconFile.set(mac.iconFile)
} }
} }

31
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 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 import java.io.File
internal enum class OS(val id: String) { internal enum class OS(val id: String) {
@ -46,3 +48,32 @@ internal fun executableName(nameWithoutExtension: String): String =
internal fun javaExecutable(javaHome: String): String = internal fun javaExecutable(javaHome: String): String =
File(javaHome).resolve("bin/${executableName("java")}").absolutePath 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

40
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<String?>
): 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()

67
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<String?>
): 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()

22
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?>): 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"

27
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
)
}
}
}

157
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 package org.jetbrains.compose.desktop.application.tasks
import org.gradle.api.file.ConfigurableFileCollection import org.gradle.api.file.ConfigurableFileCollection
import org.gradle.api.file.Directory
import org.gradle.api.file.DirectoryProperty import org.gradle.api.file.DirectoryProperty
import org.gradle.api.file.RegularFileProperty import org.gradle.api.file.RegularFileProperty
import org.gradle.api.provider.ListProperty import org.gradle.api.provider.ListProperty
import org.gradle.api.provider.Property import org.gradle.api.provider.Property
import org.gradle.api.provider.Provider
import org.gradle.api.tasks.* import org.gradle.api.tasks.*
import org.gradle.process.ExecResult import org.gradle.process.ExecResult
import org.gradle.process.ExecSpec import org.gradle.process.ExecSpec
import org.gradle.work.ChangeType import org.gradle.work.ChangeType
import org.gradle.work.InputChanges 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.dsl.TargetFormat
import org.jetbrains.compose.desktop.application.internal.* import org.jetbrains.compose.desktop.application.internal.*
import org.jetbrains.compose.desktop.application.internal.validation.validate
import java.io.File import java.io.File
import javax.inject.Inject import javax.inject.Inject
@ -103,30 +107,10 @@ abstract class AbstractJPackageTask @Inject constructor(
@get:Optional @get:Optional
val linuxRpmLicenseType: Property<String?> = objects.nullableProperty() val linuxRpmLicenseType: Property<String?> = objects.nullableProperty()
@get:Input
@get:Optional
val macPackageIdentifier: Property<String?> = objects.nullableProperty()
@get:Input @get:Input
@get:Optional @get:Optional
val macPackageName: Property<String?> = objects.nullableProperty() val macPackageName: Property<String?> = objects.nullableProperty()
@get:Input
@get:Optional
val macBundleSigningPrefix: Property<String?> = objects.nullableProperty()
@get:Input
@get:Optional
val macSign: Property<Boolean?> = objects.nullableProperty()
@get:InputFile
@get:Optional
val macSigningKeychain: RegularFileProperty = objects.fileProperty()
@get:Input
@get:Optional
val macSigningKeyUserName: Property<String?> = objects.nullableProperty()
@get:Input @get:Input
@get:Optional @get:Optional
val winConsole: Property<Boolean?> = objects.nullableProperty() val winConsole: Property<Boolean?> = objects.nullableProperty()
@ -159,17 +143,64 @@ abstract class AbstractJPackageTask @Inject constructor(
@get:Optional @get:Optional
val runtimeImage: DirectoryProperty = objects.directoryProperty() val runtimeImage: DirectoryProperty = objects.directoryProperty()
@get:InputDirectory
@get:Optional
val appImage: DirectoryProperty = objects.directoryProperty()
@get:Input
@get:Optional
internal val nonValidatedMacBundleID: Property<String?> = objects.nullableProperty()
@get:Nested
internal lateinit var nonValidatedMacSigningSettings: MacOSSigningSettings
private fun validateSigning() =
nonValidatedMacSigningSettings.validate(nonValidatedMacBundleID)
@get:LocalState
protected val signDir: Provider<Directory> = project.layout.buildDirectory.dir("compose/tmp/sign")
override fun makeArgs(tmpDir: File): MutableList<String> = super.makeArgs(tmpDir).apply { override fun makeArgs(tmpDir: File): MutableList<String> = 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("--type", targetFormat.id)
cliArg("--dest", destinationDir) cliArg("--dest", destinationDir)
cliArg("--verbose", verbose) cliArg("--verbose", verbose)
if (targetFormat != TargetFormat.AppImage) {
cliArg("--install-dir", installationPath)
cliArg("--license-file", licenseFile)
}
cliArg("--icon", iconFile) cliArg("--icon", iconFile)
cliArg("--name", packageName) cliArg("--name", packageName)
@ -178,8 +209,6 @@ abstract class AbstractJPackageTask @Inject constructor(
cliArg("--app-version", packageVersion) cliArg("--app-version", packageVersion)
cliArg("--vendor", packageVendor) cliArg("--vendor", packageVendor)
cliArg("--main-jar", launcherMainJar.ioFile.name)
cliArg("--main-class", launcherMainClass)
launcherArgs.orNull?.forEach { launcherArgs.orNull?.forEach {
cliArg("--arguments", it) cliArg("--arguments", it)
} }
@ -188,44 +217,42 @@ abstract class AbstractJPackageTask @Inject constructor(
} }
when (currentOS) { 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 -> { OS.MacOS -> {
cliArg("--mac-package-identifier", macPackageIdentifier)
cliArg("--mac-package-name", macPackageName) cliArg("--mac-package-name", macPackageName)
cliArg("--mac-bundle-signing-prefix", macBundleSigningPrefix) cliArg("--mac-package-identifier", nonValidatedMacBundleID)
cliArg("--mac-sign", macSign)
cliArg("--mac-signing-keychain", macSigningKeychain) if (nonValidatedMacSigningSettings.sign.get()) {
cliArg("--mac-signing-key-user-name", macSigningKeyUserName) 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 -> { OS.Windows -> {
cliArg("--win-console", winConsole) 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) { override fun prepareWorkingDir(inputChanges: InputChanges) {
val workingDir = workingDir.ioFile 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) { if (inputChanges.isIncremental) {
logger.debug("Updating working dir incrementally: $workingDir") logger.debug("Updating working dir incrementally: $workingDir")
val allChanges = inputChanges.getFileChanges(files).asSequence() + val allChanges = inputChanges.getFileChanges(files).asSequence() +
@ -238,7 +265,7 @@ abstract class AbstractJPackageTask @Inject constructor(
fileOperations.delete(targetFile) fileOperations.delete(targetFile)
logger.debug("Deleted: $targetFile") logger.debug("Deleted: $targetFile")
} else { } else {
sourceFile.copyTo(targetFile, overwrite = true) fileProcessor.copy(sourceFile, targetFile)
logger.debug("Updated: $targetFile") logger.debug("Updated: $targetFile")
} }
} }
@ -246,10 +273,14 @@ abstract class AbstractJPackageTask @Inject constructor(
logger.debug("Updating working dir non-incrementally: $workingDir") logger.debug("Updating working dir non-incrementally: $workingDir")
fileOperations.delete(workingDir) fileOperations.delete(workingDir)
fileOperations.mkdir(workingDir) fileOperations.mkdir(workingDir)
fileOperations.copy {
it.from(files) files.forEach { sourceFile ->
it.from(launcherMainJar) val targetFile = workingDir.resolve(sourceFile.name)
it.into(workingDir) 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) { override fun checkResult(result: ExecResult) {
super.checkResult(result) super.checkResult(result)
val outputFile = findOutputFileOrDir(destinationDir.ioFile, targetFormat)
val finalLocation = destinationDir.ioFile.let { destinationDir -> logger.lifecycle("The distribution is written to ${outputFile.canonicalPath}")
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}")
} }
} }

31
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<String?> = objects.nullableProperty()
@get:Nested
internal lateinit var nonValidatedNotarizationSettings: MacOSNotarizationSettings
internal fun validateNotarization() =
nonValidatedNotarizationSettings.validate(nonValidatedBundleID)
}

68
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}")
}
}

12
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`) * Windows — `.exe` (`TargetFormat.Exe`), `.msi` (`TargetFormat.Msi`)
* Linux — `.deb` (`TargetFormat.Deb`), `.rpm` (`TargetFormat.Rpm`) * 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 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: to run such applications will be faced with an error like this:
![](attrs-error.png) ![](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): See [our tutorial](tutorials/Signing_and_notarization_on_macOS/README.md)
* `xattr -cr MyFancyProgram.app` on how to sign and notarize your application.
* `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)
## Customizing JDK version ## Customizing JDK version

226
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 <apple_id>
--password <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<PACKAGING_FORMAT>` to get a signed application
(no separate step is required).
* Use `notarize<PACKAGING_FORMAT>` 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<PACKAGING_FORMAT>` to check a status of
the last notarization request. You can also use a command-line command directly:
```
xcrun altool --notarization-info <RequestUUID>
--username <Apple_ID>
--password "@keychain:NOTARIZATION_PASSWORD"
```
Loading…
Cancel
Save