feat(spm): add multi-module Package.swift auto-generation#284
feat(spm): add multi-module Package.swift auto-generation#284hanrw wants to merge 1 commit intotouchlab:mainfrom
Conversation
Add a new root-level plugin `co.touchlab.kmmbridge.spm` that automatically generates Package.swift for multi-module KMP projects. ## New Features - **spmDevBuildAll**: Builds all XCFrameworks locally and generates Package.swift with local paths for development - **kmmBridgePublishAll**: Publishes all modules and generates Package.swift with URLs for distribution - **generatePackageSwift**: Generates Package.swift from published metadata ## Changes - Add `KmmBridgeSpmPlugin` for root-level SPM management - Add `KmmBridgeSpmExtension` for configuration options - Add `SpmModuleMetadata` for JSON metadata exchange between modules - Add `writeSpmMetadata` task to each module for metadata generation - Disable module-level `spmDevBuild` when root SPM plugin is applied - Add comprehensive documentation in docs/SPM_MULTI_MODULE.md ## Benefits - No manual Package.swift editing required - No need for `useCustomPackageFile` or `perModuleVariablesBlock` flags - Unified workflow for both local development and CI publishing - Automatic platform version resolution (takes maximum) - Automatic Swift tools version resolution
faogustavo
left a comment
There was a problem hiding this comment.
LGTM. Things seem to be working locally. I'll clean up my tests and push a branch with this new implementation to github.com/touchlab/KMMBridgeSPMQuickStart
| if (useCustomPackageFile) return // No local dev when using a custom package file | ||
|
|
||
| // Skip if root SPM plugin is applied (use spmDevBuildAll instead) | ||
| val rootHasSpmPlugin = project.rootProject.extensions.findByName("kmmBridgeSpm") != null |
There was a problem hiding this comment.
Nit: Use the constant to prevent breaking if the name changes
| val rootHasSpmPlugin = project.rootProject.extensions.findByName("kmmBridgeSpm") != null | |
| val rootHasSpmPlugin = project.rootProject.extensions.findByName(KmmBridgeSpmPlugin.EXTENSION_NAME) != null |
|
|
||
| // Depend on all module kmmBridgePublish tasks | ||
| kmmBridgeModules.forEach { module -> | ||
| val publishTask = module.tasks.findByName("kmmBridgePublish") |
There was a problem hiding this comment.
This is out of the scope, as it's already implemented this way, but if possible, try to create an object (or something similar) to store the task names. uploadXCFramework, kmmBridgePublish, writeSpmMetadata, etc).
Not a blocker :)
|
Adding copilot just for a second pair of eyes 😅 |
There was a problem hiding this comment.
Pull request overview
This PR adds comprehensive multi-module SPM support to KMMBridge, enabling automatic Package.swift generation for projects with multiple Kotlin Multiplatform modules. The feature eliminates the need for manual Package.swift editing or using custom package file markers.
Changes:
- Added a new root-level Gradle plugin
co.touchlab.kmmbridge.spmthat automatically discovers KMMBridge modules and generates unified Package.swift - Introduced metadata exchange system using JSON files to communicate module information between subprojects and the root plugin
- Extended existing SPM dependency manager to write module metadata and disable conflicting module-level tasks when root plugin is active
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
| kmmbridge/src/main/kotlin/co/touchlab/kmmbridge/spm/KmmBridgeSpmPlugin.kt | Core plugin implementation with task registration, module discovery, and Package.swift generation logic |
| kmmbridge/src/main/kotlin/co/touchlab/kmmbridge/spm/KmmBridgeSpmExtension.kt | Configuration DSL for the root-level SPM plugin with properties for package name, version, output directory, and module filters |
| kmmbridge/src/main/kotlin/co/touchlab/kmmbridge/spm/SpmModuleMetadata.kt | Data model and JSON serialization for module metadata exchange |
| kmmbridge/src/main/kotlin/co/touchlab/kmmbridge/dependencymanager/SpmDependencyManager.kt | Added writeSpmMetadata task and logic to skip module-level spmDevBuild when root plugin is present |
| kmmbridge/build.gradle.kts | Registered new plugin with Gradle plugin portal configuration |
| kmmbridge/src/test/kotlin/co/touchlab/kmmbridge/spm/SpmModuleMetadataTest.kt | Unit tests for metadata serialization, deserialization, and file I/O |
| kmmbridge/src/test/kotlin/co/touchlab/kmmbridge/spm/PackageSwiftGeneratorTest.kt | Unit tests for Package.swift generation, version resolution, and platform aggregation logic |
| docs/SPM_MULTI_MODULE.md | Comprehensive documentation covering usage, configuration, workflows, architecture, and troubleshooting |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| val zipFile = project.zipFilePath() | ||
| val urlFile = project.urlFile | ||
| val metadataFile = File(project.layout.buildDirectory.asFile.get(), SpmModuleMetadata.METADATA_FILE_NAME) | ||
| val platformsMap = parsePlatformsMap(project) |
There was a problem hiding this comment.
The platformsMap is evaluated eagerly during task configuration (outside of doLast), which may cause issues with Gradle's configuration cache and prevent lazy evaluation. Consider moving the parsePlatformsMap call inside the doLast block to ensure it's evaluated at execution time.
| // Depend on all module upload tasks | ||
| kmmBridgeModules.forEach { module -> | ||
| val uploadTask = module.tasks.findByName("uploadXCFramework") | ||
| if (uploadTask != null) { | ||
| dependsOn(uploadTask) | ||
| } | ||
| } |
There was a problem hiding this comment.
The generatePackageSwift task depends on uploadXCFramework, but the metadata it reads is written by the writeSpmMetadata task. While the dependency chain is technically correct (since writeSpmMetadata depends on uploadXCFramework), it would be clearer and more robust to have generatePackageSwift depend directly on writeSpmMetadata tasks from each module. This makes the dependency relationship more explicit and prevents issues if the task structure changes.
| // Default platforms (we could enhance this to read from config) | ||
| val platforms = mapOf("iOS" to "15", "macOS" to "15") | ||
|
|
There was a problem hiding this comment.
The default platforms are hardcoded to iOS 15 and macOS 15, which may not reflect the actual platforms configured in the module. This could result in incorrect platform specifications in the generated Package.swift when using spmDevBuildAll for local development. Consider reading the actual platform configuration from each module's KMMBridge extension instead of using hardcoded defaults.
| // Default platforms (we could enhance this to read from config) | |
| val platforms = mapOf("iOS" to "15", "macOS" to "15") | |
| // Platforms: try to read from the KMMBridge extension, fall back to sensible defaults | |
| val platforms: Map<String, String> = run { | |
| // Prefer an explicit SPM platforms configuration if provided by the extension | |
| val platformsMethod = kmmBridgeExt.javaClass.methods.find { it.name == "getSpmPlatforms" } | |
| ?: kmmBridgeExt.javaClass.methods.find { it.name == "getPlatforms" } | |
| val rawPlatforms = platformsMethod?.invoke(kmmBridgeExt) | |
| val extracted: Map<*, *>? = when (rawPlatforms) { | |
| is org.gradle.api.provider.Property<*> -> rawPlatforms.orNull as? Map<*, *> | |
| is Map<*, *> -> rawPlatforms | |
| else -> null | |
| } | |
| val typed = extracted | |
| ?.entries | |
| ?.mapNotNull { (k, v) -> | |
| val key = k as? String | |
| val value = v as? String | |
| if (key != null && value != null) key to value else null | |
| } | |
| ?.toMap() | |
| // Fall back to previous hardcoded defaults if nothing is configured | |
| typed?.takeIf { it.isNotEmpty() } ?: mapOf("iOS" to "15", "macOS" to "15") | |
| } |
| private fun parsePlatformsMap(project: Project): Map<String, String> { | ||
| val targetPlatforms = TargetPlatformDsl() | ||
| .apply(_targetPlatforms) | ||
| .targetPlatforms | ||
|
|
||
| val platformMap = mutableMapOf<String, String>() | ||
|
|
||
| targetPlatforms.forEach { platform -> | ||
| project.kotlin.targets | ||
| .withType<KotlinNativeTarget>() | ||
| .asSequence() | ||
| .filter { it.konanTarget.family.isAppleFamily } | ||
| .filter { appleTarget -> platform.targets.firstOrNull { it.konanTarget == appleTarget.konanTarget } != null } | ||
| .mapNotNull { it.konanTarget.family.swiftPackagePlatformName } | ||
| .distinct() | ||
| .forEach { platformName -> | ||
| platformMap[platformName] = platform.version.name | ||
| } | ||
| } | ||
|
|
||
| return platformMap | ||
| } |
There was a problem hiding this comment.
If multiple TargetPlatform entries specify the same Swift package platform name (e.g., both iOS and tvOS can map to different versions), the last one wins due to simple map assignment. This could result in incorrect platform version metadata. Consider tracking the maximum version across all occurrences of the same platform, similar to how resolvePlatforms works in KmmBridgeSpmPlugin.
| private fun parsePlatformsMap(project: Project): Map<String, String> { | |
| val targetPlatforms = TargetPlatformDsl() | |
| .apply(_targetPlatforms) | |
| .targetPlatforms | |
| val platformMap = mutableMapOf<String, String>() | |
| targetPlatforms.forEach { platform -> | |
| project.kotlin.targets | |
| .withType<KotlinNativeTarget>() | |
| .asSequence() | |
| .filter { it.konanTarget.family.isAppleFamily } | |
| .filter { appleTarget -> platform.targets.firstOrNull { it.konanTarget == appleTarget.konanTarget } != null } | |
| .mapNotNull { it.konanTarget.family.swiftPackagePlatformName } | |
| .distinct() | |
| .forEach { platformName -> | |
| platformMap[platformName] = platform.version.name | |
| } | |
| } | |
| return platformMap | |
| } | |
| * Parse platforms into a map for metadata JSON. | |
| * Returns a map like {"iOS": "15", "macOS": "15"} | |
| */ | |
| private fun parsePlatformsMap(project: Project): Map<String, String> { | |
| val targetPlatforms = TargetPlatformDsl() | |
| .apply(_targetPlatforms) | |
| .targetPlatforms | |
| val platformMap = mutableMapOf<String, String>() | |
| targetPlatforms.forEach { platform -> | |
| project.kotlin.targets | |
| .withType<KotlinNativeTarget>() | |
| .asSequence() | |
| .filter { it.konanTarget.family.isAppleFamily } | |
| .filter { appleTarget -> platform.targets.firstOrNull { it.konanTarget == appleTarget.konanTarget } != null } | |
| .mapNotNull { it.konanTarget.family.swiftPackagePlatformName } | |
| .distinct() | |
| .forEach { platformName -> | |
| val newVersion = platform.version.name | |
| val existingVersion = platformMap[platformName] | |
| if (existingVersion == null || isNewerSwiftPlatformVersion(newVersion, existingVersion)) { | |
| platformMap[platformName] = newVersion | |
| } | |
| } | |
| } | |
| return platformMap | |
| } | |
| /** | |
| * Compare Swift platform version strings and determine if [candidate] is newer than [current]. | |
| * | |
| * Tries to compare numerically when both versions are simple integer strings (e.g., "13", "15"). | |
| * Falls back to lexicographical comparison otherwise. | |
| */ | |
| private fun isNewerSwiftPlatformVersion(candidate: String, current: String): Boolean { | |
| val candidateInt = candidate.toIntOrNull() | |
| val currentInt = current.toIntOrNull() | |
| return if (candidateInt != null && currentInt != null) { | |
| candidateInt > currentInt | |
| } else { | |
| candidate > current | |
| } | |
| } |
| ".library(name: \"${module.frameworkName}\", targets: [\"${module.frameworkName}\"])" | ||
| } | ||
|
|
||
| val targetsString = modules | ||
| .sortedBy { it.frameworkName } | ||
| .joinToString(",\n ") { module -> | ||
| """.binaryTarget( | ||
| name: "${module.frameworkName}", | ||
| url: "${module.url}", | ||
| checksum: "${module.checksum}" | ||
| )""" |
There was a problem hiding this comment.
String interpolation is used directly without escaping when generating Swift code. If framework names, URLs, or checksums contain special characters like quotes or backslashes, the generated Package.swift could be malformed. Consider adding string escaping for these values to ensure robustness against edge cases.
| ".library(name: \"${module.frameworkName}\", targets: [\"${module.frameworkName}\"])" | ||
| } | ||
|
|
||
| val targetsString = modules | ||
| .sortedBy { it.frameworkName } | ||
| .joinToString(",\n ") { module -> | ||
| """.binaryTarget( | ||
| name: "${module.frameworkName}", | ||
| path: "${module.localPath}" | ||
| )""" |
There was a problem hiding this comment.
String interpolation is used directly without escaping when generating Swift code. If framework names or local paths contain special characters like quotes or backslashes, the generated Package.swift could be malformed. Consider adding string escaping for these values to ensure robustness against edge cases.
| private class PackageSwiftGenerator { | ||
| private val versionComparator = Comparator<String> { v1, v2 -> | ||
| val parts1 = v1.split(".").mapNotNull { it.toIntOrNull() } | ||
| val parts2 = v2.split(".").mapNotNull { it.toIntOrNull() } | ||
| val maxLen = maxOf(parts1.size, parts2.size) | ||
| for (i in 0 until maxLen) { | ||
| val p1 = parts1.getOrElse(i) { 0 } | ||
| val p2 = parts2.getOrElse(i) { 0 } | ||
| if (p1 != p2) return@Comparator p1.compareTo(p2) | ||
| } | ||
| 0 | ||
| } | ||
|
|
||
| fun resolveSwiftToolsVersion(modules: List<SpmModuleMetadata>, defaultVersion: String): String { | ||
| val versions = modules.map { it.swiftToolsVersion }.filter { it.isNotBlank() } | ||
| return if (versions.isNotEmpty()) { | ||
| versions.maxWithOrNull(versionComparator) ?: defaultVersion | ||
| } else { | ||
| defaultVersion | ||
| } | ||
| } | ||
|
|
||
| fun resolvePlatforms(modules: List<SpmModuleMetadata>): Map<String, String> { | ||
| val platformVersions = mutableMapOf<String, MutableList<String>>() | ||
|
|
||
| modules.forEach { module -> | ||
| module.platforms.forEach { (platform, version) -> | ||
| platformVersions.getOrPut(platform) { mutableListOf() }.add(version) | ||
| } | ||
| } | ||
|
|
||
| return platformVersions.mapValues { (_, versions) -> | ||
| versions.maxWithOrNull(versionComparator) ?: versions.first() | ||
| } | ||
| } | ||
|
|
||
| fun generatePackageSwift( | ||
| packageName: String, | ||
| swiftToolsVersion: String, | ||
| modules: List<SpmModuleMetadata> | ||
| ): String { | ||
| val platforms = resolvePlatforms(modules) | ||
| val platformsString = platforms.entries | ||
| .sortedBy { it.key } | ||
| .joinToString(",\n ") { (platform, version) -> | ||
| ".$platform(.v$version)" | ||
| } | ||
|
|
||
| val productsString = modules | ||
| .sortedBy { it.frameworkName } | ||
| .joinToString(",\n ") { module -> | ||
| ".library(name: \"${module.frameworkName}\", targets: [\"${module.frameworkName}\"])" | ||
| } | ||
|
|
||
| val targetsString = modules | ||
| .sortedBy { it.frameworkName } | ||
| .joinToString(",\n ") { module -> | ||
| """.binaryTarget( | ||
| name: "${module.frameworkName}", | ||
| url: "${module.url}", | ||
| checksum: "${module.checksum}" | ||
| )""" | ||
| } | ||
|
|
||
| return """// swift-tools-version:$swiftToolsVersion | ||
| // Generated by KMMBridge - DO NOT EDIT MANUALLY | ||
| // https://github.com/touchlab/KMMBridge | ||
| import PackageDescription | ||
|
|
||
| let package = Package( | ||
| name: "$packageName", | ||
| platforms: [ | ||
| $platformsString | ||
| ], | ||
| products: [ | ||
| $productsString | ||
| ], | ||
| targets: [ | ||
| $targetsString | ||
| ] | ||
| ) | ||
| """ | ||
| } | ||
| } |
There was a problem hiding this comment.
The PackageSwiftGenerator helper class in the test duplicates the version comparison and package generation logic from KmmBridgeSpmPlugin. This creates maintenance burden as changes to the production code won't automatically update the test helper. Consider extracting the common logic into a shared utility class that both the plugin and tests can use, or test the actual plugin methods directly.
Summary
Add a new root-level plugin
co.touchlab.kmmbridge.spmthat automatically generates Package.swift for multi-module KMP projects.New Features
Changes
KmmBridgeSpmPluginfor root-level SPM managementKmmBridgeSpmExtensionfor configuration optionsSpmModuleMetadatafor JSON metadata exchange between moduleswriteSpmMetadatatask to each module for metadata generationspmDevBuildwhen root SPM plugin is appliedBenefits
useCustomPackageFileorperModuleVariablesBlockflags