CocoaPods: Basic support

This patch adds a separate Gradle plugin allowing
a user to import Kotlin/Native modules in Cocoapods
projects and use Cocoapods dependencies in Kotlin
code via cinterop.

The plugin when applied does the following things:

1. Add a framework binary for each supported (iOS
   or macOS) platform in the project.

2. Add a Cocoapods extension allowing one to configure
   Cocoapods dependencies of the project.

3. Create cinterop tasks for each dependency from the
   previous point. The tasks are added for main
   compilations of each supported platform.

4. Add a task to generate a podspec-file which includes
   the framework from the point 1, script to
   build it and dependencies from the point 2.

So the Cocoapods import procedure is the following:

1. A user runs `./gradlew generatePodspec`. The plugin
   creates a podspec-file with all dependencies.

2. The user adds a dependency on this podspec in his Podfile
   and runs `pod install`. Cocoapods downloads dependencies
   and configures user's XCode project.

3. The user runs XCode build. XCode builds dependencies and then
   runs Gradle build. Gradle performs interop processing and
   builds the Kotlin framework. Compiler options and header
   search paths are passed in the Gradle from XCode environment
   variables. After that the final application is built by XCode.

Issue #KT-30269 Fixed
This commit is contained in:
Ilya Matveev
2019-02-26 17:01:59 +03:00
parent 2999cdd3e0
commit 21a69b0e5b
11 changed files with 483 additions and 2 deletions
@@ -90,7 +90,9 @@ tasks {
for ((name, value) in propertiesToExpand) {
inputs.property(name, value)
}
expand("projectVersion" to project.version)
filesMatching("project.properties") {
expand("projectVersion" to project.version)
}
}
named<Jar>("jar") {
@@ -0,0 +1,81 @@
/*
* Copyright 2010-2019 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license
* that can be found in the license/LICENSE.txt file.
*/
package org.jetbrains.kotlin.gradle.plugin.cocoapods
import org.gradle.api.Named
import org.gradle.api.NamedDomainObjectSet
import org.gradle.api.Project
import org.gradle.api.tasks.Input
import org.gradle.api.tasks.Internal
import org.gradle.api.tasks.Nested
import org.gradle.api.tasks.Optional
open class CocoapodsExtension(private val project: Project) {
@get:Input
val version: String
get() = project.version.toString()
/**
* Configure authors of the pod built from this project.
*/
@Optional
@Input
var authors: String? = null
/**
* Configure license of the pod built from this project.
*/
@Optional
@Input
var license: String? = null
/**
* Configure description of the pod built from this project.
*/
@Optional
@Input
var summary: String? = null
/**
* Configure homepage of the pod built from this project.
*/
@Optional
@Input
var homepage: String? = null
private val _pods = project.container(CocoapodsDependency::class.java)
// For some reason Gradle doesn't consume the @Nested annotation on NamedDomainObjectContainer.
@get:Nested
protected val podsAsTaskInput: List<CocoapodsDependency>
get() = _pods.toList()
/**
* Returns a list of pod dependencies.
*/
// Already taken into account as a task input in the [podsAsTaskInput] property.
@get:Internal
val pods: NamedDomainObjectSet<CocoapodsDependency>
get() = _pods
/**
* Add a Cocoapods dependency to the pod built from this project.
*/
@JvmOverloads
fun pod(name: String, version: String? = null, moduleName: String = name) {
check(_pods.findByName(name) == null) { "Project already has a CocoaPods dependency with name $name" }
_pods.add(CocoapodsDependency(name, version, moduleName))
}
data class CocoapodsDependency(
private val name: String,
@get:Optional @get:Input val version: String?,
@get:Input val moduleName: String
) : Named {
@Input
override fun getName(): String = name
}
}
@@ -0,0 +1,223 @@
/*
* Copyright 2010-2019 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license
* that can be found in the license/LICENSE.txt file.
*/
package org.jetbrains.kotlin.gradle.plugin.cocoapods
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.api.tasks.Sync
import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension
import org.jetbrains.kotlin.gradle.dsl.kotlinExtension
import org.jetbrains.kotlin.gradle.plugin.KotlinCompilation
import org.jetbrains.kotlin.gradle.plugin.addExtension
import org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeTarget
import org.jetbrains.kotlin.gradle.plugin.whenEvaluated
import org.jetbrains.kotlin.gradle.tasks.DefFileTask
import org.jetbrains.kotlin.gradle.tasks.DummyFrameworkTask
import org.jetbrains.kotlin.gradle.tasks.PodspecTask
import org.jetbrains.kotlin.gradle.utils.asValidTaskName
import org.jetbrains.kotlin.gradle.utils.lowerCamelCaseName
import org.jetbrains.kotlin.konan.target.Family
import org.jetbrains.kotlin.konan.target.HostManager
import java.io.File
internal val Project.cocoapodsBuildDirs: CocoapodsBuildDirs
get() = CocoapodsBuildDirs(this)
internal class CocoapodsBuildDirs(val project: Project) {
val root: File
get() = project.buildDir.resolve("cocoapods")
val framework: File
get() = root.resolve("framework")
val defs: File
get() = root.resolve("defs")
}
private enum class State {
CONSUME_ESCAPED,
CONSUME,
SKIP;
}
/**
* Splits a string using a whitespace characters as delimiters.
* Ignores whitespaces in quotes and drops quotes, e.g. a string
* `foo "bar baz" qux="quux"` will be split into ["foo", "bar baz", "qux=quux"].
*/
private fun String.splitQuotedArgs(): List<String> {
if (isEmpty()) {
return emptyList()
}
var state: State = State.SKIP
val result = mutableListOf<String>()
val token = StringBuilder(length)
forEachIndexed { index, char ->
when (state) {
State.CONSUME_ESCAPED -> {
when (char) {
'"' -> state = State.CONSUME // Skip `"`
else -> token.append(char)
}
}
State.CONSUME -> {
when {
char == '"' -> state = State.CONSUME_ESCAPED // Skip `"`
char.isWhitespace() -> {
state = State.SKIP
result.add(token.toString())
token.setLength(0)
}
else -> token.append(char)
}
}
State.SKIP -> {
when {
char == '"' -> state = State.CONSUME_ESCAPED // Skip `"`
!char.isWhitespace() -> {
state = State.CONSUME
token.append(char)
}
}
}
}
}
if (token.isNotEmpty()) {
result.add(token.toString())
}
return result
}
open class KotlinCocoapodsPlugin: Plugin<Project> {
private fun KotlinMultiplatformExtension.supportedTargets() = targets
.withType(KotlinNativeTarget::class.java)
.matching { it.konanTarget.family == Family.IOS || it.konanTarget.family == Family.OSX }
private fun createDefaultFrameworks(kotlinExtension: KotlinMultiplatformExtension) {
kotlinExtension.supportedTargets().all { target ->
target.binaries.framework {
// TODO: Add in the framework DSL.
this.freeCompilerArgs.add("-Xstatic-framework")
}
}
}
private fun createSyncTask(
project: Project,
kotlinExtension: KotlinMultiplatformExtension
) = project.whenEvaluated {
val requestedTargetName = project.findProperty(TARGET_PROPERTY)?.toString() ?: return@whenEvaluated
val requestedBuildType = project.findProperty(CONFIGURATION_PROPERTY)?.toString()?.toUpperCase() ?: return@whenEvaluated
val requestedTarget = HostManager().targetByName(requestedTargetName)
val targets = kotlinExtension.supportedTargets().matching {
it.konanTarget == requestedTarget
}
check(targets.isNotEmpty()) { "The project doesn't contain a target for the requested platform: $requestedTargetName" }
check(targets.size == 1) { "The project has more than one targets for the requested platform: $requestedTargetName" }
val framework = targets.single().binaries.getFramework(requestedBuildType)
project.tasks.create("syncFramework", Sync::class.java) {
it.group = TASK_GROUP
it.description = "Copies a framework for given platform and build type into the cocoapods build directory"
it.dependsOn(framework.linkTask)
it.from(framework.linkTask.destinationDir)
it.destinationDir = cocoapodsBuildDirs.framework
}
}
private fun createPodspecGenerationTask(
project: Project,
cocoapodsExtension: CocoapodsExtension
) {
val dummyFrameworkTask = project.tasks.create("generateDummyFramework", DummyFrameworkTask::class.java)
project.tasks.create("generatePodspec", PodspecTask::class.java) {
it.group = TASK_GROUP
it.description = "Generates a podspec file for Cocoapods import"
it.settings = cocoapodsExtension
it.dependsOn(dummyFrameworkTask)
}
}
private fun createInterops(
project: Project,
kotlinExtension: KotlinMultiplatformExtension,
cocoapodsExtension: CocoapodsExtension
) {
cocoapodsExtension.pods.all { pod ->
kotlinExtension.supportedTargets().all { target ->
target.compilations.getByName(KotlinCompilation.MAIN_COMPILATION_NAME).cinterops.create(pod.name) { interop ->
val defTask = project.tasks.create(
lowerCamelCaseName("generateDef", pod.name, target.name).asValidTaskName(),
DefFileTask::class.java
) {
it.pod = pod
it.description = "Generates a def file for Cocoapods dependency ${pod.name} for target ${target.name}"
// This task is an implementation detail so we don't add it in any group
// to avoid showing it in the `tasks` output.
}
project.tasks.getByPath(interop.interopProcessingTaskName).dependsOn(defTask)
interop.defFile = defTask.outputFile
interop.packageName = "cocoapods.${pod.moduleName}"
project.findProperty(CFLAGS_PROPERTY)?.toString()?.let { args ->
// XCode quotes around paths with spaces.
// Here and below we need to split such paths taking this into account.
interop.compilerOpts.addAll(args.splitQuotedArgs())
}
project.findProperty(HEADER_PATHS_PROPERTY)?.toString()?.let { args->
interop.compilerOpts.addAll(args.splitQuotedArgs().map { "-I$it" })
}
project.findProperty(FRAMEWORK_PATHS_PROPERTY)?.toString()?.let { args ->
interop.compilerOpts.addAll(args.splitQuotedArgs().map { "-F$it" })
}
}
}
}
}
override fun apply(project: Project): Unit = with(project) {
pluginManager.withPlugin("kotlin-multiplatform") {
val kotlinExtension = project.kotlinExtension as? KotlinMultiplatformExtension
if (kotlinExtension == null) {
logger.info("Cannot apply cocoapods plugin: Cannot cast ${project.kotlinExtension} to KotlinMultiplatformExtension")
return@withPlugin
}
val cocoapodsExtension = CocoapodsExtension(this)
kotlinExtension.addExtension(EXTENSION_NAME, cocoapodsExtension)
createDefaultFrameworks(kotlinExtension)
createSyncTask(project, kotlinExtension)
createPodspecGenerationTask(project, cocoapodsExtension)
createInterops(project, kotlinExtension, cocoapodsExtension)
}
}
companion object {
const val EXTENSION_NAME = "cocoapods"
const val TASK_GROUP = "cocoapods"
// We don't move these properties in PropertiesProvider because
// they are not intended to be overridden in local.properties.
const val TARGET_PROPERTY = "kotlin.native.cocoapods.target"
const val CONFIGURATION_PROPERTY = "kotlin.native.cocoapods.configuration"
const val CFLAGS_PROPERTY = "kotlin.native.cocoapods.cflags"
const val HEADER_PATHS_PROPERTY = "kotlin.native.cocoapods.paths.headers"
const val FRAMEWORK_PATHS_PROPERTY = "kotlin.native.cocoapods.paths.frameworks"
}
}
@@ -0,0 +1,155 @@
/*
* Copyright 2010-2019 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license
* that can be found in the license/LICENSE.txt file.
*/
package org.jetbrains.kotlin.gradle.tasks
import org.gradle.api.DefaultTask
import org.gradle.api.tasks.*
import org.jetbrains.kotlin.gradle.plugin.cocoapods.CocoapodsExtension
import org.jetbrains.kotlin.gradle.plugin.cocoapods.KotlinCocoapodsPlugin
import org.jetbrains.kotlin.gradle.plugin.cocoapods.cocoapodsBuildDirs
import java.io.File
/**
* The task generates a podspec file which allows a user to
* integrate a Kotlin/Native framework into a Cocoapods project.
*/
open class PodspecTask: DefaultTask() {
@OutputFile
val outputFile: File = project.projectDir.resolve("${project.name}.podspec")
@Nested
lateinit var settings: CocoapodsExtension
// TODO: Handle Framework name customization - rename the framework during sync process.
@TaskAction
fun generate() {
val frameworkDir = project.cocoapodsBuildDirs.framework.relativeTo(outputFile.parentFile).path
val dependencies = settings.pods.map { pod ->
val versionSuffix = if (pod.version != null) ", '${pod.version}'" else ""
"| spec.dependency '${pod.name}'$versionSuffix"
}.joinToString(separator = "\n")
val specName = project.name.replace('-', '_')
outputFile.writeText("""
|Pod::Spec.new do |spec|
| spec.name = '$specName'
| spec.version = '${settings.version}'
| spec.homepage = '${settings.homepage.orEmpty()}'
| spec.source = { :git => "Not Published", :tag => "Cocoapods/#{spec.name}/#{spec.version}" }
| spec.authors = '${settings.authors.orEmpty()}'
| spec.license = '${settings.license.orEmpty()}'
| spec.summary = '${settings.summary.orEmpty()}'
|
| spec.static_framework = true
| spec.vendored_frameworks = "$frameworkDir/#{spec.name}.framework"
| spec.libraries = "c++"
| spec.module_name = "#{spec.name}_umbrella"
|
$dependencies
|
| spec.pod_target_xcconfig = {
| 'KOTLIN_TARGET[sdk=iphonesimulator*]' => 'ios_x64',
| 'KOTLIN_TARGET[sdk=iphoneos*]' => 'ios_arm64',
| 'KOTLIN_TARGET[sdk=macosx*]' => 'macos_x64'
| }
|
| spec.script_phases = [
| {
| :name => 'Build $specName',
| :execution_position => :before_compile,
| :shell_path => '/bin/sh',
| :script => <<-SCRIPT
| set -ev
| REPO_ROOT=`realpath "${'$'}PODS_TARGET_SRCROOT"`
| ${'$'}REPO_ROOT/gradlew -p "${'$'}REPO_ROOT" syncFramework \
| -P${KotlinCocoapodsPlugin.TARGET_PROPERTY}=${'$'}KOTLIN_TARGET \
| -P${KotlinCocoapodsPlugin.CONFIGURATION_PROPERTY}=${'$'}CONFIGURATION \
| -P${KotlinCocoapodsPlugin.CFLAGS_PROPERTY}="${'$'}OTHER_CFLAGS" \
| -P${KotlinCocoapodsPlugin.HEADER_PATHS_PROPERTY}="${'$'}HEADER_SEARCH_PATHS" \
| -P${KotlinCocoapodsPlugin.FRAMEWORK_PATHS_PROPERTY}="${'$'}FRAMEWORK_SEARCH_PATHS"
| SCRIPT
| }
| ]
|end
""".trimMargin())
}
}
/**
* Creates a dummy framework in the target directory.
*
* We represent a Kotlin/Native module to Cocoapods as a vendored framework.
* Cocoapods needs access to such frameworks during installation process to obtain
* their type (static or dynamic) and configure the XCode project accordingly.
* But we cannot build the real framework before installation because it may
* depend on Cocoapods libraries which are not downloaded and built at this stage.
* So we create a dummy static framework to allow Cocoapods install our pod correctly
* and then replace it with the real one during a real build process.
*/
open class DummyFrameworkTask: DefaultTask() {
@OutputDirectory
val destinationDir = project.cocoapodsBuildDirs.framework
@get:Input
val frameworkName
get() = project.name.replace('-', '_')
private val frameworkDir: File
get() = destinationDir.resolve("$frameworkName.framework")
private fun copyResource(from: String, to: File) {
to.parentFile.mkdirs()
to.outputStream().use { file ->
javaClass.getResourceAsStream(from).use { resource ->
resource.copyTo(file)
}
}
}
private fun copyFrameworkFile(relativeFrom: String, relativeTo: String = relativeFrom) =
copyResource(
"/cocoapods/dummy.framework/$relativeFrom",
frameworkDir.resolve(relativeTo)
)
@TaskAction
fun create() {
// Reset the destination directory
with(destinationDir) {
deleteRecursively()
mkdirs()
}
// Copy files for the dummy framework.
copyFrameworkFile("Info.plist")
copyFrameworkFile("dummy", frameworkName)
copyFrameworkFile("Modules/module.modulemap")
copyFrameworkFile("Headers/dummy.h")
}
}
/**
* Generates a def-file for the given Cocoapods dependency.
*/
open class DefFileTask : DefaultTask() {
@Nested
lateinit var pod: CocoapodsExtension.CocoapodsDependency
@get:OutputFile
val outputFile: File
get() = project.cocoapodsBuildDirs.defs.resolve("${pod.name}.def")
@TaskAction
fun generate() {
outputFile.parentFile.mkdirs()
outputFile.writeText("""
language = Objective-C
modules = ${pod.moduleName}
""".trimIndent())
}
}
@@ -19,4 +19,11 @@ internal fun dashSeparatedName(nameParts: Iterable<String?>) = dashSeparatedName
internal fun dashSeparatedName(vararg nameParts: String?): String {
val nonEmptyParts = nameParts.mapNotNull { it?.takeIf(String::isNotEmpty) }
return nonEmptyParts.joinToString(separator = "-")
}
}
private val invalidTaskNameCharacters = "[/\\\\:<>\"?*|]".toRegex()
/**
* Replaces characters which are not allowed in Gradle task names (/, \, :, <, >, ", ?, *, |) with '_'
*/
internal fun String.asValidTaskName() = replace(invalidTaskNameCharacters, "_")
@@ -0,0 +1 @@
implementation-class=org.jetbrains.kotlin.gradle.plugin.cocoapods.KotlinCocoapodsPlugin
@@ -0,0 +1 @@
implementation-class=org.jetbrains.kotlin.gradle.plugin.cocoapods.KotlinCocoapodsPlugin
@@ -0,0 +1,5 @@
//! Project version number for dummy.
FOUNDATION_EXPORT double dummyVersionNumber;
//! Project version string for dummy.
FOUNDATION_EXPORT const unsigned char dummyVersionString[];
@@ -0,0 +1,6 @@
framework module dummy {
umbrella header "dummy.h"
export *
module * { export * }
}