Skip to content
Open
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
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 } }`
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@ abstract class GraalvmSettings
) {
val isEnabled: Property<Boolean> = 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<Boolean> = 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")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -24,6 +26,9 @@ import java.io.File
*/
@CacheableTask
abstract class FilterLibraryMetadataTask : DefaultTask() {
@get:Input
abstract val headless: Property<Boolean>

/** The runtime classpath JARs/dirs to check for conditional library presence. */
@get:InputFiles
@get:PathSensitive(PathSensitivity.NONE)
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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",
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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}",
)
}
}

Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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%)
Expand Down Expand Up @@ -1268,6 +1283,7 @@ internal fun JvmApplicationContext.configureGraalvmApplication() {
)
OS.Linux ->
configureLinuxGraalvmPackaging(
graalvm,
graalvmHome,
nativeImageCompile,
nativeCompileDir,
Expand Down Expand Up @@ -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) }
}
}
Expand All @@ -2049,12 +2068,14 @@ private fun JvmApplicationContext.configureWindowsGraalvmPackaging(

@Suppress("LongParameterList")
private fun JvmApplicationContext.configureLinuxGraalvmPackaging(
graalvm: GraalvmSettings,
graalvmHome: org.gradle.api.provider.Provider<String>,
nativeImageCompile: TaskProvider<Exec>,
nativeCompileDir: org.gradle.api.provider.Provider<org.gradle.api.file.Directory>,
imageName: org.gradle.api.provider.Provider<String>,
packageUberJar: TaskProvider<Jar>,
): TaskProvider<DefaultTask> {
val headless = graalvm.headless.get()
val outputDir = graalvmOutputDir.map { it.dir(resolvedPackageNameProvider().get()) }

val copyBinary =
Expand Down Expand Up @@ -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,
)
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, Any?>(
"type" to mainClass,
"jniAccessible" to true,
"methods" to
listOf(
mapOf(
"name" to "main",
"parameterTypes" to listOf("java.lang.String[]"),
),
),
)
}
if (headless) {
val reflection = mutableListOf<Any?>()
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())
}
Expand All @@ -566,18 +590,6 @@ internal fun writePlatformMetadata(
(root["reflection"] as? MutableList<Any?>)
?: mutableListOf<Any?>().also { root["reflection"] = it }

val mainClassEntry =
mutableMapOf<String, Any?>(
"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")
Expand Down
Loading