diff --git a/docs/SPM_MULTI_MODULE.md b/docs/SPM_MULTI_MODULE.md new file mode 100644 index 00000000..51a8c7f5 --- /dev/null +++ b/docs/SPM_MULTI_MODULE.md @@ -0,0 +1,301 @@ +# Multi-Module SPM Support + +This guide explains how to use KMMBridge to publish multiple KMP modules as a single SPM package with automatic Package.swift generation. + +## Overview + +When you have multiple KMP darwin modules (e.g., `module-a-darwin`, `module-b-darwin`), KMMBridge can automatically: + +1. Discover all modules with KMMBridge configured +2. Build/publish all XCFrameworks +3. Generate a unified `Package.swift` with all modules + +No manual Package.swift editing required! + +## Quick Start + +### 1. Apply the SPM plugin to your root project + +```kotlin +// Root build.gradle.kts +plugins { + id("co.touchlab.kmmbridge.spm") +} + +kmmBridgeSpm { + packageName.set("my-sdk") // Optional, defaults to project name +} +``` + +### 2. Configure each darwin module (simplified) + +```kotlin +// Each darwin module's build.gradle.kts +plugins { + id("co.touchlab.kmmbridge") +} + +kmmbridge { + gitHubReleaseArtifacts() + spm(swiftToolsVersion = "5.9") { + iOS { v("15") } + macOS { v("15") } + } +} +``` + +Note: No need for `useCustomPackageFile` or `perModuleVariablesBlock` - the root plugin handles everything automatically! + +### 3. Run the tasks + +**For local development:** +```bash +./gradlew spmDevBuildAll +``` + +**For CI publishing:** +```bash +./gradlew kmmBridgePublishAll +``` + +## Available Tasks + +| Task | Description | +|------|-------------| +| `spmDevBuildAll` | Builds all XCFrameworks locally and generates Package.swift with local paths | +| `kmmBridgePublishAll` | Publishes all modules to artifact storage and generates Package.swift with URLs | +| `generatePackageSwift` | Generates Package.swift from published module metadata | + +> **Note**: When using the root SPM plugin, the module-level `spmDevBuild` task is automatically disabled to avoid conflicts. Use `spmDevBuildAll` instead for multi-module projects. + +## Configuration Options + +```kotlin +kmmBridgeSpm { + // SPM package name (default: rootProject.name) + packageName.set("my-awesome-sdk") + + // Swift tools version (default: "5.9", or max from modules) + swiftToolsVersion.set("5.9") + + // Output directory (default: rootProject.projectDir) + outputDirectory.set(file(".")) + + // Include only specific modules (default: all KMMBridge modules) + includeModules.set(setOf( + ":module-a:module-a-darwin", + ":module-b:module-b-darwin" + )) + + // Exclude specific modules + excludeModules.set(setOf(":legacy-module")) +} +``` + +## Generated Package.swift + +### Local Development (`spmDevBuildAll`) + +```swift +// swift-tools-version:5.9 +// Generated by KMMBridge (LOCAL DEV) - DO NOT COMMIT +import PackageDescription + +let package = Package( + name: "my-sdk", + platforms: [ + .iOS(.v15), + .macOS(.v15) + ], + products: [ + .library(name: "ModuleA", targets: ["ModuleA"]), + .library(name: "ModuleB", targets: ["ModuleB"]), + ], + targets: [ + .binaryTarget( + name: "ModuleA", + path: "module-a/module-a-darwin/build/XCFrameworks/debug/ModuleA.xcframework" + ), + .binaryTarget( + name: "ModuleB", + path: "module-b/module-b-darwin/build/XCFrameworks/debug/ModuleB.xcframework" + ), + ] +) +``` + +### CI Publishing (`kmmBridgePublishAll`) + +```swift +// swift-tools-version:5.9 +// Generated by KMMBridge - DO NOT EDIT MANUALLY +import PackageDescription + +let package = Package( + name: "my-sdk", + platforms: [ + .iOS(.v15), + .macOS(.v15) + ], + products: [ + .library(name: "ModuleA", targets: ["ModuleA"]), + .library(name: "ModuleB", targets: ["ModuleB"]), + ], + targets: [ + .binaryTarget( + name: "ModuleA", + url: "https://github.com/example/repo/releases/download/1.0.0/ModuleA.xcframework.zip", + checksum: "abc123..." + ), + .binaryTarget( + name: "ModuleB", + url: "https://github.com/example/repo/releases/download/1.0.0/ModuleB.xcframework.zip", + checksum: "def456..." + ), + ] +) +``` + +## GitHub Actions Workflow + +```yaml +name: KMMBridge-Release +on: + workflow_dispatch: + +permissions: + contents: write + +jobs: + publish: + runs-on: macos-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: actions/setup-java@v4 + with: + distribution: "adopt" + java-version: 17 + + - name: Create Release + id: release + uses: softprops/action-gh-release@v2 + with: + draft: true + tag_name: ${{ github.ref_name }} + + - name: Build and Publish + run: | + ./gradlew kmmBridgePublishAll \ + -PENABLE_PUBLISHING=true \ + -PGITHUB_ARTIFACT_RELEASE_ID=${{ steps.release.outputs.id }} \ + -PGITHUB_PUBLISH_TOKEN=${{ secrets.GITHUB_TOKEN }} \ + -PGITHUB_REPO=${{ github.repository }} + + - uses: touchlab/ga-update-release-tag@v1 + with: + tagVersion: ${{ github.ref_name }} +``` + +## How It Works + +### Architecture + +``` +┌─────────────────────────────────────────────────────────┐ +│ Root Project │ +│ ┌─────────────────────────────────────────────────┐ │ +│ │ co.touchlab.kmmbridge.spm plugin │ │ +│ │ - Discovers all KMMBridge modules │ │ +│ │ - Collects metadata from each module │ │ +│ │ - Generates unified Package.swift │ │ +│ └─────────────────────────────────────────────────┘ │ +│ ▲ ▲ ▲ │ +│ │ │ │ │ +│ ┌────────┴──┐ ┌──────┴────┐ ┌────┴──────┐ │ +│ │ Module A │ │ Module B │ │ Module C │ │ +│ │ kmmbridge │ │ kmmbridge │ │ kmmbridge │ │ +│ │ plugin │ │ plugin │ │ plugin │ │ +│ └───────────┘ └───────────┘ └───────────┘ │ +└─────────────────────────────────────────────────────────┘ +``` + +### Flow + +1. **Module Configuration**: Each darwin module configures `kmmbridge { spm { ... } }` +2. **Discovery**: Root plugin discovers all modules with KMMBridge +3. **Build/Publish**: Each module builds its XCFramework and (optionally) publishes +4. **Metadata**: Each module writes metadata JSON with framework info +5. **Generation**: Root plugin collects all metadata and generates Package.swift + +## Migration from Manual Package.swift + +If you're currently using `useCustomPackageFile = true` with manual markers: + +### Before + +```kotlin +// Each module +kmmbridge { + spm(useCustomPackageFile = true, perModuleVariablesBlock = true) { ... } +} + +// Manual Package.swift with markers +// BEGIN KMMBRIDGE VARIABLES BLOCK FOR 'MyModule' (do not edit) +// ... +// END KMMBRIDGE BLOCK FOR 'MyModule' +``` + +### After + +```kotlin +// Root build.gradle.kts +plugins { + id("co.touchlab.kmmbridge.spm") +} + +// Each module (simplified) +kmmbridge { + spm { ... } // No special flags needed! +} + +// Package.swift is auto-generated - delete your manual one! +``` + +## Troubleshooting + +### "No KMMBridge modules found" + +Make sure: +- Each module applies the `co.touchlab.kmmbridge` plugin +- Each module configures `spm { ... }` in the `kmmbridge` block + +### "No module metadata found" + +For `generatePackageSwift`: +- Metadata is created during publishing +- Run `kmmBridgePublishAll` instead, or use `spmDevBuildAll` for local dev + +### "XCFramework not found" + +For `spmDevBuildAll`: +- Make sure XCFrameworks are built first +- The task should auto-depend on assemble tasks, but you can run `./gradlew assembleXCFramework` manually + +## Platform Resolution + +When modules specify different platform versions, the plugin takes the **maximum** version for each platform: + +```kotlin +// Module A: iOS 14, macOS 11 +// Module B: iOS 15, macOS 12 +// Module C: iOS 13, macOS 13 + +// Result: iOS 15, macOS 13 +``` + +## Swift Tools Version Resolution + +Similarly, Swift tools version is resolved to the maximum across all modules, or the configured default if none specified. \ No newline at end of file diff --git a/kmmbridge/build.gradle.kts b/kmmbridge/build.gradle.kts index e2647502..19af8e53 100644 --- a/kmmbridge/build.gradle.kts +++ b/kmmbridge/build.gradle.kts @@ -47,6 +47,24 @@ gradlePlugin { "consume", ) } + register("kmmbridge-spm-plugin") { + id = "co.touchlab.kmmbridge.spm" + implementationClass = "co.touchlab.kmmbridge.spm.KmmBridgeSpmPlugin" + displayName = "KMMBridge SPM Package.swift Generator" + description = "Root-level plugin that auto-generates Package.swift from all KMMBridge modules" + tags = + listOf( + "kmm", + "kotlin", + "multiplatform", + "mobile", + "ios", + "xcode", + "framework", + "spm", + "swift-package-manager", + ) + } } } diff --git a/kmmbridge/src/main/kotlin/co/touchlab/kmmbridge/dependencymanager/SpmDependencyManager.kt b/kmmbridge/src/main/kotlin/co/touchlab/kmmbridge/dependencymanager/SpmDependencyManager.kt index 2fb6ce56..768219e0 100644 --- a/kmmbridge/src/main/kotlin/co/touchlab/kmmbridge/dependencymanager/SpmDependencyManager.kt +++ b/kmmbridge/src/main/kotlin/co/touchlab/kmmbridge/dependencymanager/SpmDependencyManager.kt @@ -15,6 +15,7 @@ package co.touchlab.kmmbridge.dependencymanager import co.touchlab.kmmbridge.TASK_GROUP_NAME import co.touchlab.kmmbridge.dsl.TargetPlatformDsl +import co.touchlab.kmmbridge.spm.SpmModuleMetadata import co.touchlab.kmmbridge.internal.domain.SwiftToolVersion import co.touchlab.kmmbridge.internal.domain.TargetPlatform import co.touchlab.kmmbridge.internal.domain.konanTarget @@ -93,6 +94,43 @@ internal class SpmDependencyManager( project.logger.error(buildPackageFileErrorMessage(packageName, perModuleVariablesBlock)) } + // Task to write module metadata for root-level Package.swift generation + val writeMetadataTask = + project.tasks.register("writeSpmMetadata") { + group = TASK_GROUP_NAME + description = "Writes SPM module metadata for Package.swift generation" + val zipFile = project.zipFilePath() + val urlFile = project.urlFile + val metadataFile = File(project.layout.buildDirectory.asFile.get(), SpmModuleMetadata.METADATA_FILE_NAME) + val platformsMap = parsePlatformsMap(project) + + inputs.files(zipFile, urlFile) + outputs.file(metadataFile) + + @Suppress("ObjectLiteralToLambda") + doLast( + object : Action { + override fun execute(t: Task) { + val checksum = providers.findSpmChecksum(zipFile, projectDir) + val url = urlFile.readText() + + val metadata = SpmModuleMetadata( + frameworkName = packageName, + url = url, + checksum = checksum, + platforms = platformsMap, + swiftToolsVersion = swiftToolVersion.name + ) + + metadata.writeToFile(metadataFile) + project.logger.info("Wrote SPM metadata to ${metadataFile.absolutePath}") + } + }, + ) + } + + writeMetadataTask.configure { dependsOn(uploadTask) } + val updatePackageSwiftTask = project.tasks.register("updatePackageSwift") { group = TASK_GROUP_NAME @@ -135,7 +173,10 @@ internal class SpmDependencyManager( } updatePackageSwiftTask.configure { dependsOn(uploadTask) } - publishRemoteTask.configure { dependsOn(updatePackageSwiftTask) } + publishRemoteTask.configure { + dependsOn(updatePackageSwiftTask) + dependsOn(writeMetadataTask) + } } private fun hasKmmbridgeVariablesSection(swiftPackageFile: File, packageName: String): Boolean { @@ -206,6 +247,10 @@ internal class SpmDependencyManager( fun configureLocalDev(project: Project) { 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 + if (rootHasSpmPlugin) return + val extension = project.kmmBridgeExtension val swiftToolVersion = SwiftToolVersion.of(_swiftToolVersion) @@ -265,6 +310,33 @@ internal class SpmDependencyManager( .map { platformName -> ".$platformName(.v${platform.version.name})" } .toList() }.joinToString(separator = ",\n") + + /** + * Parse platforms into a map for metadata JSON. + * Returns a map like {"iOS": "15", "macOS": "15"} + */ + private fun parsePlatformsMap(project: Project): Map { + val targetPlatforms = TargetPlatformDsl() + .apply(_targetPlatforms) + .targetPlatforms + + val platformMap = mutableMapOf() + + targetPlatforms.forEach { platform -> + project.kotlin.targets + .withType() + .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 + } } /** diff --git a/kmmbridge/src/main/kotlin/co/touchlab/kmmbridge/spm/KmmBridgeSpmExtension.kt b/kmmbridge/src/main/kotlin/co/touchlab/kmmbridge/spm/KmmBridgeSpmExtension.kt new file mode 100644 index 00000000..1cb5d5c5 --- /dev/null +++ b/kmmbridge/src/main/kotlin/co/touchlab/kmmbridge/spm/KmmBridgeSpmExtension.kt @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2024 Touchlab. + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ + +package co.touchlab.kmmbridge.spm + +import org.gradle.api.provider.Property +import org.gradle.api.provider.SetProperty +import java.io.File + +/** + * Extension for configuring SPM Package.swift generation at the root project level. + * + * Example usage: + * ```kotlin + * // Root build.gradle.kts + * plugins { + * id("co.touchlab.kmmbridge.spm") + * } + * + * kmmBridgeSpm { + * packageName = "MyAwesomeSDK" // Optional: defaults to rootProject.name + * } + * ``` + */ +interface KmmBridgeSpmExtension { + /** + * The name of the SPM package. Defaults to the root project name. + */ + val packageName: Property + + /** + * The Swift tools version for Package.swift. Defaults to the maximum version + * specified by any module, or "5.9" if none specified. + */ + val swiftToolsVersion: Property + + /** + * Output path for Package.swift. Defaults to the root project directory. + */ + val outputDirectory: Property + + /** + * Explicitly include only these module paths. If empty, all KMMBridge modules are included. + * Example: setOf(":openai-client:openai-client-darwin", ":anthropic-client:anthropic-client-darwin") + */ + val includeModules: SetProperty + + /** + * Exclude these module paths from Package.swift generation. + * Example: setOf(":legacy-module") + */ + val excludeModules: SetProperty +} \ No newline at end of file diff --git a/kmmbridge/src/main/kotlin/co/touchlab/kmmbridge/spm/KmmBridgeSpmPlugin.kt b/kmmbridge/src/main/kotlin/co/touchlab/kmmbridge/spm/KmmBridgeSpmPlugin.kt new file mode 100644 index 00000000..75b87b8d --- /dev/null +++ b/kmmbridge/src/main/kotlin/co/touchlab/kmmbridge/spm/KmmBridgeSpmPlugin.kt @@ -0,0 +1,431 @@ +/* + * Copyright (c) 2024 Touchlab. + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ + +package co.touchlab.kmmbridge.spm + +import co.touchlab.kmmbridge.TASK_GROUP_NAME +import org.gradle.api.Action +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.api.Task +import org.gradle.kotlin.dsl.create +import org.gradle.kotlin.dsl.getByType +import java.io.File + +/** + * Root-level plugin for generating Package.swift from all KMMBridge modules. + * + * This plugin automatically discovers all subprojects that use KMMBridge with SPM, + * collects their metadata after publishing, and generates a unified Package.swift. + * + * Usage: + * ```kotlin + * // Root build.gradle.kts + * plugins { + * id("co.touchlab.kmmbridge.spm") + * } + * + * kmmBridgeSpm { + * packageName = "MySDK" // Optional + * } + * ``` + */ +class KmmBridgeSpmPlugin : Plugin { + + companion object { + const val EXTENSION_NAME = "kmmBridgeSpm" + const val GENERATE_TASK_NAME = "generatePackageSwift" + const val PUBLISH_ALL_TASK_NAME = "kmmBridgePublishAll" + const val SPM_DEV_BUILD_ALL_TASK_NAME = "spmDevBuildAll" + } + + override fun apply(project: Project): Unit = with(project) { + if (project != project.rootProject) { + logger.warn("KmmBridgeSpmPlugin should be applied to the root project only") + } + + val extension = extensions.create(EXTENSION_NAME) + + // Set conventions (defaults) + extension.packageName.convention(project.name) + extension.swiftToolsVersion.convention("5.9") + extension.outputDirectory.convention(project.projectDir) + extension.includeModules.convention(emptySet()) + extension.excludeModules.convention(emptySet()) + + // Register tasks after all projects are evaluated + gradle.projectsEvaluated { + registerTasks(project, extension) + } + } + + private fun registerTasks(project: Project, extension: KmmBridgeSpmExtension) { + val kmmBridgeModules = findKmmBridgeModules(project, extension) + + if (kmmBridgeModules.isEmpty()) { + project.logger.warn("No KMMBridge modules with SPM found. Make sure subprojects apply 'co.touchlab.kmmbridge' and configure spm().") + return + } + + project.logger.info("Found ${kmmBridgeModules.size} KMMBridge modules: ${kmmBridgeModules.map { it.path }}") + + // Register generatePackageSwift task + val generateTask = project.tasks.register(GENERATE_TASK_NAME) { + group = TASK_GROUP_NAME + description = "Generates Package.swift from all KMMBridge module metadata" + + // Depend on all module upload tasks + kmmBridgeModules.forEach { module -> + val uploadTask = module.tasks.findByName("uploadXCFramework") + if (uploadTask != null) { + dependsOn(uploadTask) + } + } + + val outputDir = extension.outputDirectory.get() + val packageName = extension.packageName.get() + val swiftToolsVersion = extension.swiftToolsVersion.get() + val moduleProjects = kmmBridgeModules.toList() + + outputs.file(File(outputDir, "Package.swift")) + + @Suppress("ObjectLiteralToLambda") + doLast(object : Action { + override fun execute(t: Task) { + val metadata = collectMetadata(moduleProjects) + if (metadata.isEmpty()) { + project.logger.warn("No module metadata found. Make sure modules have been published.") + return + } + + val packageSwift = generatePackageSwift( + packageName = packageName, + swiftToolsVersion = resolveSwiftToolsVersion(metadata, swiftToolsVersion), + modules = metadata + ) + + val outputFile = File(outputDir, "Package.swift") + outputFile.writeText(packageSwift) + project.logger.lifecycle("Generated Package.swift with ${metadata.size} modules at ${outputFile.absolutePath}") + } + }) + } + + // Register kmmBridgePublishAll task that does everything + project.tasks.register(PUBLISH_ALL_TASK_NAME) { + group = TASK_GROUP_NAME + description = "Publishes all KMMBridge modules and generates Package.swift" + + // Depend on all module kmmBridgePublish tasks + kmmBridgeModules.forEach { module -> + val publishTask = module.tasks.findByName("kmmBridgePublish") + if (publishTask != null) { + dependsOn(publishTask) + } + } + + // Then generate Package.swift + finalizedBy(generateTask) + } + + // Register spmDevBuildAll task for local development + project.tasks.register(SPM_DEV_BUILD_ALL_TASK_NAME) { + group = TASK_GROUP_NAME + description = "Builds all XCFrameworks locally and generates Package.swift with local paths" + + // Depend on all module XCFramework assemble tasks + kmmBridgeModules.forEach { module -> + val assembleTask = module.tasks.findByName("assembleXCFramework") + ?: module.tasks.findByName("assembleDebugXCFramework") + if (assembleTask != null) { + dependsOn(assembleTask) + } + } + + val outputDir = extension.outputDirectory.get() + val packageName = extension.packageName.get() + val swiftToolsVersion = extension.swiftToolsVersion.get() + val moduleProjects = kmmBridgeModules.toList() + val rootDir = project.projectDir + + outputs.file(File(outputDir, "Package.swift")) + + @Suppress("ObjectLiteralToLambda") + doLast(object : Action { + override fun execute(t: Task) { + val localModules = collectLocalModuleInfo(moduleProjects, rootDir) + if (localModules.isEmpty()) { + project.logger.warn("No local XCFrameworks found. Make sure modules have been built.") + return + } + + val packageSwift = generateLocalPackageSwift( + packageName = packageName, + swiftToolsVersion = swiftToolsVersion, + modules = localModules + ) + + val outputFile = File(outputDir, "Package.swift") + outputFile.writeText(packageSwift) + project.logger.lifecycle("Generated local Package.swift with ${localModules.size} modules at ${outputFile.absolutePath}") + } + }) + } + } + + /** + * Data class for local module info (used for spmDevBuildAll). + */ + private data class LocalModuleInfo( + val frameworkName: String, + val localPath: String, + val platforms: Map + ) + + /** + * Collect local module info from built XCFrameworks. + */ + private fun collectLocalModuleInfo(modules: List, rootDir: File): List { + return modules.mapNotNull { module -> + val kmmBridgeExt = module.extensions.findByName("kmmbridge") ?: return@mapNotNull null + + // Get framework name from extension + val frameworkNameProp = kmmBridgeExt.javaClass.methods + .find { it.name == "getFrameworkName" } + ?.invoke(kmmBridgeExt) + + val frameworkName = when (frameworkNameProp) { + is org.gradle.api.provider.Property<*> -> frameworkNameProp.orNull?.toString() + else -> null + } ?: return@mapNotNull null + + // Find XCFramework in build directory + val buildDir = module.layout.buildDirectory.asFile.get() + val debugXcFramework = File(buildDir, "XCFrameworks/debug/$frameworkName.xcframework") + val releaseXcFramework = File(buildDir, "XCFrameworks/release/$frameworkName.xcframework") + + val xcFrameworkDir = when { + debugXcFramework.exists() -> debugXcFramework + releaseXcFramework.exists() -> releaseXcFramework + else -> { + module.logger.warn("XCFramework not found for $frameworkName in ${buildDir.absolutePath}") + return@mapNotNull null + } + } + + // Calculate relative path from root + val relativePath = rootDir.toPath().relativize(xcFrameworkDir.toPath()).toString() + + // Default platforms (we could enhance this to read from config) + val platforms = mapOf("iOS" to "15", "macOS" to "15") + + LocalModuleInfo( + frameworkName = frameworkName, + localPath = relativePath, + platforms = platforms + ) + } + } + + /** + * Generate Package.swift with local paths for development. + */ + private fun generateLocalPackageSwift( + packageName: String, + swiftToolsVersion: String, + modules: List + ): String { + val platforms = modules.flatMap { it.platforms.entries } + .groupBy({ it.key }, { it.value }) + .mapValues { (_, versions) -> versions.maxWithOrNull(versionComparator) ?: versions.first() } + + 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}", + path: "${module.localPath}" + )""" + } + + return """// swift-tools-version:$swiftToolsVersion +// Generated by KMMBridge (LOCAL DEV) - DO NOT COMMIT +// https://github.com/touchlab/KMMBridge +import PackageDescription + +let package = Package( + name: "$packageName", + platforms: [ + $platformsString + ], + products: [ + $productsString + ], + targets: [ + $targetsString + ] +) +""" + } + + /** + * Find all subprojects that have KMMBridge with SPM configured. + */ + private fun findKmmBridgeModules(project: Project, extension: KmmBridgeSpmExtension): Set { + val includeModules = extension.includeModules.get() + val excludeModules = extension.excludeModules.get() + + return project.subprojects.filter { subproject -> + // Check if this module has KMMBridge extension + val hasKmmBridge = try { + subproject.extensions.findByName("kmmbridge") != null + } catch (e: Exception) { + false + } + + if (!hasKmmBridge) return@filter false + + // Check include/exclude filters + val modulePath = subproject.path + val isIncluded = includeModules.isEmpty() || includeModules.contains(modulePath) + val isExcluded = excludeModules.contains(modulePath) + + isIncluded && !isExcluded + }.toSet() + } + + /** + * Collect metadata from all modules. + */ + private fun collectMetadata(modules: List): List { + return modules.mapNotNull { module -> + val metadataFile = File(module.layout.buildDirectory.asFile.get(), SpmModuleMetadata.METADATA_FILE_NAME) + if (metadataFile.exists()) { + try { + SpmModuleMetadata.fromFile(metadataFile) + } catch (e: Exception) { + module.logger.warn("Failed to read metadata from ${metadataFile.absolutePath}: ${e.message}") + null + } + } else { + module.logger.warn("Metadata file not found: ${metadataFile.absolutePath}") + null + } + } + } + + /** + * Resolve the Swift tools version to use. + * Uses the maximum version from all modules, or the configured default. + */ + private fun resolveSwiftToolsVersion(modules: List, defaultVersion: String): String { + val versions = modules.map { it.swiftToolsVersion }.filter { it.isNotBlank() } + return if (versions.isNotEmpty()) { + versions.maxWithOrNull(versionComparator) ?: defaultVersion + } else { + defaultVersion + } + } + + private val versionComparator = Comparator { 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 + } + + /** + * Generate the complete Package.swift content. + */ + private fun generatePackageSwift( + packageName: String, + swiftToolsVersion: String, + modules: List + ): 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 + ] +) +""" + } + + /** + * Resolve platforms by taking the maximum version for each platform across all modules. + */ + private fun resolvePlatforms(modules: List): Map { + val platformVersions = mutableMapOf>() + + modules.forEach { module -> + module.platforms.forEach { (platform, version) -> + platformVersions.getOrPut(platform) { mutableListOf() }.add(version) + } + } + + return platformVersions.mapValues { (_, versions) -> + versions.maxWithOrNull(versionComparator) ?: versions.first() + } + } +} \ No newline at end of file diff --git a/kmmbridge/src/main/kotlin/co/touchlab/kmmbridge/spm/SpmModuleMetadata.kt b/kmmbridge/src/main/kotlin/co/touchlab/kmmbridge/spm/SpmModuleMetadata.kt new file mode 100644 index 00000000..0a2b094e --- /dev/null +++ b/kmmbridge/src/main/kotlin/co/touchlab/kmmbridge/spm/SpmModuleMetadata.kt @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2024 Touchlab. + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ + +package co.touchlab.kmmbridge.spm + +import com.google.gson.Gson +import com.google.gson.GsonBuilder +import java.io.File + +/** + * Metadata for a single KMMBridge module that will be included in Package.swift. + * Each module writes this metadata after uploading its XCFramework. + */ +data class SpmModuleMetadata( + val frameworkName: String, + val url: String, + val checksum: String, + val platforms: Map, // e.g., {"iOS": "15", "macOS": "15"} + val swiftToolsVersion: String +) { + companion object { + private val gson: Gson = GsonBuilder().setPrettyPrinting().create() + + const val METADATA_FILE_NAME = "kmmbridge-spm-metadata.json" + + fun fromJson(json: String): SpmModuleMetadata = gson.fromJson(json, SpmModuleMetadata::class.java) + + fun fromFile(file: File): SpmModuleMetadata = fromJson(file.readText()) + } + + fun toJson(): String = gson.toJson(this) + + fun writeToFile(file: File) { + file.parentFile?.mkdirs() + file.writeText(toJson()) + } +} \ No newline at end of file diff --git a/kmmbridge/src/test/kotlin/co/touchlab/kmmbridge/spm/PackageSwiftGeneratorTest.kt b/kmmbridge/src/test/kotlin/co/touchlab/kmmbridge/spm/PackageSwiftGeneratorTest.kt new file mode 100644 index 00000000..1c52bd0d --- /dev/null +++ b/kmmbridge/src/test/kotlin/co/touchlab/kmmbridge/spm/PackageSwiftGeneratorTest.kt @@ -0,0 +1,260 @@ +/* + * Copyright (c) 2024 Touchlab. + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ + +package co.touchlab.kmmbridge.spm + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class PackageSwiftGeneratorTest { + + // Helper class to test Package.swift generation without Gradle + private class PackageSwiftGenerator { + private val versionComparator = Comparator { 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, 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): Map { + val platformVersions = mutableMapOf>() + + 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 + ): 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 + ] +) +""" + } + } + + private val generator = PackageSwiftGenerator() + + @Test + fun `generates Package swift for single module`() { + val modules = listOf( + SpmModuleMetadata( + frameworkName = "MyFramework", + url = "https://example.com/MyFramework.xcframework.zip", + checksum = "abc123", + platforms = mapOf("iOS" to "15"), + swiftToolsVersion = "5.9" + ) + ) + + val result = generator.generatePackageSwift("my-sdk", "5.9", modules) + + assertTrue(result.contains("// swift-tools-version:5.9")) + assertTrue(result.contains("name: \"my-sdk\"")) + assertTrue(result.contains(".library(name: \"MyFramework\", targets: [\"MyFramework\"])")) + assertTrue(result.contains("name: \"MyFramework\"")) + assertTrue(result.contains("url: \"https://example.com/MyFramework.xcframework.zip\"")) + assertTrue(result.contains("checksum: \"abc123\"")) + assertTrue(result.contains(".iOS(.v15)")) + } + + @Test + fun `generates Package swift for multiple modules`() { + val modules = listOf( + SpmModuleMetadata( + frameworkName = "OpenAIClient", + url = "https://example.com/OpenAIClient.xcframework.zip", + checksum = "checksum1", + platforms = mapOf("iOS" to "15", "macOS" to "12"), + swiftToolsVersion = "5.9" + ), + SpmModuleMetadata( + frameworkName = "AnthropicClient", + url = "https://example.com/AnthropicClient.xcframework.zip", + checksum = "checksum2", + platforms = mapOf("iOS" to "15", "macOS" to "12"), + swiftToolsVersion = "5.9" + ) + ) + + val result = generator.generatePackageSwift("openai-kotlin", "5.9", modules) + + // Check both modules are included (sorted alphabetically) + assertTrue(result.contains(".library(name: \"AnthropicClient\", targets: [\"AnthropicClient\"])")) + assertTrue(result.contains(".library(name: \"OpenAIClient\", targets: [\"OpenAIClient\"])")) + assertTrue(result.contains("name: \"AnthropicClient\"")) + assertTrue(result.contains("name: \"OpenAIClient\"")) + + // Check platforms + assertTrue(result.contains(".iOS(.v15)")) + assertTrue(result.contains(".macOS(.v12)")) + } + + @Test + fun `resolves maximum swift tools version`() { + val modules = listOf( + SpmModuleMetadata("A", "", "", emptyMap(), "5.7"), + SpmModuleMetadata("B", "", "", emptyMap(), "5.9"), + SpmModuleMetadata("C", "", "", emptyMap(), "5.8") + ) + + val version = generator.resolveSwiftToolsVersion(modules, "5.5") + + assertEquals("5.9", version) + } + + @Test + fun `uses default swift tools version when none specified`() { + val modules = listOf( + SpmModuleMetadata("A", "", "", emptyMap(), ""), + SpmModuleMetadata("B", "", "", emptyMap(), "") + ) + + val version = generator.resolveSwiftToolsVersion(modules, "5.9") + + assertEquals("5.9", version) + } + + @Test + fun `resolves maximum platform versions across modules`() { + val modules = listOf( + SpmModuleMetadata("A", "", "", mapOf("iOS" to "14", "macOS" to "11"), "5.9"), + SpmModuleMetadata("B", "", "", mapOf("iOS" to "15", "macOS" to "12"), "5.9"), + SpmModuleMetadata("C", "", "", mapOf("iOS" to "13", "macOS" to "13"), "5.9") + ) + + val platforms = generator.resolvePlatforms(modules) + + assertEquals("15", platforms["iOS"]) + assertEquals("13", platforms["macOS"]) + } + + @Test + fun `handles modules with different platforms`() { + val modules = listOf( + SpmModuleMetadata("A", "", "", mapOf("iOS" to "15"), "5.9"), + SpmModuleMetadata("B", "", "", mapOf("macOS" to "12"), "5.9"), + SpmModuleMetadata("C", "", "", mapOf("iOS" to "14", "tvOS" to "15"), "5.9") + ) + + val platforms = generator.resolvePlatforms(modules) + + assertEquals("15", platforms["iOS"]) + assertEquals("12", platforms["macOS"]) + assertEquals("15", platforms["tvOS"]) + } + + @Test + fun `version comparator handles different version formats`() { + val modules = listOf( + SpmModuleMetadata("A", "", "", emptyMap(), "5.7"), + SpmModuleMetadata("B", "", "", emptyMap(), "5.10"), + SpmModuleMetadata("C", "", "", emptyMap(), "5.9") + ) + + val version = generator.resolveSwiftToolsVersion(modules, "5.5") + + assertEquals("5.10", version) // 5.10 > 5.9 > 5.7 + } + + @Test + fun `generated Package swift has correct structure`() { + val modules = listOf( + SpmModuleMetadata( + frameworkName = "TestModule", + url = "https://test.com/TestModule.zip", + checksum = "test123", + platforms = mapOf("iOS" to "15"), + swiftToolsVersion = "5.9" + ) + ) + + val result = generator.generatePackageSwift("test-package", "5.9", modules) + + // Verify structure order + val toolsVersionIndex = result.indexOf("swift-tools-version") + val importIndex = result.indexOf("import PackageDescription") + val packageIndex = result.indexOf("let package = Package") + val platformsIndex = result.indexOf("platforms:") + val productsIndex = result.indexOf("products:") + val targetsIndex = result.indexOf("targets:") + + assertTrue(toolsVersionIndex < importIndex) + assertTrue(importIndex < packageIndex) + assertTrue(packageIndex < platformsIndex) + assertTrue(platformsIndex < productsIndex) + assertTrue(productsIndex < targetsIndex) + } +} \ No newline at end of file diff --git a/kmmbridge/src/test/kotlin/co/touchlab/kmmbridge/spm/SpmModuleMetadataTest.kt b/kmmbridge/src/test/kotlin/co/touchlab/kmmbridge/spm/SpmModuleMetadataTest.kt new file mode 100644 index 00000000..90877b3c --- /dev/null +++ b/kmmbridge/src/test/kotlin/co/touchlab/kmmbridge/spm/SpmModuleMetadataTest.kt @@ -0,0 +1,121 @@ +/* + * Copyright (c) 2024 Touchlab. + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ + +package co.touchlab.kmmbridge.spm + +import java.io.File +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class SpmModuleMetadataTest { + + @Test + fun `toJson creates valid JSON`() { + val metadata = SpmModuleMetadata( + frameworkName = "TestFramework", + url = "https://example.com/TestFramework.xcframework.zip", + checksum = "abc123def456", + platforms = mapOf("iOS" to "15", "macOS" to "12"), + swiftToolsVersion = "5.9" + ) + + val json = metadata.toJson() + + assertTrue(json.contains("\"frameworkName\": \"TestFramework\"")) + assertTrue(json.contains("\"url\": \"https://example.com/TestFramework.xcframework.zip\"")) + assertTrue(json.contains("\"checksum\": \"abc123def456\"")) + assertTrue(json.contains("\"swiftToolsVersion\": \"5.9\"")) + assertTrue(json.contains("\"iOS\": \"15\"")) + assertTrue(json.contains("\"macOS\": \"12\"")) + } + + @Test + fun `fromJson parses JSON correctly`() { + val json = """ + { + "frameworkName": "MyFramework", + "url": "https://github.com/example/repo/releases/download/1.0.0/MyFramework.xcframework.zip", + "checksum": "sha256checksum123", + "platforms": { + "iOS": "14", + "macOS": "11" + }, + "swiftToolsVersion": "5.7" + } + """.trimIndent() + + val metadata = SpmModuleMetadata.fromJson(json) + + assertEquals("MyFramework", metadata.frameworkName) + assertEquals("https://github.com/example/repo/releases/download/1.0.0/MyFramework.xcframework.zip", metadata.url) + assertEquals("sha256checksum123", metadata.checksum) + assertEquals(mapOf("iOS" to "14", "macOS" to "11"), metadata.platforms) + assertEquals("5.7", metadata.swiftToolsVersion) + } + + @Test + fun `roundtrip serialization works`() { + val original = SpmModuleMetadata( + frameworkName = "RoundtripTest", + url = "https://example.com/test.zip", + checksum = "checksum123", + platforms = mapOf("iOS" to "15", "macOS" to "12", "tvOS" to "15"), + swiftToolsVersion = "5.9" + ) + + val json = original.toJson() + val restored = SpmModuleMetadata.fromJson(json) + + assertEquals(original, restored) + } + + @Test + fun `writeToFile and fromFile work correctly`() { + val tempFile = File.createTempFile("test-metadata", ".json") + tempFile.deleteOnExit() + + val metadata = SpmModuleMetadata( + frameworkName = "FileTest", + url = "https://example.com/file-test.zip", + checksum = "file-checksum", + platforms = mapOf("iOS" to "16"), + swiftToolsVersion = "5.8" + ) + + metadata.writeToFile(tempFile) + + assertTrue(tempFile.exists()) + assertTrue(tempFile.length() > 0) + + val restored = SpmModuleMetadata.fromFile(tempFile) + assertEquals(metadata, restored) + } + + @Test + fun `empty platforms map is handled`() { + val metadata = SpmModuleMetadata( + frameworkName = "NoPlatforms", + url = "https://example.com/test.zip", + checksum = "checksum", + platforms = emptyMap(), + swiftToolsVersion = "5.9" + ) + + val json = metadata.toJson() + val restored = SpmModuleMetadata.fromJson(json) + + assertEquals(metadata, restored) + assertTrue(restored.platforms.isEmpty()) + } +} \ No newline at end of file