Skip to content

Commit

Permalink
Resources improvements for native macOS app (#5169)
Browse files Browse the repository at this point in the history
First commit fixes compose resources for native macOS app (see currently
broken chat app using `runDebugExecutableMacosArm64`).

Second commit adds support for embedding resources into native macOS
framework. Similar to how it works on iOS. This allows using the macOS
compose code in an Xcode project. Just like iOS, using kotlin code as
framwork. Then call the main function from the Xcode project to run the
compose app.

## Testing
First commit: run chat app using `runDebugExecutableMacosArm64`
Second commit: Tested by adding macOS support to the iOS Xcode project
in chat app (not in this PR). Can add the sample app if needed.
  • Loading branch information
Thomas-Vos authored Dec 9, 2024
1 parent e912311 commit 5870305
Show file tree
Hide file tree
Showing 19 changed files with 417 additions and 11 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -39,12 +39,32 @@ internal actual fun getPlatformResourceReader(): ResourceReader = object : Resou
private fun getPathOnDisk(path: String): String {
val fm = NSFileManager.defaultManager()
val currentDirectoryPath = fm.currentDirectoryPath
val pathFix = getPathWithoutPackage(path)
return listOf(
// Framework binary
// todo: support fallback path at bundle root?
NSBundle.mainBundle.resourcePath + "/compose-resources/" + path,
// Executable binary
//todo in future bundle resources with app and use all sourceSets (skikoMain, nativeMain)
"$currentDirectoryPath/src/macosMain/composeResources/$path",
"$currentDirectoryPath/src/macosTest/composeResources/$path",
"$currentDirectoryPath/src/commonMain/composeResources/$path",
"$currentDirectoryPath/src/commonTest/composeResources/$path"
"$currentDirectoryPath/src/macosMain/composeResources/$pathFix",
"$currentDirectoryPath/src/macosTest/composeResources/$pathFix",
"$currentDirectoryPath/src/commonMain/composeResources/$pathFix",
"$currentDirectoryPath/src/commonTest/composeResources/$pathFix"
).firstOrNull { p -> fm.fileExistsAtPath(p) } ?: throw MissingResourceException(path)
}

private fun getPathWithoutPackage(path: String): String {
// At the moment resources are not bundled when running a macOS executable binary.
// As a workaround, load the resources from the actual path on disk. So the
// "composeResources/PACKAGE/" prefix must be removed. For example:
// "composeResources/chat_mpp.shared.generated.resources/drawable/background.jpg"
// Will be transformed into:
// "drawable/background.jpg"
// In the future when resources are bundled when running macOS executable binary this
// workaround is no longer needed.
require(path.startsWith("composeResources/")) { "Invalid path: $path" }
return path
.substringAfter("composeResources/") // remove "composeResources/" part
.substringAfter("/") // remove PACKAGE path
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ internal fun Project.configureSyncIosComposeResources(
}

kotlinExtension.targets.withType(KotlinNativeTarget::class.java).all { nativeTarget ->
if (nativeTarget.isIosTarget()) {
if (nativeTarget.isIosOrMacTarget()) {
nativeTarget.binaries.withType(Framework::class.java).all { iosFramework ->
val frameworkClassifier = iosFramework.getClassifier()
val checkNoSandboxTask = tasks.registerOrConfigure<CheckCanAccessComposeResourcesDirectory>(
Expand Down Expand Up @@ -116,6 +116,7 @@ private fun Framework.getClassifier(): String {
}

internal fun Framework.getSyncResourcesTaskName() = "sync${getClassifier()}ComposeResourcesForIos"

private fun Framework.isCocoapodsFramework() = name.startsWith("pod")

private fun Framework.getFinalResourcesDir(): Provider<Directory> {
Expand All @@ -125,9 +126,9 @@ private fun Framework.getFinalResourcesDir(): Provider<Directory> {
} else {
providers.environmentVariable("BUILT_PRODUCTS_DIR")
.zip(
providers.environmentVariable("CONTENTS_FOLDER_PATH")
) { builtProductsDir, contentsFolderPath ->
File("$builtProductsDir/$contentsFolderPath/$IOS_COMPOSE_RESOURCES_ROOT_DIR").canonicalPath
providers.environmentVariable("UNLOCALIZED_RESOURCES_FOLDER_PATH")
) { builtProductsDir, unlocalizedResourcesFolderPath ->
File("$builtProductsDir/$unlocalizedResourcesFolderPath/$IOS_COMPOSE_RESOURCES_ROOT_DIR").canonicalPath
}
.flatMap {
project.objects.directoryProperty().apply { set(File(it)) }
Expand All @@ -142,4 +143,10 @@ private fun KotlinNativeTarget.isIosDeviceTarget(): Boolean =
konanTarget === KonanTarget.IOS_ARM64

private fun KotlinNativeTarget.isIosTarget(): Boolean =
isIosSimulatorTarget() || isIosDeviceTarget()
isIosSimulatorTarget() || isIosDeviceTarget()

private fun KotlinNativeTarget.isMacTarget(): Boolean =
konanTarget === KonanTarget.MACOS_X64 || konanTarget === KonanTarget.MACOS_ARM64

private fun KotlinNativeTarget.isIosOrMacTarget(): Boolean =
isIosTarget() || isMacTarget()
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,17 @@ private fun getRequestedKonanTargetsByXcode(platform: String, archs: List<String
})
}

else -> error("Unknown iOS platform: '$platform'")
platform.startsWith("macosx") -> {
targets.addAll(archs.map { arch ->
when (arch) {
"arm64" -> KonanTarget.MACOS_ARM64
"x86_64" -> KonanTarget.MACOS_X64
else -> error("Unknown macOS arch: '$arch'")
}
})
}

else -> error("Unknown Apple platform: '$platform'")
}

return targets.toList()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,13 @@ class ResourcesTest : GradlePluginTestBase() {
libpath("iossimulatorarm64", "-kotlin_resources.kotlin_resources.zip")
)
checkResourcesZip(iossimulatorarm64ResZip, resourcesFiles, false)

val macosx64ResZip =
file(libpath("macosx64", "-kotlin_resources.kotlin_resources.zip"))
checkResourcesZip(macosx64ResZip, resourcesFiles, false)
val macosarm64ResZip =
file(libpath("macosarm64", "-kotlin_resources.kotlin_resources.zip"))
checkResourcesZip(macosarm64ResZip, resourcesFiles, false)
}
val jsResZip = file(libpath("js", "-kotlin_resources.kotlin_resources.zip"))
checkResourcesZip(jsResZip, resourcesFiles, false)
Expand All @@ -300,6 +307,13 @@ class ResourcesTest : GradlePluginTestBase() {
":appModule:iosSimulatorArm64Test"
}
gradle(iosTask)

val macosTask = if (currentArch == Arch.X64) {
":appModule:macosX64Test"
} else {
":appModule:macosArm64Test"
}
gradle(macosTask)
}

file("featureModule/src/commonMain/kotlin/me/sample/app/Feature.kt").modify { content ->
Expand Down Expand Up @@ -687,6 +701,95 @@ class ResourcesTest : GradlePluginTestBase() {
}
}

@Test
fun macosResources() {
Assumptions.assumeTrue(currentOS == OS.MacOS)
val macosEnv = mapOf(
"PLATFORM_NAME" to "macosx",
"ARCHS" to "arm64",
"CONFIGURATION" to "Debug",
)
val testEnv = defaultTestEnvironment.copy(
additionalEnvVars = macosEnv
)

with(TestProject("misc/macosResources", testEnv)) {
gradle(":podspec", "-Pkotlin.native.cocoapods.generate.wrapper=true").checks {
assertEqualTextFiles(
file("macosResources.podspec"),
file("expected/macosResources.podspec")
)
file("build/compose/cocoapods/compose-resources").checkExists()
}

gradle(
":syncFramework",
"-Pkotlin.native.cocoapods.platform=${macosEnv["PLATFORM_NAME"]}",
"-Pkotlin.native.cocoapods.archs=${macosEnv["ARCHS"]}",
"-Pkotlin.native.cocoapods.configuration=${macosEnv["CONFIGURATION"]}",
"--dry-run"
).checks {
check.taskSkipped(":generateComposeResClass")

check.taskSkipped(":convertXmlValueResourcesForCommonMain")
check.taskSkipped(":copyNonXmlValueResourcesForCommonMain")
check.taskSkipped(":prepareComposeResourcesTaskForCommonMain")
check.taskSkipped(":generateResourceAccessorsForCommonMain")

check.taskSkipped(":convertXmlValueResourcesForNativeMain")
check.taskSkipped(":copyNonXmlValueResourcesForNativeMain")
check.taskSkipped(":prepareComposeResourcesTaskForNativeMain")
check.taskSkipped(":generateResourceAccessorsForNativeMain")

check.taskSkipped(":convertXmlValueResourcesForAppleMain")
check.taskSkipped(":copyNonXmlValueResourcesForAppleMain")
check.taskSkipped(":prepareComposeResourcesTaskForAppleMain")
check.taskSkipped(":generateResourceAccessorsForAppleMain")

check.taskSkipped(":convertXmlValueResourcesForMacosMain")
check.taskSkipped(":copyNonXmlValueResourcesForMacosMain")
check.taskSkipped(":prepareComposeResourcesTaskForMacosMain")
check.taskSkipped(":generateResourceAccessorsForMacosMain")

check.taskSkipped(":convertXmlValueResourcesForMacosX64Main")
check.taskSkipped(":copyNonXmlValueResourcesForMacosX64Main")
check.taskSkipped(":prepareComposeResourcesTaskForMacosX64Main")
check.taskSkipped(":generateResourceAccessorsForMacosX64Main")

check.taskSkipped(":syncPodComposeResourcesForIos")
}
gradle(":syncPodComposeResourcesForIos").checks {
check.taskNoSource(":convertXmlValueResourcesForCommonMain")
check.taskSuccessful(":copyNonXmlValueResourcesForCommonMain")
check.taskSuccessful(":prepareComposeResourcesTaskForCommonMain")
check.taskSkipped(":generateResourceAccessorsForCommonMain")

check.taskNoSource(":convertXmlValueResourcesForNativeMain")
check.taskNoSource(":copyNonXmlValueResourcesForNativeMain")
check.taskNoSource(":prepareComposeResourcesTaskForNativeMain")
check.taskSkipped(":generateResourceAccessorsForNativeMain")

check.taskNoSource(":convertXmlValueResourcesForAppleMain")
check.taskNoSource(":copyNonXmlValueResourcesForAppleMain")
check.taskNoSource(":prepareComposeResourcesTaskForAppleMain")
check.taskSkipped(":generateResourceAccessorsForAppleMain")

check.taskNoSource(":convertXmlValueResourcesForMacosMain")
check.taskSuccessful(":copyNonXmlValueResourcesForMacosMain")
check.taskSuccessful(":prepareComposeResourcesTaskForMacosMain")
check.taskSkipped(":generateResourceAccessorsForMacosMain")

check.taskNoSource(":convertXmlValueResourcesForMacosX64Main")
check.taskNoSource(":copyNonXmlValueResourcesForMacosX64Main")
check.taskNoSource(":prepareComposeResourcesTaskForMacosX64Main")
check.taskSkipped(":generateResourceAccessorsForMacosX64Main")

file("build/compose/cocoapods/compose-resources/composeResources/macosresources.generated.resources/drawable/compose-multiplatform.xml").checkExists()
file("build/compose/cocoapods/compose-resources/composeResources/macosresources.generated.resources/drawable/icon.xml").checkExists()
}
}
}

@Test
fun iosTestResources() {
Assumptions.assumeTrue(currentOS == OS.MacOS)
Expand All @@ -702,6 +805,21 @@ class ResourcesTest : GradlePluginTestBase() {
}
}

@Test
fun macosTestResources() {
Assumptions.assumeTrue(currentOS == OS.MacOS)
with(testProject("misc/macosResources")) {
gradle(":linkDebugTestMacosX64", "--dry-run").checks {
check.taskSkipped(":copyTestComposeResourcesForMacosX64")
check.taskSkipped(":linkDebugTestMacosX64")
}
gradle(":copyTestComposeResourcesForMacosX64").checks {
file("build/bin/macosX64/debugTest/compose-resources/composeResources/macosresources.generated.resources/drawable/compose-multiplatform.xml").checkExists()
file("build/bin/macosX64/debugTest/compose-resources/composeResources/macosresources.generated.resources/drawable/icon.xml").checkExists()
}
}
}

@Test
fun checkTestResources() {
with(testProject("misc/testResources")) {
Expand All @@ -714,6 +832,10 @@ class ResourcesTest : GradlePluginTestBase() {
check.logContains("Configure test resources for 'iosArm64' target")
check.logContains("Configure main resources for 'iosSimulatorArm64' target")
check.logContains("Configure test resources for 'iosSimulatorArm64' target")
check.logContains("Configure main resources for 'macosX64' target")
check.logContains("Configure test resources for 'macosX64' target")
check.logContains("Configure main resources for 'macosArm64' target")
check.logContains("Configure test resources for 'macosArm64' target")

check.taskSuccessful(":desktopTest")
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ kotlin {
iosX64()
iosArm64()
iosSimulatorArm64()
macosX64()
macosArm64()
js { browser() }
wasmJs { browser() }

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
<resources>
<string name="macOS_str">macOS string</string>
</resources>
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package me.sample.app

import androidx.compose.runtime.Composable
import kmpresourcepublication.appmodule.generated.resources.Res
import kmpresourcepublication.appmodule.generated.resources.macOS_str
import org.jetbrains.compose.resources.stringResource

@Composable
actual fun getPlatformSpecificString(): String =
stringResource(Res.string.macOS_str)
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ kotlin {
iosX64()
iosArm64()
iosSimulatorArm64()
macosX64()
macosArm64()
js { browser() }
wasmJs { browser() }

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ kotlin {
iosX64()
iosArm64()
iosSimulatorArm64()
macosX64()
macosArm64()
js { browser() }
wasmJs { browser() }

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@ android.useAndroidX=true
org.jetbrains.compose.experimental.uikit.enabled=true
org.jetbrains.compose.experimental.jscanvas.enabled=true
org.jetbrains.compose.experimental.wasm.enabled=true
org.jetbrains.compose.experimental.macos.enabled=true
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
plugins {
kotlin("multiplatform")
kotlin("plugin.compose")
kotlin("native.cocoapods")
id("org.jetbrains.compose")
}

kotlin {
cocoapods {
version = "1.0"
summary = "Some description for a Kotlin/Native module"
homepage = "Link to a Kotlin/Native module homepage"
pod("Base64", "1.1.2")
framework {
baseName = "shared"
isStatic = true
}
}

macosX64()
macosArm64()

sourceSets {
commonMain {
dependencies {
implementation(compose.runtime)
implementation(compose.material)
implementation(compose.components.resources)
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
Pod::Spec.new do |spec|
spec.name = 'macosResources'
spec.version = '1.0'
spec.homepage = 'Link to a Kotlin/Native module homepage'
spec.source = { :http=> ''}
spec.authors = ''
spec.license = ''
spec.summary = 'Some description for a Kotlin/Native module'
spec.vendored_frameworks = 'build/cocoapods/framework/shared.framework'
spec.libraries = 'c++'

spec.dependency 'Base64', '1.1.2'

if !Dir.exist?('build/cocoapods/framework/shared.framework') || Dir.empty?('build/cocoapods/framework/shared.framework')
raise "
Kotlin framework 'shared' doesn't exist yet, so a proper Xcode project can't be generated.
'pod install' should be executed after running ':generateDummyFramework' Gradle task:
./gradlew :generateDummyFramework
Alternatively, proper pod installation is performed during Gradle sync in the IDE (if Podfile location is set)"
end

spec.xcconfig = {
'ENABLE_USER_SCRIPT_SANDBOXING' => 'NO',
}

spec.pod_target_xcconfig = {
'KOTLIN_PROJECT_PATH' => '',
'PRODUCT_MODULE_NAME' => 'shared',
}

spec.script_phases = [
{
:name => 'Build macosResources',
:execution_position => :before_compile,
:shell_path => '/bin/sh',
:script => <<-SCRIPT
if [ "YES" = "$OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED" ]; then
echo "Skipping Gradle build task invocation due to OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED environment variable set to \"YES\""
exit 0
fi
set -ev
REPO_ROOT="$PODS_TARGET_SRCROOT"
"$REPO_ROOT/gradlew" -p "$REPO_ROOT" $KOTLIN_PROJECT_PATH:syncFramework \
-Pkotlin.native.cocoapods.platform=$PLATFORM_NAME \
-Pkotlin.native.cocoapods.archs="$ARCHS" \
-Pkotlin.native.cocoapods.configuration="$CONFIGURATION"
SCRIPT
}
]
spec.resources = ['build/compose/cocoapods/compose-resources']
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
org.gradle.jvmargs=-Xmx8096M
org.jetbrains.compose.experimental.macos.enabled=true
Loading

0 comments on commit 5870305

Please sign in to comment.