From 8d0d92d431c4d261bacadf77321def27dcf4f1d9 Mon Sep 17 00:00:00 2001 From: Yannick Date: Sat, 15 Feb 2025 21:36:55 +0100 Subject: [PATCH 1/6] Updates fullDeveloperID --- .../internal/validation/ValidatedMacOSSigningSettings.kt | 4 ++++ 1 file changed, 4 insertions(+) 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 96bcc35b96..b63cc433ba 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 @@ -24,9 +24,13 @@ internal data class ValidatedMacOSSigningSettings( get() { val developerIdPrefix = "Developer ID Application: " val thirdPartyMacDeveloperPrefix = "3rd Party Mac Developer Application: " + val appleDevelopmentPrefix = "Apple Development: " + val appleDistributionPrefix = "Apple Distribution: " return when { identity.startsWith(developerIdPrefix) -> identity identity.startsWith(thirdPartyMacDeveloperPrefix) -> identity + identity.startsWith(appleDevelopmentPrefix) -> identity + identity.startsWith(appleDistributionPrefix) -> identity else -> (if (!appStore) developerIdPrefix else thirdPartyMacDeveloperPrefix) + identity } } From bd55c523a6894bb592b3b98c13dd589ff5e5f5ee Mon Sep 17 00:00:00 2001 From: Yannick Pulver Date: Fri, 27 Mar 2026 15:29:55 +0100 Subject: [PATCH 2/6] Support Apple Development, Apple Distribution, Mac App Distribution and Mac Development certificates for macOS signing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit jpackage only recognizes "Developer ID Application" and "3rd Party Mac Developer Application" certificate prefixes — it prepends these to the identity, breaking newer Apple certificate types. Skip jpackage signing for unsupported cert types and let the existing MacSigner handle it. Also fixes passing raw identity instead of fullDeveloperID to jpackage. Fixes CMP-4272, relates to CMP-7651 --- .../validation/ValidatedMacOSSigningSettings.kt | 10 ++++++++++ .../application/tasks/AbstractJPackageTask.kt | 13 +++++++++---- 2 files changed, 19 insertions(+), 4 deletions(-) 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 b63cc433ba..f45d35546f 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 @@ -26,14 +26,24 @@ internal data class ValidatedMacOSSigningSettings( val thirdPartyMacDeveloperPrefix = "3rd Party Mac Developer Application: " val appleDevelopmentPrefix = "Apple Development: " val appleDistributionPrefix = "Apple Distribution: " + val macAppDistributionPrefix = "Mac App Distribution: " + val macDevelopmentPrefix = "Mac Development: " return when { identity.startsWith(developerIdPrefix) -> identity identity.startsWith(thirdPartyMacDeveloperPrefix) -> identity identity.startsWith(appleDevelopmentPrefix) -> identity identity.startsWith(appleDistributionPrefix) -> identity + identity.startsWith(macAppDistributionPrefix) -> identity + identity.startsWith(macDevelopmentPrefix) -> identity else -> (if (!appStore) developerIdPrefix else thirdPartyMacDeveloperPrefix) + identity } } + + val isJPackageCompatible: Boolean + get() = identity.startsWith("Developer ID Application: ") || + identity.startsWith("3rd Party Mac Developer Application: ") || + // Unknown prefix — let jpackage add its default prefix + fullDeveloperID != identity } internal fun MacOSSigningSettings.validate( 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 d1935673db..fcd692d279 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 @@ -502,10 +502,15 @@ 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) + // 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 (signingSettings.isJPackageCompatible) { + cliArg("--mac-sign", true) + cliArg("--mac-signing-key-user-name", signingSettings.fullDeveloperID) + cliArg("--mac-signing-keychain", signingSettings.keychain) + cliArg("--mac-package-signing-prefix", signingSettings.prefix) + } } } } From 01f96bfedc2b6670a4cecd89f5a81d4f3eebf7b9 Mon Sep 17 00:00:00 2001 From: Yannick Pulver Date: Fri, 27 Mar 2026 22:25:06 +0100 Subject: [PATCH 3/6] Add productsign PKG signing for non-jpackage-compatible certificates jpackage only signs PKGs with "Developer ID Installer" and "3rd Party Mac Developer Installer" prefixes. For newer certificate types (Apple Distribution, Mac App Distribution), sign the PKG post-creation using productsign. Tries both "3rd Party Mac Developer Installer" and "Mac Installer Distribution" as candidates since Apple still issues certs with the legacy name despite documenting the new one. --- .../ValidatedMacOSSigningSettings.kt | 25 +++++++++++ .../application/tasks/AbstractJPackageTask.kt | 42 +++++++++++++++++++ .../compose/internal/utils/osUtils.kt | 4 ++ 3 files changed, 71 insertions(+) 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 f45d35546f..f9d35c0fe8 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 @@ -44,6 +44,31 @@ internal data class ValidatedMacOSSigningSettings( identity.startsWith("3rd Party Mac Developer Application: ") || // Unknown prefix — let jpackage add its default prefix fullDeveloperID != identity + + /** + * Returns possible installer signing identities for PKG signing. + * Multiple candidates are returned because Apple's documentation uses "Mac Installer Distribution" + * but currently still issues certificates as "3rd Party Mac Developer Installer". + */ + val installerSigningIdentityCandidates: List + get() { + val name = when { + identity.startsWith("Developer ID Application: ") -> + return listOf(identity.replaceFirst("Developer ID Application: ", "Developer ID Installer: ")) + identity.startsWith("3rd Party Mac Developer Application: ") -> + identity.removePrefix("3rd Party Mac Developer Application: ") + identity.startsWith("Apple Distribution: ") -> + identity.removePrefix("Apple Distribution: ") + identity.startsWith("Mac App Distribution: ") -> + identity.removePrefix("Mac App Distribution: ") + !appStore -> return listOf("Developer ID Installer: " + identity) + else -> identity + } + return listOf( + "3rd Party Mac Developer Installer: $name", + "Mac Installer Distribution: $name", + ) + } } internal fun MacOSSigningSettings.validate( 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 fcd692d279..2eaf0dffe2 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 @@ -56,6 +56,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 @@ -637,10 +638,51 @@ 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 signingSettings = macSigner?.settings ?: return + if (signingSettings.isJPackageCompatible) return // jpackage already signed it + + val candidates = signingSettings.installerSigningIdentityCandidates + if (candidates.isEmpty()) return + + 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 candidates) { + 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) { + lastError = e + pkgFile.delete() + } + } + check(tmpPkg.renameTo(pkgFile)) { + "Failed to restore unsigned PKG from ${tmpPkg.absolutePath} to ${pkgFile.absolutePath}" + } + throw lastError!! + } + private fun modifyRuntimeOnMacOsIfNeeded() { if (currentOS != OS.MacOS || targetFormat != TargetFormat.AppImage) return 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 4537489e8f..88edec9f06 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() } From 46de46ad6b2c4d17b9b4ba5e74db3f80294c3eaf Mon Sep 17 00:00:00 2001 From: Yannick Pulver Date: Mon, 30 Mar 2026 16:21:31 +0200 Subject: [PATCH 4/6] Fix macOS certificate resolution and PKG signing for modern Apple certs --- .../desktop/application/internal/MacSigner.kt | 132 ++++++++++----- .../internal/validation/MacSigningIdentity.kt | 153 ++++++++++++++++++ .../ValidatedMacOSSigningSettings.kt | 63 +------- .../application/tasks/AbstractJPackageTask.kt | 27 +++- .../compose/test/tests/unit/MacSignerTest.kt | 142 ++++++++++++++++ .../README.md | 18 ++- 6 files changed, 433 insertions(+), 102 deletions(-) create mode 100644 gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/internal/validation/MacSigningIdentity.kt create mode 100644 gradle-plugins/compose/src/test/kotlin/org/jetbrains/compose/test/tests/unit/MacSignerTest.kt 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 1cd2c4b234..93a516c1f7 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,8 @@ package org.jetbrains.compose.desktop.application.internal +import org.jetbrains.compose.desktop.application.internal.validation.MacSigningCertificateKind +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 +31,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 +64,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 +102,87 @@ 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)) + } + + if (matches.isEmpty()) { + error(buildMissingCertificateMessage(settings)) + } + + val distinctMatches = linkedSetOf().apply { addAll(matches) } + if (matches.size > 1 || 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 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 regex = Pattern.compile("\"alis\"=(0x[0-9A-F]+)?\\s*\"([^\"]+)\"") + val m = 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 +213,4 @@ 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() 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 0000000000..61abd35973 --- /dev/null +++ b/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/internal/validation/MacSigningIdentity.kt @@ -0,0 +1,153 @@ +/* + * 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 + +internal enum class MacSigningCertificateKind( + val prefix: String, + val isAppSigningCertificate: Boolean, + val isJPackageCompatible: Boolean = false, + val isDevelopmentCertificate: Boolean = false +) { + DeveloperIdApplication( + prefix = "Developer ID Application: ", + isAppSigningCertificate = true, + isJPackageCompatible = true + ), + ThirdPartyMacDeveloperApplication( + prefix = "3rd Party Mac Developer Application: ", + isAppSigningCertificate = true, + isJPackageCompatible = true + ), + AppleDistribution( + prefix = "Apple Distribution: ", + isAppSigningCertificate = true + ), + MacAppDistribution( + prefix = "Mac App Distribution: ", + isAppSigningCertificate = true + ), + AppleDevelopment( + prefix = "Apple Development: ", + isAppSigningCertificate = true, + isDevelopmentCertificate = true + ), + MacDevelopment( + prefix = "Mac Development: ", + isAppSigningCertificate = true, + isDevelopmentCertificate = true + ), + MacDeveloper( + prefix = "Mac Developer: ", + isAppSigningCertificate = true, + isDevelopmentCertificate = true + ), + DeveloperIdInstaller( + prefix = "Developer ID Installer: ", + isAppSigningCertificate = false + ), + ThirdPartyMacDeveloperInstaller( + prefix = "3rd Party Mac Developer Installer: ", + isAppSigningCertificate = false + ), + MacInstallerDistribution( + prefix = "Mac Installer Distribution: ", + isAppSigningCertificate = 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 f9d35c0fe8..85db07c3e9 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,64 +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: " - val appleDevelopmentPrefix = "Apple Development: " - val appleDistributionPrefix = "Apple Distribution: " - val macAppDistributionPrefix = "Mac App Distribution: " - val macDevelopmentPrefix = "Mac Development: " - return when { - identity.startsWith(developerIdPrefix) -> identity - identity.startsWith(thirdPartyMacDeveloperPrefix) -> identity - identity.startsWith(appleDevelopmentPrefix) -> identity - identity.startsWith(appleDistributionPrefix) -> identity - identity.startsWith(macAppDistributionPrefix) -> identity - identity.startsWith(macDevelopmentPrefix) -> identity - else -> (if (!appStore) developerIdPrefix else thirdPartyMacDeveloperPrefix) + identity - } - } - - val isJPackageCompatible: Boolean - get() = identity.startsWith("Developer ID Application: ") || - identity.startsWith("3rd Party Mac Developer Application: ") || - // Unknown prefix — let jpackage add its default prefix - fullDeveloperID != identity + val parsedIdentity: MacSigningIdentityInput + get() = MacSigningIdentityInput.parse(identity) - /** - * Returns possible installer signing identities for PKG signing. - * Multiple candidates are returned because Apple's documentation uses "Mac Installer Distribution" - * but currently still issues certificates as "3rd Party Mac Developer Installer". - */ - val installerSigningIdentityCandidates: List - get() { - val name = when { - identity.startsWith("Developer ID Application: ") -> - return listOf(identity.replaceFirst("Developer ID Application: ", "Developer ID Installer: ")) - identity.startsWith("3rd Party Mac Developer Application: ") -> - identity.removePrefix("3rd Party Mac Developer Application: ") - identity.startsWith("Apple Distribution: ") -> - identity.removePrefix("Apple Distribution: ") - identity.startsWith("Mac App Distribution: ") -> - identity.removePrefix("Mac App Distribution: ") - !appStore -> return listOf("Developer ID Installer: " + identity) - else -> identity - } - return listOf( - "3rd Party Mac Developer Installer: $name", - "Mac Installer Distribution: $name", - ) - } + 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 } @@ -93,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 2eaf0dffe2..d1eecdbb28 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 @@ -339,7 +339,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 @@ -503,12 +503,13 @@ abstract class AbstractJPackageTask @Inject constructor( cliArg("--mac-entitlements", macEntitlementsFile) macSigner?.settings?.let { signingSettings -> + 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 (signingSettings.isJPackageCompatible) { + if (resolvedSigningIdentity?.isJPackageCompatible == true) { cliArg("--mac-sign", true) - cliArg("--mac-signing-key-user-name", signingSettings.fullDeveloperID) + cliArg("--mac-signing-key-user-name", resolvedSigningIdentity.fullIdentity) cliArg("--mac-signing-keychain", signingSettings.keychain) cliArg("--mac-package-signing-prefix", signingSettings.prefix) } @@ -645,11 +646,21 @@ abstract class AbstractJPackageTask @Inject constructor( private fun signPkgIfNeeded() { if (currentOS != OS.MacOS || targetFormat != TargetFormat.Pkg) return - val signingSettings = macSigner?.settings ?: return - if (signingSettings.isJPackageCompatible) return // jpackage already signed it - - val candidates = signingSettings.installerSigningIdentityCandidates - if (candidates.isEmpty()) 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 pkgFile = findOutputFileOrDir(destinationDir.ioFile, targetFormat) val tmpPkg = pkgFile.resolveSibling("${pkgFile.nameWithoutExtension}-unsigned.pkg") 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 0000000000..3d5e390587 --- /dev/null +++ b/gradle-plugins/compose/src/test/kotlin/org/jetbrains/compose/test/tests/unit/MacSignerTest.kt @@ -0,0 +1,142 @@ +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 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 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 d79be326e5..0f2b33b9aa 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 From e7fb4218af4622e25f664236f7a0b6909b12b72d Mon Sep 17 00:00:00 2001 From: Yannick Pulver Date: Mon, 30 Mar 2026 16:38:52 +0200 Subject: [PATCH 5/6] Clarify macOS signing certificate kinds --- .../internal/validation/MacSigningIdentity.kt | 63 ++++++++++++++----- 1 file changed, 46 insertions(+), 17 deletions(-) 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 index 61abd35973..a5faafac3b 100644 --- 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 @@ -5,56 +5,85 @@ 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 = false, - val isDevelopmentCertificate: Boolean = false + val isJPackageCompatible: Boolean, + val isDevelopmentCertificate: Boolean ) { + // Current outside-App-Store distribution certificates DeveloperIdApplication( prefix = "Developer ID Application: ", isAppSigningCertificate = true, - isJPackageCompatible = true + isJPackageCompatible = true, + isDevelopmentCertificate = false ), - ThirdPartyMacDeveloperApplication( - prefix = "3rd Party Mac Developer Application: ", - isAppSigningCertificate = true, - isJPackageCompatible = true + DeveloperIdInstaller( + prefix = "Developer ID Installer: ", + isAppSigningCertificate = false, + isJPackageCompatible = false, + isDevelopmentCertificate = false ), + + // Current App Store / distribution certificates AppleDistribution( prefix = "Apple Distribution: ", - isAppSigningCertificate = true + isAppSigningCertificate = true, + isJPackageCompatible = false, + isDevelopmentCertificate = false ), MacAppDistribution( prefix = "Mac App Distribution: ", - isAppSigningCertificate = true + 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 ), - DeveloperIdInstaller( - prefix = "Developer ID Installer: ", - isAppSigningCertificate = false + + // 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 - ), - MacInstallerDistribution( - prefix = "Mac Installer Distribution: ", - isAppSigningCertificate = false + isAppSigningCertificate = false, + isJPackageCompatible = false, + isDevelopmentCertificate = false ); val displayName: String From d0e75931956572934e05cb57fa7eb41c7f590473 Mon Sep 17 00:00:00 2001 From: Yannick Pulver Date: Mon, 30 Mar 2026 22:42:39 +0200 Subject: [PATCH 6/6] Harden macOS certificate matching and PKG signing fallback --- .../desktop/application/internal/MacSigner.kt | 30 +++++++++-- .../application/tasks/AbstractJPackageTask.kt | 53 ++++++++++++++++++- .../compose/test/tests/unit/MacSignerTest.kt | 39 ++++++++++++++ 3 files changed, 118 insertions(+), 4 deletions(-) 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 93a516c1f7..4cf066f10f 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 @@ -6,6 +6,7 @@ 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 @@ -121,6 +122,7 @@ internal fun resolveMacSigningIdentity( val matches = mutableListOf() for (candidate in settings.appSigningSearchIdentities) { matches += extractCertificateAliases(findCertificates(candidate)) + .filter { it.matchesCandidateIdentity(candidate) } } if (matches.isEmpty()) { @@ -128,7 +130,7 @@ internal fun resolveMacSigningIdentity( } val distinctMatches = linkedSetOf().apply { addAll(matches) } - if (matches.size > 1 || distinctMatches.size > 1) { + if (distinctMatches.size > 1) { error(buildAmbiguousCertificateMessage(settings, distinctMatches)) } @@ -160,13 +162,30 @@ private fun buildAmbiguousCertificateMessage( 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 regex = Pattern.compile("\"alis\"=(0x[0-9A-F]+)?\\s*\"([^\"]+)\"") - val m = regex.matcher(certificates) + val m = CERTIFICATE_ALIAS_REGEX.matcher(certificates) val result = linkedSetOf() while (m.find()) { val hexEncoded = m.group(1) @@ -214,3 +233,8 @@ private fun optionalArg(arg: String, value: String?): Array = private val File.isExecutable: Boolean 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/tasks/AbstractJPackageTask.kt b/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/tasks/AbstractJPackageTask.kt index d1eecdbb28..fc08a469a1 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 @@ -76,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 @@ -661,6 +663,14 @@ abstract class AbstractJPackageTask @Inject constructor( 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") @@ -669,7 +679,7 @@ abstract class AbstractJPackageTask @Inject constructor( } var lastError: IllegalStateException? = null - for (installerIdentity in candidates) { + for (installerIdentity in existingCandidates) { val args = mutableListOf("--sign", installerIdentity) signingSettings.keychain?.let { args.addAll(listOf("--keychain", it.absolutePath)) @@ -684,6 +694,12 @@ abstract class AbstractJPackageTask @Inject constructor( 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() } @@ -694,6 +710,18 @@ abstract class AbstractJPackageTask @Inject constructor( 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 @@ -800,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/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 index 3d5e390587..242c5d19d3 100644 --- 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 @@ -62,6 +62,29 @@ class MacSignerTest { 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( @@ -114,6 +137,22 @@ class MacSignerTest { 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 {