Kotlin/Native talks to Apple code through cinterop, 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 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 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.

JetBrains is working on an official answer too: SwiftPM import, 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:

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.
  2. 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 shows, and the 3.0.0 announcement 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 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), 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.