From d07b3df678153559a64b60622175fcf3d9ab323d Mon Sep 17 00:00:00 2001 From: "Elie G." Date: Mon, 13 Jul 2026 15:38:19 +0300 Subject: [PATCH] fix(plugin): allow applying org.jetbrains.compose alongside Nucleus Nucleus forks the Compose Desktop packaging and duplicated two of the Compose plugin's registrations, causing order-dependent conflicts when both plugins are applied to the same module: - The Groovy-DSL setup guard used File.endsWith(String), which matches by path components (never a suffix), so it ran for Kotlin DSL projects too and registered a 'jetbrainsCompose' repositories extension that clashes with the Compose plugin. - propagateMainClassToComposeApplication called the lazy getApplication getter, initializing compose.desktop.application and making the Compose plugin register its own packaging tasks, which collide with Nucleus's identically-named ones (unpackDefaultComposeDesktopJvmApplicationResources, createDistributable, packageDmg, ...). Fixes: - Detect Kotlin DSL via buildFile.name so the Groovy setup only runs for Groovy build files. - Only forward mainClass when Compose's application is already initialized, read via the internal _isJvmApplicationInitialized flag without triggering initialization. Nucleus therefore never causes Compose to register its packaging tasks; only Nucleus's forked tasks exist. - Fail fast with an actionable message when both nucleus.application and compose.desktop.application are configured. --- .../dev/nucleusframework/NucleusPlugin.kt | 6 ++- .../application/internal/configureDesktop.kt | 53 +++++++++++++++++-- 2 files changed, 54 insertions(+), 5 deletions(-) diff --git a/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/NucleusPlugin.kt b/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/NucleusPlugin.kt index 59052a11c..1c6d8a35c 100644 --- a/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/NucleusPlugin.kt +++ b/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/NucleusPlugin.kt @@ -33,7 +33,11 @@ abstract class NucleusPlugin : Plugin { project.dependencies.extensions.add("nucleus", Dependencies(project)) } - if (!project.buildFile.endsWith(".gradle.kts")) { + // `File.endsWith(String)` matches by path components, not by string suffix, so + // `buildFile.endsWith(".gradle.kts")` is always false and the Groovy DSL setup used to + // run for Kotlin DSL projects too — registering a `jetbrainsCompose` repositories + // extension that clashes with the Compose plugin when it is applied afterwards. + if (!project.buildFile.name.endsWith(".gradle.kts")) { setUpGroovyDslExtensions(project) } diff --git a/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/internal/configureDesktop.kt b/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/internal/configureDesktop.kt index 331dd2a10..0810ec4cb 100644 --- a/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/internal/configureDesktop.kt +++ b/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/internal/configureDesktop.kt @@ -21,6 +21,7 @@ internal fun configureDesktop( nucleusExtension: NucleusExtension, ) { if (nucleusExtension.isJvmApplicationInitialized) { + checkNoComposeDesktopApplication(project) val appInternal = nucleusExtension.application as JvmApplicationInternal val defaultBuildType = appInternal.data.buildTypes.default val appData = JvmApplicationContext(project, appInternal, defaultBuildType) @@ -65,11 +66,15 @@ private fun propagateMainClassToComposeApplication( appInternal: JvmApplicationInternal, ) { val mainClass = appInternal.data.mainClass ?: return - val compose = project.extensions.findByName("compose") as? ExtensionAware ?: return - val desktop = compose.extensions.findByName("desktop") as? ExtensionAware ?: return + val desktop = composeDesktopExtension(project) ?: return + // Only forward when the Compose Desktop application has ALREADY been initialized + // (by an explicit `compose.desktop.application { }` block or the Hot Reload plugin). + // `desktop.application` is a lazy getter that initializes the extension as a side effect; + // triggering it here would make the Compose plugin register its own packaging tasks, which + // collide with Nucleus's identically-named ones. See [isComposeJvmApplicationInitialized]. + if (!isComposeJvmApplicationInitialized(desktop)) return runCatching { - // `desktop.application` is lazy (org.jetbrains.compose.desktop.DesktopExtension). - // Accessing it via reflection avoids a compile-time dependency on the Compose plugin. + // Accessing via reflection avoids a compile-time dependency on the Compose plugin. val applicationGetter = desktop.javaClass.getMethod("getApplication") val composeApplication = applicationGetter.invoke(desktop) ?: return val mainClassSetter = composeApplication.javaClass.methods @@ -79,6 +84,46 @@ private fun propagateMainClassToComposeApplication( } } +/** + * Fails with an actionable message when both `nucleus.application { }` and + * `compose.desktop.application { }` are configured in the same project. Nucleus ships its own + * (forked) Compose Desktop packaging and registers the same task names, so letting both DSLs + * drive packaging throws a cryptic `Cannot add task '…' as a task with that name already exists`. + * The Compose plugin itself can stay applied (for Hot Reload, IDE integration, `compose.desktop + * .currentOs`, …) — only its `application { }` packaging block must be removed. + */ +private fun checkNoComposeDesktopApplication(project: Project) { + val desktop = composeDesktopExtension(project) ?: return + if (!isComposeJvmApplicationInitialized(desktop)) return + error( + "Both `nucleus.application { }` and `compose.desktop.application { }` are configured in " + + "project '${project.path}'. Nucleus replaces Compose Desktop's packaging and registers " + + "the same Gradle tasks, so the two blocks conflict. Remove the " + + "`compose.desktop.application { }` block and configure packaging via " + + "`nucleus.application { }` instead — the Compose plugin can stay applied for Hot Reload " + + "and IDE integration, and `mainClass` is forwarded automatically.", + ) +} + +/** Returns the `compose.desktop` extension via reflection, or null when the Compose plugin is absent. */ +private fun composeDesktopExtension(project: Project): ExtensionAware? { + val compose = project.extensions.findByName("compose") as? ExtensionAware ?: return null + return compose.extensions.findByName("desktop") as? ExtensionAware +} + +/** + * Reads Compose's internal `_isJvmApplicationInitialized` flag without initializing the lazy + * `application` extension (unlike calling `getApplication`, which would trigger the Compose plugin + * to register its packaging tasks). Returns false if the flag cannot be read. + */ +private fun isComposeJvmApplicationInitialized(desktop: ExtensionAware): Boolean = + runCatching { + desktop.javaClass + .getMethod("get_isJvmApplicationInitialized\$compose") + .invoke(desktop) as? Boolean + ?: false + }.getOrDefault(false) + /** * Adds `-XstartOnFirstThread` to Compose Hot Reload's `JavaExec` tasks on macOS when the * project depends on `decorated-window-tao`. The TAO backend drives a winit/Tao native