diff --git a/gradle.properties b/gradle.properties index 9f9a44a5..d149d3a9 100755 --- a/gradle.properties +++ b/gradle.properties @@ -7,6 +7,8 @@ kotlin.code.style=official 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/gradle/moko.versions.toml b/gradle/moko.versions.toml index 5b9b28a3..5214152c 100644 --- a/gradle/moko.versions.toml +++ b/gradle/moko.versions.toml @@ -1,5 +1,5 @@ [versions] -resourcesVersion = "0.24.3" +resourcesVersion = "0.24.4" [libraries] resources = { module = "dev.icerock.moko:resources", version.ref = "resourcesVersion" } 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..564e19ab --- /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 -> "aarch64-apple-ios" + KonanTarget.IOS_SIMULATOR_ARM64 -> "aarch64-apple-ios-simulator" + KonanTarget.IOS_X64 -> "x86_64-apple-ios-simulator" + + KonanTarget.MACOS_ARM64 -> "aarch64-apple-macosx" + KonanTarget.MACOS_X64 -> "x86_64-apple-macosx" + + KonanTarget.TVOS_ARM64 -> "aarch64-apple-tvos" + KonanTarget.TVOS_SIMULATOR_ARM64 -> "aarch64-apple-tvos-simulator" + KonanTarget.TVOS_X64 -> "x86_64-apple-tvos-simulator" + + KonanTarget.WATCHOS_ARM32 -> "armv7k-apple-watchos" + KonanTarget.WATCHOS_ARM64 -> "arm64_32-apple-watchos" + KonanTarget.WATCHOS_DEVICE_ARM64 -> "aarch64-apple-watchos" + KonanTarget.WATCHOS_SIMULATOR_ARM64 -> "aarch64-apple-watchos-simulator" + KonanTarget.WATCHOS_X64 -> "x86_64-apple-watchos-simulator" + 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 e8e6bda0..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,15 +4,31 @@ 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 +import platform.Foundation.NSLog import platform.Foundation.NSURL import platform.Foundation.pathExtension fun NSBundle.Companion.loadableBundle(identifier: String): NSBundle { - val bundlePath: String = NSBundle.mainBundle.bundlePath - val enumerator: NSDirectoryEnumerator = requireNotNull(NSFileManager.defaultManager.enumeratorAtPath(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) + ) { "can't get enumerator" } + while (true) { val relativePath: String = enumerator.nextObject() as? String ?: break val url = NSURL(fileURLWithPath = relativePath) @@ -22,7 +38,8 @@ fun NSBundle.Companion.loadableBundle(identifier: String): NSBundle { val loadedIdentifier: String? = foundedBundle?.bundleIdentifier if (isBundleSearchLogEnabled) { - println("moko-resources auto-load bundle with identifier $loadedIdentifier at path $fullPath") + // NSLog to see this logs in Console app when debug SwiftUI previews or release apps + NSLog("moko-resources auto-load bundle with identifier $loadedIdentifier at path $fullPath") } if (foundedBundle?.bundleIdentifier == identifier) return foundedBundle diff --git a/resources/src/appleMain/objective-c/MRResourcesBundle.h b/resources/src/appleMain/objective-c/MRResourcesBundle.h new file mode 100644 index 00000000..b77ef65e --- /dev/null +++ b/resources/src/appleMain/objective-c/MRResourcesBundle.h @@ -0,0 +1,9 @@ +#import +#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..1d1e819e --- /dev/null +++ b/resources/src/appleMain/objective-c/MRResourcesBundle.m @@ -0,0 +1,13 @@ +// 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 "MRResourcesBundle.h" + +@implementation ResourcesBundleAnchor + ++ (NSBundle*) getResourcesBundle { + return [NSBundle bundleForClass:[ResourcesBundleAnchor class]]; +} + +@end diff --git a/samples/resources-gallery/ios-app/Podfile.lock b/samples/resources-gallery/ios-app/Podfile.lock index 70c0bc1e..d8886802 100644 --- a/samples/resources-gallery/ios-app/Podfile.lock +++ b/samples/resources-gallery/ios-app/Podfile.lock @@ -13,4 +13,4 @@ SPEC CHECKSUMS: PODFILE CHECKSUM: 30d7a0645fcfdfc7d29c9d352fe92e63fe2a9f63 -COCOAPODS: 1.15.2 +COCOAPODS: 1.16.2 diff --git a/samples/resources-gallery/macos-app/Podfile.lock b/samples/resources-gallery/macos-app/Podfile.lock index 15c879c6..904f81f5 100644 --- a/samples/resources-gallery/macos-app/Podfile.lock +++ b/samples/resources-gallery/macos-app/Podfile.lock @@ -13,4 +13,4 @@ SPEC CHECKSUMS: PODFILE CHECKSUM: 6055317a84821966cb9ec76bbac09672d2f57bdf -COCOAPODS: 1.15.2 +COCOAPODS: 1.16.2