The Problem

Kotlin Multiplatform is fantastic. Sharing business logic between iOS and Android, single source of truth for networking and domain models - the benefits are real.

But local KMP development on iOS? That’s where things get painful. Cmd+click on a KMP type in Xcode and see… nothing. Just a question mark mocking you.

This is the story of how I fixed it.

The Local Development Struggle

The Default: Remote SPM Works Great

Our KMP framework is published to GitHub Packages via KMMBridge. The iOS project consumes it like any other SPM dependency:

// Package.swift
let useLocalKMP: Bool = false  // Default: use remote

if !useLocalKMP {
    coreDependencies.append(.package(url: "https://github.com/our-org/shared-kmp", exact: "0.5.42"))
    appCoreDependencies.append(.product(name: "Shared", package: "shared-kmp"))
}

This works perfectly. Code completion, Cmd+click, indexing - all great. The framework is a proper SPM package, and Xcode handles everything.

The problem starts when you need to iterate on the KMP code locally.

Switching to Local: The Pain Begins

When you’re actively developing the shared Kotlin code, waiting for CI to publish a new version for every change isn’t practical. You need local integration.

Our setup uses a simple flag to switch between remote and local:

let useLocalKMP: Bool = true  // Switch to local development

if useLocalKMP {
    // Link against locally-built framework instead of SPM package
    appCoreLinkerSettings.append(.linkedFramework("Shared"))
}

But now you need to actually build that local framework. The workflow becomes:

cd shared-kmp
./gradlew spmDevBuild -PspmBuildTargets=ios_simulator_arm64

Then open Xcode. Every time you change Kotlin code? Rebuild manually. Forget to rebuild? Stale framework, confusing errors. It’s friction that adds up fast.

The Cache Nightmare

Then came the cache issues. Xcode would cache old framework headers, and after rebuilding KMP, we’d get cryptic errors. Different ones every time:

Module 'Shared' was built with a different Swift version
Header 'Shared-Swift.h' not found
No such module 'Shared'
Failed to build module 'AppCore'; this SDK is not supported

The errors were never consistent. Sometimes it was headers, sometimes dependencies wouldn’t compile, sometimes Xcode just couldn’t find the local package at all. You’d fix one issue, and a different one would appear.

We even created a Fastlane lane to clean KMP-specific caches:

lane :clean_kmp do
  # Find app derived data
  derived_data_base = File.expand_path("~/Library/Developer/Xcode/DerivedData")
  app_derived_dirs = Dir.glob("#{derived_data_base}/MyApp-*")

  app_derived_dirs.each do |derived_dir|
    # Clear precompiled modules
    pcm_pattern = "#{derived_dir}/Build/Intermediates.noindex/SwiftExplicitPrecompiledModules/Shared-*.pcm"
    FileUtils.rm_rf(Dir.glob(pcm_pattern))

    # Clear module cache
    module_cache = "#{derived_dir}/ModuleCache.noindex"
    FileUtils.rm_rf(module_cache) if Dir.exist?(module_cache)
  end
end

It helped… sometimes. The real fix was usually nuking DerivedData entirely:

rm -rf ~/Library/Developer/Xcode/DerivedData/MyApp-*

And even that wasn’t guaranteed. If you were really unlucky, you needed the full ritual: remove DerivedData, clean build folder (Cmd+Shift+K), restart Xcode, circle three times around yourself, throw salt behind your shoulder, perform a moon dance. And even then you’d sometimes end up with “No such module ‘Shared’”.

Frustrated developer

Not exactly a great developer experience.

Automating the Build: Pre-Action Scripts

To automate the build, we added a Scheme Pre-Action following Kotlin’s SPM Local Integration guide:

cd "$SRCROOT/../../shared-kmp"
./gradlew :shared:embedAndSignAppleFrameworkForXcode

Builds worked automatically. No more manual spmDevBuild. Life was better.

Well, most of the time. We still hit occasional issues:

  • Kotlin/Native cache corruption - random serialization errors like IllegalStateException: No module deserializer. Fix: ./gradlew clean in the KMP directory.
  • Gradle daemon running wrong Java version - cryptic failures showing just a version number like 25.0.1. Fix: ./gradlew --stop to restart the daemon with correct Java.
  • Java 17 not being picked up - the pre-action runs in Xcode’s environment, which might not have your shell’s JAVA_HOME.

But at least builds were automated. Progress.

Then we noticed something else was broken: Cmd+click on KMP types showed a question mark. Code completion for Shared types was gone. The indexer couldn’t find the framework.

