diff --git a/gradle.properties b/gradle.properties index fd97ac0e..d149d3a9 100755 --- a/gradle.properties +++ b/gradle.properties @@ -8,6 +8,7 @@ kotlin.mpp.stability.nowarn=true kotlin.mpp.androidGradlePluginCompatibility.nowarn=true kotlin.mpp.androidSourceSetLayoutVersion=2 kotlin.mpp.applyDefaultHierarchyTemplate=false +kotlin.mpp.enableCInteropCommonization=true org.jetbrains.compose.experimental.jscanvas.enabled=true org.jetbrains.compose.experimental.uikit.enabled=true diff --git a/resources-build-logic/src/main/kotlin/apple-bundle-searcher-convention.gradle.kts b/resources-build-logic/src/main/kotlin/apple-bundle-searcher-convention.gradle.kts new file mode 100644 index 00000000..4c44057e --- /dev/null +++ b/resources-build-logic/src/main/kotlin/apple-bundle-searcher-convention.gradle.kts @@ -0,0 +1,164 @@ +/* + * Copyright 2024 IceRock MAG Inc. Use of this source code is governed by the Apache 2.0 license. + */ + +import org.jetbrains.kotlin.gradle.plugin.KotlinCompilation +import org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeTarget +import org.jetbrains.kotlin.konan.target.KonanTarget + +plugins { + id("org.jetbrains.kotlin.multiplatform") +} + +/* +This code ensures that the Bundle in an iOS application, built with Kotlin Multiplatform (KMP), can be correctly +located at runtime. The issue arises because Kotlin doesn’t allow direct lookup of a Bundle by a class from +Objective-C. To resolve this, a static library written in Objective-C was created and automatically included in the +Kotlin Framework during the build process. This library contains a class used to locate the required Bundle. + +Key steps performed by the code: + +1. Handling Apple targets in KMP: + The code automatically configures the build for Apple platforms only (iOS, macOS, tvOS, watchOS). +2. Compiling and linking the static library: + - clang is used to compile the source file MRResourcesBundle.m into an object file. + - The object file is linked into a static library (libMRResourcesBundle.a) using the ar utility. +3. Integrating the static library into the Kotlin Framework: + - A C-interop is created, enabling Kotlin to interact with the Objective-C code from the library. + - The C-interop task is configured to depend on the compilation and linking tasks, ensuring the library is ready for + use during the build process. +4. Support for multiple Apple platforms: + - The code adapts the build process for specific Apple SDKs and architectures by using helper functions getAppleSdk + and getClangTarget. +5. Retrieving the SDK path: + The xcrun utility is used to dynamically fetch the SDK path required by clang. + +What does this achieve? + +As a result, a Kotlin Multiplatform application for iOS, macOS, tvOS, or watchOS can correctly locate the Bundle +containing resources by leveraging standard Apple APIs wrapped in the static library. This process is fully automated +during the project build, requiring no manual intervention from the developer. + +Bundle search logic: +resources/src/appleMain/kotlin/dev/icerock/moko/resources/utils/NSBundleExt.kt +*/ + +kotlin.targets + .withType() + .matching { it.konanTarget.family.isAppleFamily } + .configureEach { + val sdk: String = this.konanTarget.getAppleSdk() + val target: String = this.konanTarget.getClangTarget() + + val sdkPath: String = getSdkPath(sdk) + + val libsDir = File(buildDir, "moko-resources/cinterop/$name") + libsDir.mkdirs() + val sourceFile = File(projectDir, "src/appleMain/objective-c/MRResourcesBundle.m") + val objectFile = File(libsDir, "MRResourcesBundle.o") + val libFile = File(libsDir, "libMRResourcesBundle.a") + val kotlinTargetPostfix: String = this.name.capitalize() + + val compileStaticLibrary = tasks.register("mokoBundleSearcherCompile$kotlinTargetPostfix", Exec::class) { + group = "moko-resources" + + commandLine = listOf( + "clang", + "-target", + target, + "-isysroot", + sdkPath, + "-c", + sourceFile.absolutePath, + "-o", + objectFile.absolutePath + ) + outputs.file(objectFile.absolutePath) + } + val linkStaticLibrary = tasks.register("mokoBundleSearcherLink$kotlinTargetPostfix", Exec::class) { + group = "moko-resources" + + dependsOn(compileStaticLibrary) + + commandLine = listOf( + "ar", + "rcs", + libFile.absolutePath, + objectFile.absolutePath + ) + outputs.file(libFile.absolutePath) + } + + compilations.getByName(KotlinCompilation.MAIN_COMPILATION_NAME) { + val bundleSearcher by cinterops.creating { + defFile(project.file("src/appleMain/def/bundleSearcher.def")) + + includeDirs("$projectDir/src/appleMain/objective-c") + extraOpts("-libraryPath", libsDir.absolutePath) + } + + tasks.named(bundleSearcher.interopProcessingTaskName).configure { + dependsOn(linkStaticLibrary) + } + } + } + +fun KonanTarget.getAppleSdk(): String { + return when (this) { + KonanTarget.IOS_ARM32, + KonanTarget.IOS_ARM64 -> "iphoneos" + + KonanTarget.IOS_SIMULATOR_ARM64, + KonanTarget.IOS_X64 -> "iphonesimulator" + + KonanTarget.MACOS_ARM64, + KonanTarget.MACOS_X64 -> "macosx" + + KonanTarget.TVOS_ARM64 -> "appletvos" + + KonanTarget.TVOS_SIMULATOR_ARM64, + KonanTarget.TVOS_X64 -> "appletvsimulator" + + KonanTarget.WATCHOS_ARM32, + KonanTarget.WATCHOS_DEVICE_ARM64 -> "watchos" + + KonanTarget.WATCHOS_ARM64, + KonanTarget.WATCHOS_SIMULATOR_ARM64, + KonanTarget.WATCHOS_X64, + KonanTarget.WATCHOS_X86 -> "watchsimulator" + + else -> error("Unsupported target for selecting SDK: $this") + } +} + +fun KonanTarget.getClangTarget(): String { + return when (this) { + KonanTarget.IOS_ARM32 -> "armv7-apple-ios" + KonanTarget.IOS_ARM64 -> "arm64-apple-ios" + KonanTarget.IOS_SIMULATOR_ARM64 -> "arm64-apple-ios-simulator" + KonanTarget.IOS_X64 -> "x86_64-apple-ios-simulator" + + KonanTarget.MACOS_ARM64 -> "arm64-apple-macosx" + KonanTarget.MACOS_X64 -> "x86_64-apple-macosx" + + KonanTarget.TVOS_ARM64 -> "arm64-apple-tvos" + KonanTarget.TVOS_SIMULATOR_ARM64 -> "arm64-apple-tvsimulator" + KonanTarget.TVOS_X64 -> "x86_64-apple-tvsimulator" + + KonanTarget.WATCHOS_ARM32 -> "armv7k-apple-watchos" + KonanTarget.WATCHOS_ARM64 -> "arm64-apple-watchos" + KonanTarget.WATCHOS_DEVICE_ARM64 -> "arm64_32-apple-watchos" + KonanTarget.WATCHOS_SIMULATOR_ARM64 -> "arm64-apple-watchos" + KonanTarget.WATCHOS_X64 -> "x86_64-apple-watchos" + KonanTarget.WATCHOS_X86 -> "i386-apple-watchos" + + else -> error("Unsupported target for selecting clang target: $this") + } +} + +fun getSdkPath(sdk: String): String { + val process = ProcessBuilder("xcrun", "--sdk", sdk, "--show-sdk-path") + .redirectErrorStream(true) + .start() + return process.inputStream.bufferedReader().use { it.readText().trim() } +} diff --git a/resources/build.gradle.kts b/resources/build.gradle.kts index ba13311f..f55b49d3 100644 --- a/resources/build.gradle.kts +++ b/resources/build.gradle.kts @@ -6,6 +6,7 @@ plugins { id("multiplatform-library-extended-convention") id("multiplatform-android-publish-convention") id("apple-main-convention") + id("apple-bundle-searcher-convention") id("detekt-convention") id("javadoc-stub-convention") id("publication-convention") diff --git a/resources/src/appleMain/def/bundleSearcher.def b/resources/src/appleMain/def/bundleSearcher.def new file mode 100644 index 00000000..1e6af252 --- /dev/null +++ b/resources/src/appleMain/def/bundleSearcher.def @@ -0,0 +1,5 @@ +# https://kotlinlang.org/docs/native-definition-file.html +language = Objective-C +package = dev.icerock.moko.resources.apple.native +staticLibraries = libMRResourcesBundle.a +headers = MRResourcesBundle.h diff --git a/resources/src/appleMain/kotlin/dev/icerock/moko/resources/utils/NSBundleExt.kt b/resources/src/appleMain/kotlin/dev/icerock/moko/resources/utils/NSBundleExt.kt index 398a17dd..2d18f581 100644 --- a/resources/src/appleMain/kotlin/dev/icerock/moko/resources/utils/NSBundleExt.kt +++ b/resources/src/appleMain/kotlin/dev/icerock/moko/resources/utils/NSBundleExt.kt @@ -4,6 +4,8 @@ package dev.icerock.moko.resources.utils +import dev.icerock.moko.resources.apple.native.ResourcesBundleAnchor +import kotlinx.cinterop.ExperimentalForeignApi import platform.Foundation.NSBundle import platform.Foundation.NSDirectoryEnumerator import platform.Foundation.NSFileManager @@ -12,18 +14,16 @@ import platform.Foundation.NSURL import platform.Foundation.pathExtension fun NSBundle.Companion.loadableBundle(identifier: String): NSBundle { - // at first we try to find required bundle inside Bundle.main, because it's faster way - // https://github.com/icerockdev/moko-resources/issues/708 - // but in some cases (for example in SwiftUI Previews) dynamic framework with bundles can be located - // in different location, not inside Bundle.main. So in this case we run less performant way - bundleWithIdentifier - // https://github.com/icerockdev/moko-resources/issues/747 - return findBundleInMain(identifier) - ?: NSBundle.bundleWithIdentifier(identifier) - ?: throw IllegalArgumentException("bundle with identifier $identifier not found") -} - -private fun findBundleInMain(identifier: String): NSBundle? { - val bundlePath: String = NSBundle.mainBundle.bundlePath + // we should use search by our class because dynamic framework with resources can be placed in + // external directory, not inside app directory (NSBundle.main). for example in case of + // SwiftUI preview - app directory empty, but dynamic framework with resources will be in + // different directory (DerivedData) + // more details inside resources-build-logic/src/main/kotlin/apple-bundle-searcher-convention.gradle.kts + @OptIn(ExperimentalForeignApi::class) + val rootBundle: NSBundle = requireNotNull(ResourcesBundleAnchor.getResourcesBundle()) { + "root NSBundle can't be found" + } + val bundlePath: String = rootBundle.bundlePath val enumerator: NSDirectoryEnumerator = requireNotNull( NSFileManager.defaultManager.enumeratorAtPath(bundlePath) @@ -46,7 +46,7 @@ private fun findBundleInMain(identifier: String): NSBundle? { } } - return null + throw IllegalArgumentException("bundle with identifier $identifier not found") } var isBundleSearchLogEnabled = false diff --git a/resources/src/appleMain/objective-c/MRResourcesBundle.h b/resources/src/appleMain/objective-c/MRResourcesBundle.h new file mode 100644 index 00000000..b3afab7d --- /dev/null +++ b/resources/src/appleMain/objective-c/MRResourcesBundle.h @@ -0,0 +1,8 @@ +#import +#import + +@interface ResourcesBundleAnchor : NSObject + ++ (NSBundle*) getResourcesBundle; + +@end diff --git a/resources/src/appleMain/objective-c/MRResourcesBundle.m b/resources/src/appleMain/objective-c/MRResourcesBundle.m new file mode 100644 index 00000000..690c146a --- /dev/null +++ b/resources/src/appleMain/objective-c/MRResourcesBundle.m @@ -0,0 +1,15 @@ +// clang -target arm64-apple-ios -isysroot $(xcrun --sdk iphoneos --show-sdk-path) -c MRResourcesBundle.m -o source.o +// ar rcs libMRResourcesBundle.a source.o +// lipo -info libMRResourcesBundle.a + +#import +#import +#import "MRResourcesBundle.h" + +@implementation ResourcesBundleAnchor + ++ (NSBundle*) getResourcesBundle { + return [NSBundle bundleForClass:[ResourcesBundleAnchor class]]; +} + +@end