diff --git a/libraries/tools/kotlin-gradle-plugin/build.gradle.kts b/libraries/tools/kotlin-gradle-plugin/build.gradle.kts index 77859c2dfef..771c10f52bb 100644 --- a/libraries/tools/kotlin-gradle-plugin/build.gradle.kts +++ b/libraries/tools/kotlin-gradle-plugin/build.gradle.kts @@ -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") { diff --git a/libraries/tools/kotlin-gradle-plugin/src/main/kotlin/org/jetbrains/kotlin/gradle/plugin/cocoapods/CocoapodsExtension.kt b/libraries/tools/kotlin-gradle-plugin/src/main/kotlin/org/jetbrains/kotlin/gradle/plugin/cocoapods/CocoapodsExtension.kt new file mode 100644 index 00000000000..d4bc0ad96f5 --- /dev/null +++ b/libraries/tools/kotlin-gradle-plugin/src/main/kotlin/org/jetbrains/kotlin/gradle/plugin/cocoapods/CocoapodsExtension.kt @@ -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 + 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 + 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 + } +} \ No newline at end of file diff --git a/libraries/tools/kotlin-gradle-plugin/src/main/kotlin/org/jetbrains/kotlin/gradle/plugin/cocoapods/KotlinCocoapodsPlugin.kt b/libraries/tools/kotlin-gradle-plugin/src/main/kotlin/org/jetbrains/kotlin/gradle/plugin/cocoapods/KotlinCocoapodsPlugin.kt new file mode 100644 index 00000000000..3d651b0b0ab --- /dev/null +++ b/libraries/tools/kotlin-gradle-plugin/src/main/kotlin/org/jetbrains/kotlin/gradle/plugin/cocoapods/KotlinCocoapodsPlugin.kt @@ -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 { + if (isEmpty()) { + return emptyList() + } + + var state: State = State.SKIP + val result = mutableListOf() + 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 { + + 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" + + } +} \ No newline at end of file diff --git a/libraries/tools/kotlin-gradle-plugin/src/main/kotlin/org/jetbrains/kotlin/gradle/tasks/CocoapodsTasks.kt b/libraries/tools/kotlin-gradle-plugin/src/main/kotlin/org/jetbrains/kotlin/gradle/tasks/CocoapodsTasks.kt new file mode 100644 index 00000000000..7ed9e4d4ce5 --- /dev/null +++ b/libraries/tools/kotlin-gradle-plugin/src/main/kotlin/org/jetbrains/kotlin/gradle/tasks/CocoapodsTasks.kt @@ -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()) + } +} \ No newline at end of file diff --git a/libraries/tools/kotlin-gradle-plugin/src/main/kotlin/org/jetbrains/kotlin/gradle/utils/stringUtils.kt b/libraries/tools/kotlin-gradle-plugin/src/main/kotlin/org/jetbrains/kotlin/gradle/utils/stringUtils.kt index 6da109d68af..d44372163ed 100644 --- a/libraries/tools/kotlin-gradle-plugin/src/main/kotlin/org/jetbrains/kotlin/gradle/utils/stringUtils.kt +++ b/libraries/tools/kotlin-gradle-plugin/src/main/kotlin/org/jetbrains/kotlin/gradle/utils/stringUtils.kt @@ -19,4 +19,11 @@ internal fun dashSeparatedName(nameParts: Iterable) = dashSeparatedName internal fun dashSeparatedName(vararg nameParts: String?): String { val nonEmptyParts = nameParts.mapNotNull { it?.takeIf(String::isNotEmpty) } return nonEmptyParts.joinToString(separator = "-") -} \ No newline at end of file +} + +private val invalidTaskNameCharacters = "[/\\\\:<>\"?*|]".toRegex() + +/** + * Replaces characters which are not allowed in Gradle task names (/, \, :, <, >, ", ?, *, |) with '_' + */ +internal fun String.asValidTaskName() = replace(invalidTaskNameCharacters, "_") \ No newline at end of file diff --git a/libraries/tools/kotlin-gradle-plugin/src/main/resources/META-INF/gradle-plugins/kotlin-cocoapods.properties b/libraries/tools/kotlin-gradle-plugin/src/main/resources/META-INF/gradle-plugins/kotlin-cocoapods.properties new file mode 100644 index 00000000000..ca158c10e62 --- /dev/null +++ b/libraries/tools/kotlin-gradle-plugin/src/main/resources/META-INF/gradle-plugins/kotlin-cocoapods.properties @@ -0,0 +1 @@ +implementation-class=org.jetbrains.kotlin.gradle.plugin.cocoapods.KotlinCocoapodsPlugin \ No newline at end of file diff --git a/libraries/tools/kotlin-gradle-plugin/src/main/resources/META-INF/gradle-plugins/org.jetbrains.kotlin.cocoapods.properties b/libraries/tools/kotlin-gradle-plugin/src/main/resources/META-INF/gradle-plugins/org.jetbrains.kotlin.cocoapods.properties new file mode 100644 index 00000000000..ca158c10e62 --- /dev/null +++ b/libraries/tools/kotlin-gradle-plugin/src/main/resources/META-INF/gradle-plugins/org.jetbrains.kotlin.cocoapods.properties @@ -0,0 +1 @@ +implementation-class=org.jetbrains.kotlin.gradle.plugin.cocoapods.KotlinCocoapodsPlugin \ No newline at end of file diff --git a/libraries/tools/kotlin-gradle-plugin/src/main/resources/cocoapods/dummy.framework/Headers/dummy.h b/libraries/tools/kotlin-gradle-plugin/src/main/resources/cocoapods/dummy.framework/Headers/dummy.h new file mode 100644 index 00000000000..a91320276ef --- /dev/null +++ b/libraries/tools/kotlin-gradle-plugin/src/main/resources/cocoapods/dummy.framework/Headers/dummy.h @@ -0,0 +1,5 @@ +//! Project version number for dummy. +FOUNDATION_EXPORT double dummyVersionNumber; + +//! Project version string for dummy. +FOUNDATION_EXPORT const unsigned char dummyVersionString[]; diff --git a/libraries/tools/kotlin-gradle-plugin/src/main/resources/cocoapods/dummy.framework/Info.plist b/libraries/tools/kotlin-gradle-plugin/src/main/resources/cocoapods/dummy.framework/Info.plist new file mode 100644 index 00000000000..8fcb3c949a9 Binary files /dev/null and b/libraries/tools/kotlin-gradle-plugin/src/main/resources/cocoapods/dummy.framework/Info.plist differ diff --git a/libraries/tools/kotlin-gradle-plugin/src/main/resources/cocoapods/dummy.framework/Modules/module.modulemap b/libraries/tools/kotlin-gradle-plugin/src/main/resources/cocoapods/dummy.framework/Modules/module.modulemap new file mode 100644 index 00000000000..9a96072c648 --- /dev/null +++ b/libraries/tools/kotlin-gradle-plugin/src/main/resources/cocoapods/dummy.framework/Modules/module.modulemap @@ -0,0 +1,6 @@ +framework module dummy { + umbrella header "dummy.h" + + export * + module * { export * } +} diff --git a/libraries/tools/kotlin-gradle-plugin/src/main/resources/cocoapods/dummy.framework/dummy b/libraries/tools/kotlin-gradle-plugin/src/main/resources/cocoapods/dummy.framework/dummy new file mode 100755 index 00000000000..861837242d0 Binary files /dev/null and b/libraries/tools/kotlin-gradle-plugin/src/main/resources/cocoapods/dummy.framework/dummy differ