Skip to content

Commit d88a123

Browse files
authored
Merge pull request #227 from kdroidFilter/feat/notarize-keychain-profile
feat(plugin-mac): add notarytool keychain-profile auth mode
2 parents 6354506 + 3c3ad67 commit d88a123

7 files changed

Lines changed: 339 additions & 52 deletions

File tree

docs/code-signing.md

Lines changed: 40 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,9 @@ macOS {
122122

123123
### Notarization
124124

125-
Apple notarization is required for distributing outside the Mac App Store on macOS 10.15+:
125+
Apple notarization is required for distributing outside the Mac App Store on macOS 10.15+. Two authentication modes are supported (mutually exclusive — App Store Connect API key support is planned):
126+
127+
#### Mode 1 — Apple ID + app-specific password
126128

127129
```kotlin
128130
macOS {
@@ -134,13 +136,43 @@ macOS {
134136
}
135137
```
136138

137-
> **Tip:** Use `xcrun notarytool store-credentials` to save credentials in the keychain:
138-
> ```bash
139-
> xcrun notarytool store-credentials "AC_PASSWORD" \
140-
> --apple-id "dev@example.com" \
141-
> --team-id "TEAMID" \
142-
> --password "app-specific-password"
143-
> ```
139+
Equivalent Gradle properties:
140+
141+
| Gradle property | Description |
142+
|-----------------|-------------|
143+
| `compose.desktop.mac.notarization.appleID` | Apple ID email |
144+
| `compose.desktop.mac.notarization.password` | App-specific password (or `@keychain:` reference) |
145+
| `compose.desktop.mac.notarization.teamID` | Apple Team ID |
146+
147+
#### Mode 2 — `notarytool` keychain profile
148+
149+
Store credentials once with `xcrun notarytool store-credentials`, then reference the profile by name:
150+
151+
```bash
152+
xcrun notarytool store-credentials "AC_PASSWORD" \
153+
--apple-id "dev@example.com" \
154+
--team-id "TEAMID" \
155+
--password "app-specific-password"
156+
```
157+
158+
```kotlin
159+
macOS {
160+
notarization {
161+
keychainProfile.set("AC_PASSWORD")
162+
// Optional — defaults to the user's login keychain:
163+
// keychainPath.set("/Users/me/Library/Keychains/login.keychain-db")
164+
}
165+
}
166+
```
167+
168+
Equivalent Gradle properties:
169+
170+
| Gradle property | Description |
171+
|-----------------|-------------|
172+
| `compose.desktop.mac.notarization.keychainProfile` | Profile name created via `store-credentials` |
173+
| `compose.desktop.mac.notarization.keychainPath` | Optional path to the keychain holding the profile |
174+
175+
> Configuring both modes in the same build is rejected at validation time. Pick one.
144176

145177
### CI/CD: macOS Signing
146178

plugin-build/plugin/src/main/kotlin/io/github/kdroidfilter/nucleus/desktop/application/dsl/MacOSNotarizationSettings.kt

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,29 @@ abstract class MacOSNotarizationSettings {
4343
set(NucleusProperties.macNotarizationTeamID(providers))
4444
}
4545

46+
/**
47+
* Name of a `notarytool` keychain profile created via `xcrun notarytool store-credentials`.
48+
* When set, notarization uses `--keychain-profile <name>` instead of `--apple-id`/`--team-id`/password.
49+
* Mutually exclusive with the `appleID`/`password`/`teamID` mode.
50+
*/
51+
@get:Input
52+
@get:Optional
53+
val keychainProfile: Property<String> =
54+
objects.nullableProperty<String>().apply {
55+
set(NucleusProperties.macNotarizationKeychainProfile(providers))
56+
}
57+
58+
/**
59+
* Optional path to the keychain that stores the `keychainProfile` credentials.
60+
* Forwarded to `notarytool` as `--keychain <path>`. Only used when [keychainProfile] is set.
61+
*/
62+
@get:Input
63+
@get:Optional
64+
val keychainPath: Property<String> =
65+
objects.nullableProperty<String>().apply {
66+
set(NucleusProperties.macNotarizationKeychainPath(providers))
67+
}
68+
4669
@Deprecated("This option is no longer supported and got replaced by teamID", level = DeprecationLevel.ERROR)
4770
@get:Internal
4871
val ascProvider: Property<String> =

plugin-build/plugin/src/main/kotlin/io/github/kdroidfilter/nucleus/desktop/application/internal/NucleusProjectProperties.kt

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ internal object NucleusProperties {
2222
internal const val MAC_NOTARIZATION_APPLE_ID = "compose.desktop.mac.notarization.appleID"
2323
internal const val MAC_NOTARIZATION_PASSWORD = "compose.desktop.mac.notarization.password"
2424
internal const val MAC_NOTARIZATION_TEAM_ID_PROVIDER = "compose.desktop.mac.notarization.teamID"
25+
internal const val MAC_NOTARIZATION_KEYCHAIN_PROFILE = "compose.desktop.mac.notarization.keychainProfile"
26+
internal const val MAC_NOTARIZATION_KEYCHAIN_PATH = "compose.desktop.mac.notarization.keychainPath"
2527
internal const val CHECK_JDK_VENDOR = "compose.desktop.packaging.checkJdkVendor"
2628
internal const val DISABLE_MULTIMODULE_RESOURCES = "org.jetbrains.compose.resources.multimodule.disable"
2729
internal const val SYNC_RESOURCES_PROPERTY = "compose.ios.resources.sync"
@@ -50,6 +52,12 @@ internal object NucleusProperties {
5052
@Suppress("MaxLineLength")
5153
fun macNotarizationTeamID(providers: ProviderFactory): Provider<String> = providers.valueOrNull(MAC_NOTARIZATION_TEAM_ID_PROVIDER)
5254

55+
@Suppress("MaxLineLength")
56+
fun macNotarizationKeychainProfile(providers: ProviderFactory): Provider<String> = providers.valueOrNull(MAC_NOTARIZATION_KEYCHAIN_PROFILE)
57+
58+
@Suppress("MaxLineLength")
59+
fun macNotarizationKeychainPath(providers: ProviderFactory): Provider<String> = providers.valueOrNull(MAC_NOTARIZATION_KEYCHAIN_PATH)
60+
5361
fun checkJdkVendor(providers: ProviderFactory): Provider<Boolean> = providers.valueOrNull(CHECK_JDK_VENDOR).toBooleanProvider(true)
5462

5563
fun disableMultimoduleResources(providers: ProviderFactory): Provider<Boolean> =

plugin-build/plugin/src/main/kotlin/io/github/kdroidfilter/nucleus/desktop/application/internal/configureJvmApplication.kt

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -872,10 +872,12 @@ internal fun JvmApplicationContext.configureCommonNotarizationSettings(notarizat
872872
val notarization = app.nativeDistributions.macOS.notarization
873873
notarizationTask.nonValidatedNotarizationSettings = notarization
874874
notarizationTask.onlyIf {
875-
val configured =
875+
val hasAppleId =
876876
!notarization.appleID.orNull.isNullOrEmpty() &&
877877
!notarization.password.orNull.isNullOrEmpty() &&
878878
!notarization.teamID.orNull.isNullOrEmpty()
879+
val hasKeychainProfile = !notarization.keychainProfile.orNull.isNullOrEmpty()
880+
val configured = hasAppleId || hasKeychainProfile
879881
if (!configured) {
880882
it.logger.info("Notarization skipped: macOS notarization settings are not configured")
881883
}

plugin-build/plugin/src/main/kotlin/io/github/kdroidfilter/nucleus/desktop/application/internal/validation/ValidatedMacOSNotarizationSettings.kt

Lines changed: 79 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -8,36 +8,98 @@ package io.github.kdroidfilter.nucleus.desktop.application.internal.validation
88
import io.github.kdroidfilter.nucleus.desktop.application.dsl.MacOSNotarizationSettings
99
import io.github.kdroidfilter.nucleus.desktop.application.internal.NucleusProperties
1010

11-
internal data class ValidatedMacOSNotarizationSettings(
12-
val appleID: String,
13-
val password: String,
14-
val teamID: String,
15-
)
11+
internal sealed class NotarizationAuth {
12+
data class AppleId(
13+
val appleID: String,
14+
val password: String,
15+
val teamID: String,
16+
) : NotarizationAuth()
17+
18+
data class KeychainProfile(
19+
val profileName: String,
20+
val keychainPath: String?,
21+
) : NotarizationAuth()
22+
}
23+
24+
/**
25+
* Builds the `notarytool` authentication arguments and an optional stdin payload
26+
* (the Apple ID password is fed via stdin to keep it off the command line).
27+
*/
28+
internal fun NotarizationAuth.toNotaryToolArgs(): Pair<List<String>, String?> =
29+
when (this) {
30+
is NotarizationAuth.AppleId ->
31+
listOf("--apple-id", appleID, "--team-id", teamID) to password
32+
is NotarizationAuth.KeychainProfile ->
33+
buildList {
34+
add("--keychain-profile")
35+
add(profileName)
36+
keychainPath?.let {
37+
add("--keychain")
38+
add(it)
39+
}
40+
} to null
41+
}
42+
43+
internal data class ValidatedMacOSNotarizationSettings(val auth: NotarizationAuth)
1644

1745
internal fun MacOSNotarizationSettings?.validate(): ValidatedMacOSNotarizationSettings {
1846
checkNotNull(this) {
1947
ERR_NOTARIZATION_SETTINGS_ARE_NOT_PROVIDED
2048
}
2149

22-
check(!appleID.orNull.isNullOrEmpty()) {
23-
ERR_APPLE_ID_IS_EMPTY
50+
val appleId = appleID.orNull?.takeUnless { it.isEmpty() }
51+
val pwd = password.orNull?.takeUnless { it.isEmpty() }
52+
val team = teamID.orNull?.takeUnless { it.isEmpty() }
53+
val profile = keychainProfile.orNull?.takeUnless { it.isEmpty() }
54+
val keychainPathValue = keychainPath.orNull?.takeUnless { it.isEmpty() }
55+
56+
val appleIdMode = appleId != null || pwd != null || team != null
57+
val keychainMode = profile != null
58+
59+
check(!(appleIdMode && keychainMode)) {
60+
ERR_MUTUALLY_EXCLUSIVE
2461
}
25-
check(!password.orNull.isNullOrEmpty()) {
26-
ERR_PASSWORD_IS_EMPTY
62+
check(appleIdMode || keychainMode) {
63+
ERR_NO_MODE_CONFIGURED
2764
}
28-
check(!teamID.orNull.isNullOrEmpty()) {
29-
TEAM_ID_IS_EMPTY
65+
66+
return if (profile != null) {
67+
ValidatedMacOSNotarizationSettings(
68+
NotarizationAuth.KeychainProfile(
69+
profileName = profile,
70+
keychainPath = keychainPathValue,
71+
),
72+
)
73+
} else {
74+
checkNotNull(appleId) { ERR_APPLE_ID_IS_EMPTY }
75+
checkNotNull(pwd) { ERR_PASSWORD_IS_EMPTY }
76+
checkNotNull(team) { ERR_TEAM_ID_IS_EMPTY }
77+
ValidatedMacOSNotarizationSettings(
78+
NotarizationAuth.AppleId(
79+
appleID = appleId,
80+
password = pwd,
81+
teamID = team,
82+
),
83+
)
3084
}
31-
return ValidatedMacOSNotarizationSettings(
32-
appleID = appleID.orNull!!,
33-
password = password.orNull!!,
34-
teamID = teamID.orNull!!,
35-
)
3685
}
3786

3887
private const val ERR_PREFIX = "Notarization settings error:"
3988
private const val ERR_NOTARIZATION_SETTINGS_ARE_NOT_PROVIDED =
4089
"$ERR_PREFIX notarization settings are not provided"
90+
private val ERR_NO_MODE_CONFIGURED =
91+
"""|$ERR_PREFIX no authentication mode configured. Configure one of:
92+
| * Apple ID mode: appleID + password + teamID
93+
| (Gradle properties: ${NucleusProperties.MAC_NOTARIZATION_APPLE_ID},
94+
| ${NucleusProperties.MAC_NOTARIZATION_PASSWORD},
95+
| ${NucleusProperties.MAC_NOTARIZATION_TEAM_ID_PROVIDER});
96+
| * Keychain profile mode: keychainProfile (created via 'xcrun notarytool store-credentials')
97+
| (Gradle property: ${NucleusProperties.MAC_NOTARIZATION_KEYCHAIN_PROFILE});
98+
""".trimMargin()
99+
private val ERR_MUTUALLY_EXCLUSIVE =
100+
"""|$ERR_PREFIX appleID/password/teamID and keychainProfile are mutually exclusive.
101+
|Configure only one authentication mode.
102+
""".trimMargin()
41103
private val ERR_APPLE_ID_IS_EMPTY =
42104
"""|$ERR_PREFIX appleID is null or empty. To specify:
43105
| * Use '${NucleusProperties.MAC_NOTARIZATION_APPLE_ID}' Gradle property;
@@ -48,7 +110,7 @@ private val ERR_PASSWORD_IS_EMPTY =
48110
| * Use '${NucleusProperties.MAC_NOTARIZATION_PASSWORD}' Gradle property;
49111
| * Or use 'nativeDistributions.macOS.notarization.password' DSL property;
50112
""".trimMargin()
51-
private val TEAM_ID_IS_EMPTY =
113+
private val ERR_TEAM_ID_IS_EMPTY =
52114
"""|$ERR_PREFIX teamID is null or empty. To specify:
53115
| * Use '${NucleusProperties.MAC_NOTARIZATION_TEAM_ID_PROVIDER}' Gradle property;
54116
| * Or use 'nativeDistributions.macOS.notarization.teamID' DSL property;

plugin-build/plugin/src/main/kotlin/io/github/kdroidfilter/nucleus/desktop/application/tasks/AbstractNotarizationTask.kt

Lines changed: 19 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import io.github.kdroidfilter.nucleus.desktop.application.internal.NotarizationR
1212
import io.github.kdroidfilter.nucleus.desktop.application.internal.files.checkExistingFile
1313
import io.github.kdroidfilter.nucleus.desktop.application.internal.files.findOutputFileOrDir
1414
import io.github.kdroidfilter.nucleus.desktop.application.internal.validation.ValidatedMacOSNotarizationSettings
15+
import io.github.kdroidfilter.nucleus.desktop.application.internal.validation.toNotaryToolArgs
1516
import io.github.kdroidfilter.nucleus.desktop.application.internal.validation.validate
1617
import io.github.kdroidfilter.nucleus.desktop.tasks.AbstractNucleusTask
1718
import io.github.kdroidfilter.nucleus.internal.utils.MacUtils
@@ -64,17 +65,15 @@ abstract class AbstractNotarizationTask
6465
packageFile: File,
6566
) {
6667
logger.lifecycle("Uploading '${packageFile.name}' for notarization")
68+
val (authArgs, stdin) = notarization.auth.toNotaryToolArgs()
6769
val args =
68-
listOfNotNull(
69-
"notarytool",
70-
"submit",
71-
"--wait",
72-
"--apple-id",
73-
notarization.appleID,
74-
"--team-id",
75-
notarization.teamID,
76-
packageFile.absolutePath,
77-
)
70+
buildList {
71+
add("notarytool")
72+
add("submit")
73+
add("--wait")
74+
addAll(authArgs)
75+
add(packageFile.absolutePath)
76+
}
7877

7978
var submissionId: String? = null
8079
var stdout = ""
@@ -83,7 +82,7 @@ abstract class AbstractNotarizationTask
8382
runExternalTool(
8483
tool = MacUtils.xcrun,
8584
args = args,
86-
stdinStr = notarization.password,
85+
stdinStr = stdin,
8786
checkExitCodeIsNormal = false,
8887
processStdout = { output ->
8988
stdout = output
@@ -110,11 +109,7 @@ abstract class AbstractNotarizationTask
110109
appendLine(appleLog)
111110
} else if (submissionId != null) {
112111
appendLine("To fetch the log manually run:")
113-
appendLine(
114-
" xcrun notarytool log $submissionId" +
115-
" --apple-id ${notarization.appleID}" +
116-
" --team-id ${notarization.teamID}",
117-
)
112+
appendLine(" xcrun notarytool log $submissionId ${authArgs.joinToString(" ")}")
118113
}
119114
}
120115
error(errMsg)
@@ -138,21 +133,19 @@ abstract class AbstractNotarizationTask
138133
): String? {
139134
if (submissionId == null) return null
140135

136+
val (authArgs, stdin) = notarization.auth.toNotaryToolArgs()
141137
return try {
142138
var logContent = ""
143139
runExternalTool(
144140
tool = MacUtils.xcrun,
145141
args =
146-
listOf(
147-
"notarytool",
148-
"log",
149-
submissionId,
150-
"--apple-id",
151-
notarization.appleID,
152-
"--team-id",
153-
notarization.teamID,
154-
),
155-
stdinStr = notarization.password,
142+
buildList {
143+
add("notarytool")
144+
add("log")
145+
add(submissionId)
146+
addAll(authArgs)
147+
},
148+
stdinStr = stdin,
156149
processStdout = { logContent = it },
157150
)
158151
logContent.ifEmpty { null }

0 commit comments

Comments
 (0)