From 3f633e0a4ba3dfdf0e453bc92b9057a4a4447ec7 Mon Sep 17 00:00:00 2001 From: alexstyl <1665273+alexstyl@users.noreply.github.com> Date: Tue, 23 Jun 2026 09:41:15 +0700 Subject: [PATCH 1/9] Fix generated Android Gradle compatibility --- cli/src/jvmMain/kotlin/Cli.kt | 6 +++--- cli/src/jvmTest/kotlin/com/composables/cli/CliTest.kt | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/cli/src/jvmMain/kotlin/Cli.kt b/cli/src/jvmMain/kotlin/Cli.kt index d158e21..5657c54 100644 --- a/cli/src/jvmMain/kotlin/Cli.kt +++ b/cli/src/jvmMain/kotlin/Cli.kt @@ -886,7 +886,7 @@ class Target : CliktCommand("target") { "[versions]", """[versions] # Android -agp = "9.0.0" +agp = "8.8.2" android-compileSdk = "37" android-minSdk = "23" android-targetSdk = "37" @@ -1858,7 +1858,7 @@ private fun cloneGradleProjectAt( val androidVersions = if (normalizedTargets.contains(ANDROID)) { """# Android -agp = "9.0.0" +agp = "8.8.2" android-compileSdk = "37" android-minSdk = "23" android-targetSdk = "37" @@ -2353,7 +2353,7 @@ fun updateVersionCatalog( // Add Android versions if android target is selected if (targets.contains("android")) { - if (!hasVersionVariable(versionsSection, "agp")) newVersions.add("agp = \"9.0.0\"") + if (!hasVersionVariable(versionsSection, "agp")) newVersions.add("agp = \"8.8.2\"") if (!hasVersionVariable(versionsSection, "android-compileSdk")) newVersions.add("android-compileSdk = \"37\"") if (!hasVersionVariable(versionsSection, "android-minSdk")) newVersions.add("android-minSdk = \"23\"") if (!hasVersionVariable(versionsSection, "android-targetSdk")) newVersions.add("android-targetSdk = \"37\"") diff --git a/cli/src/jvmTest/kotlin/com/composables/cli/CliTest.kt b/cli/src/jvmTest/kotlin/com/composables/cli/CliTest.kt index 450ab7a..20c3bac 100644 --- a/cli/src/jvmTest/kotlin/com/composables/cli/CliTest.kt +++ b/cli/src/jvmTest/kotlin/com/composables/cli/CliTest.kt @@ -173,7 +173,7 @@ class CliTest { val content = File(targetDir, "gradle/libs.versions.toml").readText() - assertThat(content.countOccurrences("""agp = "9.0.0"""")).isEqualTo(1) + assertThat(content.countOccurrences("""agp = "8.8.2"""")).isEqualTo(1) assertThat(content.countOccurrences("""androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }""")).isEqualTo(1) assertThat(content.countOccurrences("""composables-ui = { group = "com.composables", name = "ui", version.ref = "composablesUi" }""")).isEqualTo(1) assertThat(content.countOccurrences("""android-application = { id = "com.android.application", version.ref = "agp" }""")).isEqualTo(1) From eed8a51ffa194f5d792c64a96ad72a266bdcb62f Mon Sep 17 00:00:00 2001 From: alexstyl <1665273+alexstyl@users.noreply.github.com> Date: Tue, 23 Jun 2026 09:49:44 +0700 Subject: [PATCH 2/9] Upgrade generated Android projects to AGP 9.2.1 --- cli/src/jvmMain/kotlin/Cli.kt | 8 +++++--- .../project/gradle/wrapper/gradle-wrapper.properties | 2 +- cli/src/jvmTest/kotlin/com/composables/cli/CliTest.kt | 2 +- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/cli/src/jvmMain/kotlin/Cli.kt b/cli/src/jvmMain/kotlin/Cli.kt index 5657c54..5f59c73 100644 --- a/cli/src/jvmMain/kotlin/Cli.kt +++ b/cli/src/jvmMain/kotlin/Cli.kt @@ -886,7 +886,7 @@ class Target : CliktCommand("target") { "[versions]", """[versions] # Android -agp = "8.8.2" +agp = "9.2.1" android-compileSdk = "37" android-minSdk = "23" android-targetSdk = "37" @@ -1858,7 +1858,7 @@ private fun cloneGradleProjectAt( val androidVersions = if (normalizedTargets.contains(ANDROID)) { """# Android -agp = "8.8.2" +agp = "9.2.1" android-compileSdk = "37" android-minSdk = "23" android-targetSdk = "37" @@ -1893,6 +1893,8 @@ activityCompose = "1.13.0" """#Android android.nonTransitiveRClass=true android.useAndroidX=true +android.builtInKotlin=false +android.newDsl=false """ } else { "" @@ -2353,7 +2355,7 @@ fun updateVersionCatalog( // Add Android versions if android target is selected if (targets.contains("android")) { - if (!hasVersionVariable(versionsSection, "agp")) newVersions.add("agp = \"8.8.2\"") + if (!hasVersionVariable(versionsSection, "agp")) newVersions.add("agp = \"9.2.1\"") if (!hasVersionVariable(versionsSection, "android-compileSdk")) newVersions.add("android-compileSdk = \"37\"") if (!hasVersionVariable(versionsSection, "android-minSdk")) newVersions.add("android-minSdk = \"23\"") if (!hasVersionVariable(versionsSection, "android-targetSdk")) newVersions.add("android-targetSdk = \"37\"") diff --git a/cli/src/jvmMain/resources/project/gradle/wrapper/gradle-wrapper.properties b/cli/src/jvmMain/resources/project/gradle/wrapper/gradle-wrapper.properties index aaaabb3..c61a118 100644 --- a/cli/src/jvmMain/resources/project/gradle/wrapper/gradle-wrapper.properties +++ b/cli/src/jvmMain/resources/project/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.4-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/cli/src/jvmTest/kotlin/com/composables/cli/CliTest.kt b/cli/src/jvmTest/kotlin/com/composables/cli/CliTest.kt index 20c3bac..03c02e9 100644 --- a/cli/src/jvmTest/kotlin/com/composables/cli/CliTest.kt +++ b/cli/src/jvmTest/kotlin/com/composables/cli/CliTest.kt @@ -173,7 +173,7 @@ class CliTest { val content = File(targetDir, "gradle/libs.versions.toml").readText() - assertThat(content.countOccurrences("""agp = "8.8.2"""")).isEqualTo(1) + assertThat(content.countOccurrences("""agp = "9.2.1"""")).isEqualTo(1) assertThat(content.countOccurrences("""androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }""")).isEqualTo(1) assertThat(content.countOccurrences("""composables-ui = { group = "com.composables", name = "ui", version.ref = "composablesUi" }""")).isEqualTo(1) assertThat(content.countOccurrences("""android-application = { id = "com.android.application", version.ref = "agp" }""")).isEqualTo(1) From c2d81779d9759d96a84ef01f6af281d9f46de995 Mon Sep 17 00:00:00 2001 From: alexstyl <1665273+alexstyl@users.noreply.github.com> Date: Tue, 23 Jun 2026 10:04:54 +0700 Subject: [PATCH 3/9] Migrate generated Android target to Android KMP --- cli/src/jvmMain/kotlin/Cli.kt | 512 +++++------------- .../project/androidApp/build.gradle.kts | 39 ++ .../src/main}/AndroidManifest.xml | 0 .../org/example/project/MainActivity.kt | 0 .../drawable-v24/ic_launcher_foreground.xml | 0 .../res/drawable/ic_launcher_background.xml | 0 .../res/mipmap-anydpi-v26/ic_launcher.xml | 0 .../mipmap-anydpi-v26/ic_launcher_round.xml | 0 .../src/main}/res/mipmap-hdpi/ic_launcher.png | Bin .../res/mipmap-hdpi/ic_launcher_round.png | Bin .../src/main}/res/mipmap-mdpi/ic_launcher.png | Bin .../res/mipmap-mdpi/ic_launcher_round.png | Bin .../main}/res/mipmap-xhdpi/ic_launcher.png | Bin .../res/mipmap-xhdpi/ic_launcher_round.png | Bin .../main}/res/mipmap-xxhdpi/ic_launcher.png | Bin .../res/mipmap-xxhdpi/ic_launcher_round.png | Bin .../main}/res/mipmap-xxxhdpi/ic_launcher.png | Bin .../res/mipmap-xxxhdpi/ic_launcher_round.png | Bin .../src/main}/res/values/strings.xml | 0 .../resources/project/build.gradle.kts | 2 +- .../resources/project/gradle.properties | 2 +- .../resources/project/settings.gradle.kts | 1 + .../kotlin/com/composables/cli/CliTest.kt | 43 ++ 23 files changed, 233 insertions(+), 366 deletions(-) create mode 100644 cli/src/jvmMain/resources/project/androidApp/build.gradle.kts rename cli/src/jvmMain/resources/project/{composeApp/src/androidMain => androidApp/src/main}/AndroidManifest.xml (100%) rename cli/src/jvmMain/resources/project/{composeApp/src/androidMain => androidApp/src/main}/kotlin/org/example/project/MainActivity.kt (100%) rename cli/src/jvmMain/resources/project/{composeApp/src/androidMain => androidApp/src/main}/res/drawable-v24/ic_launcher_foreground.xml (100%) rename cli/src/jvmMain/resources/project/{composeApp/src/androidMain => androidApp/src/main}/res/drawable/ic_launcher_background.xml (100%) rename cli/src/jvmMain/resources/project/{composeApp/src/androidMain => androidApp/src/main}/res/mipmap-anydpi-v26/ic_launcher.xml (100%) rename cli/src/jvmMain/resources/project/{composeApp/src/androidMain => androidApp/src/main}/res/mipmap-anydpi-v26/ic_launcher_round.xml (100%) rename cli/src/jvmMain/resources/project/{composeApp/src/androidMain => androidApp/src/main}/res/mipmap-hdpi/ic_launcher.png (100%) rename cli/src/jvmMain/resources/project/{composeApp/src/androidMain => androidApp/src/main}/res/mipmap-hdpi/ic_launcher_round.png (100%) rename cli/src/jvmMain/resources/project/{composeApp/src/androidMain => androidApp/src/main}/res/mipmap-mdpi/ic_launcher.png (100%) rename cli/src/jvmMain/resources/project/{composeApp/src/androidMain => androidApp/src/main}/res/mipmap-mdpi/ic_launcher_round.png (100%) rename cli/src/jvmMain/resources/project/{composeApp/src/androidMain => androidApp/src/main}/res/mipmap-xhdpi/ic_launcher.png (100%) rename cli/src/jvmMain/resources/project/{composeApp/src/androidMain => androidApp/src/main}/res/mipmap-xhdpi/ic_launcher_round.png (100%) rename cli/src/jvmMain/resources/project/{composeApp/src/androidMain => androidApp/src/main}/res/mipmap-xxhdpi/ic_launcher.png (100%) rename cli/src/jvmMain/resources/project/{composeApp/src/androidMain => androidApp/src/main}/res/mipmap-xxhdpi/ic_launcher_round.png (100%) rename cli/src/jvmMain/resources/project/{composeApp/src/androidMain => androidApp/src/main}/res/mipmap-xxxhdpi/ic_launcher.png (100%) rename cli/src/jvmMain/resources/project/{composeApp/src/androidMain => androidApp/src/main}/res/mipmap-xxxhdpi/ic_launcher_round.png (100%) rename cli/src/jvmMain/resources/project/{composeApp/src/androidMain => androidApp/src/main}/res/values/strings.xml (100%) diff --git a/cli/src/jvmMain/kotlin/Cli.kt b/cli/src/jvmMain/kotlin/Cli.kt index 5f59c73..1beb277 100644 --- a/cli/src/jvmMain/kotlin/Cli.kt +++ b/cli/src/jvmMain/kotlin/Cli.kt @@ -496,6 +496,9 @@ private fun toCamelCase(input: String): String = input.split(Regex("[-_]")) } .joinToString("") +private fun toProjectAccessorName(input: String): String = toCamelCase(input) + .replaceFirstChar { if (it.isUpperCase()) it.lowercase() else it.toString() } + class Target : CliktCommand("target") { override fun help(context: Context): String = """ Adds a new Kotlin target to the current Compose Multiplatform project (options: android, jvm, ios, wasm). @@ -668,7 +671,7 @@ class Target : CliktCommand("target") { private fun hasAndroidTarget(buildFile: File): Boolean { val content = buildFile.readText() - return content.contains("androidTarget {") || content.contains("android {") + return content.contains("androidLibrary {") || content.contains("androidTarget {") || content.contains("android {") } private fun hasJvmTarget(buildFile: File): Boolean { @@ -687,8 +690,11 @@ class Target : CliktCommand("target") { } private fun addAndroidTarget(workingDir: String, buildFile: File) { - var content = buildFile.readText() + val content = buildFile.readText() val lines = content.lines().toMutableList() + val moduleDir = buildFile.parentFile + val moduleName = moduleDir.name + val namespace = inferNamespace(moduleDir) // Add import if needed if (!content.contains("import org.jetbrains.kotlin.gradle.dsl.JvmTarget")) { @@ -709,7 +715,7 @@ class Target : CliktCommand("target") { // Append to plugins block val pluginsCloseIndex = findPluginsBlockEnd(lines) if (pluginsCloseIndex >= 0) { - lines.add(pluginsCloseIndex, " alias(libs.plugins.android.application)") + lines.add(pluginsCloseIndex, " alias(libs.plugins.android.kotlin.multiplatform.library)") } // Append to kotlin block @@ -717,7 +723,14 @@ class Target : CliktCommand("target") { if (kotlinCloseIndex >= 0) { val androidTargetLines = listOf( "", - " androidTarget {", + " androidLibrary {", + " namespace = \"$namespace.composeapp\"", + " compileSdk = libs.versions.android.compileSdk.get().toInt()", + " minSdk = libs.versions.android.minSdk.get().toInt()", + " withJava()", + " androidResources {", + " enable = true", + " }", " compilerOptions {", " jvmTarget.set(JvmTarget.JVM_17)", " }", @@ -728,73 +741,24 @@ class Target : CliktCommand("target") { } } - // Append to sourceSets block - val sourceSetsCloseIndex = findSourceSetsBlockEnd(lines) - if (sourceSetsCloseIndex >= 0) { - val androidMainLines = listOf( - "", - " androidMain.dependencies {", - " implementation(compose.preview)", - " implementation(\"com.composables:ui:0.1.0\")", - " implementation(libs.androidx.activity.compose)", - " }", - ) - androidMainLines.reversed().forEach { line -> - lines.add(sourceSetsCloseIndex, line) - } - } - - // Add android block at the end - val namespace = "com.example.app" // Default namespace for target command - val androidBlock = listOf( - "", - "android {", - " namespace = \"$namespace\"", - " compileSdk = 36", - "", - " defaultConfig {", - " applicationId = \"$namespace\"", - " minSdk = 24", - " targetSdk = 36", - " versionCode = 1", - " versionName = \"1.0\"", - " }", - " packaging {", - " resources {", - " excludes += \"/META-INF/{AL2.0,LGPL2.1}\"", - " }", - " }", - " buildTypes {", - " getByName(\"release\") {", - " isMinifyEnabled = false", - " }", - " }", - " compileOptions {", - " sourceCompatibility = JavaVersion.VERSION_17", - " targetCompatibility = JavaVersion.VERSION_17", - " }", - "}", - ) - lines.addAll(androidBlock) - // Write updated content buildFile.writeText(lines.joinToString("\n")) - // Create androidMain source set and MainActivity - val moduleDir = buildFile.parentFile - createAndroidSourceSet(moduleDir, namespace) - - // Copy Android resources - copyAndroidResources(moduleDir, namespace) + createAndroidAppModule( + projectDir = File(workingDir), + sharedModuleName = moduleName, + namespace = namespace, + ) + addAndroidAppModuleToSettings(workingDir) // Update root build.gradle.kts - updateRootBuildFile(workingDir) + updateRootBuildFile(workingDir, setOf(ANDROID)) // Update gradle.properties updateGradleProperties(workingDir) // Update libs.versions.toml - updateVersionsFile(workingDir) + updateVersionCatalog(workingDir, setOf(ANDROID)) } private fun findPluginsBlockEnd(lines: List): Int { @@ -858,64 +822,21 @@ class Target : CliktCommand("target") { return "com.example.app" // fallback } - private fun updateRootBuildFile(workingDir: String) { - val rootBuildFile = File(workingDir, "build.gradle.kts") - if (!rootBuildFile.exists()) return - - var content = rootBuildFile.readText() - if (!content.contains("android-application")) { - // Find the plugins block and add android plugin - val lines = content.lines().toMutableList() - val pluginsCloseIndex = findPluginsBlockEnd(lines) - if (pluginsCloseIndex >= 0) { - lines.add(pluginsCloseIndex, " alias(libs.plugins.android.application) apply false") - rootBuildFile.writeText(lines.joinToString("\n")) - } - } - } - - private fun updateVersionsFile(workingDir: String) { - val versionsFile = File(workingDir, "gradle/libs.versions.toml") - if (!versionsFile.exists()) return - - var content = versionsFile.readText() - - // Add Android versions if not present - if (!content.contains("agp =")) { - content = content.replace( - "[versions]", - """[versions] -# Android -agp = "9.2.1" -android-compileSdk = "37" -android-minSdk = "23" -android-targetSdk = "37" -activityCompose = "1.13.0" -""", - ) - } - - // Add Android libraries if not present - if (!content.contains("androidx-activity-compose")) { - content = content.replace( - "[libraries]", - """[libraries] -androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } -""", - ) - } - - // Add Android plugins if not present - if (!content.contains("android-application")) { - content = content.replace( - "[plugins]", - """[plugins] -android-application = { id = "com.android.application", version.ref = "agp" } -""", - ) + private fun inferNamespace(moduleDir: File): String { + val commonMainDir = File(moduleDir, "src/commonMain/kotlin") + if (commonMainDir.exists()) { + commonMainDir.walkTopDown() + .firstOrNull { it.isFile && it.extension == "kt" } + ?.useLines { lines -> + lines.firstOrNull { it.startsWith("package ") } + ?.removePrefix("package ") + ?.trim() + ?.takeIf { it.isNotEmpty() } + } + ?.let { return it } } - versionsFile.writeText(content) + return "com.example.app" } private fun updateGradleProperties(workingDir: String) { @@ -932,85 +853,57 @@ android-application = { id = "com.android.application", version.ref = "agp" } gradlePropertiesFile.writeText(content) } - private fun createAndroidSourceSet(moduleDir: File, namespace: String) { - val androidMainDir = File(moduleDir, "src/androidMain/kotlin") - val packageDir = File(androidMainDir, namespace.replace(".", "/")) - - // Create directories - packageDir.mkdirs() - - // Create MainActivity.kt - val mainActivityFile = File(packageDir, "MainActivity.kt") - val mainActivityContent = """package $namespace - -import android.os.Bundle -import androidx.activity.ComponentActivity -import androidx.activity.compose.setContent -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.safeDrawingPadding -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import com.composables.ui.components.Text -import com.composables.ui.theme.ComposablesTheme -import org.jetbrains.compose.ui.tooling.preview.Preview - -class MainActivity : ComponentActivity() { - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setContent { - AndroidApp() - } - } -} - -@Composable -fun AndroidApp() { - ComposablesTheme { - Box( - modifier = Modifier - .safeDrawingPadding() - .fillMaxSize() - .padding(16.dp), - contentAlignment = Alignment.Center - ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterVertically) - ) { - Text( - text = "Hello Beautiful World!", - textAlign = TextAlign.Center, - ) - Text( - text = "Go to MainActivity.kt to edit your app", - textAlign = TextAlign.Center, - ) - Text( - text = "Pro tip: Use the `dev` configuration in your IDE to auto-reload your app when you edit your code", - textAlign = TextAlign.Center - ) + private fun createAndroidAppModule( + projectDir: File, + sharedModuleName: String, + namespace: String, + ) { + val androidAppDir = File(projectDir, "androidApp") + androidAppDir.mkdirs() + File(androidAppDir, "build.gradle.kts").writeText( + """ + plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.jetbrains.compose.compiler) + } + + android { + namespace = "$namespace" + compileSdk = libs.versions.android.compileSdk.get().toInt() + + defaultConfig { + applicationId = "$namespace" + minSdk = libs.versions.android.minSdk.get().toInt() + targetSdk = libs.versions.android.targetSdk.get().toInt() + versionCode = 1 + versionName = "1.0" + } + buildFeatures { + compose = true + } + packaging { + resources { + excludes += "/META-INF/{AL2.0,LGPL2.1}" + } + } + buildTypes { + getByName("release") { + isMinifyEnabled = false + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } } - } - } -} -@Preview -@Composable -fun DefaultPreview() { - AndroidApp() -} -""" - mainActivityFile.writeText(mainActivityContent) - } + dependencies { + implementation(projects.${toProjectAccessorName(sharedModuleName)}) + implementation(libs.androidx.activity.compose) + } + """.trimIndent() + "\n", + ) - private fun copyAndroidResources(moduleDir: File, namespace: String) { fun copyResource(resourcePath: String, targetFile: File) { val inputStream: InputStream? = object {}.javaClass.getResourceAsStream(resourcePath) if (inputStream != null) { @@ -1058,37 +951,18 @@ fun DefaultPreview() { return resources } - val resources = listResources("/project/composeApp/src/androidMain") + val resources = listResources("/project/androidApp/src/main") resources.forEach { resourcePath -> - val targetPath = resourcePath.removePrefix("/project/composeApp/src/androidMain/") - - // Skip MainActivity.kt template since we create it programmatically - if (targetPath.endsWith("MainActivity.kt")) { - return@forEach - } - - val targetFile = File(moduleDir, "src/androidMain/$targetPath") + val targetPath = resourcePath.removePrefix("/project/androidApp/src/main/") + .replace("org/example/project", namespace.replace(".", "/")) + val targetFile = File(androidAppDir, "src/main/$targetPath") copyResource(resourcePath, targetFile) - // Replace placeholders in text files - if (targetFile.name.endsWith(".kt")) { + if (targetFile.name.endsWith(".kt") || targetFile.name.endsWith(".xml")) { try { val content = targetFile.readText() var updatedContent = content.replace("{{namespace}}", namespace) - updatedContent = updatedContent.replace("{{app_name}}", "My App") - if (content != updatedContent) { - targetFile.writeText(updatedContent) - } - } catch (e: Exception) { - // Skip binary files - } - } - - // Replace placeholders in strings.xml - if (targetFile.name == "strings.xml") { - try { - val content = targetFile.readText() - var updatedContent = content.replace("{{app_name}}", "My App") + updatedContent = updatedContent.replace("{{app_name}}", projectDir.name) if (content != updatedContent) { targetFile.writeText(updatedContent) } @@ -1099,6 +973,16 @@ fun DefaultPreview() { } } + private fun addAndroidAppModuleToSettings(workingDir: String) { + val settingsFile = File(workingDir, "settings.gradle.kts") + if (!settingsFile.exists()) return + + val content = settingsFile.readText() + if (content.contains("""include(":androidApp")""")) return + + settingsFile.writeText(content.trimEnd() + "\ninclude(\":androidApp\")\n") + } + private fun addJvmTarget(workingDir: String, buildFile: File) { var content = buildFile.readText() val lines = content.lines().toMutableList() @@ -1800,6 +1684,9 @@ private fun cloneGradleProjectAt( if (!normalizedTargets.contains(IOS) && targetPath.startsWith("iosApp/")) { return@forEach } + if (!normalizedTargets.contains(ANDROID) && targetPath.startsWith("androidApp/")) { + return@forEach + } // Skip source set directories if corresponding target is not selected val isInsideAKotlinSourceSet = targetPath.startsWith("composeApp/src/") @@ -1879,12 +1766,11 @@ activityCompose = "1.13.0" } val androidPlugins = - if (normalizedTargets.contains(ANDROID)) """android-application = { id = "com.android.application", version.ref = "agp" }""" else "" - - val androidPlugin = if (normalizedTargets.contains(ANDROID)) { - """ alias(libs.plugins.android.application) apply false -""" + """ +android-application = { id = "com.android.application", version.ref = "agp" } +android-kotlin-multiplatform-library = { id = "com.android.kotlin.multiplatform.library", version.ref = "agp" } + """.trimIndent() } else { "" } @@ -1893,13 +1779,21 @@ activityCompose = "1.13.0" """#Android android.nonTransitiveRClass=true android.useAndroidX=true -android.builtInKotlin=false -android.newDsl=false """ } else { "" } + val androidRootPlugins = if (normalizedTargets.contains(ANDROID)) { + """ alias(libs.plugins.android.application) apply false + alias(libs.plugins.android.kotlin.multiplatform.library) apply false +""" + } else { + "" + } + + val androidInclude = if (normalizedTargets.contains(ANDROID)) """include(":androidApp")""" else "" + val webPreloadTaskWiring = if (normalizedTargets.contains(WASM)) { """ subprojects { @@ -1991,7 +1885,7 @@ subprojects { } plugins.add(" alias(libs.plugins.jetbrains.compose.hotreload)") if (normalizedTargets.contains(ANDROID)) { - plugins.add(" alias(libs.plugins.android.application)") + plugins.add(" alias(libs.plugins.android.kotlin.multiplatform.library)") } val pluginsBlock = "plugins {\n" + plugins.joinToString("\n") + "\n}" @@ -1999,7 +1893,14 @@ subprojects { val kotlinTargets = mutableListOf() if (normalizedTargets.contains(ANDROID)) { kotlinTargets.add( - """ androidTarget { + """ androidLibrary { + namespace = "{{namespace}}.composeapp" + compileSdk = libs.versions.android.compileSdk.get().toInt() + minSdk = libs.versions.android.minSdk.get().toInt() + withJava() + androidResources { + enable = true + } compilerOptions { jvmTarget.set(JvmTarget.JVM_17) } @@ -2062,14 +1963,6 @@ subprojects { sourcesets.add( """ jvmMain.dependencies { implementation(compose.desktop.currentOs) - }""", - ) - } - if (normalizedTargets.contains(ANDROID)) { - sourcesets.add( - """ androidMain.dependencies { - implementation(compose.preview) - implementation(libs.androidx.activity.compose) }""", ) } @@ -2078,36 +1971,6 @@ subprojects { // Build configuration blocks val configurations = mutableListOf() - if (normalizedTargets.contains(ANDROID)) { - configurations.add( - """android { - namespace = "{{namespace}}" - compileSdk = libs.versions.android.compileSdk.get().toInt() - - defaultConfig { - applicationId = "{{namespace}}" - minSdk = libs.versions.android.minSdk.get().toInt() - targetSdk = libs.versions.android.targetSdk.get().toInt() - versionCode = 1 - versionName = "1.0" - } - packaging { - resources { - excludes += "/META-INF/{AL2.0,LGPL2.1}" - } - } - buildTypes { - getByName("release") { - isMinifyEnabled = false - } - } - compileOptions { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 - } -}""", - ) - } if (normalizedTargets.contains(JVM)) { configurations.add( """compose.desktop { @@ -2126,68 +1989,11 @@ subprojects { val configurationBlocksBlock = if (configurations.isNotEmpty()) configurations.joinToString("\n\n") else "" - val composeDesktop = if (normalizedTargets.contains(JVM)) { - """compose.desktop { - application { - mainClass = "{{namespace}}.MainDesktopKt" - - nativeDistributions { - targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb) - packageName = "{{namespace}}" - packageVersion = "1.0.0" - } - } -}""" - } else { - "" - } - - val androidMainDependencies = if (normalizedTargets.contains(ANDROID)) { - """ androidMain.dependencies { - implementation(compose.preview) - implementation(libs.androidx.activity.compose) - }""" - } else { - "" - } - - val androidBlock = if (normalizedTargets.contains(ANDROID)) { - """android { - namespace = "{{namespace}}" - compileSdk = libs.versions.android.compileSdk.get().toInt() - - defaultConfig { - applicationId = "{{namespace}}" - minSdk = libs.versions.android.minSdk.get().toInt() - targetSdk = libs.versions.android.targetSdk.get().toInt() - versionCode = 1 - versionName = "1.0" - } - packaging { - resources { - excludes += "/META-INF/{AL2.0,LGPL2.1}" - } - } - buildTypes { - getByName("release") { - isMinifyEnabled = false - } - } - compileOptions { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 - } -} - -""" - } else { - "" - } - updatedContent = updatedContent.replace("{{android_versions}}", androidVersions) updatedContent = updatedContent.replace("{{android_libraries}}", androidLibraries) updatedContent = updatedContent.replace("{{android_plugins}}", androidPlugins) - updatedContent = updatedContent.replace("{{android_plugin}}", androidPlugin) + updatedContent = updatedContent.replace("{{android_root_plugins}}", androidRootPlugins) + updatedContent = updatedContent.replace("{{android_include}}", androidInclude) updatedContent = updatedContent.replace("{{android_properties}}", androidProperties) updatedContent = updatedContent.replace("{{web_preload_task_wiring}}", webPreloadTaskWiring) @@ -2203,6 +2009,7 @@ subprojects { updatedContent = updatedContent.replace("{{project_name}}", target.name) updatedContent = updatedContent.replace("{{module_name}}", moduleName) updatedContent = updatedContent.replace("{{app_name}}", appName) + updatedContent = updatedContent.replace("{{shared_module_accessor}}", toProjectAccessorName(moduleName)) updatedContent = updatedContent.replace("{{ios_binary_name}}", toCamelCase(moduleName)) updatedContent = updatedContent.replace("{{target_name}}", toCamelCase(moduleName) + ".app") if (content != updatedContent) { @@ -2287,6 +2094,9 @@ fun updateRootBuildFile( if (normalizedTargets.contains(ANDROID) && !pluginsContent.contains("libs.plugins.android.application")) { requiredPlugins.add(" alias(libs.plugins.android.application) apply false") } + if (normalizedTargets.contains(ANDROID) && !pluginsContent.contains("libs.plugins.android.kotlin.multiplatform.library")) { + requiredPlugins.add(" alias(libs.plugins.android.kotlin.multiplatform.library) apply false") + } if (requiredPlugins.isNotEmpty()) { // Add missing plugins before closing brace @@ -2305,6 +2115,7 @@ fun updateRootBuildFile( requiredPlugins.add(" alias(libs.plugins.jetbrains.compose.hotreload) apply false") if (normalizedTargets.contains(ANDROID)) { requiredPlugins.add(" alias(libs.plugins.android.application) apply false") + requiredPlugins.add(" alias(libs.plugins.android.kotlin.multiplatform.library) apply false") } requiredPlugins.add("}") @@ -2388,6 +2199,9 @@ fun updateVersionCatalog( if (targets.contains("android") && !hasPluginVariable(pluginsSection, "android-application")) { newPlugins.add("android-application = { id = \"com.android.application\", version.ref = \"agp\" }") } + if (targets.contains("android") && !hasPluginVariable(pluginsSection, "android-kotlin-multiplatform-library")) { + newPlugins.add("android-kotlin-multiplatform-library = { id = \"com.android.kotlin.multiplatform.library\", version.ref = \"agp\" }") + } // Build updated content if (newVersions.isNotEmpty() || newLibraries.isNotEmpty() || newPlugins.isNotEmpty()) { @@ -2627,7 +2441,7 @@ fun createModuleOnly( } plugins.add(" alias(libs.plugins.jetbrains.compose.hotreload)") if (normalizedTargets.contains(ANDROID)) { - plugins.add(" alias(libs.plugins.android.application)") + plugins.add(" alias(libs.plugins.android.kotlin.multiplatform.library)") } val pluginsBlock = "plugins {\n" + plugins.joinToString("\n") + "\n}" @@ -2635,7 +2449,14 @@ fun createModuleOnly( val kotlinTargets = mutableListOf() if (normalizedTargets.contains(ANDROID)) { kotlinTargets.add( - """ androidTarget { + """ androidLibrary { + namespace = "{{namespace}}.composeapp" + compileSdk = libs.versions.android.compileSdk.get().toInt() + minSdk = libs.versions.android.minSdk.get().toInt() + withJava() + androidResources { + enable = true + } compilerOptions { jvmTarget.set(JvmTarget.JVM_17) } @@ -2698,14 +2519,6 @@ fun createModuleOnly( sourcesets.add( """ jvmMain.dependencies { implementation(compose.desktop.currentOs) - }""", - ) - } - if (normalizedTargets.contains(ANDROID)) { - sourcesets.add( - """ androidMain.dependencies { - implementation(compose.preview) - implementation(libs.androidx.activity.compose) }""", ) } @@ -2714,36 +2527,6 @@ fun createModuleOnly( // Build configuration blocks val configurations = mutableListOf() - if (normalizedTargets.contains(ANDROID)) { - configurations.add( - """android { - namespace = "{{namespace}}" - compileSdk = libs.versions.android.compileSdk.get().toInt() - - defaultConfig { - applicationId = "{{namespace}}" - minSdk = libs.versions.android.minSdk.get().toInt() - targetSdk = libs.versions.android.targetSdk.get().toInt() - versionCode = 1 - versionName = "1.0" - } - packaging { - resources { - excludes += "/META-INF/{AL2.0,LGPL2.1}" - } - } - buildTypes { - getByName("release") { - isMinifyEnabled = false - } - } - compileOptions { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 - } -}""", - ) - } if (normalizedTargets.contains(JVM)) { configurations.add( """compose.desktop { @@ -2773,6 +2556,7 @@ fun createModuleOnly( updatedContent = updatedContent.replace("{{namespace}}", packageName) updatedContent = updatedContent.replace("{{module_name}}", moduleName) updatedContent = updatedContent.replace("{{app_name}}", appName) + updatedContent = updatedContent.replace("{{shared_module_accessor}}", toProjectAccessorName(moduleName)) updatedContent = updatedContent.replace("{{ios_binary_name}}", toCamelCase(moduleName)) updatedContent = updatedContent.replace("{{target_name}}", "${toCamelCase(moduleName)}.app") if (content != updatedContent) { diff --git a/cli/src/jvmMain/resources/project/androidApp/build.gradle.kts b/cli/src/jvmMain/resources/project/androidApp/build.gradle.kts new file mode 100644 index 0000000..a8b13be --- /dev/null +++ b/cli/src/jvmMain/resources/project/androidApp/build.gradle.kts @@ -0,0 +1,39 @@ +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.jetbrains.compose.compiler) +} + +android { + namespace = "{{namespace}}" + compileSdk = libs.versions.android.compileSdk.get().toInt() + + defaultConfig { + applicationId = "{{namespace}}" + minSdk = libs.versions.android.minSdk.get().toInt() + targetSdk = libs.versions.android.targetSdk.get().toInt() + versionCode = 1 + versionName = "1.0" + } + buildFeatures { + compose = true + } + packaging { + resources { + excludes += "/META-INF/{AL2.0,LGPL2.1}" + } + } + buildTypes { + getByName("release") { + isMinifyEnabled = false + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } +} + +dependencies { + implementation(projects.{{shared_module_accessor}}) + implementation(libs.androidx.activity.compose) +} diff --git a/cli/src/jvmMain/resources/project/composeApp/src/androidMain/AndroidManifest.xml b/cli/src/jvmMain/resources/project/androidApp/src/main/AndroidManifest.xml similarity index 100% rename from cli/src/jvmMain/resources/project/composeApp/src/androidMain/AndroidManifest.xml rename to cli/src/jvmMain/resources/project/androidApp/src/main/AndroidManifest.xml diff --git a/cli/src/jvmMain/resources/project/composeApp/src/androidMain/kotlin/org/example/project/MainActivity.kt b/cli/src/jvmMain/resources/project/androidApp/src/main/kotlin/org/example/project/MainActivity.kt similarity index 100% rename from cli/src/jvmMain/resources/project/composeApp/src/androidMain/kotlin/org/example/project/MainActivity.kt rename to cli/src/jvmMain/resources/project/androidApp/src/main/kotlin/org/example/project/MainActivity.kt diff --git a/cli/src/jvmMain/resources/project/composeApp/src/androidMain/res/drawable-v24/ic_launcher_foreground.xml b/cli/src/jvmMain/resources/project/androidApp/src/main/res/drawable-v24/ic_launcher_foreground.xml similarity index 100% rename from cli/src/jvmMain/resources/project/composeApp/src/androidMain/res/drawable-v24/ic_launcher_foreground.xml rename to cli/src/jvmMain/resources/project/androidApp/src/main/res/drawable-v24/ic_launcher_foreground.xml diff --git a/cli/src/jvmMain/resources/project/composeApp/src/androidMain/res/drawable/ic_launcher_background.xml b/cli/src/jvmMain/resources/project/androidApp/src/main/res/drawable/ic_launcher_background.xml similarity index 100% rename from cli/src/jvmMain/resources/project/composeApp/src/androidMain/res/drawable/ic_launcher_background.xml rename to cli/src/jvmMain/resources/project/androidApp/src/main/res/drawable/ic_launcher_background.xml diff --git a/cli/src/jvmMain/resources/project/composeApp/src/androidMain/res/mipmap-anydpi-v26/ic_launcher.xml b/cli/src/jvmMain/resources/project/androidApp/src/main/res/mipmap-anydpi-v26/ic_launcher.xml similarity index 100% rename from cli/src/jvmMain/resources/project/composeApp/src/androidMain/res/mipmap-anydpi-v26/ic_launcher.xml rename to cli/src/jvmMain/resources/project/androidApp/src/main/res/mipmap-anydpi-v26/ic_launcher.xml diff --git a/cli/src/jvmMain/resources/project/composeApp/src/androidMain/res/mipmap-anydpi-v26/ic_launcher_round.xml b/cli/src/jvmMain/resources/project/androidApp/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml similarity index 100% rename from cli/src/jvmMain/resources/project/composeApp/src/androidMain/res/mipmap-anydpi-v26/ic_launcher_round.xml rename to cli/src/jvmMain/resources/project/androidApp/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml diff --git a/cli/src/jvmMain/resources/project/composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher.png b/cli/src/jvmMain/resources/project/androidApp/src/main/res/mipmap-hdpi/ic_launcher.png similarity index 100% rename from cli/src/jvmMain/resources/project/composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher.png rename to cli/src/jvmMain/resources/project/androidApp/src/main/res/mipmap-hdpi/ic_launcher.png diff --git a/cli/src/jvmMain/resources/project/composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher_round.png b/cli/src/jvmMain/resources/project/androidApp/src/main/res/mipmap-hdpi/ic_launcher_round.png similarity index 100% rename from cli/src/jvmMain/resources/project/composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher_round.png rename to cli/src/jvmMain/resources/project/androidApp/src/main/res/mipmap-hdpi/ic_launcher_round.png diff --git a/cli/src/jvmMain/resources/project/composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher.png b/cli/src/jvmMain/resources/project/androidApp/src/main/res/mipmap-mdpi/ic_launcher.png similarity index 100% rename from cli/src/jvmMain/resources/project/composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher.png rename to cli/src/jvmMain/resources/project/androidApp/src/main/res/mipmap-mdpi/ic_launcher.png diff --git a/cli/src/jvmMain/resources/project/composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher_round.png b/cli/src/jvmMain/resources/project/androidApp/src/main/res/mipmap-mdpi/ic_launcher_round.png similarity index 100% rename from cli/src/jvmMain/resources/project/composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher_round.png rename to cli/src/jvmMain/resources/project/androidApp/src/main/res/mipmap-mdpi/ic_launcher_round.png diff --git a/cli/src/jvmMain/resources/project/composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher.png b/cli/src/jvmMain/resources/project/androidApp/src/main/res/mipmap-xhdpi/ic_launcher.png similarity index 100% rename from cli/src/jvmMain/resources/project/composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher.png rename to cli/src/jvmMain/resources/project/androidApp/src/main/res/mipmap-xhdpi/ic_launcher.png diff --git a/cli/src/jvmMain/resources/project/composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher_round.png b/cli/src/jvmMain/resources/project/androidApp/src/main/res/mipmap-xhdpi/ic_launcher_round.png similarity index 100% rename from cli/src/jvmMain/resources/project/composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher_round.png rename to cli/src/jvmMain/resources/project/androidApp/src/main/res/mipmap-xhdpi/ic_launcher_round.png diff --git a/cli/src/jvmMain/resources/project/composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher.png b/cli/src/jvmMain/resources/project/androidApp/src/main/res/mipmap-xxhdpi/ic_launcher.png similarity index 100% rename from cli/src/jvmMain/resources/project/composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher.png rename to cli/src/jvmMain/resources/project/androidApp/src/main/res/mipmap-xxhdpi/ic_launcher.png diff --git a/cli/src/jvmMain/resources/project/composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher_round.png b/cli/src/jvmMain/resources/project/androidApp/src/main/res/mipmap-xxhdpi/ic_launcher_round.png similarity index 100% rename from cli/src/jvmMain/resources/project/composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher_round.png rename to cli/src/jvmMain/resources/project/androidApp/src/main/res/mipmap-xxhdpi/ic_launcher_round.png diff --git a/cli/src/jvmMain/resources/project/composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher.png b/cli/src/jvmMain/resources/project/androidApp/src/main/res/mipmap-xxxhdpi/ic_launcher.png similarity index 100% rename from cli/src/jvmMain/resources/project/composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher.png rename to cli/src/jvmMain/resources/project/androidApp/src/main/res/mipmap-xxxhdpi/ic_launcher.png diff --git a/cli/src/jvmMain/resources/project/composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher_round.png b/cli/src/jvmMain/resources/project/androidApp/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png similarity index 100% rename from cli/src/jvmMain/resources/project/composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher_round.png rename to cli/src/jvmMain/resources/project/androidApp/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png diff --git a/cli/src/jvmMain/resources/project/composeApp/src/androidMain/res/values/strings.xml b/cli/src/jvmMain/resources/project/androidApp/src/main/res/values/strings.xml similarity index 100% rename from cli/src/jvmMain/resources/project/composeApp/src/androidMain/res/values/strings.xml rename to cli/src/jvmMain/resources/project/androidApp/src/main/res/values/strings.xml diff --git a/cli/src/jvmMain/resources/project/build.gradle.kts b/cli/src/jvmMain/resources/project/build.gradle.kts index 3f2a012..4d9942c 100644 --- a/cli/src/jvmMain/resources/project/build.gradle.kts +++ b/cli/src/jvmMain/resources/project/build.gradle.kts @@ -5,6 +5,6 @@ plugins { alias(libs.plugins.jetbrains.compose.hotreload) apply false alias(libs.plugins.jetbrains.compose) apply false alias(libs.plugins.jetbrains.compose.compiler) apply false -{{android_plugin}}} +{{android_root_plugins}}} {{web_preload_task_wiring}} diff --git a/cli/src/jvmMain/resources/project/gradle.properties b/cli/src/jvmMain/resources/project/gradle.properties index 9f314c3..a662228 100644 --- a/cli/src/jvmMain/resources/project/gradle.properties +++ b/cli/src/jvmMain/resources/project/gradle.properties @@ -7,4 +7,4 @@ org.gradle.jvmargs=-Xmx4096M -Dfile.encoding=UTF-8 org.gradle.configuration-cache=true org.gradle.caching=true -{{android_properties}} \ No newline at end of file +{{android_properties}} diff --git a/cli/src/jvmMain/resources/project/settings.gradle.kts b/cli/src/jvmMain/resources/project/settings.gradle.kts index ac5debc..e9315f0 100644 --- a/cli/src/jvmMain/resources/project/settings.gradle.kts +++ b/cli/src/jvmMain/resources/project/settings.gradle.kts @@ -36,3 +36,4 @@ plugins { } include(":{{module_name}}") +{{android_include}} diff --git a/cli/src/jvmTest/kotlin/com/composables/cli/CliTest.kt b/cli/src/jvmTest/kotlin/com/composables/cli/CliTest.kt index 03c02e9..0e1d491 100644 --- a/cli/src/jvmTest/kotlin/com/composables/cli/CliTest.kt +++ b/cli/src/jvmTest/kotlin/com/composables/cli/CliTest.kt @@ -39,6 +39,7 @@ class CliTest { assertThat(appFile.exists(), "App source should be moved to the requested package").isTrue() assertThat(File(projectDir, "iosDesktopApp").exists(), "iOS app scaffold should be skipped for JVM-only template runs").isFalse() + assertThat(File(projectDir, "androidApp").exists(), "Android app scaffold should be skipped for JVM-only template runs").isFalse() assertThat(File(projectDir, "desktopApp/src/androidMain").exists(), "Android sources should be omitted for JVM-only template runs").isFalse() assertThat(File(projectDir, "desktopApp/src/wasmJsMain").exists(), "Wasm sources should be omitted for JVM-only template runs").isFalse() @@ -86,6 +87,46 @@ class CliTest { assertThat(targetDir.name).isEqualTo("sample-app") } + @Test + fun `cloneGradleProject renders Android as a separate app module`() { + withTempDir { targetDir -> + cloneGradleProject( + targetDir = targetDir.absolutePath, + dirName = "newApp", + packageName = "com.composables.demo", + moduleName = "sharedUi", + appName = "The App", + targets = setOf(ANDROID, JVM, IOS, WASM), + ) + + val projectDir = File(targetDir, "newApp") + val sharedBuildFile = File(projectDir, "sharedUi/build.gradle.kts") + val androidAppBuildFile = File(projectDir, "androidApp/build.gradle.kts") + val settingsFile = File(projectDir, "settings.gradle.kts") + val mainActivityFile = File(projectDir, "androidApp/src/main/kotlin/com/composables/demo/MainActivity.kt") + + assertThat(sharedBuildFile).exists() + assertThat(androidAppBuildFile).exists() + assertThat(mainActivityFile).exists() + assertThat(File(projectDir, "sharedUi/src/androidMain").exists()).isFalse() + + val sharedBuildContent = sharedBuildFile.readText() + val androidAppBuildContent = androidAppBuildFile.readText() + val settingsContent = settingsFile.readText() + + assertThat(sharedBuildContent).contains("alias(libs.plugins.android.kotlin.multiplatform.library)") + assertThat(sharedBuildContent).contains("androidLibrary {") + assertThat(sharedBuildContent).doesNotContain("alias(libs.plugins.android.application)") + assertThat(sharedBuildContent).doesNotContain("androidMain.dependencies") + assertThat(sharedBuildContent).doesNotContain("defaultConfig {") + + assertThat(androidAppBuildContent).contains("alias(libs.plugins.android.application)") + assertThat(androidAppBuildContent).contains("buildFeatures {") + assertThat(androidAppBuildContent).contains("implementation(projects.sharedUi)") + assertThat(settingsContent).contains("""include(":androidApp")""") + } + } + @Test fun `parseTargets normalizes and de-duplicates targets`() { val targets = parseTargets("JVM, android, jvm, ios") @@ -149,6 +190,7 @@ class CliTest { assertThat(content.countOccurrences("alias(libs.plugins.jetbrains.compose.compiler) apply false")).isEqualTo(1) assertThat(content.countOccurrences("alias(libs.plugins.jetbrains.compose.hotreload) apply false")).isEqualTo(1) assertThat(content.countOccurrences("alias(libs.plugins.android.application) apply false")).isEqualTo(1) + assertThat(content.countOccurrences("alias(libs.plugins.android.kotlin.multiplatform.library) apply false")).isEqualTo(1) } } @@ -177,6 +219,7 @@ class CliTest { assertThat(content.countOccurrences("""androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }""")).isEqualTo(1) assertThat(content.countOccurrences("""composables-ui = { group = "com.composables", name = "ui", version.ref = "composablesUi" }""")).isEqualTo(1) assertThat(content.countOccurrences("""android-application = { id = "com.android.application", version.ref = "agp" }""")).isEqualTo(1) + assertThat(content.countOccurrences("""android-kotlin-multiplatform-library = { id = "com.android.kotlin.multiplatform.library", version.ref = "agp" }""")).isEqualTo(1) assertThat(content).contains("""compose = "1.11.1"""") assertThat(content).contains("""composeHotReload = "1.1.0"""") assertThat(content).contains("""composablesUi = "0.1.0"""") From 972d64479f977f15de1855ea3ada3766fdfe3ab7 Mon Sep 17 00:00:00 2001 From: alexstyl <1665273+alexstyl@users.noreply.github.com> Date: Tue, 23 Jun 2026 10:49:17 +0700 Subject: [PATCH 4/9] Split generated app modules by platform --- cli/src/jvmMain/kotlin/Cli.kt | 594 +++++++----------- .../resources/project/build.gradle.kts | 3 +- .../project/desktopApp/build.gradle.kts | 31 + .../src/jvmMain/kotlin/org/example/main.kt | 0 .../resources/project/settings.gradle.kts | 4 +- .../{composeApp => shared}/build.gradle.kts | 0 .../kotlin/org/example/project/App.kt | 0 .../org/example/project/MainViewController.kt | 0 .../resources/project/webApp/build.gradle.kts | 35 ++ .../src/wasmJsMain/kotlin/org/example/main.kt | 0 .../src/wasmJsMain/resources/index.html | 2 +- .../src/wasmJsMain/resources/styles.css | 0 .../webpack.config.d/watch.js | 0 .../kotlin/com/composables/cli/CliTest.kt | 52 +- 14 files changed, 319 insertions(+), 402 deletions(-) create mode 100644 cli/src/jvmMain/resources/project/desktopApp/build.gradle.kts rename cli/src/jvmMain/resources/project/{composeApp => desktopApp}/src/jvmMain/kotlin/org/example/main.kt (100%) rename cli/src/jvmMain/resources/project/{composeApp => shared}/build.gradle.kts (100%) rename cli/src/jvmMain/resources/project/{composeApp => shared}/src/commonMain/kotlin/org/example/project/App.kt (100%) rename cli/src/jvmMain/resources/project/{composeApp => shared}/src/iosMain/kotlin/org/example/project/MainViewController.kt (100%) create mode 100644 cli/src/jvmMain/resources/project/webApp/build.gradle.kts rename cli/src/jvmMain/resources/project/{composeApp => webApp}/src/wasmJsMain/kotlin/org/example/main.kt (100%) rename cli/src/jvmMain/resources/project/{composeApp => webApp}/src/wasmJsMain/resources/index.html (78%) rename cli/src/jvmMain/resources/project/{composeApp => webApp}/src/wasmJsMain/resources/styles.css (100%) rename cli/src/jvmMain/resources/project/{composeApp => webApp}/webpack.config.d/watch.js (100%) diff --git a/cli/src/jvmMain/kotlin/Cli.kt b/cli/src/jvmMain/kotlin/Cli.kt index 1beb277..aee2901 100644 --- a/cli/src/jvmMain/kotlin/Cli.kt +++ b/cli/src/jvmMain/kotlin/Cli.kt @@ -21,6 +21,11 @@ val ANDROID = "android" val JVM = "jvm" val IOS = "ios" val WASM = "wasm" +val SHARED_MODULE = "shared" +val ANDROID_APP_MODULE = "androidApp" +val IOS_APP_MODULE = "iosApp" +val DESKTOP_APP_MODULE = "desktopApp" +val WEB_APP_MODULE = "webApp" private fun File.toResourcePath(): String = invariantSeparatorsPath.replace('\\', '/') @@ -129,7 +134,7 @@ class CreateApp : CliktCommand("create-app") { packageName = resolvedPackageName, appName = resolvedAppName, targets = targets, - moduleName = "composeApp", + moduleName = SHARED_MODULE, ) } } @@ -240,11 +245,11 @@ class Init : CliktCommand("init") { private fun readModuleName(projectName: String): String { while (true) { - echo("Enter module name (default: composeApp): ", trailingNewline = false) + echo("Enter module name (default: $SHARED_MODULE): ", trailingNewline = false) val moduleName = readln().trim() if (moduleName.isEmpty()) { - return "composeApp" + return SHARED_MODULE } if (moduleName == projectName) { @@ -263,15 +268,15 @@ class Init : CliktCommand("init") { private fun readUniqueModuleName(targetDir: File): String { while (true) { - echo("Enter module name (default: composeApp): ", trailingNewline = false) + echo("Enter module name (default: $SHARED_MODULE): ", trailingNewline = false) val moduleName = readln().trim() if (moduleName.isEmpty()) { - if (File(targetDir, "composeApp").exists()) { - echo("Module name 'composeApp' already exists. Please choose a different name.") + if (File(targetDir, SHARED_MODULE).exists()) { + echo("Module name '$SHARED_MODULE' already exists. Please choose a different name.") continue } - return "composeApp" + return SHARED_MODULE } if (!isValidModuleName(moduleName)) { @@ -630,6 +635,11 @@ class Target : CliktCommand("target") { private fun findComposeModuleBuildFile(workingDir: String): File? { val dir = File(workingDir) + val sharedBuildFile = File(dir, "$SHARED_MODULE/build.gradle.kts") + if (sharedBuildFile.exists()) { + return sharedBuildFile + } + val composeModules = dir.listFiles()?.filter { subDir -> subDir.isDirectory && File(subDir, "build.gradle.kts").exists() }?.filter { subDir -> @@ -686,7 +696,7 @@ class Target : CliktCommand("target") { private fun hasWasmTarget(buildFile: File): Boolean { val content = buildFile.readText() - return content.contains("wasmJs(") + return content.contains("wasmJs") } private fun addAndroidTarget(workingDir: String, buildFile: File) { @@ -724,7 +734,7 @@ class Target : CliktCommand("target") { val androidTargetLines = listOf( "", " androidLibrary {", - " namespace = \"$namespace.composeapp\"", + " namespace = \"$namespace.shared\"", " compileSdk = libs.versions.android.compileSdk.get().toInt()", " minSdk = libs.versions.android.minSdk.get().toInt()", " withJava()", @@ -749,7 +759,7 @@ class Target : CliktCommand("target") { sharedModuleName = moduleName, namespace = namespace, ) - addAndroidAppModuleToSettings(workingDir) + addModuleIncludeToSettings(workingDir, ANDROID_APP_MODULE) // Update root build.gradle.kts updateRootBuildFile(workingDir, setOf(ANDROID)) @@ -858,7 +868,7 @@ class Target : CliktCommand("target") { sharedModuleName: String, namespace: String, ) { - val androidAppDir = File(projectDir, "androidApp") + val androidAppDir = File(projectDir, ANDROID_APP_MODULE) androidAppDir.mkdirs() File(androidAppDir, "build.gradle.kts").writeText( """ @@ -951,9 +961,9 @@ class Target : CliktCommand("target") { return resources } - val resources = listResources("/project/androidApp/src/main") + val resources = listResources("/project/$ANDROID_APP_MODULE/src/main") resources.forEach { resourcePath -> - val targetPath = resourcePath.removePrefix("/project/androidApp/src/main/") + val targetPath = resourcePath.removePrefix("/project/$ANDROID_APP_MODULE/src/main/") .replace("org/example/project", namespace.replace(".", "/")) val targetFile = File(androidAppDir, "src/main/$targetPath") copyResource(resourcePath, targetFile) @@ -973,153 +983,44 @@ class Target : CliktCommand("target") { } } - private fun addAndroidAppModuleToSettings(workingDir: String) { + private fun addModuleIncludeToSettings( + workingDir: String, + moduleName: String, + ) { val settingsFile = File(workingDir, "settings.gradle.kts") if (!settingsFile.exists()) return val content = settingsFile.readText() - if (content.contains("""include(":androidApp")""")) return + if (content.contains("""include(":$moduleName")""")) return - settingsFile.writeText(content.trimEnd() + "\ninclude(\":androidApp\")\n") + settingsFile.writeText(content.trimEnd() + "\ninclude(\":$moduleName\")\n") } private fun addJvmTarget(workingDir: String, buildFile: File) { - var content = buildFile.readText() + val content = buildFile.readText() val lines = content.lines().toMutableList() - // Add import if needed - if (!content.contains("import org.jetbrains.compose.desktop.application.dsl.TargetFormat")) { - val importLine = "import org.jetbrains.compose.desktop.application.dsl.TargetFormat" - // Find the last import line and add after it - val lastImportIndex = lines.indexOfLast { it.startsWith("import ") } - if (lastImportIndex >= 0) { - lines.add(lastImportIndex + 1, importLine) - } else { - // Add before the first non-empty, non-comment line - val firstCodeLine = lines.indexOfFirst { !it.trim().isEmpty() && !it.trim().startsWith("//") } - if (firstCodeLine >= 0) { - lines.add(firstCodeLine, importLine) - } - } - } - // Append to kotlin block val kotlinCloseIndex = findKotlinBlockEnd(lines) if (kotlinCloseIndex >= 0) { lines.add(kotlinCloseIndex, " jvm()") } - // Append to sourceSets block - val sourceSetsCloseIndex = findSourceSetsBlockEnd(lines) - if (sourceSetsCloseIndex >= 0) { - val jvmMainLines = listOf( - "", - " jvmMain.dependencies {", - " implementation(compose.desktop.currentOs)", - " implementation(\"com.composables:ui:0.1.0\")", - " }", - ) - jvmMainLines.reversed().forEach { line -> - lines.add(sourceSetsCloseIndex, line) - } - } - - // Add compose.desktop block at the end - val namespace = extractNamespace(lines) - val desktopBlock = listOf( - "", - "compose.desktop {", - " application {", - " mainClass = \"$namespace.MainKt\"", - "", - " nativeDistributions {", - " targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb)", - " packageName = \"${namespace}\"", - " packageVersion = \"1.0.0\"", - " }", - " }", - "}", - ) - lines.addAll(desktopBlock) - // Write updated content buildFile.writeText(lines.joinToString("\n")) - // Create jvmMain source set and main function val moduleDir = buildFile.parentFile - createJvmSourceSet(moduleDir, namespace) - } - - private fun createJvmSourceSet(moduleDir: File, namespace: String) { - val jvmMainDir = File(moduleDir, "src/jvmMain/kotlin") - val packageDir = File(jvmMainDir, namespace.replace(".", "/")) - - // Create directories - packageDir.mkdirs() - - // Create main.desktop.kt - val mainFile = File(packageDir, "main.desktop.kt") - val mainContent = """@file:JvmName("MainKt") -package $namespace - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.safeDrawingPadding -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.singleWindowApplication -import com.composables.ui.components.Text -import com.composables.ui.theme.ComposablesTheme -import org.jetbrains.compose.ui.tooling.preview.Preview - -fun main() = singleWindowApplication { - DesktopApp() -} - -@Composable -fun DesktopApp() { - ComposablesTheme { - Box( - modifier = Modifier - .safeDrawingPadding() - .fillMaxSize() - .padding(16.dp), - contentAlignment = Alignment.Center - ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterVertically) - ) { - Text( - text = "Hello Beautiful World!", - textAlign = TextAlign.Center, - ) - Text( - text = "Go to main.desktop.kt to edit your app", - textAlign = TextAlign.Center, - ) - Text( - text = "Pro tip: Use the `dev` configuration in your IDE to auto-reload your app when you edit your code", - textAlign = TextAlign.Center - ) - } - } - } -} + val moduleName = moduleDir.name + val namespace = inferNamespace(moduleDir) -@Preview -@Composable -fun DesktopAppPreview() { - DesktopApp() -} -""" - mainFile.writeText(mainContent) + createDesktopAppModule( + projectDir = File(workingDir), + sharedModuleName = moduleName, + namespace = namespace, + ) + addModuleIncludeToSettings(workingDir, DESKTOP_APP_MODULE) + updateRootBuildFile(workingDir, setOf(JVM)) + updateVersionCatalog(workingDir, setOf(JVM)) } private fun addIosTarget(workingDir: String, buildFile: File) { @@ -1258,7 +1159,7 @@ fun IosAppPreview() { } private fun addWasmTarget(workingDir: String, buildFile: File) { - var content = buildFile.readText() + val content = buildFile.readText() val lines = content.lines().toMutableList() // Add imports if needed @@ -1275,19 +1176,6 @@ fun IosAppPreview() { } } - if (!content.contains("import org.jetbrains.kotlin.gradle.targets.js.webpack.KotlinWebpackConfig")) { - val importLine = "import org.jetbrains.kotlin.gradle.targets.js.webpack.KotlinWebpackConfig" - val lastImportIndex = lines.indexOfLast { it.startsWith("import ") } - if (lastImportIndex >= 0) { - lines.add(lastImportIndex + 1, importLine) - } else { - val firstCodeLine = lines.indexOfFirst { !it.trim().isEmpty() && !it.trim().startsWith("//") } - if (firstCodeLine >= 0) { - lines.add(firstCodeLine, importLine) - } - } - } - // Append to kotlin block val kotlinCloseIndex = findKotlinBlockEnd(lines) if (kotlinCloseIndex >= 0) { @@ -1295,20 +1183,7 @@ fun IosAppPreview() { "", " @OptIn(ExperimentalWasmDsl::class)", " wasmJs {", - " browser {", - " val rootDirPath = project.rootDir.path", - " val projectDirPath = project.projectDir.path", - " commonWebpackConfig {", - " outputFileName = \"composeApp.js\"", - " devServer = (devServer ?: KotlinWebpackConfig.DevServer()).apply {", - " static = (static ?: mutableListOf()).apply {", - " add(rootDirPath)", - " add(projectDirPath)", - " }", - " }", - " }", - " }", - " binaries.executable()", + " browser()", " }", ) wasmTargetLines.reversed().forEach { line -> @@ -1320,74 +1195,48 @@ fun IosAppPreview() { buildFile.writeText(lines.joinToString("\n")) val moduleDir = buildFile.parentFile + val moduleName = moduleDir.name + val namespace = inferNamespace(moduleDir) - // Copy webpack.config.d directory - copyWebpackConfigDirectory(moduleDir) - - // Copy resources directory - copyWasmResourcesDirectory(moduleDir) + createWebAppModule( + projectDir = File(workingDir), + sharedModuleName = moduleName, + namespace = namespace, + ) + addModuleIncludeToSettings(workingDir, WEB_APP_MODULE) + updateRootBuildFile(workingDir, setOf(WASM)) + updateVersionCatalog(workingDir, setOf(WASM)) } - private fun copyWebpackConfigDirectory(moduleDir: File) { - val targetDir = File(moduleDir, "webpack.config.d") - - fun copyResource(resourcePath: String, targetFile: File) { - val inputStream: InputStream? = object {}.javaClass.getResourceAsStream(resourcePath) - if (inputStream != null) { - targetFile.parentFile?.mkdirs() - inputStream.use { input -> - targetFile.outputStream().use { output -> - input.copyTo(output) - } - } - } - } - - fun listResources(path: String): List { - val resources = mutableListOf() - val resourceUrl = object {}.javaClass.getResource(path) - - if (resourceUrl != null) { - when (resourceUrl.protocol) { - "file" -> { - val dir = File(resourceUrl.toURI()) - dir.walkTopDown().forEach { file -> - if (file.isFile) { - val relativePath = file.relativeTo(dir) - resources.add("$path/${relativePath.toResourcePath()}") - } - } - } - - "jar" -> { - val jarPath = resourceUrl.path.substringBefore("!") - val jarFile = JarFile(File(jarPath.substringAfter("file:"))) - val entries = jarFile.entries() - - while (entries.hasMoreElements()) { - val entry = entries.nextElement() - if (entry.name.startsWith(path.substring(1)) && !entry.isDirectory) { - resources.add("/${entry.name}") - } - } - jarFile.close() - } - } - } - - return resources - } + private fun createDesktopAppModule( + projectDir: File, + sharedModuleName: String, + namespace: String, + ) { + val desktopAppDir = File(projectDir, DESKTOP_APP_MODULE) + desktopAppDir.mkdirs() + File(desktopAppDir, "build.gradle.kts").writeText( + object {}.javaClass.getResource("/project/$DESKTOP_APP_MODULE/build.gradle.kts")!!.readText() + .replace("{{shared_module_accessor}}", toProjectAccessorName(sharedModuleName)) + .replace("{{namespace}}", namespace) + .trim() + "\n", + ) - val resources = listResources("/project/composeApp/webpack.config.d") - resources.forEach { resourcePath -> - val targetPath = resourcePath.removePrefix("/project/composeApp/webpack.config.d/") - val targetFile = targetDir.resolve(targetPath) - copyResource(resourcePath, targetFile) - } + val mainFile = File(desktopAppDir, "src/jvmMain/kotlin/${namespace.replace(".", "/")}/main.kt") + mainFile.parentFile.mkdirs() + mainFile.writeText( + object {}.javaClass.getResource("/project/$DESKTOP_APP_MODULE/src/jvmMain/kotlin/org/example/main.kt")!!.readText() + .replace("{{namespace}}", namespace) + .trim() + "\n", + ) } - private fun copyWasmResourcesDirectory(moduleDir: File) { - val targetDir = File(moduleDir, "src/wasmJsMain/resources") + private fun createWebAppModule( + projectDir: File, + sharedModuleName: String, + namespace: String, + ) { + val webAppDir = File(projectDir, WEB_APP_MODULE) fun copyResource(resourcePath: String, targetFile: File) { val inputStream: InputStream? = object {}.javaClass.getResourceAsStream(resourcePath) @@ -1436,30 +1285,34 @@ fun IosAppPreview() { return resources } - val resources = listResources("/project/composeApp/src/wasmJsMain/resources") - resources.forEach { resourcePath -> - val targetPath = resourcePath.removePrefix("/project/composeApp/src/wasmJsMain/resources/") - val targetFile = targetDir.resolve(targetPath) + listResources("/project/$WEB_APP_MODULE").forEach { resourcePath -> + var targetPath = resourcePath.removePrefix("/project/$WEB_APP_MODULE/") + targetPath = targetPath.replace("org/example", namespace.replace(".", "/")) + val targetFile = webAppDir.resolve(targetPath) copyResource(resourcePath, targetFile) - // Replace placeholders in text files - if (targetFile.name.endsWith(".html") || targetFile.name.endsWith(".css") || targetFile.name.endsWith(".js")) { - try { - val content = targetFile.readText() - var updatedContent = content.replace("{{app_name}}", "ComposeApp") - if (content != updatedContent) { - targetFile.writeText(updatedContent) - } - } catch (e: Exception) { - // Skip binary files + if (targetFile.isFile && + ( + targetFile.extension == "kts" || + targetFile.extension == "kt" || + targetFile.extension == "html" || + targetFile.extension == "css" || + targetFile.extension == "js" + ) + ) { + val content = targetFile.readText() + val updatedContent = content + .replace("{{shared_module_accessor}}", toProjectAccessorName(sharedModuleName)) + .replace("{{namespace}}", namespace) + if (content != updatedContent) { + targetFile.writeText(updatedContent.trim() + "\n") } } } } private fun copyIosAppDirectory(workingDir: String, moduleName: String) { - val iosAppName = "ios${toCamelCase(moduleName)}" // Dynamic iOS app name based on module - val targetDir = File(workingDir, iosAppName) // iOS app directory name based on module + val targetDir = File(workingDir, IOS_APP_MODULE) fun copyResource(resourcePath: String, targetFile: File) { val inputStream: InputStream? = object {}.javaClass.getResourceAsStream(resourcePath) @@ -1508,9 +1361,9 @@ fun IosAppPreview() { return resources } - val resources = listResources("/project/iosApp") + val resources = listResources("/project/$IOS_APP_MODULE") resources.forEach { resourcePath -> - val targetPath = resourcePath.removePrefix("/project/iosApp/") + val targetPath = resourcePath.removePrefix("/project/$IOS_APP_MODULE/") val targetFile = targetDir.resolve(targetPath) copyResource(resourcePath, targetFile) @@ -1527,7 +1380,7 @@ fun IosAppPreview() { val content = targetFile.readText() var updatedContent = content.replace("{{module_name}}", moduleName) updatedContent = updatedContent.replace("{{ios_binary_name}}", toCamelCase(moduleName)) - updatedContent = updatedContent.replace("{{target_name}}", "${toCamelCase(moduleName)}.app") + updatedContent = updatedContent.replace("{{target_name}}", "$IOS_APP_MODULE.app") // For target command, use hardcoded defaults since appName/namespace aren't in scope updatedContent = updatedContent.replace("{{app_name}}", "My App") updatedContent = updatedContent.replace("{{namespace}}", "com.example.app") @@ -1570,7 +1423,7 @@ fun cloneGradleProjectAndPrint( infoln { "Project Configuration:" } infoln { "\tApp Name: $appName" } infoln { "\tPackage: $packageName" } - infoln { "\tCompose Module: $moduleName" } + infoln { "\tShared Module: $moduleName" } infoln { "\tTargets: ${targets.joinToString(", ")}" } infoln { "" } @@ -1578,7 +1431,12 @@ fun cloneGradleProjectAndPrint( debugln { "Start by typing:" } infoln { "" } infoln { "\tcd ${target.absolutePath}" } - infoln { "\t$gradleScript run" } + val startCommand = when { + targets.contains(JVM) -> "$gradleScript :$DESKTOP_APP_MODULE:run" + targets.contains(WASM) -> "$gradleScript :$WEB_APP_MODULE:wasmJsBrowserDevelopmentRun" + else -> "$gradleScript build" + } + infoln { "\t$startCommand" } infoln { "" } debugln { "Happy coding!" } } @@ -1680,18 +1538,23 @@ private fun cloneGradleProjectAt( var targetPath = resourcePath.removePrefix("/project/") // Skip iOS directory if iOS target is not selected - val iosAppName = "ios${toCamelCase(moduleName)}" - if (!normalizedTargets.contains(IOS) && targetPath.startsWith("iosApp/")) { + if (!normalizedTargets.contains(IOS) && targetPath.startsWith("$IOS_APP_MODULE/")) { + return@forEach + } + if (!normalizedTargets.contains(ANDROID) && targetPath.startsWith("$ANDROID_APP_MODULE/")) { + return@forEach + } + if (!normalizedTargets.contains(JVM) && targetPath.startsWith("$DESKTOP_APP_MODULE/")) { return@forEach } - if (!normalizedTargets.contains(ANDROID) && targetPath.startsWith("androidApp/")) { + if (!normalizedTargets.contains(WASM) && targetPath.startsWith("$WEB_APP_MODULE/")) { return@forEach } // Skip source set directories if corresponding target is not selected - val isInsideAKotlinSourceSet = targetPath.startsWith("composeApp/src/") + val isInsideAKotlinSourceSet = targetPath.startsWith("$SHARED_MODULE/src/") if (isInsideAKotlinSourceSet) { - val sourceSetType = targetPath.substringAfter("composeApp/src/").substringBefore("/") + val sourceSetType = targetPath.substringAfter("$SHARED_MODULE/src/").substringBefore("/") when (sourceSetType) { "androidMain" -> if (!normalizedTargets.contains(ANDROID)) return@forEach @@ -1703,22 +1566,12 @@ private fun cloneGradleProjectAt( } } - if (!normalizedTargets.contains(WASM) && targetPath.startsWith("$moduleName/webpack.config.d/")) { - return@forEach - } - // Replace org.example.project with the actual namespace in file paths targetPath = targetPath.replace("org/example/project", packageName.replace(".", "/")) + targetPath = targetPath.replace("org/example", packageName.replace(".", "/")) - // Replace composeApp with the actual module name in file paths - targetPath = targetPath.replace("composeApp", moduleName) - - // Replace only the top-level iosApp directory with the dynamic iOS app name - if (targetPath.startsWith("iosApp/")) { - val newPath = iosAppName + "/" + targetPath.removePrefix("iosApp/") -// .replace("iosApp/", iosAppName + "/") - targetPath = newPath - } + // Replace shared module directory when a custom name is requested + targetPath = targetPath.replace(SHARED_MODULE, moduleName) val targetFile = target.resolve(targetPath) copyResource(resourcePath, targetFile) @@ -1792,7 +1645,9 @@ android.useAndroidX=true "" } - val androidInclude = if (normalizedTargets.contains(ANDROID)) """include(":androidApp")""" else "" + val androidInclude = if (normalizedTargets.contains(ANDROID)) """include(":$ANDROID_APP_MODULE")""" else "" + val desktopInclude = if (normalizedTargets.contains(JVM)) """include(":$DESKTOP_APP_MODULE")""" else "" + val webInclude = if (normalizedTargets.contains(WASM)) """include(":$WEB_APP_MODULE")""" else "" val webPreloadTaskWiring = if (normalizedTargets.contains(WASM)) { """ @@ -1863,15 +1718,11 @@ subprojects { // Build imports block val imports = mutableListOf() - if (normalizedTargets.contains(JVM)) { - imports.add("import org.jetbrains.compose.desktop.application.dsl.TargetFormat") + if (normalizedTargets.contains(ANDROID)) { + imports.add("import org.jetbrains.kotlin.gradle.dsl.JvmTarget") } if (normalizedTargets.contains(WASM)) { imports.add("import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl") - imports.add("import org.jetbrains.kotlin.gradle.targets.js.webpack.KotlinWebpackConfig") - } - if (normalizedTargets.contains(ANDROID)) { - imports.add("import org.jetbrains.kotlin.gradle.dsl.JvmTarget") } val importsBlock = if (imports.isNotEmpty()) imports.joinToString("\n") + "\n" else "" @@ -1883,7 +1734,6 @@ subprojects { if (!content.contains("libs.plugins.kotlin.compose")) { plugins.add(" alias(libs.plugins.jetbrains.compose.compiler)") } - plugins.add(" alias(libs.plugins.jetbrains.compose.hotreload)") if (normalizedTargets.contains(ANDROID)) { plugins.add(" alias(libs.plugins.android.kotlin.multiplatform.library)") } @@ -1894,7 +1744,7 @@ subprojects { if (normalizedTargets.contains(ANDROID)) { kotlinTargets.add( """ androidLibrary { - namespace = "{{namespace}}.composeapp" + namespace = "{{namespace}}.shared" compileSdk = libs.versions.android.compileSdk.get().toInt() minSdk = libs.versions.android.minSdk.get().toInt() withJava() @@ -1928,21 +1778,7 @@ subprojects { kotlinTargets.add( """ @OptIn(ExperimentalWasmDsl::class) wasmJs { - browser { - val rootDirPath = project.rootDir.path - val projectDirPath = project.projectDir.path - commonWebpackConfig { - outputFileName = "composeApp.js" - devServer = (devServer ?: KotlinWebpackConfig.DevServer()).apply { - static = (static ?: mutableListOf()).apply { - // Serve sources to debug inside browser - add(rootDirPath) - add(projectDirPath) - } - } - } - } - binaries.executable() + browser() }""", ) } @@ -1959,45 +1795,23 @@ subprojects { }""", ) - if (normalizedTargets.contains(JVM)) { - sourcesets.add( - """ jvmMain.dependencies { - implementation(compose.desktop.currentOs) - }""", - ) - } sourcesets.add(" }") val sourcesetsBlock = sourcesets.joinToString("\n") // Build configuration blocks - val configurations = mutableListOf() - if (normalizedTargets.contains(JVM)) { - configurations.add( - """compose.desktop { - application { - mainClass = "{{namespace}}.MainKt" - - nativeDistributions { - targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb) - packageName = "{{namespace}}" - packageVersion = "1.0.0" - } - } -}""", - ) - } - val configurationBlocksBlock = - if (configurations.isNotEmpty()) configurations.joinToString("\n\n") else "" + val configurationBlocksBlock = "" updatedContent = updatedContent.replace("{{android_versions}}", androidVersions) updatedContent = updatedContent.replace("{{android_libraries}}", androidLibraries) updatedContent = updatedContent.replace("{{android_plugins}}", androidPlugins) updatedContent = updatedContent.replace("{{android_root_plugins}}", androidRootPlugins) updatedContent = updatedContent.replace("{{android_include}}", androidInclude) + updatedContent = updatedContent.replace("{{desktop_include}}", desktopInclude) + updatedContent = updatedContent.replace("{{web_include}}", webInclude) updatedContent = updatedContent.replace("{{android_properties}}", androidProperties) updatedContent = updatedContent.replace("{{web_preload_task_wiring}}", webPreloadTaskWiring) - // Replace composeApp build.gradle.kts blocks + // Replace shared build.gradle.kts blocks updatedContent = updatedContent.replace("{{imports}}", importsBlock) updatedContent = updatedContent.replace("{{plugins}}", pluginsBlock) updatedContent = updatedContent.replace("{{kotlin_targets}}", kotlinTargetsBlock) @@ -2008,10 +1822,11 @@ subprojects { updatedContent = updatedContent.replace("{{namespace}}", packageName) updatedContent = updatedContent.replace("{{project_name}}", target.name) updatedContent = updatedContent.replace("{{module_name}}", moduleName) + updatedContent = updatedContent.replace("{{shared_module_name}}", moduleName) updatedContent = updatedContent.replace("{{app_name}}", appName) updatedContent = updatedContent.replace("{{shared_module_accessor}}", toProjectAccessorName(moduleName)) updatedContent = updatedContent.replace("{{ios_binary_name}}", toCamelCase(moduleName)) - updatedContent = updatedContent.replace("{{target_name}}", toCamelCase(moduleName) + ".app") + updatedContent = updatedContent.replace("{{target_name}}", "$IOS_APP_MODULE.app") if (content != updatedContent) { file.writeText(updatedContent.trim() + "\n") } @@ -2127,7 +1942,75 @@ fun updateRootBuildFile( } if (modified) { - buildFile.writeText(lines.joinToString("\n")) + content = lines.joinToString("\n") + buildFile.writeText(content) + } + + if (normalizedTargets.contains(WASM) && !content.contains("injectWasmPreloads")) { + buildFile.writeText( + buildFile.readText().trimEnd() + "\n\n" + """ +subprojects { + fun registerPreloadInjectionTask( + distributionTarget: String, + markerName: String, + includeWasmArtifacts: Boolean, + ) = tasks.register("inject${'$'}{distributionTarget.replaceFirstChar(Char::titlecase)}Preloads") { + description = "Injects preload links for generated ${'$'}distributionTarget distribution artifacts." + val distributionDir = layout.buildDirectory.dir("dist/${'$'}distributionTarget/productionExecutable") + val preloadMarker = markerName + val preloadWasmArtifacts = includeWasmArtifacts + + doLast { + val distDir = distributionDir.get().asFile + val indexFile = distDir.resolve("index.html") + if (!indexFile.isFile) return@doLast + + val scriptPreloads = distDir + .listFiles { file -> file.isFile && file.extension == "js" } + .orEmpty() + .sortedBy { it.name } + .map { " " } + + val artifactPreloads = if (preloadWasmArtifacts) { + distDir + .listFiles { file -> file.isFile && file.extension == "wasm" } + .orEmpty() + .sortedBy { it.name } + .map { + " " + } + } else { + emptyList() + } + + val preloadBlock = (scriptPreloads + artifactPreloads).joinToString( + separator = "\n", + prefix = " \n", + postfix = "\n ", + ) + + val existingPreloadBlock = Regex( + pattern = "\\n? .*? \\n?", + options = setOf(RegexOption.DOT_MATCHES_ALL), + ) + val indexHtml = indexFile.readText().replace(existingPreloadBlock, "\n") + val updatedIndexHtml = indexHtml.replaceFirst("", "\n${'$'}preloadBlock") + indexFile.writeText(updatedIndexHtml) + } + } + + val injectWasmPreloads = registerPreloadInjectionTask( + distributionTarget = "wasmJs", + markerName = "wasm-preloads", + includeWasmArtifacts = true, + ) + + tasks.matching { it.name == "wasmJsBrowserDistribution" }.configureEach { + finalizedBy(injectWasmPreloads) + } +} + """.trimIndent() + "\n", + ) } } @@ -2369,9 +2252,9 @@ fun createModuleOnly( } // Copy only the module contents (not the entire project) - val resources = listResources("/project/composeApp") + val resources = listResources("/project/$SHARED_MODULE") resources.forEach { resourcePath -> - var targetPath = resourcePath.removePrefix("/project/composeApp/") + var targetPath = resourcePath.removePrefix("/project/$SHARED_MODULE/") // Skip source set directories if corresponding target is not selected val isInsideAKotlinSourceSet = targetPath.startsWith("src/") @@ -2387,12 +2270,9 @@ fun createModuleOnly( } } - if (!normalizedTargets.contains(WASM) && targetPath.startsWith("webpack.config.d/")) { - return@forEach - } - // Replace org.example.project with the actual namespace in file paths targetPath = targetPath.replace("org/example/project", packageName.replace(".", "/")) + targetPath = targetPath.replace("org/example", packageName.replace(".", "/")) val targetFile = moduleDir.resolve(targetPath) copyResource(resourcePath, targetFile) @@ -2419,12 +2299,8 @@ fun createModuleOnly( // Build imports block val imports = mutableListOf() - if (normalizedTargets.contains(JVM)) { - imports.add("import org.jetbrains.compose.desktop.application.dsl.TargetFormat") - } if (normalizedTargets.contains(WASM)) { imports.add("import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl") - imports.add("import org.jetbrains.kotlin.gradle.targets.js.webpack.KotlinWebpackConfig") } if (normalizedTargets.contains(ANDROID)) { imports.add("import org.jetbrains.kotlin.gradle.dsl.JvmTarget") @@ -2439,7 +2315,6 @@ fun createModuleOnly( if (!content.contains("libs.plugins.kotlin.compose")) { plugins.add(" alias(libs.plugins.jetbrains.compose.compiler)") } - plugins.add(" alias(libs.plugins.jetbrains.compose.hotreload)") if (normalizedTargets.contains(ANDROID)) { plugins.add(" alias(libs.plugins.android.kotlin.multiplatform.library)") } @@ -2450,7 +2325,7 @@ fun createModuleOnly( if (normalizedTargets.contains(ANDROID)) { kotlinTargets.add( """ androidLibrary { - namespace = "{{namespace}}.composeapp" + namespace = "{{namespace}}.shared" compileSdk = libs.versions.android.compileSdk.get().toInt() minSdk = libs.versions.android.minSdk.get().toInt() withJava() @@ -2484,21 +2359,7 @@ fun createModuleOnly( kotlinTargets.add( """ @OptIn(ExperimentalWasmDsl::class) wasmJs { - browser { - val rootDirPath = project.rootDir.path - val projectDirPath = project.projectDir.path - commonWebpackConfig { - outputFileName = "composeApp.js" - devServer = (devServer ?: KotlinWebpackConfig.DevServer()).apply { - static = (static ?: mutableListOf()).apply { - // Serve sources to debug inside browser - add(rootDirPath) - add(projectDirPath) - } - } - } - } - binaries.executable() + browser() }""", ) } @@ -2515,37 +2376,13 @@ fun createModuleOnly( }""", ) - if (normalizedTargets.contains(JVM)) { - sourcesets.add( - """ jvmMain.dependencies { - implementation(compose.desktop.currentOs) - }""", - ) - } sourcesets.add(" }") val sourcesetsBlock = sourcesets.joinToString("\n") // Build configuration blocks - val configurations = mutableListOf() - if (normalizedTargets.contains(JVM)) { - configurations.add( - """compose.desktop { - application { - mainClass = "{{namespace}}.MainKt" - - nativeDistributions { - targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb) - packageName = "{{namespace}}" - packageVersion = "1.0.0" - } - } -}""", - ) - } - val configurationBlocksBlock = - if (configurations.isNotEmpty()) configurations.joinToString("\n\n") else "" + val configurationBlocksBlock = "" - // Replace composeApp build.gradle.kts blocks + // Replace shared build.gradle.kts blocks updatedContent = updatedContent.replace("{{imports}}", importsBlock) updatedContent = updatedContent.replace("{{plugins}}", pluginsBlock) updatedContent = updatedContent.replace("{{kotlin_targets}}", kotlinTargetsBlock) @@ -2558,7 +2395,7 @@ fun createModuleOnly( updatedContent = updatedContent.replace("{{app_name}}", appName) updatedContent = updatedContent.replace("{{shared_module_accessor}}", toProjectAccessorName(moduleName)) updatedContent = updatedContent.replace("{{ios_binary_name}}", toCamelCase(moduleName)) - updatedContent = updatedContent.replace("{{target_name}}", "${toCamelCase(moduleName)}.app") + updatedContent = updatedContent.replace("{{target_name}}", "$IOS_APP_MODULE.app") if (content != updatedContent) { file.writeText(updatedContent.trim() + "\n") } @@ -2585,8 +2422,7 @@ private fun createIosAppDirectory( targetDir: String, moduleName: String, ) { - val iosAppName = "ios${toCamelCase(moduleName)}" - val targetDir = File(targetDir, iosAppName) + val targetDir = File(targetDir, IOS_APP_MODULE) fun copyResource(resourcePath: String, targetFile: File) { val inputStream: InputStream? = object {}.javaClass.getResourceAsStream(resourcePath) @@ -2635,9 +2471,9 @@ private fun createIosAppDirectory( return resources } - val resources = listResources("/project/iosApp") + val resources = listResources("/project/$IOS_APP_MODULE") resources.forEach { resourcePath -> - val targetPath = resourcePath.removePrefix("/project/iosApp/") + val targetPath = resourcePath.removePrefix("/project/$IOS_APP_MODULE/") val targetFile = targetDir.resolve(targetPath) copyResource(resourcePath, targetFile) @@ -2652,7 +2488,7 @@ private fun createIosAppDirectory( val content = targetFile.readText() var updatedContent = content.replace("{{module_name}}", moduleName) updatedContent = updatedContent.replace("{{ios_binary_name}}", toCamelCase(moduleName)) - updatedContent = updatedContent.replace("{{target_name}}", "${toCamelCase(moduleName)}.app") + updatedContent = updatedContent.replace("{{target_name}}", "$IOS_APP_MODULE.app") // Use defaults for module addition since we don't have app name/namespace in scope updatedContent = updatedContent.replace("{{app_name}}", "My App") updatedContent = updatedContent.replace("{{namespace}}", "com.example.app") diff --git a/cli/src/jvmMain/resources/project/build.gradle.kts b/cli/src/jvmMain/resources/project/build.gradle.kts index 4d9942c..79f5e91 100644 --- a/cli/src/jvmMain/resources/project/build.gradle.kts +++ b/cli/src/jvmMain/resources/project/build.gradle.kts @@ -5,6 +5,7 @@ plugins { alias(libs.plugins.jetbrains.compose.hotreload) apply false alias(libs.plugins.jetbrains.compose) apply false alias(libs.plugins.jetbrains.compose.compiler) apply false -{{android_root_plugins}}} +{{android_root_plugins}} +} {{web_preload_task_wiring}} diff --git a/cli/src/jvmMain/resources/project/desktopApp/build.gradle.kts b/cli/src/jvmMain/resources/project/desktopApp/build.gradle.kts new file mode 100644 index 0000000..f0a31ba --- /dev/null +++ b/cli/src/jvmMain/resources/project/desktopApp/build.gradle.kts @@ -0,0 +1,31 @@ +import org.jetbrains.compose.desktop.application.dsl.TargetFormat + +plugins { + alias(libs.plugins.jetbrains.kotlin.multiplatform) + alias(libs.plugins.jetbrains.compose) + alias(libs.plugins.jetbrains.compose.compiler) + alias(libs.plugins.jetbrains.compose.hotreload) +} + +kotlin { + jvm() + + sourceSets { + jvmMain.dependencies { + implementation(projects.{{shared_module_accessor}}) + implementation(compose.desktop.currentOs) + } + } +} + +compose.desktop { + application { + mainClass = "{{namespace}}.MainKt" + + nativeDistributions { + targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb) + packageName = "{{namespace}}" + packageVersion = "1.0.0" + } + } +} diff --git a/cli/src/jvmMain/resources/project/composeApp/src/jvmMain/kotlin/org/example/main.kt b/cli/src/jvmMain/resources/project/desktopApp/src/jvmMain/kotlin/org/example/main.kt similarity index 100% rename from cli/src/jvmMain/resources/project/composeApp/src/jvmMain/kotlin/org/example/main.kt rename to cli/src/jvmMain/resources/project/desktopApp/src/jvmMain/kotlin/org/example/main.kt diff --git a/cli/src/jvmMain/resources/project/settings.gradle.kts b/cli/src/jvmMain/resources/project/settings.gradle.kts index e9315f0..d6c5eb0 100644 --- a/cli/src/jvmMain/resources/project/settings.gradle.kts +++ b/cli/src/jvmMain/resources/project/settings.gradle.kts @@ -35,5 +35,7 @@ plugins { id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0" } -include(":{{module_name}}") +include(":{{shared_module_name}}") {{android_include}} +{{desktop_include}} +{{web_include}} diff --git a/cli/src/jvmMain/resources/project/composeApp/build.gradle.kts b/cli/src/jvmMain/resources/project/shared/build.gradle.kts similarity index 100% rename from cli/src/jvmMain/resources/project/composeApp/build.gradle.kts rename to cli/src/jvmMain/resources/project/shared/build.gradle.kts diff --git a/cli/src/jvmMain/resources/project/composeApp/src/commonMain/kotlin/org/example/project/App.kt b/cli/src/jvmMain/resources/project/shared/src/commonMain/kotlin/org/example/project/App.kt similarity index 100% rename from cli/src/jvmMain/resources/project/composeApp/src/commonMain/kotlin/org/example/project/App.kt rename to cli/src/jvmMain/resources/project/shared/src/commonMain/kotlin/org/example/project/App.kt diff --git a/cli/src/jvmMain/resources/project/composeApp/src/iosMain/kotlin/org/example/project/MainViewController.kt b/cli/src/jvmMain/resources/project/shared/src/iosMain/kotlin/org/example/project/MainViewController.kt similarity index 100% rename from cli/src/jvmMain/resources/project/composeApp/src/iosMain/kotlin/org/example/project/MainViewController.kt rename to cli/src/jvmMain/resources/project/shared/src/iosMain/kotlin/org/example/project/MainViewController.kt diff --git a/cli/src/jvmMain/resources/project/webApp/build.gradle.kts b/cli/src/jvmMain/resources/project/webApp/build.gradle.kts new file mode 100644 index 0000000..9015f65 --- /dev/null +++ b/cli/src/jvmMain/resources/project/webApp/build.gradle.kts @@ -0,0 +1,35 @@ +import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl +import org.jetbrains.kotlin.gradle.targets.js.webpack.KotlinWebpackConfig + +plugins { + alias(libs.plugins.jetbrains.kotlin.multiplatform) + alias(libs.plugins.jetbrains.compose) + alias(libs.plugins.jetbrains.compose.compiler) +} + +kotlin { + @OptIn(ExperimentalWasmDsl::class) + wasmJs { + browser { + val rootDirPath = project.rootDir.path + val projectDirPath = project.projectDir.path + commonWebpackConfig { + outputFileName = "webApp.js" + devServer = (devServer ?: KotlinWebpackConfig.DevServer()).apply { + static = (static ?: mutableListOf()).apply { + add(rootDirPath) + add(projectDirPath) + } + } + } + } + binaries.executable() + } + + sourceSets { + wasmJsMain.dependencies { + implementation(compose.ui) + implementation(projects.{{shared_module_accessor}}) + } + } +} diff --git a/cli/src/jvmMain/resources/project/composeApp/src/wasmJsMain/kotlin/org/example/main.kt b/cli/src/jvmMain/resources/project/webApp/src/wasmJsMain/kotlin/org/example/main.kt similarity index 100% rename from cli/src/jvmMain/resources/project/composeApp/src/wasmJsMain/kotlin/org/example/main.kt rename to cli/src/jvmMain/resources/project/webApp/src/wasmJsMain/kotlin/org/example/main.kt diff --git a/cli/src/jvmMain/resources/project/composeApp/src/wasmJsMain/resources/index.html b/cli/src/jvmMain/resources/project/webApp/src/wasmJsMain/resources/index.html similarity index 78% rename from cli/src/jvmMain/resources/project/composeApp/src/wasmJsMain/resources/index.html rename to cli/src/jvmMain/resources/project/webApp/src/wasmJsMain/resources/index.html index eba44b4..e119fbf 100644 --- a/cli/src/jvmMain/resources/project/composeApp/src/wasmJsMain/resources/index.html +++ b/cli/src/jvmMain/resources/project/webApp/src/wasmJsMain/resources/index.html @@ -5,7 +5,7 @@ {{app_name}} - + diff --git a/cli/src/jvmMain/resources/project/composeApp/src/wasmJsMain/resources/styles.css b/cli/src/jvmMain/resources/project/webApp/src/wasmJsMain/resources/styles.css similarity index 100% rename from cli/src/jvmMain/resources/project/composeApp/src/wasmJsMain/resources/styles.css rename to cli/src/jvmMain/resources/project/webApp/src/wasmJsMain/resources/styles.css diff --git a/cli/src/jvmMain/resources/project/composeApp/webpack.config.d/watch.js b/cli/src/jvmMain/resources/project/webApp/webpack.config.d/watch.js similarity index 100% rename from cli/src/jvmMain/resources/project/composeApp/webpack.config.d/watch.js rename to cli/src/jvmMain/resources/project/webApp/webpack.config.d/watch.js diff --git a/cli/src/jvmTest/kotlin/com/composables/cli/CliTest.kt b/cli/src/jvmTest/kotlin/com/composables/cli/CliTest.kt index 0e1d491..87f4f1b 100644 --- a/cli/src/jvmTest/kotlin/com/composables/cli/CliTest.kt +++ b/cli/src/jvmTest/kotlin/com/composables/cli/CliTest.kt @@ -22,40 +22,45 @@ class CliTest { targetDir = targetDir.absolutePath, dirName = "newApp", packageName = "com.composables.demo", - moduleName = "desktopApp", + moduleName = "shared", appName = "The App", targets = setOf(JVM), ) val projectDir = File(targetDir, "newApp") - val buildFile = File(projectDir, "desktopApp/build.gradle.kts") + val sharedBuildFile = File(projectDir, "shared/build.gradle.kts") + val desktopBuildFile = File(projectDir, "desktopApp/build.gradle.kts") val rootBuildFile = File(projectDir, "build.gradle.kts") val settingsFile = File(projectDir, "settings.gradle.kts") - val appFile = File(projectDir, "desktopApp/src/commonMain/kotlin/com/composables/demo/App.kt") + val appFile = File(projectDir, "shared/src/commonMain/kotlin/com/composables/demo/App.kt") + val desktopMainFile = File(projectDir, "desktopApp/src/jvmMain/kotlin/com/composables/demo/main.kt") assertThat(projectDir.isDirectory, "Generated project directory should exist").isTrue() - assertThat(buildFile.exists(), "Generated module build file should exist").isTrue() + assertThat(sharedBuildFile.exists(), "Generated shared module build file should exist").isTrue() + assertThat(desktopBuildFile.exists(), "Generated desktop module build file should exist").isTrue() assertThat(settingsFile.exists(), "Generated settings file should exist").isTrue() assertThat(appFile.exists(), "App source should be moved to the requested package").isTrue() + assertThat(desktopMainFile.exists(), "Desktop launcher source should exist").isTrue() - assertThat(File(projectDir, "iosDesktopApp").exists(), "iOS app scaffold should be skipped for JVM-only template runs").isFalse() + assertThat(File(projectDir, "iosApp").exists(), "iOS app scaffold should be skipped for JVM-only template runs").isFalse() assertThat(File(projectDir, "androidApp").exists(), "Android app scaffold should be skipped for JVM-only template runs").isFalse() - assertThat(File(projectDir, "desktopApp/src/androidMain").exists(), "Android sources should be omitted for JVM-only template runs").isFalse() - assertThat(File(projectDir, "desktopApp/src/wasmJsMain").exists(), "Wasm sources should be omitted for JVM-only template runs").isFalse() + assertThat(File(projectDir, "webApp").exists(), "Web app scaffold should be skipped for JVM-only template runs").isFalse() + assertThat(File(projectDir, "shared/src/iosMain").exists(), "iOS sources should be omitted for JVM-only template runs").isFalse() - val buildContent = buildFile.readText() + val sharedBuildContent = sharedBuildFile.readText() + val desktopBuildContent = desktopBuildFile.readText() val rootBuildContent = rootBuildFile.readText() val settingsContent = settingsFile.readText() val appContent = appFile.readText() - assertThat(buildContent).contains("jvm()") - assertThat(buildContent).contains("implementation(libs.composables.ui)") - assertThat(buildContent).doesNotContain("androidTarget {") - assertThat(buildContent).doesNotContain("iosArm64()") - assertThat(buildContent).doesNotContain("wasmJs {") - assertThat(buildContent).doesNotContain("compose.material3") - assertThat(buildContent).contains("mainClass = \"com.composables.demo.MainKt\"") - assertThat(buildContent).doesNotContain("{{module_name}}") + assertThat(sharedBuildContent).contains("jvm()") + assertThat(sharedBuildContent).contains("implementation(libs.composables.ui)") + assertThat(sharedBuildContent).doesNotContain("androidLibrary {") + assertThat(sharedBuildContent).doesNotContain("iosArm64()") + assertThat(sharedBuildContent).doesNotContain("wasmJs()") + assertThat(sharedBuildContent).doesNotContain("{{shared_module_name}}") + assertThat(desktopBuildContent).contains("implementation(projects.shared)") + assertThat(desktopBuildContent).contains("mainClass = \"com.composables.demo.MainKt\"") assertThat(rootBuildContent).doesNotContain("composeCompatibilityBrowserDistribution") assertThat(rootBuildContent).doesNotContain("jsBrowserDistribution") @@ -63,6 +68,7 @@ class CliTest { assertThat(rootBuildContent).doesNotContain("js-preloads") assertThat(rootBuildContent).doesNotContain("wasm-preloads") assertThat(settingsContent).contains("""rootProject.name = "newApp"""") + assertThat(settingsContent).contains("""include(":shared")""") assertThat(settingsContent).contains("""include(":desktopApp")""") assertThat(appContent).contains("package com.composables.demo") @@ -124,6 +130,8 @@ class CliTest { assertThat(androidAppBuildContent).contains("buildFeatures {") assertThat(androidAppBuildContent).contains("implementation(projects.sharedUi)") assertThat(settingsContent).contains("""include(":androidApp")""") + assertThat(settingsContent).contains("""include(":desktopApp")""") + assertThat(settingsContent).contains("""include(":webApp")""") } } @@ -150,22 +158,26 @@ class CliTest { targetDir = targetDir.absolutePath, dirName = "newApp", packageName = "com.composables.demo", - moduleName = "composeApp", + moduleName = "shared", appName = "The App", targets = setOf(WASM), ) val projectDir = File(targetDir, "newApp") val rootBuildContent = File(projectDir, "build.gradle.kts").readText() - val moduleBuildContent = File(projectDir, "composeApp/build.gradle.kts").readText() + val sharedBuildContent = File(projectDir, "shared/build.gradle.kts").readText() + val webAppBuildContent = File(projectDir, "webApp/build.gradle.kts").readText() assertThat(rootBuildContent).contains("wasmJsBrowserDistribution") assertThat(rootBuildContent).contains("wasm-preloads") assertThat(rootBuildContent).doesNotContain("composeCompatibilityBrowserDistribution") assertThat(rootBuildContent).doesNotContain("jsBrowserDistribution") assertThat(rootBuildContent).doesNotContain("js-preloads") - assertThat(moduleBuildContent).doesNotContain("js {") - assertThat(moduleBuildContent).contains("wasmJs {") + assertThat(sharedBuildContent).contains("wasmJs {") + assertThat(sharedBuildContent).contains("browser()") + assertThat(sharedBuildContent).doesNotContain("js {") + assertThat(webAppBuildContent).contains("implementation(compose.ui)") + assertThat(webAppBuildContent).contains("wasmJs {") } } From 514d22db7ea88542f133f7564d081a088b09a49b Mon Sep 17 00:00:00 2001 From: alexstyl <1665273+alexstyl@users.noreply.github.com> Date: Tue, 23 Jun 2026 11:09:39 +0700 Subject: [PATCH 5/9] Simplify template rendering and iOS setup --- cli/src/jvmMain/kotlin/Cli.kt | 606 +++++++++------------------------- 1 file changed, 153 insertions(+), 453 deletions(-) diff --git a/cli/src/jvmMain/kotlin/Cli.kt b/cli/src/jvmMain/kotlin/Cli.kt index aee2901..994bfd5 100644 --- a/cli/src/jvmMain/kotlin/Cli.kt +++ b/cli/src/jvmMain/kotlin/Cli.kt @@ -805,23 +805,6 @@ class Target : CliktCommand("target") { return -1 } - private fun findSourceSetsBlockEnd(lines: List): Int { - var depth = 0 - for (i in lines.indices) { - val line = lines[i].trim() - if (line.contains("sourceSets") && line.contains("{")) { - depth = 1 - for (j in i + 1 until lines.size) { - val currentLine = lines[j].trim() - if (currentLine.contains("{")) depth++ - if (currentLine.contains("}")) depth-- - if (depth == 0) return j - } - } - } - return -1 - } - private fun extractNamespace(lines: List): String { // Try to find existing namespace from android block or use a default for (line in lines) { @@ -1049,20 +1032,6 @@ class Target : CliktCommand("target") { } } - // Append to sourceSets block - val sourceSetsCloseIndex = findSourceSetsBlockEnd(lines) - if (sourceSetsCloseIndex >= 0) { - val iosMainLines = listOf( - "", - " iosMain.dependencies {", - " implementation(\"com.composables:ui:0.1.0\")", - " }", - ) - iosMainLines.reversed().forEach { line -> - lines.add(sourceSetsCloseIndex, line) - } - } - // Write updated content buildFile.writeText(lines.joinToString("\n")) @@ -1071,89 +1040,22 @@ class Target : CliktCommand("target") { createIosSourceSet(moduleDir, extractNamespace(lines)) // Copy iOS app directory - copyIosAppDirectory(workingDir, moduleDir.name) // iOS app is still at root level - - // Link iOS project for IDE - try { - debugln { "Preparing iOS target..." } - val process = ProcessBuilder(gradleScript, "compileIosMainKotlinMetadata", "--quiet") - .directory(File(workingDir)) - .inheritIO() - .start() - val exitCode = process.waitFor() - if (exitCode == 0) { - echo("iOS target is now ready to run from the IDE") - } else { - echo("Warning: Failed to link iOS project for IDE. You may need to run '$gradleScript compileIosMainKotlinMetadata' manually.") - } - } catch (e: Exception) { - echo("Warning: Failed to link iOS project for IDE: ${e.message}") - } + copyIosAppDirectory(workingDir, moduleDir.name) } private fun createIosSourceSet(moduleDir: File, namespace: String) { val iosMainDir = File(moduleDir, "src/iosMain/kotlin") - val packageDir = iosMainDir + val packageDir = File(iosMainDir, namespace.replace(".", "/")) // Create directories packageDir.mkdirs() - // Create IosApp.kt val mainFile = File(packageDir, "MainViewController.kt") - val mainContent = """import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.safeDrawingPadding -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp + val mainContent = """package $namespace + import androidx.compose.ui.window.ComposeUIViewController -import com.composables.ui.components.Text -import com.composables.ui.theme.ComposablesTheme -import org.jetbrains.compose.ui.tooling.preview.Preview - -fun MainViewController() = ComposeUIViewController { IosApp() } - -@Composable -fun IosApp() { - ComposablesTheme { - Box( - modifier = Modifier - .safeDrawingPadding() - .fillMaxSize() - .padding(16.dp), - contentAlignment = Alignment.Center - ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterVertically) - ) { - Text( - text = "Hello Beautiful World!", - textAlign = TextAlign.Center, - ) - Text( - text = "Go to MainViewController.kt to edit your app", - textAlign = TextAlign.Center, - ) - Text( - text = "Pro tip: Use the `dev` configuration in your IDE to auto-reload your app when you edit your code", - textAlign = TextAlign.Center - ) - } - } - } -} -@Preview -@Composable -fun IosAppPreview() { - IosApp() -} +fun MainViewController() = ComposeUIViewController { App() } """ mainFile.writeText(mainContent) } @@ -1594,10 +1496,92 @@ private fun cloneGradleProjectAt( try { val content = file.readText() - var updatedContent = content.replace("{{app_name}}", appName) + val updatedContent = renderProjectTemplate( + content = content, + packageName = packageName, + moduleName = moduleName, + appName = appName, + targets = normalizedTargets, + projectName = target.name, + ) + if (content != updatedContent) { + file.writeText(updatedContent) + } + } catch (e: Exception) { + // If we can't read as text, skip this file + debugln { "Skipping binary file: ${file.name}" } + } + } + } +} - val androidVersions = if (normalizedTargets.contains(ANDROID)) { - """# Android +private fun renderProjectTemplate( + content: String, + packageName: String, + moduleName: String, + appName: String, + targets: Set, + projectName: String = "", +): String { + val normalizedTargets = normalizeTargets(targets) + val imports = buildList { + if (normalizedTargets.contains(ANDROID)) add("import org.jetbrains.kotlin.gradle.dsl.JvmTarget") + if (normalizedTargets.contains(WASM)) add("import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl") + } + val plugins = buildList { + add(" alias(libs.plugins.jetbrains.kotlin.multiplatform)") + add(" alias(libs.plugins.jetbrains.compose)") + if (!content.contains("libs.plugins.kotlin.compose")) { + add(" alias(libs.plugins.jetbrains.compose.compiler)") + } + if (normalizedTargets.contains(ANDROID)) { + add(" alias(libs.plugins.android.kotlin.multiplatform.library)") + } + } + val kotlinTargets = buildList { + if (normalizedTargets.contains(ANDROID)) { + add( + """ androidLibrary { + namespace = "{{namespace}}.shared" + compileSdk = libs.versions.android.compileSdk.get().toInt() + minSdk = libs.versions.android.minSdk.get().toInt() + withJava() + androidResources { + enable = true + } + compilerOptions { + jvmTarget.set(JvmTarget.JVM_17) + } + }""", + ) + } + if (normalizedTargets.contains(IOS)) { + add( + """ listOf( + iosArm64(), + iosSimulatorArm64() + ).forEach { iosTarget -> + iosTarget.binaries.framework { + baseName = "${toCamelCase(moduleName)}" + isStatic = true + } + }""", + ) + } + if (normalizedTargets.contains(JVM)) add(" jvm()") + if (normalizedTargets.contains(WASM)) { + add( + """ @OptIn(ExperimentalWasmDsl::class) + wasmJs { + browser() + }""", + ) + } + } + + val replacements = linkedMapOf( + "{{android_versions}}" to if (normalizedTargets.contains(ANDROID)) { + """# Android agp = "9.2.1" android-compileSdk = "37" android-minSdk = "23" @@ -1605,52 +1589,69 @@ android-targetSdk = "37" activityCompose = "1.13.0" """ - } else { - "" - } - - val androidLibraries = - if (normalizedTargets.contains(ANDROID)) { - """androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } + } else { + "" + }, + "{{android_libraries}}" to if (normalizedTargets.contains(ANDROID)) { + """androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } """ - } else { - "" - } - - val androidPlugins = - if (normalizedTargets.contains(ANDROID)) { - """ + } else { + "" + }, + "{{android_plugins}}" to if (normalizedTargets.contains(ANDROID)) { + """ android-application = { id = "com.android.application", version.ref = "agp" } android-kotlin-multiplatform-library = { id = "com.android.kotlin.multiplatform.library", version.ref = "agp" } - """.trimIndent() - } else { - "" - } - - val androidProperties = if (normalizedTargets.contains(ANDROID)) { - """#Android + """.trimIndent() + } else { + "" + }, + "{{android_root_plugins}}" to if (normalizedTargets.contains(ANDROID)) { + """ alias(libs.plugins.android.application) apply false + alias(libs.plugins.android.kotlin.multiplatform.library) apply false +""" + } else { + "" + }, + "{{android_include}}" to if (normalizedTargets.contains(ANDROID)) """include(":$ANDROID_APP_MODULE")""" else "", + "{{desktop_include}}" to if (normalizedTargets.contains(JVM)) """include(":$DESKTOP_APP_MODULE")""" else "", + "{{web_include}}" to if (normalizedTargets.contains(WASM)) """include(":$WEB_APP_MODULE")""" else "", + "{{android_properties}}" to if (normalizedTargets.contains(ANDROID)) { + """#Android android.nonTransitiveRClass=true android.useAndroidX=true """ - } else { - "" - } - - val androidRootPlugins = if (normalizedTargets.contains(ANDROID)) { - """ alias(libs.plugins.android.application) apply false - alias(libs.plugins.android.kotlin.multiplatform.library) apply false -""" - } else { - "" - } + } else { + "" + }, + "{{web_preload_task_wiring}}" to if (normalizedTargets.contains(WASM)) wasmPreloadTaskWiring() else "", + "{{imports}}" to if (imports.isNotEmpty()) imports.joinToString("\n") + "\n" else "", + "{{plugins}}" to "plugins {\n" + plugins.joinToString("\n") + "\n}", + "{{kotlin_targets}}" to if (kotlinTargets.isNotEmpty()) kotlinTargets.joinToString("\n\n") + "\n" else "", + "{{sourcesets}}" to """ sourceSets { + commonMain.dependencies { + implementation(compose.components.uiToolingPreview) + implementation(libs.composables.ui) + } + }""", + "{{configuration_blocks}}" to "", + "{{namespace}}" to packageName, + "{{project_name}}" to projectName, + "{{module_name}}" to moduleName, + "{{shared_module_name}}" to moduleName, + "{{app_name}}" to appName, + "{{shared_module_accessor}}" to toProjectAccessorName(moduleName), + "{{ios_binary_name}}" to toCamelCase(moduleName), + "{{target_name}}" to "$IOS_APP_MODULE.app", + ) - val androidInclude = if (normalizedTargets.contains(ANDROID)) """include(":$ANDROID_APP_MODULE")""" else "" - val desktopInclude = if (normalizedTargets.contains(JVM)) """include(":$DESKTOP_APP_MODULE")""" else "" - val webInclude = if (normalizedTargets.contains(WASM)) """include(":$WEB_APP_MODULE")""" else "" + return replacements.entries.fold(content) { updated, (placeholder, value) -> + updated.replace(placeholder, value) + }.trim() + "\n" +} - val webPreloadTaskWiring = if (normalizedTargets.contains(WASM)) { - """ +private fun wasmPreloadTaskWiring(): String = """ subprojects { fun registerPreloadInjectionTask( distributionTarget: String, @@ -1711,151 +1712,7 @@ subprojects { finalizedBy(injectWasmPreloads) } } - """.trimIndent() - } else { - "" - } - - // Build imports block - val imports = mutableListOf() - if (normalizedTargets.contains(ANDROID)) { - imports.add("import org.jetbrains.kotlin.gradle.dsl.JvmTarget") - } - if (normalizedTargets.contains(WASM)) { - imports.add("import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl") - } - val importsBlock = if (imports.isNotEmpty()) imports.joinToString("\n") + "\n" else "" - - // Build plugins block - val plugins = mutableListOf() - plugins.add(" alias(libs.plugins.jetbrains.kotlin.multiplatform)") - plugins.add(" alias(libs.plugins.jetbrains.compose)") - // Only add compose compiler if kotlin compose plugin is not already present - if (!content.contains("libs.plugins.kotlin.compose")) { - plugins.add(" alias(libs.plugins.jetbrains.compose.compiler)") - } - if (normalizedTargets.contains(ANDROID)) { - plugins.add(" alias(libs.plugins.android.kotlin.multiplatform.library)") - } - val pluginsBlock = "plugins {\n" + plugins.joinToString("\n") + "\n}" - - // Build kotlin targets block - val kotlinTargets = mutableListOf() - if (normalizedTargets.contains(ANDROID)) { - kotlinTargets.add( - """ androidLibrary { - namespace = "{{namespace}}.shared" - compileSdk = libs.versions.android.compileSdk.get().toInt() - minSdk = libs.versions.android.minSdk.get().toInt() - withJava() - androidResources { - enable = true - } - compilerOptions { - jvmTarget.set(JvmTarget.JVM_17) - } - }""", - ) - } - if (normalizedTargets.contains(IOS)) { - val baseName = toCamelCase(moduleName) - kotlinTargets.add( - """ listOf( - iosArm64(), - iosSimulatorArm64() - ).forEach { iosTarget -> - iosTarget.binaries.framework { - baseName = "$baseName" - isStatic = true - } - }""", - ) - } - if (normalizedTargets.contains(JVM)) { - kotlinTargets.add(" jvm()") - } - if (normalizedTargets.contains(WASM)) { - kotlinTargets.add( - """ @OptIn(ExperimentalWasmDsl::class) - wasmJs { - browser() - }""", - ) - } - val kotlinTargetsBlock = - if (kotlinTargets.isNotEmpty()) kotlinTargets.joinToString("\n\n") + "\n" else "" - - // Build sourcesets block - val sourcesets = mutableListOf() - sourcesets.add( - """ sourceSets { - commonMain.dependencies { - implementation(compose.components.uiToolingPreview) - implementation(libs.composables.ui) - }""", - ) - - sourcesets.add(" }") - val sourcesetsBlock = sourcesets.joinToString("\n") - - // Build configuration blocks - val configurationBlocksBlock = "" - - updatedContent = updatedContent.replace("{{android_versions}}", androidVersions) - updatedContent = updatedContent.replace("{{android_libraries}}", androidLibraries) - updatedContent = updatedContent.replace("{{android_plugins}}", androidPlugins) - updatedContent = updatedContent.replace("{{android_root_plugins}}", androidRootPlugins) - updatedContent = updatedContent.replace("{{android_include}}", androidInclude) - updatedContent = updatedContent.replace("{{desktop_include}}", desktopInclude) - updatedContent = updatedContent.replace("{{web_include}}", webInclude) - updatedContent = updatedContent.replace("{{android_properties}}", androidProperties) - updatedContent = updatedContent.replace("{{web_preload_task_wiring}}", webPreloadTaskWiring) - - // Replace shared build.gradle.kts blocks - updatedContent = updatedContent.replace("{{imports}}", importsBlock) - updatedContent = updatedContent.replace("{{plugins}}", pluginsBlock) - updatedContent = updatedContent.replace("{{kotlin_targets}}", kotlinTargetsBlock) - updatedContent = updatedContent.replace("{{sourcesets}}", sourcesetsBlock) - updatedContent = updatedContent.replace("{{configuration_blocks}}", configurationBlocksBlock) - - // Replace remaining placeholders after blocks are built - updatedContent = updatedContent.replace("{{namespace}}", packageName) - updatedContent = updatedContent.replace("{{project_name}}", target.name) - updatedContent = updatedContent.replace("{{module_name}}", moduleName) - updatedContent = updatedContent.replace("{{shared_module_name}}", moduleName) - updatedContent = updatedContent.replace("{{app_name}}", appName) - updatedContent = updatedContent.replace("{{shared_module_accessor}}", toProjectAccessorName(moduleName)) - updatedContent = updatedContent.replace("{{ios_binary_name}}", toCamelCase(moduleName)) - updatedContent = updatedContent.replace("{{target_name}}", "$IOS_APP_MODULE.app") - if (content != updatedContent) { - file.writeText(updatedContent.trim() + "\n") - } - } catch (e: Exception) { - // If we can't read as text, skip this file - debugln { "Skipping binary file: ${file.name}" } - } - } - } - - // Link iOS project for IDE if iOS target was included - if (normalizedTargets.contains(IOS)) { - try { - debugln { "Preparing iOS target..." } - val process = ProcessBuilder(gradleScript, "compileIosMainKotlinMetadata", "--quiet") - .directory(target) - .inheritIO() - .start() - val exitCode = process.waitFor() - if (exitCode == 0) { - debugln { "iOS target is now ready to run from the IDE" } - } else { - warnln { "Warning: Failed to link iOS project for IDE. You may need to run '$gradleScript compileIosMainKotlinMetadata' manually." } - } - } catch (e: Exception) { - warnln { "Warning: Failed to link iOS project for IDE: ${e.message}" } - } - } -} +""".trimIndent() fun updateRootBuildFile( targetDir: String, @@ -1947,70 +1804,7 @@ fun updateRootBuildFile( } if (normalizedTargets.contains(WASM) && !content.contains("injectWasmPreloads")) { - buildFile.writeText( - buildFile.readText().trimEnd() + "\n\n" + """ -subprojects { - fun registerPreloadInjectionTask( - distributionTarget: String, - markerName: String, - includeWasmArtifacts: Boolean, - ) = tasks.register("inject${'$'}{distributionTarget.replaceFirstChar(Char::titlecase)}Preloads") { - description = "Injects preload links for generated ${'$'}distributionTarget distribution artifacts." - val distributionDir = layout.buildDirectory.dir("dist/${'$'}distributionTarget/productionExecutable") - val preloadMarker = markerName - val preloadWasmArtifacts = includeWasmArtifacts - - doLast { - val distDir = distributionDir.get().asFile - val indexFile = distDir.resolve("index.html") - if (!indexFile.isFile) return@doLast - - val scriptPreloads = distDir - .listFiles { file -> file.isFile && file.extension == "js" } - .orEmpty() - .sortedBy { it.name } - .map { " " } - - val artifactPreloads = if (preloadWasmArtifacts) { - distDir - .listFiles { file -> file.isFile && file.extension == "wasm" } - .orEmpty() - .sortedBy { it.name } - .map { - " " - } - } else { - emptyList() - } - - val preloadBlock = (scriptPreloads + artifactPreloads).joinToString( - separator = "\n", - prefix = " \n", - postfix = "\n ", - ) - - val existingPreloadBlock = Regex( - pattern = "\\n? .*? \\n?", - options = setOf(RegexOption.DOT_MATCHES_ALL), - ) - val indexHtml = indexFile.readText().replace(existingPreloadBlock, "\n") - val updatedIndexHtml = indexHtml.replaceFirst("", "\n${'$'}preloadBlock") - indexFile.writeText(updatedIndexHtml) - } - } - - val injectWasmPreloads = registerPreloadInjectionTask( - distributionTarget = "wasmJs", - markerName = "wasm-preloads", - includeWasmArtifacts = true, - ) - - tasks.matching { it.name == "wasmJsBrowserDistribution" }.configureEach { - finalizedBy(injectWasmPreloads) - } -} - """.trimIndent() + "\n", - ) + buildFile.writeText(buildFile.readText().trimEnd() + "\n\n" + wasmPreloadTaskWiring() + "\n") } } @@ -2295,109 +2089,15 @@ fun createModuleOnly( try { val content = file.readText() - var updatedContent = content.replace("{{app_name}}", appName) - - // Build imports block - val imports = mutableListOf() - if (normalizedTargets.contains(WASM)) { - imports.add("import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl") - } - if (normalizedTargets.contains(ANDROID)) { - imports.add("import org.jetbrains.kotlin.gradle.dsl.JvmTarget") - } - val importsBlock = if (imports.isNotEmpty()) imports.joinToString("\n") + "\n" else "" - - // Build plugins block - val plugins = mutableListOf() - plugins.add(" alias(libs.plugins.jetbrains.kotlin.multiplatform)") - plugins.add(" alias(libs.plugins.jetbrains.compose)") - // Only add compose compiler if kotlin compose plugin is not already present - if (!content.contains("libs.plugins.kotlin.compose")) { - plugins.add(" alias(libs.plugins.jetbrains.compose.compiler)") - } - if (normalizedTargets.contains(ANDROID)) { - plugins.add(" alias(libs.plugins.android.kotlin.multiplatform.library)") - } - val pluginsBlock = "plugins {\n" + plugins.joinToString("\n") + "\n}" - - // Build kotlin targets block - val kotlinTargets = mutableListOf() - if (normalizedTargets.contains(ANDROID)) { - kotlinTargets.add( - """ androidLibrary { - namespace = "{{namespace}}.shared" - compileSdk = libs.versions.android.compileSdk.get().toInt() - minSdk = libs.versions.android.minSdk.get().toInt() - withJava() - androidResources { - enable = true - } - compilerOptions { - jvmTarget.set(JvmTarget.JVM_17) - } - }""", - ) - } - if (normalizedTargets.contains(IOS)) { - val baseName = toCamelCase(moduleName) - kotlinTargets.add( - """ listOf( - iosArm64(), - iosSimulatorArm64() - ).forEach { iosTarget -> - iosTarget.binaries.framework { - baseName = "$baseName" - isStatic = true - } - }""", - ) - } - if (normalizedTargets.contains(JVM)) { - kotlinTargets.add(" jvm()") - } - if (normalizedTargets.contains(WASM)) { - kotlinTargets.add( - """ @OptIn(ExperimentalWasmDsl::class) - wasmJs { - browser() - }""", - ) - } - val kotlinTargetsBlock = - if (kotlinTargets.isNotEmpty()) kotlinTargets.joinToString("\n\n") + "\n" else "" - - // Build sourcesets block - val sourcesets = mutableListOf() - sourcesets.add( - """ sourceSets { - commonMain.dependencies { - implementation(compose.components.uiToolingPreview) - implementation(libs.composables.ui) - }""", + val updatedContent = renderProjectTemplate( + content = content, + packageName = packageName, + moduleName = moduleName, + appName = appName, + targets = normalizedTargets, ) - - sourcesets.add(" }") - val sourcesetsBlock = sourcesets.joinToString("\n") - - // Build configuration blocks - val configurationBlocksBlock = "" - - // Replace shared build.gradle.kts blocks - updatedContent = updatedContent.replace("{{imports}}", importsBlock) - updatedContent = updatedContent.replace("{{plugins}}", pluginsBlock) - updatedContent = updatedContent.replace("{{kotlin_targets}}", kotlinTargetsBlock) - updatedContent = updatedContent.replace("{{sourcesets}}", sourcesetsBlock) - updatedContent = updatedContent.replace("{{configuration_blocks}}", configurationBlocksBlock) - - // Replace remaining placeholders after blocks are built - updatedContent = updatedContent.replace("{{namespace}}", packageName) - updatedContent = updatedContent.replace("{{module_name}}", moduleName) - updatedContent = updatedContent.replace("{{app_name}}", appName) - updatedContent = updatedContent.replace("{{shared_module_accessor}}", toProjectAccessorName(moduleName)) - updatedContent = updatedContent.replace("{{ios_binary_name}}", toCamelCase(moduleName)) - updatedContent = updatedContent.replace("{{target_name}}", "$IOS_APP_MODULE.app") if (content != updatedContent) { - file.writeText(updatedContent.trim() + "\n") + file.writeText(updatedContent) } } catch (e: Exception) { // If we can't read as text, skip this file From decdfc5517b2fe005130e67888b80a58292e7034 Mon Sep 17 00:00:00 2001 From: alexstyl <1665273+alexstyl@users.noreply.github.com> Date: Tue, 23 Jun 2026 11:13:35 +0700 Subject: [PATCH 6/9] Migrate generated previews to AndroidX tooling --- cli/src/jvmMain/kotlin/Cli.kt | 24 ++++++++++++++++--- .../project/gradle/libs.versions.toml | 1 + .../kotlin/org/example/project/App.kt | 2 +- .../kotlin/com/composables/cli/CliTest.kt | 3 +++ 4 files changed, 26 insertions(+), 4 deletions(-) diff --git a/cli/src/jvmMain/kotlin/Cli.kt b/cli/src/jvmMain/kotlin/Cli.kt index 994bfd5..4d697b1 100644 --- a/cli/src/jvmMain/kotlin/Cli.kt +++ b/cli/src/jvmMain/kotlin/Cli.kt @@ -619,7 +619,7 @@ class Target : CliktCommand("target") { private fun hasComposeDependencies(content: String): Boolean { val composeDependencies = listOf( "compose.components.resources", - "compose.components.uiToolingPreview", + "org.jetbrains.compose.ui:ui-tooling-preview", "libs.composables.ui", "com.composables:ui:", "compose.desktop.currentOs", @@ -1592,6 +1592,10 @@ activityCompose = "1.13.0" } else { "" }, + "{{compose_libraries}}" to """compose-ui-tooling = { group = "org.jetbrains.compose.ui", name = "ui-tooling", version.ref = "compose" } +compose-ui-tooling-preview = { group = "org.jetbrains.compose.ui", name = "ui-tooling-preview", version.ref = "compose" } + +""", "{{android_libraries}}" to if (normalizedTargets.contains(ANDROID)) { """androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } @@ -1631,11 +1635,19 @@ android.useAndroidX=true "{{kotlin_targets}}" to if (kotlinTargets.isNotEmpty()) kotlinTargets.joinToString("\n\n") + "\n" else "", "{{sourcesets}}" to """ sourceSets { commonMain.dependencies { - implementation(compose.components.uiToolingPreview) + implementation(libs.compose.ui.tooling.preview) implementation(libs.composables.ui) } }""", - "{{configuration_blocks}}" to "", + "{{configuration_blocks}}" to if (normalizedTargets.contains(ANDROID)) { + """ +dependencies { + androidRuntimeClasspath(libs.compose.ui.tooling) +} + """.trimIndent() + } else { + "" + }, "{{namespace}}" to packageName, "{{project_name}}" to projectName, "{{module_name}}" to moduleName, @@ -1855,6 +1867,12 @@ fun updateVersionCatalog( if (!hasLibraryVariable(librariesSection, "composables-ui")) { newLibraries.add("composables-ui = { group = \"com.composables\", name = \"ui\", version.ref = \"composablesUi\" }") } + if (!hasLibraryVariable(librariesSection, "compose-ui-tooling")) { + newLibraries.add("compose-ui-tooling = { group = \"org.jetbrains.compose.ui\", name = \"ui-tooling\", version.ref = \"compose\" }") + } + if (!hasLibraryVariable(librariesSection, "compose-ui-tooling-preview")) { + newLibraries.add("compose-ui-tooling-preview = { group = \"org.jetbrains.compose.ui\", name = \"ui-tooling-preview\", version.ref = \"compose\" }") + } if (targets.contains("android") && !hasLibraryVariable(librariesSection, "androidx-activity-compose")) { newLibraries.add("androidx-activity-compose = { group = \"androidx.activity\", name = \"activity-compose\", version.ref = \"activityCompose\" }") } diff --git a/cli/src/jvmMain/resources/project/gradle/libs.versions.toml b/cli/src/jvmMain/resources/project/gradle/libs.versions.toml index 03c300a..5567105 100644 --- a/cli/src/jvmMain/resources/project/gradle/libs.versions.toml +++ b/cli/src/jvmMain/resources/project/gradle/libs.versions.toml @@ -10,6 +10,7 @@ kotlin = "2.4.0" {{android_versions}} [libraries] composables-ui = { group = "com.composables", name = "ui", version.ref = "composablesUi" } +{{compose_libraries}} {{android_libraries}} [plugins] {{android_plugins}} diff --git a/cli/src/jvmMain/resources/project/shared/src/commonMain/kotlin/org/example/project/App.kt b/cli/src/jvmMain/resources/project/shared/src/commonMain/kotlin/org/example/project/App.kt index 4c253c6..5708f18 100644 --- a/cli/src/jvmMain/resources/project/shared/src/commonMain/kotlin/org/example/project/App.kt +++ b/cli/src/jvmMain/resources/project/shared/src/commonMain/kotlin/org/example/project/App.kt @@ -10,10 +10,10 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.composables.ui.components.Text import com.composables.ui.theme.ComposablesTheme -import org.jetbrains.compose.ui.tooling.preview.Preview @Composable fun App() { diff --git a/cli/src/jvmTest/kotlin/com/composables/cli/CliTest.kt b/cli/src/jvmTest/kotlin/com/composables/cli/CliTest.kt index 87f4f1b..cad6e09 100644 --- a/cli/src/jvmTest/kotlin/com/composables/cli/CliTest.kt +++ b/cli/src/jvmTest/kotlin/com/composables/cli/CliTest.kt @@ -55,6 +55,7 @@ class CliTest { assertThat(sharedBuildContent).contains("jvm()") assertThat(sharedBuildContent).contains("implementation(libs.composables.ui)") + assertThat(sharedBuildContent).contains("implementation(libs.compose.ui.tooling.preview)") assertThat(sharedBuildContent).doesNotContain("androidLibrary {") assertThat(sharedBuildContent).doesNotContain("iosArm64()") assertThat(sharedBuildContent).doesNotContain("wasmJs()") @@ -72,6 +73,7 @@ class CliTest { assertThat(settingsContent).contains("""include(":desktopApp")""") assertThat(appContent).contains("package com.composables.demo") + assertThat(appContent).contains("import androidx.compose.ui.tooling.preview.Preview") assertThat(appContent).contains("Hello Beautiful World!") assertThat(appContent).contains("Go to App.kt to edit your app") assertThat(appContent).contains("Pro tip: Use the `dev` configuration in your IDE to auto-reload your app when you edit your code") @@ -122,6 +124,7 @@ class CliTest { assertThat(sharedBuildContent).contains("alias(libs.plugins.android.kotlin.multiplatform.library)") assertThat(sharedBuildContent).contains("androidLibrary {") + assertThat(sharedBuildContent).contains("androidRuntimeClasspath(libs.compose.ui.tooling)") assertThat(sharedBuildContent).doesNotContain("alias(libs.plugins.android.application)") assertThat(sharedBuildContent).doesNotContain("androidMain.dependencies") assertThat(sharedBuildContent).doesNotContain("defaultConfig {") From 67e4cdd1e9b797799538a522af4f7178a633b803 Mon Sep 17 00:00:00 2001 From: alexstyl <1665273+alexstyl@users.noreply.github.com> Date: Tue, 23 Jun 2026 11:21:08 +0700 Subject: [PATCH 7/9] Remove deprecated generated Compose wiring --- cli/src/jvmMain/kotlin/Cli.kt | 13 +++++++++---- .../resources/project/webApp/build.gradle.kts | 11 +---------- .../jvmTest/kotlin/com/composables/cli/CliTest.kt | 7 +++++-- 3 files changed, 15 insertions(+), 16 deletions(-) diff --git a/cli/src/jvmMain/kotlin/Cli.kt b/cli/src/jvmMain/kotlin/Cli.kt index 4d697b1..a0fa998 100644 --- a/cli/src/jvmMain/kotlin/Cli.kt +++ b/cli/src/jvmMain/kotlin/Cli.kt @@ -620,10 +620,11 @@ class Target : CliktCommand("target") { val composeDependencies = listOf( "compose.components.resources", "org.jetbrains.compose.ui:ui-tooling-preview", + "libs.compose.ui", + "libs.compose.ui.tooling.preview", "libs.composables.ui", "com.composables:ui:", "compose.desktop.currentOs", - "compose.preview", "compose.runtime", ) @@ -733,7 +734,7 @@ class Target : CliktCommand("target") { if (kotlinCloseIndex >= 0) { val androidTargetLines = listOf( "", - " androidLibrary {", + " android {", " namespace = \"$namespace.shared\"", " compileSdk = libs.versions.android.compileSdk.get().toInt()", " minSdk = libs.versions.android.minSdk.get().toInt()", @@ -1541,7 +1542,7 @@ private fun renderProjectTemplate( val kotlinTargets = buildList { if (normalizedTargets.contains(ANDROID)) { add( - """ androidLibrary { + """ android { namespace = "{{namespace}}.shared" compileSdk = libs.versions.android.compileSdk.get().toInt() minSdk = libs.versions.android.minSdk.get().toInt() @@ -1592,7 +1593,8 @@ activityCompose = "1.13.0" } else { "" }, - "{{compose_libraries}}" to """compose-ui-tooling = { group = "org.jetbrains.compose.ui", name = "ui-tooling", version.ref = "compose" } + "{{compose_libraries}}" to """compose-ui = { group = "org.jetbrains.compose.ui", name = "ui", version.ref = "compose" } +compose-ui-tooling = { group = "org.jetbrains.compose.ui", name = "ui-tooling", version.ref = "compose" } compose-ui-tooling-preview = { group = "org.jetbrains.compose.ui", name = "ui-tooling-preview", version.ref = "compose" } """, @@ -1867,6 +1869,9 @@ fun updateVersionCatalog( if (!hasLibraryVariable(librariesSection, "composables-ui")) { newLibraries.add("composables-ui = { group = \"com.composables\", name = \"ui\", version.ref = \"composablesUi\" }") } + if (!hasLibraryVariable(librariesSection, "compose-ui")) { + newLibraries.add("compose-ui = { group = \"org.jetbrains.compose.ui\", name = \"ui\", version.ref = \"compose\" }") + } if (!hasLibraryVariable(librariesSection, "compose-ui-tooling")) { newLibraries.add("compose-ui-tooling = { group = \"org.jetbrains.compose.ui\", name = \"ui-tooling\", version.ref = \"compose\" }") } diff --git a/cli/src/jvmMain/resources/project/webApp/build.gradle.kts b/cli/src/jvmMain/resources/project/webApp/build.gradle.kts index 9015f65..c1eb0b9 100644 --- a/cli/src/jvmMain/resources/project/webApp/build.gradle.kts +++ b/cli/src/jvmMain/resources/project/webApp/build.gradle.kts @@ -1,5 +1,4 @@ import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl -import org.jetbrains.kotlin.gradle.targets.js.webpack.KotlinWebpackConfig plugins { alias(libs.plugins.jetbrains.kotlin.multiplatform) @@ -11,16 +10,8 @@ kotlin { @OptIn(ExperimentalWasmDsl::class) wasmJs { browser { - val rootDirPath = project.rootDir.path - val projectDirPath = project.projectDir.path commonWebpackConfig { outputFileName = "webApp.js" - devServer = (devServer ?: KotlinWebpackConfig.DevServer()).apply { - static = (static ?: mutableListOf()).apply { - add(rootDirPath) - add(projectDirPath) - } - } } } binaries.executable() @@ -28,7 +19,7 @@ kotlin { sourceSets { wasmJsMain.dependencies { - implementation(compose.ui) + implementation(libs.compose.ui) implementation(projects.{{shared_module_accessor}}) } } diff --git a/cli/src/jvmTest/kotlin/com/composables/cli/CliTest.kt b/cli/src/jvmTest/kotlin/com/composables/cli/CliTest.kt index cad6e09..8369974 100644 --- a/cli/src/jvmTest/kotlin/com/composables/cli/CliTest.kt +++ b/cli/src/jvmTest/kotlin/com/composables/cli/CliTest.kt @@ -57,6 +57,7 @@ class CliTest { assertThat(sharedBuildContent).contains("implementation(libs.composables.ui)") assertThat(sharedBuildContent).contains("implementation(libs.compose.ui.tooling.preview)") assertThat(sharedBuildContent).doesNotContain("androidLibrary {") + assertThat(sharedBuildContent).doesNotContain("android {") assertThat(sharedBuildContent).doesNotContain("iosArm64()") assertThat(sharedBuildContent).doesNotContain("wasmJs()") assertThat(sharedBuildContent).doesNotContain("{{shared_module_name}}") @@ -123,7 +124,8 @@ class CliTest { val settingsContent = settingsFile.readText() assertThat(sharedBuildContent).contains("alias(libs.plugins.android.kotlin.multiplatform.library)") - assertThat(sharedBuildContent).contains("androidLibrary {") + assertThat(sharedBuildContent).contains("android {") + assertThat(sharedBuildContent).doesNotContain("androidLibrary {") assertThat(sharedBuildContent).contains("androidRuntimeClasspath(libs.compose.ui.tooling)") assertThat(sharedBuildContent).doesNotContain("alias(libs.plugins.android.application)") assertThat(sharedBuildContent).doesNotContain("androidMain.dependencies") @@ -179,7 +181,7 @@ class CliTest { assertThat(sharedBuildContent).contains("wasmJs {") assertThat(sharedBuildContent).contains("browser()") assertThat(sharedBuildContent).doesNotContain("js {") - assertThat(webAppBuildContent).contains("implementation(compose.ui)") + assertThat(webAppBuildContent).contains("implementation(libs.compose.ui)") assertThat(webAppBuildContent).contains("wasmJs {") } } @@ -233,6 +235,7 @@ class CliTest { assertThat(content.countOccurrences("""agp = "9.2.1"""")).isEqualTo(1) assertThat(content.countOccurrences("""androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }""")).isEqualTo(1) assertThat(content.countOccurrences("""composables-ui = { group = "com.composables", name = "ui", version.ref = "composablesUi" }""")).isEqualTo(1) + assertThat(content.countOccurrences("""compose-ui = { group = "org.jetbrains.compose.ui", name = "ui", version.ref = "compose" }""")).isEqualTo(1) assertThat(content.countOccurrences("""android-application = { id = "com.android.application", version.ref = "agp" }""")).isEqualTo(1) assertThat(content.countOccurrences("""android-kotlin-multiplatform-library = { id = "com.android.kotlin.multiplatform.library", version.ref = "agp" }""")).isEqualTo(1) assertThat(content).contains("""compose = "1.11.1"""") From c396c2bd7f345bdb5363bc9d1eed18c3e5565285 Mon Sep 17 00:00:00 2001 From: alexstyl <1665273+alexstyl@users.noreply.github.com> Date: Tue, 23 Jun 2026 11:23:39 +0700 Subject: [PATCH 8/9] Align shared Android namespace with module name --- cli/src/jvmMain/kotlin/Cli.kt | 8 ++++++-- cli/src/jvmTest/kotlin/com/composables/cli/CliTest.kt | 1 + 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/cli/src/jvmMain/kotlin/Cli.kt b/cli/src/jvmMain/kotlin/Cli.kt index a0fa998..ae374c4 100644 --- a/cli/src/jvmMain/kotlin/Cli.kt +++ b/cli/src/jvmMain/kotlin/Cli.kt @@ -504,6 +504,8 @@ private fun toCamelCase(input: String): String = input.split(Regex("[-_]")) private fun toProjectAccessorName(input: String): String = toCamelCase(input) .replaceFirstChar { if (it.isUpperCase()) it.lowercase() else it.toString() } +private fun toNamespaceSegment(input: String): String = toProjectAccessorName(input) + class Target : CliktCommand("target") { override fun help(context: Context): String = """ Adds a new Kotlin target to the current Compose Multiplatform project (options: android, jvm, ios, wasm). @@ -732,10 +734,11 @@ class Target : CliktCommand("target") { // Append to kotlin block val kotlinCloseIndex = findKotlinBlockEnd(lines) if (kotlinCloseIndex >= 0) { + val moduleNamespace = toNamespaceSegment(moduleName) val androidTargetLines = listOf( "", " android {", - " namespace = \"$namespace.shared\"", + " namespace = \"$namespace.$moduleNamespace\"", " compileSdk = libs.versions.android.compileSdk.get().toInt()", " minSdk = libs.versions.android.minSdk.get().toInt()", " withJava()", @@ -1525,6 +1528,7 @@ private fun renderProjectTemplate( projectName: String = "", ): String { val normalizedTargets = normalizeTargets(targets) + val sharedModuleNamespace = toNamespaceSegment(moduleName) val imports = buildList { if (normalizedTargets.contains(ANDROID)) add("import org.jetbrains.kotlin.gradle.dsl.JvmTarget") if (normalizedTargets.contains(WASM)) add("import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl") @@ -1543,7 +1547,7 @@ private fun renderProjectTemplate( if (normalizedTargets.contains(ANDROID)) { add( """ android { - namespace = "{{namespace}}.shared" + namespace = "{{namespace}}.$sharedModuleNamespace" compileSdk = libs.versions.android.compileSdk.get().toInt() minSdk = libs.versions.android.minSdk.get().toInt() withJava() diff --git a/cli/src/jvmTest/kotlin/com/composables/cli/CliTest.kt b/cli/src/jvmTest/kotlin/com/composables/cli/CliTest.kt index 8369974..a9daf90 100644 --- a/cli/src/jvmTest/kotlin/com/composables/cli/CliTest.kt +++ b/cli/src/jvmTest/kotlin/com/composables/cli/CliTest.kt @@ -126,6 +126,7 @@ class CliTest { assertThat(sharedBuildContent).contains("alias(libs.plugins.android.kotlin.multiplatform.library)") assertThat(sharedBuildContent).contains("android {") assertThat(sharedBuildContent).doesNotContain("androidLibrary {") + assertThat(sharedBuildContent).contains("""namespace = "com.composables.demo.sharedUi"""") assertThat(sharedBuildContent).contains("androidRuntimeClasspath(libs.compose.ui.tooling)") assertThat(sharedBuildContent).doesNotContain("alias(libs.plugins.android.application)") assertThat(sharedBuildContent).doesNotContain("androidMain.dependencies") From be7504459fe260ca5e1cc20091513c64f0d3431b Mon Sep 17 00:00:00 2001 From: alexstyl <1665273+alexstyl@users.noreply.github.com> Date: Tue, 23 Jun 2026 12:44:46 +0700 Subject: [PATCH 9/9] Fix integration tests for shared module rename --- .../kotlin/com/composables/cli/CliIntegrationTest.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/cli/src/integrationTest/kotlin/com/composables/cli/CliIntegrationTest.kt b/cli/src/integrationTest/kotlin/com/composables/cli/CliIntegrationTest.kt index 5233019..1f8c364 100644 --- a/cli/src/integrationTest/kotlin/com/composables/cli/CliIntegrationTest.kt +++ b/cli/src/integrationTest/kotlin/com/composables/cli/CliIntegrationTest.kt @@ -47,7 +47,7 @@ class CliIntegrationTest { assertThat(initResult.output).contains("Success! Your new Compose app is ready") val compileResult = runProcess( - command = listOf(projectGradleScript(), ":composeApp:compileKotlinJvm"), + command = listOf(projectGradleScript(), ":shared:compileKotlinJvm"), workingDir = projectDir, timeoutSeconds = 180, ) @@ -88,7 +88,7 @@ class CliIntegrationTest { assertThat(createResult.output).contains("Success! Your new Compose app is ready") val compileResult = runProcess( - command = listOf(projectGradleScript(), ":composeApp:compileKotlinJvm"), + command = listOf(projectGradleScript(), ":shared:compileKotlinJvm"), workingDir = projectDir, timeoutSeconds = 180, ) @@ -120,7 +120,7 @@ class CliIntegrationTest { assertThat(createResult.output).contains("Success! Your new Compose app is ready") val compileResult = runProcess( - command = listOf(projectGradleScript(), ":composeApp:compileKotlinJvm"), + command = listOf(projectGradleScript(), ":shared:compileKotlinJvm"), workingDir = projectDir, timeoutSeconds = 180, )