Alexey Tsvetkov
4 years ago
committed by
GitHub
18 changed files with 945 additions and 124 deletions
@ -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)) |
||||||
|
} |
||||||
|
} |
@ -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)) |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,7 @@ |
|||||||
|
package org.jetbrains.compose.desktop.application.internal |
||||||
|
|
||||||
|
import java.io.File |
||||||
|
|
||||||
|
internal interface FileCopyingProcessor { |
||||||
|
fun copy(source: File, target: File) |
||||||
|
} |
@ -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() |
||||||
|
} |
||||||
|
} |
@ -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) |
||||||
|
} |
||||||
|
} |
@ -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() |
@ -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() |
@ -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" |
@ -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 |
||||||
|
) |
||||||
|
} |
||||||
|
} |
||||||
|
} |
@ -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) |
||||||
|
} |
@ -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}") |
||||||
|
} |
||||||
|
} |
@ -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…
Reference in new issue