diff --git a/CLAUDE.md b/CLAUDE.md index 9c4a23770..9a7f361ad 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -133,6 +133,7 @@ Published tags are `v2.4.x`. The `v` prefix is stripped for the Maven version. - **L1**: Generic cross-platform metadata shipped in `graalvm-runtime` JAR (`reachability-metadata.json` with ~300+ types) - **L2**: Oracle GraalVM Reachability Metadata Repository — auto-resolved for classpath deps (enabled by default, `metadataRepository {}` DSL) - **L3**: Platform-specific metadata (macOS/Windows/Linux) shipped inside the plugin JAR under `nucleus/graalvm/platform-metadata/` +- `graalvm { headless = true }` is for daemons/CLIs: skips L3 AWT/Java2D platform metadata, skips always-on L1 packs (`jdk-awt`, `jdk-fonts`, `jdk-graphics2d`, Skiko/Compose/tray), skips copying companion GUI native libs (`libawt`, `libfontmanager`, Skiko, …), and bakes `-Djava.awt.headless=true`. Default `false` (GUI). Without this, JNI registration of AWT types makes `native-image` pull `libawt`/`libawt_xawt` even when app code never references `java.awt`. - `graalvm-runtime` auto-includes `.svg`, `.ttf`, `.otf`, `composeResources/*`, `nucleus/native/*`, and `META-INF/services/*` via `reachability-metadata.json` resource globs (the deprecated `-H:IncludeResources` option was dropped). The blanket `**/*.{svg,ttf,otf}` globs are a required catch-all for fonts/icons bundled inside **library** JARs (e.g. Jewel SVG icons) — those are not the app's own resources so `autoIncludeResources` doesn't cover them. They knowingly trigger native-image's advisory "pattern too generic" warning; do not remove them (it breaks Jewel icons in native image) - The tracing agent (`runWithNativeAgent`) is only needed for app-specific reflection, uncommon libraries, and resource bundles - PGO (Oracle GraalVM): `runWithPgoInstrument` builds + runs an instrumented image and records `graalvm/pgo/default.iprof` on exit; later native-image builds apply the profile automatically. Opt out with `-Pnucleus.graalvm.pgo=off`; customize via `graalvm { pgo { enabled / profile } }` diff --git a/README.md b/README.md index 457dcdae0..b9533b2b5 100644 --- a/README.md +++ b/README.md @@ -234,7 +234,7 @@ Each module is published independently to Maven Central — use them together or | `nucleus.native-http-okhttp` | OkHttp engine on `native-http` | | `nucleus.native-http-ktor` | Ktor engine on `native-http` | | `nucleus.linux-hidpi` | Native HiDPI scale detection on Linux | -| `nucleus.graalvm-runtime` | Native-image bootstrap, font fixes, automatic resource inclusion | +| `nucleus.graalvm-runtime` | Native-image bootstrap, font fixes, automatic resource inclusion. Daemons/CLIs: `graalvm { headless = true }` to skip AWT/Skiko metadata and GUI `.so`/`.dll` copies | ## Documentation diff --git a/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/dsl/GraalvmSettings.kt b/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/dsl/GraalvmSettings.kt index f65b53468..a08d3cb4a 100644 --- a/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/dsl/GraalvmSettings.kt +++ b/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/dsl/GraalvmSettings.kt @@ -21,6 +21,11 @@ abstract class GraalvmSettings ) { val isEnabled: Property = objects.notNullProperty(false) + // Skip AWT/Java2D/Skiko reachability metadata and companion GUI native libs + // (libawt, libfontmanager, Skiko, …). Use for daemons and CLIs. Also bakes + // `-Djava.awt.headless=true` into the image. + val headless: Property = objects.notNullProperty(false) + // Gradle toolchain spec used only when toolchain.autoDownload is disabled; the // auto-downloaded toolchain is selected via toolchain { channel / version } instead. @Suppress("MagicNumber") diff --git a/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/internal/FilterLibraryMetadataTask.kt b/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/internal/FilterLibraryMetadataTask.kt index f148783f6..844b1ac43 100644 --- a/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/internal/FilterLibraryMetadataTask.kt +++ b/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/internal/FilterLibraryMetadataTask.kt @@ -5,7 +5,9 @@ import groovy.json.JsonSlurper import org.gradle.api.DefaultTask import org.gradle.api.file.ConfigurableFileCollection import org.gradle.api.file.DirectoryProperty +import org.gradle.api.provider.Property import org.gradle.api.tasks.CacheableTask +import org.gradle.api.tasks.Input import org.gradle.api.tasks.InputFiles import org.gradle.api.tasks.OutputDirectory import org.gradle.api.tasks.PathSensitive @@ -24,6 +26,9 @@ import java.io.File */ @CacheableTask abstract class FilterLibraryMetadataTask : DefaultTask() { + @get:Input + abstract val headless: Property + /** The runtime classpath JARs/dirs to check for conditional library presence. */ @get:InputFiles @get:PathSensitive(PathSensitivity.NONE) @@ -52,7 +57,12 @@ abstract class FilterLibraryMetadataTask : DefaultTask() { var includedCount = 0 var skippedCount = 0 + val skipGuiMetadata = headless.get() for (fileName in index) { + if (skipGuiMetadata && fileName in HEADLESS_SKIP_METADATA) { + skippedCount++ + continue + } val stream = javaClass.classLoader.getResourceAsStream("$metadataDir/$fileName") ?: continue @Suppress("UNCHECKED_CAST") @@ -97,4 +107,18 @@ abstract class FilterLibraryMetadataTask : DefaultTask() { "Library metadata: included $includedCount files, skipped $skippedCount conditional files", ) } + + companion object { + private val HEADLESS_SKIP_METADATA = + setOf( + "jdk-awt.json", + "jdk-fonts.json", + "jdk-graphics2d.json", + "skia-skiko.json", + "composetray.json", + "compose-ui.json", + "compose-mediaplayer.json", + "compose-webview-wry.json", + ) + } } diff --git a/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/internal/configureGraalvmApplication.kt b/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/internal/configureGraalvmApplication.kt index 282c1bb17..a5b641746 100644 --- a/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/internal/configureGraalvmApplication.kt +++ b/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/internal/configureGraalvmApplication.kt @@ -612,7 +612,9 @@ internal fun JvmApplicationContext.configureGraalvmApplication() { taskNameObject = "graalvmPlatformMetadata", ) { description = "Generate platform-specific GraalVM metadata for AWT/Java2D and main class" + val headlessForMetadata = graalvm.headless.get() inputs.property("mainClass", mainClassName ?: "") + inputs.property("headless", headlessForMetadata) outputs.dir(platformMetadataDir) doLast { @@ -622,8 +624,15 @@ internal fun JvmApplicationContext.configureGraalvmApplication() { OS.MacOS -> "macos" OS.Linux -> "linux" } - writePlatformMetadata(platform, platformMetadataDir.get().asFile, mainClassName) - logger.lifecycle("Platform metadata ($platform) written to: ${platformMetadataDir.get().asFile}") + writePlatformMetadata( + platform, + platformMetadataDir.get().asFile, + mainClassName, + headless = headlessForMetadata, + ) + logger.lifecycle( + "Platform metadata ($platform${if (headlessForMetadata) ", headless" else ""}) written to: ${platformMetadataDir.get().asFile}", + ) } } @@ -723,6 +732,7 @@ internal fun JvmApplicationContext.configureGraalvmApplication() { "Filter and merge per-library GraalVM metadata based on runtime classpath" task.group = NUCLEUS_TASK_GROUP task.outputDir.set(libraryMetadataDir) + task.headless.set(graalvm.headless) if (runtimeCfg != null) { task.runtimeClasspath.from(runtimeCfg) } @@ -916,6 +926,7 @@ internal fun JvmApplicationContext.configureGraalvmApplication() { val resolvedMaxHeapSizePercent = graalvm.maxHeapSizePercent.get() val resolvedGarbageCollector = graalvm.garbageCollector.orNull val resolvedImageName = imageName.get() + val resolvedHeadless = graalvm.headless.get() val resolvedUberJar = uberJarFile.get().asFile.absolutePath val resolvedMacOsMinVersion = if (currentOS == OS.MacOS) graalvm.macOS.minimumSystemVersion.get() else null @@ -991,6 +1002,7 @@ internal fun JvmApplicationContext.configureGraalvmApplication() { inputs.property("pgoMode", resolvedPgoMode) inputs.property("pgoEnabled", resolvedPgoEnabled) inputs.property("imageName", resolvedImageName) + inputs.property("headless", resolvedHeadless) if (resolvedMacOsMinVersion != null) { inputs.property("macOsMinVersion", resolvedMacOsMinVersion) } @@ -1038,6 +1050,9 @@ internal fun JvmApplicationContext.configureGraalvmApplication() { // in as a launcher default so the produced binary never prints the warning // and stays forward-compatible. Placed before user buildArgs (last wins). add("--enable-native-access=ALL-UNNAMED") + if (resolvedHeadless) { + add("-Djava.awt.headless=true") + } // Garbage collector + default runtime max heap. Serial GC otherwise defaults // to 80% of RAM; bake a desktop-appropriate ceiling (JVM parity, ~25%) @@ -1268,6 +1283,7 @@ internal fun JvmApplicationContext.configureGraalvmApplication() { ) OS.Linux -> configureLinuxGraalvmPackaging( + graalvm, graalvmHome, nativeImageCompile, nativeCompileDir, @@ -2038,7 +2054,10 @@ private fun JvmApplicationContext.configureWindowsGraalvmPackaging( taskNameObject = "graalvmNative", ) { description = "Build native image and package with DLLs" - dependsOn(copyBinary, copyAppResources, copyAwtDlls, copyJvmDll, copyJawtToBin, copySkikoLib, copyFontConfig) + dependsOn(copyBinary, copyAppResources) + if (!graalvm.headless.get()) { + dependsOn(copyAwtDlls, copyJvmDll, copyJawtToBin, copySkikoLib, copyFontConfig) + } copyCRuntime?.let { dependsOn(it) } } } @@ -2049,12 +2068,14 @@ private fun JvmApplicationContext.configureWindowsGraalvmPackaging( @Suppress("LongParameterList") private fun JvmApplicationContext.configureLinuxGraalvmPackaging( + graalvm: GraalvmSettings, graalvmHome: org.gradle.api.provider.Provider, nativeImageCompile: TaskProvider, nativeCompileDir: org.gradle.api.provider.Provider, imageName: org.gradle.api.provider.Provider, packageUberJar: TaskProvider, ): TaskProvider { + val headless = graalvm.headless.get() val outputDir = graalvmOutputDir.map { it.dir(resolvedPackageNameProvider().get()) } val copyBinary = @@ -2198,18 +2219,17 @@ private fun JvmApplicationContext.configureLinuxGraalvmPackaging( taskNameObject = "graalvmNative", ) { description = "Build native image and package with .so libs" - dependsOn( - copyBinary, - copyAppResources, - copyAwtSoLibs, - copyJvmSo, - copyJawtToLib, - copySkikoLib, - fixRpath, - fixSoRpath, - stripSoLibs, - stripBinary, - ) + dependsOn(copyBinary, copyAppResources, fixRpath, stripBinary) + if (!headless) { + dependsOn( + copyAwtSoLibs, + copyJvmSo, + copyJawtToLib, + copySkikoLib, + fixSoRpath, + stripSoLibs, + ) + } } } diff --git a/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/internal/mergeNativeImageConfig.kt b/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/internal/mergeNativeImageConfig.kt index 646e1a004..aa12b7b0b 100644 --- a/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/internal/mergeNativeImageConfig.kt +++ b/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/internal/mergeNativeImageConfig.kt @@ -539,16 +539,40 @@ internal fun writePlatformMetadata( platform: String, outputDir: File, mainClass: String? = null, + headless: Boolean = false, ) { + outputDir.mkdirs() + val targetFile = File(outputDir, "reachability-metadata.json") + val mainClassEntry = + if (mainClass.isNullOrBlank()) { + null + } else { + mutableMapOf( + "type" to mainClass, + "jniAccessible" to true, + "methods" to + listOf( + mapOf( + "name" to "main", + "parameterTypes" to listOf("java.lang.String[]"), + ), + ), + ) + } + if (headless) { + val reflection = mutableListOf() + if (mainClassEntry != null) reflection.add(mainClassEntry) + targetFile.writeText( + JsonOutput.prettyPrint(JsonOutput.toJson(mapOf("reflection" to reflection))) + "\n", + ) + return + } val resourcePath = "nucleus/graalvm/platform-metadata/$platform-reachability-metadata.json" val stream = object {}::class.java.classLoader.getResourceAsStream(resourcePath) ?: return - outputDir.mkdirs() - val targetFile = File(outputDir, "reachability-metadata.json") - - if (mainClass.isNullOrBlank()) { + if (mainClassEntry == null) { stream.bufferedReader().use { reader -> targetFile.writeText(reader.readText()) } @@ -566,18 +590,6 @@ internal fun writePlatformMetadata( (root["reflection"] as? MutableList) ?: mutableListOf().also { root["reflection"] = it } - val mainClassEntry = - mutableMapOf( - "type" to mainClass, - "jniAccessible" to true, - "methods" to - listOf( - mapOf( - "name" to "main", - "parameterTypes" to listOf("java.lang.String[]"), - ), - ), - ) reflection.add(0, mainClassEntry) targetFile.writeText(JsonOutput.prettyPrint(JsonOutput.toJson(root)) + "\n")