Browse Source

Switch to notarytool for notarization (#3642)

See https://github.com/JetBrains/compose-multiplatform/pull/3642 for details

Resolves #3208
Resolves #2253

---------

Co-authored-by: Michael Rittmeister <michael@rittmeister.in>
pull/3708/head
Alexey Tsvetkov 8 months ago committed by GitHub
parent
commit
50d45f3326
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 13
      gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/dsl/MacOSNotarizationSettings.kt
  2. 6
      gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/internal/ComposeProjectProperties.kt
  3. 8
      gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/internal/ExternalToolRunner.kt
  4. 13
      gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/internal/configureJvmApplication.kt
  5. 14
      gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/internal/validation/ValidatedMacOSNotarizationSettings.kt
  6. 60
      gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/tasks/AbstractCheckNotarizationStatusTask.kt
  7. 65
      gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/tasks/AbstractNotarizationTask.kt
  8. 82
      gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/tasks/AbstractUploadAppForNotarizationTask.kt
  9. 81
      tutorials/Signing_and_notarization_on_macOS/README.md
  10. BIN
      tutorials/Signing_and_notarization_on_macOS/notarization-team-id.png

13
gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/dsl/MacOSNotarizationSettings.kt

@ -9,6 +9,7 @@ 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.Internal
import org.gradle.api.tasks.Optional
import org.jetbrains.compose.desktop.application.internal.ComposeProperties
import org.jetbrains.compose.internal.utils.nullableProperty
@ -35,7 +36,15 @@ abstract class MacOSNotarizationSettings {
@get:Input
@get:Optional
val teamID: Property<String?> = objects.nullableProperty<String>().apply {
set(ComposeProperties.macNotarizationTeamID(providers))
}
@Deprecated("This option is no longer supported and got replaced by teamID", level = DeprecationLevel.ERROR)
@get:Internal
val ascProvider: Property<String?> = objects.nullableProperty<String>().apply {
set(ComposeProperties.macNotarizationAscProvider(providers))
set(providers.provider {
throw UnsupportedOperationException("This option is not supported by notary tool and was replaced by teamID")
})
}
}
}

6
gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/internal/ComposeProjectProperties.kt

