diff --git a/build.gradle.kts b/build.gradle.kts index db9a626d..867453dc 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -12,7 +12,7 @@ */ plugins { alias(libs.plugins.kotlin) apply false - id("org.jetbrains.kotlin.plugin.allopen") version "1.9.0" apply false + alias(libs.plugins.allopen) apply false alias(libs.plugins.maven.publish) apply false id("org.jlleitschuh.gradle.ktlint") version "12.2.0" apply false } @@ -50,4 +50,4 @@ subprojects { dependsOn(tasks.getByName("ktlintCheck")) } } -} \ No newline at end of file +} diff --git a/docs/SPM_MULTI_MODULE.md b/docs/SPM_MULTI_MODULE.md new file mode 100644 index 00000000..3f301d74 --- /dev/null +++ b/docs/SPM_MULTI_MODULE.md @@ -0,0 +1,337 @@ +# 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 +} +``` + +Apply `co.touchlab.kmmbridge.spm` **only** to the root project, and only there - never combine it +with the per-module `co.touchlab.kmmbridge` plugin on the same project. They are two separate +plugins with two separate jobs: the root plugin aggregates, the per-module plugin builds/publishes. + +### 2. Configure each darwin module (simplified) + +```kotlin +// Each darwin module's build.gradle.kts +plugins { + id("co.touchlab.kmmbridge") +} + +kmmbridge { + gitHubReleaseArtifacts() + spm(swiftToolVersion = "5.9") { + iOS { v("15") } + macOS { v("15") } + } +} +``` + +Note: No need for `useCustomPackageFile` or `perModuleVariablesBlock` - the root plugin handles everything automatically! + +Each module's `frameworkName` (the name passed to `Framework { baseName = ... }`/derived from the +Kotlin target) must be **unique across all participating modules**. Two modules sharing the same +name will fail Package.swift generation with a clear error rather than produce a broken manifest - +see [Troubleshooting](#duplicate-framework-name). + +### 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 the root SPM plugin is applied, each module's own `spmDevBuild` and `updatePackageSwift` +> tasks still run (and can still be up-to-date-checked), but their write step short-circuits at +> execution time - each logs a `Skipping ...` message and defers to `spmDevBuildAll`/the root +> `generatePackageSwift` instead of writing its own single-module Package.swift and racing with the +> aggregated one. This is expected; you'll see these log lines for every module when running +> `spmDevBuildAll` or `kmmBridgePublishAll` from the root. +> +> `kmmBridgePublishAll` generates Package.swift as a **dependency**, not a finalizer: if any module's +> publish fails, `generatePackageSwift` does not run at all, so a failed release never leaves behind a +> stale or partially-updated Package.swift. + +## 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`, if **no** discovered module has metadata: +- Metadata is created during publishing +- Run `kmmBridgePublishAll` instead, or use `spmDevBuildAll` for local dev + +### "Missing or unreadable SPM metadata for module(s): ..." + +For `generatePackageSwift`, if **some but not all** discovered modules have metadata, the task fails +fast with this error instead of silently generating an incomplete Package.swift missing those +modules. Make sure every selected module (see `includeModules`/`excludeModules`) has actually been +published - i.e. its `writeSpmMetadata` task has run successfully - before generating Package.swift. + +### "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 + +### Duplicate framework name + +If two modules share the same `frameworkName`, Package.swift generation (both `generatePackageSwift` +and `spmDevBuildAll`) fails with `Duplicate framework name(s) found across KMMBridge modules: ...`. +SPM product/target names must be unique within a single Package.swift, so give each module's +framework a distinct name. + +## 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. This applies identically to **both** generation paths: `generatePackageSwift` +(remote, using each module's published metadata) and `spmDevBuildAll` (local dev, using each module's +configured `swiftToolVersion`) resolve the version the same way - a module built with a newer +`swiftToolVersion` raises the version for the whole generated package in either case. \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 78cc8273..9324b4a6 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -10,5 +10,6 @@ kotlin-gradle-plugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin-api [plugins] kotlin = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } +allopen = { id = "org.jetbrains.kotlin.plugin.allopen", version.ref = "kotlin" } maven-publish = { id = "com.vanniktech.maven.publish.base", version.ref = "mavenPublish" } diff --git a/kmmbridge-github/build.gradle.kts b/kmmbridge-github/build.gradle.kts index 2dd4129b..ba0a7387 100644 --- a/kmmbridge-github/build.gradle.kts +++ b/kmmbridge-github/build.gradle.kts @@ -16,7 +16,7 @@ plugins { `kotlin-dsl` alias(libs.plugins.kotlin) - id("org.jetbrains.kotlin.plugin.allopen") + alias(libs.plugins.allopen) id("java-gradle-plugin") alias(libs.plugins.maven.publish) id("com.gradle.plugin-publish") version "1.0.0" diff --git a/kmmbridge-gitlab/build.gradle.kts b/kmmbridge-gitlab/build.gradle.kts index 0f945f44..d993e241 100644 --- a/kmmbridge-gitlab/build.gradle.kts +++ b/kmmbridge-gitlab/build.gradle.kts @@ -16,7 +16,7 @@ plugins { `kotlin-dsl` alias(libs.plugins.kotlin) - id("org.jetbrains.kotlin.plugin.allopen") + alias(libs.plugins.allopen) id("java-gradle-plugin") alias(libs.plugins.maven.publish) id("com.gradle.plugin-publish") version "1.0.0" diff --git a/kmmbridge-test/build.gradle.kts b/kmmbridge-test/build.gradle.kts index 75a7e606..1bdd5261 100644 --- a/kmmbridge-test/build.gradle.kts +++ b/kmmbridge-test/build.gradle.kts @@ -16,7 +16,7 @@ plugins { `kotlin-dsl` alias(libs.plugins.kotlin) - id("org.jetbrains.kotlin.plugin.allopen") + alias(libs.plugins.allopen) id("java-gradle-plugin") alias(libs.plugins.maven.publish) id("com.gradle.plugin-publish") version "1.0.0" diff --git a/kmmbridge/build.gradle.kts b/kmmbridge/build.gradle.kts index e2647502..b4e58dc3 100644 --- a/kmmbridge/build.gradle.kts +++ b/kmmbridge/build.gradle.kts @@ -16,7 +16,7 @@ plugins { `kotlin-dsl` alias(libs.plugins.kotlin) - id("org.jetbrains.kotlin.plugin.allopen") + alias(libs.plugins.allopen) id("java-gradle-plugin") alias(libs.plugins.maven.publish) id("com.gradle.plugin-publish") version "1.0.0" @@ -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/KMMBridge.kt b/kmmbridge/src/main/kotlin/co/touchlab/kmmbridge/KMMBridge.kt index 2ef886f8..41292da9 100644 --- a/kmmbridge/src/main/kotlin/co/touchlab/kmmbridge/KMMBridge.kt +++ b/kmmbridge/src/main/kotlin/co/touchlab/kmmbridge/KMMBridge.kt @@ -46,6 +46,10 @@ import org.jetbrains.kotlin.gradle.plugin.mpp.apple.XCFrameworkConfig open class KMMBridgePlugin : BaseKMMBridgePlugin() abstract class BaseKMMBridgePlugin : Plugin { + companion object { + internal const val PUBLISH_TASK_NAME = "kmmBridgePublish" + } + override fun apply(project: Project): Unit = with(project) { val extension = extensions.create(EXTENSION_NAME) @@ -141,7 +145,7 @@ abstract class BaseKMMBridgePlugin : Plugin { // Publish task depends on the upload task val publishRemoteTask = - tasks.register("kmmBridgePublish") { + tasks.register(PUBLISH_TASK_NAME) { description = "Publishes your framework. Uses your KMMBridge block configured in the build gradle to determine details." group = TASK_GROUP_NAME dependsOn(uploadTask) 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..7cecdc2a 100644 --- a/kmmbridge/src/main/kotlin/co/touchlab/kmmbridge/dependencymanager/SpmDependencyManager.kt +++ b/kmmbridge/src/main/kotlin/co/touchlab/kmmbridge/dependencymanager/SpmDependencyManager.kt @@ -16,7 +16,6 @@ package co.touchlab.kmmbridge.dependencymanager import co.touchlab.kmmbridge.TASK_GROUP_NAME import co.touchlab.kmmbridge.dsl.TargetPlatformDsl import co.touchlab.kmmbridge.internal.domain.SwiftToolVersion -import co.touchlab.kmmbridge.internal.domain.TargetPlatform import co.touchlab.kmmbridge.internal.domain.konanTarget import co.touchlab.kmmbridge.internal.domain.swiftPackagePlatformName import co.touchlab.kmmbridge.internal.findXCFrameworkAssembleTask @@ -25,6 +24,9 @@ import co.touchlab.kmmbridge.internal.kotlin import co.touchlab.kmmbridge.internal.layoutBuildDir import co.touchlab.kmmbridge.internal.urlFile import co.touchlab.kmmbridge.internal.zipFilePath +import co.touchlab.kmmbridge.spm.KmmBridgeSpmRegistry +import co.touchlab.kmmbridge.spm.SpmModuleMetadata +import co.touchlab.kmmbridge.spm.kmmBridgeSpmRegistry import java.io.ByteArrayOutputStream import java.io.File import java.nio.charset.Charset @@ -86,6 +88,7 @@ internal class SpmDependencyManager( SwiftToolVersion.of(_swiftToolVersion) ?: throw IllegalArgumentException("Parameter swiftToolVersion should be not blank!") val platforms = swiftTargetPlatforms(project) + val platformsMap = parsePlatformsMap(project) val swiftPackageFile = project.swiftPackageFile(project.rootDir) val packageName = extension.frameworkName.get() @@ -93,12 +96,55 @@ internal class SpmDependencyManager( project.logger.error(buildPackageFileErrorMessage(packageName, perModuleVariablesBlock)) } + registerInSpmRegistry(project, packageName, swiftToolVersion) + + // 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) + + inputs.files(zipFile, urlFile) + inputs.property("frameworkName", packageName) + inputs.property("platforms", platformsMap) + inputs.property("swiftToolsVersion", swiftToolVersion.name) + outputs.file(metadataFile) + + dependsOn(uploadTask) + + @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) + t.logger.info("Wrote SPM metadata to ${metadataFile.absolutePath}") + } + }, + ) + } + + val registry = project.kmmBridgeSpmRegistry() val updatePackageSwiftTask = project.tasks.register("updatePackageSwift") { group = TASK_GROUP_NAME val zipFile = project.zipFilePath() inputs.files(zipFile, project.urlFile) outputs.files(swiftPackageFile) + usesService(registry) val urlFile = project.urlFile @@ -106,6 +152,16 @@ internal class SpmDependencyManager( doLast( object : Action { override fun execute(t: Task) { + // Skip if the root SPM aggregator plugin is applied (use generatePackageSwift/ + // kmmBridgePublishAll instead) to avoid racing/overwriting the aggregated file. + if (registry.get().isRootApplied()) { + t.logger.lifecycle( + "Skipping updatePackageSwift: root project applies co.touchlab.kmmbridge.spm. " + + "The aggregated Package.swift is generated by 'generatePackageSwift'/'kmmBridgePublishAll' instead.", + ) + return + } + val checksum = providers.findSpmChecksum(zipFile, projectDir) val url = urlFile.readText() if (useCustomPackageFile && @@ -135,7 +191,30 @@ internal class SpmDependencyManager( } updatePackageSwiftTask.configure { dependsOn(uploadTask) } - publishRemoteTask.configure { dependsOn(updatePackageSwiftTask) } + publishRemoteTask.configure { + dependsOn(updatePackageSwiftTask) + dependsOn(writeMetadataTask) + } + } + + /** + * Registers this module with the shared [KmmBridgeSpmRegistry] so the root aggregator plugin + * (if applied) can discover it without ever touching this project's live [Project] object. + * Safe to call more than once per project - it only overwrites this module's own entry. + */ + private fun registerInSpmRegistry(project: Project, packageName: String, swiftToolsVersion: SwiftToolVersion) { + val buildDir = project.layout.buildDirectory.asFile.get() + project.kmmBridgeSpmRegistry().get().registerModule( + KmmBridgeSpmRegistry.ModuleRegistration( + path = project.path, + frameworkName = packageName, + metadataFile = File(buildDir, SpmModuleMetadata.METADATA_FILE_NAME), + debugXCFrameworkDir = File(buildDir, "XCFrameworks/${NativeBuildType.DEBUG.getName()}/$packageName.xcframework"), + debugAssembleTaskName = project.findXCFrameworkAssembleTask(NativeBuildType.DEBUG).name, + platforms = parsePlatformsMap(project), + swiftToolsVersion = swiftToolsVersion.name, + ), + ) } private fun hasKmmbridgeVariablesSection(swiftPackageFile: File, packageName: String): Boolean { @@ -204,19 +283,26 @@ internal class SpmDependencyManager( override val needsGitTags: Boolean = true fun configureLocalDev(project: Project) { - if (useCustomPackageFile) return // No local dev when using a custom package file - - val extension = project.kmmBridgeExtension val swiftToolVersion = SwiftToolVersion.of(_swiftToolVersion) ?: throw IllegalArgumentException("Parameter swiftToolVersion should be not blank!") + + // Register regardless of useCustomPackageFile so the root aggregator can still discover this + // module (matches the discovery semantics used before this was registry-based). + registerInSpmRegistry(project, project.kmmBridgeExtension.frameworkName.get(), swiftToolVersion) + + if (useCustomPackageFile) return // No local dev when using a custom package file + + val extension = project.kmmBridgeExtension val platforms = swiftTargetPlatforms(project) + val registry = project.kmmBridgeSpmRegistry() project.tasks.register("spmDevBuild") { description = "When using SPM, builds a debug version of the XCFramework and writes a local dev path to your Package.swift." group = TASK_GROUP_NAME dependsOn(project.findXCFrameworkAssembleTask(NativeBuildType.DEBUG)) + usesService(registry) val swiftPackageFile = project.swiftPackageFile(project.rootDir) val layoutBuildDir = project.layoutBuildDir @@ -225,6 +311,14 @@ internal class SpmDependencyManager( doLast( object : Action { override fun execute(t: Task) { + // Skip if the root SPM aggregator plugin is applied (use spmDevBuildAll instead) + if (registry.get().isRootApplied()) { + t.logger.lifecycle( + "Skipping spmDevBuild: root project applies co.touchlab.kmmbridge.spm. Use 'spmDevBuildAll' instead.", + ) + return + } + swiftPackageFile.writeText( makeLocalDevPackageFileText( swiftPackageFile, @@ -240,21 +334,29 @@ internal class SpmDependencyManager( } } - private fun swiftTargetPlatforms(project: Project): String { - val targetPlatforms = - TargetPlatformDsl() - .apply(_targetPlatforms) - .targetPlatforms - .ifEmpty { - throw IllegalArgumentException("At least one target platform should be specified!") - } + private fun swiftTargetPlatforms(project: Project): String = parsePlatformsMap(project) + .map { (platformName, platformVersion) -> ".$platformName(.v$platformVersion)" } + .joinToString(separator = ",\n") - val platforms = platforms(project, targetPlatforms) - return platforms - } + /** + * Parse platforms into a map for metadata JSON. + * Returns a map like {"iOS": "15", "macOS": "15"} + * + * When multiple TargetPlatform entries map to the same Swift package platform name, + * the maximum version is kept. + */ + internal fun parsePlatformsMap(project: Project): Map { + val targetPlatforms = TargetPlatformDsl() + .apply(_targetPlatforms) + .targetPlatforms - private fun platforms(project: Project, targetPlatforms: List): String = targetPlatforms - .flatMap { platform -> + require(targetPlatforms.isNotEmpty()) { + "No target platforms configured for SPM. Make sure to specify at least one platform in the spm { } block." + } + + val platformMap = mutableMapOf() + + targetPlatforms.forEach { platform -> project.kotlin.targets .withType() .asSequence() @@ -262,9 +364,35 @@ internal class SpmDependencyManager( .filter { appleTarget -> platform.targets.firstOrNull { it.konanTarget == appleTarget.konanTarget } != null } .mapNotNull { it.konanTarget.family.swiftPackagePlatformName } .distinct() - .map { platformName -> ".$platformName(.v${platform.version.name})" } - .toList() - }.joinToString(separator = ",\n") + .forEach { platformName -> + val newVersion = platform.version.name + val existingVersion = platformMap[platformName] + if (existingVersion == null || isNewerVersion(newVersion, existingVersion)) { + platformMap[platformName] = newVersion + } + } + } + + require(platformMap.isNotEmpty()) { + "No valid platforms resolved for SPM Package.swift. Check that your target platforms match configured Kotlin/Native targets." + } + + return platformMap + } + + /** + * Compare version strings, returning true if [candidate] is newer than [current]. + * Tries numeric comparison first, falls back to lexicographic. + */ + private fun isNewerVersion(candidate: String, current: String): Boolean { + val candidateInt = candidate.toIntOrNull() + val currentInt = current.toIntOrNull() + return if (candidateInt != null && currentInt != null) { + candidateInt > currentInt + } else { + candidate > current + } + } } /** diff --git a/kmmbridge/src/main/kotlin/co/touchlab/kmmbridge/internal/ProjectExtensionsInternal.kt b/kmmbridge/src/main/kotlin/co/touchlab/kmmbridge/internal/ProjectExtensionsInternal.kt index 1ab9e1ae..25ac8c98 100644 --- a/kmmbridge/src/main/kotlin/co/touchlab/kmmbridge/internal/ProjectExtensionsInternal.kt +++ b/kmmbridge/src/main/kotlin/co/touchlab/kmmbridge/internal/ProjectExtensionsInternal.kt @@ -17,7 +17,9 @@ import org.jetbrains.kotlin.gradle.plugin.mpp.NativeBuildType internal val Project.layoutBuildDir get() = layout.buildDirectory.get().asFile internal val Project.kotlin: KotlinMultiplatformExtension get() = extensions.getByType() + internal val Project.kmmBridgeExtension get() = extensions.getByType() +internal val Project.kmmBridgeExtensionOrNull get() = extensions.findByType() internal val Project.urlFile get() = file("$layoutBuildDir/kmmbridge/url") 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..39a3c7a2 --- /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 java.io.File +import org.gradle.api.provider.Property +import org.gradle.api.provider.SetProperty + +/** + * 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 +} 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..04a367d6 --- /dev/null +++ b/kmmbridge/src/main/kotlin/co/touchlab/kmmbridge/spm/KmmBridgeSpmPlugin.kt @@ -0,0 +1,301 @@ +/* + * 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.BaseKMMBridgePlugin +import co.touchlab.kmmbridge.TASK_GROUP_NAME +import java.io.File +import org.gradle.api.Action +import org.gradle.api.GradleException +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.api.Task +import org.gradle.api.logging.Logger +import org.gradle.api.logging.Logging +import org.gradle.api.provider.Provider +import org.gradle.kotlin.dsl.create + +/** + * 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" + const val WRITE_SPM_METADATA_TASK_NAME = "writeSpmMetadata" + } + + private val generator = PackageSwiftGenerator() + private val logger: Logger = Logging.getLogger(KmmBridgeSpmPlugin::class.java) + + 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()) + + val registry = project.kmmBridgeSpmRegistry() + registry.get().markRootApplied() + + registerTasks(project, extension, registry) + } + + /** + * Registers the aggregator tasks. Every subproject's data flows through [registry] rather than + * this project touching another project's live [Project]/[Task]/extension objects, and + * cross-project task wiring uses path-string [Task.dependsOn] references - both Project + * Isolation-safe patterns. The registry is only read from task-execution-time actions + * ([Task.doLast]/[Task.doFirst]) or lazy [Provider]s, never at configuration time, since task + * execution is always ordered after every project finishes configuring. + */ + private fun registerTasks(project: Project, extension: KmmBridgeSpmExtension, registry: Provider) { + val modulesProvider: Provider> = + registry.map { filterModules(it.modules(), extension) } + + // Register generatePackageSwift task + val generateTask = project.tasks.register(GENERATE_TASK_NAME) { + group = TASK_GROUP_NAME + description = "Generates Package.swift from all KMMBridge module metadata" + usesService(registry) + + // Depend on all module metadata-write tasks, resolved lazily by task path + dependsOn(modulesProvider.map { modules -> modules.map { "${it.path}:$WRITE_SPM_METADATA_TASK_NAME" } }) + + val outputDir = extension.outputDirectory.get() + val packageName = extension.packageName.get() + val swiftToolsVersion = extension.swiftToolsVersion.get() + + // Declare the actual data this task reads so up-to-date checks and the build cache see + // changes to a module's published metadata, not just this task's own output file. + inputs.files(modulesProvider.map { modules -> modules.map { it.metadataFile } }) + inputs.property("packageName", packageName) + inputs.property("swiftToolsVersion", swiftToolsVersion) + outputs.file(File(outputDir, "Package.swift")) + + @Suppress("ObjectLiteralToLambda") + doLast( + object : Action { + override fun execute(t: Task) { + val modules = filterModules(registry.get().modules(), extension) + if (modules.isEmpty()) { + logger.warn( + "No KMMBridge modules with SPM found. Make sure subprojects apply 'co.touchlab.kmmbridge' and configure spm().", + ) + return + } + + val metadata = collectMetadata(modules) + if (metadata.isEmpty()) { + logger.warn("No module metadata found. Make sure modules have been published.") + return + } + if (metadata.size < modules.size) { + val missing = modules.map { it.frameworkName } - metadata.map { it.frameworkName }.toSet() + throw GradleException( + "Missing or unreadable SPM metadata for module(s): ${missing.joinToString()}. " + + "Make sure all selected modules have been published (writeSpmMetadata ran successfully) " + + "before generating Package.swift.", + ) + } + + val packageSwift = generator.generatePackageSwift( + packageName = packageName, + swiftToolsVersion = generator.resolveSwiftToolsVersion( + metadata.map { it.swiftToolsVersion }, + swiftToolsVersion, + ), + modules = metadata, + ) + + val outputFile = File(outputDir, "Package.swift") + outputFile.parentFile?.mkdirs() + outputFile.writeText(packageSwift) + 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" + usesService(registry) + + // Depend on all module kmmBridgePublish tasks, resolved lazily by task path. Modules + // only register themselves once publishing is configured (ENABLE_PUBLISHING), so a + // module without publishing enabled simply never appears here. + dependsOn(modulesProvider.map { modules -> modules.map { "${it.path}:${BaseKMMBridgePlugin.PUBLISH_TASK_NAME}" } }) + + // A dependency (not finalizedBy): finalizedBy would run generateTask even if a module's + // publish failed, silently generating Package.swift from a mix of stale and new data and + // masking the failed release. As a dependency, generateTask only runs - and this task + // only succeeds - once every publish task it depends on (transitively, via writeSpmMetadata) + // has completed successfully. + dependsOn(generateTask) + + doFirst { + if (filterModules(registry.get().modules(), extension).isEmpty()) { + logger.warn( + "Task $PUBLISH_ALL_TASK_NAME did not find any KMMBridge modules with SPM configured. " + + "Publishing is disabled or not configured (e.g. ENABLE_PUBLISHING not set); skipping Package.swift generation.", + ) + } + } + } + + // 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" + usesService(registry) + + // Depend on all module XCFramework assemble tasks, resolved lazily by task path + dependsOn(modulesProvider.map { modules -> modules.map { "${it.path}:${it.debugAssembleTaskName}" } }) + + val outputDir = extension.outputDirectory.get() + val packageName = extension.packageName.get() + val defaultSwiftToolsVersion = extension.swiftToolsVersion.get() + + // Declare the actual data this task reads: each module's built XCFramework directory + // (content changes when rebuilt), plus the configuration that shapes the output. + inputs.files(modulesProvider.map { modules -> modules.map { it.debugXCFrameworkDir } }) + inputs.property("packageName", packageName) + inputs.property("moduleSwiftToolsVersions", modulesProvider.map { modules -> modules.map { it.swiftToolsVersion } }) + outputs.file(File(outputDir, "Package.swift")) + + @Suppress("ObjectLiteralToLambda") + doLast( + object : Action { + override fun execute(t: Task) { + val modules = filterModules(registry.get().modules(), extension) + val localModules = collectLocalModuleInfo(modules, outputDir) + if (localModules.isEmpty()) { + logger.warn("No local XCFrameworks found. Make sure modules have been built.") + return + } + + // Resolve the maximum Swift-tools version across modules, same as the + // published/remote generatePackageSwift path, rather than always using the + // root default. + val resolvedSwiftToolsVersion = + generator.resolveSwiftToolsVersion(localModules.map { it.swiftToolsVersion }, defaultSwiftToolsVersion) + + val packageSwift = generator.generateLocalPackageSwift( + packageName = packageName, + swiftToolsVersion = resolvedSwiftToolsVersion, + modules = localModules, + ) + + val outputFile = File(outputDir, "Package.swift") + outputFile.parentFile?.mkdirs() + outputFile.writeText(packageSwift) + logger.lifecycle( + "Generated local Package.swift with ${localModules.size} modules at ${outputFile.absolutePath}", + ) + } + }, + ) + } + } + + /** + * Collect local module info from built XCFrameworks. Modules whose XCFramework directory + * doesn't exist yet (not built) are skipped with a warning rather than emitting a + * `.binaryTarget(path:)` that points at nothing. + */ + private fun collectLocalModuleInfo( + modules: List, + rootDir: File, + ): List = modules.mapNotNull { module -> + if (!module.debugXCFrameworkDir.exists()) { + logger.warn( + "Skipping module ${module.path}: XCFramework not found at ${module.debugXCFrameworkDir.absolutePath}. " + + "Make sure '${module.debugAssembleTaskName}' has run for this module.", + ) + return@mapNotNull null + } + + // Calculate the relative path from root + val relativePath = rootDir.toPath().relativize(module.debugXCFrameworkDir.toPath()).toString() + + PackageSwiftGenerator.LocalModuleInfo( + frameworkName = module.frameworkName, + localPath = relativePath, + platforms = module.platforms, + swiftToolsVersion = module.swiftToolsVersion, + ) + } + + /** + * Filter registered modules by the extension's include/exclude module paths. + */ + private fun filterModules( + allModules: List, + extension: KmmBridgeSpmExtension, + ): List { + val includeModules = extension.includeModules.get() + val excludeModules = extension.excludeModules.get() + + return allModules.filter { module -> + val isIncluded = includeModules.isEmpty() || includeModules.contains(module.path) + val isExcluded = excludeModules.contains(module.path) + isIncluded && !isExcluded + } + } + + /** + * Collect metadata from all modules. + */ + private fun collectMetadata(modules: List): List = + modules.mapNotNull { module -> + if (module.metadataFile.exists()) { + try { + SpmModuleMetadata.fromFile(module.metadataFile) + } catch (e: Exception) { + logger.warn("Failed to read metadata from ${module.metadataFile.absolutePath} (module ${module.path}): ${e.message}") + null + } + } else { + logger.warn("Metadata file not found for module ${module.path}: ${module.metadataFile.absolutePath}") + null + } + } +} diff --git a/kmmbridge/src/main/kotlin/co/touchlab/kmmbridge/spm/KmmBridgeSpmRegistry.kt b/kmmbridge/src/main/kotlin/co/touchlab/kmmbridge/spm/KmmBridgeSpmRegistry.kt new file mode 100644 index 00000000..9d78c386 --- /dev/null +++ b/kmmbridge/src/main/kotlin/co/touchlab/kmmbridge/spm/KmmBridgeSpmRegistry.kt @@ -0,0 +1,62 @@ +/* + * 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 java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicBoolean +import org.gradle.api.Project +import org.gradle.api.provider.Provider +import org.gradle.api.services.BuildService +import org.gradle.api.services.BuildServiceParameters + +/** + * Build-scoped registry that lets KMMBridge SPM modules and the root aggregator plugin exchange + * data without ever touching each other's live [Project]/[org.gradle.api.Task] objects, so the + * plugin stays compatible with Gradle's Project Isolation feature. + * + * Modules write their own [ModuleRegistration] about themselves at their own configuration time + * (always safe). The root plugin only reads this data from task-execution-time actions, never at + * configuration time - task execution is always ordered after every project has finished + * configuring, so there is no dependency on project evaluation order. + */ +internal abstract class KmmBridgeSpmRegistry : BuildService { + data class ModuleRegistration( + val path: String, + val frameworkName: String, + val metadataFile: File, + val debugXCFrameworkDir: File, + val debugAssembleTaskName: String, + val platforms: Map, + val swiftToolsVersion: String, + ) + + private val registrations = ConcurrentHashMap() + private val rootApplied = AtomicBoolean(false) + + fun registerModule(registration: ModuleRegistration) { + registrations[registration.path] = registration + } + + fun markRootApplied() { + rootApplied.set(true) + } + + fun isRootApplied(): Boolean = rootApplied.get() + + fun modules(): List = registrations.values.toList() +} + +internal fun Project.kmmBridgeSpmRegistry(): Provider = + gradle.sharedServices.registerIfAbsent("kmmBridgeSpmRegistry", KmmBridgeSpmRegistry::class.java) {} diff --git a/kmmbridge/src/main/kotlin/co/touchlab/kmmbridge/spm/PackageSwiftGenerator.kt b/kmmbridge/src/main/kotlin/co/touchlab/kmmbridge/spm/PackageSwiftGenerator.kt new file mode 100644 index 00000000..f9b994fc --- /dev/null +++ b/kmmbridge/src/main/kotlin/co/touchlab/kmmbridge/spm/PackageSwiftGenerator.kt @@ -0,0 +1,198 @@ +/* + * 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 + +/** + * Generates Package.swift content from module metadata. + * + * This class is intentionally free of Gradle dependencies so it can be unit-tested + * without a Gradle project context. + */ +internal class PackageSwiftGenerator { + + internal 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 + } + + /** + * Resolve the Swift tools version to use. + * Uses the maximum version across [versions], or the configured default. + */ + fun resolveSwiftToolsVersion(versions: List, defaultVersion: String): String { + val filtered = versions.filter { it.isNotBlank() } + return if (filtered.isNotEmpty()) { + filtered.maxWithOrNull(versionComparator) ?: defaultVersion + } else { + defaultVersion + } + } + + /** + * Resolve platforms by taking the maximum version for each platform across all modules. + */ + 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() + } + } + + /** + * Generate the complete Package.swift content for remote (published) modules. + */ + fun generatePackageSwift(packageName: String, swiftToolsVersion: String, modules: List): String { + validateUniqueFrameworkNames(modules.map { it.frameworkName }) + + val platforms = resolvePlatforms(modules) + val platformsString = formatPlatforms(platforms) + + val productsString = modules + .sortedBy { it.frameworkName } + .joinToString(",\n ") { module -> + val name = escapeSwiftString(module.frameworkName) + ".library(name: \"$name\", targets: [\"$name\"])" + } + + val targetsString = modules + .sortedBy { it.frameworkName } + .joinToString(",\n ") { module -> + val name = escapeSwiftString(module.frameworkName) + val url = escapeSwiftString(module.url) + val checksum = escapeSwiftString(module.checksum) + """.binaryTarget( + name: "$name", + url: "$url", + checksum: "$checksum" + )""" + } + + return """// swift-tools-version:$swiftToolsVersion +// Generated by KMMBridge - DO NOT EDIT MANUALLY +// https://github.com/touchlab/KMMBridge +import PackageDescription + +let package = Package( + name: "${escapeSwiftString(packageName)}", + platforms: [ + $platformsString + ], + products: [ + $productsString + ], + targets: [ + $targetsString + ] +) +""" + } + + /** + * Generate Package.swift content with local paths for development. + */ + fun generateLocalPackageSwift(packageName: String, swiftToolsVersion: String, modules: List): String { + validateUniqueFrameworkNames(modules.map { it.frameworkName }) + + val platforms = modules.flatMap { it.platforms.entries } + .groupBy({ it.key }, { it.value }) + .mapValues { (_, versions) -> versions.maxWithOrNull(versionComparator) ?: versions.first() } + + val platformsString = formatPlatforms(platforms) + + val productsString = modules + .sortedBy { it.frameworkName } + .joinToString(",\n ") { module -> + val name = escapeSwiftString(module.frameworkName) + ".library(name: \"$name\", targets: [\"$name\"])" + } + + val targetsString = modules + .sortedBy { it.frameworkName } + .joinToString(",\n ") { module -> + val name = escapeSwiftString(module.frameworkName) + val path = escapeSwiftString(module.localPath) + """.binaryTarget( + name: "$name", + path: "$path" + )""" + } + + return """// swift-tools-version:$swiftToolsVersion +// Generated by KMMBridge (LOCAL DEV) - DO NOT COMMIT +// https://github.com/touchlab/KMMBridge +import PackageDescription + +let package = Package( + name: "${escapeSwiftString(packageName)}", + platforms: [ + $platformsString + ], + products: [ + $productsString + ], + targets: [ + $targetsString + ] +) +""" + } + + private fun formatPlatforms(platforms: Map): String = platforms.entries + .sortedBy { it.key } + .joinToString(",\n ") { (platform, version) -> ".$platform(.v$version)" } + + /** + * Data class for local module info (used for spmDevBuildAll). + */ + data class LocalModuleInfo( + val frameworkName: String, + val localPath: String, + val platforms: Map, + val swiftToolsVersion: String, + ) + + /** + * SPM product and target names must be unique within a Package.swift. Fail early with a clear + * message rather than emitting a manifest SwiftPM will reject. + */ + private fun validateUniqueFrameworkNames(names: List) { + val duplicates = names.groupingBy { it }.eachCount().filterValues { it > 1 }.keys + require(duplicates.isEmpty()) { + "Duplicate framework name(s) found across KMMBridge modules: ${duplicates.joinToString()}. " + + "Each module's framework name must be unique to generate a valid Package.swift." + } + } + + companion object { + /** + * Escape a string for safe inclusion in a Swift string literal. + */ + fun escapeSwiftString(value: String): String = value + .replace("\\", "\\\\").replace("\"", "\\\"") + } +} 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..a2364e04 --- /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()) + } +} diff --git a/kmmbridge/src/test/kotlin/co/touchlab/kmmbridge/spm/KmmBridgeSpmMultiModuleTest.kt b/kmmbridge/src/test/kotlin/co/touchlab/kmmbridge/spm/KmmBridgeSpmMultiModuleTest.kt new file mode 100644 index 00000000..a1d1fd43 --- /dev/null +++ b/kmmbridge/src/test/kotlin/co/touchlab/kmmbridge/spm/KmmBridgeSpmMultiModuleTest.kt @@ -0,0 +1,123 @@ +package co.touchlab.kmmbridge.spm + +import co.touchlab.kmmbridge.BasePluginTest +import co.touchlab.kmmbridge.ProcessHelper +import java.io.File +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import org.junit.jupiter.api.Test + +/** + * Exercises the root-level [KmmBridgeSpmPlugin] aggregator against a real multi-module build + * (module-a, module-b, module-excluded), verifying module discovery/exclusion and generated + * Package.swift content flow entirely through [KmmBridgeSpmRegistry] rather than the root project + * touching subprojects' live `Project`/`Task` objects. + */ +class KmmBridgeSpmMultiModuleTest : BasePluginTest() { + override fun testProjectPath(): String = "test-projects/multi-module" + + private fun packageSwiftFile(): File = File(testProjectDir, "Package.swift") + + @Test + fun generatePackageSwift() { + val result = + ProcessHelper.runSh( + "./gradlew generatePackageSwift -PENABLE_PUBLISHING=true --stacktrace", + workingDir = testProjectDir, + ) + logExecResult(result) + assertEquals(0, result.status) + + val packageSwift = packageSwiftFile().readText() + assertTrue(packageSwift.contains("ModuleA"), "Package.swift should include module-a") + assertTrue(packageSwift.contains("ModuleB"), "Package.swift should include module-b") + assertFalse(packageSwift.contains("ModuleExcluded"), "Package.swift should not include the excluded module") + } + + @Test + fun spmDevBuildAll() { + val result = + ProcessHelper.runSh( + "./gradlew spmDevBuildAll --stacktrace", + workingDir = testProjectDir, + ) + logExecResult(result) + assertEquals(0, result.status) + + val packageSwift = packageSwiftFile().readText() + assertTrue(packageSwift.contains("module-a/build/XCFrameworks/debug/ModuleA.xcframework")) + assertTrue(packageSwift.contains("module-b/build/XCFrameworks/debug/ModuleB.xcframework")) + assertFalse(packageSwift.contains("ModuleExcluded")) + } + + @Test + fun kmmBridgePublishAll() { + val result = + ProcessHelper.runSh( + "./gradlew kmmBridgePublishAll -PENABLE_PUBLISHING=true --stacktrace", + workingDir = testProjectDir, + ) + logExecResult(result) + assertEquals(0, result.status) + + // The root aggregator is applied, so each module's own updatePackageSwift must defer to it + // rather than racing to write its own single-module Package.swift to the same file. + assertTrue((result.output + result.error).contains("Skipping updatePackageSwift")) + + val packageSwift = packageSwiftFile().readText() + assertTrue(packageSwift.contains("ModuleA")) + assertTrue(packageSwift.contains("ModuleB")) + assertFalse(packageSwift.contains("ModuleExcluded")) + } + + /** + * Regression test: if a module is registered but its metadata was never actually written (e.g. + * it hasn't been published in this run), `generatePackageSwift` must fail loudly instead of + * silently emitting an incomplete Package.swift missing that module. + */ + @Test + fun generatePackageSwiftFailsOnPartialMetadata() { + val result = + ProcessHelper.runSh( + "./gradlew generatePackageSwift -PENABLE_PUBLISHING=true -x :module-b:writeSpmMetadata --stacktrace", + workingDir = testProjectDir, + ) + logExecResult(result) + assertEquals(1, result.status) + assertTrue((result.output + result.error).contains("Missing or unreadable SPM metadata for module(s): ModuleB")) + } + + /** + * Regression test: once the root aggregator is applied, a module's own `spmDevBuild` must + * defer to `spmDevBuildAll` rather than overwriting the aggregated Package.swift. This exercises + * [KmmBridgeSpmRegistry.isRootApplied], read at task-execution time rather than by the module + * reaching into the root project's live extension. + */ + @Test + fun perModuleSpmDevBuildSkipsWhenRootAggregatorApplied() { + val aggregate = + ProcessHelper.runSh( + "./gradlew spmDevBuildAll --stacktrace", + workingDir = testProjectDir, + ) + logExecResult(aggregate) + assertEquals(0, aggregate.status) + val aggregatedContent = packageSwiftFile().readText() + + val perModule = + ProcessHelper.runSh( + "./gradlew :module-a:spmDevBuild --stacktrace", + workingDir = testProjectDir, + ) + logExecResult(perModule) + assertEquals(0, perModule.status) + assertTrue(perModule.output.contains("Skipping spmDevBuild")) + + assertEquals( + aggregatedContent, + packageSwiftFile().readText(), + "Per-module spmDevBuild must not overwrite the aggregated Package.swift", + ) + } +} 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..e9b91a9b --- /dev/null +++ b/kmmbridge/src/test/kotlin/co/touchlab/kmmbridge/spm/PackageSwiftGeneratorTest.kt @@ -0,0 +1,199 @@ +/* + * 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.assertFailsWith +import kotlin.test.assertTrue + +class PackageSwiftGeneratorTest { + + 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.map { it.swiftToolsVersion }, "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.map { it.swiftToolsVersion }, "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.map { it.swiftToolsVersion }, "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) + } + + @Test + fun `rejects duplicate framework names for remote generation`() { + val modules = listOf( + SpmModuleMetadata("Shared", "https://a", "checksumA", mapOf("iOS" to "15"), "5.9"), + SpmModuleMetadata("Shared", "https://b", "checksumB", mapOf("iOS" to "15"), "5.9"), + ) + + assertFailsWith { + generator.generatePackageSwift("my-sdk", "5.9", modules) + } + } + + @Test + fun `rejects duplicate framework names for local generation`() { + val modules = listOf( + PackageSwiftGenerator.LocalModuleInfo("Shared", "a/build", mapOf("iOS" to "15"), "5.9"), + PackageSwiftGenerator.LocalModuleInfo("Shared", "b/build", mapOf("iOS" to "15"), "5.9"), + ) + + assertFailsWith { + generator.generateLocalPackageSwift("my-sdk", "5.9", modules) + } + } +} 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..bee817ed --- /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()) + } +} diff --git a/test-projects/basic/allshared/build.gradle.kts b/test-projects/basic/allshared/build.gradle.kts index 2eed5a38..ad6d0218 100644 --- a/test-projects/basic/allshared/build.gradle.kts +++ b/test-projects/basic/allshared/build.gradle.kts @@ -9,7 +9,7 @@ kotlin { listOf( iosX64(), iosArm64(), - iosSimulatorArm64() + iosSimulatorArm64(), ).forEach { it.binaries.framework { isStatic = true diff --git a/test-projects/basic/allshared/src/iosMain/kotlin/co.touchlab/kmmbridgetest/StartSDK.kt b/test-projects/basic/allshared/src/iosMain/kotlin/co.touchlab/kmmbridgetest/StartSDK.kt index 227a4e6a..ed96ec70 100644 --- a/test-projects/basic/allshared/src/iosMain/kotlin/co.touchlab/kmmbridgetest/StartSDK.kt +++ b/test-projects/basic/allshared/src/iosMain/kotlin/co.touchlab/kmmbridgetest/StartSDK.kt @@ -1,3 +1,3 @@ package co.touchlab.kmmbridgetest -fun sayHello() = "Hello from Kotlin!" \ No newline at end of file +fun sayHello() = "Hello from Kotlin!" diff --git a/test-projects/multi-module/LICENSE.txt b/test-projects/multi-module/LICENSE.txt new file mode 100644 index 00000000..97d4aa3c --- /dev/null +++ b/test-projects/multi-module/LICENSE.txt @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "{}" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2021 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. \ No newline at end of file diff --git a/test-projects/multi-module/build.gradle.kts b/test-projects/multi-module/build.gradle.kts new file mode 100644 index 00000000..1c9d281b --- /dev/null +++ b/test-projects/multi-module/build.gradle.kts @@ -0,0 +1,13 @@ +plugins { + alias(libs.plugins.kotlin.multiplatform) apply false + alias(libs.plugins.kmmbridge) apply false + alias(libs.plugins.kmmbridge.spm) +} + +kmmBridgeSpm { + excludeModules.set(setOf(":module-excluded")) +} + +tasks.register("clean") { + delete(rootProject.layout.buildDirectory) +} diff --git a/test-projects/multi-module/gradle.properties b/test-projects/multi-module/gradle.properties new file mode 100644 index 00000000..d7bc5ada --- /dev/null +++ b/test-projects/multi-module/gradle.properties @@ -0,0 +1,6 @@ +kotlin.code.style=official +android.useAndroidX=true +org.gradle.jvmargs=-Xmx4g + +LIBRARY_VERSION=0.1.8 +GROUP=co.touchlab.kmmbridgespmmultimodule diff --git a/test-projects/multi-module/gradle/libs.versions.toml b/test-projects/multi-module/gradle/libs.versions.toml new file mode 100644 index 00000000..8a860551 --- /dev/null +++ b/test-projects/multi-module/gradle/libs.versions.toml @@ -0,0 +1,8 @@ +[versions] +kotlin = "2.0.10" +kmmBridge = "9.9.9" + +[plugins] +kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } +kmmbridge = { id = "co.touchlab.kmmbridge.test", version.ref = "kmmBridge" } +kmmbridge-spm = { id = "co.touchlab.kmmbridge.spm", version.ref = "kmmBridge" } diff --git a/test-projects/multi-module/gradle/wrapper/gradle-wrapper.jar b/test-projects/multi-module/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 00000000..033e24c4 Binary files /dev/null and b/test-projects/multi-module/gradle/wrapper/gradle-wrapper.jar differ diff --git a/test-projects/multi-module/gradle/wrapper/gradle-wrapper.properties b/test-projects/multi-module/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..3fa8f862 --- /dev/null +++ b/test-projects/multi-module/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.4-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/test-projects/multi-module/gradlew b/test-projects/multi-module/gradlew new file mode 100755 index 00000000..fcb6fca1 --- /dev/null +++ b/test-projects/multi-module/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# 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 +# +# https://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. +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command; +# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of +# shell script including quotes and variable substitutions, so put them in +# double quotes to make sure that they get re-expanded; and +# * put everything else in single quotes, so that it's not re-expanded. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/test-projects/multi-module/gradlew.bat b/test-projects/multi-module/gradlew.bat new file mode 100644 index 00000000..6689b85b --- /dev/null +++ b/test-projects/multi-module/gradlew.bat @@ -0,0 +1,92 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/test-projects/multi-module/module-a/build.gradle.kts b/test-projects/multi-module/module-a/build.gradle.kts new file mode 100644 index 00000000..5e680017 --- /dev/null +++ b/test-projects/multi-module/module-a/build.gradle.kts @@ -0,0 +1,29 @@ +import co.touchlab.kmmbridge.test.TestArtifactManager + +plugins { + alias(libs.plugins.kotlin.multiplatform) + alias(libs.plugins.kmmbridge) +} + +group = providers.gradleProperty("GROUP").get() +version = providers.gradleProperty("LIBRARY_VERSION").get() + +kotlin { + listOf( + iosX64(), + iosArm64(), + iosSimulatorArm64(), + ).forEach { + it.binaries.framework { + baseName = "ModuleA" + isStatic = true + } + } +} + +kmmbridge { + testArtifacts() + spm(swiftToolVersion = "5.8", spmDirectory = "../") { + iOS { v("14") } + } +} diff --git a/test-projects/multi-module/module-a/src/iosMain/kotlin/co/touchlab/kmmbridgetest/modulea/StartSDK.kt b/test-projects/multi-module/module-a/src/iosMain/kotlin/co/touchlab/kmmbridgetest/modulea/StartSDK.kt new file mode 100644 index 00000000..51af5ece --- /dev/null +++ b/test-projects/multi-module/module-a/src/iosMain/kotlin/co/touchlab/kmmbridgetest/modulea/StartSDK.kt @@ -0,0 +1,3 @@ +package co.touchlab.kmmbridgetest.modulea + +fun sayHello() = "Hello from Module A!" diff --git a/test-projects/multi-module/module-b/build.gradle.kts b/test-projects/multi-module/module-b/build.gradle.kts new file mode 100644 index 00000000..4f53de57 --- /dev/null +++ b/test-projects/multi-module/module-b/build.gradle.kts @@ -0,0 +1,29 @@ +import co.touchlab.kmmbridge.test.TestArtifactManager + +plugins { + alias(libs.plugins.kotlin.multiplatform) + alias(libs.plugins.kmmbridge) +} + +group = providers.gradleProperty("GROUP").get() +version = providers.gradleProperty("LIBRARY_VERSION").get() + +kotlin { + listOf( + iosX64(), + iosArm64(), + iosSimulatorArm64(), + ).forEach { + it.binaries.framework { + baseName = "ModuleB" + isStatic = true + } + } +} + +kmmbridge { + testArtifacts() + spm(swiftToolVersion = "5.8", spmDirectory = "../") { + iOS { v("14") } + } +} diff --git a/test-projects/multi-module/module-b/src/iosMain/kotlin/co/touchlab/kmmbridgetest/moduleb/StartSDK.kt b/test-projects/multi-module/module-b/src/iosMain/kotlin/co/touchlab/kmmbridgetest/moduleb/StartSDK.kt new file mode 100644 index 00000000..eafb8a60 --- /dev/null +++ b/test-projects/multi-module/module-b/src/iosMain/kotlin/co/touchlab/kmmbridgetest/moduleb/StartSDK.kt @@ -0,0 +1,3 @@ +package co.touchlab.kmmbridgetest.moduleb + +fun sayHello() = "Hello from Module B!" diff --git a/test-projects/multi-module/module-excluded/build.gradle.kts b/test-projects/multi-module/module-excluded/build.gradle.kts new file mode 100644 index 00000000..b64874d0 --- /dev/null +++ b/test-projects/multi-module/module-excluded/build.gradle.kts @@ -0,0 +1,29 @@ +import co.touchlab.kmmbridge.test.TestArtifactManager + +plugins { + alias(libs.plugins.kotlin.multiplatform) + alias(libs.plugins.kmmbridge) +} + +group = providers.gradleProperty("GROUP").get() +version = providers.gradleProperty("LIBRARY_VERSION").get() + +kotlin { + listOf( + iosX64(), + iosArm64(), + iosSimulatorArm64(), + ).forEach { + it.binaries.framework { + baseName = "ModuleExcluded" + isStatic = true + } + } +} + +kmmbridge { + testArtifacts() + spm(swiftToolVersion = "5.8", spmDirectory = "../") { + iOS { v("14") } + } +} diff --git a/test-projects/multi-module/module-excluded/src/iosMain/kotlin/co/touchlab/kmmbridgetest/moduleexcluded/StartSDK.kt b/test-projects/multi-module/module-excluded/src/iosMain/kotlin/co/touchlab/kmmbridgetest/moduleexcluded/StartSDK.kt new file mode 100644 index 00000000..c02b1f4c --- /dev/null +++ b/test-projects/multi-module/module-excluded/src/iosMain/kotlin/co/touchlab/kmmbridgetest/moduleexcluded/StartSDK.kt @@ -0,0 +1,3 @@ +package co.touchlab.kmmbridgetest.moduleexcluded + +fun sayHello() = "Hello from Module Excluded!" diff --git a/test-projects/multi-module/settings.gradle.kts b/test-projects/multi-module/settings.gradle.kts new file mode 100644 index 00000000..e71101ac --- /dev/null +++ b/test-projects/multi-module/settings.gradle.kts @@ -0,0 +1,21 @@ +pluginManagement { + repositories { + mavenLocal() + google() + gradlePluginPortal() + mavenCentral() + } +} + +dependencyResolutionManagement { + @Suppress("UnstableApiUsage") + repositories { + mavenLocal() + google() + mavenCentral() + } +} + +include("module-a") +include("module-b") +include("module-excluded")