From f7fdc9f888e81b8331836ad5e244ceb278d4aaa9 Mon Sep 17 00:00:00 2001 From: Elie Gambache Date: Sun, 1 Nov 2026 20:43:22 +0200 Subject: [PATCH 1/7] feat(graalvm): run the native image under the macOS App Sandbox A Mac App Store PKG of a GraalVM app is now packaged from a second, sandboxed image, built only when such a format is configured: - third-party native libraries are extracted at build time into Contents/Frameworks and their load sites routed to NucleusSandboxLoader, reusing the JVM store pipeline's strip/extract tasks; - the image is compiled without Substrate signal handling, whose named POSIX semaphore the sandbox denies; - GraalVmInitializer wires Frameworks, the shim manifest and JNA for that bundle, and points user.home at the container when sandboxed. The regular image, bundle and packages are unchanged. --- CLAUDE.md | 1 + docs/internals/graalvm-app-sandbox.md | 66 +++++ .../graalvm/GraalVmInitializer.kt | 79 +++++ .../graalvm/GraalVmInitializerTest.kt | 39 +++ .../internal/configureGraalvmApplication.kt | 276 ++++++++++++++++-- ...tractAssembleSandboxedGraalvmMacAppTask.kt | 139 +++++++++ 6 files changed, 568 insertions(+), 32 deletions(-) create mode 100644 docs/internals/graalvm-app-sandbox.md create mode 100644 plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/tasks/AbstractAssembleSandboxedGraalvmMacAppTask.kt diff --git a/CLAUDE.md b/CLAUDE.md index 6caa70c29..43417dae8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -99,6 +99,7 @@ Deep dives (why a mechanism is built the way it is, measurements, E2E) live in [ - **Windows NSIS hot update** (every NSIS installer of a JVM app, per-user installs): the app never leaves the screen while it updates. PowerShell scripts are written with a UTF-8 BOM (`writePowerShellScript`). Layout, handoff, multi-instance model, E2E: [docs/internals/windows-nsis-hot-update.md](docs/internals/windows-nsis-hot-update.md) - **Testing updates without publishing**: `nucleus.updater.feedUrl`, `nucleus.updater.simulate`, the `serveUpdateFeed` task and `updater-testing`'s `UpdateFeedServer`. Installed apps honour launch overrides only with `UpdaterConfig.allowLaunchOverrides`; an unpackaged run never installs: [docs/internals/updater-testing.md](docs/internals/updater-testing.md) - **PKG has two channels** (`macOS { pkg { appStore } }`): App Store (sandboxed, never notarized) vs Developer ID (#249). Gate sandbox-sensitive features on `ExecutableRuntime.isSandboxed()`, never on `isPkg()`. Signing identity, install-script shims: [docs/internals/macos-pkg.md](docs/internals/macos-pkg.md) +- **GraalVM + Mac App Store**: a macOS App Store PKG of a GraalVM app is packaged from a second, sandboxed image (`nativeImageCompileSandboxed` → `createSandboxedGraalvmNativeDistributable`), built only when such a format is configured; the regular image is untouched. It runs without Substrate signal handling (`-R:-EnableSignalHandling`: the sandbox denies its named semaphore, so SIGINT/SIGTERM skip shutdown hooks), ships third-party natives in `Contents/Frameworks`, and `GraalVmInitializer` points `user.home` at the container: [docs/internals/graalvm-app-sandbox.md](docs/internals/graalvm-app-sandbox.md) - **Node.js is provisioned, not required**: the plugin downloads Node for electron-builder into `/nucleus/nodejs`, so CI runs no `actions/setup-node`. Precedence, and the `universal-macos` mirror script: [docs/internals/nodejs-provisioning.md](docs/internals/nodejs-provisioning.md) - **Flathub repackages the released `tar.gz`** (no source build, the closed-source path): inside a Flatpak the type comes from `/.flatpak-info` (not the archive's marker), Tao sets the Wayland `app_id` to `FLATPAK_ID`, and GNOME's `button-layout` is read through the Settings portal (GSettings only sees the app's keyfile there). Manifest, local repo loop, lint with Flathub's flags, screenshots: [docs/internals/flathub.md](docs/internals/flathub.md) - **Windows signing covers the app image, not only the installer** (#748): `WindowsAppImageSigner` signs launchers and DLLs (loose and inside JARs) before electron-builder runs: [docs/internals/windows-signing.md](docs/internals/windows-signing.md) diff --git a/docs/internals/graalvm-app-sandbox.md b/docs/internals/graalvm-app-sandbox.md new file mode 100644 index 000000000..ee1f7db2d --- /dev/null +++ b/docs/internals/graalvm-app-sandbox.md @@ -0,0 +1,66 @@ +# GraalVM native image under the macOS App Sandbox + +A macOS App Store PKG of a GraalVM app (`graalvm { isEnabled = true }` + `TargetFormat.Pkg` with +`macOS { pkg { appStore = true } }`) is packaged from a **second, sandboxed image**, built beside +the regular one only when such a format is configured. The regular image, its command line and its +inputs are unchanged; DMG, ZIP and Developer ID PKG keep using it. + +## Why a regular image cannot run sandboxed + +Three things kill or cripple a regular native image under `com.apple.security.app-sandbox`: + +1. **Signal handling.** At start-up Substrate's `SunMiscSupport` creates the named POSIX semaphore + `/cSunMiscSignal-`; the sandbox denies `ipc-posix-sem-create` and the image aborts before + `main`. The sandboxed image is compiled with `-R:-EnableSignalHandling` (a runtime option baked + as the default). Consequence: **SIGINT / SIGTERM do not run shutdown hooks** in the sandboxed + image. A store app quits through the app menu / `requestQuit`, which is unaffected. +2. **`user.home`.** SubstrateVM reads it from the password database (`/Users/`), where + HotSpot follows `HOME` — the container. Every `~/Library/…` path then lands outside the + container and is denied. `GraalVmInitializer` sets `user.home` from `HOME` when + `APP_SANDBOX_CONTAINER_ID` is present. +3. **Native code extracted at run time** (JNA's `jnidispatch`, zstd-kmp, compose-webview, …) is + quarantined inside the sandbox and refused by Gatekeeper behind a prompt. The JVM store pipeline + already solves this; the GraalVM one reuses it. + +## Pipeline (`configureGraalvmApplication.kt`, `registerSandboxedMacGraalvmApp`) + +| Task | Does | +|---|---| +| `stripGraalvmNativeLibsForSandboxing` | `AbstractStripNativeLibsFromJarsTask` on the stripped uber JAR: third-party native libs replaced by markers, their `System.load` call sites rewritten to `NucleusSandboxLoader`, redirect manifest written | +| `stageGraalvmSandboxedJars` | rewritten JAR as `app.jar` + `nucleus-sandbox-shim.jar` | +| `extractGraalvmNativeLibsForSandboxing` | the same libs, extracted at build time for the target arch | +| `nativeImageCompileSandboxed` | `-cp -jar app.jar -R:-EnableSignalHandling` into `graalvm/nativeCompileSandboxed` | +| `createSandboxedGraalvmNativeDistributable` | `AbstractAssembleSandboxedGraalvmMacAppTask`: `ditto` of the regular `.app`, sandboxed binary swapped in (build-version patch, rpath, `strip -x`), libs signed in `Contents/Frameworks`, manifest in `Contents/Resources`, provisioning profile embedded, ad hoc signature with the sandbox entitlements | +| `runSandboxedGraalvmNativeDistributable` | runs it | + +The App Store PKG task packages that bundle and re-signs it with the real identity, like the JVM +store pipeline. Flatpak (the other sandboxed format) stays unsupported for GraalVM. + +## Run time (`GraalVmInitializer.applyMacOsAppSandbox`) + +The sandboxed bundle is recognized by `Contents/Resources/nucleus-sandbox-manifest.properties` +(a data file, kept out of `Contents/MacOS` / `Contents/Frameworks`, where Gatekeeper expects only +code). Then, without overriding any explicit setting: `Contents/Frameworks` joins +`java.library.path`, `nucleus.sandbox.manifest` points the shim at the manifest, and JNA is told +not to unpack (`jna.nounpack`, `jna.nosys`, `jna.boot.library.path`, `jna.library.path`) — the same +properties the sandboxed JVM launcher passes. Any other bundle, and any unsandboxed run, is left +untouched. + +## Verified (Lab, macOS 27, arm64, GraalVM CE 25.4) + +Launched through LaunchServices with the default sandbox entitlements: the Lab reports +`sandboxed`, `checks.json` lands in `~/Library/Containers//Data/Library/Application +Support/NucleusLab` and survives a restart, and the four libraries extracted at build time load from +`Contents/Frameworks` — `libjnidispatch` (Jewel), `libzstd-kmp` (`system.zstd`: round trip +identical, the image resource is the marker), `libcompose_webview_macos` (`rendering.webview`), +`libMacTray` (`shell.tray`). No file access is denied (only `system-info vfs.disk-space`, the +`system.info` probe's own business). `packageGraalvmPkg` packages this bundle. + +**Test-launch trap**: start the sandboxed binary by its absolute path (or `open`). Started as +`./nucleus-lab`, the image inside the sandbox resolves its executable directory wrong, the font +configuration is not found, and composition dies with "Fontconfig head is null". Finder, `open` and +the Dock always pass the absolute path. + +**Memory**: the sandboxed variant doubles native-image work (two compiles, ~8.5 GiB each, run one +after the other). To iterate on packaging only, exclude both compiles: +`-x :app:nativeImageCompile -x :app:nativeImageCompileSandboxed`. diff --git a/graalvm-runtime/src/main/kotlin/dev/nucleusframework/graalvm/GraalVmInitializer.kt b/graalvm-runtime/src/main/kotlin/dev/nucleusframework/graalvm/GraalVmInitializer.kt index 495426a0e..0a6fbe491 100644 --- a/graalvm-runtime/src/main/kotlin/dev/nucleusframework/graalvm/GraalVmInitializer.kt +++ b/graalvm-runtime/src/main/kotlin/dev/nucleusframework/graalvm/GraalVmInitializer.kt @@ -52,6 +52,8 @@ public object GraalVmInitializer { System.setProperty("java.library.path", "$execDir$sep$execDir${File.separator}bin") resetLibraryPathCache() + applyMacOsAppSandbox(execDir) + // Early charset init Charset.defaultCharset() @@ -83,6 +85,83 @@ public object GraalVmInitializer { } } + /** + * Fit a native image to the macOS App Sandbox, which only lets the app write in its container + * and refuses native code extracted at run time (quarantined, then blocked by Gatekeeper). + * + * The sandboxed (App Store) bundle the plugin builds is recognized by the redirect manifest in + * `Contents/Resources`: its third-party native libraries were extracted at build time into + * `Contents/Frameworks`, so they join `java.library.path`, the extract-and-load shim + * (`NucleusSandboxLoader`) is pointed at the manifest, and JNA loads `jnidispatch` from there + * instead of unpacking it — the same properties the sandboxed JVM launcher passes. + * + * Inside the sandbox `user.home` follows `HOME`, the container: SubstrateVM reads it from the + * password database (`/Users/`) where HotSpot follows `HOME`, and every `~/Library/…` + * path would land outside the container and be denied. + * + * Nothing changes for any other bundle or outside the sandbox; an explicit setting wins. + */ + private fun applyMacOsAppSandbox(execDir: String) { + if (Platform.Current != Platform.MacOS) return + val layout = macOsSandboxedLayout(File(execDir)) + if (layout != null) { + System.setProperty( + "java.library.path", + System.getProperty("java.library.path") + File.pathSeparator + layout.librariesDir.absolutePath, + ) + resetLibraryPathCache() + layout.properties.forEach(::setIfAbsent) + } + sandboxedUserHome(System::getenv)?.let { System.setProperty("user.home", it) } + } + + /** What the sandboxed bundle layout adds to the image's environment; see [applyMacOsAppSandbox]. */ + internal class SandboxedLayout( + val librariesDir: File, + val properties: Map, + ) + + /** The sandboxed layout of the bundle [execDir] (`Contents/MacOS`) belongs to, or `null` for any other. */ + internal fun macOsSandboxedLayout(execDir: File): SandboxedLayout? { + val contents = execDir.absoluteFile.normalize().parentFile ?: return null + val manifest = File(contents, "Resources/$SANDBOX_MANIFEST_FILE") + if (!manifest.isFile) return null + val frameworks = File(contents, "Frameworks") + return SandboxedLayout( + librariesDir = frameworks, + properties = + mapOf( + SANDBOX_MANIFEST_PROPERTY to manifest.absolutePath, + "jna.nounpack" to "true", + "jna.nosys" to "true", + "jna.boot.library.path" to frameworks.absolutePath, + "jna.library.path" to frameworks.absolutePath, + ), + ) + } + + /** The container `HOME` to use as `user.home` when sandboxed, or `null` outside the sandbox. */ + internal fun sandboxedUserHome(env: (String) -> String?): String? { + if (env(APP_SANDBOX_CONTAINER_ID_ENV).isNullOrBlank()) return null + return env("HOME")?.takeIf { it.isNotBlank() && File(it).isDirectory } + } + + private fun setIfAbsent( + key: String, + value: String, + ) { + if (System.getProperty(key).isNullOrBlank()) System.setProperty(key, value) + } + + /** Set by macOS in the environment of every sandboxed process. */ + private const val APP_SANDBOX_CONTAINER_ID_ENV = "APP_SANDBOX_CONTAINER_ID" + + /** Written by the plugin's sandboxed pipeline (`SandboxMarkers.MANIFEST_FILENAME`). */ + private const val SANDBOX_MANIFEST_FILE = "nucleus-sandbox-manifest.properties" + + /** Read by the shim (`NucleusSandboxLoader.MANIFEST_PATH_PROPERTY`). */ + private const val SANDBOX_MANIFEST_PROPERTY = "nucleus.sandbox.manifest" + /** * Align [Locale.getDefault] with the macOS UI language under native-image. * diff --git a/graalvm-runtime/src/test/kotlin/dev/nucleusframework/graalvm/GraalVmInitializerTest.kt b/graalvm-runtime/src/test/kotlin/dev/nucleusframework/graalvm/GraalVmInitializerTest.kt index 32bedbd90..cc9ab38de 100644 --- a/graalvm-runtime/src/test/kotlin/dev/nucleusframework/graalvm/GraalVmInitializerTest.kt +++ b/graalvm-runtime/src/test/kotlin/dev/nucleusframework/graalvm/GraalVmInitializerTest.kt @@ -51,6 +51,45 @@ class GraalVmInitializerTest { } } + @Test + fun `only the sandboxed bundle gets the Frameworks and JNA wiring`() { + val bundle = Files.createTempDirectory("nucleus-bundle").toFile() + try { + val execDir = bundle.resolve("Contents/MacOS") + assertTrue(execDir.mkdirs()) + assertTrue(bundle.resolve("Contents/Resources").mkdirs()) + // A regular bundle: nothing to wire. + assertNull(GraalVmInitializer.macOsSandboxedLayout(execDir)) + + val manifest = bundle.resolve("Contents/Resources/nucleus-sandbox-manifest.properties") + manifest.writeText("abc=libjnidispatch.jnilib\n") + val layout = GraalVmInitializer.macOsSandboxedLayout(File(execDir, ".")) + val frameworks = bundle.resolve("Contents/Frameworks").absoluteFile.normalize() + assertEquals(frameworks, layout?.librariesDir?.absoluteFile?.normalize()) + val properties = layout!!.properties + val manifestProperty = File(properties.getValue("nucleus.sandbox.manifest")).normalize() + assertEquals(manifest.absoluteFile.normalize(), manifestProperty) + assertEquals("true", properties["jna.nounpack"]) + assertEquals(frameworks.path, File(properties.getValue("jna.boot.library.path")).normalize().path) + } finally { + bundle.deleteRecursively() + } + } + + @Test + fun `user home follows the container only inside the sandbox`() { + val container = Files.createTempDirectory("nucleus-container").toFile() + try { + val sandboxed = mapOf("APP_SANDBOX_CONTAINER_ID" to "dev.app", "HOME" to container.path) + assertEquals(container.path, GraalVmInitializer.sandboxedUserHome(sandboxed::get)) + assertNull(GraalVmInitializer.sandboxedUserHome(mapOf("HOME" to container.path)::get)) + val missingHome = mapOf("APP_SANDBOX_CONTAINER_ID" to "dev.app", "HOME" to "/nope") + assertNull(GraalVmInitializer.sandboxedUserHome(missingHome::get)) + } finally { + container.deleteRecursively() + } + } + @Test fun `macos locale bridge reports a language tag when loaded`() { if (Platform.Current != Platform.MacOS) return 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 8cfbe4b82..d73de7518 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 @@ -18,6 +18,9 @@ import dev.nucleusframework.desktop.application.internal.files.nucleusNativeDir import dev.nucleusframework.desktop.application.internal.files.skikoLibraryFileName import dev.nucleusframework.desktop.application.internal.files.shipsSkikoBesideExecutable import dev.nucleusframework.desktop.application.internal.files.skikoEntriesDroppedFromImage +import dev.nucleusframework.desktop.application.tasks.AbstractAssembleSandboxedGraalvmMacAppTask +import dev.nucleusframework.desktop.application.tasks.AbstractExtractNativeLibsTask +import dev.nucleusframework.desktop.application.tasks.AbstractStripNativeLibsFromJarsTask import dev.nucleusframework.desktop.application.tasks.AbstractElectronBuilderPackageTask import dev.nucleusframework.desktop.application.tasks.AbstractNotarizationTask import dev.nucleusframework.desktop.application.tasks.AbstractUnpackNucleusNativesTask @@ -37,6 +40,7 @@ import org.gradle.api.artifacts.ProjectDependency import org.gradle.api.file.Directory import org.gradle.api.file.DuplicatesStrategy import org.gradle.api.file.FileCollection +import org.gradle.api.file.RegularFile import org.gradle.api.logging.Logger import org.gradle.api.plugins.JavaPluginExtension import org.gradle.api.provider.Provider @@ -896,15 +900,28 @@ internal fun JvmApplicationContext.configureGraalvmApplication() { } // ── nativeImageCompile ── - - val nativeImageCompile = + // One registration for the regular image and, with a macOS App Store format, the sandboxed + // one: the same arguments and inputs, compiled from other JARs (extra classpath entry) with + // extra arguments placed before the user's buildArgs. Both stay unset for the regular image, + // whose inputs and command line are therefore unchanged. + + fun registerNativeImageCompile( + taskNameObject: String, + taskDescription: String, + uberJarFile: Provider, + compileDir: Provider, + extraClasspath: Provider? = null, + extraArgs: List = emptyList(), + extraDependency: TaskProvider<*>? = null, + ): TaskProvider = tasks.register( taskNameAction = "nativeImage", - taskNameObject = "compile", + taskNameObject = taskNameObject, ) { - description = "Compile the application into a GraalVM native image" + description = taskDescription dependsOn(unpackNucleusNatives) + extraDependency?.let { dependsOn(it) } dependsOn(generatePlatformMetadata) dependsOn(resolveReachabilityMetadata) dependsOn(analyzeStaticMetadata) @@ -913,8 +930,7 @@ internal fun JvmApplicationContext.configureGraalvmApplication() { compileStubs?.let { dependsOn(it) } generateWindowsResources?.let { dependsOn(it) } - val uberJarFile = unpackNucleusNatives.flatMap { it.strippedJar } - val outputDir = nativeCompileDir.get().asFile + val outputDir = compileDir.get().asFile outputs.dir(outputDir) val nativeImageExe = @@ -993,6 +1009,7 @@ internal fun JvmApplicationContext.configureGraalvmApplication() { val resolvedImageName = imageName.get() val resolvedHeadless = graalvm.headless.get() val resolvedUberJar = uberJarFile.get().asFile.absolutePath + val resolvedExtraClasspath = extraClasspath?.get()?.asFile?.absolutePath val resolvedMacOsMinVersion = if (currentOS == OS.MacOS) graalvm.macOS.minimumSystemVersion.get() else null // Keep File (not path strings) so inputs and -H:NativeLinkerOption share one value. @@ -1020,6 +1037,8 @@ internal fun JvmApplicationContext.configureGraalvmApplication() { // fileTree / files() — inputs.dir(...).optional() still fails validation in // Gradle 9 when the path is set but missing. inputs.file(uberJarFile) + extraClasspath?.let { inputs.file(it).withPropertyName("extraClasspath") } + if (extraArgs.isNotEmpty()) inputs.property("extraArgs", extraArgs) inputs .files(project.fileTree(resolvedConfigDir)) .withPropertyName("nativeImageConfigDir") @@ -1088,6 +1107,10 @@ internal fun JvmApplicationContext.configureGraalvmApplication() { // (static analysis dir, metadata repo dirs file, …) exist on disk. val builtArgs = buildList { + if (resolvedExtraClasspath != null) { + add("-cp") + add(resolvedExtraClasspath) + } add("-jar") add(resolvedUberJar) add("-o") @@ -1300,6 +1323,7 @@ internal fun JvmApplicationContext.configureGraalvmApplication() { } } + addAll(extraArgs) addAll(resolvedBuildArgs) } @@ -1324,6 +1348,14 @@ internal fun JvmApplicationContext.configureGraalvmApplication() { } } + val nativeImageCompile = + registerNativeImageCompile( + taskNameObject = "compile", + taskDescription = "Compile the application into a GraalVM native image", + uberJarFile = unpackNucleusNatives.flatMap { it.strippedJar }, + compileDir = nativeCompileDir, + ) + // ── Platform-specific packaging ── val packageGraalvmNative: TaskProvider = @@ -1361,6 +1393,22 @@ internal fun JvmApplicationContext.configureGraalvmApplication() { ) } + // ── Sandboxed (Mac App Store) variant ── + + val createSandboxedGraalvmApp = + if (currentOS == OS.MacOS && hasMacAppStoreFormat()) { + registerSandboxedMacGraalvmApp( + graalvm, + unpackNucleusNatives, + unpackDefaultResources, + packageGraalvmNative, + imageName, + ::registerNativeImageCompile, + ) + } else { + null + } + // ── Run the native image ── val packagedBinaryFile = @@ -1491,7 +1539,152 @@ internal fun JvmApplicationContext.configureGraalvmApplication() { // ── Electron-builder integration ── - configureGraalvmElectronBuilderPackaging(packageGraalvmNative, unpackDefaultResources, imageName) + configureGraalvmElectronBuilderPackaging( + packageGraalvmNative, + unpackDefaultResources, + imageName, + createSandboxedGraalvmApp, + ) +} + +/** A macOS App Store PKG among the target formats: the one sandboxed format the GraalVM pipeline builds. */ +private fun JvmApplicationContext.hasMacAppStoreFormat(): Boolean = + app.nativeDistributions.targetFormats.any { + app.nativeDistributions.isSandboxed(it) && it.isCompatibleWith(OS.MacOS) + } + +/** Registers a native-image compile task; see `registerNativeImageCompile` in [configureGraalvmApplication]. */ +private typealias NativeImageCompileRegistration = ( + taskNameObject: String, + taskDescription: String, + uberJarFile: Provider, + compileDir: Provider, + extraClasspath: Provider?, + extraArgs: List, + extraDependency: TaskProvider<*>?, +) -> TaskProvider + +private val JvmApplicationContext.sandboxedGraalvmOutputDir: Provider + get() = app.nativeDistributions.outputBaseDir.map { it.dir("$appDirName/graalvm-app-sandboxed") } + +/** + * The sandboxed (Mac App Store) GraalVM app, alongside the regular one, as the JVM pipeline + * builds `createSandboxedDistributable` alongside `createDistributable`: + * - the image's JARs get the sandboxed pipeline's treatment ([AbstractStripNativeLibsFromJarsTask]): + * native libraries replaced by markers, extract-and-load call sites routed to the + * `NucleusSandboxLoader` shim, compiled in with the image; + * - the libraries themselves are extracted at build time ([AbstractExtractNativeLibsTask]), to ship + * signed in `Contents/Frameworks`; + * - the image is compiled again from those JARs without Substrate's signal handling + * (`-R:-EnableSignalHandling`): it creates a named POSIX semaphore (`/cSunMiscSignal-`) the + * App Sandbox only allows under an App Group prefix, so the image died at start-up. The cost is + * that SIGINT/SIGTERM no longer run shutdown hooks, which a sandboxed GUI app does not receive + * (it quits through AppKit, and System.exit still runs them); + * - [AbstractAssembleSandboxedGraalvmMacAppTask] derives the bundle from the regular one. + * + * The regular image, bundle and packages are left untouched. + */ +@Suppress("LongParameterList") +private fun JvmApplicationContext.registerSandboxedMacGraalvmApp( + graalvm: GraalvmSettings, + unpackNucleusNatives: TaskProvider, + unpackDefaultResources: TaskProvider, + packageGraalvmNative: TaskProvider, + imageName: Provider, + registerNativeImageCompile: NativeImageCompileRegistration, +): TaskProvider { + val sandboxedJar = unpackNucleusNatives.flatMap { it.strippedJar } + val stripNativeLibs = + tasks.register( + taskNameAction = "strip", + taskNameObject = "graalvmNativeLibsForSandboxing", + ) { + inputJars.from(sandboxedJar) + mainJarName.set(sandboxedJar.map { it.asFile.name }) + outputDir.set(appTmpDir.map { it.dir("graalvm/sandboxed/rewritten-jars") }) + manifestOutputDir.set(appTmpDir.map { it.dir("graalvm/sandboxed/manifest") }) + // Matched against JAR names: the image is compiled from one uber JAR, so the escape + // hatch can only keep everything (or nothing) verbatim here. + keepNativeLibsInJar.set(provider { app.nativeDistributions.sandboxing.keepNativeLibsInJars }) + } + // The rewritten JAR's name is content-mangled and only known once the strip ran: copy it, and + // the shim, under fixed names the compile task can declare up front. + val compileJarsDir = appTmpDir.map { it.dir("graalvm/sandboxed/compile-jars") } + val stageSandboxedJars = + tasks.register( + taskNameAction = "stage", + taskNameObject = "graalvmSandboxedJars", + ) { + from(stripNativeLibs.flatMap { it.outputDir }) { + it.include("*.jar") + it.exclude(SandboxMarkers.SHIM_JAR_NAME) + it.rename { "app.jar" } + } + from(stripNativeLibs.flatMap { it.outputDir }) { + it.include(SandboxMarkers.SHIM_JAR_NAME) + } + into(compileJarsDir) + } + val extractNativeLibs = + tasks.register( + taskNameAction = "extract", + taskNameObject = "graalvmNativeLibsForSandboxing", + ) { + inputJars.from(sandboxedJar) + targetOs.set("macos") + targetArch.set(if (currentArch == Arch.Arm64) "arm64" else "x64") + outputDir.set(appTmpDir.map { it.dir("graalvm/sandboxed/native-libs") }) + } + + val sandboxedCompileDir = appTmpDir.map { it.dir("graalvm/nativeCompileSandboxed") } + val compileSandboxed = + registerNativeImageCompile( + "compileSandboxed", + "Compile the sandboxed (Mac App Store) GraalVM native image", + compileJarsDir.map { it.file("app.jar") }, + sandboxedCompileDir, + compileJarsDir.map { it.file(SandboxMarkers.SHIM_JAR_NAME) }, + listOf("-R:-EnableSignalHandling"), + stageSandboxedJars, + ) + + val mac = app.nativeDistributions.macOS + val assemble = + tasks.register( + taskNameAction = "create", + taskNameObject = "sandboxedGraalvmNativeDistributable", + ) { + description = "Create the sandboxed (Mac App Store) GraalVM native app distributable" + dependsOn(packageGraalvmNative, compileSandboxed, extractNativeLibs, stripNativeLibs) + regularAppRoot.set(graalvmOutputDir) + bundleName.set(resolvedMacBundleNameProvider().map { "$it.app" }) + executableName.set(imageName) + sandboxedBinary.set(sandboxedCompileDir.zip(imageName) { dir, name -> dir.file(name) }) + nativeLibsDir.set(extractNativeLibs.flatMap { it.outputDir }) + sandboxManifest.set(stripNativeLibs.flatMap { it.manifestOutputDir.file(SandboxMarkers.MANIFEST_FILENAME) }) + entitlementsFile.set( + mac.entitlementsFile.orElse(unpackDefaultResources.flatMap { it.resources.defaultSandboxEntitlements }), + ) + provisioningProfile.set(mac.provisioningProfile) + minimumSystemVersion.set(graalvm.macOS.minimumSystemVersion) + sdkVersion.set(graalvm.macOS.macOsSdkVersion) + destinationDir.set(sandboxedGraalvmOutputDir) + } + + tasks.register( + taskNameAction = "run", + taskNameObject = "sandboxedGraalvmNativeDistributable", + ) { + description = "Build and run the sandboxed (Mac App Store) GraalVM native app" + dependsOn(assemble) + val binary = + sandboxedGraalvmOutputDir.map { + it.file("${resolvedMacBundleNameProvider().get()}.app/Contents/MacOS/${imageName.get()}") + } + executable = binary.get().asFile.absolutePath + args = app.args + } + return assemble } // ═══════════════════════════════════════════════════════════════════ @@ -2520,27 +2713,40 @@ private fun JvmApplicationContext.configureGraalvmElectronBuilderPackaging( packageGraalvmNative: TaskProvider, unpackDefaultResources: TaskProvider, imageName: org.gradle.api.provider.Provider, + sandboxedMacApp: TaskProvider?, ) { + val formats = app.nativeDistributions.targetFormats val ebFormats = - app.nativeDistributions.targetFormats - .filter { it.backend == PackagingBackend.ELECTRON_BUILDER && !app.nativeDistributions.isSandboxed(it) } + formats.filter { it.backend == PackagingBackend.ELECTRON_BUILDER && !app.nativeDistributions.isSandboxed(it) } + // The Mac App Store PKG is built from the sandboxed app (registerSandboxedMacGraalvmApp). + val macStoreFormats = + if (sandboxedMacApp != null) { + formats.filter { app.nativeDistributions.isSandboxed(it) && it.isCompatibleWith(OS.MacOS) } + } else { + emptyList() + } val droppedStoreFormats = - app.nativeDistributions.targetFormats - .filter { app.nativeDistributions.isSandboxed(it) && it.isCompatibleWithCurrentOS } + formats.filter { + app.nativeDistributions.isSandboxed(it) && it.isCompatibleWithCurrentOS && it !in macStoreFormats + } if (droppedStoreFormats.isNotEmpty()) { // info, not warn: the configuration is legitimate and nothing is lost overall — the JVM - // packagePkg still builds the store package. Only the GraalVM-native variant is skipped, + // package task still builds the store package. Only the GraalVM-native variant is skipped, // and warning on every configuration would fire on any project combining the two. project.logger.info( - "GraalVM native image does not support the sandboxed (store) pipeline, so no " + + "GraalVM native image builds no sandboxed package but the Mac App Store PKG, so no " + "packageGraalvm task is registered for ${droppedStoreFormats.joinToString { it.name }}; " + - "the JVM package task still builds it. For a native PKG use " + - "macOS { pkg { appStore = false } } (Developer ID).", + "the JVM package task still builds it.", ) } - for (targetFormat in ebFormats) { + fun registerPackage( + targetFormat: TargetFormat, + appRoot: Provider, + appTask: TaskProvider, + sandboxed: Boolean, + ) { val packageFormat = tasks.register( taskNameAction = "packageGraalvm", @@ -2548,10 +2754,10 @@ private fun JvmApplicationContext.configureGraalvmElectronBuilderPackaging( args = listOf(targetFormat), ) { enabled = targetFormat.isCompatibleWithCurrentOS - dependsOn(packageGraalvmNative, unpackDefaultResources) + dependsOn(appTask, unpackDefaultResources) // The app image root is the output directory from the native packaging step - appImageRoot.set(graalvmOutputDir) + appImageRoot.set(appRoot) destinationDir.set( app.nativeDistributions.outputBaseDir.map { @@ -2565,7 +2771,7 @@ private fun JvmApplicationContext.configureGraalvmElectronBuilderPackaging( // Only wire platform-specific icons/entitlements for the current OS // to avoid validation errors from missing cross-platform files. - configureGraalvmPlatformPackageSettings(this, targetFormat, unpackDefaultResources) + configureGraalvmPlatformPackageSettings(this, targetFormat, unpackDefaultResources, sandboxed) executableName.set(imageName) runtimeAppId.set(resolvedAppIdProvider()) @@ -2591,6 +2797,15 @@ private fun JvmApplicationContext.configureGraalvmElectronBuilderPackaging( } } } + + for (targetFormat in ebFormats) { + registerPackage(targetFormat, graalvmOutputDir, packageGraalvmNative, sandboxed = false) + } + if (sandboxedMacApp != null) { + for (targetFormat in macStoreFormats) { + registerPackage(targetFormat, sandboxedGraalvmOutputDir, sandboxedMacApp, sandboxed = true) + } + } } /** Wires the icons, entitlements and signing settings of the current OS into an electron-builder [task]. */ @@ -2598,6 +2813,7 @@ private fun JvmApplicationContext.configureGraalvmPlatformPackageSettings( task: AbstractElectronBuilderPackageTask, targetFormat: TargetFormat, unpackDefaultResources: TaskProvider, + sandboxed: Boolean, ) { when (currentOS) { OS.Linux -> { @@ -2635,23 +2851,19 @@ private fun JvmApplicationContext.configureGraalvmPlatformPackageSettings( val mac = app.nativeDistributions.macOS task.nonValidatedMacSigningSettings = mac.signing task.nonValidatedMacBundleID.set(mac.bundleID) - // Sandboxed formats are filtered out by the caller, so a PKG reaching this point is - // always Developer ID — the GraalVM pipeline does not build store packages. - task.macAppStore.set(false) + // A sandboxed PKG is the Mac App Store one, built from the sandboxed app; any other + // PKG is Developer ID. + task.macAppStore.set(sandboxed) if (targetFormat == TargetFormat.Pkg) { task.macPkgPreInstall.set(mac.pkg.preInstall) task.macPkgPostInstall.set(mac.pkg.postInstall) } - task.macEntitlementsFile.set( - mac.entitlementsFile.orElse( - unpackDefaultResources.flatMap { it.resources.defaultEntitlements }, - ), - ) - task.macRuntimeEntitlementsFile.set( - mac.runtimeEntitlementsFile.orElse( - unpackDefaultResources.flatMap { it.resources.defaultEntitlements }, - ), - ) + val defaultEntitlements = + unpackDefaultResources.flatMap { + if (sandboxed) it.resources.defaultSandboxEntitlements else it.resources.defaultEntitlements + } + task.macEntitlementsFile.set(mac.entitlementsFile.orElse(defaultEntitlements)) + task.macRuntimeEntitlementsFile.set(mac.runtimeEntitlementsFile.orElse(defaultEntitlements)) task.macAppExtensions.set(mac.appExtensions.extensions) task.macAppExtensionFiles.from( mac.appExtensions.extensions.flatMap { diff --git a/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/tasks/AbstractAssembleSandboxedGraalvmMacAppTask.kt b/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/tasks/AbstractAssembleSandboxedGraalvmMacAppTask.kt new file mode 100644 index 000000000..dd53d7e3e --- /dev/null +++ b/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/tasks/AbstractAssembleSandboxedGraalvmMacAppTask.kt @@ -0,0 +1,139 @@ +package dev.nucleusframework.desktop.application.tasks + +import dev.nucleusframework.desktop.application.internal.patchMachOBuildVersion +import dev.nucleusframework.desktop.tasks.AbstractNucleusTask +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.file.RegularFileProperty +import org.gradle.api.provider.Property +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.InputDirectory +import org.gradle.api.tasks.InputFile +import org.gradle.api.tasks.Optional +import org.gradle.api.tasks.OutputDirectory +import org.gradle.api.tasks.PathSensitive +import org.gradle.api.tasks.PathSensitivity +import org.gradle.api.tasks.TaskAction +import org.gradle.work.DisableCachingByDefault +import java.io.File +import java.nio.file.Files +import java.nio.file.StandardCopyOption + +/** + * Builds the sandboxed (Mac App Store) GraalVM `.app` from the regular one. + * + * The bundles differ in four places only, so everything else — Info.plist, icons, the AWT, Skiko + * and Nucleus libraries, app extensions — comes from the regular bundle as built: + * - the executable is the sandboxed image ([sandboxedBinary]): compiled from JARs whose + * extract-and-load call sites go through `NucleusSandboxLoader`, and without Substrate's signal + * handling, whose named POSIX semaphore the sandbox denies (the image would die at start-up); + * - the third-party native libraries the JARs carried ([nativeLibsDir]) ship in + * `Contents/Frameworks`, signed, since a library extracted at run time is quarantined inside + * the sandbox and refused by Gatekeeper; + * - the redirect manifest ([sandboxManifest]) ships in `Contents/Resources`, a data file kept out + * of `Contents/MacOS` and `Contents/Frameworks` where Gatekeeper expects only code; + * - the app is signed (ad hoc) with the sandbox [entitlementsFile], and the + * [provisioningProfile], when set, is embedded. The App Store PKG re-signs it with the real + * identity. + * + * `GraalVmInitializer` recognizes the bundle by the manifest and wires the rest at run time. + */ +@DisableCachingByDefault(because = "Copies and signs an app bundle") +abstract class AbstractAssembleSandboxedGraalvmMacAppTask : AbstractNucleusTask() { + /** The directory holding the regular `.app`. */ + @get:InputDirectory + @get:PathSensitive(PathSensitivity.RELATIVE) + abstract val regularAppRoot: DirectoryProperty + + /** `.app`. */ + @get:Input + abstract val bundleName: Property + + @get:Input + abstract val executableName: Property + + @get:InputFile + @get:PathSensitive(PathSensitivity.NONE) + abstract val sandboxedBinary: RegularFileProperty + + @get:InputDirectory + @get:PathSensitive(PathSensitivity.RELATIVE) + abstract val nativeLibsDir: DirectoryProperty + + @get:InputFile + @get:PathSensitive(PathSensitivity.NONE) + abstract val sandboxManifest: RegularFileProperty + + @get:InputFile + @get:PathSensitive(PathSensitivity.NONE) + abstract val entitlementsFile: RegularFileProperty + + @get:InputFile + @get:Optional + @get:PathSensitive(PathSensitivity.NONE) + abstract val provisioningProfile: RegularFileProperty + + @get:Input + abstract val minimumSystemVersion: Property + + @get:Input + abstract val sdkVersion: Property + + @get:OutputDirectory + abstract val destinationDir: DirectoryProperty + + /** Copies the regular bundle, swaps in the sandboxed pieces and signs the result. */ + @TaskAction + fun assemble() { + val outRoot = destinationDir.get().asFile + if (outRoot.exists()) outRoot.deleteRecursively() + outRoot.mkdirs() + val source = regularAppRoot.get().asFile.resolve(bundleName.get()) + check(source.isDirectory) { "No regular GraalVM app bundle at $source" } + val app = outRoot.resolve(bundleName.get()) + // ditto keeps symlinks, extended attributes and the nested signatures (app extensions). + run("/usr/bin/ditto", source.absolutePath, app.absolutePath) + val contents = app.resolve("Contents") + + val binary = contents.resolve("MacOS/${executableName.get()}") + Files.copy(sandboxedBinary.get().asFile.toPath(), binary.toPath(), StandardCopyOption.REPLACE_EXISTING) + binary.setExecutable(true) + // The same post-processing the regular binary gets, in the same order. + patchMachOBuildVersion(binary, minimumSystemVersion.get(), sdkVersion.get(), logger) + run("/usr/bin/install_name_tool", "-add_rpath", "@executable_path/.", binary.absolutePath, ignoreFailure = true) + run("/usr/bin/strip", "-x", binary.absolutePath) + + val frameworks = contents.resolve("Frameworks").apply { mkdirs() } + val libs = nativeLibsDir.get().asFile.listFiles { file -> file.isFile }.orEmpty().sortedBy { it.name } + for (lib in libs) { + val target = frameworks.resolve(lib.name) + lib.copyTo(target, overwrite = true) + run("/usr/bin/codesign", "--force", "--sign", "-", target.absolutePath) + } + val manifest = sandboxManifest.get().asFile + manifest.copyTo(contents.resolve("Resources/${manifest.name}"), overwrite = true) + provisioningProfile.orNull?.asFile?.copyTo(contents.resolve("embedded.provisionprofile"), overwrite = true) + + // Seal the bundle: the main executable gets the sandbox entitlements; nested code keeps + // its own signature (dylibs above, app extensions as copied). + run( + "/usr/bin/codesign", + "--force", + "--sign", + "-", + "--entitlements", + entitlementsFile.get().asFile.absolutePath, + app.absolutePath, + ) + logger.lifecycle("Sandboxed GraalVM app: $app (${libs.size} native lib(s) in Contents/Frameworks)") + } + + private fun run( + vararg command: String, + ignoreFailure: Boolean = false, + ) { + val result = execOperations.exec { it.commandLine(*command).isIgnoreExitValue = true } + if (result.exitValue != 0 && !ignoreFailure) { + error("${File(command.first()).name} failed (exit ${result.exitValue}): ${command.joinToString(" ")}") + } + } +} From 9322fc9bbc73185509bfa93cdb9459a5b3b529a2 Mon Sep 17 00:00:00 2001 From: Elie Gambache Date: Sun, 1 Nov 2026 21:05:38 +0200 Subject: [PATCH 2/7] feat(graalvm): give the sandboxed image its termination signals back The sandboxed image is compiled without Substrate's signal handling, so SIGINT/SIGTERM/SIGHUP killed it without running the shutdown hooks. TerminationSignals receives them through libdispatch (kqueue, allowed by the App Sandbox) and exits like HotSpot: shutdown hooks, then 128 + signal. The handler is an empty sigaction rather than SIG_IGN, which would be inherited across exec and make every child process ignore SIGTERM. --- CLAUDE.md | 2 +- docs/internals/graalvm-app-sandbox.md | 12 +- .../graalvm/GraalVmInitializer.kt | 12 ++ .../graalvm/signal/TerminationSignals.kt | 39 ++++++ .../native/macos/NucleusTerminationSignals.c | 73 +++++++++++ .../src/main/native/macos/build.sh | 6 +- .../reachability-metadata.json | 5 + .../graalvm/signal/TerminationSignalsTest.kt | 113 ++++++++++++++++++ .../internal/configureGraalvmApplication.kt | 6 +- 9 files changed, 259 insertions(+), 9 deletions(-) create mode 100644 graalvm-runtime/src/main/kotlin/dev/nucleusframework/graalvm/signal/TerminationSignals.kt create mode 100644 graalvm-runtime/src/main/native/macos/NucleusTerminationSignals.c create mode 100644 graalvm-runtime/src/test/kotlin/dev/nucleusframework/graalvm/signal/TerminationSignalsTest.kt diff --git a/CLAUDE.md b/CLAUDE.md index 43417dae8..52bc8cfd5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -99,7 +99,7 @@ Deep dives (why a mechanism is built the way it is, measurements, E2E) live in [ - **Windows NSIS hot update** (every NSIS installer of a JVM app, per-user installs): the app never leaves the screen while it updates. PowerShell scripts are written with a UTF-8 BOM (`writePowerShellScript`). Layout, handoff, multi-instance model, E2E: [docs/internals/windows-nsis-hot-update.md](docs/internals/windows-nsis-hot-update.md) - **Testing updates without publishing**: `nucleus.updater.feedUrl`, `nucleus.updater.simulate`, the `serveUpdateFeed` task and `updater-testing`'s `UpdateFeedServer`. Installed apps honour launch overrides only with `UpdaterConfig.allowLaunchOverrides`; an unpackaged run never installs: [docs/internals/updater-testing.md](docs/internals/updater-testing.md) - **PKG has two channels** (`macOS { pkg { appStore } }`): App Store (sandboxed, never notarized) vs Developer ID (#249). Gate sandbox-sensitive features on `ExecutableRuntime.isSandboxed()`, never on `isPkg()`. Signing identity, install-script shims: [docs/internals/macos-pkg.md](docs/internals/macos-pkg.md) -- **GraalVM + Mac App Store**: a macOS App Store PKG of a GraalVM app is packaged from a second, sandboxed image (`nativeImageCompileSandboxed` → `createSandboxedGraalvmNativeDistributable`), built only when such a format is configured; the regular image is untouched. It runs without Substrate signal handling (`-R:-EnableSignalHandling`: the sandbox denies its named semaphore, so SIGINT/SIGTERM skip shutdown hooks), ships third-party natives in `Contents/Frameworks`, and `GraalVmInitializer` points `user.home` at the container: [docs/internals/graalvm-app-sandbox.md](docs/internals/graalvm-app-sandbox.md) +- **GraalVM + Mac App Store**: a macOS App Store PKG of a GraalVM app is packaged from a second, sandboxed image (`nativeImageCompileSandboxed` → `createSandboxedGraalvmNativeDistributable`), built only when such a format is configured; the regular image is untouched. It runs without Substrate signal handling (`-R:-EnableSignalHandling`: the sandbox denies its named semaphore; `TerminationSignals` gives SIGINT/SIGTERM/SIGHUP their shutdown hooks back through libdispatch), ships third-party natives in `Contents/Frameworks`, and `GraalVmInitializer` points `user.home` at the container: [docs/internals/graalvm-app-sandbox.md](docs/internals/graalvm-app-sandbox.md) - **Node.js is provisioned, not required**: the plugin downloads Node for electron-builder into `/nucleus/nodejs`, so CI runs no `actions/setup-node`. Precedence, and the `universal-macos` mirror script: [docs/internals/nodejs-provisioning.md](docs/internals/nodejs-provisioning.md) - **Flathub repackages the released `tar.gz`** (no source build, the closed-source path): inside a Flatpak the type comes from `/.flatpak-info` (not the archive's marker), Tao sets the Wayland `app_id` to `FLATPAK_ID`, and GNOME's `button-layout` is read through the Settings portal (GSettings only sees the app's keyfile there). Manifest, local repo loop, lint with Flathub's flags, screenshots: [docs/internals/flathub.md](docs/internals/flathub.md) - **Windows signing covers the app image, not only the installer** (#748): `WindowsAppImageSigner` signs launchers and DLLs (loose and inside JARs) before electron-builder runs: [docs/internals/windows-signing.md](docs/internals/windows-signing.md) diff --git a/docs/internals/graalvm-app-sandbox.md b/docs/internals/graalvm-app-sandbox.md index ee1f7db2d..7b9bd161e 100644 --- a/docs/internals/graalvm-app-sandbox.md +++ b/docs/internals/graalvm-app-sandbox.md @@ -12,8 +12,16 @@ Three things kill or cripple a regular native image under `com.apple.security.ap 1. **Signal handling.** At start-up Substrate's `SunMiscSupport` creates the named POSIX semaphore `/cSunMiscSignal-`; the sandbox denies `ipc-posix-sem-create` and the image aborts before `main`. The sandboxed image is compiled with `-R:-EnableSignalHandling` (a runtime option baked - as the default). Consequence: **SIGINT / SIGTERM do not run shutdown hooks** in the sandboxed - image. A store app quits through the app menu / `requestQuit`, which is unaffected. + as the default). Left there, SIGINT / SIGTERM / SIGHUP would take their default action and skip + the shutdown hooks, so `GraalVmInitializer` installs `TerminationSignals` for that bundle: the + `nucleus_locale` library receives the three signals through libdispatch (kqueue, no semaphore) + and calls back into Kotlin on a dispatch thread, which exits like HotSpot — shutdown hooks, + then status 128 + signal. `TerminationSignalsTest` checks it on a child JVM started with `-Xrs` + (HotSpot's handlers off, the same state). The handler is an empty `sigaction`, not `SIG_IGN`: + an ignored disposition survives `exec`, so every process the app starts would ignore SIGTERM + (`Process.destroy()` included); a handler is reset on `exec`, as HotSpot's are — the test + checks that too. What does not come back: `sun.misc.Signal.handle` for the app's own signals + (SIGUSR1, …), which needs Substrate's signal handling. 2. **`user.home`.** SubstrateVM reads it from the password database (`/Users/`), where HotSpot follows `HOME` — the container. Every `~/Library/…` path then lands outside the container and is denied. `GraalVmInitializer` sets `user.home` from `HOME` when diff --git a/graalvm-runtime/src/main/kotlin/dev/nucleusframework/graalvm/GraalVmInitializer.kt b/graalvm-runtime/src/main/kotlin/dev/nucleusframework/graalvm/GraalVmInitializer.kt index 0a6fbe491..75dafd9ef 100644 --- a/graalvm-runtime/src/main/kotlin/dev/nucleusframework/graalvm/GraalVmInitializer.kt +++ b/graalvm-runtime/src/main/kotlin/dev/nucleusframework/graalvm/GraalVmInitializer.kt @@ -3,11 +3,13 @@ package dev.nucleusframework.graalvm import dev.nucleusframework.core.runtime.Platform import dev.nucleusframework.graalvm.encoding.PlatformEncodingInitializer import dev.nucleusframework.graalvm.locale.NativeLocaleBridge +import dev.nucleusframework.graalvm.signal.TerminationSignals import dev.nucleusframework.hidpi.applyLinuxHiDpiScale import dev.nucleusframework.hidpi.capLinuxMallocArenas import java.io.File import java.nio.charset.Charset import java.util.Locale +import java.util.logging.Logger public object GraalVmInitializer { public val isNativeImage: Boolean = @@ -95,6 +97,9 @@ public object GraalVmInitializer { * (`NucleusSandboxLoader`) is pointed at the manifest, and JNA loads `jnidispatch` from there * instead of unpacking it — the same properties the sandboxed JVM launcher passes. * + * That image is also compiled without Substrate's signal handling (its named semaphore is denied), + * so [TerminationSignals] gives SIGINT / SIGTERM / SIGHUP their shutdown hooks back. + * * Inside the sandbox `user.home` follows `HOME`, the container: SubstrateVM reads it from the * password database (`/Users/`) where HotSpot follows `HOME`, and every `~/Library/…` * path would land outside the container and be denied. @@ -111,6 +116,13 @@ public object GraalVmInitializer { ) resetLibraryPathCache() layout.properties.forEach(::setIfAbsent) + // This image is compiled without Substrate's signal handling: route SIGINT / SIGTERM / + // SIGHUP to the shutdown hooks ourselves. + if (!TerminationSignals.install()) { + Logger.getLogger(GraalVmInitializer::class.java.name).warning( + "Termination signals unavailable: SIGINT/SIGTERM will skip the shutdown hooks", + ) + } } sandboxedUserHome(System::getenv)?.let { System.setProperty("user.home", it) } } diff --git a/graalvm-runtime/src/main/kotlin/dev/nucleusframework/graalvm/signal/TerminationSignals.kt b/graalvm-runtime/src/main/kotlin/dev/nucleusframework/graalvm/signal/TerminationSignals.kt new file mode 100644 index 000000000..888b85173 --- /dev/null +++ b/graalvm-runtime/src/main/kotlin/dev/nucleusframework/graalvm/signal/TerminationSignals.kt @@ -0,0 +1,39 @@ +package dev.nucleusframework.graalvm.signal + +import dev.nucleusframework.core.runtime.NativeLibraryLoader +import kotlin.system.exitProcess + +/** + * SIGINT / SIGTERM / SIGHUP for a macOS native image compiled without Substrate's signal handling + * — the sandboxed (Mac App Store) image, see [dev.nucleusframework.graalvm.GraalVmInitializer]. + * + * Without it those signals kill the process outright and the shutdown hooks never run. The + * native side receives them through libdispatch (no semaphore, so the App Sandbox allows it) and + * calls [onTerminationSignal] on a dispatch thread, as HotSpot runs them on its signal thread. + * Shares the `nucleus_locale` library, already loaded at start-up on macOS. + */ +internal object TerminationSignals { + private const val LIBRARY_NAME = "nucleus_locale" + + /** Exit status of a process ended by a signal, as HotSpot reports it. */ + private const val SIGNAL_EXIT_BASE = 128 + + private val loaded = NativeLibraryLoader.load(LIBRARY_NAME, TerminationSignals::class.java) + + /** Routes the three signals to the shutdown sequence; `false` when the bridge is unavailable. */ + fun install(): Boolean = loaded && runCatching { nativeInstall() }.getOrDefault(false) + + @JvmStatic + private external fun nativeInstall(): Boolean + + /** Called from native code; never returns. */ + @JvmStatic + fun onTerminationSignal(signal: Int) { + val status = SIGNAL_EXIT_BASE + signal + try { + exitProcess(status) + } catch (_: Throwable) { + Runtime.getRuntime().halt(status) + } + } +} diff --git a/graalvm-runtime/src/main/native/macos/NucleusTerminationSignals.c b/graalvm-runtime/src/main/native/macos/NucleusTerminationSignals.c new file mode 100644 index 000000000..3e8e3e300 --- /dev/null +++ b/graalvm-runtime/src/main/native/macos/NucleusTerminationSignals.c @@ -0,0 +1,73 @@ +#include +#include +#include +#include +#include + +// SIGINT / SIGTERM / SIGHUP for a native image compiled without Substrate's signal handling +// (the sandboxed Mac App Store image: its named POSIX semaphore is denied by the App Sandbox). +// Without this, those signals take their default action and the process dies without running +// the shutdown hooks. libdispatch delivers them through kqueue instead, which needs no +// semaphore; the handler hands the signal to TerminationSignals.onTerminationSignal, which +// exits like HotSpot does (shutdown hooks, then status 128 + signal). + +static JavaVM *g_jvm; +static jclass g_class; +static jmethodID g_callback; + +static void nucleus_signal_noop(int sig) { + (void)sig; +} + +static void nucleus_on_signal(void *context) { + int sig = (int)(intptr_t)context; + JNIEnv *env = NULL; + if ((*g_jvm)->AttachCurrentThread(g_jvm, (void **)&env, NULL) == JNI_OK) { + // Does not return: the callback ends in System.exit (or Runtime.halt). + (*env)->CallStaticVoidMethod(env, g_class, g_callback, (jint)sig); + } + _exit(128 + sig); +} + +JNIEXPORT jboolean JNICALL +Java_dev_nucleusframework_graalvm_signal_TerminationSignals_nativeInstall(JNIEnv *env, jclass clazz) { + if (g_class != NULL) { + return JNI_TRUE; + } + if ((*env)->GetJavaVM(env, &g_jvm) != JNI_OK) { + return JNI_FALSE; + } + // A missing method leaves NoSuchMethodError pending, which the Kotlin caller catches. + g_callback = (*env)->GetStaticMethodID(env, clazz, "onTerminationSignal", "(I)V"); + if (g_callback == NULL) { + return JNI_FALSE; + } + g_class = (jclass)(*env)->NewGlobalRef(env, clazz); + + // Not SIG_IGN: an ignored disposition survives exec, so every child process (ProcessBuilder, + // Process.destroy's SIGTERM) would ignore these signals too. A handler is reset to the default + // on exec, as HotSpot's are. Either way the dispatch source still sees the signal. + struct sigaction action = {0}; + action.sa_handler = nucleus_signal_noop; + action.sa_flags = SA_RESTART; + sigemptyset(&action.sa_mask); + + static const int signals[] = {SIGINT, SIGTERM, SIGHUP}; + dispatch_queue_t queue = dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0); + for (size_t i = 0; i < sizeof(signals) / sizeof(signals[0]); i++) { + int sig = signals[i]; + // Replaces the default action (terminate), which would kill the process before the + // dispatch source runs. + sigaction(sig, &action, NULL); + dispatch_source_t source = + dispatch_source_create(DISPATCH_SOURCE_TYPE_SIGNAL, (uintptr_t)sig, 0, queue); + if (source == NULL) { + continue; + } + dispatch_set_context(source, (void *)(intptr_t)sig); + dispatch_source_set_event_handler_f(source, nucleus_on_signal); + // Kept for the life of the process. + dispatch_resume(source); + } + return JNI_TRUE; +} diff --git a/graalvm-runtime/src/main/native/macos/build.sh b/graalvm-runtime/src/main/native/macos/build.sh index 307bf35e6..43704815e 100755 --- a/graalvm-runtime/src/main/native/macos/build.sh +++ b/graalvm-runtime/src/main/native/macos/build.sh @@ -8,7 +8,7 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -SRC="$SCRIPT_DIR/NucleusLocaleBridge.c" +SRCS=("$SCRIPT_DIR/NucleusLocaleBridge.c" "$SCRIPT_DIR/NucleusTerminationSignals.c") RESOURCE_DIR="$SCRIPT_DIR/../../resources/nucleus/native" OUT_DIR_ARM64="$RESOURCE_DIR/darwin-aarch64" OUT_DIR_X64="$RESOURCE_DIR/darwin-x64" @@ -46,12 +46,12 @@ COMMON_FLAGS=( # Compile for arm64 clang -arch arm64 "${COMMON_FLAGS[@]}" \ - -o "$OUT_DIR_ARM64/libnucleus_locale.dylib" "$SRC" + -o "$OUT_DIR_ARM64/libnucleus_locale.dylib" "${SRCS[@]}" strip -x "$OUT_DIR_ARM64/libnucleus_locale.dylib" # Compile for x86_64 clang -arch x86_64 "${COMMON_FLAGS[@]}" \ - -o "$OUT_DIR_X64/libnucleus_locale.dylib" "$SRC" + -o "$OUT_DIR_X64/libnucleus_locale.dylib" "${SRCS[@]}" strip -x "$OUT_DIR_X64/libnucleus_locale.dylib" # Clear the NativeLibraryLoader cache so a freshly built dylib is not shadowed diff --git a/graalvm-runtime/src/main/resources/META-INF/native-image/dev.nucleusframework/nucleus.graalvm-runtime/reachability-metadata.json b/graalvm-runtime/src/main/resources/META-INF/native-image/dev.nucleusframework/nucleus.graalvm-runtime/reachability-metadata.json index 7b1e58c88..2263316af 100644 --- a/graalvm-runtime/src/main/resources/META-INF/native-image/dev.nucleusframework/nucleus.graalvm-runtime/reachability-metadata.json +++ b/graalvm-runtime/src/main/resources/META-INF/native-image/dev.nucleusframework/nucleus.graalvm-runtime/reachability-metadata.json @@ -1,5 +1,10 @@ { "reflection": [ + { + "type": "dev.nucleusframework.graalvm.signal.TerminationSignals", + "jniAccessible": true, + "methods": [ { "name": "onTerminationSignal", "parameterTypes": ["int"] } ] + }, { "type": "com.sun.media.sound.DirectAudioDeviceProvider", "methods": [ { "name": "", "parameterTypes": [] } ] diff --git a/graalvm-runtime/src/test/kotlin/dev/nucleusframework/graalvm/signal/TerminationSignalsTest.kt b/graalvm-runtime/src/test/kotlin/dev/nucleusframework/graalvm/signal/TerminationSignalsTest.kt new file mode 100644 index 000000000..e27967007 --- /dev/null +++ b/graalvm-runtime/src/test/kotlin/dev/nucleusframework/graalvm/signal/TerminationSignalsTest.kt @@ -0,0 +1,113 @@ +package dev.nucleusframework.graalvm.signal + +import java.io.File +import java.nio.file.Files +import java.util.concurrent.TimeUnit +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * A child JVM started with `-Xrs` installs no handler for SIGINT / SIGTERM / SIGHUP — the state of + * the sandboxed native image, compiled without Substrate's signal handling. With + * [TerminationSignals] installed, each signal must still run the shutdown hooks and exit with + * 128 + signal, as HotSpot does. + */ +class TerminationSignalsTest { + @Test + fun `SIGTERM runs the shutdown hooks and exits 143`() = assertSignalExit("TERM", 143) + + @Test + fun `SIGINT runs the shutdown hooks and exits 130`() = assertSignalExit("INT", 130) + + @Test + fun `SIGHUP runs the shutdown hooks and exits 129`() = assertSignalExit("HUP", 129) + + @Test + fun `a child process started afterwards still dies on SIGTERM`() { + if (!System.getProperty("os.name").startsWith("Mac")) return + val dir = Files.createTempDirectory("nucleus-signals").toFile() + try { + val result = File(dir, "result") + val child = startChild(dir, "spawn", result.path) + assertTrue(child.waitFor(30, TimeUnit.SECONDS), "child hung") + assertEquals("destroyed", result.readText(), File(dir, "child.log").readText()) + } finally { + dir.deleteRecursively() + } + } + + private fun startChild( + dir: File, + vararg args: String, + ): Process = + ProcessBuilder( + listOf( + File(System.getProperty("java.home"), "bin/java").path, + "-Xrs", + "-cp", + System.getProperty("java.class.path"), + TerminationSignalsChild::class.java.name, + ) + args, + ).redirectErrorStream(true) + .redirectOutput(File(dir, "child.log")) + .start() + + private fun assertSignalExit( + signal: String, + expectedStatus: Int, + ) { + if (!System.getProperty("os.name").startsWith("Mac")) return + val dir = Files.createTempDirectory("nucleus-signals").toFile() + try { + val ready = File(dir, "ready") + val hookRan = File(dir, "hook-ran") + val child = startChild(dir, "wait", ready.path, hookRan.path) + val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(30) + while (!ready.exists() && child.isAlive && System.nanoTime() < deadline) Thread.sleep(50) + assertTrue(ready.exists(), "child never got ready: ${File(dir, "child.log").readText()}") + assertEquals("installed", ready.readText()) + + ProcessBuilder("kill", "-$signal", child.pid().toString()).start().waitFor() + assertTrue(child.waitFor(30, TimeUnit.SECONDS), "child ignored SIG$signal") + assertEquals(expectedStatus, child.exitValue()) + assertTrue(hookRan.exists(), "shutdown hook did not run") + } finally { + dir.deleteRecursively() + } + } +} + +/** + * Child process of [TerminationSignalsTest]: installs the handlers, then either waits for a signal + * (`wait `) or checks that a process it starts still dies on SIGTERM + * (`spawn `) — an ignored disposition would be inherited across exec. + */ +object TerminationSignalsChild { + @JvmStatic + fun main(args: Array) { + val installed = TerminationSignals.install() + when (args.first()) { + "wait" -> { + val (ready, hookRan) = args.drop(1).map(::File) + Runtime.getRuntime().addShutdownHook(Thread { hookRan.writeText("yes") }) + ready.writeText(if (installed) "installed" else "unavailable") + Thread.sleep(TimeUnit.MINUTES.toMillis(1)) + } + "spawn" -> { + val sleeper = ProcessBuilder("sleep", "60").start() + Thread.sleep(500) + sleeper.destroy() + val died = sleeper.waitFor(10, TimeUnit.SECONDS) + val result = + when { + !installed -> "unavailable" + died -> "destroyed" + else -> "survived" + } + File(args[1]).writeText(result) + if (!died) sleeper.destroyForcibly() + } + } + } +} 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 d73de7518..b04ee9827 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 @@ -1577,9 +1577,9 @@ private val JvmApplicationContext.sandboxedGraalvmOutputDir: Provider * signed in `Contents/Frameworks`; * - the image is compiled again from those JARs without Substrate's signal handling * (`-R:-EnableSignalHandling`): it creates a named POSIX semaphore (`/cSunMiscSignal-`) the - * App Sandbox only allows under an App Group prefix, so the image died at start-up. The cost is - * that SIGINT/SIGTERM no longer run shutdown hooks, which a sandboxed GUI app does not receive - * (it quits through AppKit, and System.exit still runs them); + * App Sandbox only allows under an App Group prefix, so the image died at start-up. + * `GraalVmInitializer` gives SIGINT / SIGTERM / SIGHUP their shutdown hooks back through + * libdispatch (`TerminationSignals`); * - [AbstractAssembleSandboxedGraalvmMacAppTask] derives the bundle from the regular one. * * The regular image, bundle and packages are left untouched. From f3dadb67ba0c1c60f71afb811c74adc8d4e3ad40 Mon Sep 17 00:00:00 2001 From: Elie Gambache Date: Sun, 1 Nov 2026 22:01:14 +0200 Subject: [PATCH 3/7] docs(graalvm): record the PKG-installed sandboxed run --- docs/internals/graalvm-app-sandbox.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/internals/graalvm-app-sandbox.md b/docs/internals/graalvm-app-sandbox.md index 7b9bd161e..f0e8516bf 100644 --- a/docs/internals/graalvm-app-sandbox.md +++ b/docs/internals/graalvm-app-sandbox.md @@ -64,6 +64,13 @@ identical, the image resource is the marker), `libcompose_webview_macos` (`rende `libMacTray` (`shell.tray`). No file access is denied (only `system-info vfs.disk-space`, the `system.info` probe's own business). `packageGraalvmPkg` packages this bundle. +The copy taken from the PKG payload (re-signed with the hardened runtime, plus the +`.nucleus-executable-type` = `pkg` marker) behaves the same. The marker turns single instance on +(`enableSingleInstance = !isDev()`): the lock lands in the container's `tmp`, a second launch exits +and brings the first window forward. Its FSEvents watch logs one `file-read-data` denial per +ancestor of the container (`/Users`, `/Users/`, …, `Containers/`) — harmless, the +watch works. + **Test-launch trap**: start the sandboxed binary by its absolute path (or `open`). Started as `./nucleus-lab`, the image inside the sandbox resolves its executable directory wrong, the font configuration is not found, and composition dies with "Fontconfig head is null". Finder, `open` and From 46de07b765a4ed0dde88e4cdea9295292f9902ba Mon Sep 17 00:00:00 2001 From: Elie Gambache Date: Sun, 1 Nov 2026 23:41:09 +0200 Subject: [PATCH 4/7] fix(graalvm): resolve the exec dir through symlinks and keep an explicit user.home - execDirOf canonicalizes the launch path: an image started through a symlink (a CLI link in /usr/local/bin) looked for its fonts and libraries next to the link and died with "Fontconfig head is null". A relative launch path is resolved against PWD, since SubstrateVM's user.dir is not the start directory inside the App Sandbox. - Inside the sandbox user.home only moves to the container while it still holds SubstrateVM's default, so an explicit -Duser.home wins. - Document the torture run of the sandboxed Lab. --- docs/internals/graalvm-app-sandbox.md | 19 ++++-- .../graalvm/GraalVmInitializer.kt | 58 +++++++++++++++++-- .../graalvm/GraalVmInitializerTest.kt | 39 +++++++++++-- 3 files changed, 101 insertions(+), 15 deletions(-) diff --git a/docs/internals/graalvm-app-sandbox.md b/docs/internals/graalvm-app-sandbox.md index f0e8516bf..d7d1e7965 100644 --- a/docs/internals/graalvm-app-sandbox.md +++ b/docs/internals/graalvm-app-sandbox.md @@ -71,10 +71,21 @@ and brings the first window forward. Its FSEvents watch logs one `file-read-data ancestor of the container (`/Users`, `/Users/`, …, `Containers/`) — harmless, the watch works. -**Test-launch trap**: start the sandboxed binary by its absolute path (or `open`). Started as -`./nucleus-lab`, the image inside the sandbox resolves its executable directory wrong, the font -configuration is not found, and composition dies with "Fontconfig head is null". Finder, `open` and -the Dock always pass the absolute path. +**Launch path** (`GraalVmInitializer.execDirOf`): on macOS the executable directory comes from +`ProcessHandle…command()`, the path the process was started with. It is canonicalized, so a CLI +symlink (`/usr/local/bin/app` → `…/Contents/MacOS/app`) finds the bundle's fonts and libraries — +before, every image launched through a symlink died with "Fontconfig head is null". A relative +path (`./app`) is resolved against `PWD`: inside the sandbox SubstrateVM's `user.dir` is not the +directory the process started in. A sandboxed image launched through a symlink still dies, in +AppKit ("NSRemoteView encountered app without bundle"): a sandboxed app must run from its bundle, +as LaunchServices always does. + +**Torture run** (sandboxed Lab): every probe opened (25 from the sidebar, 37 by `LAB_PROBE`) with +no crash or exception; the only denials are the sandbox's own business and identical on the JVM +(`AppleSMCClient` for temperatures, `locationd` without the location entitlement, `ps` being +setuid). 20 × SIGTERM, SIGINT + SIGTERM together, SIGTERM 0.3 s into start-up: same exit codes +as the regular image, never a hang. Five simultaneous launches leave one instance; a `kill -9` +leaves no stale lock; a bundle under a path with spaces and accents works. **Memory**: the sandboxed variant doubles native-image work (two compiles, ~8.5 GiB each, run one after the other). To iterate on packaging only, exclude both compiles: diff --git a/graalvm-runtime/src/main/kotlin/dev/nucleusframework/graalvm/GraalVmInitializer.kt b/graalvm-runtime/src/main/kotlin/dev/nucleusframework/graalvm/GraalVmInitializer.kt index 75dafd9ef..04acf4c11 100644 --- a/graalvm-runtime/src/main/kotlin/dev/nucleusframework/graalvm/GraalVmInitializer.kt +++ b/graalvm-runtime/src/main/kotlin/dev/nucleusframework/graalvm/GraalVmInitializer.kt @@ -124,7 +124,9 @@ public object GraalVmInitializer { ) } } - sandboxedUserHome(System::getenv)?.let { System.setProperty("user.home", it) } + sandboxedUserHome(System::getenv, System.getProperty("user.home"))?.let { + System.setProperty("user.home", it) + } } /** What the sandboxed bundle layout adds to the image's environment; see [applyMacOsAppSandbox]. */ @@ -152,10 +154,19 @@ public object GraalVmInitializer { ) } - /** The container `HOME` to use as `user.home` when sandboxed, or `null` outside the sandbox. */ - internal fun sandboxedUserHome(env: (String) -> String?): String? { + /** + * The container `HOME` to use as `user.home` when sandboxed, or `null` to leave [userHome] as is: + * outside the sandbox, or when it is not SubstrateVM's default — the account home the container + * lives in (`/Library/Containers//Data`) — so an explicit `-Duser.home` wins. + */ + internal fun sandboxedUserHome( + env: (String) -> String?, + userHome: String?, + ): String? { if (env(APP_SANDBOX_CONTAINER_ID_ENV).isNullOrBlank()) return null - return env("HOME")?.takeIf { it.isNotBlank() && File(it).isDirectory } + val home = env("HOME")?.takeIf { it.isNotBlank() && File(it).isDirectory } ?: return null + val accountHome = home.substringBefore(CONTAINERS_SEGMENT, missingDelimiterValue = "") + return home.takeIf { userHome.isNullOrBlank() || userHome.trimEnd('/') == accountHome } } private fun setIfAbsent( @@ -168,6 +179,9 @@ public object GraalVmInitializer { /** Set by macOS in the environment of every sandboxed process. */ private const val APP_SANDBOX_CONTAINER_ID_ENV = "APP_SANDBOX_CONTAINER_ID" + /** Where a sandboxed app's container sits under the account home. */ + private const val CONTAINERS_SEGMENT = "/Library/Containers/" + /** Written by the plugin's sandboxed pipeline (`SandboxMarkers.MANIFEST_FILENAME`). */ private const val SANDBOX_MANIFEST_FILE = "nucleus-sandbox-manifest.properties" @@ -245,13 +259,45 @@ public object GraalVmInitializer { // fall through } } - return File( + return execDirOf( ProcessHandle .current() .info() .command() .orElse(""), - ).parentFile?.absolutePath ?: "." + System.getenv("PWD"), + ) + } + + /** + * The directory of the executable [command] names. On macOS the command is the path the + * process was started with, so it is canonicalized: launched through a symlink (a CLI link in + * `/usr/local/bin`) or a relative path (`./app`), its parent would otherwise not be the bundle's + * `Contents/MacOS`, and fonts and libraries would be looked up next to the link. + * + * A relative command is resolved against [workingDir] (the shell's `PWD`) when it is an absolute + * path: inside the App Sandbox SubstrateVM's `user.dir` is not the directory the process was + * started from. + */ + internal fun execDirOf( + command: String, + workingDir: String? = null, + ): String { + if (command.isBlank()) return "." + val started = File(command) + val file = + if (started.isAbsolute || workingDir == null || !File(workingDir).isAbsolute) { + started + } else { + File(workingDir, command) + } + val resolved = + try { + file.canonicalFile + } catch (_: Exception) { + file.absoluteFile + } + return resolved.parentFile?.absolutePath ?: "." } /** diff --git a/graalvm-runtime/src/test/kotlin/dev/nucleusframework/graalvm/GraalVmInitializerTest.kt b/graalvm-runtime/src/test/kotlin/dev/nucleusframework/graalvm/GraalVmInitializerTest.kt index cc9ab38de..190b7e157 100644 --- a/graalvm-runtime/src/test/kotlin/dev/nucleusframework/graalvm/GraalVmInitializerTest.kt +++ b/graalvm-runtime/src/test/kotlin/dev/nucleusframework/graalvm/GraalVmInitializerTest.kt @@ -76,17 +76,46 @@ class GraalVmInitializerTest { } } + @Test + fun `exec dir follows symlinks and relative paths to the real executable`() { + val root = Files.createTempDirectory("nucleus-exec").toFile().canonicalFile + try { + val macOs = root.resolve("App.app/Contents/MacOS").apply { mkdirs() } + val binary = macOs.resolve("app").apply { writeText("") } + val link = root.resolve("bin/app") + link.parentFile.mkdirs() + Files.createSymbolicLink(link.toPath(), binary.toPath()) + + assertEquals(macOs.path, GraalVmInitializer.execDirOf(binary.path)) + assertEquals(macOs.path, GraalVmInitializer.execDirOf(link.path)) + assertEquals(".", GraalVmInitializer.execDirOf("")) + // ./app started from Contents/MacOS, whatever user.dir says. + assertEquals(macOs.path, GraalVmInitializer.execDirOf("./app", macOs.path)) + // A relative symlink path: both resolutions apply. + assertEquals(macOs.path, GraalVmInitializer.execDirOf("bin/app", root.path)) + } finally { + root.deleteRecursively() + } + } + @Test fun `user home follows the container only inside the sandbox`() { - val container = Files.createTempDirectory("nucleus-container").toFile() + val account = Files.createTempDirectory("nucleus-account").toFile() try { + val container = account.resolve("Library/Containers/dev.app/Data").apply { mkdirs() } val sandboxed = mapOf("APP_SANDBOX_CONTAINER_ID" to "dev.app", "HOME" to container.path) - assertEquals(container.path, GraalVmInitializer.sandboxedUserHome(sandboxed::get)) - assertNull(GraalVmInitializer.sandboxedUserHome(mapOf("HOME" to container.path)::get)) + // SubstrateVM's default (the account home) moves to the container. + assertEquals(container.path, GraalVmInitializer.sandboxedUserHome(sandboxed::get, account.path)) + assertEquals(container.path, GraalVmInitializer.sandboxedUserHome(sandboxed::get, null)) + // An explicit -Duser.home wins. + assertNull(GraalVmInitializer.sandboxedUserHome(sandboxed::get, "/explicit/home")) + // Outside the sandbox nothing changes. + val unsandboxed = mapOf("HOME" to container.path) + assertNull(GraalVmInitializer.sandboxedUserHome(unsandboxed::get, account.path)) val missingHome = mapOf("APP_SANDBOX_CONTAINER_ID" to "dev.app", "HOME" to "/nope") - assertNull(GraalVmInitializer.sandboxedUserHome(missingHome::get)) + assertNull(GraalVmInitializer.sandboxedUserHome(missingHome::get, account.path)) } finally { - container.deleteRecursively() + account.deleteRecursively() } } From 2b866e15d3608dd2cf466e4f44ee1263fde4983a Mon Sep 17 00:00:00 2001 From: Elie Gambache Date: Mon, 2 Nov 2026 07:24:27 +0200 Subject: [PATCH 5/7] fix(graalvm): ignore keepNativeLibsInJars for the sandboxed native image The escape hatch matches dependency JAR names, but the image is compiled from a single uber JAR: a substring either matched nothing or kept every native library verbatim, all of them then extracted at run time and refused by the App Sandbox. Every library is now redirected, with a warning when the option is set. --- docs/internals/graalvm-app-sandbox.md | 4 ++++ .../internal/configureGraalvmApplication.kt | 16 +++++++++++++--- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/docs/internals/graalvm-app-sandbox.md b/docs/internals/graalvm-app-sandbox.md index d7d1e7965..c71815470 100644 --- a/docs/internals/graalvm-app-sandbox.md +++ b/docs/internals/graalvm-app-sandbox.md @@ -41,6 +41,10 @@ Three things kill or cripple a regular native image under `com.apple.security.ap | `createSandboxedGraalvmNativeDistributable` | `AbstractAssembleSandboxedGraalvmMacAppTask`: `ditto` of the regular `.app`, sandboxed binary swapped in (build-version patch, rpath, `strip -x`), libs signed in `Contents/Frameworks`, manifest in `Contents/Resources`, provisioning profile embedded, ad hoc signature with the sandbox entitlements | | `runSandboxedGraalvmNativeDistributable` | runs it | +`sandboxing.keepNativeLibsInJars` does not apply (warning): it matches dependency JAR names, and +the image is compiled from one uber JAR, so a substring would match nothing or keep every library +verbatim — all of them then extracted at run time, which the sandbox refuses. + The App Store PKG task packages that bundle and re-signs it with the real identity, like the JVM store pipeline. Flatpak (the other sandboxed format) stays unsupported for GraalVM. 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 b04ee9827..86f40f340 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 @@ -1603,9 +1603,19 @@ private fun JvmApplicationContext.registerSandboxedMacGraalvmApp( mainJarName.set(sandboxedJar.map { it.asFile.name }) outputDir.set(appTmpDir.map { it.dir("graalvm/sandboxed/rewritten-jars") }) manifestOutputDir.set(appTmpDir.map { it.dir("graalvm/sandboxed/manifest") }) - // Matched against JAR names: the image is compiled from one uber JAR, so the escape - // hatch can only keep everything (or nothing) verbatim here. - keepNativeLibsInJar.set(provider { app.nativeDistributions.sandboxing.keepNativeLibsInJars }) + // The escape hatch matches dependency JAR names, and the image has one uber JAR: a + // substring would either match nothing or keep every library verbatim (all of them then + // extracted at run time, which the sandbox refuses). It does not apply here. + keepNativeLibsInJar.set(emptySet()) + val kept = app.nativeDistributions.sandboxing.keepNativeLibsInJars + if (kept.isNotEmpty()) { + logger.warn( + "sandboxing.keepNativeLibsInJars ({}) does not apply to the GraalVM native image: it is " + + "compiled from a single uber JAR, so every native library is redirected to " + + "Contents/Frameworks.", + kept.joinToString(), + ) + } } // The rewritten JAR's name is content-mangled and only known once the strip ran: copy it, and // the shim, under fixed names the compile task can declare up front. From b18d3ccf2044b140e9fef004cd82e0ad63a95b69 Mon Sep 17 00:00:00 2001 From: Elie Gambache Date: Mon, 2 Nov 2026 08:09:42 +0200 Subject: [PATCH 6/7] fix(graalvm): never let a stale PWD break a relative launch execDirOf resolved a relative launch path against PWD, which a launcher that changes directory without updating it (Python subprocess with cwd=) leaves stale: the regular image then died with "Fontconfig head is null". Try user.dir first, then PWD, and keep the one that names the executable. Also cover hostile inputs (trailing slashes, accented paths, a manifest that is a directory, installing the signal handlers twice). --- docs/internals/graalvm-app-sandbox.md | 7 +++- .../graalvm/GraalVmInitializer.kt | 15 ++++--- .../graalvm/GraalVmInitializerTest.kt | 41 +++++++++++++++++-- .../graalvm/signal/TerminationSignalsTest.kt | 16 ++++++-- 4 files changed, 65 insertions(+), 14 deletions(-) diff --git a/docs/internals/graalvm-app-sandbox.md b/docs/internals/graalvm-app-sandbox.md index c71815470..3d79662ed 100644 --- a/docs/internals/graalvm-app-sandbox.md +++ b/docs/internals/graalvm-app-sandbox.md @@ -79,8 +79,11 @@ watch works. `ProcessHandle…command()`, the path the process was started with. It is canonicalized, so a CLI symlink (`/usr/local/bin/app` → `…/Contents/MacOS/app`) finds the bundle's fonts and libraries — before, every image launched through a symlink died with "Fontconfig head is null". A relative -path (`./app`) is resolved against `PWD`: inside the sandbox SubstrateVM's `user.dir` is not the -directory the process started in. A sandboxed image launched through a symlink still dies, in +path (`./app`) is resolved against `user.dir`, then `PWD`, keeping the one that names the file: +inside the sandbox `user.dir` is not the directory the process started in, and `PWD` goes stale +under a launcher that changes directory without updating it (Python's `subprocess` with `cwd=`). +Sandboxed, relative *and* with a stale `PWD` has no right answer left; LaunchServices never +starts an app that way. A sandboxed image launched through a symlink still dies, in AppKit ("NSRemoteView encountered app without bundle"): a sandboxed app must run from its bundle, as LaunchServices always does. diff --git a/graalvm-runtime/src/main/kotlin/dev/nucleusframework/graalvm/GraalVmInitializer.kt b/graalvm-runtime/src/main/kotlin/dev/nucleusframework/graalvm/GraalVmInitializer.kt index 04acf4c11..0d490f68c 100644 --- a/graalvm-runtime/src/main/kotlin/dev/nucleusframework/graalvm/GraalVmInitializer.kt +++ b/graalvm-runtime/src/main/kotlin/dev/nucleusframework/graalvm/GraalVmInitializer.kt @@ -275,21 +275,26 @@ public object GraalVmInitializer { * `/usr/local/bin`) or a relative path (`./app`), its parent would otherwise not be the bundle's * `Contents/MacOS`, and fonts and libraries would be looked up next to the link. * - * A relative command is resolved against [workingDir] (the shell's `PWD`) when it is an absolute - * path: inside the App Sandbox SubstrateVM's `user.dir` is not the directory the process was - * started from. + * A relative command is resolved against [currentDir] (`user.dir`) first, then [workingDir] + * (the shell's `PWD`), keeping the first that names an existing file: inside the App Sandbox + * `user.dir` is not the directory the process started in, while `PWD` is only inherited and + * goes stale under a launcher that changes directory without updating it. */ internal fun execDirOf( command: String, workingDir: String? = null, + currentDir: String? = System.getProperty("user.dir"), ): String { if (command.isBlank()) return "." val started = File(command) val file = - if (started.isAbsolute || workingDir == null || !File(workingDir).isAbsolute) { + if (started.isAbsolute) { started } else { - File(workingDir, command) + listOfNotNull(currentDir, workingDir) + .filter { File(it).isAbsolute } + .map { File(it, command) } + .firstOrNull { it.isFile } ?: started.absoluteFile } val resolved = try { diff --git a/graalvm-runtime/src/test/kotlin/dev/nucleusframework/graalvm/GraalVmInitializerTest.kt b/graalvm-runtime/src/test/kotlin/dev/nucleusframework/graalvm/GraalVmInitializerTest.kt index 190b7e157..badb7c49b 100644 --- a/graalvm-runtime/src/test/kotlin/dev/nucleusframework/graalvm/GraalVmInitializerTest.kt +++ b/graalvm-runtime/src/test/kotlin/dev/nucleusframework/graalvm/GraalVmInitializerTest.kt @@ -89,15 +89,50 @@ class GraalVmInitializerTest { assertEquals(macOs.path, GraalVmInitializer.execDirOf(binary.path)) assertEquals(macOs.path, GraalVmInitializer.execDirOf(link.path)) assertEquals(".", GraalVmInitializer.execDirOf("")) - // ./app started from Contents/MacOS, whatever user.dir says. - assertEquals(macOs.path, GraalVmInitializer.execDirOf("./app", macOs.path)) + // ./app started from Contents/MacOS: user.dir when it is right… + assertEquals(macOs.path, GraalVmInitializer.execDirOf("./app", "/stale/pwd", macOs.path)) + // …PWD when user.dir is not (inside the App Sandbox)… + assertEquals(macOs.path, GraalVmInitializer.execDirOf("./app", macOs.path, root.path)) + // …and a stale PWD never wins over a user.dir that names the executable. + assertEquals(macOs.path, GraalVmInitializer.execDirOf("./app", root.path, macOs.path)) // A relative symlink path: both resolutions apply. - assertEquals(macOs.path, GraalVmInitializer.execDirOf("bin/app", root.path)) + assertEquals(macOs.path, GraalVmInitializer.execDirOf("bin/app", null, root.path)) } finally { root.deleteRecursively() } } + @Test + fun `hostile inputs keep the sandbox wiring sane`() { + val account = Files.createTempDirectory("nucleus account é").toFile() + try { + val container = account.resolve("Library/Containers/dev.app/Data").apply { mkdirs() } + val env = mapOf("APP_SANDBOX_CONTAINER_ID" to "dev.app", "HOME" to container.path + "/") + // A trailing slash on either side still recognizes SubstrateVM's default. + assertEquals(container.path + "/", GraalVmInitializer.sandboxedUserHome(env::get, account.path + "/")) + // A HOME outside any container leaves an explicit user.home alone. + val odd = mapOf("APP_SANDBOX_CONTAINER_ID" to "dev.app", "HOME" to account.path) + assertNull(GraalVmInitializer.sandboxedUserHome(odd::get, "/explicit")) + + // A manifest that is a directory is not a sandboxed layout. + val bundle = account.resolve("My App é.app/Contents") + val execDir = bundle.resolve("MacOS").apply { mkdirs() } + bundle.resolve("Resources/nucleus-sandbox-manifest.properties").mkdirs() + assertNull(GraalVmInitializer.macOsSandboxedLayout(File(execDir, "."))) + + // Spaces and accents in the bundle path; a command naming a missing file. + val binary = execDir.resolve("my app").apply { writeText("") } + assertEquals(execDir.canonicalPath, GraalVmInitializer.execDirOf(binary.path)) + assertEquals(execDir.canonicalPath, GraalVmInitializer.execDirOf(execDir.resolve("gone").path)) + assertEquals( + execDir.canonicalPath, + GraalVmInitializer.execDirOf("./my app", null, execDir.path), + ) + } finally { + account.deleteRecursively() + } + } + @Test fun `user home follows the container only inside the sandbox`() { val account = Files.createTempDirectory("nucleus-account").toFile() diff --git a/graalvm-runtime/src/test/kotlin/dev/nucleusframework/graalvm/signal/TerminationSignalsTest.kt b/graalvm-runtime/src/test/kotlin/dev/nucleusframework/graalvm/signal/TerminationSignalsTest.kt index e27967007..71ca3bd44 100644 --- a/graalvm-runtime/src/test/kotlin/dev/nucleusframework/graalvm/signal/TerminationSignalsTest.kt +++ b/graalvm-runtime/src/test/kotlin/dev/nucleusframework/graalvm/signal/TerminationSignalsTest.kt @@ -23,6 +23,10 @@ class TerminationSignalsTest { @Test fun `SIGHUP runs the shutdown hooks and exits 129`() = assertSignalExit("HUP", 129) + @Test + fun `installing twice still runs the hooks once`() = + assertSignalExit("TERM", 143, mode = "wait-twice", hookRuns = 1) + @Test fun `a child process started afterwards still dies on SIGTERM`() { if (!System.getProperty("os.name").startsWith("Mac")) return @@ -56,13 +60,15 @@ class TerminationSignalsTest { private fun assertSignalExit( signal: String, expectedStatus: Int, + mode: String = "wait", + hookRuns: Int = 1, ) { if (!System.getProperty("os.name").startsWith("Mac")) return val dir = Files.createTempDirectory("nucleus-signals").toFile() try { val ready = File(dir, "ready") val hookRan = File(dir, "hook-ran") - val child = startChild(dir, "wait", ready.path, hookRan.path) + val child = startChild(dir, mode, ready.path, hookRan.path) val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(30) while (!ready.exists() && child.isAlive && System.nanoTime() < deadline) Thread.sleep(50) assertTrue(ready.exists(), "child never got ready: ${File(dir, "child.log").readText()}") @@ -72,6 +78,7 @@ class TerminationSignalsTest { assertTrue(child.waitFor(30, TimeUnit.SECONDS), "child ignored SIG$signal") assertEquals(expectedStatus, child.exitValue()) assertTrue(hookRan.exists(), "shutdown hook did not run") + assertEquals(hookRuns, hookRan.readLines().size, "shutdown hook runs") } finally { dir.deleteRecursively() } @@ -88,10 +95,11 @@ object TerminationSignalsChild { fun main(args: Array) { val installed = TerminationSignals.install() when (args.first()) { - "wait" -> { + "wait", "wait-twice" -> { val (ready, hookRan) = args.drop(1).map(::File) - Runtime.getRuntime().addShutdownHook(Thread { hookRan.writeText("yes") }) - ready.writeText(if (installed) "installed" else "unavailable") + Runtime.getRuntime().addShutdownHook(Thread { hookRan.appendText("ran\n") }) + val again = args.first() == "wait-twice" && TerminationSignals.install() + ready.writeText(if (installed && (again || args.first() == "wait")) "installed" else "unavailable") Thread.sleep(TimeUnit.MINUTES.toMillis(1)) } "spawn" -> { From 2a6f9e3425b6c8d133fc07bdfb3382db5b62bc87 Mon Sep 17 00:00:00 2001 From: Elie Gambache Date: Mon, 2 Nov 2026 09:18:46 +0200 Subject: [PATCH 7/7] fix(lab): survive a file in place of the data dir and keep concurrent check results - LabPaths moves a plain file that sits where the data directory should be aside (NucleusLab.not-a-dir-) instead of failing every probe with FileAlreadyExistsException at start-up. - CheckStore applies each change to checks.json as it is on disk, through CheckFile, and writes atomically: two Lab processes sharing a container no longer erase each other's results. --- .../dev/nucleusframework/lab/core/LabPaths.kt | 20 ++++- .../lab/core/checks/CheckFile.kt | 60 ++++++++++++++ .../lab/core/checks/CheckStore.kt | 59 +++++--------- .../nucleusframework/lab/core/LabPathsTest.kt | 31 ++++++++ .../lab/core/checks/CheckFileTest.kt | 78 +++++++++++++++++++ 5 files changed, 206 insertions(+), 42 deletions(-) create mode 100644 examples/lab/core/src/main/kotlin/dev/nucleusframework/lab/core/checks/CheckFile.kt create mode 100644 examples/lab/core/src/test/kotlin/dev/nucleusframework/lab/core/LabPathsTest.kt create mode 100644 examples/lab/core/src/test/kotlin/dev/nucleusframework/lab/core/checks/CheckFileTest.kt diff --git a/examples/lab/core/src/main/kotlin/dev/nucleusframework/lab/core/LabPaths.kt b/examples/lab/core/src/main/kotlin/dev/nucleusframework/lab/core/LabPaths.kt index cd0380f2d..82657e680 100644 --- a/examples/lab/core/src/main/kotlin/dev/nucleusframework/lab/core/LabPaths.kt +++ b/examples/lab/core/src/main/kotlin/dev/nucleusframework/lab/core/LabPaths.kt @@ -5,11 +5,13 @@ import java.nio.file.Files import java.nio.file.Path import java.nio.file.StandardCopyOption import java.util.concurrent.ConcurrentHashMap +import java.util.logging.Logger import kotlin.io.path.Path import kotlin.io.path.appendText import kotlin.io.path.createDirectories import kotlin.io.path.deleteIfExists import kotlin.io.path.exists +import kotlin.io.path.isDirectory import kotlin.io.path.readLines import kotlin.io.path.writeText @@ -23,9 +25,25 @@ object LabPaths { Platform.Windows -> Path(System.getenv("APPDATA") ?: home, "NucleusLab") else -> Path(System.getenv("XDG_DATA_HOME") ?: "$home/.local/share", "nucleus-lab") } - dir.createDirectories() + ensureDirectory(dir) } + /** + * Creates [dir], first moving aside a plain file that sits where it should be (a botched copy, a + * restore gone wrong) — kept as `.not-a-dir-` so nothing is lost — instead of + * failing every probe with `FileAlreadyExistsException`. + */ + internal fun ensureDirectory(dir: Path): Path { + if (dir.exists() && !dir.isDirectory()) { + val aside = dir.resolveSibling("${dir.fileName}.not-a-dir-${System.currentTimeMillis()}") + Files.move(dir, aside) + logger.warning("$dir was a file, not a directory: moved it to $aside") + } + return dir.createDirectories() + } + + private val logger: Logger = Logger.getLogger(LabPaths::class.java.name) + /** A fresh scratch directory under [dataDir], for probes that need real files. */ fun scratch(name: String): Path = dataDir.resolve("scratch").resolve(name).createDirectories() diff --git a/examples/lab/core/src/main/kotlin/dev/nucleusframework/lab/core/checks/CheckFile.kt b/examples/lab/core/src/main/kotlin/dev/nucleusframework/lab/core/checks/CheckFile.kt new file mode 100644 index 000000000..d9a82eb12 --- /dev/null +++ b/examples/lab/core/src/main/kotlin/dev/nucleusframework/lab/core/checks/CheckFile.kt @@ -0,0 +1,60 @@ +package dev.nucleusframework.lab.core.checks + +import kotlinx.serialization.json.Json +import java.nio.file.AtomicMoveNotSupportedException +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.StandardCopyOption +import java.util.logging.Level +import java.util.logging.Logger +import kotlin.io.path.exists +import kotlin.io.path.readText +import kotlin.io.path.writeText + +/** + * `checks.json` on disk, shared by every Lab process of one user: two instances that run side by + * side (a dev run next to a packaged one, both sandboxed in the same container) each apply their + * change to the file as it is now, never to a copy loaded at start-up, so neither erases the + * other's results. Writes go through a temporary file and a move, so a reader never sees half a + * file. + */ +internal class CheckFile( + private val file: Path, +) { + /** The book on disk, or `null` when there is none or it cannot be read. */ + fun read(): CheckBook? = + runCatching { if (file.exists()) json.decodeFromString(CheckBook.serializer(), file.readText()) else null } + .onFailure { logger.log(Level.WARNING, "Unreadable $file", it) } + .getOrNull() + + /** + * Applies [change] to the book on disk ([fallback] when there is none) and writes the result. + * Returns the new book even when the write fails, which is logged. + */ + fun update( + fallback: CheckBook, + change: (CheckBook) -> CheckBook, + ): CheckBook = + synchronized(this) { + val next = change(read() ?: fallback) + runCatching { write(next) }.onFailure { logger.log(Level.WARNING, "Could not write $file", it) } + next + } + + private fun write(book: CheckBook) { + val temp = file.resolveSibling("${file.fileName}.${ProcessHandle.current().pid()}.tmp") + temp.writeText(json.encodeToString(CheckBook.serializer(), book)) + try { + Files.move(temp, file, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE) + } catch (_: AtomicMoveNotSupportedException) { + Files.move(temp, file, StandardCopyOption.REPLACE_EXISTING) + } finally { + Files.deleteIfExists(temp) + } + } + + private companion object { + val json = Json { prettyPrint = true } + val logger: Logger = Logger.getLogger(CheckFile::class.java.name) + } +} diff --git a/examples/lab/core/src/main/kotlin/dev/nucleusframework/lab/core/checks/CheckStore.kt b/examples/lab/core/src/main/kotlin/dev/nucleusframework/lab/core/checks/CheckStore.kt index 2475527e7..9b357e8ee 100644 --- a/examples/lab/core/src/main/kotlin/dev/nucleusframework/lab/core/checks/CheckStore.kt +++ b/examples/lab/core/src/main/kotlin/dev/nucleusframework/lab/core/checks/CheckStore.kt @@ -9,15 +9,7 @@ import dev.zacsweers.metro.SingleIn import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update import kotlinx.serialization.Serializable -import kotlinx.serialization.json.Json -import java.nio.file.Path -import java.util.logging.Level -import java.util.logging.Logger -import kotlin.io.path.exists -import kotlin.io.path.readText -import kotlin.io.path.writeText @Serializable enum class CheckStatus { Untested, Pass, Fail, Skipped } @@ -44,8 +36,8 @@ data class CheckBook( class CheckStore( private val environment: EnvironmentProvider, ) { - private val file: Path = LabPaths.dataDir.resolve("checks.json") - private val state = MutableStateFlow(load()) + private val file = CheckFile(LabPaths.dataDir.resolve("checks.json")) + private val state = MutableStateFlow(file.read() ?: CheckBook()) val book: StateFlow = state.asStateFlow() private val fingerprint: String get() = environment.snapshot.value.fingerprint @@ -60,42 +52,27 @@ class CheckStore( checkId: String, status: CheckStatus, note: String? = null, - ) { - state.update { book -> - val env = book.results[fingerprint].orEmpty() - val probeResults = env[probe.value].orEmpty() - val previous = probeResults[checkId] ?: CheckResult() - val next = - previous.copy( - status = status, - note = note ?: previous.note, - epochMillis = System.currentTimeMillis(), - ) - book.copy(results = book.results + (fingerprint to env + (probe.value to probeResults + (checkId to next)))) - } - save() + ) = change { book -> + val env = book.results[fingerprint].orEmpty() + val probeResults = env[probe.value].orEmpty() + val previous = probeResults[checkId] ?: CheckResult() + val next = + previous.copy( + status = status, + note = note ?: previous.note, + epochMillis = System.currentTimeMillis(), + ) + book.copy(results = book.results + (fingerprint to env + (probe.value to probeResults + (checkId to next)))) } - fun reset(probe: ProbeId) { - state.update { book -> + fun reset(probe: ProbeId) = + change { book -> val env = book.results[fingerprint].orEmpty() book.copy(results = book.results + (fingerprint to env - probe.value)) } - save() - } - - private fun load(): CheckBook = - runCatching { if (file.exists()) json.decodeFromString(CheckBook.serializer(), file.readText()) else null } - .onFailure { logger.log(Level.WARNING, "Unreadable $file, starting from scratch", it) } - .getOrNull() ?: CheckBook() - - private fun save() { - runCatching { file.writeText(json.encodeToString(CheckBook.serializer(), state.value)) } - .onFailure { logger.log(Level.WARNING, "Could not write $file", it) } - } - private companion object { - val json = Json { prettyPrint = true } - val logger: Logger = Logger.getLogger(CheckStore::class.java.name) + /** Applies [transform] to the file as it is now, so another Lab process's results survive. */ + private fun change(transform: (CheckBook) -> CheckBook) { + state.value = file.update(fallback = state.value, change = transform) } } diff --git a/examples/lab/core/src/test/kotlin/dev/nucleusframework/lab/core/LabPathsTest.kt b/examples/lab/core/src/test/kotlin/dev/nucleusframework/lab/core/LabPathsTest.kt new file mode 100644 index 000000000..3577301fa --- /dev/null +++ b/examples/lab/core/src/test/kotlin/dev/nucleusframework/lab/core/LabPathsTest.kt @@ -0,0 +1,31 @@ +package dev.nucleusframework.lab.core + +import java.nio.file.Files +import kotlin.io.path.isDirectory +import kotlin.io.path.listDirectoryEntries +import kotlin.io.path.readText +import kotlin.io.path.writeText +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class LabPathsTest { + @Test + fun `a file in the way of the data dir is moved aside, not fatal`() { + val parent = Files.createTempDirectory("lab-paths") + try { + val dir = parent.resolve("NucleusLab") + dir.writeText("not a directory") + + val created = LabPaths.ensureDirectory(dir) + + assertTrue(created.isDirectory()) + val aside = parent.listDirectoryEntries("NucleusLab.not-a-dir-*").single() + assertEquals("not a directory", aside.readText()) + // Idempotent once it is a directory. + assertEquals(dir, LabPaths.ensureDirectory(dir)) + } finally { + parent.toFile().deleteRecursively() + } + } +} diff --git a/examples/lab/core/src/test/kotlin/dev/nucleusframework/lab/core/checks/CheckFileTest.kt b/examples/lab/core/src/test/kotlin/dev/nucleusframework/lab/core/checks/CheckFileTest.kt new file mode 100644 index 000000000..f8e7bfa67 --- /dev/null +++ b/examples/lab/core/src/test/kotlin/dev/nucleusframework/lab/core/checks/CheckFileTest.kt @@ -0,0 +1,78 @@ +package dev.nucleusframework.lab.core.checks + +import java.nio.file.Files +import java.nio.file.Path +import kotlin.io.path.readText +import kotlin.io.path.writeText +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class CheckFileTest { + private val dir: Path = Files.createTempDirectory("lab-checks") + private val path: Path = dir.resolve("checks.json") + + @AfterTest + fun cleanUp() { + dir.toFile().setWritable(true) + dir.toFile().deleteRecursively() + } + + private fun pass( + book: CheckBook, + probe: String, + check: String, + ): CheckBook { + val env = book.results["fp"].orEmpty() + val probeResults = env[probe].orEmpty() + (check to CheckResult(CheckStatus.Pass, epochMillis = 1)) + return book.copy(results = book.results + ("fp" to env + (probe to probeResults))) + } + + private fun CheckBook.checks(): Set = + results["fp"].orEmpty().flatMap { (probe, checks) -> checks.keys.map { "$probe/$it" } }.toSet() + + @Test + fun `two processes writing in turn keep each other's results`() { + // Each Lab process holds its own CheckFile and its own start-up copy of the book. + val first = CheckFile(path) + val second = CheckFile(path) + val firstStartup = first.read() ?: CheckBook() + val secondStartup = second.read() ?: CheckBook() + + first.update(firstStartup) { pass(it, "window.state", "a") } + second.update(secondStartup) { pass(it, "window.popups", "b") } + first.update(firstStartup) { pass(it, "window.state", "c") } + + assertEquals( + setOf("window.state/a", "window.popups/b", "window.state/c"), + CheckFile(path).read()!!.checks(), + ) + } + + @Test + fun `an unreadable file falls back to the book in memory and is repaired`() { + path.writeText("""{"garbage": [1,2,""") + val file = CheckFile(path) + assertNull(file.read()) + + val inMemory = pass(CheckBook(), "system.zstd", "roundtrip") + val next = file.update(inMemory) { pass(it, "window.state", "a") } + + assertEquals(setOf("system.zstd/roundtrip", "window.state/a"), next.checks()) + assertEquals(next, CheckFile(path).read()) + } + + @Test + fun `a failed write is reported and still returns the new book`() { + val file = CheckFile(path) + file.update(CheckBook()) { pass(it, "window.state", "a") } + val before = path.readText() + dir.toFile().setWritable(false) + + val next = file.update(CheckBook()) { pass(it, "window.state", "b") } + + assertEquals(setOf("window.state/a", "window.state/b"), next.checks()) + assertEquals(before, path.readText()) + } +}