@ -19,7 +19,7 @@ internal object ComposeProperties {
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"
internal const val MAC_NOTARIZATION_ASC_PROVIDER = "compose.desktop.mac.notarization.ascProvider"
internal const val MAC_NOTARIZATION_TEAM_ID_PROVIDER = "compose.desktop.mac.notarization.teamID"
internal const val CHECK_JDK_VENDOR = "compose.desktop.packaging.checkJdkVendor"
fun isVerbose(providers: ProviderFactory): Provider<Boolean> =
@ -46,8 +46,8 @@ internal object ComposeProperties {
fun macNotarizationPassword(providers: ProviderFactory): Provider<String?> =
providers.valueOrNull(MAC_NOTARIZATION_PASSWORD)
fun macNotarizationAscProvider(providers: ProviderFactory): Provider<String?> =
providers.valueOrNull(MAC_NOTARIZATION_ASC_PROVIDER)
fun macNotarizationTeamID(providers: ProviderFactory): Provider<String?> =
providers.valueOrNull(MAC_NOTARIZATION_TEAM_ID_PROVIDER)
fun checkJdkVendor(providers: ProviderFactory): Provider<Boolean> =
providers.valueOrNull(CHECK_JDK_VENDOR).toBooleanProvider(true)

8
gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/internal/ExternalToolRunner.kt

@ -11,6 +11,7 @@ import org.gradle.api.provider.Provider
import org.gradle.process.ExecOperations
import org.gradle.process.ExecResult
import org.jetbrains.compose.internal.utils.ioFile
import java.io.ByteArrayInputStream
import java.io.File
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
@ -33,7 +34,8 @@ internal class ExternalToolRunner(
workingDir: File? = null,
checkExitCodeIsNormal: Boolean = true,
processStdout: Function1<String, Unit>? = null,
logToConsole: LogToConsole = LogToConsole.OnlyWhenVerbose
logToConsole: LogToConsole = LogToConsole.OnlyWhenVerbose,
stdinStr: String? = null
): ExecResult {
val logsDir = logsDir.ioFile
logsDir.mkdirs()
@ -52,6 +54,10 @@ internal class ExternalToolRunner(
// check exit value later
spec.isIgnoreExitValue = true
if (stdinStr != null) {
spec.standardInput = ByteArrayInputStream(stdinStr.toByteArray())
}
@Suppress("NAME_SHADOWING")
val logToConsole = when (logToConsole) {
LogToConsole.Always -> true

13
gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/internal/configureJvmApplication.kt

@ -186,23 +186,13 @@ private fun JvmApplicationContext.configurePackagingTasks(
"Unexpected target format for MacOS: $targetFormat"
}
val notarizationRequestsDir = project.layout.buildDirectory.dir("compose/notarization/$app")
tasks.register<AbstractUploadAppForNotarizationTask>(
tasks.register<AbstractNotarizationTask>(
taskNameAction = "notarize",
taskNameObject = targetFormat.name,
args = listOf(targetFormat)
) {
dependsOn(packageFormat)
inputDir.set(packageFormat.flatMap { it.destinationDir })
requestsDir.set(notarizationRequestsDir)
configureCommonNotarizationSettings(this)
}
tasks.register<AbstractCheckNotarizationStatusTask>(
taskNameAction = "check",
taskNameObject = "notarizationStatus"
) {
requestDir.set(notarizationRequestsDir)
configureCommonNotarizationSettings(this)
}
}
@ -351,7 +341,6 @@ private fun JvmApplicationContext.configurePackageTask(
internal fun JvmApplicationContext.configureCommonNotarizationSettings(
notarizationTask: AbstractNotarizationTask
) {
notarizationTask.nonValidatedBundleID.set(app.nativeDistributions.macOS.bundleID)
notarizationTask.nonValidatedNotarizationSettings = app.nativeDistributions.macOS.notarization
}

14
gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/internal/validation/ValidatedMacOSNotarizationSettings.kt

@ -5,25 +5,20 @@
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,
val ascProvider: String?
val teamID: String?
)
internal fun MacOSNotarizationSettings?.validate(
bundleIDProvider: Provider<String?>
): ValidatedMacOSNotarizationSettings {
internal fun MacOSNotarizationSettings?.validate(): ValidatedMacOSNotarizationSettings {
checkNotNull(this) {
ERR_NOTARIZATION_SETTINGS_ARE_NOT_PROVIDED
}
val bundleID = validateBundleID(bundleIDProvider)
check(!appleID.orNull.isNullOrEmpty()) {
ERR_APPLE_ID_IS_EMPTY
}
@ -31,10 +26,9 @@ internal fun MacOSNotarizationSettings?.validate(
ERR_PASSWORD_IS_EMPTY
}
return ValidatedMacOSNotarizationSettings(
bundleID = bundleID,
appleID = appleID.orNull!!,
password = password.orNull!!,
ascProvider = ascProvider.orNull
teamID = teamID.orNull
)
}
@ -50,4 +44,4 @@ 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()
""".trimMargin()

60
gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/tasks/AbstractCheckNotarizationStatusTask.kt

@ -1,60 +0,0 @@
/*
* Copyright 2020-2021 JetBrains s.r.o. and respective authors and developers.
* Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file.
*/
package org.jetbrains.compose.desktop.application.tasks
import org.gradle.api.file.DirectoryProperty
import org.gradle.api.tasks.*
import org.jetbrains.compose.desktop.application.internal.ExternalToolRunner
import org.jetbrains.compose.internal.utils.MacUtils
import org.jetbrains.compose.desktop.application.internal.NOTARIZATION_REQUEST_INFO_FILE_NAME
import org.jetbrains.compose.desktop.application.internal.NotarizationRequestInfo
import org.jetbrains.compose.internal.utils.ioFile
abstract class AbstractCheckNotarizationStatusTask : AbstractNotarizationTask() {
@get:Internal
val requestDir: DirectoryProperty = objects.directoryProperty()
@TaskAction
fun run() {
val notarization = validateNotarization()
val requests = HashSet<NotarizationRequestInfo>()
for (file in requestDir.ioFile.walk()) {
if (file.isFile && file.name == NOTARIZATION_REQUEST_INFO_FILE_NAME) {
try {
val status = NotarizationRequestInfo()
status.loadFrom(file)
requests.add(status)
} catch (e: Exception) {
logger.error("Invalid notarization request status file: $file", e)
}
}
}
if (requests.isEmpty()) {
logger.quiet("No existing notarization requests")
return
}
for (request in requests.sortedBy { it.uploadTime }) {
try {
logger.quiet("Checking status of notarization request '${request.uuid}'")
runExternalTool(
tool = MacUtils.xcrun,
args = listOf(
"altool",
"--notarization-info", request.uuid,
"--username", notarization.appleID,
"--password", notarization.password
),
logToConsole = ExternalToolRunner.LogToConsole.Always
)
} catch (e: Exception) {
logger.error("Could not check notarization request '${request.uuid}'", e)
}
}
}
}

65
gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/tasks/AbstractNotarizationTask.kt

@ -5,24 +5,67 @@
package org.jetbrains.compose.desktop.application.tasks
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.api.file.DirectoryProperty
import org.gradle.api.tasks.*
import org.jetbrains.compose.desktop.application.dsl.MacOSNotarizationSettings
import org.jetbrains.compose.internal.utils.nullableProperty
import org.jetbrains.compose.desktop.application.dsl.TargetFormat
import org.jetbrains.compose.desktop.application.internal.files.checkExistingFile
import org.jetbrains.compose.desktop.application.internal.files.findOutputFileOrDir
import org.jetbrains.compose.desktop.application.internal.validation.ValidatedMacOSNotarizationSettings
import org.jetbrains.compose.desktop.application.internal.validation.validate
import org.jetbrains.compose.desktop.tasks.AbstractComposeDesktopTask
import org.jetbrains.compose.internal.utils.MacUtils
import org.jetbrains.compose.internal.utils.ioFile
import java.io.File
import javax.inject.Inject
abstract class AbstractNotarizationTask : AbstractComposeDesktopTask() {
abstract class AbstractNotarizationTask @Inject constructor(
@get:Input
@get:Optional
internal val nonValidatedBundleID: Property<String?> = objects.nullableProperty()
val targetFormat: TargetFormat
) : AbstractComposeDesktopTask() {
@get:Nested
@get:Optional
internal var nonValidatedNotarizationSettings: MacOSNotarizationSettings? = null
internal fun validateNotarization() =
nonValidatedNotarizationSettings.validate(nonValidatedBundleID)
}
@get:InputDirectory
val inputDir: DirectoryProperty = objects.directoryProperty()
init {
check(targetFormat != TargetFormat.AppImage) { "${TargetFormat.AppImage} cannot be notarized!" }
}
@TaskAction
fun run() {
val notarization = nonValidatedNotarizationSettings.validate()
val packageFile = findOutputFileOrDir(inputDir.ioFile, targetFormat).checkExistingFile()
notarize(notarization, packageFile)
staple(packageFile)
}
private fun notarize(
notarization: ValidatedMacOSNotarizationSettings,
packageFile: File
) {
logger.info("Uploading '${packageFile.name}' for notarization")
val args = listOfNotNull(
"notarytool",
"submit",
"--wait",
"--apple-id",
notarization.appleID,
"--team-id".takeIf { notarization.teamID != null },
notarization.teamID,
packageFile.absolutePath
)
runExternalTool(tool = MacUtils.xcrun, args = args, stdinStr = notarization.password)
}
private fun staple(packageFile: File) {
runExternalTool(
tool = MacUtils.xcrun,
args = listOf("stapler", "staple", packageFile.absolutePath)
)
}
}

82
gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/tasks/AbstractUploadAppForNotarizationTask.kt

@ -1,82 +0,0 @@
/*
* Copyright 2020-2021 JetBrains s.r.o. and respective authors and developers.
* Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file.
*/
package org.jetbrains.compose.desktop.application.tasks
import org.gradle.api.file.DirectoryProperty
import org.gradle.api.tasks.*
import org.jetbrains.compose.desktop.application.dsl.TargetFormat
import org.jetbrains.compose.desktop.application.internal.*
import org.jetbrains.compose.desktop.application.internal.files.checkExistingFile
import org.jetbrains.compose.desktop.application.internal.files.findOutputFileOrDir
import org.jetbrains.compose.internal.utils.MacUtils
import org.jetbrains.compose.internal.utils.ioFile
import java.io.File
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
import javax.inject.Inject
abstract class AbstractUploadAppForNotarizationTask @Inject constructor(
@get:Input
val targetFormat: TargetFormat
) : AbstractNotarizationTask() {
@get:InputDirectory
val inputDir: DirectoryProperty = objects.directoryProperty()
@get:Internal
val requestsDir: DirectoryProperty = objects.directoryProperty()
init {
check(targetFormat != TargetFormat.AppImage) { "${TargetFormat.AppImage} cannot be notarized!" }
}
@TaskAction
fun run() {
val notarization = validateNotarization()
val packageFile = findOutputFileOrDir(inputDir.ioFile, targetFormat).checkExistingFile()
logger.quiet("Uploading '${packageFile.name}' for notarization (package id: '${notarization.bundleID}')")
val args = arrayListOf(
"altool",
"--notarize-app",
"--primary-bundle-id", notarization.bundleID,
"--username", notarization.appleID,
"--password", notarization.password,
"--file", packageFile.absolutePath
)
if (notarization.ascProvider != null) {
args.add("--asc-provider")
args.add(notarization.ascProvider)
}
runExternalTool(
tool = MacUtils.xcrun,
args = args,
processStdout = { output ->
processUploadToolOutput(packageFile, output)
}
)
}
private fun processUploadToolOutput(packageFile: File, output: String) {
val m = "RequestUUID = ([A-Za-z0-9\\-]+)".toRegex().find(output)
?: error("Could not determine RequestUUID from output: $output")
val requestId = m.groupValues[1]
val uploadTime = LocalDateTime.now()
.format(DateTimeFormatter.ofPattern("yyyy-MM-dd-HH-mm-ss"))
val requestDir = requestsDir.ioFile.resolve("$uploadTime-${targetFormat.id}")
val packageCopy = requestDir.resolve(packageFile.name)
packageFile.copyTo(packageCopy)
val requestInfo = NotarizationRequestInfo(uuid = requestId, uploadTime = uploadTime)
val requestInfoFile = requestDir.resolve(NOTARIZATION_REQUEST_INFO_FILE_NAME)
requestInfo.saveTo(requestInfoFile)
logger.quiet("Request UUID: $requestId")
logger.quiet("Request UUID is saved to ${requestInfoFile.absolutePath}")
logger.quiet("Uploaded file is saved to ${packageCopy.absolutePath}")
}
}

81
tutorials/Signing_and_notarization_on_macOS/README.md

@ -221,38 +221,58 @@ The following Gradle properties can be used instead of DSL properties:
Those properties could be stored in `$HOME/.gradle/gradle.properties` to use across multiple applications.
### Configuring notarization settings
### Notarization
Notarization is only required for apps outside the App Store.
Distributing your macOS application outside the App Store
requires notarization.
Notarization involves submitting your application to Apple for verification.
If your software passes the verification,
it's signed by Apple, stating that it has been notarized.
``` kotlin
macOS {
notarization {
appleID.set("john.doe@example.com")
password.set("@keychain:NOTARIZATION_PASSWORD")
// optional
ascProvider.set("<TEAM_ID>")
}
}
To notarize your app, you can use `notarize<PACKAGING_FORMAT>` task:
```
* Set `appleID` to your Apple ID.
* Alternatively, the `compose.desktop.mac.notarization.appleID` Gradle property can be used.
* Set `password` to the app-specific password created previously.
* Alternatively, the `compose.desktop.mac.notarization.password` Gradle property 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
```
* Set `ascProvider` to your Team ID, if your account is associated with multiple teams.
* Alternatively, the `compose.desktop.mac.notarization.ascProvider` Gradle property can be used.
* To get a table of team IDs associated with a given username and password, run:
./gradlew notarizeDmg \
-Pcompose.desktop.mac.notarization.appleID=<APPLE_ID> \
-Pcompose.desktop.mac.notarization.password=<PASSWORD> \
-Pcompose.desktop.mac.notarization.teamID=<TEAM_ID>
```
where:
* `<APPLE_ID>` — your Apple ID;
* `<PASSWORD>` — the app-specific password created previously;
* `<TEAM_ID>` — your Team. To get a table of team IDs associated with a given username and password, run:
```
xcrun altool --list-providers -u <Apple ID> -p <Notarization password>"
```
<img alt="Team ID" src="notarization-team-id.png" />
The following tasks can be used for notarization:
* `notarizeDmg` — build, sign and notarize `.dmg` installer;
* `notarizeReleaseDmg` — same as `notarizeDmg`, but with [ProGuard](tutorials/Native_distributions_and_local_execution/README.md).
* `notarizePkg` — build, sign and notarize `.pkg` installer;
* `notarizeReleasePkg` — same as `notarizePkg`, but with [ProGuard](tutorials/Native_distributions_and_local_execution/README.md).
The notarization settings can also be set using the DSL.
For example, it is possible to pass credentials using environment variables:
```
compose.desktop.application {
nativeDistributions {
macOS {
notarization {
val providers = project.providers
appleID.set(providers.environmentVariable("NOTARIZATION_APPLE_ID"))
password.set(providers.environmentVariable("NOTARIZATION_PASSWORD"))
teamId.set(providers.environmentVariable("NOTARIZATION_TEAM_ID"))
}
}
}
}
```
According to Apple, for 98 percent of software notarization completes within 15 minutes.
To learn more on how to avoid long response times, check [the official documentation](https://developer.apple.com/documentation/security/notarizing_macos_software_before_distribution/customizing_the_notarization_workflow#3561440).
### Configuring provisioning profile
For testing on TestFlight (when publishing to the App Store), you need to add a provisioning
@ -375,14 +395,3 @@ The following tasks are available:
(no separate step is required).
* Use `notarize<PACKAGING_FORMAT>` (e.g. `notarizeDmg`) to upload an application for notarization.
Notarization is only required for apps outside the App Store.
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.
Uploaded file is saved to `<BUILD_DIR>/compose/notarization/main/<UPLOAD_DATE>-<PACKAGING_FORMAT>`
* Use `checkNotarizationStatus` to check a status of
last notarization requests. You can also use a command-line command to check any notarization request:
```
xcrun altool --notarization-info <RequestUUID>
--username <Apple_ID>
--password "@keychain:NOTARIZATION_PASSWORD"
```

BIN
tutorials/Signing_and_notarization_on_macOS/notarization-team-id.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Loading…
Cancel
Save