diff --git a/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/internal/MacSigner.kt b/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/internal/MacSigner.kt index 1cd2c4b2347..4cf066f10f4 100644 --- a/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/internal/MacSigner.kt +++ b/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/internal/MacSigner.kt @@ -5,6 +5,9 @@ package org.jetbrains.compose.desktop.application.internal +import org.jetbrains.compose.desktop.application.internal.validation.MacSigningCertificateKind +import org.jetbrains.compose.desktop.application.internal.validation.MacSigningIdentityInput +import org.jetbrains.compose.desktop.application.internal.validation.ResolvedMacSigningIdentity import org.jetbrains.compose.desktop.application.internal.validation.ValidatedMacOSSigningSettings import org.jetbrains.compose.internal.utils.Arch import org.jetbrains.compose.internal.utils.MacUtils @@ -29,6 +32,9 @@ internal abstract class MacSigner(protected val runTool: ExternalToolRunner) { } abstract val settings: ValidatedMacOSSigningSettings? + + open val resolvedSigningIdentity: ResolvedMacSigningIdentity? + get() = null } internal class NoCertificateSigner(runTool: ExternalToolRunner) : MacSigner(runTool) { @@ -59,29 +65,35 @@ internal class MacSignerImpl( @Transient private var signKeyValue: String? = null - override fun sign( - file: File, - entitlements: File?, - forceEntitlements: Boolean - ) { - // sign key calculation is delayed to avoid - // creating an external process during the configuration - // phase, which became an error in Gradle 8.1 - // https://github.com/JetBrains/compose-multiplatform/issues/3060 - val signKey = signKeyValue ?: run { + override val resolvedSigningIdentity: ResolvedMacSigningIdentity by lazy { + resolveMacSigningIdentity(settings) { candidate -> + var certificates = "" runTool( MacUtils.security, args = listOfNotNull( "find-certificate", "-a", "-c", - settings.fullDeveloperID, + candidate, settings.keychain?.absolutePath ), - processStdout = { signKeyValue = matchCertificates(it) } + processStdout = { certificates = it }, + logToConsole = ExternalToolRunner.LogToConsole.Never ) - signKeyValue!! + certificates } + } + + override fun sign( + file: File, + entitlements: File?, + forceEntitlements: Boolean + ) { + // sign key calculation is delayed to avoid + // creating an external process during the configuration + // phase, which became an error in Gradle 8.1 + // https://github.com/JetBrains/compose-multiplatform/issues/3060 + val signKey = signKeyValue ?: resolvedSigningIdentity.fullIdentity.also { signKeyValue = it } runTool.unsign(file) runTool.sign( file = file, @@ -91,40 +103,105 @@ internal class MacSignerImpl( keychain = settings.keychain ) } +} - private fun matchCertificates(certificates: String): String { - // When the developer id contains non-ascii characters, the output of `security find-certificate` is - // slightly different. The `alis` line first has the hex-encoded developer id, then some spaces, - // and then the developer id with non-ascii characters encoded as octal. - // See https://bugs.openjdk.org/browse/JDK-8308042 - val regex = Pattern.compile("\"alis\"=(0x[0-9A-F]+)?\\s*\"([^\"]+)\"") - val m = regex.matcher(certificates) - if (!m.find()) { - val keychainPath = settings.keychain?.absolutePath - error( - "Could not find certificate for '${settings.identity}'" + - " in keychain [${keychainPath.orEmpty()}]" - ) +internal fun resolveMacSigningIdentity( + settings: ValidatedMacOSSigningSettings, + findCertificates: (String) -> String +): ResolvedMacSigningIdentity { + val requestedIdentity = settings.parsedIdentity + check(requestedIdentity.isAppSigningIdentity) { + buildString { + append("Signing settings error: '${settings.identity}' is not an app signing certificate. ") + append("Use one of: ") + append(MacSigningCertificateKind.appSigningKinds.joinToString { it.displayName }) + append(".") } + } + + val matches = mutableListOf() + for (candidate in settings.appSigningSearchIdentities) { + matches += extractCertificateAliases(findCertificates(candidate)) + .filter { it.matchesCandidateIdentity(candidate) } + } + + if (matches.isEmpty()) { + error(buildMissingCertificateMessage(settings)) + } + + val distinctMatches = linkedSetOf().apply { addAll(matches) } + if (distinctMatches.size > 1) { + error(buildAmbiguousCertificateMessage(settings, distinctMatches)) + } + + return ResolvedMacSigningIdentity.fromIdentity(distinctMatches.single()) +} + +private fun buildMissingCertificateMessage(settings: ValidatedMacOSSigningSettings): String { + val keychainPath = settings.keychain?.absolutePath.orEmpty() + val identity = settings.identity + val checkedIdentities = settings.appSigningSearchIdentities.joinToString("\n") { "* $it" } + return buildString { + appendLine("Could not find a matching app signing certificate for '$identity' in keychain [$keychainPath].") + appendLine("Checked certificate names:") + appendLine(checkedIdentities) + append("For notarized distribution outside the App Store, use 'Developer ID Application'. ") + append("For Mac App Store uploads, use 'Mac App Distribution' or 'Apple Distribution' for the app ") + append("and a matching installer certificate for PKG. Development certificates such as ") + append("'Apple Development', 'Mac Development', and 'Mac Developer' are only suitable for local app signing.") + } +} + +private fun buildAmbiguousCertificateMessage( + settings: ValidatedMacOSSigningSettings, + matches: Set +): String = buildString { + appendLine("Multiple matching certificates are found for '${settings.identity}' in keychain [${settings.keychain?.absolutePath.orEmpty()}].") + appendLine("Matching certificates:") + appendLine(matches.joinToString("\n") { "* $it" }) + append("Specify the full certificate identity in 'nativeDistributions.macOS.signing.identity'.") +} + +private fun String.matchesCandidateIdentity(candidate: String): Boolean { + val candidateIdentity = MacSigningIdentityInput.parse(candidate) + val aliasIdentity = MacSigningIdentityInput.parse(this) + if (candidateIdentity.kind == null || aliasIdentity.kind != candidateIdentity.kind) { + return false + } + + val candidateName = candidateIdentity.name + val aliasName = aliasIdentity.name + if (aliasName == candidateName) { + return true + } + if (!aliasName.startsWith(candidateName)) { + return false + } + return TEAM_ID_SUFFIX_REGEX.matches(aliasName.removePrefix(candidateName)) +} + +private fun extractCertificateAliases(certificates: String): List { + // When the developer id contains non-ascii characters, the output of `security find-certificate` is + // slightly different. The `alis` line first has the hex-encoded developer id, then some spaces, + // and then the developer id with non-ascii characters encoded as octal. + // See https://bugs.openjdk.org/browse/JDK-8308042 + val m = CERTIFICATE_ALIAS_REGEX.matcher(certificates) + val result = linkedSetOf() + while (m.find()) { val hexEncoded = m.group(1) - if (hexEncoded.isNullOrBlank()) { - // Regular case; developer id only has ascii characters - val result = m.group(2) - if (m.find()) - error( - "Multiple matching certificates are found for '${settings.fullDeveloperID}'. " + - "Please specify keychain containing unique matching certificate." - ) - return result + val alias = if (hexEncoded.isNullOrBlank()) { + m.group(2) } else { - return hexEncoded + hexEncoded .substring(2) .chunked(2) .map { it.toInt(16).toByte() } .toByteArray() .toString(Charsets.UTF_8) } + result.add(alias) } + return result.toList() } private fun ExternalToolRunner.codesign(vararg args: String) = @@ -155,4 +232,9 @@ private fun optionalArg(arg: String, value: String?): Array = if (value != null) arrayOf(arg, value) else emptyArray() private val File.isExecutable: Boolean - get() = toPath().isExecutable() \ No newline at end of file + get() = toPath().isExecutable() + +private val CERTIFICATE_ALIAS_REGEX: Pattern = + Pattern.compile("\"alis\"=(0x[0-9A-F]+)?\\s*\"([^\"]+)\"") + +private val TEAM_ID_SUFFIX_REGEX = Regex(""" \([A-Z0-9]{10}\)$""") diff --git a/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/internal/validation/MacSigningIdentity.kt b/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/internal/validation/MacSigningIdentity.kt new file mode 100644 index 00000000000..a5faafac3bf --- /dev/null +++ b/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/internal/validation/MacSigningIdentity.kt @@ -0,0 +1,182 @@ +/* + * 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.internal.validation + +/** + * Current Apple certificate names plus legacy aliases we still need to support. + * + * Legacy compatibility matters because: + * - jpackage still recognizes the older "3rd Party Mac Developer ..." names + * - existing user keychains may still contain legacy "Mac Developer" certificates + */ +internal enum class MacSigningCertificateKind( + val prefix: String, + val isAppSigningCertificate: Boolean, + val isJPackageCompatible: Boolean, + val isDevelopmentCertificate: Boolean +) { + // Current outside-App-Store distribution certificates + DeveloperIdApplication( + prefix = "Developer ID Application: ", + isAppSigningCertificate = true, + isJPackageCompatible = true, + isDevelopmentCertificate = false + ), + DeveloperIdInstaller( + prefix = "Developer ID Installer: ", + isAppSigningCertificate = false, + isJPackageCompatible = false, + isDevelopmentCertificate = false + ), + + // Current App Store / distribution certificates + AppleDistribution( + prefix = "Apple Distribution: ", + isAppSigningCertificate = true, + isJPackageCompatible = false, + isDevelopmentCertificate = false + ), + MacAppDistribution( + prefix = "Mac App Distribution: ", + isAppSigningCertificate = true, + isJPackageCompatible = false, + isDevelopmentCertificate = false + ), + MacInstallerDistribution( + prefix = "Mac Installer Distribution: ", + isAppSigningCertificate = false, + isJPackageCompatible = false, + isDevelopmentCertificate = false + ), + + // Current development certificates + AppleDevelopment( + prefix = "Apple Development: ", + isAppSigningCertificate = true, + isJPackageCompatible = false, + isDevelopmentCertificate = true + ), + MacDevelopment( + prefix = "Mac Development: ", + isAppSigningCertificate = true, + isJPackageCompatible = false, + isDevelopmentCertificate = true + ), + MacDeveloper( + prefix = "Mac Developer: ", + isAppSigningCertificate = true, + isJPackageCompatible = false, + isDevelopmentCertificate = true + ), + + // Legacy compatibility certificates + ThirdPartyMacDeveloperApplication( + prefix = "3rd Party Mac Developer Application: ", + isAppSigningCertificate = true, + isJPackageCompatible = true, + isDevelopmentCertificate = false + ), + ThirdPartyMacDeveloperInstaller( + prefix = "3rd Party Mac Developer Installer: ", + isAppSigningCertificate = false, + isJPackageCompatible = false, + isDevelopmentCertificate = false + ); + + val displayName: String + get() = prefix.removeSuffix(": ") + + companion object { + val appSigningKinds = listOf( + DeveloperIdApplication, + ThirdPartyMacDeveloperApplication, + AppleDistribution, + MacAppDistribution, + AppleDevelopment, + MacDevelopment, + MacDeveloper, + ) + + fun fromIdentity(identity: String): MacSigningCertificateKind? = + entries.firstOrNull { identity.startsWith(it.prefix) } + } +} + +internal data class MacSigningIdentityInput( + val rawIdentity: String, + val kind: MacSigningCertificateKind?, + val name: String +) { + val isExplicitlyPrefixed: Boolean + get() = kind != null + + val fullIdentity: String + get() = kind?.prefix?.plus(name) ?: rawIdentity + + val isAppSigningIdentity: Boolean + get() = kind?.isAppSigningCertificate != false + + fun appSigningSearchIdentities(): List { + if (isExplicitlyPrefixed) { + return listOfNotNull(fullIdentity.takeIf { isAppSigningIdentity }) + } + + return MacSigningCertificateKind.appSigningKinds.map { it.prefix + name } + } + + companion object { + fun parse(identity: String): MacSigningIdentityInput { + val kind = MacSigningCertificateKind.fromIdentity(identity) + val name = kind?.let { identity.removePrefix(it.prefix) } ?: identity + return MacSigningIdentityInput( + rawIdentity = identity, + kind = kind, + name = name + ) + } + } +} + +internal data class ResolvedMacSigningIdentity( + val fullIdentity: String, + val kind: MacSigningCertificateKind +) { + val isJPackageCompatible: Boolean + get() = kind.isJPackageCompatible + + val installerSigningIdentityCandidates: List + get() = when (kind) { + MacSigningCertificateKind.DeveloperIdApplication -> + listOf(fullIdentity.replaceFirst(kind.prefix, MacSigningCertificateKind.DeveloperIdInstaller.prefix)) + + MacSigningCertificateKind.ThirdPartyMacDeveloperApplication, + MacSigningCertificateKind.AppleDistribution, + MacSigningCertificateKind.MacAppDistribution -> listOf( + MacSigningCertificateKind.ThirdPartyMacDeveloperInstaller.prefix + commonName, + MacSigningCertificateKind.MacInstallerDistribution.prefix + commonName, + ) + + MacSigningCertificateKind.AppleDevelopment, + MacSigningCertificateKind.MacDevelopment, + MacSigningCertificateKind.MacDeveloper, + MacSigningCertificateKind.DeveloperIdInstaller, + MacSigningCertificateKind.ThirdPartyMacDeveloperInstaller, + MacSigningCertificateKind.MacInstallerDistribution -> emptyList() + } + + private val commonName: String + get() = fullIdentity.removePrefix(kind.prefix) + + companion object { + fun fromIdentity(identity: String): ResolvedMacSigningIdentity { + val kind = MacSigningCertificateKind.fromIdentity(identity) + check(kind != null && kind.isAppSigningCertificate) { + "Unsupported macOS app signing identity: '$identity'" + } + return ResolvedMacSigningIdentity(identity, kind) + } + } +} diff --git a/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/internal/validation/ValidatedMacOSSigningSettings.kt b/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/internal/validation/ValidatedMacOSSigningSettings.kt index 96bcc35b968..85db07c3e96 100644 --- a/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/internal/validation/ValidatedMacOSSigningSettings.kt +++ b/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/internal/validation/ValidatedMacOSSigningSettings.kt @@ -17,25 +17,18 @@ internal data class ValidatedMacOSSigningSettings( val bundleID: String, val identity: String, val keychain: File?, - val prefix: String, - private val appStore: Boolean + 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 -> (if (!appStore) developerIdPrefix else thirdPartyMacDeveloperPrefix) + identity - } - } + val parsedIdentity: MacSigningIdentityInput + get() = MacSigningIdentityInput.parse(identity) + + val appSigningSearchIdentities: List + get() = parsedIdentity.appSigningSearchIdentities() } internal fun MacOSSigningSettings.validate( bundleIDProvider: Provider, - project: Project, - appStoreProvider: Provider + project: Project ): ValidatedMacOSSigningSettings { check(currentOS == OS.MacOS) { ERR_WRONG_OS } @@ -54,14 +47,11 @@ internal fun MacOSSigningSettings.validate( } keychainFile } else null - val appStore = appStoreProvider.orNull == true - return ValidatedMacOSSigningSettings( bundleID = bundleID, identity = signIdentity, keychain = keychainFile, - prefix = signPrefix, - appStore = appStore + prefix = signPrefix ) } diff --git a/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/tasks/AbstractJPackageTask.kt b/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/tasks/AbstractJPackageTask.kt index d1935673dbd..fc08a469a12 100644 --- a/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/tasks/AbstractJPackageTask.kt +++ b/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/tasks/AbstractJPackageTask.kt @@ -32,6 +32,7 @@ import org.jetbrains.compose.desktop.application.dsl.FileAssociation import org.jetbrains.compose.desktop.application.dsl.MacOSSigningSettings import org.jetbrains.compose.desktop.application.dsl.TargetFormat import org.jetbrains.compose.desktop.application.internal.APP_RESOURCES_DIR +import org.jetbrains.compose.desktop.application.internal.ExternalToolRunner import org.jetbrains.compose.desktop.application.internal.InfoPlistBuilder import org.jetbrains.compose.desktop.application.internal.InfoPlistBuilder.InfoPlistValue.InfoPlistListValue import org.jetbrains.compose.desktop.application.internal.InfoPlistBuilder.InfoPlistValue.InfoPlistMapValue @@ -56,6 +57,7 @@ import org.jetbrains.compose.desktop.application.internal.files.normalizedPath import org.jetbrains.compose.desktop.application.internal.files.transformJar import org.jetbrains.compose.desktop.application.internal.javaOption import org.jetbrains.compose.desktop.application.internal.validation.validate +import org.jetbrains.compose.internal.utils.MacUtils import org.jetbrains.compose.internal.utils.OS import org.jetbrains.compose.internal.utils.clearDirs import org.jetbrains.compose.internal.utils.currentArch @@ -75,6 +77,7 @@ import java.io.ObjectOutputStream import java.io.Serializable import java.nio.file.LinkOption import java.util.* +import java.util.regex.Pattern import javax.inject.Inject import kotlin.io.path.isExecutable import kotlin.io.path.isRegularFile @@ -338,7 +341,7 @@ abstract class AbstractJPackageTask @Inject constructor( if (currentOS == OS.MacOS) { if (shouldSign) { val validatedSettings = - nonValidatedSettings!!.validate(nonValidatedMacBundleID, project, macAppStore) + nonValidatedSettings!!.validate(nonValidatedMacBundleID, project) MacSignerImpl(validatedSettings, runExternalTool) } else NoCertificateSigner(runExternalTool) } else null @@ -502,10 +505,16 @@ abstract class AbstractJPackageTask @Inject constructor( cliArg("--mac-entitlements", macEntitlementsFile) macSigner?.settings?.let { signingSettings -> - cliArg("--mac-sign", true) - cliArg("--mac-signing-key-user-name", signingSettings.identity) - cliArg("--mac-signing-keychain", signingSettings.keychain) - cliArg("--mac-package-signing-prefix", signingSettings.prefix) + val resolvedSigningIdentity = macSigner?.resolvedSigningIdentity + // jpackage only supports "Developer ID Application" and "3rd Party Mac Developer Application" + // certificates. For other types (Apple Development, Apple Distribution, Mac App Distribution, + // Mac Development), skip jpackage signing. + if (resolvedSigningIdentity?.isJPackageCompatible == true) { + cliArg("--mac-sign", true) + cliArg("--mac-signing-key-user-name", resolvedSigningIdentity.fullIdentity) + cliArg("--mac-signing-keychain", signingSettings.keychain) + cliArg("--mac-package-signing-prefix", signingSettings.prefix) + } } } } @@ -632,10 +641,87 @@ abstract class AbstractJPackageTask @Inject constructor( override fun checkResult(result: ExecResult) { super.checkResult(result) modifyRuntimeOnMacOsIfNeeded() + signPkgIfNeeded() val outputFile = findOutputFileOrDir(destinationDir.ioFile, targetFormat) logger.lifecycle("The distribution is written to ${outputFile.canonicalPath}") } + private fun signPkgIfNeeded() { + if (currentOS != OS.MacOS || targetFormat != TargetFormat.Pkg) return + val macSigner = macSigner ?: return + val signingSettings = macSigner.settings ?: return + val resolvedSigningIdentity = macSigner.resolvedSigningIdentity ?: return + if (resolvedSigningIdentity.isJPackageCompatible) return // jpackage already signed it + + val candidates = resolvedSigningIdentity.installerSigningIdentityCandidates + check(candidates.isNotEmpty()) { + buildString { + append("PKG signing is not supported with '${resolvedSigningIdentity.fullIdentity}'. ") + append("Development certificates can sign local app bundles, but installer packages require ") + append("'Developer ID Application' plus 'Developer ID Installer' for outside-App-Store distribution, ") + append("or 'Mac App Distribution'/'Apple Distribution' plus a matching installer certificate for ") + append("Mac App Store uploads.") + } + } + val existingCandidates = candidates.filter { installerCertificateExists(it, signingSettings.keychain) } + check(existingCandidates.isNotEmpty()) { + buildString { + appendLine("Could not find a matching installer signing certificate for '${resolvedSigningIdentity.fullIdentity}'.") + appendLine("Checked installer certificate names:") + appendLine(candidates.joinToString("\n") { "* $it" }) + } + } + + val pkgFile = findOutputFileOrDir(destinationDir.ioFile, targetFormat) + val tmpPkg = pkgFile.resolveSibling("${pkgFile.nameWithoutExtension}-unsigned.pkg") + check(pkgFile.renameTo(tmpPkg)) { + "Failed to rename ${pkgFile.absolutePath} to ${tmpPkg.absolutePath}" + } + + var lastError: IllegalStateException? = null + for (installerIdentity in existingCandidates) { + val args = mutableListOf("--sign", installerIdentity) + signingSettings.keychain?.let { + args.addAll(listOf("--keychain", it.absolutePath)) + } + args.addAll(listOf(tmpPkg.absolutePath, pkgFile.absolutePath)) + + try { + runExternalTool( + tool = MacUtils.productsign, + args = args + ) + tmpPkg.delete() + return + } catch (e: IllegalStateException) { + if (existingCandidates.size == 1) { + check(tmpPkg.renameTo(pkgFile)) { + "Failed to restore unsigned PKG from ${tmpPkg.absolutePath} to ${pkgFile.absolutePath}" + } + throw e + } + lastError = e + pkgFile.delete() + } + } + check(tmpPkg.renameTo(pkgFile)) { + "Failed to restore unsigned PKG from ${tmpPkg.absolutePath} to ${pkgFile.absolutePath}" + } + throw lastError!! + } + + private fun installerCertificateExists(identity: String, keychain: File?): Boolean { + var certificates = "" + runExternalTool( + tool = MacUtils.security, + args = listOfNotNull("find-certificate", "-a", "-c", identity, keychain?.absolutePath), + checkExitCodeIsNormal = false, + processStdout = { certificates = it }, + logToConsole = ExternalToolRunner.LogToConsole.Never + ) + return extractInstallerCertificateAliases(certificates).contains(identity) + } + private fun modifyRuntimeOnMacOsIfNeeded() { if (currentOS != OS.MacOS || targetFormat != TargetFormat.AppImage) return @@ -742,6 +828,29 @@ abstract class AbstractJPackageTask @Inject constructor( } } +private fun extractInstallerCertificateAliases(certificates: String): Set { + val matcher = CERTIFICATE_ALIAS_REGEX.matcher(certificates) + val result = linkedSetOf() + while (matcher.find()) { + val hexEncoded = matcher.group(1) + val alias = if (hexEncoded.isNullOrBlank()) { + matcher.group(2) + } else { + hexEncoded + .substring(2) + .chunked(2) + .map { it.toInt(16).toByte() } + .toByteArray() + .toString(Charsets.UTF_8) + } + result.add(alias) + } + return result +} + +private val CERTIFICATE_ALIAS_REGEX: Pattern = + Pattern.compile("\"alis\"=(0x[0-9A-F]+)?\\s*\"([^\"]+)\"") + // Serializable is only needed to avoid breaking configuration cache: // https://docs.gradle.org/current/userguide/configuration_cache.html#config_cache:requirements private class FilesMapping : Serializable { diff --git a/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/internal/utils/osUtils.kt b/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/internal/utils/osUtils.kt index 4537489e8f4..88edec9f061 100644 --- a/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/internal/utils/osUtils.kt +++ b/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/internal/utils/osUtils.kt @@ -76,6 +76,10 @@ internal object MacUtils { File("/usr/bin/make").checkExistingFile() } + val productsign: File by lazy { + File("/usr/bin/productsign").checkExistingFile() + } + val open: File by lazy { File("/usr/bin/open").checkExistingFile() } diff --git a/gradle-plugins/compose/src/test/kotlin/org/jetbrains/compose/test/tests/unit/MacSignerTest.kt b/gradle-plugins/compose/src/test/kotlin/org/jetbrains/compose/test/tests/unit/MacSignerTest.kt new file mode 100644 index 00000000000..242c5d19d31 --- /dev/null +++ b/gradle-plugins/compose/src/test/kotlin/org/jetbrains/compose/test/tests/unit/MacSignerTest.kt @@ -0,0 +1,181 @@ +package org.jetbrains.compose.test.tests.unit + +import org.jetbrains.compose.desktop.application.internal.resolveMacSigningIdentity +import org.jetbrains.compose.desktop.application.internal.validation.ValidatedMacOSSigningSettings +import kotlin.test.Test +import kotlin.test.assertContains +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class MacSignerTest { + @Test + fun resolvesBareIdentityToDeveloperIdApplicationCertificate() { + val resolved = resolveMacSigningIdentity(settings(identity = "Andy Himberger")) { candidate -> + when (candidate) { + "Developer ID Application: Andy Himberger" -> + certificateOutput("Developer ID Application: Andy Himberger (GK8V53S8Z3)") + + else -> "" + } + } + + assertEquals("Developer ID Application: Andy Himberger (GK8V53S8Z3)", resolved.fullIdentity) + assertTrue(resolved.isJPackageCompatible) + assertEquals( + listOf("Developer ID Installer: Andy Himberger (GK8V53S8Z3)"), + resolved.installerSigningIdentityCandidates + ) + } + + @Test + fun resolvesBareIdentityToLegacyMacDeveloperCertificate() { + val resolved = resolveMacSigningIdentity(settings(identity = "Andy Himberger")) { candidate -> + when (candidate) { + "Mac Developer: Andy Himberger" -> + certificateOutput("Mac Developer: Andy Himberger (GK8V53S8Z3)") + + else -> "" + } + } + + assertEquals("Mac Developer: Andy Himberger (GK8V53S8Z3)", resolved.fullIdentity) + assertFalse(resolved.isJPackageCompatible) + assertTrue(resolved.installerSigningIdentityCandidates.isEmpty()) + } + + @Test + fun keepsExplicitAppleDevelopmentIdentity() { + val resolved = resolveMacSigningIdentity( + settings(identity = "Apple Development: Andy Himberger (GK8V53S8Z3)") + ) { candidate -> + if (candidate == "Apple Development: Andy Himberger (GK8V53S8Z3)") { + certificateOutput(candidate) + } else { + "" + } + } + + assertEquals("Apple Development: Andy Himberger (GK8V53S8Z3)", resolved.fullIdentity) + assertFalse(resolved.isJPackageCompatible) + assertTrue(resolved.installerSigningIdentityCandidates.isEmpty()) + } + + @Test + fun keepsExplicitAppleDistributionIdentity() { + val resolved = resolveMacSigningIdentity( + settings(identity = "Apple Distribution: Andy Himberger (GK8V53S8Z3)") + ) { candidate -> + if (candidate == "Apple Distribution: Andy Himberger (GK8V53S8Z3)") { + certificateOutput(candidate) + } else { + "" + } + } + + assertEquals("Apple Distribution: Andy Himberger (GK8V53S8Z3)", resolved.fullIdentity) + assertFalse(resolved.isJPackageCompatible) + assertEquals( + listOf( + "3rd Party Mac Developer Installer: Andy Himberger (GK8V53S8Z3)", + "Mac Installer Distribution: Andy Himberger (GK8V53S8Z3)" + ), + resolved.installerSigningIdentityCandidates + ) + } + + @Test + fun resolvesPkgInstallerCandidatesForDistributionCertificates() { + val resolved = resolveMacSigningIdentity( + settings(identity = "Mac App Distribution: Andy Himberger (GK8V53S8Z3)") + ) { candidate -> + if (candidate == "Mac App Distribution: Andy Himberger (GK8V53S8Z3)") { + certificateOutput(candidate) + } else { + "" + } + } + + assertEquals( + listOf( + "3rd Party Mac Developer Installer: Andy Himberger (GK8V53S8Z3)", + "Mac Installer Distribution: Andy Himberger (GK8V53S8Z3)" + ), + resolved.installerSigningIdentityCandidates + ) + } + + @Test + fun failsWhenBareIdentityMatchesMultipleCertificates() { + val error = assertFailsWith { + resolveMacSigningIdentity(settings(identity = "Andy Himberger")) { candidate -> + when (candidate) { + "Apple Development: Andy Himberger" -> + certificateOutput("Apple Development: Andy Himberger (GK8V53S8Z3)") + + "Mac Development: Andy Himberger" -> + certificateOutput("Mac Development: Andy Himberger (GK8V53S8Z3)") + + else -> "" + } + } + } + + assertContains(error.message.orEmpty(), "Multiple matching certificates are found") + assertContains(error.message.orEmpty(), "Specify the full certificate identity") + } + + @Test + fun failsWhenIdentityCannotBeResolved() { + val error = assertFailsWith { + resolveMacSigningIdentity(settings(identity = "Andy Himberger")) { "" } + } + + assertContains(error.message.orEmpty(), "Could not find a matching app signing certificate") + assertContains(error.message.orEmpty(), "Developer ID Application: Andy Himberger") + assertContains(error.message.orEmpty(), "Mac Developer: Andy Himberger") + } + + @Test + fun ignoresSubstringMatchesThatDoNotMatchCandidateIdentity() { + val error = assertFailsWith { + resolveMacSigningIdentity(settings(identity = "Andy Himberger")) { candidate -> + when (candidate) { + "Apple Development: Andy Himberger" -> + certificateOutput("Apple Development: Andy Himberger II (GK8V53S8Z3)") + + else -> "" + } + } + } + + assertContains(error.message.orEmpty(), "Could not find a matching app signing certificate") + } + + @Test + fun rejectsInstallerCertificatesAsAppSigningIdentities() { + val error = assertFailsWith { + resolveMacSigningIdentity( + settings(identity = "Developer ID Installer: Andy Himberger (GK8V53S8Z3)") + ) { "" } + } + + assertContains(error.message.orEmpty(), "is not an app signing certificate") + } + + private fun settings(identity: String) = ValidatedMacOSSigningSettings( + bundleID = "com.example.app", + identity = identity, + keychain = null, + prefix = "com.example." + ) + + private fun certificateOutput(alias: String): String = """ + keychain: "/Users/test/Library/Keychains/login.keychain-db" + version: 512 + class: 0x80001000 + attributes: + "alis"="$alias" + """.trimIndent() +} diff --git a/tutorials/Signing_and_notarization_on_macOS/README.md b/tutorials/Signing_and_notarization_on_macOS/README.md index d79be326e58..0f2b33b9aa9 100644 --- a/tutorials/Signing_and_notarization_on_macOS/README.md +++ b/tutorials/Signing_and_notarization_on_macOS/README.md @@ -207,8 +207,11 @@ macOS { * 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"`. +* Set the `identity` DSL property to the certificate's full name, e.g. + `"Developer ID Application: John Doe (TEAMID)"`. * Alternatively, the `compose.desktop.mac.signing.identity` Gradle property can be used. + * Bare names such as `"John Doe"` are also supported, but if multiple matching certificates are found, + you will need to use the full identity. * 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 certificates of the same type are installed. @@ -219,6 +222,19 @@ The following Gradle properties can be used instead of DSL properties: * `compose.desktop.mac.signing.identity` overrides the `identity` DSL property. * `compose.desktop.mac.signing.keychain` overrides the `keychain` DSL property. +### Choosing the correct certificate type + +Use different certificate types depending on the task: + +* Local app signing and development builds: `Apple Development`, `Mac Development`, or legacy `Mac Developer`. +* Notarized distribution outside the App Store: `Developer ID Application`. +* Mac App Store app bundle signing: `Mac App Distribution` or `Apple Distribution`. +* Mac App Store PKG signing: a matching installer certificate such as + `Mac Installer Distribution` or `3rd Party Mac Developer Installer`. + +Development certificates can sign app bundles, but they cannot sign installer packages and are not suitable for +notarized outside-App-Store distribution. + Those properties could be stored in `$HOME/.gradle/gradle.properties` to use across multiple applications. ### Notarization