# 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 `iosMain` is 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 of `cocoapods.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, imports `cocoapods.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 the `plugins { }` block. - Remove the entire `cocoapods { ... }` block inside `kotlin { }`. - Keep `group = "dev.ulfrx.recipe"` and `version = "1.0.0"` (the comment explaining "required by cocoapods plugin" can be deleted; group is also referenced by Compose Resources package naming — do NOT change `group`). - The framework declaration is already provided by the `recipe.kotlin.multiplatform` convention plugin via `iosTarget.binaries.framework`. Verify it sets `baseName = "ComposeApp"` and `isStatic = true`. If not, add the `binaries.framework { baseName = "ComposeApp"; isStatic = true }` block to the iOS targets in the convention plugin (or inline in composeApp). - `gradle/libs.versions.toml`: leave `appauth-ios` version entry — repurpose it as the documented SwiftPM pin in `docs/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.xcodeproj` directly (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 in `libs.versions.toml`. - Add `AppAuth` product to the `iosApp` target. ### 4 — Swift bridge (in `iosApp/iosApp/`) - Define a Kotlin interface in `composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/auth/IosAuthBridge.kt`: ```kotlin interface 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() } ``` Mark with `@OptIn(ExperimentalObjCName::class)` and `@ObjCName` so Swift sees stable names. - Implement in Swift: `iosApp/iosApp/Auth/AuthBridge.swift` — uses `OIDAuthState`, `OIDAuthorizationService`, etc. Maps AppAuth callbacks → suspending Kotlin via `kotlinx.coroutines` continuation helpers (or callback-style if simpler — pick one and stay consistent). - Decide AuthState persistence format: - **Option A (recommended):** Define a Kotlin `AuthTokens` data class (access token, refresh token, id token, expiresAt, scopes). Bridge returns this. `SecureAuthStateStore.ios.kt` persists it as JSON via kotlinx.serialization. Removes the last AppAuth dependency from Kotlin and lets you delete `NSKeyedArchiver`/`NSKeyedUnarchiver` plumbing. - **Option B:** Keep persisting opaque `NSData` blob produced by Swift via `NSKeyedArchiver(rootObject: OIDAuthState)`. Less rewrite of `SecureAuthStateStore`, but Kotlin is now blind to token contents (can't compute expiry locally). - Wire in Koin from `iosApp` entry point (`MainViewController.kt` or wherever Koin's iOS module starts): `single { IosAuthBridgeImpl() }` where `IosAuthBridgeImpl` is an `@ObjCName`-annotated Kotlin shim that holds a reference to a Swift-side instance handed over from `iosApp` Swift code at startup. ### 5 — Rewrite `OidcClient.ios.kt` - Drop all `cocoapods.AppAuth.*` imports. - Inject `IosAuthBridge` via constructor (Koin). - Each `OidcClient` method becomes a thin call into the bridge + result mapping to the existing common `OidcClient` contract (Cancelled / NetworkError / Failed / Success). - Error code mapping (`OIDErrorCodeUserCanceledAuthorizationFlow`, `OIDErrorCodeProgramCanceledAuthorizationFlow`, `OIDErrorCodeNetworkError`) now lives in Swift, surfaced as `AuthBridgeResult.ErrorKind` enum. ### 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` / `AuthSessionTest` are 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 from `iosMain`. 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 from `iosApp` at 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 (`.xcodeproj` directly, 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 revert` the migration commit(s). - Restore `Podfile`, run `pod 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.