diff --git a/libraries/tools/kotlin-gradle-plugin/src/common/kotlin/org/jetbrains/kotlin/gradle/targets/native/tasks/AdvancedCocoapodsTasks.kt b/libraries/tools/kotlin-gradle-plugin/src/common/kotlin/org/jetbrains/kotlin/gradle/targets/native/cocoapods/PodBuildSettingsProperties.kt similarity index 100% rename from libraries/tools/kotlin-gradle-plugin/src/common/kotlin/org/jetbrains/kotlin/gradle/targets/native/tasks/AdvancedCocoapodsTasks.kt rename to libraries/tools/kotlin-gradle-plugin/src/common/kotlin/org/jetbrains/kotlin/gradle/targets/native/cocoapods/PodBuildSettingsProperties.kt diff --git a/libraries/tools/kotlin-gradle-plugin/src/common/kotlin/org/jetbrains/kotlin/gradle/targets/native/cocoapods/tasks/AbstractPodInstallTask.kt b/libraries/tools/kotlin-gradle-plugin/src/common/kotlin/org/jetbrains/kotlin/gradle/targets/native/cocoapods/tasks/AbstractPodInstallTask.kt new file mode 100644 index 00000000000..8a04a217427 --- /dev/null +++ b/libraries/tools/kotlin-gradle-plugin/src/common/kotlin/org/jetbrains/kotlin/gradle/targets/native/cocoapods/tasks/AbstractPodInstallTask.kt @@ -0,0 +1,524 @@ +/* + * Copyright 2010-2020 JetBrains s.r.o. and Kotlin Programming Language contributors. + * Use of this source code is governed by the Apache 2.0 license that can be found in the license/LICENSE.txt file. + */ + +@file:Suppress("LeakingThis") // All tasks should be inherited only by Gradle + +package org.jetbrains.kotlin.gradle.targets.native.tasks + +import org.gradle.api.DefaultTask +import org.gradle.api.file.FileCollection +import org.gradle.api.file.FileTree +import org.gradle.api.provider.ListProperty +import org.gradle.api.provider.Property +import org.gradle.api.provider.Provider +import org.gradle.api.tasks.* +import org.gradle.api.tasks.Optional +import org.jetbrains.kotlin.gradle.plugin.cocoapods.CocoapodsExtension.* +import org.jetbrains.kotlin.gradle.plugin.cocoapods.CocoapodsExtension.CocoapodsDependency.PodLocation.* +import org.jetbrains.kotlin.gradle.plugin.cocoapods.cocoapodsBuildDirs +import org.jetbrains.kotlin.gradle.plugin.cocoapods.platformLiteral +import org.jetbrains.kotlin.gradle.targets.native.cocoapods.MissingCocoapodsMessage +import org.jetbrains.kotlin.gradle.targets.native.cocoapods.MissingSpecReposMessage +import org.jetbrains.kotlin.gradle.utils.runCommand +import org.jetbrains.kotlin.konan.target.Family +import org.jetbrains.kotlin.konan.target.HostManager +import java.io.File +import java.io.IOException +import java.io.Reader +import java.util.* + +val CocoapodsDependency.schemeName: String + get() = name.split("/")[0] + + +open class CocoapodsTask : DefaultTask() { + init { + onlyIf { + HostManager.hostIsMac + } + } +} + + +/** + * The task takes the path to the Podfile and calls `pod install` + * to obtain sources or artifacts for the declared dependencies. + * This task is a part of CocoaPods integration infrastructure. + */ +abstract class AbstractPodInstallTask : CocoapodsTask() { + init { + onlyIf { podfile.isPresent } + } + + @get:Optional + @get:InputFile + abstract val podfile: Property + + @get:Internal + protected val workingDir: Provider = podfile.map { file: File? -> + requireNotNull(file) { "Task outputs shouldn't be queried if it's skipped" }.parentFile + } + + @get:OutputDirectory + internal val podsDir: Provider = workingDir.map { it.resolve("Pods") } + + @get:Internal + internal val podsXcodeProjDirProvider: Provider = podsDir.map { it.resolve("Pods.xcodeproj") } + + @TaskAction + open fun doPodInstall() { + val podInstallCommand = listOf("pod", "install") + + runCommand(podInstallCommand, + logger, + errorHandler = ::handleError, + exceptionHandler = { e: IOException -> + CocoapodsErrorHandlingUtil.handle(e, podInstallCommand) + }, + processConfiguration = { + directory(workingDir.get()) + }) + + with(podsXcodeProjDirProvider.get()) { + check(exists() && isDirectory) { + "The directory 'Pods/Pods.xcodeproj' was not created as a result of the `pod install` call." + } + } + } + + abstract fun handleError(retCode: Int, error: String, process: Process): String? +} + +abstract class PodInstallTask : AbstractPodInstallTask() { + + @get:Optional + @get:InputFile + abstract val podspec: Property + + @get:Input + abstract val frameworkName: Property + + @get:Nested + abstract val specRepos: Property + + @get:Nested + abstract val pods: ListProperty + + @get:InputDirectory + abstract val dummyFramework: Property + + private val framework = project.provider { project.cocoapodsBuildDirs.framework.resolve("${frameworkName.get()}.framework") } + private val tmpFramework = dummyFramework.map { dummy -> dummy.parentFile.resolve("tmp.framework").also { it.deleteOnExit() } } + + override fun doPodInstall() { + // We always need to execute 'pod install' with the dummy framework because the one left from a previous build + // may have a wrong linkage type. So we temporarily swap them, run 'pod install' and then swap them back + framework.rename(tmpFramework) + dummyFramework.rename(framework) + super.doPodInstall() + framework.rename(dummyFramework) + tmpFramework.rename(framework) + } + + private fun Provider.rename(dest: Provider) = get().rename(dest.get()) + + private fun File.rename(dest: File) { + if (!exists()) { + mkdirs() + } + + check(renameTo(dest)) { "Can't rename '${this}' to '${dest}'" } + } + + override fun handleError(retCode: Int, error: String, process: Process): String? { + val specReposMessages = MissingSpecReposMessage(specRepos.get()).missingMessage + val cocoapodsMessages = pods.get().map { MissingCocoapodsMessage(it).missingMessage } + + return listOfNotNull( + "'pod install' command failed with code $retCode.", + "Error message:", + error.lines().filter { it.isNotBlank() }.joinToString("\n"), + """ + | Please, check that podfile contains following lines in header: + | $specReposMessages + | + """.trimMargin(), + """ + | Please, check that each target depended on ${frameworkName.get()} contains following dependencies: + | ${cocoapodsMessages.joinToString("\n")} + | + """.trimMargin() + + ).joinToString("\n") + } +} + +abstract class PodInstallSyntheticTask : AbstractPodInstallTask() { + + @get:Input + abstract val family: Property + + @get:Input + abstract val podName: Property + + @get:OutputDirectory + internal val syntheticXcodeProject: Provider = workingDir.map { it.resolve("synthetic.xcodeproj") } + + override fun doPodInstall() { + val projResource = "/cocoapods/project.pbxproj" + val projDestination = syntheticXcodeProject.get().resolve("project.pbxproj") + + syntheticXcodeProject.get().mkdirs() + projDestination.outputStream().use { file -> + javaClass.getResourceAsStream(projResource)!!.use { resource -> + resource.copyTo(file) + } + } + + super.doPodInstall() + } + + override fun handleError(retCode: Int, error: String, process: Process): String? { + var message = """ + |'pod install' command on the synthetic project failed with return code: $retCode + | + | Error: ${error.lines().filter { it.contains("[!]") }.joinToString("\n")} + | + """.trimMargin() + + if ( + error.contains("deployment target") || + error.contains("no platform was specified") || + error.contains(Regex("The platform of the target .+ is not compatible with `${podName.get()}")) + ) { + message += """ + | + | Possible reason: ${family.get().platformLiteral} deployment target is not configured + | Configure deployment_target for ALL targets as follows: + | cocoapods { + | ... + | ${family.get().platformLiteral}.deploymentTarget = "..." + | ... + | } + | + """.trimMargin() + return message + } else if ( + error.contains("Unable to add a source with url") || + error.contains("Couldn't determine repo name for URL") || + error.contains("Unable to find a specification") + ) { + message += """ + | + | Possible reason: spec repos are not configured correctly. + | Ensure that spec repos are correctly configured for all private pod dependencies: + | cocoapods { + | specRepos { + | url("") + | } + | } + | + """.trimMargin() + return message + } else { + return null + } + } +} + +/** + * The task generates a synthetic project with all cocoapods dependencies + */ +abstract class PodGenTask : CocoapodsTask() { + + init { + onlyIf { + pods.get().isNotEmpty() + } + } + + @get:InputFile + internal abstract val podspec: Property + + @get:Input + internal abstract val podName: Property + + @get:Input + internal abstract val useLibraries: Property + + @get:Input + internal abstract val family: Property + + @get:Nested + internal abstract val platformSettings: Property + + @get:Nested + internal abstract val specRepos: Property + + @get:Nested + internal abstract val pods: ListProperty + + @get:OutputFile + val podfile: Provider = family.map { project.cocoapodsBuildDirs.synthetic(it).resolve("Podfile") } + + @TaskAction + fun generate() { + val specRepos = specRepos.get().getAll() + + val podfile = this.podfile.get() + podfile.createNewFile() + + val podfileContent = getPodfileContent(specRepos, family.get().platformLiteral) + podfile.writeText(podfileContent) + } + + private fun getPodfileContent(specRepos: Collection, xcodeTarget: String) = + buildString { + + specRepos.forEach { + appendLine("source '$it'") + } + + appendLine("target '$xcodeTarget' do") + if (useLibraries.get().not()) { + appendLine("\tuse_frameworks!") + } + val settings = platformSettings.get() + val deploymentTarget = settings.deploymentTarget + if (deploymentTarget != null) { + appendLine("\tplatform :${settings.name}, '$deploymentTarget'") + } else { + appendLine("\tplatform :${settings.name}") + } + pods.get().mapNotNull { + buildString { + append("pod '${it.name}'") + + val version = it.version + val source = it.source + + if (source != null) { + append(", ${source.getPodSourcePath()}") + } else if (version != null) { + append(", '$version'") + } + + } + }.forEach { appendLine("\t$it") } + appendLine("end\n") + //disable signing for all synthetic pods KT-54314 + append( + """ + post_install do |installer| + installer.pods_project.targets.each do |target| + target.build_configurations.each do |config| + config.build_settings['EXPANDED_CODE_SIGN_IDENTITY'] = "" + config.build_settings['CODE_SIGNING_REQUIRED'] = "NO" + config.build_settings['CODE_SIGNING_ALLOWED'] = "NO" + end + end + end + """.trimIndent() + ) + appendLine() + } +} + + +open class PodSetupBuildTask : CocoapodsTask() { + + @get:Input + lateinit var frameworkName: Provider + + @get:Input + internal lateinit var sdk: Provider + + @get:Nested + lateinit var pod: Provider + + @get:OutputFile + val buildSettingsFile: Provider = project.provider { + project.cocoapodsBuildDirs + .buildSettings + .resolve(getBuildSettingFileName(pod.get(), sdk.get())) + } + + @get:Internal + internal lateinit var podsXcodeProjDir: Provider + + @TaskAction + fun setupBuild() { + val podsXcodeProjDir = podsXcodeProjDir.get() + + val buildSettingsReceivingCommand = listOf( + "xcodebuild", "-showBuildSettings", + "-project", podsXcodeProjDir.name, + "-scheme", pod.get().schemeName, + "-sdk", sdk.get() + ) + + val outputText = runCommand(buildSettingsReceivingCommand, project.logger) { directory(podsXcodeProjDir.parentFile) } + + val buildSettingsProperties = PodBuildSettingsProperties.readSettingsFromReader(outputText.reader()) + buildSettingsFile.get().let { bsf -> + buildSettingsProperties.writeSettings(bsf) + } + } +} + +private fun getBuildSettingFileName(pod: CocoapodsDependency, sdk: String): String = + "build-settings-$sdk-${pod.schemeName}.properties" + +/** + * The task compiles external cocoa pods sources. + */ +open class PodBuildTask : CocoapodsTask() { + + @get:PathSensitive(PathSensitivity.ABSOLUTE) + @get:InputFile + lateinit var buildSettingsFile: Provider + internal set + + @get:Nested + internal lateinit var pod: Provider + + @get:PathSensitive(PathSensitivity.ABSOLUTE) + @get:IgnoreEmptyDirectories + @get:InputFiles + internal val srcDir: FileTree + get() = project.fileTree( + buildSettingsFile.map { PodBuildSettingsProperties.readSettingsFromReader(it.reader()).podsTargetSrcRoot } + ) + + @get:Internal + internal var buildDir: Provider = project.provider { + project.file(PodBuildSettingsProperties.readSettingsFromReader(buildSettingsFile.get().reader()).buildDir) + } + + @get:Input + internal lateinit var sdk: Provider + + @Suppress("unused") // declares an ouptut + @get:OutputFiles + internal val buildResult: Provider = project.provider { + project.fileTree(buildDir.get()) { + it.include("**/${pod.get().schemeName}.*/") + it.include("**/${pod.get().schemeName}/") + } + } + + @get:Internal + internal lateinit var podsXcodeProjDir: Provider + + @TaskAction + fun buildDependencies() { + val podBuildSettings = PodBuildSettingsProperties.readSettingsFromReader(buildSettingsFile.get().reader()) + + val podsXcodeProjDir = podsXcodeProjDir.get() + + val podXcodeBuildCommand = listOf( + "xcodebuild", + "-project", podsXcodeProjDir.name, + "-scheme", pod.get().schemeName, + "-sdk", sdk.get(), + "-configuration", podBuildSettings.configuration + ) + + runCommand(podXcodeBuildCommand, project.logger) { directory(podsXcodeProjDir.parentFile) } + } +} + + +data class PodBuildSettingsProperties( + internal val buildDir: String, + internal val configuration: String, + val configurationBuildDir: String, + internal val podsTargetSrcRoot: String, + internal val cflags: String? = null, + internal val headerPaths: String? = null, + internal val publicHeadersFolderPath: String? = null, + internal val frameworkPaths: String? = null +) { + + fun writeSettings( + buildSettingsFile: File + ) { + buildSettingsFile.parentFile.mkdirs() + buildSettingsFile.delete() + buildSettingsFile.createNewFile() + + check(buildSettingsFile.exists()) { "Unable to create file ${buildSettingsFile.path}!" } + + with(buildSettingsFile) { + appendText("$BUILD_DIR=$buildDir\n") + appendText("$CONFIGURATION=$configuration\n") + appendText("$CONFIGURATION_BUILD_DIR=$configurationBuildDir\n") + appendText("$PODS_TARGET_SRCROOT=$podsTargetSrcRoot\n") + cflags?.let { appendText("$OTHER_CFLAGS=$it\n") } + headerPaths?.let { appendText("$HEADER_SEARCH_PATHS=$it\n") } + publicHeadersFolderPath?.let { appendText("$PUBLIC_HEADERS_FOLDER_PATH=$it\n") } + frameworkPaths?.let { appendText("$FRAMEWORK_SEARCH_PATHS=$it") } + } + } + + companion object { + const val BUILD_DIR = "BUILD_DIR" + const val CONFIGURATION = "CONFIGURATION" + const val CONFIGURATION_BUILD_DIR = "CONFIGURATION_BUILD_DIR" + const val PODS_TARGET_SRCROOT = "PODS_TARGET_SRCROOT" + const val OTHER_CFLAGS = "OTHER_CFLAGS" + const val HEADER_SEARCH_PATHS = "HEADER_SEARCH_PATHS" + const val PUBLIC_HEADERS_FOLDER_PATH = "PUBLIC_HEADERS_FOLDER_PATH" + const val FRAMEWORK_SEARCH_PATHS = "FRAMEWORK_SEARCH_PATHS" + + fun readSettingsFromReader(reader: Reader): PodBuildSettingsProperties { + with(Properties()) { + @Suppress("BlockingMethodInNonBlockingContext") // It's ok to do blocking call here + load(reader) + return PodBuildSettingsProperties( + readProperty(BUILD_DIR), + readProperty(CONFIGURATION), + readProperty(CONFIGURATION_BUILD_DIR), + readProperty(PODS_TARGET_SRCROOT), + readNullableProperty(OTHER_CFLAGS), + readNullableProperty(HEADER_SEARCH_PATHS), + readNullableProperty(PUBLIC_HEADERS_FOLDER_PATH), + readNullableProperty(FRAMEWORK_SEARCH_PATHS) + ) + } + } + + private fun Properties.readProperty(propertyName: String) = + readNullableProperty(propertyName) ?: error("$propertyName property is absent") + + private fun Properties.readNullableProperty(propertyName: String) = + getProperty(propertyName) + } +} + +private object CocoapodsErrorHandlingUtil { + fun handle(e: IOException, command: List) { + if (e.message?.contains("No such file or directory") == true) { + val message = """ + |'${command.take(2).joinToString(" ")}' command failed with an exception: + | ${e.message} + | + | Full command: ${command.joinToString(" ")} + | + | Possible reason: CocoaPods is not installed + | Please check that CocoaPods v1.10 or above is installed. + | + | To check CocoaPods version type 'pod --version' in the terminal + | + | To install CocoaPods execute 'sudo gem install cocoapods' + | + """.trimMargin() + throw IllegalStateException(message) + } else { + throw e + } + } + +} diff --git a/libraries/tools/kotlin-gradle-plugin/src/common/kotlin/org/jetbrains/kotlin/gradle/targets/native/cocoapods/tasks/CocoapodsTask.kt b/libraries/tools/kotlin-gradle-plugin/src/common/kotlin/org/jetbrains/kotlin/gradle/targets/native/cocoapods/tasks/CocoapodsTask.kt new file mode 100644 index 00000000000..8a04a217427 --- /dev/null +++ b/libraries/tools/kotlin-gradle-plugin/src/common/kotlin/org/jetbrains/kotlin/gradle/targets/native/cocoapods/tasks/CocoapodsTask.kt @@ -0,0 +1,524 @@ +/* + * Copyright 2010-2020 JetBrains s.r.o. and Kotlin Programming Language contributors. + * Use of this source code is governed by the Apache 2.0 license that can be found in the license/LICENSE.txt file. + */ + +@file:Suppress("LeakingThis") // All tasks should be inherited only by Gradle + +package org.jetbrains.kotlin.gradle.targets.native.tasks + +import org.gradle.api.DefaultTask +import org.gradle.api.file.FileCollection +import org.gradle.api.file.FileTree +import org.gradle.api.provider.ListProperty +import org.gradle.api.provider.Property +import org.gradle.api.provider.Provider +import org.gradle.api.tasks.* +import org.gradle.api.tasks.Optional +import org.jetbrains.kotlin.gradle.plugin.cocoapods.CocoapodsExtension.* +import org.jetbrains.kotlin.gradle.plugin.cocoapods.CocoapodsExtension.CocoapodsDependency.PodLocation.* +import org.jetbrains.kotlin.gradle.plugin.cocoapods.cocoapodsBuildDirs +import org.jetbrains.kotlin.gradle.plugin.cocoapods.platformLiteral +import org.jetbrains.kotlin.gradle.targets.native.cocoapods.MissingCocoapodsMessage +import org.jetbrains.kotlin.gradle.targets.native.cocoapods.MissingSpecReposMessage +import org.jetbrains.kotlin.gradle.utils.runCommand +import org.jetbrains.kotlin.konan.target.Family +import org.jetbrains.kotlin.konan.target.HostManager +import java.io.File +import java.io.IOException +import java.io.Reader +import java.util.* + +val CocoapodsDependency.schemeName: String + get() = name.split("/")[0] + + +open class CocoapodsTask : DefaultTask() { + init { + onlyIf { + HostManager.hostIsMac + } + } +} + + +/** + * The task takes the path to the Podfile and calls `pod install` + * to obtain sources or artifacts for the declared dependencies. + * This task is a part of CocoaPods integration infrastructure. + */ +abstract class AbstractPodInstallTask : CocoapodsTask() { + init { + onlyIf { podfile.isPresent } + } + + @get:Optional + @get:InputFile + abstract val podfile: Property + + @get:Internal + protected val workingDir: Provider = podfile.map { file: File? -> + requireNotNull(file) { "Task outputs shouldn't be queried if it's skipped" }.parentFile + } + + @get:OutputDirectory + internal val podsDir: Provider = workingDir.map { it.resolve("Pods") } + + @get:Internal + internal val podsXcodeProjDirProvider: Provider = podsDir.map { it.resolve("Pods.xcodeproj") } + + @TaskAction + open fun doPodInstall() { + val podInstallCommand = listOf("pod", "install") + + runCommand(podInstallCommand, + logger, + errorHandler = ::handleError, + exceptionHandler = { e: IOException -> + CocoapodsErrorHandlingUtil.handle(e, podInstallCommand) + }, + processConfiguration = { + directory(workingDir.get()) + }) + + with(podsXcodeProjDirProvider.get()) { + check(exists() && isDirectory) { + "The directory 'Pods/Pods.xcodeproj' was not created as a result of the `pod install` call." + } + } + } + + abstract fun handleError(retCode: Int, error: String, process: Process): String? +} + +abstract class PodInstallTask : AbstractPodInstallTask() { + + @get:Optional + @get:InputFile + abstract val podspec: Property + + @get:Input + abstract val frameworkName: Property + + @get:Nested + abstract val specRepos: Property + + @get:Nested + abstract val pods: ListProperty + + @get:InputDirectory + abstract val dummyFramework: Property + + private val framework = project.provider { project.cocoapodsBuildDirs.framework.resolve("${frameworkName.get()}.framework") } + private val tmpFramework = dummyFramework.map { dummy -> dummy.parentFile.resolve("tmp.framework").also { it.deleteOnExit() } } + + override fun doPodInstall() { + // We always need to execute 'pod install' with the dummy framework because the one left from a previous build + // may have a wrong linkage type. So we temporarily swap them, run 'pod install' and then swap them back + framework.rename(tmpFramework) + dummyFramework.rename(framework) + super.doPodInstall() + framework.rename(dummyFramework) + tmpFramework.rename(framework) + } + + private fun Provider.rename(dest: Provider) = get().rename(dest.get()) + + private fun File.rename(dest: File) { + if (!exists()) { + mkdirs() + } + + check(renameTo(dest)) { "Can't rename '${this}' to '${dest}'" } + } + + override fun handleError(retCode: Int, error: String, process: Process): String? { + val specReposMessages = MissingSpecReposMessage(specRepos.get()).missingMessage + val cocoapodsMessages = pods.get().map { MissingCocoapodsMessage(it).missingMessage } + + return listOfNotNull( + "'pod install' command failed with code $retCode.", + "Error message:", + error.lines().filter { it.isNotBlank() }.joinToString("\n"), + """ + | Please, check that podfile contains following lines in header: + | $specReposMessages + | + """.trimMargin(), + """ + | Please, check that each target depended on ${frameworkName.get()} contains following dependencies: + | ${cocoapodsMessages.joinToString("\n")} + | + """.trimMargin() + + ).joinToString("\n") + } +} + +abstract class PodInstallSyntheticTask : AbstractPodInstallTask() { + + @get:Input + abstract val family: Property + + @get:Input + abstract val podName: Property + + @get:OutputDirectory + internal val syntheticXcodeProject: Provider = workingDir.map { it.resolve("synthetic.xcodeproj") } + + override fun doPodInstall() { + val projResource = "/cocoapods/project.pbxproj" + val projDestination = syntheticXcodeProject.get().resolve("project.pbxproj") + + syntheticXcodeProject.get().mkdirs() + projDestination.outputStream().use { file -> + javaClass.getResourceAsStream(projResource)!!.use { resource -> + resource.copyTo(file) + } + } + + super.doPodInstall() + } + + override fun handleError(retCode: Int, error: String, process: Process): String? { + var message = """ + |'pod install' command on the synthetic project failed with return code: $retCode + | + | Error: ${error.lines().filter { it.contains("[!]") }.joinToString("\n")} + | + """.trimMargin() + + if ( + error.contains("deployment target") || + error.contains("no platform was specified") || + error.contains(Regex("The platform of the target .+ is not compatible with `${podName.get()}")) + ) { + message += """ + | + | Possible reason: ${family.get().platformLiteral} deployment target is not configured + | Configure deployment_target for ALL targets as follows: + | cocoapods { + | ... + | ${family.get().platformLiteral}.deploymentTarget = "..." + | ... + | } + | + """.trimMargin() + return message + } else if ( + error.contains("Unable to add a source with url") || + error.contains("Couldn't determine repo name for URL") || + error.contains("Unable to find a specification") + ) { + message += """ + | + | Possible reason: spec repos are not configured correctly. + | Ensure that spec repos are correctly configured for all private pod dependencies: + | cocoapods { + | specRepos { + | url("") + | } + | } + | + """.trimMargin() + return message + } else { + return null + } + } +} + +/** + * The task generates a synthetic project with all cocoapods dependencies + */ +abstract class PodGenTask : CocoapodsTask() { + + init { + onlyIf { + pods.get().isNotEmpty() + } + } + + @get:InputFile + internal abstract val podspec: Property + + @get:Input + internal abstract val podName: Property + + @get:Input + internal abstract val useLibraries: Property + + @get:Input + internal abstract val family: Property + + @get:Nested + internal abstract val platformSettings: Property + + @get:Nested + internal abstract val specRepos: Property + + @get:Nested + internal abstract val pods: ListProperty + + @get:OutputFile + val podfile: Provider = family.map { project.cocoapodsBuildDirs.synthetic(it).resolve("Podfile") } + + @TaskAction + fun generate() { + val specRepos = specRepos.get().getAll() + + val podfile = this.podfile.get() + podfile.createNewFile() + + val podfileContent = getPodfileContent(specRepos, family.get().platformLiteral) + podfile.writeText(podfileContent) + } + + private fun getPodfileContent(specRepos: Collection, xcodeTarget: String) = + buildString { + + specRepos.forEach { + appendLine("source '$it'") + } + + appendLine("target '$xcodeTarget' do") + if (useLibraries.get().not()) { + appendLine("\tuse_frameworks!") + } + val settings = platformSettings.get() + val deploymentTarget = settings.deploymentTarget + if (deploymentTarget != null) { + appendLine("\tplatform :${settings.name}, '$deploymentTarget'") + } else { + appendLine("\tplatform :${settings.name}") + } + pods.get().mapNotNull { + buildString { + append("pod '${it.name}'") + + val version = it.version + val source = it.source + + if (source != null) { + append(", ${source.getPodSourcePath()}") + } else if (version != null) { + append(", '$version'") + } + + } + }.forEach { appendLine("\t$it") } + appendLine("end\n") + //disable signing for all synthetic pods KT-54314 + append( + """ + post_install do |installer| + installer.pods_project.targets.each do |target| + target.build_configurations.each do |config| + config.build_settings['EXPANDED_CODE_SIGN_IDENTITY'] = "" + config.build_settings['CODE_SIGNING_REQUIRED'] = "NO" + config.build_settings['CODE_SIGNING_ALLOWED'] = "NO" + end + end + end + """.trimIndent() + ) + appendLine() + } +} + + +open class PodSetupBuildTask : CocoapodsTask() { + + @get:Input + lateinit var frameworkName: Provider + + @get:Input + internal lateinit var sdk: Provider + + @get:Nested + lateinit var pod: Provider + + @get:OutputFile + val buildSettingsFile: Provider = project.provider { + project.cocoapodsBuildDirs + .buildSettings + .resolve(getBuildSettingFileName(pod.get(), sdk.get())) + } + + @get:Internal + internal lateinit var podsXcodeProjDir: Provider + + @TaskAction + fun setupBuild() { + val podsXcodeProjDir = podsXcodeProjDir.get() + + val buildSettingsReceivingCommand = listOf( + "xcodebuild", "-showBuildSettings", + "-project", podsXcodeProjDir.name, + "-scheme", pod.get().schemeName, + "-sdk", sdk.get() + ) + + val outputText = runCommand(buildSettingsReceivingCommand, project.logger) { directory(podsXcodeProjDir.parentFile) } + + val buildSettingsProperties = PodBuildSettingsProperties.readSettingsFromReader(outputText.reader()) + buildSettingsFile.get().let { bsf -> + buildSettingsProperties.writeSettings(bsf) + } + } +} + +private fun getBuildSettingFileName(pod: CocoapodsDependency, sdk: String): String = + "build-settings-$sdk-${pod.schemeName}.properties" + +/** + * The task compiles external cocoa pods sources. + */ +open class PodBuildTask : CocoapodsTask() { + + @get:PathSensitive(PathSensitivity.ABSOLUTE) + @get:InputFile + lateinit var buildSettingsFile: Provider + internal set + + @get:Nested + internal lateinit var pod: Provider + + @get:PathSensitive(PathSensitivity.ABSOLUTE) + @get:IgnoreEmptyDirectories + @get:InputFiles + internal val srcDir: FileTree + get() = project.fileTree( + buildSettingsFile.map { PodBuildSettingsProperties.readSettingsFromReader(it.reader()).podsTargetSrcRoot } + ) + + @get:Internal + internal var buildDir: Provider = project.provider { + project.file(PodBuildSettingsProperties.readSettingsFromReader(buildSettingsFile.get().reader()).buildDir) + } + + @get:Input + internal lateinit var sdk: Provider + + @Suppress("unused") // declares an ouptut + @get:OutputFiles + internal val buildResult: Provider = project.provider { + project.fileTree(buildDir.get()) { + it.include("**/${pod.get().schemeName}.*/") + it.include("**/${pod.get().schemeName}/") + } + } + + @get:Internal + internal lateinit var podsXcodeProjDir: Provider + + @TaskAction + fun buildDependencies() { + val podBuildSettings = PodBuildSettingsProperties.readSettingsFromReader(buildSettingsFile.get().reader()) + + val podsXcodeProjDir = podsXcodeProjDir.get() + + val podXcodeBuildCommand = listOf( + "xcodebuild", + "-project", podsXcodeProjDir.name, + "-scheme", pod.get().schemeName, + "-sdk", sdk.get(), + "-configuration", podBuildSettings.configuration + ) + + runCommand(podXcodeBuildCommand, project.logger) { directory(podsXcodeProjDir.parentFile) } + } +} + + +data class PodBuildSettingsProperties( + internal val buildDir: String, + internal val configuration: String, + val configurationBuildDir: String, + internal val podsTargetSrcRoot: String, + internal val cflags: String? = null, + internal val headerPaths: String? = null, + internal val publicHeadersFolderPath: String? = null, + internal val frameworkPaths: String? = null +) { + + fun writeSettings( + buildSettingsFile: File + ) { + buildSettingsFile.parentFile.mkdirs() + buildSettingsFile.delete() + buildSettingsFile.createNewFile() + + check(buildSettingsFile.exists()) { "Unable to create file ${buildSettingsFile.path}!" } + + with(buildSettingsFile) { + appendText("$BUILD_DIR=$buildDir\n") + appendText("$CONFIGURATION=$configuration\n") + appendText("$CONFIGURATION_BUILD_DIR=$configurationBuildDir\n") + appendText("$PODS_TARGET_SRCROOT=$podsTargetSrcRoot\n") + cflags?.let { appendText("$OTHER_CFLAGS=$it\n") } + headerPaths?.let { appendText("$HEADER_SEARCH_PATHS=$it\n") } + publicHeadersFolderPath?.let { appendText("$PUBLIC_HEADERS_FOLDER_PATH=$it\n") } + frameworkPaths?.let { appendText("$FRAMEWORK_SEARCH_PATHS=$it") } + } + } + + companion object { + const val BUILD_DIR = "BUILD_DIR" + const val CONFIGURATION = "CONFIGURATION" + const val CONFIGURATION_BUILD_DIR = "CONFIGURATION_BUILD_DIR" + const val PODS_TARGET_SRCROOT = "PODS_TARGET_SRCROOT" + const val OTHER_CFLAGS = "OTHER_CFLAGS" + const val HEADER_SEARCH_PATHS = "HEADER_SEARCH_PATHS" + const val PUBLIC_HEADERS_FOLDER_PATH = "PUBLIC_HEADERS_FOLDER_PATH" + const val FRAMEWORK_SEARCH_PATHS = "FRAMEWORK_SEARCH_PATHS" + + fun readSettingsFromReader(reader: Reader): PodBuildSettingsProperties { + with(Properties()) { + @Suppress("BlockingMethodInNonBlockingContext") // It's ok to do blocking call here + load(reader) + return PodBuildSettingsProperties( + readProperty(BUILD_DIR), + readProperty(CONFIGURATION), + readProperty(CONFIGURATION_BUILD_DIR), + readProperty(PODS_TARGET_SRCROOT), + readNullableProperty(OTHER_CFLAGS), + readNullableProperty(HEADER_SEARCH_PATHS), + readNullableProperty(PUBLIC_HEADERS_FOLDER_PATH), + readNullableProperty(FRAMEWORK_SEARCH_PATHS) + ) + } + } + + private fun Properties.readProperty(propertyName: String) = + readNullableProperty(propertyName) ?: error("$propertyName property is absent") + + private fun Properties.readNullableProperty(propertyName: String) = + getProperty(propertyName) + } +} + +private object CocoapodsErrorHandlingUtil { + fun handle(e: IOException, command: List) { + if (e.message?.contains("No such file or directory") == true) { + val message = """ + |'${command.take(2).joinToString(" ")}' command failed with an exception: + | ${e.message} + | + | Full command: ${command.joinToString(" ")} + | + | Possible reason: CocoaPods is not installed + | Please check that CocoaPods v1.10 or above is installed. + | + | To check CocoaPods version type 'pod --version' in the terminal + | + | To install CocoaPods execute 'sudo gem install cocoapods' + | + """.trimMargin() + throw IllegalStateException(message) + } else { + throw e + } + } + +} diff --git a/libraries/tools/kotlin-gradle-plugin/src/common/kotlin/org/jetbrains/kotlin/gradle/targets/native/tasks/CocoapodsTasks.kt b/libraries/tools/kotlin-gradle-plugin/src/common/kotlin/org/jetbrains/kotlin/gradle/targets/native/cocoapods/tasks/DefFileTask.kt similarity index 100% rename from libraries/tools/kotlin-gradle-plugin/src/common/kotlin/org/jetbrains/kotlin/gradle/targets/native/tasks/CocoapodsTasks.kt rename to libraries/tools/kotlin-gradle-plugin/src/common/kotlin/org/jetbrains/kotlin/gradle/targets/native/cocoapods/tasks/DefFileTask.kt diff --git a/libraries/tools/kotlin-gradle-plugin/src/common/kotlin/org/jetbrains/kotlin/gradle/targets/native/cocoapods/tasks/DummyFrameworkTask.kt b/libraries/tools/kotlin-gradle-plugin/src/common/kotlin/org/jetbrains/kotlin/gradle/targets/native/cocoapods/tasks/DummyFrameworkTask.kt new file mode 100644 index 00000000000..7dbbb3c5f83 --- /dev/null +++ b/libraries/tools/kotlin-gradle-plugin/src/common/kotlin/org/jetbrains/kotlin/gradle/targets/native/cocoapods/tasks/DummyFrameworkTask.kt @@ -0,0 +1,354 @@ +/* + * Copyright 2010-2019 JetBrains s.r.o. and Kotlin Programming Language contributors. + * Use of this source code is governed by the Apache 2.0 license that can be found in the license/LICENSE.txt file. + */ + +@file:Suppress("PackageDirectoryMismatch") // Old package for compatibility +package org.jetbrains.kotlin.gradle.tasks + +import org.gradle.api.DefaultTask +import org.gradle.api.Project +import org.gradle.api.plugins.ExtensionAware +import org.gradle.api.provider.Property +import org.gradle.api.provider.Provider +import org.gradle.api.tasks.* +import org.gradle.api.tasks.wrapper.Wrapper +import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension +import org.jetbrains.kotlin.gradle.dsl.multiplatformExtensionOrNull +import org.jetbrains.kotlin.gradle.plugin.cocoapods.CocoapodsExtension +import org.jetbrains.kotlin.gradle.plugin.cocoapods.CocoapodsExtension.* +import org.jetbrains.kotlin.gradle.plugin.cocoapods.KotlinCocoapodsPlugin +import org.jetbrains.kotlin.gradle.plugin.cocoapods.KotlinCocoapodsPlugin.Companion.COCOAPODS_EXTENSION_NAME +import org.jetbrains.kotlin.gradle.plugin.cocoapods.KotlinCocoapodsPlugin.Companion.GENERATE_WRAPPER_PROPERTY +import org.jetbrains.kotlin.gradle.plugin.cocoapods.KotlinCocoapodsPlugin.Companion.SYNC_TASK_NAME +import org.jetbrains.kotlin.gradle.plugin.cocoapods.cocoapodsBuildDirs +import org.jetbrains.kotlin.gradle.utils.appendLine +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() { + + @get:Input + internal val specName = project.objects.property(String::class.java) + + @get:Internal + internal val outputDir = project.objects.property(File::class.java) + + @get:OutputFile + val outputFile: File + get() = outputDir.get().resolve("${specName.get()}.podspec") + + @get:Input + internal lateinit var needPodspec: Provider + + @get:Nested + val pods = project.objects.listProperty(CocoapodsDependency::class.java) + + @get:Input + internal val version = project.objects.property(String::class.java) + + @get:Input + internal val publishing = project.objects.property(Boolean::class.java) + + @get:Input + @get:Optional + internal val source = project.objects.property(String::class.java) + + @get:Input + @get:Optional + internal val homepage = project.objects.property(String::class.java) + + @get:Input + @get:Optional + internal val license = project.objects.property(String::class.java) + + @get:Input + @get:Optional + internal val authors = project.objects.property(String::class.java) + + @get:Input + @get:Optional + internal val summary = project.objects.property(String::class.java) + + @get:Input + @get:Optional + internal val extraSpecAttributes = project.objects.mapProperty(String::class.java, String::class.java) + + @get:Input + internal lateinit var frameworkName: Provider + + @get:Nested + internal lateinit var ios: Provider + + @get:Nested + internal lateinit var osx: Provider + + @get:Nested + internal lateinit var tvos: Provider + + @get:Nested + internal lateinit var watchos: Provider + + init { + onlyIf { needPodspec.get() } + } + + @TaskAction + fun generate() { + + check(version.get() != Project.DEFAULT_VERSION) { + """ + Cocoapods Integration requires pod version to be specified. + Please specify pod version by adding 'version = ""' to the cocoapods block. + Alternatively, specify the version for the entire project explicitly. + Pod version format has to conform podspec syntax requirements: https://guides.cocoapods.org/syntax/podspec.html#version + """.trimIndent() + } + + val gradleWrapper = (project.rootProject.tasks.getByName("wrapper") as? Wrapper)?.scriptFile + require(gradleWrapper != null && gradleWrapper.exists()) { + """ + The Gradle wrapper is required to run the build from Xcode. + + Please run the same command with `-P$GENERATE_WRAPPER_PROPERTY=true` or run the `:wrapper` task to generate the wrapper manually. + + See details about the wrapper at https://docs.gradle.org/current/userguide/gradle_wrapper.html + """.trimIndent() + } + + val deploymentTargets = run { + listOf(ios, osx, tvos, watchos).map { it.get() }.filter { it.deploymentTarget != null }.joinToString("\n") { + if (extraSpecAttributes.get().containsKey("${it.name}.deployment_target")) "" else "| spec.${it.name}.deployment_target = '${it.deploymentTarget}'" + } + } + + val dependencies = pods.get().map { pod -> + val versionSuffix = if (pod.version != null) ", '${pod.version}'" else "" + "| spec.dependency '${pod.name}'$versionSuffix" + }.joinToString(separator = "\n") + + val frameworkDir = project.cocoapodsBuildDirs.framework.relativeTo(outputFile.parentFile) + val vendoredFramework = if (publishing.get()) "${frameworkName.get()}.xcframework" else frameworkDir.resolve("${frameworkName.get()}.framework").invariantSeparatorsPath + val vendoredFrameworks = if (extraSpecAttributes.get().containsKey("vendored_frameworks")) "" else "| spec.vendored_frameworks = '$vendoredFramework'" + + val libraries = if (extraSpecAttributes.get().containsKey("libraries")) "" else "| spec.libraries = 'c++'" + + val xcConfig = if (publishing.get() || extraSpecAttributes.get().containsKey("pod_target_xcconfig")) "" else + """ | + | spec.pod_target_xcconfig = { + | 'KOTLIN_PROJECT_PATH' => '${if (project.depth != 0) project.path else ""}', + | 'PRODUCT_MODULE_NAME' => '${frameworkName.get()}', + | } + """.trimMargin() + + val gradleCommand = "\$REPO_ROOT/${gradleWrapper.relativeTo(project.projectDir).invariantSeparatorsPath}" + val scriptPhase = if (publishing.get() || extraSpecAttributes.get().containsKey("script_phases")) "" else + """ | + | spec.script_phases = [ + | { + | :name => 'Build ${specName.get()}', + | :execution_position => :before_compile, + | :shell_path => '/bin/sh', + | :script => <<-SCRIPT + | if [ "YES" = "${'$'}OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED" ]; then + | echo "Skipping Gradle build task invocation due to OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED environment variable set to \"YES\"" + | exit 0 + | fi + | set -ev + | REPO_ROOT="${'$'}PODS_TARGET_SRCROOT" + | "$gradleCommand" -p "${'$'}REPO_ROOT" ${'$'}KOTLIN_PROJECT_PATH:$SYNC_TASK_NAME \ + | -P${KotlinCocoapodsPlugin.PLATFORM_PROPERTY}=${'$'}PLATFORM_NAME \ + | -P${KotlinCocoapodsPlugin.ARCHS_PROPERTY}="${'$'}ARCHS" \ + | -P${KotlinCocoapodsPlugin.CONFIGURATION_PROPERTY}="${'$'}CONFIGURATION" + | SCRIPT + | } + | ] + """.trimMargin() + + val customSpec = extraSpecAttributes.get().map { "| spec.${it.key} = ${it.value}" }.joinToString("\n") + + with(outputFile) { + writeText( + """ + |Pod::Spec.new do |spec| + | spec.name = '${specName.get()}' + | spec.version = '${version.get()}' + | spec.homepage = ${homepage.getOrEmpty().surroundWithSingleQuotesIfNeeded()} + | spec.source = ${source.getOrElse("{ :http=> ''}")} + | spec.authors = ${authors.getOrEmpty().surroundWithSingleQuotesIfNeeded()} + | spec.license = ${license.getOrEmpty().surroundWithSingleQuotesIfNeeded()} + | spec.summary = '${summary.getOrEmpty()}' + $vendoredFrameworks + $libraries + $deploymentTargets + $dependencies + $xcConfig + $scriptPhase + $customSpec + |end + """.trimMargin() + ) + + if (hasPodfileOwnOrParent(project) && publishing.get().not()) { + logger.quiet( + """ + Generated a podspec file at: ${absolutePath}. + To include it in your Xcode project, check that the following dependency snippet exists in your Podfile: + + pod '${specName.get()}', :path => '${parentFile.absolutePath}' + + """.trimIndent() + ) + } + + } + } + + private fun Provider.getOrEmpty(): String = getOrElse("") + + private fun String.surroundWithSingleQuotesIfNeeded(): String = + if (startsWith("{") || startsWith("<<-") || startsWith("'")) this else "'$this'" + + companion object { + private val KotlinMultiplatformExtension?.cocoapodsExtensionOrNull: CocoapodsExtension? + get() = (this as? ExtensionAware)?.extensions?.findByName(COCOAPODS_EXTENSION_NAME) as? CocoapodsExtension + + private fun hasPodfileOwnOrParent(project: Project): Boolean = + if (project.rootProject == project) project.multiplatformExtensionOrNull?.cocoapodsExtensionOrNull?.podfile != null + else project.multiplatformExtensionOrNull?.cocoapodsExtensionOrNull?.podfile != null + || (project.parent?.let { hasPodfileOwnOrParent(it) } ?: false) + + } +} + + +/** + * 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. + */ +abstract class DummyFrameworkTask : DefaultTask() { + + @get:Input + abstract val frameworkName: Property + + @get:Input + abstract val useStaticFramework: Property + + @get:OutputDirectory + val outputFramework: Provider = project.provider { project.cocoapodsBuildDirs.dummyFramework } + + private val dummyFrameworkResource: String + get() { + val staticOrDynamic = if (!useStaticFramework.get()) "dynamic" else "static" + return "/cocoapods/$staticOrDynamic/dummy.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 copyTextResource(from: String, to: File, transform: (String) -> String = { it }) { + to.parentFile.mkdirs() + to.printWriter().use { file -> + javaClass.getResourceAsStream(from)!!.use { + it.reader().forEachLine { str -> + file.println(transform(str)) + } + } + } + } + + private fun copyFrameworkFile(relativeFrom: String, relativeTo: String = relativeFrom) = + copyResource( + "$dummyFrameworkResource$relativeFrom", + outputFramework.get().resolve(relativeTo) + ) + + private fun copyFrameworkTextFile( + relativeFrom: String, + relativeTo: String = relativeFrom, + transform: (String) -> String = { it } + ) = copyTextResource( + "$dummyFrameworkResource$relativeFrom", + outputFramework.get().resolve(relativeTo), + transform + ) + + @TaskAction + fun create() { + // Reset the destination directory + with(outputFramework.get()) { + deleteRecursively() + mkdirs() + } + + // Copy files for the dummy framework. + copyFrameworkFile("Info.plist") + copyFrameworkFile("dummy", frameworkName.get()) + copyFrameworkFile("Headers/placeholder.h") + copyFrameworkTextFile("Modules/module.modulemap") { + if (it == "framework module dummy {") { + it.replace("dummy", frameworkName.get()) + } else { + it + } + } + } +} + +/** + * Generates a def-file for the given CocoaPods dependency. + */ +abstract class DefFileTask : DefaultTask() { + + @get:Nested + abstract val pod: Property + + @get:Input + abstract val useLibraries: Property + + @get:OutputFile + val outputFile: File + get() = project.cocoapodsBuildDirs.defs.resolve("${pod.get().moduleName}.def") + + @TaskAction + fun generate() { + outputFile.parentFile.mkdirs() + outputFile.writeText(buildString { + appendLine("language = Objective-C") + with(pod.get()) { + when { + headers != null -> appendLine("headers = $headers") + useLibraries.get() -> logger.warn( + """ + w: Pod '$moduleName' should have 'headers' property specified when using 'useLibraries()'. + Otherwise code from this pod won't be accessible from Kotlin. + """.trimIndent() + ) + else -> { + appendLine("modules = $moduleName") + + // Linker opt with framework name is added so produced cinterop klib would have this flag inside its manifest + // This way error will be more obvious when someone will try to depend on a library with this cinterop + appendLine("linkerOpts = -framework $moduleName") + } + } + } + }) + } +} \ No newline at end of file diff --git a/libraries/tools/kotlin-gradle-plugin/src/common/kotlin/org/jetbrains/kotlin/gradle/targets/native/cocoapods/tasks/PodBuildTask.kt b/libraries/tools/kotlin-gradle-plugin/src/common/kotlin/org/jetbrains/kotlin/gradle/targets/native/cocoapods/tasks/PodBuildTask.kt new file mode 100644 index 00000000000..8a04a217427 --- /dev/null +++ b/libraries/tools/kotlin-gradle-plugin/src/common/kotlin/org/jetbrains/kotlin/gradle/targets/native/cocoapods/tasks/PodBuildTask.kt @@ -0,0 +1,524 @@ +/* + * Copyright 2010-2020 JetBrains s.r.o. and Kotlin Programming Language contributors. + * Use of this source code is governed by the Apache 2.0 license that can be found in the license/LICENSE.txt file. + */ + +@file:Suppress("LeakingThis") // All tasks should be inherited only by Gradle + +package org.jetbrains.kotlin.gradle.targets.native.tasks + +import org.gradle.api.DefaultTask +import org.gradle.api.file.FileCollection +import org.gradle.api.file.FileTree +import org.gradle.api.provider.ListProperty +import org.gradle.api.provider.Property +import org.gradle.api.provider.Provider +import org.gradle.api.tasks.* +import org.gradle.api.tasks.Optional +import org.jetbrains.kotlin.gradle.plugin.cocoapods.CocoapodsExtension.* +import org.jetbrains.kotlin.gradle.plugin.cocoapods.CocoapodsExtension.CocoapodsDependency.PodLocation.* +import org.jetbrains.kotlin.gradle.plugin.cocoapods.cocoapodsBuildDirs +import org.jetbrains.kotlin.gradle.plugin.cocoapods.platformLiteral +import org.jetbrains.kotlin.gradle.targets.native.cocoapods.MissingCocoapodsMessage +import org.jetbrains.kotlin.gradle.targets.native.cocoapods.MissingSpecReposMessage +import org.jetbrains.kotlin.gradle.utils.runCommand +import org.jetbrains.kotlin.konan.target.Family +import org.jetbrains.kotlin.konan.target.HostManager +import java.io.File +import java.io.IOException +import java.io.Reader +import java.util.* + +val CocoapodsDependency.schemeName: String + get() = name.split("/")[0] + + +open class CocoapodsTask : DefaultTask() { + init { + onlyIf { + HostManager.hostIsMac + } + } +} + + +/** + * The task takes the path to the Podfile and calls `pod install` + * to obtain sources or artifacts for the declared dependencies. + * This task is a part of CocoaPods integration infrastructure. + */ +abstract class AbstractPodInstallTask : CocoapodsTask() { + init { + onlyIf { podfile.isPresent } + } + + @get:Optional + @get:InputFile + abstract val podfile: Property + + @get:Internal + protected val workingDir: Provider = podfile.map { file: File? -> + requireNotNull(file) { "Task outputs shouldn't be queried if it's skipped" }.parentFile + } + + @get:OutputDirectory + internal val podsDir: Provider = workingDir.map { it.resolve("Pods") } + + @get:Internal + internal val podsXcodeProjDirProvider: Provider = podsDir.map { it.resolve("Pods.xcodeproj") } + + @TaskAction + open fun doPodInstall() { + val podInstallCommand = listOf("pod", "install") + + runCommand(podInstallCommand, + logger, + errorHandler = ::handleError, + exceptionHandler = { e: IOException -> + CocoapodsErrorHandlingUtil.handle(e, podInstallCommand) + }, + processConfiguration = { + directory(workingDir.get()) + }) + + with(podsXcodeProjDirProvider.get()) { + check(exists() && isDirectory) { + "The directory 'Pods/Pods.xcodeproj' was not created as a result of the `pod install` call." + } + } + } + + abstract fun handleError(retCode: Int, error: String, process: Process): String? +} + +abstract class PodInstallTask : AbstractPodInstallTask() { + + @get:Optional + @get:InputFile + abstract val podspec: Property + + @get:Input + abstract val frameworkName: Property + + @get:Nested + abstract val specRepos: Property + + @get:Nested + abstract val pods: ListProperty + + @get:InputDirectory + abstract val dummyFramework: Property + + private val framework = project.provider { project.cocoapodsBuildDirs.framework.resolve("${frameworkName.get()}.framework") } + private val tmpFramework = dummyFramework.map { dummy -> dummy.parentFile.resolve("tmp.framework").also { it.deleteOnExit() } } + + override fun doPodInstall() { + // We always need to execute 'pod install' with the dummy framework because the one left from a previous build + // may have a wrong linkage type. So we temporarily swap them, run 'pod install' and then swap them back + framework.rename(tmpFramework) + dummyFramework.rename(framework) + super.doPodInstall() + framework.rename(dummyFramework) + tmpFramework.rename(framework) + } + + private fun Provider.rename(dest: Provider) = get().rename(dest.get()) + + private fun File.rename(dest: File) { + if (!exists()) { + mkdirs() + } + + check(renameTo(dest)) { "Can't rename '${this}' to '${dest}'" } + } + + override fun handleError(retCode: Int, error: String, process: Process): String? { + val specReposMessages = MissingSpecReposMessage(specRepos.get()).missingMessage + val cocoapodsMessages = pods.get().map { MissingCocoapodsMessage(it).missingMessage } + + return listOfNotNull( + "'pod install' command failed with code $retCode.", + "Error message:", + error.lines().filter { it.isNotBlank() }.joinToString("\n"), + """ + | Please, check that podfile contains following lines in header: + | $specReposMessages + | + """.trimMargin(), + """ + | Please, check that each target depended on ${frameworkName.get()} contains following dependencies: + | ${cocoapodsMessages.joinToString("\n")} + | + """.trimMargin() + + ).joinToString("\n") + } +} + +abstract class PodInstallSyntheticTask : AbstractPodInstallTask() { + + @get:Input + abstract val family: Property + + @get:Input + abstract val podName: Property + + @get:OutputDirectory + internal val syntheticXcodeProject: Provider = workingDir.map { it.resolve("synthetic.xcodeproj") } + + override fun doPodInstall() { + val projResource = "/cocoapods/project.pbxproj" + val projDestination = syntheticXcodeProject.get().resolve("project.pbxproj") + + syntheticXcodeProject.get().mkdirs() + projDestination.outputStream().use { file -> + javaClass.getResourceAsStream(projResource)!!.use { resource -> + resource.copyTo(file) + } + } + + super.doPodInstall() + } + + override fun handleError(retCode: Int, error: String, process: Process): String? { + var message = """ + |'pod install' command on the synthetic project failed with return code: $retCode + | + | Error: ${error.lines().filter { it.contains("[!]") }.joinToString("\n")} + | + """.trimMargin() + + if ( + error.contains("deployment target") || + error.contains("no platform was specified") || + error.contains(Regex("The platform of the target .+ is not compatible with `${podName.get()}")) + ) { + message += """ + | + | Possible reason: ${family.get().platformLiteral} deployment target is not configured + | Configure deployment_target for ALL targets as follows: + | cocoapods { + | ... + | ${family.get().platformLiteral}.deploymentTarget = "..." + | ... + | } + | + """.trimMargin() + return message + } else if ( + error.contains("Unable to add a source with url") || + error.contains("Couldn't determine repo name for URL") || + error.contains("Unable to find a specification") + ) { + message += """ + | + | Possible reason: spec repos are not configured correctly. + | Ensure that spec repos are correctly configured for all private pod dependencies: + | cocoapods { + | specRepos { + | url("") + | } + | } + | + """.trimMargin() + return message + } else { + return null + } + } +} + +/** + * The task generates a synthetic project with all cocoapods dependencies + */ +abstract class PodGenTask : CocoapodsTask() { + + init { + onlyIf { + pods.get().isNotEmpty() + } + } + + @get:InputFile + internal abstract val podspec: Property + + @get:Input + internal abstract val podName: Property + + @get:Input + internal abstract val useLibraries: Property + + @get:Input + internal abstract val family: Property + + @get:Nested + internal abstract val platformSettings: Property + + @get:Nested + internal abstract val specRepos: Property + + @get:Nested + internal abstract val pods: ListProperty + + @get:OutputFile + val podfile: Provider = family.map { project.cocoapodsBuildDirs.synthetic(it).resolve("Podfile") } + + @TaskAction + fun generate() { + val specRepos = specRepos.get().getAll() + + val podfile = this.podfile.get() + podfile.createNewFile() + + val podfileContent = getPodfileContent(specRepos, family.get().platformLiteral) + podfile.writeText(podfileContent) + } + + private fun getPodfileContent(specRepos: Collection, xcodeTarget: String) = + buildString { + + specRepos.forEach { + appendLine("source '$it'") + } + + appendLine("target '$xcodeTarget' do") + if (useLibraries.get().not()) { + appendLine("\tuse_frameworks!") + } + val settings = platformSettings.get() + val deploymentTarget = settings.deploymentTarget + if (deploymentTarget != null) { + appendLine("\tplatform :${settings.name}, '$deploymentTarget'") + } else { + appendLine("\tplatform :${settings.name}") + } + pods.get().mapNotNull { + buildString { + append("pod '${it.name}'") + + val version = it.version + val source = it.source + + if (source != null) { + append(", ${source.getPodSourcePath()}") + } else if (version != null) { + append(", '$version'") + } + + } + }.forEach { appendLine("\t$it") } + appendLine("end\n") + //disable signing for all synthetic pods KT-54314 + append( + """ + post_install do |installer| + installer.pods_project.targets.each do |target| + target.build_configurations.each do |config| + config.build_settings['EXPANDED_CODE_SIGN_IDENTITY'] = "" + config.build_settings['CODE_SIGNING_REQUIRED'] = "NO" + config.build_settings['CODE_SIGNING_ALLOWED'] = "NO" + end + end + end + """.trimIndent() + ) + appendLine() + } +} + + +open class PodSetupBuildTask : CocoapodsTask() { + + @get:Input + lateinit var frameworkName: Provider + + @get:Input + internal lateinit var sdk: Provider + + @get:Nested + lateinit var pod: Provider + + @get:OutputFile + val buildSettingsFile: Provider = project.provider { + project.cocoapodsBuildDirs + .buildSettings + .resolve(getBuildSettingFileName(pod.get(), sdk.get())) + } + + @get:Internal + internal lateinit var podsXcodeProjDir: Provider + + @TaskAction + fun setupBuild() { + val podsXcodeProjDir = podsXcodeProjDir.get() + + val buildSettingsReceivingCommand = listOf( + "xcodebuild", "-showBuildSettings", + "-project", podsXcodeProjDir.name, + "-scheme", pod.get().schemeName, + "-sdk", sdk.get() + ) + + val outputText = runCommand(buildSettingsReceivingCommand, project.logger) { directory(podsXcodeProjDir.parentFile) } + + val buildSettingsProperties = PodBuildSettingsProperties.readSettingsFromReader(outputText.reader()) + buildSettingsFile.get().let { bsf -> + buildSettingsProperties.writeSettings(bsf) + } + } +} + +private fun getBuildSettingFileName(pod: CocoapodsDependency, sdk: String): String = + "build-settings-$sdk-${pod.schemeName}.properties" + +/** + * The task compiles external cocoa pods sources. + */ +open class PodBuildTask : CocoapodsTask() { + + @get:PathSensitive(PathSensitivity.ABSOLUTE) + @get:InputFile + lateinit var buildSettingsFile: Provider + internal set + + @get:Nested + internal lateinit var pod: Provider + + @get:PathSensitive(PathSensitivity.ABSOLUTE) + @get:IgnoreEmptyDirectories + @get:InputFiles + internal val srcDir: FileTree + get() = project.fileTree( + buildSettingsFile.map { PodBuildSettingsProperties.readSettingsFromReader(it.reader()).podsTargetSrcRoot } + ) + + @get:Internal + internal var buildDir: Provider = project.provider { + project.file(PodBuildSettingsProperties.readSettingsFromReader(buildSettingsFile.get().reader()).buildDir) + } + + @get:Input + internal lateinit var sdk: Provider + + @Suppress("unused") // declares an ouptut + @get:OutputFiles + internal val buildResult: Provider = project.provider { + project.fileTree(buildDir.get()) { + it.include("**/${pod.get().schemeName}.*/") + it.include("**/${pod.get().schemeName}/") + } + } + + @get:Internal + internal lateinit var podsXcodeProjDir: Provider + + @TaskAction + fun buildDependencies() { + val podBuildSettings = PodBuildSettingsProperties.readSettingsFromReader(buildSettingsFile.get().reader()) + + val podsXcodeProjDir = podsXcodeProjDir.get() + + val podXcodeBuildCommand = listOf( + "xcodebuild", + "-project", podsXcodeProjDir.name, + "-scheme", pod.get().schemeName, + "-sdk", sdk.get(), + "-configuration", podBuildSettings.configuration + ) + + runCommand(podXcodeBuildCommand, project.logger) { directory(podsXcodeProjDir.parentFile) } + } +} + + +data class PodBuildSettingsProperties( + internal val buildDir: String, + internal val configuration: String, + val configurationBuildDir: String, + internal val podsTargetSrcRoot: String, + internal val cflags: String? = null, + internal val headerPaths: String? = null, + internal val publicHeadersFolderPath: String? = null, + internal val frameworkPaths: String? = null +) { + + fun writeSettings( + buildSettingsFile: File + ) { + buildSettingsFile.parentFile.mkdirs() + buildSettingsFile.delete() + buildSettingsFile.createNewFile() + + check(buildSettingsFile.exists()) { "Unable to create file ${buildSettingsFile.path}!" } + + with(buildSettingsFile) { + appendText("$BUILD_DIR=$buildDir\n") + appendText("$CONFIGURATION=$configuration\n") + appendText("$CONFIGURATION_BUILD_DIR=$configurationBuildDir\n") + appendText("$PODS_TARGET_SRCROOT=$podsTargetSrcRoot\n") + cflags?.let { appendText("$OTHER_CFLAGS=$it\n") } + headerPaths?.let { appendText("$HEADER_SEARCH_PATHS=$it\n") } + publicHeadersFolderPath?.let { appendText("$PUBLIC_HEADERS_FOLDER_PATH=$it\n") } + frameworkPaths?.let { appendText("$FRAMEWORK_SEARCH_PATHS=$it") } + } + } + + companion object { + const val BUILD_DIR = "BUILD_DIR" + const val CONFIGURATION = "CONFIGURATION" + const val CONFIGURATION_BUILD_DIR = "CONFIGURATION_BUILD_DIR" + const val PODS_TARGET_SRCROOT = "PODS_TARGET_SRCROOT" + const val OTHER_CFLAGS = "OTHER_CFLAGS" + const val HEADER_SEARCH_PATHS = "HEADER_SEARCH_PATHS" + const val PUBLIC_HEADERS_FOLDER_PATH = "PUBLIC_HEADERS_FOLDER_PATH" + const val FRAMEWORK_SEARCH_PATHS = "FRAMEWORK_SEARCH_PATHS" + + fun readSettingsFromReader(reader: Reader): PodBuildSettingsProperties { + with(Properties()) { + @Suppress("BlockingMethodInNonBlockingContext") // It's ok to do blocking call here + load(reader) + return PodBuildSettingsProperties( + readProperty(BUILD_DIR), + readProperty(CONFIGURATION), + readProperty(CONFIGURATION_BUILD_DIR), + readProperty(PODS_TARGET_SRCROOT), + readNullableProperty(OTHER_CFLAGS), + readNullableProperty(HEADER_SEARCH_PATHS), + readNullableProperty(PUBLIC_HEADERS_FOLDER_PATH), + readNullableProperty(FRAMEWORK_SEARCH_PATHS) + ) + } + } + + private fun Properties.readProperty(propertyName: String) = + readNullableProperty(propertyName) ?: error("$propertyName property is absent") + + private fun Properties.readNullableProperty(propertyName: String) = + getProperty(propertyName) + } +} + +private object CocoapodsErrorHandlingUtil { + fun handle(e: IOException, command: List) { + if (e.message?.contains("No such file or directory") == true) { + val message = """ + |'${command.take(2).joinToString(" ")}' command failed with an exception: + | ${e.message} + | + | Full command: ${command.joinToString(" ")} + | + | Possible reason: CocoaPods is not installed + | Please check that CocoaPods v1.10 or above is installed. + | + | To check CocoaPods version type 'pod --version' in the terminal + | + | To install CocoaPods execute 'sudo gem install cocoapods' + | + """.trimMargin() + throw IllegalStateException(message) + } else { + throw e + } + } + +} diff --git a/libraries/tools/kotlin-gradle-plugin/src/common/kotlin/org/jetbrains/kotlin/gradle/targets/native/cocoapods/tasks/PodGenTask.kt b/libraries/tools/kotlin-gradle-plugin/src/common/kotlin/org/jetbrains/kotlin/gradle/targets/native/cocoapods/tasks/PodGenTask.kt new file mode 100644 index 00000000000..8a04a217427 --- /dev/null +++ b/libraries/tools/kotlin-gradle-plugin/src/common/kotlin/org/jetbrains/kotlin/gradle/targets/native/cocoapods/tasks/PodGenTask.kt @@ -0,0 +1,524 @@ +/* + * Copyright 2010-2020 JetBrains s.r.o. and Kotlin Programming Language contributors. + * Use of this source code is governed by the Apache 2.0 license that can be found in the license/LICENSE.txt file. + */ + +@file:Suppress("LeakingThis") // All tasks should be inherited only by Gradle + +package org.jetbrains.kotlin.gradle.targets.native.tasks + +import org.gradle.api.DefaultTask +import org.gradle.api.file.FileCollection +import org.gradle.api.file.FileTree +import org.gradle.api.provider.ListProperty +import org.gradle.api.provider.Property +import org.gradle.api.provider.Provider +import org.gradle.api.tasks.* +import org.gradle.api.tasks.Optional +import org.jetbrains.kotlin.gradle.plugin.cocoapods.CocoapodsExtension.* +import org.jetbrains.kotlin.gradle.plugin.cocoapods.CocoapodsExtension.CocoapodsDependency.PodLocation.* +import org.jetbrains.kotlin.gradle.plugin.cocoapods.cocoapodsBuildDirs +import org.jetbrains.kotlin.gradle.plugin.cocoapods.platformLiteral +import org.jetbrains.kotlin.gradle.targets.native.cocoapods.MissingCocoapodsMessage +import org.jetbrains.kotlin.gradle.targets.native.cocoapods.MissingSpecReposMessage +import org.jetbrains.kotlin.gradle.utils.runCommand +import org.jetbrains.kotlin.konan.target.Family +import org.jetbrains.kotlin.konan.target.HostManager +import java.io.File +import java.io.IOException +import java.io.Reader +import java.util.* + +val CocoapodsDependency.schemeName: String + get() = name.split("/")[0] + + +open class CocoapodsTask : DefaultTask() { + init { + onlyIf { + HostManager.hostIsMac + } + } +} + + +/** + * The task takes the path to the Podfile and calls `pod install` + * to obtain sources or artifacts for the declared dependencies. + * This task is a part of CocoaPods integration infrastructure. + */ +abstract class AbstractPodInstallTask : CocoapodsTask() { + init { + onlyIf { podfile.isPresent } + } + + @get:Optional + @get:InputFile + abstract val podfile: Property + + @get:Internal + protected val workingDir: Provider = podfile.map { file: File? -> + requireNotNull(file) { "Task outputs shouldn't be queried if it's skipped" }.parentFile + } + + @get:OutputDirectory + internal val podsDir: Provider = workingDir.map { it.resolve("Pods") } + + @get:Internal + internal val podsXcodeProjDirProvider: Provider = podsDir.map { it.resolve("Pods.xcodeproj") } + + @TaskAction + open fun doPodInstall() { + val podInstallCommand = listOf("pod", "install") + + runCommand(podInstallCommand, + logger, + errorHandler = ::handleError, + exceptionHandler = { e: IOException -> + CocoapodsErrorHandlingUtil.handle(e, podInstallCommand) + }, + processConfiguration = { + directory(workingDir.get()) + }) + + with(podsXcodeProjDirProvider.get()) { + check(exists() && isDirectory) { + "The directory 'Pods/Pods.xcodeproj' was not created as a result of the `pod install` call." + } + } + } + + abstract fun handleError(retCode: Int, error: String, process: Process): String? +} + +abstract class PodInstallTask : AbstractPodInstallTask() { + + @get:Optional + @get:InputFile + abstract val podspec: Property + + @get:Input + abstract val frameworkName: Property + + @get:Nested + abstract val specRepos: Property + + @get:Nested + abstract val pods: ListProperty + + @get:InputDirectory + abstract val dummyFramework: Property + + private val framework = project.provider { project.cocoapodsBuildDirs.framework.resolve("${frameworkName.get()}.framework") } + private val tmpFramework = dummyFramework.map { dummy -> dummy.parentFile.resolve("tmp.framework").also { it.deleteOnExit() } } + + override fun doPodInstall() { + // We always need to execute 'pod install' with the dummy framework because the one left from a previous build + // may have a wrong linkage type. So we temporarily swap them, run 'pod install' and then swap them back + framework.rename(tmpFramework) + dummyFramework.rename(framework) + super.doPodInstall() + framework.rename(dummyFramework) + tmpFramework.rename(framework) + } + + private fun Provider.rename(dest: Provider) = get().rename(dest.get()) + + private fun File.rename(dest: File) { + if (!exists()) { + mkdirs() + } + + check(renameTo(dest)) { "Can't rename '${this}' to '${dest}'" } + } + + override fun handleError(retCode: Int, error: String, process: Process): String? { + val specReposMessages = MissingSpecReposMessage(specRepos.get()).missingMessage + val cocoapodsMessages = pods.get().map { MissingCocoapodsMessage(it).missingMessage } + + return listOfNotNull( + "'pod install' command failed with code $retCode.", + "Error message:", + error.lines().filter { it.isNotBlank() }.joinToString("\n"), + """ + | Please, check that podfile contains following lines in header: + | $specReposMessages + | + """.trimMargin(), + """ + | Please, check that each target depended on ${frameworkName.get()} contains following dependencies: + | ${cocoapodsMessages.joinToString("\n")} + | + """.trimMargin() + + ).joinToString("\n") + } +} + +abstract class PodInstallSyntheticTask : AbstractPodInstallTask() { + + @get:Input + abstract val family: Property + + @get:Input + abstract val podName: Property + + @get:OutputDirectory + internal val syntheticXcodeProject: Provider = workingDir.map { it.resolve("synthetic.xcodeproj") } + + override fun doPodInstall() { + val projResource = "/cocoapods/project.pbxproj" + val projDestination = syntheticXcodeProject.get().resolve("project.pbxproj") + + syntheticXcodeProject.get().mkdirs() + projDestination.outputStream().use { file -> + javaClass.getResourceAsStream(projResource)!!.use { resource -> + resource.copyTo(file) + } + } + + super.doPodInstall() + } + + override fun handleError(retCode: Int, error: String, process: Process): String? { + var message = """ + |'pod install' command on the synthetic project failed with return code: $retCode + | + | Error: ${error.lines().filter { it.contains("[!]") }.joinToString("\n")} + | + """.trimMargin() + + if ( + error.contains("deployment target") || + error.contains("no platform was specified") || + error.contains(Regex("The platform of the target .+ is not compatible with `${podName.get()}")) + ) { + message += """ + | + | Possible reason: ${family.get().platformLiteral} deployment target is not configured + | Configure deployment_target for ALL targets as follows: + | cocoapods { + | ... + | ${family.get().platformLiteral}.deploymentTarget = "..." + | ... + | } + | + """.trimMargin() + return message + } else if ( + error.contains("Unable to add a source with url") || + error.contains("Couldn't determine repo name for URL") || + error.contains("Unable to find a specification") + ) { + message += """ + | + | Possible reason: spec repos are not configured correctly. + | Ensure that spec repos are correctly configured for all private pod dependencies: + | cocoapods { + | specRepos { + | url("") + | } + | } + | + """.trimMargin() + return message + } else { + return null + } + } +} + +/** + * The task generates a synthetic project with all cocoapods dependencies + */ +abstract class PodGenTask : CocoapodsTask() { + + init { + onlyIf { + pods.get().isNotEmpty() + } + } + + @get:InputFile + internal abstract val podspec: Property + + @get:Input + internal abstract val podName: Property + + @get:Input + internal abstract val useLibraries: Property + + @get:Input + internal abstract val family: Property + + @get:Nested + internal abstract val platformSettings: Property + + @get:Nested + internal abstract val specRepos: Property + + @get:Nested + internal abstract val pods: ListProperty + + @get:OutputFile + val podfile: Provider = family.map { project.cocoapodsBuildDirs.synthetic(it).resolve("Podfile") } + + @TaskAction + fun generate() { + val specRepos = specRepos.get().getAll() + + val podfile = this.podfile.get() + podfile.createNewFile() + + val podfileContent = getPodfileContent(specRepos, family.get().platformLiteral) + podfile.writeText(podfileContent) + } + + private fun getPodfileContent(specRepos: Collection, xcodeTarget: String) = + buildString { + + specRepos.forEach { + appendLine("source '$it'") + } + + appendLine("target '$xcodeTarget' do") + if (useLibraries.get().not()) { + appendLine("\tuse_frameworks!") + } + val settings = platformSettings.get() + val deploymentTarget = settings.deploymentTarget + if (deploymentTarget != null) { + appendLine("\tplatform :${settings.name}, '$deploymentTarget'") + } else { + appendLine("\tplatform :${settings.name}") + } + pods.get().mapNotNull { + buildString { + append("pod '${it.name}'") + + val version = it.version + val source = it.source + + if (source != null) { + append(", ${source.getPodSourcePath()}") + } else if (version != null) { + append(", '$version'") + } + + } + }.forEach { appendLine("\t$it") } + appendLine("end\n") + //disable signing for all synthetic pods KT-54314 + append( + """ + post_install do |installer| + installer.pods_project.targets.each do |target| + target.build_configurations.each do |config| + config.build_settings['EXPANDED_CODE_SIGN_IDENTITY'] = "" + config.build_settings['CODE_SIGNING_REQUIRED'] = "NO" + config.build_settings['CODE_SIGNING_ALLOWED'] = "NO" + end + end + end + """.trimIndent() + ) + appendLine() + } +} + + +open class PodSetupBuildTask : CocoapodsTask() { + + @get:Input + lateinit var frameworkName: Provider + + @get:Input + internal lateinit var sdk: Provider + + @get:Nested + lateinit var pod: Provider + + @get:OutputFile + val buildSettingsFile: Provider = project.provider { + project.cocoapodsBuildDirs + .buildSettings + .resolve(getBuildSettingFileName(pod.get(), sdk.get())) + } + + @get:Internal + internal lateinit var podsXcodeProjDir: Provider + + @TaskAction + fun setupBuild() { + val podsXcodeProjDir = podsXcodeProjDir.get() + + val buildSettingsReceivingCommand = listOf( + "xcodebuild", "-showBuildSettings", + "-project", podsXcodeProjDir.name, + "-scheme", pod.get().schemeName, + "-sdk", sdk.get() + ) + + val outputText = runCommand(buildSettingsReceivingCommand, project.logger) { directory(podsXcodeProjDir.parentFile) } + + val buildSettingsProperties = PodBuildSettingsProperties.readSettingsFromReader(outputText.reader()) + buildSettingsFile.get().let { bsf -> + buildSettingsProperties.writeSettings(bsf) + } + } +} + +private fun getBuildSettingFileName(pod: CocoapodsDependency, sdk: String): String = + "build-settings-$sdk-${pod.schemeName}.properties" + +/** + * The task compiles external cocoa pods sources. + */ +open class PodBuildTask : CocoapodsTask() { + + @get:PathSensitive(PathSensitivity.ABSOLUTE) + @get:InputFile + lateinit var buildSettingsFile: Provider + internal set + + @get:Nested + internal lateinit var pod: Provider + + @get:PathSensitive(PathSensitivity.ABSOLUTE) + @get:IgnoreEmptyDirectories + @get:InputFiles + internal val srcDir: FileTree + get() = project.fileTree( + buildSettingsFile.map { PodBuildSettingsProperties.readSettingsFromReader(it.reader()).podsTargetSrcRoot } + ) + + @get:Internal + internal var buildDir: Provider = project.provider { + project.file(PodBuildSettingsProperties.readSettingsFromReader(buildSettingsFile.get().reader()).buildDir) + } + + @get:Input + internal lateinit var sdk: Provider + + @Suppress("unused") // declares an ouptut + @get:OutputFiles + internal val buildResult: Provider = project.provider { + project.fileTree(buildDir.get()) { + it.include("**/${pod.get().schemeName}.*/") + it.include("**/${pod.get().schemeName}/") + } + } + + @get:Internal + internal lateinit var podsXcodeProjDir: Provider + + @TaskAction + fun buildDependencies() { + val podBuildSettings = PodBuildSettingsProperties.readSettingsFromReader(buildSettingsFile.get().reader()) + + val podsXcodeProjDir = podsXcodeProjDir.get() + + val podXcodeBuildCommand = listOf( + "xcodebuild", + "-project", podsXcodeProjDir.name, + "-scheme", pod.get().schemeName, + "-sdk", sdk.get(), + "-configuration", podBuildSettings.configuration + ) + + runCommand(podXcodeBuildCommand, project.logger) { directory(podsXcodeProjDir.parentFile) } + } +} + + +data class PodBuildSettingsProperties( + internal val buildDir: String, + internal val configuration: String, + val configurationBuildDir: String, + internal val podsTargetSrcRoot: String, + internal val cflags: String? = null, + internal val headerPaths: String? = null, + internal val publicHeadersFolderPath: String? = null, + internal val frameworkPaths: String? = null +) { + + fun writeSettings( + buildSettingsFile: File + ) { + buildSettingsFile.parentFile.mkdirs() + buildSettingsFile.delete() + buildSettingsFile.createNewFile() + + check(buildSettingsFile.exists()) { "Unable to create file ${buildSettingsFile.path}!" } + + with(buildSettingsFile) { + appendText("$BUILD_DIR=$buildDir\n") + appendText("$CONFIGURATION=$configuration\n") + appendText("$CONFIGURATION_BUILD_DIR=$configurationBuildDir\n") + appendText("$PODS_TARGET_SRCROOT=$podsTargetSrcRoot\n") + cflags?.let { appendText("$OTHER_CFLAGS=$it\n") } + headerPaths?.let { appendText("$HEADER_SEARCH_PATHS=$it\n") } + publicHeadersFolderPath?.let { appendText("$PUBLIC_HEADERS_FOLDER_PATH=$it\n") } + frameworkPaths?.let { appendText("$FRAMEWORK_SEARCH_PATHS=$it") } + } + } + + companion object { + const val BUILD_DIR = "BUILD_DIR" + const val CONFIGURATION = "CONFIGURATION" + const val CONFIGURATION_BUILD_DIR = "CONFIGURATION_BUILD_DIR" + const val PODS_TARGET_SRCROOT = "PODS_TARGET_SRCROOT" + const val OTHER_CFLAGS = "OTHER_CFLAGS" + const val HEADER_SEARCH_PATHS = "HEADER_SEARCH_PATHS" + const val PUBLIC_HEADERS_FOLDER_PATH = "PUBLIC_HEADERS_FOLDER_PATH" + const val FRAMEWORK_SEARCH_PATHS = "FRAMEWORK_SEARCH_PATHS" + + fun readSettingsFromReader(reader: Reader): PodBuildSettingsProperties { + with(Properties()) { + @Suppress("BlockingMethodInNonBlockingContext") // It's ok to do blocking call here + load(reader) + return PodBuildSettingsProperties( + readProperty(BUILD_DIR), + readProperty(CONFIGURATION), + readProperty(CONFIGURATION_BUILD_DIR), + readProperty(PODS_TARGET_SRCROOT), + readNullableProperty(OTHER_CFLAGS), + readNullableProperty(HEADER_SEARCH_PATHS), + readNullableProperty(PUBLIC_HEADERS_FOLDER_PATH), + readNullableProperty(FRAMEWORK_SEARCH_PATHS) + ) + } + } + + private fun Properties.readProperty(propertyName: String) = + readNullableProperty(propertyName) ?: error("$propertyName property is absent") + + private fun Properties.readNullableProperty(propertyName: String) = + getProperty(propertyName) + } +} + +private object CocoapodsErrorHandlingUtil { + fun handle(e: IOException, command: List) { + if (e.message?.contains("No such file or directory") == true) { + val message = """ + |'${command.take(2).joinToString(" ")}' command failed with an exception: + | ${e.message} + | + | Full command: ${command.joinToString(" ")} + | + | Possible reason: CocoaPods is not installed + | Please check that CocoaPods v1.10 or above is installed. + | + | To check CocoaPods version type 'pod --version' in the terminal + | + | To install CocoaPods execute 'sudo gem install cocoapods' + | + """.trimMargin() + throw IllegalStateException(message) + } else { + throw e + } + } + +} diff --git a/libraries/tools/kotlin-gradle-plugin/src/common/kotlin/org/jetbrains/kotlin/gradle/targets/native/cocoapods/tasks/PodInstallSyntheticTask.kt b/libraries/tools/kotlin-gradle-plugin/src/common/kotlin/org/jetbrains/kotlin/gradle/targets/native/cocoapods/tasks/PodInstallSyntheticTask.kt new file mode 100644 index 00000000000..8a04a217427 --- /dev/null +++ b/libraries/tools/kotlin-gradle-plugin/src/common/kotlin/org/jetbrains/kotlin/gradle/targets/native/cocoapods/tasks/PodInstallSyntheticTask.kt @@ -0,0 +1,524 @@ +/* + * Copyright 2010-2020 JetBrains s.r.o. and Kotlin Programming Language contributors. + * Use of this source code is governed by the Apache 2.0 license that can be found in the license/LICENSE.txt file. + */ + +@file:Suppress("LeakingThis") // All tasks should be inherited only by Gradle + +package org.jetbrains.kotlin.gradle.targets.native.tasks + +import org.gradle.api.DefaultTask +import org.gradle.api.file.FileCollection +import org.gradle.api.file.FileTree +import org.gradle.api.provider.ListProperty +import org.gradle.api.provider.Property +import org.gradle.api.provider.Provider +import org.gradle.api.tasks.* +import org.gradle.api.tasks.Optional +import org.jetbrains.kotlin.gradle.plugin.cocoapods.CocoapodsExtension.* +import org.jetbrains.kotlin.gradle.plugin.cocoapods.CocoapodsExtension.CocoapodsDependency.PodLocation.* +import org.jetbrains.kotlin.gradle.plugin.cocoapods.cocoapodsBuildDirs +import org.jetbrains.kotlin.gradle.plugin.cocoapods.platformLiteral +import org.jetbrains.kotlin.gradle.targets.native.cocoapods.MissingCocoapodsMessage +import org.jetbrains.kotlin.gradle.targets.native.cocoapods.MissingSpecReposMessage +import org.jetbrains.kotlin.gradle.utils.runCommand +import org.jetbrains.kotlin.konan.target.Family +import org.jetbrains.kotlin.konan.target.HostManager +import java.io.File +import java.io.IOException +import java.io.Reader +import java.util.* + +val CocoapodsDependency.schemeName: String + get() = name.split("/")[0] + + +open class CocoapodsTask : DefaultTask() { + init { + onlyIf { + HostManager.hostIsMac + } + } +} + + +/** + * The task takes the path to the Podfile and calls `pod install` + * to obtain sources or artifacts for the declared dependencies. + * This task is a part of CocoaPods integration infrastructure. + */ +abstract class AbstractPodInstallTask : CocoapodsTask() { + init { + onlyIf { podfile.isPresent } + } + + @get:Optional + @get:InputFile + abstract val podfile: Property + + @get:Internal + protected val workingDir: Provider = podfile.map { file: File? -> + requireNotNull(file) { "Task outputs shouldn't be queried if it's skipped" }.parentFile + } + + @get:OutputDirectory + internal val podsDir: Provider = workingDir.map { it.resolve("Pods") } + + @get:Internal + internal val podsXcodeProjDirProvider: Provider = podsDir.map { it.resolve("Pods.xcodeproj") } + + @TaskAction + open fun doPodInstall() { + val podInstallCommand = listOf("pod", "install") + + runCommand(podInstallCommand, + logger, + errorHandler = ::handleError, + exceptionHandler = { e: IOException -> + CocoapodsErrorHandlingUtil.handle(e, podInstallCommand) + }, + processConfiguration = { + directory(workingDir.get()) + }) + + with(podsXcodeProjDirProvider.get()) { + check(exists() && isDirectory) { + "The directory 'Pods/Pods.xcodeproj' was not created as a result of the `pod install` call." + } + } + } + + abstract fun handleError(retCode: Int, error: String, process: Process): String? +} + +abstract class PodInstallTask : AbstractPodInstallTask() { + + @get:Optional + @get:InputFile + abstract val podspec: Property + + @get:Input + abstract val frameworkName: Property + + @get:Nested + abstract val specRepos: Property + + @get:Nested + abstract val pods: ListProperty + + @get:InputDirectory + abstract val dummyFramework: Property + + private val framework = project.provider { project.cocoapodsBuildDirs.framework.resolve("${frameworkName.get()}.framework") } + private val tmpFramework = dummyFramework.map { dummy -> dummy.parentFile.resolve("tmp.framework").also { it.deleteOnExit() } } + + override fun doPodInstall() { + // We always need to execute 'pod install' with the dummy framework because the one left from a previous build + // may have a wrong linkage type. So we temporarily swap them, run 'pod install' and then swap them back + framework.rename(tmpFramework) + dummyFramework.rename(framework) + super.doPodInstall() + framework.rename(dummyFramework) + tmpFramework.rename(framework) + } + + private fun Provider.rename(dest: Provider) = get().rename(dest.get()) + + private fun File.rename(dest: File) { + if (!exists()) { + mkdirs() + } + + check(renameTo(dest)) { "Can't rename '${this}' to '${dest}'" } + } + + override fun handleError(retCode: Int, error: String, process: Process): String? { + val specReposMessages = MissingSpecReposMessage(specRepos.get()).missingMessage + val cocoapodsMessages = pods.get().map { MissingCocoapodsMessage(it).missingMessage } + + return listOfNotNull( + "'pod install' command failed with code $retCode.", + "Error message:", + error.lines().filter { it.isNotBlank() }.joinToString("\n"), + """ + | Please, check that podfile contains following lines in header: + | $specReposMessages + | + """.trimMargin(), + """ + | Please, check that each target depended on ${frameworkName.get()} contains following dependencies: + | ${cocoapodsMessages.joinToString("\n")} + | + """.trimMargin() + + ).joinToString("\n") + } +} + +abstract class PodInstallSyntheticTask : AbstractPodInstallTask() { + + @get:Input + abstract val family: Property + + @get:Input + abstract val podName: Property + + @get:OutputDirectory + internal val syntheticXcodeProject: Provider = workingDir.map { it.resolve("synthetic.xcodeproj") } + + override fun doPodInstall() { + val projResource = "/cocoapods/project.pbxproj" + val projDestination = syntheticXcodeProject.get().resolve("project.pbxproj") + + syntheticXcodeProject.get().mkdirs() + projDestination.outputStream().use { file -> + javaClass.getResourceAsStream(projResource)!!.use { resource -> + resource.copyTo(file) + } + } + + super.doPodInstall() + } + + override fun handleError(retCode: Int, error: String, process: Process): String? { + var message = """ + |'pod install' command on the synthetic project failed with return code: $retCode + | + | Error: ${error.lines().filter { it.contains("[!]") }.joinToString("\n")} + | + """.trimMargin() + + if ( + error.contains("deployment target") || + error.contains("no platform was specified") || + error.contains(Regex("The platform of the target .+ is not compatible with `${podName.get()}")) + ) { + message += """ + | + | Possible reason: ${family.get().platformLiteral} deployment target is not configured + | Configure deployment_target for ALL targets as follows: + | cocoapods { + | ... + | ${family.get().platformLiteral}.deploymentTarget = "..." + | ... + | } + | + """.trimMargin() + return message + } else if ( + error.contains("Unable to add a source with url") || + error.contains("Couldn't determine repo name for URL") || + error.contains("Unable to find a specification") + ) { + message += """ + | + | Possible reason: spec repos are not configured correctly. + | Ensure that spec repos are correctly configured for all private pod dependencies: + | cocoapods { + | specRepos { + | url("") + | } + | } + | + """.trimMargin() + return message + } else { + return null + } + } +} + +/** + * The task generates a synthetic project with all cocoapods dependencies + */ +abstract class PodGenTask : CocoapodsTask() { + + init { + onlyIf { + pods.get().isNotEmpty() + } + } + + @get:InputFile + internal abstract val podspec: Property + + @get:Input + internal abstract val podName: Property + + @get:Input + internal abstract val useLibraries: Property + + @get:Input + internal abstract val family: Property + + @get:Nested + internal abstract val platformSettings: Property + + @get:Nested + internal abstract val specRepos: Property + + @get:Nested + internal abstract val pods: ListProperty + + @get:OutputFile + val podfile: Provider = family.map { project.cocoapodsBuildDirs.synthetic(it).resolve("Podfile") } + + @TaskAction + fun generate() { + val specRepos = specRepos.get().getAll() + + val podfile = this.podfile.get() + podfile.createNewFile() + + val podfileContent = getPodfileContent(specRepos, family.get().platformLiteral) + podfile.writeText(podfileContent) + } + + private fun getPodfileContent(specRepos: Collection, xcodeTarget: String) = + buildString { + + specRepos.forEach { + appendLine("source '$it'") + } + + appendLine("target '$xcodeTarget' do") + if (useLibraries.get().not()) { + appendLine("\tuse_frameworks!") + } + val settings = platformSettings.get() + val deploymentTarget = settings.deploymentTarget + if (deploymentTarget != null) { + appendLine("\tplatform :${settings.name}, '$deploymentTarget'") + } else { + appendLine("\tplatform :${settings.name}") + } + pods.get().mapNotNull { + buildString { + append("pod '${it.name}'") + + val version = it.version + val source = it.source + + if (source != null) { + append(", ${source.getPodSourcePath()}") + } else if (version != null) { + append(", '$version'") + } + + } + }.forEach { appendLine("\t$it") } + appendLine("end\n") + //disable signing for all synthetic pods KT-54314 + append( + """ + post_install do |installer| + installer.pods_project.targets.each do |target| + target.build_configurations.each do |config| + config.build_settings['EXPANDED_CODE_SIGN_IDENTITY'] = "" + config.build_settings['CODE_SIGNING_REQUIRED'] = "NO" + config.build_settings['CODE_SIGNING_ALLOWED'] = "NO" + end + end + end + """.trimIndent() + ) + appendLine() + } +} + + +open class PodSetupBuildTask : CocoapodsTask() { + + @get:Input + lateinit var frameworkName: Provider + + @get:Input + internal lateinit var sdk: Provider + + @get:Nested + lateinit var pod: Provider + + @get:OutputFile + val buildSettingsFile: Provider = project.provider { + project.cocoapodsBuildDirs + .buildSettings + .resolve(getBuildSettingFileName(pod.get(), sdk.get())) + } + + @get:Internal + internal lateinit var podsXcodeProjDir: Provider + + @TaskAction + fun setupBuild() { + val podsXcodeProjDir = podsXcodeProjDir.get() + + val buildSettingsReceivingCommand = listOf( + "xcodebuild", "-showBuildSettings", + "-project", podsXcodeProjDir.name, + "-scheme", pod.get().schemeName, + "-sdk", sdk.get() + ) + + val outputText = runCommand(buildSettingsReceivingCommand, project.logger) { directory(podsXcodeProjDir.parentFile) } + + val buildSettingsProperties = PodBuildSettingsProperties.readSettingsFromReader(outputText.reader()) + buildSettingsFile.get().let { bsf -> + buildSettingsProperties.writeSettings(bsf) + } + } +} + +private fun getBuildSettingFileName(pod: CocoapodsDependency, sdk: String): String = + "build-settings-$sdk-${pod.schemeName}.properties" + +/** + * The task compiles external cocoa pods sources. + */ +open class PodBuildTask : CocoapodsTask() { + + @get:PathSensitive(PathSensitivity.ABSOLUTE) + @get:InputFile + lateinit var buildSettingsFile: Provider + internal set + + @get:Nested + internal lateinit var pod: Provider + + @get:PathSensitive(PathSensitivity.ABSOLUTE) + @get:IgnoreEmptyDirectories + @get:InputFiles + internal val srcDir: FileTree + get() = project.fileTree( + buildSettingsFile.map { PodBuildSettingsProperties.readSettingsFromReader(it.reader()).podsTargetSrcRoot } + ) + + @get:Internal + internal var buildDir: Provider = project.provider { + project.file(PodBuildSettingsProperties.readSettingsFromReader(buildSettingsFile.get().reader()).buildDir) + } + + @get:Input + internal lateinit var sdk: Provider + + @Suppress("unused") // declares an ouptut + @get:OutputFiles + internal val buildResult: Provider = project.provider { + project.fileTree(buildDir.get()) { + it.include("**/${pod.get().schemeName}.*/") + it.include("**/${pod.get().schemeName}/") + } + } + + @get:Internal + internal lateinit var podsXcodeProjDir: Provider + + @TaskAction + fun buildDependencies() { + val podBuildSettings = PodBuildSettingsProperties.readSettingsFromReader(buildSettingsFile.get().reader()) + + val podsXcodeProjDir = podsXcodeProjDir.get() + + val podXcodeBuildCommand = listOf( + "xcodebuild", + "-project", podsXcodeProjDir.name, + "-scheme", pod.get().schemeName, + "-sdk", sdk.get(), + "-configuration", podBuildSettings.configuration + ) + + runCommand(podXcodeBuildCommand, project.logger) { directory(podsXcodeProjDir.parentFile) } + } +} + + +data class PodBuildSettingsProperties( + internal val buildDir: String, + internal val configuration: String, + val configurationBuildDir: String, + internal val podsTargetSrcRoot: String, + internal val cflags: String? = null, + internal val headerPaths: String? = null, + internal val publicHeadersFolderPath: String? = null, + internal val frameworkPaths: String? = null +) { + + fun writeSettings( + buildSettingsFile: File + ) { + buildSettingsFile.parentFile.mkdirs() + buildSettingsFile.delete() + buildSettingsFile.createNewFile() + + check(buildSettingsFile.exists()) { "Unable to create file ${buildSettingsFile.path}!" } + + with(buildSettingsFile) { + appendText("$BUILD_DIR=$buildDir\n") + appendText("$CONFIGURATION=$configuration\n") + appendText("$CONFIGURATION_BUILD_DIR=$configurationBuildDir\n") + appendText("$PODS_TARGET_SRCROOT=$podsTargetSrcRoot\n") + cflags?.let { appendText("$OTHER_CFLAGS=$it\n") } + headerPaths?.let { appendText("$HEADER_SEARCH_PATHS=$it\n") } + publicHeadersFolderPath?.let { appendText("$PUBLIC_HEADERS_FOLDER_PATH=$it\n") } + frameworkPaths?.let { appendText("$FRAMEWORK_SEARCH_PATHS=$it") } + } + } + + companion object { + const val BUILD_DIR = "BUILD_DIR" + const val CONFIGURATION = "CONFIGURATION" + const val CONFIGURATION_BUILD_DIR = "CONFIGURATION_BUILD_DIR" + const val PODS_TARGET_SRCROOT = "PODS_TARGET_SRCROOT" + const val OTHER_CFLAGS = "OTHER_CFLAGS" + const val HEADER_SEARCH_PATHS = "HEADER_SEARCH_PATHS" + const val PUBLIC_HEADERS_FOLDER_PATH = "PUBLIC_HEADERS_FOLDER_PATH" + const val FRAMEWORK_SEARCH_PATHS = "FRAMEWORK_SEARCH_PATHS" + + fun readSettingsFromReader(reader: Reader): PodBuildSettingsProperties { + with(Properties()) { + @Suppress("BlockingMethodInNonBlockingContext") // It's ok to do blocking call here + load(reader) + return PodBuildSettingsProperties( + readProperty(BUILD_DIR), + readProperty(CONFIGURATION), + readProperty(CONFIGURATION_BUILD_DIR), + readProperty(PODS_TARGET_SRCROOT), + readNullableProperty(OTHER_CFLAGS), + readNullableProperty(HEADER_SEARCH_PATHS), + readNullableProperty(PUBLIC_HEADERS_FOLDER_PATH), + readNullableProperty(FRAMEWORK_SEARCH_PATHS) + ) + } + } + + private fun Properties.readProperty(propertyName: String) = + readNullableProperty(propertyName) ?: error("$propertyName property is absent") + + private fun Properties.readNullableProperty(propertyName: String) = + getProperty(propertyName) + } +} + +private object CocoapodsErrorHandlingUtil { + fun handle(e: IOException, command: List) { + if (e.message?.contains("No such file or directory") == true) { + val message = """ + |'${command.take(2).joinToString(" ")}' command failed with an exception: + | ${e.message} + | + | Full command: ${command.joinToString(" ")} + | + | Possible reason: CocoaPods is not installed + | Please check that CocoaPods v1.10 or above is installed. + | + | To check CocoaPods version type 'pod --version' in the terminal + | + | To install CocoaPods execute 'sudo gem install cocoapods' + | + """.trimMargin() + throw IllegalStateException(message) + } else { + throw e + } + } + +} diff --git a/libraries/tools/kotlin-gradle-plugin/src/common/kotlin/org/jetbrains/kotlin/gradle/targets/native/cocoapods/tasks/PodInstallTask.kt b/libraries/tools/kotlin-gradle-plugin/src/common/kotlin/org/jetbrains/kotlin/gradle/targets/native/cocoapods/tasks/PodInstallTask.kt new file mode 100644 index 00000000000..8a04a217427 --- /dev/null +++ b/libraries/tools/kotlin-gradle-plugin/src/common/kotlin/org/jetbrains/kotlin/gradle/targets/native/cocoapods/tasks/PodInstallTask.kt @@ -0,0 +1,524 @@ +/* + * Copyright 2010-2020 JetBrains s.r.o. and Kotlin Programming Language contributors. + * Use of this source code is governed by the Apache 2.0 license that can be found in the license/LICENSE.txt file. + */ + +@file:Suppress("LeakingThis") // All tasks should be inherited only by Gradle + +package org.jetbrains.kotlin.gradle.targets.native.tasks + +import org.gradle.api.DefaultTask +import org.gradle.api.file.FileCollection +import org.gradle.api.file.FileTree +import org.gradle.api.provider.ListProperty +import org.gradle.api.provider.Property +import org.gradle.api.provider.Provider +import org.gradle.api.tasks.* +import org.gradle.api.tasks.Optional +import org.jetbrains.kotlin.gradle.plugin.cocoapods.CocoapodsExtension.* +import org.jetbrains.kotlin.gradle.plugin.cocoapods.CocoapodsExtension.CocoapodsDependency.PodLocation.* +import org.jetbrains.kotlin.gradle.plugin.cocoapods.cocoapodsBuildDirs +import org.jetbrains.kotlin.gradle.plugin.cocoapods.platformLiteral +import org.jetbrains.kotlin.gradle.targets.native.cocoapods.MissingCocoapodsMessage +import org.jetbrains.kotlin.gradle.targets.native.cocoapods.MissingSpecReposMessage +import org.jetbrains.kotlin.gradle.utils.runCommand +import org.jetbrains.kotlin.konan.target.Family +import org.jetbrains.kotlin.konan.target.HostManager +import java.io.File +import java.io.IOException +import java.io.Reader +import java.util.* + +val CocoapodsDependency.schemeName: String + get() = name.split("/")[0] + + +open class CocoapodsTask : DefaultTask() { + init { + onlyIf { + HostManager.hostIsMac + } + } +} + + +/** + * The task takes the path to the Podfile and calls `pod install` + * to obtain sources or artifacts for the declared dependencies. + * This task is a part of CocoaPods integration infrastructure. + */ +abstract class AbstractPodInstallTask : CocoapodsTask() { + init { + onlyIf { podfile.isPresent } + } + + @get:Optional + @get:InputFile + abstract val podfile: Property + + @get:Internal + protected val workingDir: Provider = podfile.map { file: File? -> + requireNotNull(file) { "Task outputs shouldn't be queried if it's skipped" }.parentFile + } + + @get:OutputDirectory + internal val podsDir: Provider = workingDir.map { it.resolve("Pods") } + + @get:Internal + internal val podsXcodeProjDirProvider: Provider = podsDir.map { it.resolve("Pods.xcodeproj") } + + @TaskAction + open fun doPodInstall() { + val podInstallCommand = listOf("pod", "install") + + runCommand(podInstallCommand, + logger, + errorHandler = ::handleError, + exceptionHandler = { e: IOException -> + CocoapodsErrorHandlingUtil.handle(e, podInstallCommand) + }, + processConfiguration = { + directory(workingDir.get()) + }) + + with(podsXcodeProjDirProvider.get()) { + check(exists() && isDirectory) { + "The directory 'Pods/Pods.xcodeproj' was not created as a result of the `pod install` call." + } + } + } + + abstract fun handleError(retCode: Int, error: String, process: Process): String? +} + +abstract class PodInstallTask : AbstractPodInstallTask() { + + @get:Optional + @get:InputFile + abstract val podspec: Property + + @get:Input + abstract val frameworkName: Property + + @get:Nested + abstract val specRepos: Property + + @get:Nested + abstract val pods: ListProperty + + @get:InputDirectory + abstract val dummyFramework: Property + + private val framework = project.provider { project.cocoapodsBuildDirs.framework.resolve("${frameworkName.get()}.framework") } + private val tmpFramework = dummyFramework.map { dummy -> dummy.parentFile.resolve("tmp.framework").also { it.deleteOnExit() } } + + override fun doPodInstall() { + // We always need to execute 'pod install' with the dummy framework because the one left from a previous build + // may have a wrong linkage type. So we temporarily swap them, run 'pod install' and then swap them back + framework.rename(tmpFramework) + dummyFramework.rename(framework) + super.doPodInstall() + framework.rename(dummyFramework) + tmpFramework.rename(framework) + } + + private fun Provider.rename(dest: Provider) = get().rename(dest.get()) + + private fun File.rename(dest: File) { + if (!exists()) { + mkdirs() + } + + check(renameTo(dest)) { "Can't rename '${this}' to '${dest}'" } + } + + override fun handleError(retCode: Int, error: String, process: Process): String? { + val specReposMessages = MissingSpecReposMessage(specRepos.get()).missingMessage + val cocoapodsMessages = pods.get().map { MissingCocoapodsMessage(it).missingMessage } + + return listOfNotNull( + "'pod install' command failed with code $retCode.", + "Error message:", + error.lines().filter { it.isNotBlank() }.joinToString("\n"), + """ + | Please, check that podfile contains following lines in header: + | $specReposMessages + | + """.trimMargin(), + """ + | Please, check that each target depended on ${frameworkName.get()} contains following dependencies: + | ${cocoapodsMessages.joinToString("\n")} + | + """.trimMargin() + + ).joinToString("\n") + } +} + +abstract class PodInstallSyntheticTask : AbstractPodInstallTask() { + + @get:Input + abstract val family: Property + + @get:Input + abstract val podName: Property + + @get:OutputDirectory + internal val syntheticXcodeProject: Provider = workingDir.map { it.resolve("synthetic.xcodeproj") } + + override fun doPodInstall() { + val projResource = "/cocoapods/project.pbxproj" + val projDestination = syntheticXcodeProject.get().resolve("project.pbxproj") + + syntheticXcodeProject.get().mkdirs() + projDestination.outputStream().use { file -> + javaClass.getResourceAsStream(projResource)!!.use { resource -> + resource.copyTo(file) + } + } + + super.doPodInstall() + } + + override fun handleError(retCode: Int, error: String, process: Process): String? { + var message = """ + |'pod install' command on the synthetic project failed with return code: $retCode + | + | Error: ${error.lines().filter { it.contains("[!]") }.joinToString("\n")} + | + """.trimMargin() + + if ( + error.contains("deployment target") || + error.contains("no platform was specified") || + error.contains(Regex("The platform of the target .+ is not compatible with `${podName.get()}")) + ) { + message += """ + | + | Possible reason: ${family.get().platformLiteral} deployment target is not configured + | Configure deployment_target for ALL targets as follows: + | cocoapods { + | ... + | ${family.get().platformLiteral}.deploymentTarget = "..." + | ... + | } + | + """.trimMargin() + return message + } else if ( + error.contains("Unable to add a source with url") || + error.contains("Couldn't determine repo name for URL") || + error.contains("Unable to find a specification") + ) { + message += """ + | + | Possible reason: spec repos are not configured correctly. + | Ensure that spec repos are correctly configured for all private pod dependencies: + | cocoapods { + | specRepos { + | url("") + | } + | } + | + """.trimMargin() + return message + } else { + return null + } + } +} + +/** + * The task generates a synthetic project with all cocoapods dependencies + */ +abstract class PodGenTask : CocoapodsTask() { + + init { + onlyIf { + pods.get().isNotEmpty() + } + } + + @get:InputFile + internal abstract val podspec: Property + + @get:Input + internal abstract val podName: Property + + @get:Input + internal abstract val useLibraries: Property + + @get:Input + internal abstract val family: Property + + @get:Nested + internal abstract val platformSettings: Property + + @get:Nested + internal abstract val specRepos: Property + + @get:Nested + internal abstract val pods: ListProperty + + @get:OutputFile + val podfile: Provider = family.map { project.cocoapodsBuildDirs.synthetic(it).resolve("Podfile") } + + @TaskAction + fun generate() { + val specRepos = specRepos.get().getAll() + + val podfile = this.podfile.get() + podfile.createNewFile() + + val podfileContent = getPodfileContent(specRepos, family.get().platformLiteral) + podfile.writeText(podfileContent) + } + + private fun getPodfileContent(specRepos: Collection, xcodeTarget: String) = + buildString { + + specRepos.forEach { + appendLine("source '$it'") + } + + appendLine("target '$xcodeTarget' do") + if (useLibraries.get().not()) { + appendLine("\tuse_frameworks!") + } + val settings = platformSettings.get() + val deploymentTarget = settings.deploymentTarget + if (deploymentTarget != null) { + appendLine("\tplatform :${settings.name}, '$deploymentTarget'") + } else { + appendLine("\tplatform :${settings.name}") + } + pods.get().mapNotNull { + buildString { + append("pod '${it.name}'") + + val version = it.version + val source = it.source + + if (source != null) { + append(", ${source.getPodSourcePath()}") + } else if (version != null) { + append(", '$version'") + } + + } + }.forEach { appendLine("\t$it") } + appendLine("end\n") + //disable signing for all synthetic pods KT-54314 + append( + """ + post_install do |installer| + installer.pods_project.targets.each do |target| + target.build_configurations.each do |config| + config.build_settings['EXPANDED_CODE_SIGN_IDENTITY'] = "" + config.build_settings['CODE_SIGNING_REQUIRED'] = "NO" + config.build_settings['CODE_SIGNING_ALLOWED'] = "NO" + end + end + end + """.trimIndent() + ) + appendLine() + } +} + + +open class PodSetupBuildTask : CocoapodsTask() { + + @get:Input + lateinit var frameworkName: Provider + + @get:Input + internal lateinit var sdk: Provider + + @get:Nested + lateinit var pod: Provider + + @get:OutputFile + val buildSettingsFile: Provider = project.provider { + project.cocoapodsBuildDirs + .buildSettings + .resolve(getBuildSettingFileName(pod.get(), sdk.get())) + } + + @get:Internal + internal lateinit var podsXcodeProjDir: Provider + + @TaskAction + fun setupBuild() { + val podsXcodeProjDir = podsXcodeProjDir.get() + + val buildSettingsReceivingCommand = listOf( + "xcodebuild", "-showBuildSettings", + "-project", podsXcodeProjDir.name, + "-scheme", pod.get().schemeName, + "-sdk", sdk.get() + ) + + val outputText = runCommand(buildSettingsReceivingCommand, project.logger) { directory(podsXcodeProjDir.parentFile) } + + val buildSettingsProperties = PodBuildSettingsProperties.readSettingsFromReader(outputText.reader()) + buildSettingsFile.get().let { bsf -> + buildSettingsProperties.writeSettings(bsf) + } + } +} + +private fun getBuildSettingFileName(pod: CocoapodsDependency, sdk: String): String = + "build-settings-$sdk-${pod.schemeName}.properties" + +/** + * The task compiles external cocoa pods sources. + */ +open class PodBuildTask : CocoapodsTask() { + + @get:PathSensitive(PathSensitivity.ABSOLUTE) + @get:InputFile + lateinit var buildSettingsFile: Provider + internal set + + @get:Nested + internal lateinit var pod: Provider + + @get:PathSensitive(PathSensitivity.ABSOLUTE) + @get:IgnoreEmptyDirectories + @get:InputFiles + internal val srcDir: FileTree + get() = project.fileTree( + buildSettingsFile.map { PodBuildSettingsProperties.readSettingsFromReader(it.reader()).podsTargetSrcRoot } + ) + + @get:Internal + internal var buildDir: Provider = project.provider { + project.file(PodBuildSettingsProperties.readSettingsFromReader(buildSettingsFile.get().reader()).buildDir) + } + + @get:Input + internal lateinit var sdk: Provider + + @Suppress("unused") // declares an ouptut + @get:OutputFiles + internal val buildResult: Provider = project.provider { + project.fileTree(buildDir.get()) { + it.include("**/${pod.get().schemeName}.*/") + it.include("**/${pod.get().schemeName}/") + } + } + + @get:Internal + internal lateinit var podsXcodeProjDir: Provider + + @TaskAction + fun buildDependencies() { + val podBuildSettings = PodBuildSettingsProperties.readSettingsFromReader(buildSettingsFile.get().reader()) + + val podsXcodeProjDir = podsXcodeProjDir.get() + + val podXcodeBuildCommand = listOf( + "xcodebuild", + "-project", podsXcodeProjDir.name, + "-scheme", pod.get().schemeName, + "-sdk", sdk.get(), + "-configuration", podBuildSettings.configuration + ) + + runCommand(podXcodeBuildCommand, project.logger) { directory(podsXcodeProjDir.parentFile) } + } +} + + +data class PodBuildSettingsProperties( + internal val buildDir: String, + internal val configuration: String, + val configurationBuildDir: String, + internal val podsTargetSrcRoot: String, + internal val cflags: String? = null, + internal val headerPaths: String? = null, + internal val publicHeadersFolderPath: String? = null, + internal val frameworkPaths: String? = null +) { + + fun writeSettings( + buildSettingsFile: File + ) { + buildSettingsFile.parentFile.mkdirs() + buildSettingsFile.delete() + buildSettingsFile.createNewFile() + + check(buildSettingsFile.exists()) { "Unable to create file ${buildSettingsFile.path}!" } + + with(buildSettingsFile) { + appendText("$BUILD_DIR=$buildDir\n") + appendText("$CONFIGURATION=$configuration\n") + appendText("$CONFIGURATION_BUILD_DIR=$configurationBuildDir\n") + appendText("$PODS_TARGET_SRCROOT=$podsTargetSrcRoot\n") + cflags?.let { appendText("$OTHER_CFLAGS=$it\n") } + headerPaths?.let { appendText("$HEADER_SEARCH_PATHS=$it\n") } + publicHeadersFolderPath?.let { appendText("$PUBLIC_HEADERS_FOLDER_PATH=$it\n") } + frameworkPaths?.let { appendText("$FRAMEWORK_SEARCH_PATHS=$it") } + } + } + + companion object { + const val BUILD_DIR = "BUILD_DIR" + const val CONFIGURATION = "CONFIGURATION" + const val CONFIGURATION_BUILD_DIR = "CONFIGURATION_BUILD_DIR" + const val PODS_TARGET_SRCROOT = "PODS_TARGET_SRCROOT" + const val OTHER_CFLAGS = "OTHER_CFLAGS" + const val HEADER_SEARCH_PATHS = "HEADER_SEARCH_PATHS" + const val PUBLIC_HEADERS_FOLDER_PATH = "PUBLIC_HEADERS_FOLDER_PATH" + const val FRAMEWORK_SEARCH_PATHS = "FRAMEWORK_SEARCH_PATHS" + + fun readSettingsFromReader(reader: Reader): PodBuildSettingsProperties { + with(Properties()) { + @Suppress("BlockingMethodInNonBlockingContext") // It's ok to do blocking call here + load(reader) + return PodBuildSettingsProperties( + readProperty(BUILD_DIR), + readProperty(CONFIGURATION), + readProperty(CONFIGURATION_BUILD_DIR), + readProperty(PODS_TARGET_SRCROOT), + readNullableProperty(OTHER_CFLAGS), + readNullableProperty(HEADER_SEARCH_PATHS), + readNullableProperty(PUBLIC_HEADERS_FOLDER_PATH), + readNullableProperty(FRAMEWORK_SEARCH_PATHS) + ) + } + } + + private fun Properties.readProperty(propertyName: String) = + readNullableProperty(propertyName) ?: error("$propertyName property is absent") + + private fun Properties.readNullableProperty(propertyName: String) = + getProperty(propertyName) + } +} + +private object CocoapodsErrorHandlingUtil { + fun handle(e: IOException, command: List) { + if (e.message?.contains("No such file or directory") == true) { + val message = """ + |'${command.take(2).joinToString(" ")}' command failed with an exception: + | ${e.message} + | + | Full command: ${command.joinToString(" ")} + | + | Possible reason: CocoaPods is not installed + | Please check that CocoaPods v1.10 or above is installed. + | + | To check CocoaPods version type 'pod --version' in the terminal + | + | To install CocoaPods execute 'sudo gem install cocoapods' + | + """.trimMargin() + throw IllegalStateException(message) + } else { + throw e + } + } + +} diff --git a/libraries/tools/kotlin-gradle-plugin/src/common/kotlin/org/jetbrains/kotlin/gradle/targets/native/cocoapods/tasks/PodSetupBuildTask.kt b/libraries/tools/kotlin-gradle-plugin/src/common/kotlin/org/jetbrains/kotlin/gradle/targets/native/cocoapods/tasks/PodSetupBuildTask.kt new file mode 100644 index 00000000000..8a04a217427 --- /dev/null +++ b/libraries/tools/kotlin-gradle-plugin/src/common/kotlin/org/jetbrains/kotlin/gradle/targets/native/cocoapods/tasks/PodSetupBuildTask.kt @@ -0,0 +1,524 @@ +/* + * Copyright 2010-2020 JetBrains s.r.o. and Kotlin Programming Language contributors. + * Use of this source code is governed by the Apache 2.0 license that can be found in the license/LICENSE.txt file. + */ + +@file:Suppress("LeakingThis") // All tasks should be inherited only by Gradle + +package org.jetbrains.kotlin.gradle.targets.native.tasks + +import org.gradle.api.DefaultTask +import org.gradle.api.file.FileCollection +import org.gradle.api.file.FileTree +import org.gradle.api.provider.ListProperty +import org.gradle.api.provider.Property +import org.gradle.api.provider.Provider +import org.gradle.api.tasks.* +import org.gradle.api.tasks.Optional +import org.jetbrains.kotlin.gradle.plugin.cocoapods.CocoapodsExtension.* +import org.jetbrains.kotlin.gradle.plugin.cocoapods.CocoapodsExtension.CocoapodsDependency.PodLocation.* +import org.jetbrains.kotlin.gradle.plugin.cocoapods.cocoapodsBuildDirs +import org.jetbrains.kotlin.gradle.plugin.cocoapods.platformLiteral +import org.jetbrains.kotlin.gradle.targets.native.cocoapods.MissingCocoapodsMessage +import org.jetbrains.kotlin.gradle.targets.native.cocoapods.MissingSpecReposMessage +import org.jetbrains.kotlin.gradle.utils.runCommand +import org.jetbrains.kotlin.konan.target.Family +import org.jetbrains.kotlin.konan.target.HostManager +import java.io.File +import java.io.IOException +import java.io.Reader +import java.util.* + +val CocoapodsDependency.schemeName: String + get() = name.split("/")[0] + + +open class CocoapodsTask : DefaultTask() { + init { + onlyIf { + HostManager.hostIsMac + } + } +} + + +/** + * The task takes the path to the Podfile and calls `pod install` + * to obtain sources or artifacts for the declared dependencies. + * This task is a part of CocoaPods integration infrastructure. + */ +abstract class AbstractPodInstallTask : CocoapodsTask() { + init { + onlyIf { podfile.isPresent } + } + + @get:Optional + @get:InputFile + abstract val podfile: Property + + @get:Internal + protected val workingDir: Provider = podfile.map { file: File? -> + requireNotNull(file) { "Task outputs shouldn't be queried if it's skipped" }.parentFile + } + + @get:OutputDirectory + internal val podsDir: Provider = workingDir.map { it.resolve("Pods") } + + @get:Internal + internal val podsXcodeProjDirProvider: Provider = podsDir.map { it.resolve("Pods.xcodeproj") } + + @TaskAction + open fun doPodInstall() { + val podInstallCommand = listOf("pod", "install") + + runCommand(podInstallCommand, + logger, + errorHandler = ::handleError, + exceptionHandler = { e: IOException -> + CocoapodsErrorHandlingUtil.handle(e, podInstallCommand) + }, + processConfiguration = { + directory(workingDir.get()) + }) + + with(podsXcodeProjDirProvider.get()) { + check(exists() && isDirectory) { + "The directory 'Pods/Pods.xcodeproj' was not created as a result of the `pod install` call." + } + } + } + + abstract fun handleError(retCode: Int, error: String, process: Process): String? +} + +abstract class PodInstallTask : AbstractPodInstallTask() { + + @get:Optional + @get:InputFile + abstract val podspec: Property + + @get:Input + abstract val frameworkName: Property + + @get:Nested + abstract val specRepos: Property + + @get:Nested + abstract val pods: ListProperty + + @get:InputDirectory + abstract val dummyFramework: Property + + private val framework = project.provider { project.cocoapodsBuildDirs.framework.resolve("${frameworkName.get()}.framework") } + private val tmpFramework = dummyFramework.map { dummy -> dummy.parentFile.resolve("tmp.framework").also { it.deleteOnExit() } } + + override fun doPodInstall() { + // We always need to execute 'pod install' with the dummy framework because the one left from a previous build + // may have a wrong linkage type. So we temporarily swap them, run 'pod install' and then swap them back + framework.rename(tmpFramework) + dummyFramework.rename(framework) + super.doPodInstall() + framework.rename(dummyFramework) + tmpFramework.rename(framework) + } + + private fun Provider.rename(dest: Provider) = get().rename(dest.get()) + + private fun File.rename(dest: File) { + if (!exists()) { + mkdirs() + } + + check(renameTo(dest)) { "Can't rename '${this}' to '${dest}'" } + } + + override fun handleError(retCode: Int, error: String, process: Process): String? { + val specReposMessages = MissingSpecReposMessage(specRepos.get()).missingMessage + val cocoapodsMessages = pods.get().map { MissingCocoapodsMessage(it).missingMessage } + + return listOfNotNull( + "'pod install' command failed with code $retCode.", + "Error message:", + error.lines().filter { it.isNotBlank() }.joinToString("\n"), + """ + | Please, check that podfile contains following lines in header: + | $specReposMessages + | + """.trimMargin(), + """ + | Please, check that each target depended on ${frameworkName.get()} contains following dependencies: + | ${cocoapodsMessages.joinToString("\n")} + | + """.trimMargin() + + ).joinToString("\n") + } +} + +abstract class PodInstallSyntheticTask : AbstractPodInstallTask() { + + @get:Input + abstract val family: Property + + @get:Input + abstract val podName: Property + + @get:OutputDirectory + internal val syntheticXcodeProject: Provider = workingDir.map { it.resolve("synthetic.xcodeproj") } + + override fun doPodInstall() { + val projResource = "/cocoapods/project.pbxproj" + val projDestination = syntheticXcodeProject.get().resolve("project.pbxproj") + + syntheticXcodeProject.get().mkdirs() + projDestination.outputStream().use { file -> + javaClass.getResourceAsStream(projResource)!!.use { resource -> + resource.copyTo(file) + } + } + + super.doPodInstall() + } + + override fun handleError(retCode: Int, error: String, process: Process): String? { + var message = """ + |'pod install' command on the synthetic project failed with return code: $retCode + | + | Error: ${error.lines().filter { it.contains("[!]") }.joinToString("\n")} + | + """.trimMargin() + + if ( + error.contains("deployment target") || + error.contains("no platform was specified") || + error.contains(Regex("The platform of the target .+ is not compatible with `${podName.get()}")) + ) { + message += """ + | + | Possible reason: ${family.get().platformLiteral} deployment target is not configured + | Configure deployment_target for ALL targets as follows: + | cocoapods { + | ... + | ${family.get().platformLiteral}.deploymentTarget = "..." + | ... + | } + | + """.trimMargin() + return message + } else if ( + error.contains("Unable to add a source with url") || + error.contains("Couldn't determine repo name for URL") || + error.contains("Unable to find a specification") + ) { + message += """ + | + | Possible reason: spec repos are not configured correctly. + | Ensure that spec repos are correctly configured for all private pod dependencies: + | cocoapods { + | specRepos { + | url("") + | } + | } + | + """.trimMargin() + return message + } else { + return null + } + } +} + +/** + * The task generates a synthetic project with all cocoapods dependencies + */ +abstract class PodGenTask : CocoapodsTask() { + + init { + onlyIf { + pods.get().isNotEmpty() + } + } + + @get:InputFile + internal abstract val podspec: Property + + @get:Input + internal abstract val podName: Property + + @get:Input + internal abstract val useLibraries: Property + + @get:Input + internal abstract val family: Property + + @get:Nested + internal abstract val platformSettings: Property + + @get:Nested + internal abstract val specRepos: Property + + @get:Nested + internal abstract val pods: ListProperty + + @get:OutputFile + val podfile: Provider = family.map { project.cocoapodsBuildDirs.synthetic(it).resolve("Podfile") } + + @TaskAction + fun generate() { + val specRepos = specRepos.get().getAll() + + val podfile = this.podfile.get() + podfile.createNewFile() + + val podfileContent = getPodfileContent(specRepos, family.get().platformLiteral) + podfile.writeText(podfileContent) + } + + private fun getPodfileContent(specRepos: Collection, xcodeTarget: String) = + buildString { + + specRepos.forEach { + appendLine("source '$it'") + } + + appendLine("target '$xcodeTarget' do") + if (useLibraries.get().not()) { + appendLine("\tuse_frameworks!") + } + val settings = platformSettings.get() + val deploymentTarget = settings.deploymentTarget + if (deploymentTarget != null) { + appendLine("\tplatform :${settings.name}, '$deploymentTarget'") + } else { + appendLine("\tplatform :${settings.name}") + } + pods.get().mapNotNull { + buildString { + append("pod '${it.name}'") + + val version = it.version + val source = it.source + + if (source != null) { + append(", ${source.getPodSourcePath()}") + } else if (version != null) { + append(", '$version'") + } + + } + }.forEach { appendLine("\t$it") } + appendLine("end\n") + //disable signing for all synthetic pods KT-54314 + append( + """ + post_install do |installer| + installer.pods_project.targets.each do |target| + target.build_configurations.each do |config| + config.build_settings['EXPANDED_CODE_SIGN_IDENTITY'] = "" + config.build_settings['CODE_SIGNING_REQUIRED'] = "NO" + config.build_settings['CODE_SIGNING_ALLOWED'] = "NO" + end + end + end + """.trimIndent() + ) + appendLine() + } +} + + +open class PodSetupBuildTask : CocoapodsTask() { + + @get:Input + lateinit var frameworkName: Provider + + @get:Input + internal lateinit var sdk: Provider + + @get:Nested + lateinit var pod: Provider + + @get:OutputFile + val buildSettingsFile: Provider = project.provider { + project.cocoapodsBuildDirs + .buildSettings + .resolve(getBuildSettingFileName(pod.get(), sdk.get())) + } + + @get:Internal + internal lateinit var podsXcodeProjDir: Provider + + @TaskAction + fun setupBuild() { + val podsXcodeProjDir = podsXcodeProjDir.get() + + val buildSettingsReceivingCommand = listOf( + "xcodebuild", "-showBuildSettings", + "-project", podsXcodeProjDir.name, + "-scheme", pod.get().schemeName, + "-sdk", sdk.get() + ) + + val outputText = runCommand(buildSettingsReceivingCommand, project.logger) { directory(podsXcodeProjDir.parentFile) } + + val buildSettingsProperties = PodBuildSettingsProperties.readSettingsFromReader(outputText.reader()) + buildSettingsFile.get().let { bsf -> + buildSettingsProperties.writeSettings(bsf) + } + } +} + +private fun getBuildSettingFileName(pod: CocoapodsDependency, sdk: String): String = + "build-settings-$sdk-${pod.schemeName}.properties" + +/** + * The task compiles external cocoa pods sources. + */ +open class PodBuildTask : CocoapodsTask() { + + @get:PathSensitive(PathSensitivity.ABSOLUTE) + @get:InputFile + lateinit var buildSettingsFile: Provider + internal set + + @get:Nested + internal lateinit var pod: Provider + + @get:PathSensitive(PathSensitivity.ABSOLUTE) + @get:IgnoreEmptyDirectories + @get:InputFiles + internal val srcDir: FileTree + get() = project.fileTree( + buildSettingsFile.map { PodBuildSettingsProperties.readSettingsFromReader(it.reader()).podsTargetSrcRoot } + ) + + @get:Internal + internal var buildDir: Provider = project.provider { + project.file(PodBuildSettingsProperties.readSettingsFromReader(buildSettingsFile.get().reader()).buildDir) + } + + @get:Input + internal lateinit var sdk: Provider + + @Suppress("unused") // declares an ouptut + @get:OutputFiles + internal val buildResult: Provider = project.provider { + project.fileTree(buildDir.get()) { + it.include("**/${pod.get().schemeName}.*/") + it.include("**/${pod.get().schemeName}/") + } + } + + @get:Internal + internal lateinit var podsXcodeProjDir: Provider + + @TaskAction + fun buildDependencies() { + val podBuildSettings = PodBuildSettingsProperties.readSettingsFromReader(buildSettingsFile.get().reader()) + + val podsXcodeProjDir = podsXcodeProjDir.get() + + val podXcodeBuildCommand = listOf( + "xcodebuild", + "-project", podsXcodeProjDir.name, + "-scheme", pod.get().schemeName, + "-sdk", sdk.get(), + "-configuration", podBuildSettings.configuration + ) + + runCommand(podXcodeBuildCommand, project.logger) { directory(podsXcodeProjDir.parentFile) } + } +} + + +data class PodBuildSettingsProperties( + internal val buildDir: String, + internal val configuration: String, + val configurationBuildDir: String, + internal val podsTargetSrcRoot: String, + internal val cflags: String? = null, + internal val headerPaths: String? = null, + internal val publicHeadersFolderPath: String? = null, + internal val frameworkPaths: String? = null +) { + + fun writeSettings( + buildSettingsFile: File + ) { + buildSettingsFile.parentFile.mkdirs() + buildSettingsFile.delete() + buildSettingsFile.createNewFile() + + check(buildSettingsFile.exists()) { "Unable to create file ${buildSettingsFile.path}!" } + + with(buildSettingsFile) { + appendText("$BUILD_DIR=$buildDir\n") + appendText("$CONFIGURATION=$configuration\n") + appendText("$CONFIGURATION_BUILD_DIR=$configurationBuildDir\n") + appendText("$PODS_TARGET_SRCROOT=$podsTargetSrcRoot\n") + cflags?.let { appendText("$OTHER_CFLAGS=$it\n") } + headerPaths?.let { appendText("$HEADER_SEARCH_PATHS=$it\n") } + publicHeadersFolderPath?.let { appendText("$PUBLIC_HEADERS_FOLDER_PATH=$it\n") } + frameworkPaths?.let { appendText("$FRAMEWORK_SEARCH_PATHS=$it") } + } + } + + companion object { + const val BUILD_DIR = "BUILD_DIR" + const val CONFIGURATION = "CONFIGURATION" + const val CONFIGURATION_BUILD_DIR = "CONFIGURATION_BUILD_DIR" + const val PODS_TARGET_SRCROOT = "PODS_TARGET_SRCROOT" + const val OTHER_CFLAGS = "OTHER_CFLAGS" + const val HEADER_SEARCH_PATHS = "HEADER_SEARCH_PATHS" + const val PUBLIC_HEADERS_FOLDER_PATH = "PUBLIC_HEADERS_FOLDER_PATH" + const val FRAMEWORK_SEARCH_PATHS = "FRAMEWORK_SEARCH_PATHS" + + fun readSettingsFromReader(reader: Reader): PodBuildSettingsProperties { + with(Properties()) { + @Suppress("BlockingMethodInNonBlockingContext") // It's ok to do blocking call here + load(reader) + return PodBuildSettingsProperties( + readProperty(BUILD_DIR), + readProperty(CONFIGURATION), + readProperty(CONFIGURATION_BUILD_DIR), + readProperty(PODS_TARGET_SRCROOT), + readNullableProperty(OTHER_CFLAGS), + readNullableProperty(HEADER_SEARCH_PATHS), + readNullableProperty(PUBLIC_HEADERS_FOLDER_PATH), + readNullableProperty(FRAMEWORK_SEARCH_PATHS) + ) + } + } + + private fun Properties.readProperty(propertyName: String) = + readNullableProperty(propertyName) ?: error("$propertyName property is absent") + + private fun Properties.readNullableProperty(propertyName: String) = + getProperty(propertyName) + } +} + +private object CocoapodsErrorHandlingUtil { + fun handle(e: IOException, command: List) { + if (e.message?.contains("No such file or directory") == true) { + val message = """ + |'${command.take(2).joinToString(" ")}' command failed with an exception: + | ${e.message} + | + | Full command: ${command.joinToString(" ")} + | + | Possible reason: CocoaPods is not installed + | Please check that CocoaPods v1.10 or above is installed. + | + | To check CocoaPods version type 'pod --version' in the terminal + | + | To install CocoaPods execute 'sudo gem install cocoapods' + | + """.trimMargin() + throw IllegalStateException(message) + } else { + throw e + } + } + +} diff --git a/libraries/tools/kotlin-gradle-plugin/src/common/kotlin/org/jetbrains/kotlin/gradle/targets/native/cocoapods/tasks/PodspecTask.kt b/libraries/tools/kotlin-gradle-plugin/src/common/kotlin/org/jetbrains/kotlin/gradle/targets/native/cocoapods/tasks/PodspecTask.kt new file mode 100644 index 00000000000..7dbbb3c5f83 --- /dev/null +++ b/libraries/tools/kotlin-gradle-plugin/src/common/kotlin/org/jetbrains/kotlin/gradle/targets/native/cocoapods/tasks/PodspecTask.kt @@ -0,0 +1,354 @@ +/* + * Copyright 2010-2019 JetBrains s.r.o. and Kotlin Programming Language contributors. + * Use of this source code is governed by the Apache 2.0 license that can be found in the license/LICENSE.txt file. + */ + +@file:Suppress("PackageDirectoryMismatch") // Old package for compatibility +package org.jetbrains.kotlin.gradle.tasks + +import org.gradle.api.DefaultTask +import org.gradle.api.Project +import org.gradle.api.plugins.ExtensionAware +import org.gradle.api.provider.Property +import org.gradle.api.provider.Provider +import org.gradle.api.tasks.* +import org.gradle.api.tasks.wrapper.Wrapper +import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension +import org.jetbrains.kotlin.gradle.dsl.multiplatformExtensionOrNull +import org.jetbrains.kotlin.gradle.plugin.cocoapods.CocoapodsExtension +import org.jetbrains.kotlin.gradle.plugin.cocoapods.CocoapodsExtension.* +import org.jetbrains.kotlin.gradle.plugin.cocoapods.KotlinCocoapodsPlugin +import org.jetbrains.kotlin.gradle.plugin.cocoapods.KotlinCocoapodsPlugin.Companion.COCOAPODS_EXTENSION_NAME +import org.jetbrains.kotlin.gradle.plugin.cocoapods.KotlinCocoapodsPlugin.Companion.GENERATE_WRAPPER_PROPERTY +import org.jetbrains.kotlin.gradle.plugin.cocoapods.KotlinCocoapodsPlugin.Companion.SYNC_TASK_NAME +import org.jetbrains.kotlin.gradle.plugin.cocoapods.cocoapodsBuildDirs +import org.jetbrains.kotlin.gradle.utils.appendLine +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() { + + @get:Input + internal val specName = project.objects.property(String::class.java) + + @get:Internal + internal val outputDir = project.objects.property(File::class.java) + + @get:OutputFile + val outputFile: File + get() = outputDir.get().resolve("${specName.get()}.podspec") + + @get:Input + internal lateinit var needPodspec: Provider + + @get:Nested + val pods = project.objects.listProperty(CocoapodsDependency::class.java) + + @get:Input + internal val version = project.objects.property(String::class.java) + + @get:Input + internal val publishing = project.objects.property(Boolean::class.java) + + @get:Input + @get:Optional + internal val source = project.objects.property(String::class.java) + + @get:Input + @get:Optional + internal val homepage = project.objects.property(String::class.java) + + @get:Input + @get:Optional + internal val license = project.objects.property(String::class.java) + + @get:Input + @get:Optional + internal val authors = project.objects.property(String::class.java) + + @get:Input + @get:Optional + internal val summary = project.objects.property(String::class.java) + + @get:Input + @get:Optional + internal val extraSpecAttributes = project.objects.mapProperty(String::class.java, String::class.java) + + @get:Input + internal lateinit var frameworkName: Provider + + @get:Nested + internal lateinit var ios: Provider + + @get:Nested + internal lateinit var osx: Provider + + @get:Nested + internal lateinit var tvos: Provider + + @get:Nested + internal lateinit var watchos: Provider + + init { + onlyIf { needPodspec.get() } + } + + @TaskAction + fun generate() { + + check(version.get() != Project.DEFAULT_VERSION) { + """ + Cocoapods Integration requires pod version to be specified. + Please specify pod version by adding 'version = ""' to the cocoapods block. + Alternatively, specify the version for the entire project explicitly. + Pod version format has to conform podspec syntax requirements: https://guides.cocoapods.org/syntax/podspec.html#version + """.trimIndent() + } + + val gradleWrapper = (project.rootProject.tasks.getByName("wrapper") as? Wrapper)?.scriptFile + require(gradleWrapper != null && gradleWrapper.exists()) { + """ + The Gradle wrapper is required to run the build from Xcode. + + Please run the same command with `-P$GENERATE_WRAPPER_PROPERTY=true` or run the `:wrapper` task to generate the wrapper manually. + + See details about the wrapper at https://docs.gradle.org/current/userguide/gradle_wrapper.html + """.trimIndent() + } + + val deploymentTargets = run { + listOf(ios, osx, tvos, watchos).map { it.get() }.filter { it.deploymentTarget != null }.joinToString("\n") { + if (extraSpecAttributes.get().containsKey("${it.name}.deployment_target")) "" else "| spec.${it.name}.deployment_target = '${it.deploymentTarget}'" + } + } + + val dependencies = pods.get().map { pod -> + val versionSuffix = if (pod.version != null) ", '${pod.version}'" else "" + "| spec.dependency '${pod.name}'$versionSuffix" + }.joinToString(separator = "\n") + + val frameworkDir = project.cocoapodsBuildDirs.framework.relativeTo(outputFile.parentFile) + val vendoredFramework = if (publishing.get()) "${frameworkName.get()}.xcframework" else frameworkDir.resolve("${frameworkName.get()}.framework").invariantSeparatorsPath + val vendoredFrameworks = if (extraSpecAttributes.get().containsKey("vendored_frameworks")) "" else "| spec.vendored_frameworks = '$vendoredFramework'" + + val libraries = if (extraSpecAttributes.get().containsKey("libraries")) "" else "| spec.libraries = 'c++'" + + val xcConfig = if (publishing.get() || extraSpecAttributes.get().containsKey("pod_target_xcconfig")) "" else + """ | + | spec.pod_target_xcconfig = { + | 'KOTLIN_PROJECT_PATH' => '${if (project.depth != 0) project.path else ""}', + | 'PRODUCT_MODULE_NAME' => '${frameworkName.get()}', + | } + """.trimMargin() + + val gradleCommand = "\$REPO_ROOT/${gradleWrapper.relativeTo(project.projectDir).invariantSeparatorsPath}" + val scriptPhase = if (publishing.get() || extraSpecAttributes.get().containsKey("script_phases")) "" else + """ | + | spec.script_phases = [ + | { + | :name => 'Build ${specName.get()}', + | :execution_position => :before_compile, + | :shell_path => '/bin/sh', + | :script => <<-SCRIPT + | if [ "YES" = "${'$'}OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED" ]; then + | echo "Skipping Gradle build task invocation due to OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED environment variable set to \"YES\"" + | exit 0 + | fi + | set -ev + | REPO_ROOT="${'$'}PODS_TARGET_SRCROOT" + | "$gradleCommand" -p "${'$'}REPO_ROOT" ${'$'}KOTLIN_PROJECT_PATH:$SYNC_TASK_NAME \ + | -P${KotlinCocoapodsPlugin.PLATFORM_PROPERTY}=${'$'}PLATFORM_NAME \ + | -P${KotlinCocoapodsPlugin.ARCHS_PROPERTY}="${'$'}ARCHS" \ + | -P${KotlinCocoapodsPlugin.CONFIGURATION_PROPERTY}="${'$'}CONFIGURATION" + | SCRIPT + | } + | ] + """.trimMargin() + + val customSpec = extraSpecAttributes.get().map { "| spec.${it.key} = ${it.value}" }.joinToString("\n") + + with(outputFile) { + writeText( + """ + |Pod::Spec.new do |spec| + | spec.name = '${specName.get()}' + | spec.version = '${version.get()}' + | spec.homepage = ${homepage.getOrEmpty().surroundWithSingleQuotesIfNeeded()} + | spec.source = ${source.getOrElse("{ :http=> ''}")} + | spec.authors = ${authors.getOrEmpty().surroundWithSingleQuotesIfNeeded()} + | spec.license = ${license.getOrEmpty().surroundWithSingleQuotesIfNeeded()} + | spec.summary = '${summary.getOrEmpty()}' + $vendoredFrameworks + $libraries + $deploymentTargets + $dependencies + $xcConfig + $scriptPhase + $customSpec + |end + """.trimMargin() + ) + + if (hasPodfileOwnOrParent(project) && publishing.get().not()) { + logger.quiet( + """ + Generated a podspec file at: ${absolutePath}. + To include it in your Xcode project, check that the following dependency snippet exists in your Podfile: + + pod '${specName.get()}', :path => '${parentFile.absolutePath}' + + """.trimIndent() + ) + } + + } + } + + private fun Provider.getOrEmpty(): String = getOrElse("") + + private fun String.surroundWithSingleQuotesIfNeeded(): String = + if (startsWith("{") || startsWith("<<-") || startsWith("'")) this else "'$this'" + + companion object { + private val KotlinMultiplatformExtension?.cocoapodsExtensionOrNull: CocoapodsExtension? + get() = (this as? ExtensionAware)?.extensions?.findByName(COCOAPODS_EXTENSION_NAME) as? CocoapodsExtension + + private fun hasPodfileOwnOrParent(project: Project): Boolean = + if (project.rootProject == project) project.multiplatformExtensionOrNull?.cocoapodsExtensionOrNull?.podfile != null + else project.multiplatformExtensionOrNull?.cocoapodsExtensionOrNull?.podfile != null + || (project.parent?.let { hasPodfileOwnOrParent(it) } ?: false) + + } +} + + +/** + * 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. + */ +abstract class DummyFrameworkTask : DefaultTask() { + + @get:Input + abstract val frameworkName: Property + + @get:Input + abstract val useStaticFramework: Property + + @get:OutputDirectory + val outputFramework: Provider = project.provider { project.cocoapodsBuildDirs.dummyFramework } + + private val dummyFrameworkResource: String + get() { + val staticOrDynamic = if (!useStaticFramework.get()) "dynamic" else "static" + return "/cocoapods/$staticOrDynamic/dummy.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 copyTextResource(from: String, to: File, transform: (String) -> String = { it }) { + to.parentFile.mkdirs() + to.printWriter().use { file -> + javaClass.getResourceAsStream(from)!!.use { + it.reader().forEachLine { str -> + file.println(transform(str)) + } + } + } + } + + private fun copyFrameworkFile(relativeFrom: String, relativeTo: String = relativeFrom) = + copyResource( + "$dummyFrameworkResource$relativeFrom", + outputFramework.get().resolve(relativeTo) + ) + + private fun copyFrameworkTextFile( + relativeFrom: String, + relativeTo: String = relativeFrom, + transform: (String) -> String = { it } + ) = copyTextResource( + "$dummyFrameworkResource$relativeFrom", + outputFramework.get().resolve(relativeTo), + transform + ) + + @TaskAction + fun create() { + // Reset the destination directory + with(outputFramework.get()) { + deleteRecursively() + mkdirs() + } + + // Copy files for the dummy framework. + copyFrameworkFile("Info.plist") + copyFrameworkFile("dummy", frameworkName.get()) + copyFrameworkFile("Headers/placeholder.h") + copyFrameworkTextFile("Modules/module.modulemap") { + if (it == "framework module dummy {") { + it.replace("dummy", frameworkName.get()) + } else { + it + } + } + } +} + +/** + * Generates a def-file for the given CocoaPods dependency. + */ +abstract class DefFileTask : DefaultTask() { + + @get:Nested + abstract val pod: Property + + @get:Input + abstract val useLibraries: Property + + @get:OutputFile + val outputFile: File + get() = project.cocoapodsBuildDirs.defs.resolve("${pod.get().moduleName}.def") + + @TaskAction + fun generate() { + outputFile.parentFile.mkdirs() + outputFile.writeText(buildString { + appendLine("language = Objective-C") + with(pod.get()) { + when { + headers != null -> appendLine("headers = $headers") + useLibraries.get() -> logger.warn( + """ + w: Pod '$moduleName' should have 'headers' property specified when using 'useLibraries()'. + Otherwise code from this pod won't be accessible from Kotlin. + """.trimIndent() + ) + else -> { + appendLine("modules = $moduleName") + + // Linker opt with framework name is added so produced cinterop klib would have this flag inside its manifest + // This way error will be more obvious when someone will try to depend on a library with this cinterop + appendLine("linkerOpts = -framework $moduleName") + } + } + } + }) + } +} \ No newline at end of file