Confused math lady

Builds worked. Indexing didn’t. Time to investigate.

Trying to Fix Indexing: Post-Actions

“Maybe the indexer needs the framework after the build?” We tried a Post-Action to copy the framework somewhere the indexer might find it.

It worked. Sometimes. Other times it didn’t run at all. Shell scripts in Xcode schemes are notoriously flaky - environment variables aren’t always set, paths can be wrong, and there’s no good way to debug failures.

Time to try a different approach.

Tackling It From the Other Side: Build Phases

In the struggle to find a solution, I went back to the source. I opened the official Kotlin Multiplatform Wizard, generated a fresh project, and looked at how JetBrains sets it up.

Searching for clues

They use Build Phases, not Pre-Actions.

The Direct Integration docs confirm this: add a Run Script build phase named “Compile Kotlin Framework” that runs before “Compile Sources”.

Here’s the key difference: Pre-Actions run outside Xcode’s build graph - they’re just shell scripts that fire before the build starts. Xcode doesn’t track what they produce. The indexer has no idea they even ran.

Build Phases are different. They’re part of the formal build system. Xcode tracks their inputs and outputs. The indexer can see frameworks produced by build phases and index them properly.

Maybe that was our answer?

We tried moving the script to a Build Phase on the main target. Immediate failure:

error: Missing required module 'Shared'

The Swift Package (AppCore) was compiling before our build phase ran. Classic dependency ordering issue.

Why does Direct Integration work for the official wizard but not for us? Because the wizard’s iOS app imports KMP directly. Our architecture has a Swift Package in between - and Swift Packages compile before the main target’s build phases.

We were stuck in a loop: Pre-Actions break indexing, Build Phases break compilation, Post-Actions are unreliable.

Surprised Pikachu

There had to be a better way. To find it, I needed to understand exactly why this was happening.

Understanding the Architecture Problem

If your iOS app directly imports the KMP framework, life is simple. Kotlin’s Direct Integration approach works beautifully - add a Build Phase script, and you’re done.

But our architecture is different:

┌─────────────────────┐
│     MyApp (App)      │
│  imports AppCore   │
└─────────────────────┘

┌─────────────────────┐
│  AppCore (SPM)     │  ← Local Swift Package
│  imports Shared │
└─────────────────────┘

┌─────────────────────┐
│ Shared (KMP)    │  ← Kotlin Multiplatform Framework
└─────────────────────┘

AppCore is a local Swift Package that imports Shared (our KMP framework). This architecture has huge benefits - clean separation, testability, modularity. But it breaks the standard KMP integration.

Why? Because Swift Packages compile before the main app target’s build phases run. If you put the KMP build in a Build Phase:

Build Order (what actually happens):
1. Swift Packages compile → AppCore needs Shared → NOT FOUND!
2. Main target build phases run → KMP builds → TOO LATE!
3. Main target compiles

The solution? Kotlin recommends SPM Local Integration using Scheme Pre-Actions. This runs the KMP build before the entire build starts, so the framework exists when Swift Packages compile.

Builds work. But as we discovered - no code completion. Let’s dig into why.

The Mystery: Why Doesn’t the Indexer Find the Framework?

Xcode’s code completion, Cmd+click navigation, and quick help all come from the indexer (SourceKit). Here’s what I discovered: Xcode maintains two completely separate build directories:

SystemDirectory
Build SystemDerivedData/MyApp-xxx/Build/Products/Debug Dev-iphonesimulator/
IndexerDerivedData/MyApp-xxx/Index.noindex/Build/Products/Debug-iphonesimulator/

Notice anything? The build system uses Debug Dev (our custom configuration), but the indexer uses plain Debug. Two directories. Two different paths.

When you add frameworks through SPM or drag them into Xcode, they get copied to both locations. But embedAndSignAppleFrameworkForXcode? It only outputs to the Build directory.

The indexer looks in its own directory, doesn’t find Shared.framework, and gives up.

Pre-Actions Make It Worse

Scheme Pre-Actions run before the build system even starts. They’re completely outside Xcode’s build graph:

┌─────────────────────────────────────────────────────┐
│ Pre-Action (OUTSIDE build graph)                     │
│ → KMP framework builds                               │
│ → Indexer has no idea this happened                  │
└─────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────┐
│ Xcode Build System                                   │
│ → Builds everything, finds framework                 │
└─────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────┐
│ Xcode Indexer (runs asynchronously)                  │
│ → Looks in Index.noindex/Build/Products/Debug-...    │
│ → Framework not there → No code completion          │
└─────────────────────────────────────────────────────┘

