Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions examples/jewel-demo/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -95,8 +95,16 @@ tasks.withType<Test>().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 {
Expand Down
8 changes: 8 additions & 0 deletions examples/scheduler-demo/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
8 changes: 8 additions & 0 deletions examples/system-info-demo/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String>,
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>,
): 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('\\')
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 ->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -151,6 +152,16 @@ abstract class AbstractJPackageTask
@get:Input
val packageFromUberJar: Property<Boolean> = 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)
Expand Down Expand Up @@ -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).
Expand All @@ -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<String> {
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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -100,13 +100,16 @@ 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) {
if (!file.exists()) continue

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) {
Expand Down Expand Up @@ -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(
Expand All @@ -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"
}
}
Original file line number Diff line number Diff line change
@@ -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())
}
}
Loading