Search code examples
xcodegradlecocoapodskotlin-multiplatform

KMM Signing Pod Dependency for Xcode 14


I've recently updated to Xcode 14 for my KMM project and I now run into this build error when syncing gradle.

shared/build/cocoapods/synthetic/IOS/Pods/Pods.xcodeproj: error: Signing for "gRPC-C++-gRPCCertificates-Cpp" requires a development team. Select a development team in the Signing & Capabilities editor. (in target 'gRPC-C++-gRPCCertificates-Cpp' from project 'Pods')

I'm using Firebase and am including the pods as dependencies in my shared build.gradle file.

kotlin {
    ...
    cocoapods {
        ...
        ios.deploymentTarget = "15.0"
        podfile = project.file("../iosApp/Podfile")
        framework {
            baseName = "shared"
        }

        xcodeConfigurationToNativeBuildType

        val firebaseVersion = "9.6.0"

        pod("FirebaseCore") { version = firebaseVersion }
        pod("FirebaseAuth") { version = firebaseVersion }
        pod("FirebaseFirestore") { version = firebaseVersion }
        pod("FirebaseCrashlytics") { version = firebaseVersion }
        pod("FirebaseAnalytics") { version = firebaseVersion }
    }
    ...
}

I have a team setup and if I open Xcode and go to the Pods project, select gRPC-C++-gRPCCertificates-Cpp, then add my team to it, the project builds fine from Xcode.

However, this doesn't fix the gradle issue as it seems to use its own temporary project file when syncing (in shared/build/cocoapods/synthetic/IOS/Pods/Pods.xcodepro)

Is there any way to add my team to the gradle sync build?

As a temporary work around, I've switched back to Xcode 13.4.1 which doesn't have this problem.


Solution

  • This will be fixed in Kotlin 1.8.0 but in the meantime here is a workaround (from https://youtrack.jetbrains.com/issue/KT-54161)

    First create the directories buildSrc/src/main/kotlin in the root of your project. In buildSrc create the file build.gradle.kts and add the following contents.

    plugins {
        `kotlin-dsl`
    }
    
    repositories {
        mavenCentral()
        google()
    }
    
    dependencies {
        implementation("org.jetbrains.kotlin:kotlin-gradle-plugin:1.7.20")
        implementation("com.android.tools.build:gradle:7.3.1")
    }
    

    Then create the file PatchedPodGenTask.kt in buildSrc/src/main/kotlin. Add the following to it.

    import org.gradle.api.Project
    import org.gradle.api.logging.Logger
    import org.gradle.api.provider.Provider
    import org.gradle.api.tasks.*
    import java.io.File
    import java.io.IOException
    import org.jetbrains.kotlin.konan.target.*
    import org.jetbrains.kotlin.gradle.targets.native.tasks.PodGenTask
    import org.jetbrains.kotlin.gradle.plugin.cocoapods.CocoapodsExtension.SpecRepos
    import org.jetbrains.kotlin.gradle.plugin.cocoapods.CocoapodsExtension.CocoapodsDependency.PodLocation.*
    import org.jetbrains.kotlin.gradle.plugin.cocoapods.CocoapodsExtension.CocoapodsDependency.PodLocation
    import org.jetbrains.kotlin.gradle.plugin.mpp.NativeBuildType
    import kotlin.concurrent.thread
    import kotlin.reflect.KClass
    
    //import org.jetbrains.kotlin.gradle.plugin.cocoapods.cocoapodsBuildDirs
    
    private val Family.platformLiteral: String
        get() = when (this) {
            Family.OSX -> "macos"
            Family.IOS -> "ios"
            Family.TVOS -> "tvos"
            Family.WATCHOS -> "watchos"
            else -> throw IllegalArgumentException("Bad family ${this.name}")
        }
    
    internal val Project.cocoapodsBuildDirs: CocoapodsBuildDirs
        get() = CocoapodsBuildDirs(this)
    
    internal class CocoapodsBuildDirs(val project: Project) {
        val root: File
            get() = project.buildDir.resolve("cocoapods")
    
        val framework: File
            get() = root.resolve("framework")
    
        val defs: File
            get() = root.resolve("defs")
    
        val buildSettings: File
            get() = root.resolve("buildSettings")
    
        val synthetic: File
            get() = root.resolve("synthetic")
    
        fun synthetic(family: Family) = synthetic.resolve(family.name)
    
        val externalSources: File
            get() = root.resolve("externalSources")
    
        val publish: File = root.resolve("publish")
    
        fun externalSources(fileName: String) = externalSources.resolve(fileName)
    
        fun fatFramework(buildType: NativeBuildType) =
            root.resolve("fat-frameworks/${buildType.getName()}")
    }
    
    
    /**
     * The task generates a synthetic project with all cocoapods dependencies
     */
    open class PatchedPodGenTask : PodGenTask() {
        private val PODFILE_SUFFIX = """
            post_install do |installer|
              installer.pods_project.build_configurations.each do |config|
                config.build_settings["EXCLUDED_ARCHS[sdk=iphonesimulator*]"] = "arm64"
              end
              installer.pods_project.targets.each do |target|
                target.build_configurations.each do |config|
                 config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '13.0'
                end
              end
              installer.generated_projects.each do |project|
                project.targets.each do |target|
                    target.build_configurations.each do |config|
                        config.build_settings["DEVELOPMENT_TEAM"] = "..."
                    end
                end
              end
            end
        """.trimIndent()
    
        @TaskAction
        fun patchedGenerate() {
            val syntheticDir = project.cocoapodsBuildDirs.synthetic(family).apply { mkdirs() }
            val specRepos: Collection<String> = specReposAccessor.get().getAllAccessor()
    
            val projResource = "/cocoapods/project.pbxproj"
            val projDestination = syntheticDir.resolve("synthetic.xcodeproj").resolve("project.pbxproj")
    
            projDestination.parentFile.mkdirs()
            projDestination.outputStream().use { file ->
                javaClass.getResourceAsStream(projResource).use { resource ->
                    resource.copyTo(file)
                }
            }
    
            val podfile = syntheticDir.resolve("Podfile")
            podfile.createNewFile()
    
            val podfileContent = getPodfileContent(specRepos, family.platformLiteral) + PODFILE_SUFFIX
            podfile.writeText(podfileContent)
            val podInstallCommand = listOf("pod", "install")
    
            runCommand(
                podInstallCommand,
                project.logger,
                exceptionHandler = { e: IOException ->
                    CocoapodsErrorHandlingUtil.handle(e, podInstallCommand)
                },
                errorHandler = { retCode, output, _ ->
                    CocoapodsErrorHandlingUtil.handlePodInstallSyntheticError(
                        podInstallCommand.joinToString(" "),
                        retCode,
                        output,
                        family,
                        podNameAccessor.get()
                    )
                },
                processConfiguration = {
                    directory(syntheticDir)
                })
    
            val podsXcprojFile = podsXcodeProjDirAccessor.get()
            check(podsXcprojFile.exists() && podsXcprojFile.isDirectory) {
                "Synthetic project '${podsXcprojFile.path}' was not created."
            }
        }
    
        private fun getPodfileContent(specRepos: Collection<String>, xcodeTarget: String) =
            buildString {
    
                specRepos.forEach {
                    appendLine("source '$it'")
                }
    
                appendLine("target '$xcodeTarget' do")
                //if (useLibraries.get().not()) {
                    appendLine("\tuse_frameworks!")
                //}
                pods.get().mapNotNull {
                    buildString {
                        append("pod '${it.name}'")
    
                        val pathType = when (it.source) {
                            is Path -> "path"
                            is Url -> "path"
                            is Git -> "git"
                            else -> null
                        }
    
                        val path = it.source?.getLocalPathAccessor(project, it.name)
    
                        if (path != null && pathType != null) {
                            append(", :$pathType => '$path'")
                        }
    
                    }
                }.forEach { appendLine("\t$it") }
                appendLine("end\n")
            }
    }
    
    fun <T : Any, R> KClass<T>.invokeDynamic(methodStart: String, instance: Any?, vararg params: Any?): R {
        val method = this.java.methods.firstOrNull { it.name.startsWith(methodStart) } ?: error("Can't find accessor for $methodStart")
        return method.invoke(instance, *params) as R
    }
    
    
    val PodGenTask.podsXcodeProjDirAccessor: Provider<File> get() = this::class.invokeDynamic("getPodsXcodeProjDir", this)
    val PodGenTask.specReposAccessor: Provider<SpecRepos> get() = this::class.invokeDynamic("getSpecRepos", this)
    val PodGenTask.podNameAccessor: Provider<String> get() = this::class.invokeDynamic("getPodName", this)
    fun SpecRepos.getAllAccessor(): Collection<String> = this::class.invokeDynamic("getAll", this)
    fun PodLocation.getLocalPathAccessor(project: Project, podName: String): String? = this::class.invokeDynamic("getLocalPath", this, project, podName)
    
    private fun runCommand(
        command: List<String>,
        logger: Logger,
        errorHandler: ((retCode: Int, output: String, process: Process) -> String?)? = null,
        exceptionHandler: ((ex: IOException) -> Unit)? = null,
        processConfiguration: ProcessBuilder.() -> Unit = { }
    ): String {
        var process: Process? = null
        try {
            process = ProcessBuilder(command)
                .apply {
                    this.processConfiguration()
                }.start()
        } catch (e: IOException) {
            if (exceptionHandler != null) exceptionHandler(e) else throw e
        }
    
        if (process == null) {
            throw IllegalStateException("Failed to run command ${command.joinToString(" ")}")
        }
    
        var inputText = ""
        var errorText = ""
    
        val inputThread = thread {
            inputText = process.inputStream.use {
                it.reader().readText()
            }
        }
    
        val errorThread = thread {
            errorText = process.errorStream.use {
                it.reader().readText()
            }
        }
    
        inputThread.join()
        errorThread.join()
    
        val retCode = process.waitFor()
        logger.info(
            """
                |Information about "${command.joinToString(" ")}" call:
                |
                |${inputText}
            """.trimMargin()
        )
    
        check(retCode == 0) {
            errorHandler?.invoke(retCode, inputText.ifBlank { errorText }, process)
                ?: """
                    |Executing of '${command.joinToString(" ")}' failed with code $retCode and message: 
                    |
                    |$inputText
                    |
                    |$errorText
                    |
                    """.trimMargin()
        }
    
        return inputText
    }
    
    private object CocoapodsErrorHandlingUtil {
        fun handle(e: IOException, command: List<String>) {
            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
            }
        }
    
        fun handlePodInstallSyntheticError(command: String, retCode: Int, error: String, family: Family, podName: String): String? {
            var message = """
                |'pod install' command on the synthetic project failed with return code: $retCode
                |
                |        Full command: $command
                |
                |        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"))
            ) {
                message += """
                    |
                    |        Possible reason: ${family.name.toLowerCase()} deployment target is not configured
                    |        Configure deployment_target for ALL targets as follows:
                    |        cocoapods {
                    |           ...
                    |           ${family.name.toLowerCase()}.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("<private spec repo url>")
                    |           }
                    |        }
                    |       
                """.trimMargin()
                return message
            }
            return null
        }
    
    }
    

    Find the line that says config.build_settings["DEVELOPMENT_TEAM"] = "..." and replace ... with your actual dev team ID.

    Finally, in the build.gradle.kts file of the module you want to apply it (mine was the shared module) add the following

    // @TODO: This is a hack, and we should remove it once PodGenTask is fixed or supports adding suffixes in the official Kotlin plugin
    tasks.replace("podGenIOS", PatchedPodGenTask::class.java)
    

    Clean your project and re-sync Gradle. The patch should be applied and work with Xcode 14