Now that we understand the problem, we can design a solution. We need something that:

  1. Runs before Swift Packages compile (so AppCore can find Shared)
  2. Is part of Xcode’s build graph (so the indexer can track it)
  3. Puts the framework where the indexer expects it (not just where the build system needs it)

The fix came in two parts.

Part 1: Aggregate Target

Instead of a Pre-Action, create an Aggregate Target that builds the KMP framework. Aggregate Targets are part of Xcode’s build graph, and critically, they resolve before Swift Packages compile.

  1. Create the target: File → New → Target → Click “Other” tab (it’s hidden!) → Select “Aggregate”

  2. Add a Run Script Build Phase with the KMP build command

  3. Make your app depend on it: MyApp target → Build Phases → Dependencies → Add “Build KMP Framework”

  4. Use Manual Order in the scheme (critical!): Edit Scheme → Build → Change “Build Order” from “Dependency Order” to “Manual Order”

Why? By default, Xcode runs targets in parallel based on dependencies. But Swift Packages are resolved and compiled independently - they don’t wait for your Aggregate Target! With Manual Order:

Build Order (with Aggregate Target + Manual Order):
1. Aggregate Target builds completely → KMP framework created ✅
2. THEN MyApp target starts → Swift Packages compile → AppCore finds Shared ✅
3. Main target compiles ✅

Without Manual Order, you’ll see “Planning Swift module AppCore” happening while the KMP script is still running. Build fails.

Don’t worry about performance: Manual Order only affects order between targets. Internal parallelization within each target is fully preserved - your Swift files still compile in parallel.

To suppress the deprecation warning, add this build setting:

DISABLE_MANUAL_TARGET_ORDER_BUILD_WARNING = YES

But this alone doesn’t fix indexing. The framework is still only in Build/Products/Debug Dev-iphonesimulator/, not in the indexer’s path.

After building the framework, symlink it to where the indexer expects it:

# Find the DerivedData directory
DERIVED_DATA=$(ls -d "$HOME/Library/Developer/Xcode/DerivedData/MyApp-"* 2>/dev/null | head -1)

if [ -n "$DERIVED_DATA" ]; then
    for config in Debug Release; do
        INDEX_DIR="$DERIVED_DATA/Index.noindex/Build/Products/${config}-iphonesimulator"
        mkdir -p "$INDEX_DIR" 2>/dev/null || true
        ln -sf "${BUILT_PRODUCTS_DIR}/Shared.framework" "$INDEX_DIR/" 2>/dev/null || true
    done
fi

This creates symlinks in both Debug-iphonesimulator and Release-iphonesimulator in the indexer’s path, pointing to the actual framework.

Build. Wait for the indexer. Cmd+click on a KMP type.

It works.

It's working!

The Complete Aggregate Target Script

Here’s the full script with all the gotchas handled:

set -e

# Check if using local KMP
PACKAGE_SWIFT="$SRCROOT/../Packages/Core/Package.swift"
if ! grep -q "let useLocalKMP: Bool = true" "$PACKAGE_SWIFT"; then
    echo "note: useLocalKMP is false, skipping KMP build"
    exit 0
fi

# Set up Java 17 (required for Kotlin)
export JAVA_HOME=$(/usr/libexec/java_home -v 17 2>/dev/null || echo "")
if [ -z "$JAVA_HOME" ]; then
    echo "error: Java 17 not found"
    exit 1
fi

# Map custom configuration to Kotlin build type
if [[ "$CONFIGURATION" == *"Debug"* ]]; then
    export KOTLIN_FRAMEWORK_BUILD_TYPE=Debug
else
    export KOTLIN_FRAMEWORK_BUILD_TYPE=Release
fi

# Aggregate Targets don't have this env var - set it manually
export FRAMEWORKS_FOLDER_PATH="Frameworks"

# Find and build KMP
KMP_DIR="$SRCROOT/../../shared-kmp"
if [ ! -d "$KMP_DIR" ]; then
    echo "error: KMP directory not found at $KMP_DIR"
    exit 1
fi

echo "Building KMP framework (${KOTLIN_FRAMEWORK_BUILD_TYPE})..."
cd "$KMP_DIR"
./gradlew :shared:embedAndSignAppleFrameworkForXcode --no-configuration-cache

