Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Save garrison-henkle/2d62832c3997fb6036ac5cee11f27e60 to your computer and use it in GitHub Desktop.
Save garrison-henkle/2d62832c3997fb6036ac5cee11f27e60 to your computer and use it in GitHub Desktop.
KMP SPM Integration Attempt
package dev.henkle.build
import org.gradle.api.Project
import org.gradle.api.Task
import org.gradle.configurationcache.extensions.capitalized
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.io.File
import java.io.InputStreamReader
import java.io.Reader
// this should match our iOS framework's name
private const val PROJECT_SCHEME = "stytchcustom"
private const val PROJECT_NAME = "shared"
// Podfile is at rooProject/shared/build/cocoapods/synthetic/ios/Podfile,
// so we need to go up 5 levels to get to the root project directory
private const val COCOAPODS_SPM_SUPPORT_PATH = "../../../../../cocoapods/cocoapods_spm_support.rb"
private const val DUMMY_PBXPROJ = "cocoapods/dummyProject.pbxproj"
private const val DUMMY_XCWORKSPACE = "cocoapods/project.xcworkspace"
private const val GENERATED_PBXPROJ_PATH = "$PROJECT_NAME/build/cocoapods/synthetic/ios/synthetic.xcodeproj/project.pbxproj"
private const val GENERATED_XCWORKSPACE_PATH = "$PROJECT_NAME/build/cocoapods/synthetic/ios/Pods/Pods.xcodeproj"
private const val TASK_POD_INSTALL_SYNTHETIC_IOS = "podInstallSyntheticIos"
private val TASK_POD_SETUP_BUILD_PREFIX = "podSetupBuild${PROJECT_NAME.capitalized()}"
val Project.rootProjectDir: File get() = rootProject.projectDir
val Project.cocoapodsDir: File
get() = File("${rootProject.projectDir.absolutePath}/$PROJECT_NAME/build/cocoapods")
val Project.buildSettingsDir: File get() = cocoapodsDir.resolve(relative = "buildSettings")
val Project.synthDir: File get() = cocoapodsDir.resolve(relative = "synthetic/ios")
val Project.podsDir: File get() = synthDir.resolve(relative = "Pods")
val Project.podfile: File get() = synthDir.resolve(relative = "Podfile")
private fun Project.log(msg: String) {
logger.info("[SPM] $msg")
}
data class BashContext(
var workingDir: File,
var loggerMsg: String,
var stdout: ByteArrayOutputStream? = null,
)
private fun Project.bash(
cmd: String,
configure: (BashContext.() -> Unit)? = null,
) {
val context = BashContext(workingDir = rootProjectDir, loggerMsg = "")
configure?.also { config -> context.config() }
exec {
workingDir = context.workingDir
commandLine = listOf("bash", "-c", cmd)
context.stdout?.also {
standardOutput = it
}
}
log(msg = context.loggerMsg)
}
private fun Project.generateBuildSettingsFile(
pod: String,
sdk: String,
): File = buildSettingsDir.resolve(relative = "build-settings-$sdk-$pod.properties")
/**
* Prefixes the generated synthetic Podfile with the custom Ruby extension enabling SPM support.
* Code taken from a Cocoapods contributor whose implementation was preferred in this thread:
* https://github.com/CocoaPods/CocoaPods/issues/11942#issuecomment-1588091616
* Original SPM extension code:
* https://github.com/mfazekas/pods-spm-dep-poc/blob/bde2958e3bfe36f402bc2a9fce27e83ff2b9c469/spm_dependencies_poc.rb
*/
private fun Project.addSPMSupport() {
if (podfile.exists()) {
val podfileLines = listOf(
"require_relative '$COCOAPODS_SPM_SUPPORT_PATH'",
podfile.readText(),
)
podfile.writeText(text = podfileLines.joinToString(separator = "\n"))
log(msg = "added Cocoapods SPM support")
}
}
private fun Project.writePodBuildSettings(
pod: String,
sdk: String,
settings: ByteArrayOutputStream,
writeSettings: (reader: Reader, buildSettingsFile: File) -> Unit,
) {
settings.use { outStream ->
ByteArrayInputStream(outStream.toByteArray()).use { inStream ->
InputStreamReader(inStream).use { reader ->
val buildSettingsFile = generateBuildSettingsFile(
pod = pod,
sdk = sdk,
)
writeSettings(reader, buildSettingsFile)
}
}
}
}
private fun Task.replacePodInstallSyntheticTask() {
actions.clear()
doLast {
with(project) {
// adds the custom SPM Ruby extensions
addSPMSupport()
// the original Kotlin Cocoapods plugin copied over a dummy .pbxproj,
// so we stole their file and will do the same
bash(cmd = "cp $DUMMY_PBXPROJ $GENERATED_PBXPROJ_PATH") {
loggerMsg = "copying dummy pbxproj file"
}
// runs pod install on the synthetic Podfile to initialize the Cocoapods project
bash(cmd = "pod install") {
workingDir = synthDir
loggerMsg = "running pod install"
}
// Without an xcworkspace, Xcode will use the legacy build location. SPM is incompatible
// with the legacy version, so a dummy xcworkspace containing the necessary build
// location config is copied over
bash(cmd = "cp -r $DUMMY_XCWORKSPACE $GENERATED_XCWORKSPACE_PATH") {
loggerMsg = "copying dummy xcworkspace directory"
}
}
}
}
private fun Task.replacePodSetupTask(
pod: String,
sdk: String,
writeSettings: (reader: Reader, buildSettingsFile: File) -> Unit,
) {
actions.clear()
doLast {
with(project) {
// extract the build settings from Xcode and feed them to the Kotlin Cocoapods plugin's
// processor. We can't do this directly because this file doesn't have access to the
// Kotlin Cocoapods plugin, so we use a lambda (writeSettings).
val buildCommand = listOf(
"xcodebuild",
"-showBuildSettings",
"-project Pods.xcodeproj",
"-scheme $PROJECT_SCHEME",
"-sdk $sdk",
).joinToString(separator = " ")
val stream = ByteArrayOutputStream()
bash(cmd = buildCommand) {
workingDir = podsDir
loggerMsg = "got build settings from xcodebuild"
stdout = stream
}
writePodBuildSettings(
pod = pod,
sdk = sdk,
settings = stream,
writeSettings = writeSettings,
)
}
}
}
data class SetupBuildTaskParams(var pod: String = "")
/**
* Adds SPM support to this project's Cocoapods install. This enables the spm_dependency function
* in podspecs that are imported using the path source in the cocoapods block:
* ```
* pod("example") {
* source = path(project.file("path/to/dir/containing/example.podspec"))
* }
* ```
* Example usage of the SPM podspec extension:
* ```
* spec.spm_dependency(
* url: 'https://github.com/stytchauth/stytch-ios.git',
* requirement: {kind: 'upToNextMajorVersion', minimumVersion: '0.20.0'},
* products: ['StytchCore']
* )
* ```
*
* Two lambdas are required to provide Kotlin Cocoapods plugin classes or methods to this function,
* as this function lives in a file that cannot access those imports. The exact steps needed
* are carefully defined in the parameter documentation below.
*
* @param getPodNameForSetupBuildTask Retrieves the name of the current task being processed. The
* provided task should be cast to [org.jetbrains.kotlin.gradle.targets.native.tasks.PodSetupBuildTask]
* and the pod name should be extracted with pod.get().schemeName and should be asigned to the pod
* variable in the SetupBuildTaskParam receiver context.
* @param writeSettings A lambda that should read [reader] and write to [buildSettingsFile] using
* [org.jetbrains.kotlin.gradle.targets.native.tasks.PodBuildSettingsProperties]
*/
fun Project.addCocoapodsSwiftPackageManagerSupport(
getPodNameForSetupBuildTask: SetupBuildTaskParams.(task: Task) -> Unit,
writeSettings: (reader: Reader, buildSettingsFile: File) -> Unit,
) {
project.tasks.all {
when {
name == TASK_POD_INSTALL_SYNTHETIC_IOS -> replacePodInstallSyntheticTask()
name.startsWith(prefix = TASK_POD_SETUP_BUILD_PREFIX) -> {
val sdk = name.removePrefix(prefix = TASK_POD_SETUP_BUILD_PREFIX).lowercase()
val params = SetupBuildTaskParams()
params.getPodNameForSetupBuildTask(this)
replacePodSetupTask(pod = params.pod, sdk = sdk, writeSettings = writeSettings)
}
}
}
}
$spm_dependencies_by_pod = {}
class Pod::Specification
def spm_dependency(url: ,requirement: ,products:)
@spm_dependencies ||= []
@spm_dependencies << { url: url, requirement: requirement, products: products}
$spm_dependencies_by_pod[self.name] = @spm_dependencies
end
end
def log(msg)
puts(" => {SPM} #{msg}")
end
module PodFileWithSpmDependencies
def add_spm_to_target(project, target, url, requirement, products)
pkg_class = Xcodeproj::Project::Object::XCRemoteSwiftPackageReference
ref_class = Xcodeproj::Project::Object::XCSwiftPackageProductDependency
pkg = project.root_object.package_references.find { |p| p.class == pkg_class && p.repositoryURL == url }
if !pkg
pkg = project.new(pkg_class)
pkg.repositoryURL = url
pkg.requirement = requirement
log(" Requirement: #{requirement}")
project.root_object.package_references << pkg
end
products.each do |product_name|
ref = target.package_product_dependencies.find do |r|
r.class == ref_class && r.package == pkg && r.product_name == product_name
end
next if ref
log(" Adding product dependency #{product_name} to #{target.name}")
ref = project.new(ref_class)
ref.package = pkg
ref.product_name = product_name
target.package_product_dependencies << ref
end
end
def clean_spm_dependencies_from_target(project)
# TODO: only clear the ones that are not in the podfile anymore
project.root_object.package_references.delete_if { |pkg| pkg.class == Xcodeproj::Project::Object::XCRemoteSwiftPackageReference }
end
def post_install!(installer)
super
project = installer.pods_project
log 'Cleaning old SPM dependencies from Pods project'
clean_spm_dependencies_from_target(project)
log 'Adding SPM dependencies to Pods project'
$spm_dependencies_by_pod.each do |pod_name, dependencies|
dependencies.each do |spm_spec|
log "Adding SPM dependency on product #{spm_spec[:products]}"
add_spm_to_target(
project,
project.targets.find { |t| t.name == pod_name},
spm_spec[:url],
spm_spec[:requirement],
spm_spec[:products]
)
log " Adding workaround for Swift package not found issue"
target = project.targets.find { |t| t.name == pod_name}
target.build_configurations.each do |config|
target.build_settings(config.name)['SWIFT_INCLUDE_PATHS'] ||= ['$(inherited)']
search_path = '${SYMROOT}/${CONFIGURATION}${EFFECTIVE_PLATFORM_NAME}/'
unless target.build_settings(config.name)['SWIFT_INCLUDE_PATHS'].include?(search_path)
target.build_settings(config.name)['SWIFT_INCLUDE_PATHS'].push(search_path)
end
end
end
end
end
end
class Pod::Podfile
prepend PodFileWithSpmDependencies
end
Copied from https://github.com/JetBrains/kotlin/blob/5ce2a2cbee0e4751c14bbf8d4e0458e54e9454a9/libraries/tools/kotlin-gradle-plugin/src/common/resources/cocoapods/project.pbxproj
Copy and paste the xcworkspace generated after running pod install on the synthetic pod file in the shared/build/cocoapods/synthetic/ios directory
// dropped the s from the kts in the filename so GitHub would syntax highlight
import dev.henkle.build.addCocoapodsSwiftPackageManagerSupport
import org.jetbrains.kotlin.gradle.targets.native.tasks.PodBuildSettingsProperties
import org.jetbrains.kotlin.gradle.targets.native.tasks.PodSetupBuildTask
import org.jetbrains.kotlin.gradle.targets.native.tasks.schemeName
...
kotlin {
...
cocoapods {
...
pod("stytchcustom") {
version = "1.0.0"
source = path(project.file("src/iosMain/swift"))
packageName = "dev.henkle.native"
extraOpts += listOf("-compiler-option", "-fmodules")
}
..
}
...
}
...
addCocoapodsSwiftPackageManagerSupport(
getPodNameForSetupBuildTask = { task ->
val setupTask = task as PodSetupBuildTask
pod = setupTask.pod.get().schemeName
},
writeSettings = { reader, buildSettingsFile ->
PodBuildSettingsProperties.readSettingsFromReader(reader = reader)
.writeSettings(buildSettingsFile = buildSettingsFile)
},
)
//
// stytch.h
// stytch
//
// Created by Garrison Henkle on 12/7/23.
//
#import <Foundation/Foundation.h>
//! Project version number for stytchcustom.
FOUNDATION_EXPORT double stytchcustomVersionNumber;
//! Project version string for stytch.
FOUNDATION_EXPORT const unsigned char stytchVersionString[];
// In this header, you should import all the public headers of your framework using statements like #import <stytch/PublicHeader.h>
//
// StytchWrapper.swift
// stytch
//
// Created by Garrison Henkle on 12/7/23.
//
import Foundation
import StytchCore
@objc public class SwiftStytchWrapper: NSObject {
@objc public func doStytchStuff() {
StytchClient.configure(publicToken: "")
print("FINALLY!")
}
}
Pod::Spec.new do |spec|
spec.name = "stytchcustom"
spec.version = "1.0.0"
spec.summary = "Native iOS utilities"
spec.homepage = "https://stytch.com"
spec.authors = ""
spec.ios.deployment_target = "14.1"
spec.swift_version = "5.9"
spec.source = { :http => ""}
spec.source_files = "stytch", "stytch/**/*.{h,m,swift}"
spec.static_framework = true
spec.spm_dependency(
url: 'https://github.com/stytchauth/stytch-ios.git',
requirement: {kind: 'upToNextMajorVersion', minimumVersion: '0.20.0'},
products: ['StytchCore']
)
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment