Last active
December 13, 2023 16:53
-
-
Save garrison-henkle/2d62832c3997fb6036ac5cee11f27e60 to your computer and use it in GitHub Desktop.
KMP SPM Integration Attempt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | |
} | |
} | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
$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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Copied from https://github.com/JetBrains/kotlin/blob/5ce2a2cbee0e4751c14bbf8d4e0458e54e9454a9/libraries/tools/kotlin-gradle-plugin/src/common/resources/cocoapods/project.pbxproj |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Copy and paste the xcworkspace generated after running pod install on the synthetic pod file in the shared/build/cocoapods/synthetic/ios directory |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment