diff --git a/examples/jewel-demo/build.gradle.kts b/examples/jewel-demo/build.gradle.kts index 1300064c1..d9a20abe3 100644 --- a/examples/jewel-demo/build.gradle.kts +++ b/examples/jewel-demo/build.gradle.kts @@ -95,8 +95,16 @@ tasks.withType().configureEach { ) } +// Compiled to class file 69, so the app has to *run* on a 25 JVM too — and the Gradle JVM +// (the packaging default) is often older. Resolved through a toolchain, not a hard-coded path. +val jvm25 = + javaToolchains + .launcherFor { languageVersion.set(JavaLanguageVersion.of(25)) } + .map { it.metadata.installationPath.asFile.absolutePath } + nucleus.application { mainClass = "jewelsample.MainKt" + javaHome = jvm25.get() buildTypes { release { proguard { diff --git a/examples/scheduler-demo/build.gradle.kts b/examples/scheduler-demo/build.gradle.kts index 599a34880..82e63f8db 100644 --- a/examples/scheduler-demo/build.gradle.kts +++ b/examples/scheduler-demo/build.gradle.kts @@ -43,8 +43,16 @@ kotlin { } } +// Compiled to class file 69, so the app has to *run* on a 25 JVM too — and the Gradle JVM +// (the packaging default) is often older. Resolved through a toolchain, not a hard-coded path. +val jvm25 = + javaToolchains + .launcherFor { languageVersion.set(JavaLanguageVersion.of(25)) } + .map { it.metadata.installationPath.asFile.absolutePath } + nucleus.application { mainClass = "schedulerdemo.MainKt" + javaHome = jvm25.get() nativeDistributions { packageName = "SchedulerDemo" packageVersion = "1.0.0" diff --git a/examples/system-info-demo/build.gradle.kts b/examples/system-info-demo/build.gradle.kts index 65874acc1..a46ad8b1f 100644 --- a/examples/system-info-demo/build.gradle.kts +++ b/examples/system-info-demo/build.gradle.kts @@ -53,8 +53,16 @@ kotlin { } } +// Compiled to class file 69, so the app has to *run* on a 25 JVM too — and the Gradle JVM +// (the packaging default) is often older. Resolved through a toolchain, not a hard-coded path. +val jvm25 = + javaToolchains + .launcherFor { languageVersion.set(JavaLanguageVersion.of(25)) } + .map { it.metadata.installationPath.asFile.absolutePath } + nucleus.application { mainClass = "systeminfodemo.MainKt" + javaHome = jvm25.get() graalvm { isEnabled = true diff --git a/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/internal/LauncherClasspathOrder.kt b/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/internal/LauncherClasspathOrder.kt new file mode 100644 index 000000000..8978977d4 --- /dev/null +++ b/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/internal/LauncherClasspathOrder.kt @@ -0,0 +1,67 @@ +package dev.nucleusframework.desktop.application.internal + +import org.gradle.api.logging.Logger +import java.io.File + +/** + * Puts the `app.classpath=` entries of jpackage launcher `.cfg` files back in classpath order. + * + * jpackage has no classpath option: it lists every file of `--input` (sorted by name) after the + * main jar. When two JARs define the same classes, the one that sorts first wins at run time, + * while `./gradlew run` resolves them in Gradle's runtime-classpath order — so a packaged app + * could load different classes than the one tested. Seen with Jewel: the IntelliJ icon + * libraries pull `kotlinx-coroutines-core-jvm-1.10.2-intellij-2`, which sorts before + * `kotlinx-coroutines-core-jvm-1.11.0` and made the packaged app fail with `NoSuchMethodError`. + */ +internal object LauncherClasspathOrder { + private const val CLASSPATH_PREFIX = "app.classpath=" + + /** + * Rewrites every launcher `.cfg` under [appImageRoot] so its classpath follows [order] + * (JAR file names, first wins). Entries not in [order] keep their relative place, after it. + * + * @return number of `.cfg` files rewritten + */ + fun apply( + appImageRoot: File, + order: List, + logger: Logger, + ): Int { + if (order.isEmpty() || !appImageRoot.exists()) return 0 + var rewritten = 0 + appImageRoot + .walkTopDown() + .filter { it.isFile && it.extension.equals("cfg", ignoreCase = true) && it.name != "jvm.cfg" } + .forEach { cfg -> + val text = cfg.readText() + val reordered = reorder(text, order) ?: return@forEach + cfg.writeText(reordered) + rewritten++ + logger.info("Restored classpath order in ${cfg.name}") + } + return rewritten + } + + /** [cfgText] with its classpath in [order], or `null` when it already is. */ + internal fun reorder( + cfgText: String, + order: List, + ): String? { + val lineSeparator = if (cfgText.contains("\r\n")) "\r\n" else "\n" + val lines = cfgText.split(lineSeparator) + val slots = lines.indices.filter { lines[it].trimStart().startsWith(CLASSPATH_PREFIX) } + if (slots.size < 2) return null + + val rank = order.withIndex().associate { (index, name) -> name to index } + val entries = slots.map { lines[it].trim().removePrefix(CLASSPATH_PREFIX) } + // sortedBy is stable: unknown entries (rank MAX) keep jpackage's relative order. + val sorted = entries.sortedBy { rank[fileName(it)] ?: Int.MAX_VALUE } + if (sorted == entries) return null + + val out = lines.toMutableList() + slots.forEachIndexed { i, slot -> out[slot] = CLASSPATH_PREFIX + sorted[i] } + return out.joinToString(lineSeparator) + } + + private fun fileName(entry: String): String = entry.substringAfterLast('/').substringAfterLast('\\') +} diff --git a/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/internal/configureJvmApplication.kt b/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/internal/configureJvmApplication.kt index 5c9226aa6..bbda87936 100644 --- a/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/internal/configureJvmApplication.kt +++ b/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/internal/configureJvmApplication.kt @@ -875,9 +875,10 @@ private fun JvmApplicationContext.configurePackageTask( val strippedOutputDir = stripNativeLibs.flatMap { it.outputDir } packageTask.files.from( strippedOutputDir.map { dir -> - dir.asFileTree.matching { it.exclude(".main-jar-name") } + dir.asFileTree.matching { it.exclude(".main-jar-name", ".classpath-order") } }, ) + packageTask.classpathOrderFile.set(strippedOutputDir.map { it.file(".classpath-order") }) val strippedMainJarName = stripNativeLibs.flatMap { it.mainJarName } packageTask.launcherMainJar.fileProvider( strippedOutputDir.zip(strippedMainJarName) { dir, mainJarName -> diff --git a/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/tasks/AbstractJPackageTask.kt b/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/tasks/AbstractJPackageTask.kt index cafb54d55..82611a3f5 100644 --- a/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/tasks/AbstractJPackageTask.kt +++ b/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/tasks/AbstractJPackageTask.kt @@ -22,6 +22,7 @@ import dev.nucleusframework.desktop.application.internal.MacAssetsTool import dev.nucleusframework.desktop.application.internal.MacSigner import dev.nucleusframework.desktop.application.internal.MacSignerImpl import dev.nucleusframework.desktop.application.internal.NoCertificateSigner +import dev.nucleusframework.desktop.application.internal.LauncherClasspathOrder import dev.nucleusframework.desktop.application.internal.PathingJarClasspath import dev.nucleusframework.desktop.application.internal.PlistKeys import dev.nucleusframework.desktop.application.internal.SKIKO_LIBRARY_PATH @@ -151,6 +152,16 @@ abstract class AbstractJPackageTask @get:Input val packageFromUberJar: Property = objects.notNullProperty(false) + /** + * Classpath order of [files] when they come from a directory and so carry none (the + * sandboxed strip task's output): one file name per line, first wins. Unset: [files] is + * already in classpath order. See [LauncherClasspathOrder]. + */ + @get:InputFile + @get:Optional + @get:PathSensitive(PathSensitivity.NONE) + val classpathOrderFile: RegularFileProperty = objects.fileProperty() + @get:InputFile @get:Optional @get:PathSensitive(PathSensitivity.ABSOLUTE) @@ -700,6 +711,10 @@ abstract class AbstractJPackageTask override fun checkResult(result: ExecResult) { super.checkResult(result) + // Before signing (macOS) and the pathing-jar collapse (Linux), which both keep the order. + if (targetFormat == TargetFormat.RawAppImage) { + LauncherClasspathOrder.apply(destinationDir.ioFile, launcherClasspathOrder(), logger) + } modifyRuntimeOnMacOsIfNeeded() // Linux only: shrink the jpackage launcher's serialized classpath so the parent // process's single pipe read cannot short-read (JDK-8380085 / Nucleus #454). @@ -714,6 +729,25 @@ abstract class AbstractJPackageTask logger.lifecycle("The distribution is written to ${outputFile.canonicalPath}") } + /** The file names jpackage copied into `--input`, in classpath order, main JAR first. */ + private fun launcherClasspathOrder(): List { + val sources = files.files.toList() + val rank = + classpathOrderFile.orNull + ?.asFile + ?.takeIf { it.isFile } + ?.readLines() + ?.filter { it.isNotBlank() } + ?.withIndex() + ?.associate { (index, name) -> name.trim() to index } + val ordered = if (rank == null) sources else sources.sortedBy { rank[it.name] ?: Int.MAX_VALUE } + val mainJar = libsMapping[launcherMainJar.ioFile].orEmpty().filter { it.isJarFile } + return (mainJar + ordered.flatMap { libsMapping[it].orEmpty() }) + .filter { it.isJarFile } + .map { it.name } + .distinct() + } + /** Bundle directory name jpackage's macOS output is renamed to, without the `.app` suffix. */ private val macAppDirName: String get() = macBundleName.orNull?.takeIf { it.isNotBlank() } ?: packageName.get() diff --git a/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/tasks/AbstractStripNativeLibsFromJarsTask.kt b/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/tasks/AbstractStripNativeLibsFromJarsTask.kt index 71446c008..c182fa652 100644 --- a/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/tasks/AbstractStripNativeLibsFromJarsTask.kt +++ b/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/tasks/AbstractStripNativeLibsFromJarsTask.kt @@ -100,6 +100,8 @@ abstract class AbstractStripNativeLibsFromJarsTask : AbstractNucleusTask() { // Inject the runtime shim JAR onto the app classpath (fixed name, not mangled). SandboxJarRewriter.injectShimJar(outDir) + // The output is a directory, which loses the input order: record it for the package task. + val classpathOrder = mutableListOf(SandboxMarkers.SHIM_JAR_NAME) logger.lifecycle("Sandboxing: injected runtime shim JAR '{}'", SandboxMarkers.SHIM_JAR_NAME) for (file in inputJars.files) { @@ -107,6 +109,7 @@ abstract class AbstractStripNativeLibsFromJarsTask : AbstractNucleusTask() { val outputFileName = file.mangledName() val outputFile = outDir.resolve(outputFileName) + classpathOrder += outputFileName // Track the mangled name of the main JAR for downstream tasks if (file.name == expectedMainJarName) { @@ -139,6 +142,8 @@ abstract class AbstractStripNativeLibsFromJarsTask : AbstractNucleusTask() { rewrittenClassCount += result.rewrittenClasses } + outDir.resolve(CLASSPATH_ORDER_FILE).writeText(classpathOrder.joinToString("\n", postfix = "\n")) + // Emit the manifest next to the extracted native libs (packaged into app resources). val manifestFile = manifestDir.resolve(SandboxMarkers.MANIFEST_FILENAME) manifest.store( @@ -158,5 +163,6 @@ abstract class AbstractStripNativeLibsFromJarsTask : AbstractNucleusTask() { private companion object { const val MAIN_JAR_META_FILE = ".main-jar-name" + const val CLASSPATH_ORDER_FILE = ".classpath-order" } } \ No newline at end of file diff --git a/plugin-build/plugin/src/test/kotlin/dev/nucleusframework/desktop/application/internal/LauncherClasspathOrderTest.kt b/plugin-build/plugin/src/test/kotlin/dev/nucleusframework/desktop/application/internal/LauncherClasspathOrderTest.kt new file mode 100644 index 000000000..fc62eb5a9 --- /dev/null +++ b/plugin-build/plugin/src/test/kotlin/dev/nucleusframework/desktop/application/internal/LauncherClasspathOrderTest.kt @@ -0,0 +1,65 @@ +package dev.nucleusframework.desktop.application.internal + +import org.gradle.api.logging.Logging +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder + +class LauncherClasspathOrderTest { + @get:Rule + val tmp = TemporaryFolder() + + private val fork = "kotlinx-coroutines-core-jvm-1.10.2-intellij-2-7b70.jar" + private val real = "kotlinx-coroutines-core-jvm-1.11.0-41a5.jar" + + private fun cfg( + separator: String, + vararg jars: String, + ) = ( + listOf("[Application]") + + jars.map { "app.classpath=\$APPDIR$separator$it" } + + listOf("app.mainclass=demo.MainKt", "", "[JavaOptions]", "java-options=-Dx=1", "") + ).joinToString("\r\n") + + @Test + fun `the jpackage name order is replaced by the classpath order`() { + val text = cfg("\\", "app.jar", fork, real, "zzz.jar") + val out = LauncherClasspathOrder.reorder(text, listOf("app.jar", "zzz.jar", real, fork))!! + assertEquals(cfg("\\", "app.jar", "zzz.jar", real, fork), out) + } + + @Test + fun `unknown entries keep their place after the known ones`() { + val text = cfg("/", "app.jar", "b.jar", "x.jar", "a.jar", "y.jar") + val out = LauncherClasspathOrder.reorder(text, listOf("app.jar", "a.jar", "b.jar"))!! + assertEquals(cfg("/", "app.jar", "a.jar", "b.jar", "x.jar", "y.jar"), out) + } + + @Test + fun `an ordered or single-entry classpath is left alone`() { + assertNull(LauncherClasspathOrder.reorder(cfg("/", "app.jar", real, fork), listOf("app.jar", real, fork))) + assertNull(LauncherClasspathOrder.reorder(cfg("/", "app.jar"), listOf("app.jar"))) + } + + @Test + fun `line endings and every other line survive`() { + val lf = cfg("/", "app.jar", fork, real).replace("\r\n", "\n") + val out = LauncherClasspathOrder.reorder(lf, listOf("app.jar", real, fork))!! + assertEquals(cfg("/", "app.jar", real, fork).replace("\r\n", "\n"), out) + } + + @Test + fun `every launcher cfg of the app image is rewritten, jvm cfg untouched`() { + val appDir = tmp.newFolder("App", "app") + val main = appDir.resolve("App.cfg").apply { writeText(cfg("\\", "app.jar", fork, real)) } + val extra = appDir.resolve("Tool.cfg").apply { writeText(cfg("\\", "app.jar", fork, real)) } + val jvm = tmp.newFolder("App", "runtime", "lib").resolve("jvm.cfg").apply { writeText("-server KNOWN\n") } + val count = LauncherClasspathOrder.apply(tmp.root, listOf("app.jar", real, fork), Logging.getLogger("test")) + assertEquals(2, count) + assertEquals(cfg("\\", "app.jar", real, fork), main.readText()) + assertEquals(cfg("\\", "app.jar", real, fork), extra.readText()) + assertEquals("-server KNOWN\n", jvm.readText()) + } +}