# 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<IosAuthBridge> { 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.
