8.2 KiB
Decision: Drop CocoaPods, switch to embedAndSign + SwiftPM bridge
Date: 2026-04-28
Status: Decided, not yet executed
Trigger: Xcode build fails with "Incompatible 'embedAndSign' Task with CocoaPods Dependencies." The Xcode run script calls :composeApp:embedAndSignAppleFrameworkForXcode while the Kotlin CocoaPods plugin is also active — these two iOS framework integration modes are mutually exclusive.
Decision
Remove the Kotlin CocoaPods plugin. Deliver the shared framework via embedAndSign (current Xcode run script stays). Deliver AppAuth-iOS via Swift Package Manager in iosApp.xcodeproj. Move all AppAuth calls out of iosMain Kotlin and behind a Swift bridge injected via Koin.
Why
- One integration mode, fewer moving parts (no Podfile, Pods/, .xcworkspace, no Ruby/CocoaPods gem prerequisite).
- Aligns with where Apple tooling is going (SwiftPM is the strategic direction; CocoaPods is in maintenance).
- AppAuth surface in
iosMainis small and contained — migration is local. - Eliminates the entire class of "cocoapods vs embedAndSign" Xcode build errors.
Cost (what we accept)
OidcClient.ios.kt(~231 lines) is rewritten to call a Swift bridge instead ofcocoapods.AppAuth.*cinterop bindings.iosApp/gains a small Swift class implementing the bridge using AppAuth-iOS APIs directly.- D-01 (PROJECT.md) remains AppAuth-iOS — only the delivery channel changes (CocoaPods → SwiftPM).
Surface area in this repo (scanned)
AppAuth-iOS is used in exactly one place:
composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.ios.kt(231 lines, importscocoapods.AppAuth.*—OIDAuthState,OIDAuthorizationRequest,OIDAuthorizationService,OIDEndSessionRequest,OIDExternalUserAgentIOS,OIDResponseTypeCode, error codes).
SecureAuthStateStore.ios.kt does NOT depend on AppAuth — it serializes OIDAuthState via NSKeyedArchiver. After migration, the serialized blob crosses the bridge as NSData/ByteArray and the Swift side does the archiving. Or we change the on-disk format to JSON of our own AuthState (cleaner; recommended).
Work plan (execute in a fresh session)
1 — Gradle / build config
composeApp/build.gradle.kts:- Remove
id("org.jetbrains.kotlin.native.cocoapods")from theplugins { }block. - Remove the entire
cocoapods { ... }block insidekotlin { }. - Keep
group = "dev.ulfrx.recipe"andversion = "1.0.0"(the comment explaining "required by cocoapods plugin" can be deleted; group is also referenced by Compose Resources package naming — do NOT changegroup). - The framework declaration is already provided by the
recipe.kotlin.multiplatformconvention plugin viaiosTarget.binaries.framework. Verify it setsbaseName = "ComposeApp"andisStatic = true. If not, add thebinaries.framework { baseName = "ComposeApp"; isStatic = true }block to the iOS targets in the convention plugin (or inline in composeApp).
- Remove
gradle/libs.versions.toml: leaveappauth-iosversion entry — repurpose it as the documented SwiftPM pin indocs/authentik-setup.md. Or delete it and put the version only in the iOS project's Package.resolved.
2 — Delete CocoaPods artifacts
- Delete:
iosApp/Podfile,iosApp/Podfile.lock,iosApp/Pods/,iosApp/iosApp.xcworkspace/. - From now on open
iosApp/iosApp.xcodeprojdirectly (not.xcworkspace). - The Xcode run script stays — it already invokes
./gradlew :composeApp:embedAndSignAppleFrameworkForXcode.
3 — Add AppAuth-iOS via SwiftPM in Xcode
- Open
iosApp.xcodeproj→ File → Add Package Dependencies →https://github.com/openid/AppAuth-iOS→ choose "Up to Next Major" from the same major version currently inlibs.versions.toml. - Add
AppAuthproduct to theiosApptarget.
4 — Swift bridge (in iosApp/iosApp/)
- Define a Kotlin interface in
composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/auth/IosAuthBridge.kt:Mark withinterface IosAuthBridge { suspend fun authorize(presentingVc: UIViewController): AuthBridgeResult suspend fun endSession(presentingVc: UIViewController, idToken: String): AuthBridgeResult // refresh, plus serialize/deserialize hooks if you keep OIDAuthState as the persisted blob } sealed class AuthBridgeResult { data class Success(...) : AuthBridgeResult(); data class Error(val kind: ErrorKind, val message: String?) : AuthBridgeResult(); object Cancelled : AuthBridgeResult() }@OptIn(ExperimentalObjCName::class)and@ObjCNameso Swift sees stable names. - Implement in Swift:
iosApp/iosApp/Auth/AuthBridge.swift— usesOIDAuthState,OIDAuthorizationService, etc. Maps AppAuth callbacks → suspending Kotlin viakotlinx.coroutinescontinuation helpers (or callback-style if simpler — pick one and stay consistent). - Decide AuthState persistence format:
- Option A (recommended): Define a Kotlin
AuthTokensdata class (access token, refresh token, id token, expiresAt, scopes). Bridge returns this.SecureAuthStateStore.ios.ktpersists it as JSON via kotlinx.serialization. Removes the last AppAuth dependency from Kotlin and lets you deleteNSKeyedArchiver/NSKeyedUnarchiverplumbing. - Option B: Keep persisting opaque
NSDatablob produced by Swift viaNSKeyedArchiver(rootObject: OIDAuthState). Less rewrite ofSecureAuthStateStore, but Kotlin is now blind to token contents (can't compute expiry locally).
- Option A (recommended): Define a Kotlin
- Wire in Koin from
iosAppentry point (MainViewController.ktor wherever Koin's iOS module starts):single<IosAuthBridge> { IosAuthBridgeImpl() }whereIosAuthBridgeImplis an@ObjCName-annotated Kotlin shim that holds a reference to a Swift-side instance handed over fromiosAppSwift code at startup.
5 — Rewrite OidcClient.ios.kt
- Drop all
cocoapods.AppAuth.*imports. - Inject
IosAuthBridgevia constructor (Koin). - Each
OidcClientmethod becomes a thin call into the bridge + result mapping to the existing commonOidcClientcontract (Cancelled / NetworkError / Failed / Success). - Error code mapping (
OIDErrorCodeUserCanceledAuthorizationFlow,OIDErrorCodeProgramCanceledAuthorizationFlow,OIDErrorCodeNetworkError) now lives in Swift, surfaced asAuthBridgeResult.ErrorKindenum.
6 — Verification
./gradlew :composeApp:linkDebugFrameworkIosSimulatorArm64— Kotlin compiles without cocoapods imports.- Open Xcode, build & run on simulator — no "incompatible task" error.
- Re-run the Phase 02-07 manual UAT (login → welcome → logout → token refresh).
./gradlew check— all existing tests still green;LoginViewModelTest/AuthSessionTestare unaffected (they test common code, not iOS actuals).
7 — Docs / planning updates
.planning/PROJECT.md§ Key Decisions: amend D-01 — "AppAuth-iOS via SwiftPM, called through a Swift bridge fromiosMain. CocoaPods plugin removed 2026-04-28.".planning/research/PITFALLS.md: replace the cocoapods-specific pitfall (if any) with a SwiftPM-bridge pitfall ("Swift bridge instances must be handed in fromiosAppat startup; do not try to instantiate AppAuth from pure Kotlin").docs/authentik-setup.md(or create it): document SwiftPM step for new contributors, AppAuth-iOS version pin, and how to open the project (.xcodeprojdirectly, not.xcworkspace).CLAUDE.md"Tech stack" line: change "Mobile OIDC: AppAuth on Android; ASWebAuthenticationSession wrapper on iOS (KMP interface)" — your current code uses AppAuth-iOS, not ASWebAuthenticationSession; keep AppAuth-iOS but note the SwiftPM + Swift-bridge delivery.
Out of scope for this change
- Phase 02-07 manual UAT — must be re-run after the migration, but on the same auth flow.
- Pre-existing failures already logged in
.planning/phases/02-authentication-foundation/deferred-items.md(Android Robolectric test, iOS ktlint warning).
Rollback
If the SwiftPM bridge proves harder than expected:
git revertthe migration commit(s).- Restore
Podfile, runpod install, reopen.xcworkspace. - The original cocoapods setup is recoverable from git history.
Resume signal
Start a fresh Claude Code session in /Users/rwilk/dev/repo/recipe. Open this file as the briefing. Plan 02-07 stays at the human-verify checkpoint until the migration lands and UAT passes.