diff --git a/CLAUDE.md b/CLAUDE.md index 6caa70c29..52bc8cfd5 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; `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 new file mode 100644 index 000000000..3d79662ed --- /dev/null +++ b/docs/internals/graalvm-app-sandbox.md @@ -0,0 +1,99 @@ +# 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). 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 + `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 | + +`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. + +## 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. + +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. + +**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 `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. + +**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: +`-x :app:nativeImageCompile -x :app:nativeImageCompileSandboxed`. 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()) + } +} 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..0d490f68c 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 = @@ -52,6 +54,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 +87,107 @@ 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. + * + * 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. + * + * 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) + // 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, System.getProperty("user.home"))?.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` 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 + 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( + 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" + + /** 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" + + /** 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. * @@ -154,13 +259,50 @@ 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 [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) { + started + } else { + listOfNotNull(currentDir, workingDir) + .filter { File(it).isAbsolute } + .map { File(it, command) } + .firstOrNull { it.isFile } ?: started.absoluteFile + } + val resolved = + try { + file.canonicalFile + } catch (_: Exception) { + file.absoluteFile + } + return resolved.parentFile?.absolutePath ?: "." } /** 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/GraalVmInitializerTest.kt b/graalvm-runtime/src/test/kotlin/dev/nucleusframework/graalvm/GraalVmInitializerTest.kt index 32bedbd90..badb7c49b 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,109 @@ 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 `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: 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", 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() + 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) + // 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, account.path)) + } finally { + account.deleteRecursively() + } + } + @Test fun `macos locale bridge reports a language tag when loaded`() { if (Platform.Current != Platform.MacOS) return 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..71ca3bd44 --- /dev/null +++ b/graalvm-runtime/src/test/kotlin/dev/nucleusframework/graalvm/signal/TerminationSignalsTest.kt @@ -0,0 +1,121 @@ +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 `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 + 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, + 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, 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()}") + 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") + assertEquals(hookRuns, hookRan.readLines().size, "shutdown hook runs") + } 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", "wait-twice" -> { + val (ready, hookRan) = args.drop(1).map(::File) + 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" -> { + 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 8cfbe4b82..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 @@ -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,162 @@ 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. + * `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. + */ +@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") }) + // 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. + 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 +2723,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 +2764,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 +2781,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 +2807,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 +2823,7 @@ private fun JvmApplicationContext.configureGraalvmPlatformPackageSettings( task: AbstractElectronBuilderPackageTask, targetFormat: TargetFormat, unpackDefaultResources: TaskProvider, + sandboxed: Boolean, ) { when (currentOS) { OS.Linux -> { @@ -2635,23 +2861,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(" ")}") + } + } +}