# Symlink to indexer path for code completion
DERIVED_DATA=$(ls -d "$HOME/Library/Developer/Xcode/DerivedData/MyApp-"* 2>/dev/null | head -1)
if [ -n "$DERIVED_DATA" ]; then
    for config in Debug Release; do
        INDEX_DIR="$DERIVED_DATA/Index.noindex/Build/Products/${config}-iphonesimulator"
        mkdir -p "$INDEX_DIR" 2>/dev/null || true
        ln -sf "${BUILT_PRODUCTS_DIR}/Shared.framework" "$INDEX_DIR/" 2>/dev/null || true
    done
fi

Gotchas I Hit Along the Way

Aggregate Target Type is Hidden

When creating a new target, “Aggregate” isn’t in the main list. You have to click the “Other” tab in the template picker. I spent way too long looking for it.

SRCROOT is the xcodeproj Directory

$SRCROOT points to where your .xcodeproj lives, not your repo root. If your structure is:

ios-app/
├── MyApp/
│   └── MyApp.xcodeproj  ← SRCROOT is here
├── Packages/
└── ...

Then to reach Packages/, you need $SRCROOT/../Packages/.

User Script Sandboxing Breaks Gradle

Xcode 14+ enables User Script Sandboxing by default. Gradle can’t run in a sandbox. For the Aggregate Target, set ENABLE_USER_SCRIPT_SANDBOXING = NO in Build Settings.

FRAMEWORKS_FOLDER_PATH Not Provided

Aggregate Targets don’t produce app bundles, so Xcode doesn’t set FRAMEWORKS_FOLDER_PATH. The Gradle task needs it, so export it manually:

export FRAMEWORKS_FOLDER_PATH="Frameworks"

Manual Order is Required (Not Just Dependency)

Adding the Aggregate Target as a dependency isn’t enough. Xcode still runs Swift Package compilation in parallel. You must use “Manual Order” in the scheme settings, or your builds will fail intermittently when the Swift Package compiles before the KMP framework exists.

Glob Patterns Don’t Expand in Quotes

This was a fun one. I initially wrote:

INDEX_PRODUCTS="$HOME/Library/Developer/Xcode/DerivedData/MyApp-*/Index.noindex/Build/Products"

Bash doesn’t expand globs inside quotes. This created a literal directory named MyApp-* instead of finding MyApp-edqqakrwzsjhbhemmdmdxvvuozss.

Use ls -d to expand globs properly:

DERIVED_DATA=$(ls -d "$HOME/Library/Developer/Xcode/DerivedData/MyApp-"* 2>/dev/null | head -1)

Gradle Daemon and Java Versions

If KMP builds fail with cryptic errors like just a version number (25.0.1), your Gradle daemon might be running with the wrong Java version.

./gradlew clean does not fix this. You need:

./gradlew --stop

Then rebuild. The daemon will restart with the correct Java version from your JAVA_HOME.

When You Don’t Need This

Simple architectures work fine. If your app target directly imports the KMP framework (no intermediate Swift Packages), use Kotlin’s standard Direct Integration. Add a Build Phase script, and code completion works out of the box.

I have another project with a simpler architecture - the app imports KMP directly, no local Swift Packages in between. Standard Direct Integration works perfectly there. Indexing, code completion, Cmd+click - all work without any tricks.

This solution is specifically for when:

  • You have local Swift Packages that import KMP (the key differentiator)
  • You use custom build configurations (Debug Dev, Release Prod, etc.)
  • You need code completion and Cmd+click to work during local KMP development

If your architecture is App → KMP (direct), you don’t need any of this. If it’s App → Local SPM Package → KMP, keep reading.

Key Takeaways

  1. Xcode’s indexer uses a separate directory - Index.noindex/Build/Products/ vs Build/Products/

  2. Custom configurations break indexing - the indexer uses Debug/Release, not Debug Dev

  3. Pre-Actions are invisible to the indexer - use Aggregate Targets to be part of the build graph

  4. Symlink the framework to the indexer path - this is the key insight that makes it work

  5. Use Manual Order in scheme settings - Dependency Order still runs Swift Packages in parallel with your Aggregate Target

  6. Manual Order only affects target ordering - internal parallelization within each target is preserved

  7. Glob patterns need ls -d to expand - don’t trust quotes to do glob expansion

  8. ./gradlew --stop fixes Java version issues - clean doesn’t restart the daemon

Celebration

Hope this saves you from the same debugging rabbit hole!

References