---
title: "Wrapping a Swift Package in a Gradle Module: How RevenueCat's KMP SDK Builds purchases-ios Without CocoaPods"
description: "In this article, you'll dive deep into how the SDK wraps a Swift package in a Gradle module."
language: "en"
publishedAt: "2026-09-30T06:39:37.502Z"
updatedAt: "2026-09-30T06:39:37.502Z"
authors:
  - name: "Jaewoong Eum"
    url: "https://www.revenuecat.com/blog/author/jaewoong-eum"
category: "Engineering"
categoryUrl: "https://www.revenuecat.com/blog/engineering"
canonical: "https://www.revenuecat.com/blog/engineering/kmp-swift-gradle"
---

# Wrapping a Swift Package in a Gradle Module: How RevenueCat's KMP SDK Builds purchases-ios Without CocoaPods

In this article, you'll dive deep into how the SDK wraps a Swift package in a Gradle module.

## Table of contents

- [The fundamental problem: cinterop reads Objective-C, and Gradle doesn't build Swift](#the-fundamental-problem-cinterop-reads-objective-c-and-gradle-doesnt-build-swift)
- [Declaring a Swift package: The swiftPackage() DSL](#declaring-a-swift-package-the-swiftpackage-dsl)
- [Reading the package: Letting SwiftPM describe itself](#reading-the-package-letting-swiftpm-describe-itself)
- [From Swift target to Clang module: A header, an archive, and a module map](#from-swift-target-to-clang-module-a-header-an-archive-and-a-module-map)
- [The .def file: A comment that keeps cinterop honest](#the-def-file-a-comment-that-keeps-cinterop-honest)
- [The facade module: A Gradle module with almost no Kotlin](#the-facade-module-a-gradle-module-with-almost-no-kotlin)
- [Build configuration: Compiled once, in release](#build-configuration-compiled-once-in-release)
- [Resources: Shipping a Swift bundle through Compose Resources](#resources-shipping-a-swift-bundle-through-compose-resources)
- [Two Swift targets, two Gradle modules: Sharing types across the boundary](#two-swift-targets-two-gradle-modules-sharing-types-across-the-boundary)
- [What cinterop can't see: Shims around the generated header](#what-cinterop-cant-see-shims-around-the-generated-header)
  - [Forcing a binding with a custom declaration](#forcing-a-binding-with-a-custom-declaration)
  - [Kotlin has no #available](#kotlin-has-no-available)
  - [Bridging APIs that exist only in Swift](#bridging-apis-that-exist-only-in-swift)
- [Conclusion](#conclusion)

Kotlin/Native talks to Apple code through [cinterop](https://kotlinlang.org/docs/native-c-interop.html), the tool that generates Kotlin bindings from C and Objective-C headers, and for most of Kotlin Multiplatform's history, the standard way to hand it an iOS dependency has been the CocoaPods plugin. Without CocoaPods, a Swift package leaves cinterop with nothing to read: Swift has no headers, and Gradle can't build a Swift package by itself.

[RevenueCat's Kotlin Multiplatform SDK](https://github.com/RevenueCat/purchases-kmp) took that route anyway in 3.0.0. It dropped CocoaPods and now compiles the `purchases-ios` Swift package from source inside two Gradle modules, `kn-core` and `kn-ui`, that contain almost no Kotlin. The question worth answering is what those modules actually do: how a Swift package becomes something the Kotlin/Native compiler can bind, cache, and publish.

In this article, you'll dive deep into how the SDK wraps a Swift package in a Gradle module, exploring the `swiftPackage()` DSL, `swift package describe`, the Swift build task, the source hash in the `.def` file, the facade module, the release build configuration, resource bundling, type sharing across modules, and the shims around cinterop.

## **The fundamental problem: cinterop reads Objective-C, and Gradle doesn't build Swift**

The cinterop tool takes its instructions from a definition file, a `.def`, that tells it what to parse and what to link. For an Objective-C library, three entries carry most of the weight:

```
language = Objective-C
modules = MyLibrary
staticLibraries = libMyLibrary.a
```

`language` switches the parser from C to Objective-C, and `modules` names a Clang module, a named set of headers, for cinterop to read. `staticLibraries` names a static library, an `.a` archive of compiled object files, that cinterop copies into the klib it produces. A klib is Kotlin/Native's library format, the counterpart of a JAR on the JVM, and as the [Kotlin documentation](https://kotlinlang.org/docs/native-definition-file.html) puts it, "When using a `klib` like this in your program, the library is linked automatically."

A Swift package provides none of these. Swift has no header files, so there's nothing to parse until you ask the compiler to generate an Objective-C header, and that header only contains the declarations Swift exposes to Objective-C. The package itself contains no Clang module and no archive to link, because SwiftPM, the Swift Package Manager, compiles a package as part of building whatever app depends on it. Even reading the package is a problem: `Package.swift` is a Swift program, and only SwiftPM can evaluate it.

Before 3.0.0, the SDK solved this the way many Kotlin Multiplatform libraries did, with the Kotlin CocoaPods plugin, which hands the build to CocoaPods and Xcode and then points cinterop at what they produce. Looking at the core of the 2.x configuration in the `core` module:

```
cocoapods {
    ios.deploymentTarget = libs.versions.ios.deploymentTarget.core.get()

    framework {
        baseName = "Purchases"
        isStatic = true
    }

    pod("PurchasesHybridCommon") {
        version = libs.versions.revenuecat.common.get()
        extraOpts += listOf("-compiler-option", "-fmodules")
    }
}
```

This works, but every step it hides has a cost. Building the iOS side needed a working CocoaPods installation, apps had to add `PurchasesHybridCommon` to their own Xcode project and keep its version paired with the Kotlin SDK, and parallel Gradle tasks could race each other inside CocoaPods' shared cache. In April 2026, the team fixed a flaky iOS CI job, and the commit message spells the problem out:

Gradle runs podInstallSyntheticIos for core, models, mappings, and revenuecatui in parallel. All four tasks hit the shared CocoaPods cache (~Library/Caches/CocoaPods/Pods/) concurrently, causing Errno::ENOENT race conditions during cp_r.

A month later, 3.0.0 deleted the plugin and the CI workaround with it. What replaced it doesn't hide the build behind another tool: it runs each step as a Gradle task, in about 1,200 lines of convention plugin code under `[build-logic/.../swift](https://github.com/RevenueCat/purchases-kmp/tree/main/build-logic/convention/src/main/kotlin/com/revenuecat/purchases/kmp/buildlogic/swift)`.

JetBrains is working on an official answer too: [SwiftPM import](https://kotlinlang.org/docs/multiplatform/multiplatform-spm-import.html), an Alpha feature that arrived with the Kotlin 2.4.20 release candidates. The SDK builds on Kotlin 2.3.20 with its own implementation, which makes it a readable reference for what any such integration has to handle.

## **Declaring a Swift package: The swiftPackage() DSL**

Everything starts in a module's build file. In SwiftPM, a target is a module inside a package, and `kn-core` declares two Swift targets from its `appleMain` source set: `RevenueCat`, from the `purchases-ios` checkout that the repository tracks as a git submodule under `upstream/`, and `AdditionalSwift`, a small local package you'll meet later. The second declaration shows the shape without optional arguments:

```
appleMain.dependencies {
    swiftPackage(
        path = file("src/swift"),
        target = "AdditionalSwift",
        packageName = "com.revenuecat.purchases.kn.core.additional"
    )
}
```

path points at the directory containing Package.swift, target picks one SwiftPM target to build, and packageName is the Kotlin package the generated bindings land in. It reads like a dependency declaration, but it doesn't add anything to a Gradle configuration. If you examine the function:

```
fun KotlinDependencyHandler.swiftPackage(
    path: File,
    target: String,
    packageName: String,
    customDeclarations: String? = null,
    swiftSettings: SwiftSettings? = null,
) {
    val registry = project.extensions.findByType(SwiftPackageRegistry::class.java)
        ?: error("SwiftPackageRegistry not found. ...")
    val dependency = SwiftDependency(
        packagePath = path,
        target = target,
        packageName = packageName,
        sourceSetName = getSourceSetName(),
        customDeclarations = customDeclarations,
        swiftSettings = swiftSettings,
    )
    registry.add(dependency)
    project.getOrCreateGlobalSwiftRegistry().register(target, project, dependency)
}
```

The function records a `SwiftDependency` in two places, a registry on the current project and a global registry on the root project, and does nothing else. The real configuration happens later, once the build script has finished evaluating.

The one surprising call is `getSourceSetName()`. The build needs to know which source set the declaration came from, because that decides which Kotlin/Native targets receive bindings, but `KotlinDependencyHandler` doesn't expose its source set publicly. The function finds it by reflection:

```
private fun KotlinDependencyHandler.getSourceSetName(): String {
    var current: Class<*>? = this.javaClass
    while (current != null && current != Any::class.java) {
        for (field in current.declaredFields) {
            try {
                field.isAccessible = true
                val value = field.get(this)
                if (value is KotlinSourceSet) {
                    return value.name
                }
            } catch (_: Exception) {
                // Continue to next field
            }
        }
        current = current.superclass
    }
    error("Could not determine source set for swiftPackage(). ...")
}
```

It walks the handler's fields up the class hierarchy until one of them holds a `KotlinSourceSet`. This leans on Kotlin Gradle Plugin internals, and the full error message admits it: "This might be due to a Kotlin Gradle Plugin version incompatibility." The name then feeds a simple mapping, where `appleMain` covers every `ios`, `macos`, `tvos`, and `watchos` target and `iosMain` covers only the `ios` ones. That's why `kn-core`, which declares its packages in `appleMain` and adds watchOS targets, builds Swift for iOS and watchOS, while `kn-ui` declares `RevenueCatUI`, the module that renders RevenueCat's paywalls and Customer Center, in `iosMain` and builds for iOS only.

The global registry adds one rule: a Swift target can have only one owner. Declaring the same target from a second Gradle project fails with "Each Swift target should be owned by exactly one Gradle project." That guarantee is what later lets `kn-ui` find `RevenueCat` by name. Nothing declares that `kn-core` has to be configured first, though. The lookup works because Gradle happens to reach `kn-core` before `kn-ui`.

## **Reading the package: Letting SwiftPM describe itself**

The convention plugin does its work in `afterEvaluate`, after every `swiftPackage()` call has run, and it starts by checking the host operating system. Everything that follows shells out to `xcrun`, which doesn't exist on Linux, so instead of failing configuration there and taking Android builds down with it, the plugin logs "Skipping Swift dependency configuration on non-macOS host" and leaves the module without Apple bindings on that machine.

On macOS, the plugin first needs the package's structure: where each target's sources live, which targets depend on which, and which resources they carry. Rather than parse `Package.swift`, it asks SwiftPM:

```
internal fun Project.getSwiftPackageInfo(packageDir: File): SwiftPackageInfo {
    val result = providers.exec {
        workingDir = packageDir
        commandLine("xcrun", "swift", "package", "describe", "--type", "json")
        isIgnoreExitValue = true
        // Avoids trying to use the iOS SDK to parse the Package.swift when building from Xcode.
        // This environment change is scoped to this subprocess only.
        environment("SDKROOT", "")
    }

    val exitCode = result.result.get().exitValue
    if (exitCode != 0) {
        val stderr = result.standardError.asText.get()
        error("Failed to run 'swift package describe' in $packageDir (exit code $exitCode): $stderr")
    }

    return SwiftPackageInfo.parse(result.standardOutput.asText.get())
}
```

`swift package describe --type json` prints SwiftPM's own model of the package after it evaluates the manifest. The `purchases-ios` manifest reads `CI.xcconfig` and environment variables while it evaluates, so no static parser could reproduce what SwiftPM sees. From the JSON, `SwiftPackageInfo.parse()` keeps each target's `name`, `path`, `target_dependencies`, and `resources`, plus the package's platform deployment targets.

The `SDKROOT` line handles a quieter problem. When Gradle runs from an Xcode build phase, Xcode has already exported `SDKROOT` for the iOS SDK, and SwiftPM would try to use that SDK for a manifest that has to run on the Mac itself. Clearing the variable for this one subprocess keeps builds from Xcode working.

## **From Swift target to Clang module: A header, an archive, and a module map**

For each Kotlin/Native target that includes the declaring source set, the plugin registers a `SwiftBuildTask` with a name like `compileSwiftRevenueCatIosSimulatorArm64`. Its job is to produce what the `.def` file needs: an Objective-C header, a static archive, and a module map. The first step runs `swift build` for one Swift target and one architecture, and each `-Xswiftc` or `-Xcc` in the command forwards the argument after it to the Swift compiler or to Clang:

```
execOperations.exec {
    workingDir = packageDir.get().asFile
    environment("SDKROOT", "")
    commandLine(
        listOf(
            "xcrun", "swift", "build",
            "--target", targetName,
            "--configuration", configValue,
            "--triple", tripleValue,
            "--scratch-path", scratchPath.absolutePath,
            "-Xswiftc", "-sdk",
            "-Xswiftc", sdkPath,
            "-Xcc", "-isysroot",
            "-Xcc", sdkPath,
            "-Xswiftc", "-emit-objc-header",
            "-Xswiftc", "-emit-objc-header-path",
            "-Xswiftc", targetOutputDir.resolve(headerName.get()).absolutePath
        ) + extraSwiftArgs
    )
}
```

A few arguments carry most of the weight:

- `**-triple**`** and **`**sdk**`: both derive from the `KonanTarget`, so `iosSimulatorArm64` builds for `arm64-apple-ios-simulator` against the iPhone Simulator SDK, and `watchosArm64` builds for `arm64_32-apple-watchos`. Each Swift target builds once per Kotlin/Native target.
- `**emit-objc-header-path**`: tells swiftc to write the module's Objective-C header, `RevenueCat-Swift.h`, into the task's output directory. The header holds only what Swift exposes to Objective-C, which is why every Kotlin binding carries an Objective-C name like `RCPurchases` or `RCCustomerInfo`.
- `**-scratch-path**`: gives each Swift target its own scratch directory, the folder where SwiftPM keeps its build files. The section on sharing types explains why.
- `**extraSwiftArgs**`: carries `SwiftSettings`, where each `define("FLAG")` becomes `Xswiftc -D -Xswiftc FLAG`.
`swift build --target` stops at object files and a Swift module. It doesn't produce an archive you can hand to a linker, so the task builds one itself:

```
val buildPath = scratchPath.resolve("$tripleValue/$configValue")
val objectFiles = buildPath.resolve("$targetName.build")
    .walkTopDown()
    .filter { it.extension == "o" }
    .toList()

if (objectFiles.isEmpty()) {
    error("No object files found in ${buildPath.resolve("$targetName.build")}")
}

execOperations.exec {
    commandLine(
        listOf(
            "libtool", "-static", "-o",
            targetOutputDir.resolve(libraryName.get()).absolutePath
        ) + objectFiles.map { it.absolutePath }
    )
}
```

The walk only covers `<Target>.build`, so each archive holds exactly one target's code. Today, `libRevenueCat.a` contains 565 object files and defines the `RCPurchases` class, while `libRevenueCatUI.a` contains 333 and doesn't define it. When your app links both, RevenueCat's code comes from one archive, not two.

The last output is a module map, the file that tells Clang which headers make up a module. SwiftPM writes one of its own deep inside the scratch directory, but it points at the header's default location, and `-emit-objc-header-path` has moved the header elsewhere. So the task writes its own module map next to the header. For `RevenueCat`, the generated file is four lines:

```
module RevenueCat {
    header "RevenueCat-Swift.h"
    export *
}
```

The header, the archive, and the module map now sit together in `build/swift-packages/RevenueCat/<konanTarget>/`, the task's declared output directory. Writing its own module map also gives the build a place to list headers from other targets, which becomes important once two modules share types.

## **The .def file: A comment that keeps cinterop honest**

A second task, `GenerateDefFileTask`, writes the definition file. Looking at the core of its task action:

```
val sourceHash = computeSourceHash()

val baseContent = """
    # sourceHash=$sourceHash
    language = Objective-C
    package = ${packageName.get()}
    modules = ${moduleName.get()}
    staticLibraries = ${libraryName.get()}
    linkerOpts = -L/usr/lib/swift
    linkerOpts.ios_x64 = -L$toolchain/lib/swift/iphonesimulator/
    linkerOpts.ios_arm64 = -L$toolchain/lib/swift/iphoneos/
    linkerOpts.ios_simulator_arm64 = -L$toolchain/lib/swift/iphonesimulator/
    linkerOpts.watchos_arm64 = -L$toolchain/lib/swift/watchos/
    linkerOpts.watchos_device_arm64 = -L$toolchain/lib/swift/watchos/
    linkerOpts.watchos_simulator_arm64 = -L$toolchain/lib/swift/watchsimulator/
""".trimIndent()
```

Most of it maps onto the three entries from earlier: the language, the module from the module map, and the archive. `package` sets the Kotlin package for the bindings, and the `linkerOpts` lines add Swift library search paths for the final link. The interesting line is the first one. `# sourceHash=` is a comment that cinterop ignores, and it's there on purpose:

```
private fun computeSourceHash(): String {
    val sourceDir = swiftSourceDir.get().asFile
    val digest = MessageDigest.getInstance("MD5")

    sourceDir.walkTopDown()
        .filter { it.isFile && it.extension == "swift" }
        .sortedBy { it.relativeTo(sourceDir).path }
        .forEach { file ->
            digest.update(file.relativeTo(sourceDir).path.toByteArray())
            digest.update(file.readBytes())
        }

    return digest.digest().joinToString("") { "%02x".format(it) }
}
```

It hashes every `.swift` file under the target's own source directory, path and contents, in a stable order, so any edit to those files, even to a comment, produces a different `.def` file.

The reason lives in how Gradle decides whether cinterop needs to run: it skips the task as `UP-TO-DATE` unless one of the task's tracked inputs changed. As of Kotlin 2.3.20, `CInteropProcess` tracks the `.def` file, option strings such as `compilerOpts` and `extraOpts`, dependency klibs, include directories registered through the Gradle DSL, and headers listed in a `headers` entry. This build lists a module instead of headers and passes its output directory only as `-I` and `-libraryPath` strings, so nothing inside that directory counts as an input: not the header, not the module map, not the archive.

Skipping cinterop costs more than stale bindings, because cinterop doesn't only read the archive, it copies it. After a build, the cinterop klib contains `default/targets/<target>/included/libRevenueCat.a`, byte for byte the archive the Swift task produced, and when cinterop doesn't run, that copy doesn't change. Here is the pipeline so far. The only tracked path from a Swift edit to the klib runs through the `.def` file:

![](https://cdn.sanity.io/images/c3qnx9b0/production/a0941ac6b87b86de1417de2dc7767b6cc1c8c16e-1896x1284.png)

Replacing the hash with a constant makes the consequence concrete:

1. **A change to a function body**: the Swift task re-runs and writes a new archive, the header comes out byte for byte identical, and cinterop reports `UP-TO-DATE`. The klib keeps the old archive, so your app would link yesterday's Swift code.
1. **A new **`**@objc**`** method**: the method appears in the generated header, and cinterop still reports `UP-TO-DATE`, so it never reaches the Kotlin bindings.
With the hash restored, both edits re-run cinterop, and the embedded archive matches the new one. The obvious alternative, declaring the archive as a cinterop input, is what the team tried. The comment above the hash records why it didn't stick:

We add a hash of the Swift source directory to force cinterop to re-run when Swift source changes, even if the changes are non-public. This is necessary because we found using inputs.file() on the cinterop task directly causes failures when building debug after release (e.g. publishToMavenLocal). This seems related to the cinterop commonizer.

The build logic tests pin this behavior with Gradle TestKit. Each test creates a throwaway Swift package, runs cinterop once, changes one thing, and checks the task outcome. In the implementation change test, the package starts with a `greet()` method that returns `"hello"`:

```
// Act
val original = pkg.target.readSourceFile("TestClass.swift")
// An implementation change does not modify the header.
pkg.target.writeSourceFile(
    "TestClass.swift",
    original.replace("return \"hello\"", "return \"hello world\"")
)
val result = runBuild(pkg.cinteropTaskName)

// Assert
assertEquals(TaskOutcome.SUCCESS, result.task(pkg.cinteropTaskName)?.outcome)
```

Sibling tests cover an edit that only touches a comment, an added file, a deleted file, and the negative case: with no change at all, cinterop must be `UP-TO-DATE`. That last test matters as much as the others, because a hash that changed on every build would turn the fix into a rebuild tax.

The hash has limits worth knowing if you copy it. It covers only the target's own sources, so a change to a dependency target, to the build configuration, or to compiler defines leaves the `.def` file as it was and cinterop `UP-TO-DATE`. Switching the same sources from `debug` to `release`, for example, rebuilds `libAdditionalSwift.a` while the klib keeps the debug copy.

## **The facade module: A Gradle module with almost no Kotlin**

With bindings generated, the module that owns them can be published. `kn-core` has exactly one Kotlin file:

```
@file:Suppress("unused")

package com.revenuecat.purchases.kn.core

/**
 * We need at least 1 Kotlin source file for the kn-core module to be published.
 */
internal object Dummy
```

The substance is in the published artifacts. For the iOS device target in version 3.10.1, Maven Central has:

- `**purchases-kmp-kn-core-iosarm64-3.10.1.klib**`: 3,726 bytes, holding the `Dummy` object.
- `**purchases-kmp-kn-core-iosarm64-3.10.1-cinterop-RevenueCat.klib**`: a zip of about 14 MB, holding the bindings and a 55 MB `libRevenueCat.a` under `default/targets/ios_arm64/included/`.
- `**purchases-kmp-kn-core-iosarm64-3.10.1-cinterop-AdditionalSwift.klib**`: about 60 KB for the local shims.
So a Gradle module with one placeholder object is really a delivery vehicle: a Kotlin/Native library whose payload is compiled Swift. Other modules consume it like any project dependency. `core` and `mappings` add `implementation(projects.knCore)` to `appleMain`, `revenuecatui` adds `knCore` and `knUi` to `iosMain`, and from there Kotlin code imports `com.revenuecat.purchases.kn.core.RCPurchases` and calls straight into `purchases-ios`.

For your app, this is why the Xcode side of the setup disappeared in 3.0.0. The compiled Swift travels inside the klibs and gets linked into your app's framework automatically, so there's no Podfile, no `PurchasesHybridCommon` package in Xcode, and no second version to keep in sync. Installing the SDK comes down to a Gradle dependency in `commonMain`, as the [installation guide](https://www.revenuecat.com/docs/getting-started/installation/kotlin-multiplatform) shows, and the [3.0.0 announcement](https://www.revenuecat.com/blog/engineering/kmp-sdk-3) walks through moving an existing project over.

## **Build configuration: Compiled once, in release**

Publishing has a subtler consequence, which starts with how the Swift build configuration is chosen. Leaving out the validation of the property value, the order of checks looks like this:

```
private fun Project.getSwiftConfiguration(): String {
    val override = findProperty("swiftConfiguration")?.toString()
    if (override != null) return override

    val xcodeConfig = providers.environmentVariable("CONFIGURATION").orNull
    if (xcodeConfig != null) {
        return when (xcodeConfig.lowercase()) {
            "debug" -> "debug"
            "release" -> "release"
            else -> "release"
        }
    }

    val taskNames = gradle.startParameter.taskNames
    val isPublishing = taskNames.any { it.contains("publish", ignoreCase = true) }
    if (isPublishing) return "release"

    return "debug"
}
```

An explicit `-PswiftConfiguration` wins, then Xcode's `CONFIGURATION` variable, where any custom configuration name counts as release, then a check for publishing tasks, and everything else falls back to `debug` for faster local builds. RevenueCat's release job runs a publishing task with neither override, so the `purchases-ios` code inside a published artifact is always a release build, no matter how you configure your app. Every `#if DEBUG` in that Swift code resolves when RevenueCat publishes, not when you build.

That collided with a safeguard in `purchases-ios`. RevenueCat's [Test Store](https://www.revenuecat.com/docs/test-and-launch/sandbox/test-store) lets you run test purchases with a dedicated API key and no App Store Connect or Google Play setup. To keep that key out of production, the iOS SDK deliberately crashes release builds that still use it. With the Swift code always compiled in release, the check would fire during everyday debug runs of a KMP app, so `kn-core` opts out with a compiler flag:

```
swiftSettings = SwiftSettings {
    define("BYPASS_SIMULATED_STORE_RELEASE_CHECK")
}
```

The flag exists in `purchases-ios` for exactly this case, and its comment states the trade off: "Setting this flag means apps shipped to production with a Test Store API key won't be caught at runtime."

The toolchain is frozen the same way. Version 3.0.1 was compiled with Xcode 26.4.1, and some apps failed to link it, with errors about SwiftUICore ([#859](https://github.com/RevenueCat/purchases-kmp/issues/859)), so CI now builds releases with Xcode 16.4. Decisions that a regular Swift app makes on every build, from `#if DEBUG` to the Xcode version, become decisions made once per release when the Swift code ships pre-compiled.

## **Resources: Shipping a Swift bundle through Compose Resources**

`RevenueCatUI` also carries resources: asset catalogs and localized strings that SwiftPM would normally package into a resource bundle. A klib has no place for a SwiftPM bundle, so the plugin borrows a delivery mechanism Kotlin Multiplatform already has. `ProcessSwiftResourcesTask` sorts the resources by type, running asset catalogs through `actool` and keeping localized files in their `.lproj` directories, and the plugin registers the output as a custom Compose Resources directory:

```
with(resourcesExtension) {
    packageOfResClass = "${dependency.packageName}.resources"
    customDirectory(
        sourceSetName = dependency.sourceSetName,
        directoryProvider = processResourcesTask.map { it.outputDir.get() }
    )
}
```

On the Swift side, kn-ui compiles RevenueCatUI with define("COMPOSE_RESOURCES"), which switches the bundle it loads resources from:

```
static var revenueCatUI: Bundle {
    #if COMPOSE_RESOURCES
    return composeResourcesBundle
    #else
    return module
    #endif
}
```

`composeResourcesBundle` resolves a path ending in `composeResources/com.revenuecat.purchases.kn.ui.resources/files`, which is exactly where Compose Resources places files for the package set above. The published `kn-ui` artifact's resource archive contains that directory, with an `Assets.car` and 47 `.lproj` directories.

## **Two Swift targets, two Gradle modules: Sharing types across the boundary**

`RevenueCatUI` depends on `RevenueCat`, and its public API is full of RevenueCat types. A paywall delegate receives an `RCPackage` when a purchase starts and an `RCCustomerInfo` when a restore finishes. Each target belongs to a different Gradle module, and the generated header shows what that means for cinterop. Here is an excerpt from `RevenueCatUI-Swift.h` as Swift 6.3 emits it:

```
#if __has_feature(objc_modules)
@import Foundation;
@import RevenueCat;
@import UIKit;
#endif

@class RCCustomerInfo;
@class NSError;
@class RCStoreTransaction;
```

The header imports the `RevenueCat` module, and each `@class` line is a forward declaration: it says a class exists without describing its methods or properties. Completing them takes either the `RevenueCat` module or the full `@interface` declarations in `RevenueCat-Swift.h`, and both live in another Gradle module's build directory.

The global registry is how `kn-ui` finds them. For each name in the target's `target_dependencies` from SwiftPM, the plugin looks up the owning project and keeps the dependencies owned by other projects. Then it wires each one into the Swift task:

```
moduleDependencies.forEach { dep ->
    val depTaskName = compileSwiftTaskName(dep.dependency.target, kotlinTarget.konanTarget)
    dependsOn(dep.project.tasks.named(depTaskName))

    val depOutputDir = dep.project.layout.buildDirectory
        .dir("swift-packages/${dep.dependency.target}/${kotlinTarget.konanTarget.name}")
    dependencyHeaders.from(depOutputDir.map { it.file(dep.dependency.headerName) })
}
```

The task now runs after kn-core's Swift task for the same Kotlin/Native target, and RevenueCat-Swift.h becomes one of its inputs. At the end of its action, the task copies each dependency header into its own output directory and lists it first in the module map. The one kn-ui generates:

```
module RevenueCatUI {
    header "RevenueCat-Swift.h"
    header "RevenueCatUI-Swift.h"
    export *
}
```

The RevenueCat declarations become part of the `RevenueCatUI` Clang module, placed before the header whose forward declarations they complete. Separately, the cinterop configuration adds each dependency's output directory to the `-I` search path, which is what lets the `@import RevenueCat` line resolve at all.

The code keeps both mechanisms because toolchains disagree about which one they need. The commit that introduced the header merge describes cinterop failing with Xcode 26 and Swift 6.2, where the forward declared types came out as stubs that Kotlin couldn't use. A later commit restored the `-I` paths because removing them had broken Xcode 16. On Xcode 26.5, only the `-I` path is essential: without it, cinterop stops at `fatal error: module 'RevenueCat' not found`, while dropping only the header merge still produces complete bindings. Generated headers change between Swift releases, and carrying both paths keeps cinterop working on either toolchain.

The separate scratch directory per target came out of the same investigation. `-Xswiftc` flags aren't scoped to the target you ask for. SwiftPM passes them to every swiftc invocation in the build, so when `RevenueCatUI` builds, the compile of its `RevenueCat` dependency also writes a header to the path meant for `RevenueCatUI-Swift.h`. That's harmless only when `RevenueCatUI` compiles afterwards and writes last. With one scratch directory shared by both modules, each module's build recompiled RevenueCat with its own flags, so simply re-running both builds could leave RevenueCat's header in that file.

Separate scratch directories stopped that, at a measurable price: after a clean build, RevenueCat's 565 object files exist three times for each iOS target, under `RevenueCat`, `AdditionalSwift`, and `RevenueCatUI`. They narrow the leak rather than close it, though. An edit to a RevenueCat function body recompiles only RevenueCat inside `kn-ui`'s scratch directory, and `RevenueCatUI-Swift.h` then holds RevenueCat's header until something makes `RevenueCatUI` compile again.

Sharing types also leaves a visible mark in Kotlin. `kn-ui`'s cinterop sees the RevenueCat classes in its own module and doesn't depend on `kn-core`'s bindings, so it generates bindings for them too, and `RCPackage` exists twice: as `com.revenuecat.purchases.kn.core.RCPackage` and as `com.revenuecat.purchases.kn.ui.RCPackage`. The paywall delegate in `revenuecatui` shows how the Kotlin side copes:

```
import com.revenuecat.purchases.kn.core.RCPackage
import com.revenuecat.purchases.kn.ui.RCPackage as RCPackageFromKnUi

@Suppress("CAST_NEVER_SUCCEEDS")
override fun paywallViewController(
    controller: RCPaywallViewController,
    didStartPurchaseWithPackage: RCPackageFromKnUi,
) {
    listener?.onPurchaseStarted(
        (didStartPurchaseWithPackage as RCPackage).toPackage()
    )
}
```

The compiler warns that the cast can never succeed, since as far as Kotlin knows, the two classes are unrelated. At runtime, Kotlin/Native checks casts to Objective-C classes with `isKindOfClass:`, both bindings name the same Objective-C class, and the cast goes through, which lets the existing `toPackage()` mapping for `kn-core` types handle the value. The alias, the suppression, and the cast are the cost of the duplicate bindings, paid in a handful of delegate methods.

## **What cinterop can't see: Shims around the generated header**

The generated header decides what cinterop can bind, and some things never reach it in a usable form. The SDK covers them with small shims, one in the `.def` file and the rest in Swift.

### **Forcing a binding with a custom declaration**

Store messages are the first case. The Swift API takes a `Set<StoreMessageType>`, which Objective-C can't express, so `purchases-ios` adds a separate `@objc` overload that takes an untyped `NSSet`:

```
- (void)showStoreMessagesForTypes:(NSSet * _Nonnull)types completion:(void (^ _Nonnull)(void))completion;
```

No Objective-C signature mentions the enum. The header still declares RCStoreMessageType, but in a build without any reference to it, cinterop leaves it out: the bindings lose the RCStoreMessageType typealias and all four of its constants, while classes like RCPurchases are unaffected. kn-core's build file supplies the missing reference:

```
customDeclarations = """
    // Force cinterop binding generation for types otherwise not in the public API
    static inline int __forceBindings(
        enum RCStoreMessageType _1
    ) { return 0; }
""".trimIndent(),
```

The generator appends it to the `.def` file after a `---` separator, a section the Kotlin docs treat as part of the header, which is why the function is `static inline`. Nothing calls it. Its only job is to mention `RCStoreMessageType` in a signature, and with it in place, the `mappings` module can build the `Set<RCStoreMessageType>` that Kotlin passes to `showStoreMessagesForTypes`.

### **Kotlin has no #available**

Swift gates newer APIs with `#available`, roughly what an `SDK_INT` check does on Android, and Kotlin/Native has no equivalent. The `AdditionalSwift` package in `kn-core/src/swift` exists for gaps like this one. It's a small Swift package that depends on `purchases-ios` by path and exposes whatever Kotlin needs through `@objc`. Looking at one of its availability checks:

```
@objc
public class AppleApiAvailability: NSObject {
    @objc
    public func isCodeRedemptionSheetAPIAvailable() -> Bool {
        #if os(tvOS) || os(watchOS) || os(macOS) || targetEnvironment(macCatalyst)
        return false
        #else
        if #available(iOS 14.0, visionOS 1.0, *) {
            return true
        } else {
            return false
        }
        #endif
    }
}
```

Kotlin calls it like any other binding before it touches the gated API:

```
internal actual fun IosPurchases.presentCodeRedemptionSheetIfAvailable() {
    if (AppleApiAvailability().isCodeRedemptionSheetAPIAvailable())
        presentCodeRedemptionSheet()
    else Purchases.logHandler.d(
        tag = "Purchases",
        msg = "`presentCodeRedemptionSheet()` is only available on iOS 14.0 and up."
    )
}
```

### **Bridging APIs that exist only in Swift**

The same package covers APIs that `purchases-ios` offers only in Swift. The ad tracking API lives on `Purchases.shared.adTracker` without an `@objc` form, so `AdTracking.swift` wraps each call in an `@objc` static function. Anything a Kotlin caller needs that the upstream header doesn't express gets a thin wrapper in a package the KMP SDK owns, without adding `@objc` surface to `purchases-ios` itself.

## **Conclusion**

This approach earns its keep when the code you need ships as a Swift package rather than a pod, and when your consumers shouldn't have to open Xcode to add it. If you build something similar, write every input that decides the output into a file Gradle already tracks, as the source hash does for Swift sources, and before trusting the build, open the artifact you publish and confirm that the archive and bindings you expect are really inside.

A Gradle module doesn't need much Kotlin to be useful. `kn-core` is a build recipe with an artifact attached, and most of its engineering answers one question: does Gradle know about every file that decides what ends up in the klib? Each file it doesn't know about is a place where a green build can quietly ship yesterday's code.
