diff --git a/CHANGELOG.md b/CHANGELOG.md index 419c6624..a8effc3c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +### Enhancements + +- Gradle Plugin: implement conditional Cocoa linking for targets ([#421](https://github.com/getsentry/sentry-kotlin-multiplatform/pull/421)) + ## 0.14.0 ### Dependencies diff --git a/sentry-kotlin-multiplatform-gradle-plugin/build.gradle.kts b/sentry-kotlin-multiplatform-gradle-plugin/build.gradle.kts index af287258..7d5db4b9 100644 --- a/sentry-kotlin-multiplatform-gradle-plugin/build.gradle.kts +++ b/sentry-kotlin-multiplatform-gradle-plugin/build.gradle.kts @@ -25,6 +25,7 @@ dependencies { testImplementation(libs.junit) testImplementation(libs.junit.params) testImplementation(libs.mockk) + testImplementation(libs.truth) } tasks.test { diff --git a/sentry-kotlin-multiplatform-gradle-plugin/gradle/libs.versions.toml b/sentry-kotlin-multiplatform-gradle-plugin/gradle/libs.versions.toml index 42893a18..cd9ac996 100644 --- a/sentry-kotlin-multiplatform-gradle-plugin/gradle/libs.versions.toml +++ b/sentry-kotlin-multiplatform-gradle-plugin/gradle/libs.versions.toml @@ -18,3 +18,4 @@ kover = { id = "org.jetbrains.kotlinx.kover", version.ref = "kover"} junit = "org.junit.jupiter:junit-jupiter-api:5.10.3" junit-params = "org.junit.jupiter:junit-jupiter-params:5.10.3" mockk = "io.mockk:mockk:1.13.12" +truth = { module = "com.google.truth:truth", version = "1.4.4" } diff --git a/sentry-kotlin-multiplatform-gradle-plugin/src/main/java/io/sentry/kotlin/multiplatform/gradle/SentryPlugin.kt b/sentry-kotlin-multiplatform-gradle-plugin/src/main/java/io/sentry/kotlin/multiplatform/gradle/SentryPlugin.kt index 907e9c0d..25283376 100644 --- a/sentry-kotlin-multiplatform-gradle-plugin/src/main/java/io/sentry/kotlin/multiplatform/gradle/SentryPlugin.kt +++ b/sentry-kotlin-multiplatform-gradle-plugin/src/main/java/io/sentry/kotlin/multiplatform/gradle/SentryPlugin.kt @@ -3,10 +3,12 @@ package io.sentry.kotlin.multiplatform.gradle import org.gradle.api.GradleException import org.gradle.api.Plugin import org.gradle.api.Project +import org.gradle.api.execution.TaskExecutionGraph import org.gradle.api.plugins.ExtensionAware import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension import org.jetbrains.kotlin.gradle.plugin.cocoapods.CocoapodsExtension import org.jetbrains.kotlin.gradle.plugin.cocoapods.KotlinCocoapodsPlugin +import org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeTarget import org.jetbrains.kotlin.konan.target.HostManager import org.slf4j.LoggerFactory @@ -43,9 +45,13 @@ class SentryPlugin : Plugin { } } - internal fun executeConfiguration(project: Project, hostIsMac: Boolean = HostManager.hostIsMac) { + internal fun executeConfiguration( + project: Project, + hostIsMac: Boolean = HostManager.hostIsMac + ) { val sentryExtension = project.extensions.getByType(SentryExtension::class.java) - val hasCocoapodsPlugin = project.plugins.findPlugin(KotlinCocoapodsPlugin::class.java) != null + val hasCocoapodsPlugin = + project.plugins.findPlugin(KotlinCocoapodsPlugin::class.java) != null if (sentryExtension.autoInstall.enabled.get()) { val autoInstall = sentryExtension.autoInstall @@ -59,25 +65,78 @@ class SentryPlugin : Plugin { } } - if (hostIsMac && !hasCocoapodsPlugin) { - project.logger.info("Cocoapods plugin not found. Attempting to link Sentry Cocoa framework.") + maybeLinkCocoaFramework(project, hasCocoapodsPlugin, hostIsMac) + } - val kmpExtension = project.extensions.findByName(KOTLIN_EXTENSION_NAME) as? KotlinMultiplatformExtension - val appleTargets = kmpExtension?.appleTargets()?.toList() - ?: throw GradleException("Error fetching Apple targets from Kotlin Multiplatform plugin.") + companion object { + internal val logger by lazy { + LoggerFactory.getLogger(SentryPlugin::class.java) + } + } +} + +private fun maybeLinkCocoaFramework( + project: Project, + hasCocoapods: Boolean, + hostIsMac: Boolean +) { + if (hostIsMac && !hasCocoapods) { + // Register a task graph listener so that we only configure Cocoa framework linking + // if at least one Apple target task is part of the requested task graph. This avoids + // executing the (potentially expensive) path-resolution logic when the build is only + // concerned with non-Apple targets such as Android. + + val kmpExtension = + project.extensions.findByName(KOTLIN_EXTENSION_NAME) as? KotlinMultiplatformExtension + ?: throw GradleException("Error fetching Kotlin Multiplatform extension.") + + val appleTargets = kmpExtension.appleTargets().toList() + + if (appleTargets.isEmpty()) { + project.logger.info("No Apple targets detected – skipping Sentry Cocoa framework linking setup.") + return + } + + project.gradle.taskGraph.whenReady { graph -> + // Check which of the Kotlin/Native targets are actually in the graph + val activeTarget = getActiveTarget(project, appleTargets, graph) + + if (activeTarget == null) { + project.logger.lifecycle( + "No Apple compile task scheduled for this build " + + "- skipping Sentry Cocoa framework linking" + ) + return@whenReady + } + + project.logger.lifecycle("Set up Sentry Cocoa linking for target: ${activeTarget.name}") CocoaFrameworkLinker( logger = project.logger, pathResolver = FrameworkPathResolver(project), binaryLinker = FrameworkLinker(project.logger) - ).configure(appleTargets) + ).configure(appleTargets = listOf(activeTarget)) } } +} - companion object { - internal val logger by lazy { - LoggerFactory.getLogger(SentryPlugin::class.java) - } +private fun getActiveTarget( + project: Project, + appleTargets: List, + graph: TaskExecutionGraph +): KotlinNativeTarget? = appleTargets.firstOrNull { target -> + val targetName = target.name.replaceFirstChar { + it.uppercase() + } + val path = if (project.path == ":") { + ":compileKotlin$targetName" + } else { + "${project.path}:compileKotlin$targetName" + } + try { + graph.hasTask(path) + } catch (_: Exception) { + false } } diff --git a/sentry-kotlin-multiplatform-gradle-plugin/src/test/java/io/sentry/kotlin/multiplatform/gradle/CocoaFrameworkLinkerIntegrationTest.kt b/sentry-kotlin-multiplatform-gradle-plugin/src/test/java/io/sentry/kotlin/multiplatform/gradle/CocoaFrameworkLinkerIntegrationTest.kt new file mode 100644 index 00000000..4639d8f8 --- /dev/null +++ b/sentry-kotlin-multiplatform-gradle-plugin/src/test/java/io/sentry/kotlin/multiplatform/gradle/CocoaFrameworkLinkerIntegrationTest.kt @@ -0,0 +1,130 @@ +package io.sentry.kotlin.multiplatform.gradle + +import com.google.common.truth.Truth.assertThat +import org.gradle.testkit.runner.GradleRunner +import org.gradle.testkit.runner.internal.PluginUnderTestMetadataReading +import org.gradle.testkit.runner.internal.io.SynchronizedOutputStream +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.condition.EnabledOnOs +import org.junit.jupiter.api.condition.OS +import org.junit.jupiter.api.io.TempDir +import java.io.ByteArrayOutputStream +import java.io.File +import java.io.OutputStreamWriter + +@EnabledOnOs(OS.MAC) +class CocoaFrameworkLinkerIntegrationTest { + /** + * Verifies that the Cocoa linker is **not** configured when the task graph + * contains only non-Apple targets. + */ + @Test + fun `linker is not configured when only non-Apple tasks are requested`(@TempDir projectDir: File) { + writeBuildFiles(projectDir) + + val output = ByteArrayOutputStream() + defaultRunner(projectDir, output) + .withArguments("compileKotlinJvm", "--dry-run", "--info") + .build() + + assertThat(output.toString()) + .contains("No Apple compile task scheduled for this build - skipping Sentry Cocoa framework linking") + } + + /** + * Verifies that the Cocoa linker **is** configured when at least one Apple + * task is present in the task graph. + */ + @Test + fun `linker is configured when an Apple task is requested`(@TempDir projectDir: File) { + writeBuildFiles(projectDir) + + val output = ByteArrayOutputStream() + defaultRunner(projectDir, output) + .withArguments("compileKotlinIosSimulatorArm64", "--dry-run", "--info") + .build() + + assertThat(output.toString()) + .contains("Set up Sentry Cocoa linking for target: iosSimulatorArm64") + assertThat(output.toString()) + .contains("Start resolving Sentry Cocoa framework paths for target: iosSimulatorArm64") + } + + // --------------------------------------------------------------------- + // test-fixture helpers + // --------------------------------------------------------------------- + + private fun writeBuildFiles(dir: File) { + // ----------------------------------------------------------------- + // Create a fake XCFramework on disk so that the CustomPathStrategy + // can resolve a valid framework path even on CI machines where SPM + // (and hence DerivedData) is not available. + // ----------------------------------------------------------------- + val fakeFrameworkDir = File(dir, "Sentry-Dynamic.xcframework").apply { + // Create minimal structure that satisfies path validation logic + val archDirName = "ios-arm64_x86_64-simulator" // architecture used for iosSimulatorArm64 + val archDir = File(this, archDirName) + archDir.mkdirs() + } + + File(dir, "settings.gradle").writeText("""rootProject.name = "fixture"""") + + val pluginClasspath = PluginUnderTestMetadataReading + .readImplementationClasspath() + .joinToString(", ") { "\"${it.absolutePath.replace('\\', '/')}\"" } + + File(dir, "build.gradle").writeText( + """ + buildscript { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } + dependencies { + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$KOTLIN_VERSION" + classpath files($pluginClasspath) + } + } + + apply plugin: 'org.jetbrains.kotlin.multiplatform' + apply plugin: 'io.sentry.kotlin.multiplatform.gradle' + + repositories { + google() + mavenCentral() + } + + kotlin { + jvm() // non-Apple target + iosSimulatorArm64() // Apple target used in tests + } + + // ----------------------------------------------------------------- + // Configure the plugin to use the fake framework path created above. + // This makes the CustomPathStrategy succeed immediately, bypassing the + // DerivedData and manual search strategies that rely on an SPM setup. + // ----------------------------------------------------------------- + sentryKmp { + linker { + frameworkPath.set("${fakeFrameworkDir.absolutePath.replace('\\', '/')}") + } + } + """.trimIndent() + ) + } + + /** Returns a pre-configured [GradleRunner] that logs into [out]. */ + private fun defaultRunner(projectDir: File, out: ByteArrayOutputStream): GradleRunner = + GradleRunner.create() + .withProjectDir(projectDir) + .withPluginClasspath() + .withGradleVersion(org.gradle.util.GradleVersion.current().version) + .forwardStdOutput(OutputStreamWriter(SynchronizedOutputStream(out))) + .forwardStdError(OutputStreamWriter(SynchronizedOutputStream(out))) + .withArguments("--stacktrace") + + private companion object { + private const val KOTLIN_VERSION = "2.1.21" + } +}